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 001/171] 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 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 002/171] 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 003/171] 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 01:41:45 +0300 Subject: [PATCH 004/171] CI: run tests with nextest and skip building examples (#2204) cargo test runs the ~200 test binaries one after another; on Windows that took 14 of the job's 37 minutes, 5 of them in gpu's parity tests on the software adapter. nextest runs them in parallel with the GPU group from .config/nextest.toml. The examples were built and linked but never run; Clippy's --all-targets still checks them. Doctests get their own step. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec29abaf5..5f306edae4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,9 +115,21 @@ jobs: if: runner.os == 'Linux' run: cargo fmt --all -- --check + # nextest runs the test binaries in parallel (cargo test runs them one at a time), with the + # GPU tests capped by the `gpu` group in .config/nextest.toml (#2204). + - uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20 + with: + tool: cargo-nextest@0.9.146 + # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. + # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but + # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so + # they get their own step. - name: Test - run: cargo test --workspace --locked --features heif + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests + + - name: Doctests + run: cargo test --workspace --locked --features heif --doc # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" # error for distributors who leave it out. @@ -127,9 +139,10 @@ jobs: # The same tests built with the optional craft-fonts input (crates/text/build.rs), so the # Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace - # --features heif` of the Test step: any other package or feature selection resolves a - # different feature set, recompiles the whole dependency graph into the same target dir and - # runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800). + # --features heif --lib --bins --tests` of the Test step: any other package, target or + # feature selection resolves a different feature set, recompiles the whole dependency graph + # into the same target dir and runs the runner out of disk (as `-p photocraft-text -p + # photocraft-ui-egui` did after #800). - name: Check out craft-fonts if: runner.os == 'Linux' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -143,7 +156,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo test --workspace --locked --features heif + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings From bf93459200f5d79e6212ce7f6a24c4bab4e5c96d Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 05:06:06 +0300 Subject: [PATCH 005/171] CI: no debuginfo for the Windows test build Writing PDBs for ~200 test binaries took 2.2 of ~21 build minutes on the Windows runner (3 runs each on the same CPU model). A panic still reports its file and line; Linux keeps line-tables-only for backtraces with lines. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f306edae4..05e62ee140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,11 @@ jobs: fail-fast: false matrix: os: ${{ fromJSON(needs.platforms.outputs.os) }} + env: + # No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes. + # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps + # line-tables-only, so a backtrace with lines is one OS away). + CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }} steps: - uses: actions/checkout@v4 From 7b1dd35b7bb3cd8c180971040a1eadc1ab0000d4 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 05:07:17 +0300 Subject: [PATCH 006/171] CI: report every failing test, not just the first With the binaries running in parallel, running the rest after a failure costs little, and a red main no longer hides other failures behind the first one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05e62ee140..8a0ec7391c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so # they get their own step. - name: Test - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast - name: Doctests run: cargo test --workspace --locked --features heif --doc @@ -161,7 +161,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings From fa17b6f4b191fdcaafb3cfceb530e4283d09a90b Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 05:08:46 +0300 Subject: [PATCH 007/171] CI: run the Linux without-heif and craft-fonts builds in parallel They ran after the main tests in the Linux test job and added ~10 min to its critical path (and were why that job ran out of disk). Now a two-leg job restores the test job's cache read-only (shared-key: test) and saves nothing, so the cache budget (#2688) doesn't grow. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 86 +++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a0ec7391c..dcb0017775 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,33 +136,6 @@ jobs: - name: Doctests run: cargo test --workspace --locked --features heif --doc - # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" - # error for distributors who leave it out. - - name: Test without heif - if: runner.os == 'Linux' - run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked - - # The same tests built with the optional craft-fonts input (crates/text/build.rs), so the - # Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace - # --features heif --lib --bins --tests` of the Test step: any other package, target or - # feature selection resolves a different feature set, recompiles the whole dependency graph - # into the same target dir and runs the runner out of disk (as `-p photocraft-text -p - # photocraft-ui-egui` did after #800). - - name: Check out craft-fonts - if: runner.os == 'Linux' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - - name: Test with craft-fonts - if: runner.os == 'Linux' - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast - - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings @@ -175,6 +148,65 @@ jobs: if: runner.os == 'Linux' run: cargo xtask scorecard --check + # The two other builds of the Linux tests, in parallel with the test job instead of after it (they + # added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is + # that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is + # tight (#2688). + test-variants: + name: test (${{ matrix.variant }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + variant: [without heif, with craft-fonts] + steps: + - uses: actions/checkout@v4 + + - name: Install Linux system dependencies (egui/winit/rfd) + run: | + nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ + /opt/hostedtoolcache/CodeQL /dev/null 2>&1 & + 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@stable + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: test + save-if: false + + - uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20 + if: matrix.variant == 'with craft-fonts' + with: + tool: cargo-nextest@0.9.146 + + # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" + # error for distributors who leave it out. + - name: Test without heif + if: matrix.variant == 'without heif' + run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked + + # The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese + # font tests that skip without it run. Keep the test job's exact `--workspace --features heif + # --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds. + - name: Check out craft-fonts + if: matrix.variant == 'with craft-fonts' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + + - name: Test with craft-fonts + if: matrix.variant == 'with craft-fonts' + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast + 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 2ff756b51a572f5339aa48e0314c24490b74204d Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 06:36:32 +0300 Subject: [PATCH 008/171] Tests: build ui-egui without optimization in the test profile Its library and unit tests were the biggest test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its tests run no heavy pixel code of their own. On the Windows runner (EPYC 7763, 3 runs vs 7): build 18.9 -> 15.4 min, test run 10.0 -> 10.5. Only test builds change; cargo run and cargo build use the dev profile. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 2c329d980d..f945869aa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,14 @@ opt-level = 1 # CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true. debug = "line-tables-only" +# Test builds compile ui-egui without optimization: its library and unit tests were the biggest +# test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its +# tests run no heavier pixel code of their own (that lives in the crates that stay optimized). +# Measured on CI: Windows build 18.9 -> 15.4 min, test run 10.0 -> 10.5. `cargo run` and +# `cargo build` use the dev profile and are unaffected. +[profile.test.package.photocraft-ui-egui] +opt-level = 0 + [profile.release] lto = "thin" codegen-units = 1 From 6382148eb846f5c4e8c588f5aea7fb4354ee5249 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 07:48:54 +0300 Subject: [PATCH 009/171] CI: run the Windows tests as two parallel legs Unit tests (--lib --bins, plus doctests) and integration tests (--test '*', plus Clippy and the layering check) now run on two Windows runners at once. The feature selection is the same, so both legs restore one dependency cache; the integration leg saves it, so Clippy's metadata stays in it. On the Windows runner (EPYC 7763) the job took 28.3 min; the legs take 18.2 and 18.3, running the same 6095 tests between them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcb0017775..d3e7176afc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: name: select platforms runs-on: ubuntu-latest outputs: - os: ${{ steps.pick.outputs.os }} + legs: ${{ steps.pick.outputs.legs }} web: ${{ steps.pick.outputs.web }} steps: - uses: actions/checkout@v4 @@ -56,9 +56,12 @@ jobs: run: | # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and # release.yml builds and tests macOS (a failing release is fixed and rebuilt). - all='["ubuntu-latest","windows-latest"]' + # Windows tests run as two legs in parallel (unit tests, integration tests): together they + # took ~28 min on the Windows runner, each leg takes ~18. + linux='{"os":"ubuntu-latest","part":"all"}' + all="[$linux,{\"os\":\"windows-latest\",\"part\":\"unit\"},{\"os\":\"windows-latest\",\"part\":\"integration\"}]" if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then - echo "os=$all" >> "$GITHUB_OUTPUT" + echo "legs=$all" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -71,19 +74,19 @@ jobs: echo "web=false" >> "$GITHUB_OUTPUT" fi if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then - echo "os=$all" >> "$GITHUB_OUTPUT" + echo "legs=$all" >> "$GITHUB_OUTPUT" else - echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT" + echo "legs=[$linux]" >> "$GITHUB_OUTPUT" fi test: - name: test (${{ matrix.os }}) + name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }}) needs: platforms runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: ${{ fromJSON(needs.platforms.outputs.os) }} + include: ${{ fromJSON(needs.platforms.outputs.legs) }} env: # No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes. # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps @@ -111,9 +114,11 @@ jobs: # Only main saves caches; PRs restore main's. Every PR used to save its own set (up to # 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's. # A failing test still saves: the cache holds compiled dependencies, not test results. + # The two Windows legs share one key (same dependencies); the integration leg saves it, + # because it also runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@v2 with: - save-if: ${{ github.ref == 'refs/heads/main' }} + save-if: ${{ github.ref == 'refs/heads/main' && matrix.part != 'unit' }} cache-on-failure: true - name: Format @@ -130,16 +135,32 @@ jobs: # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so # they get their own step. + # + # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every + # integration-test target); the feature selection stays the same, so both legs share the + # dependency cache. - name: Test - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast + env: + PART: ${{ matrix.part }} + run: | + case "$PART" in + unit) targets=(--lib --bins) ;; + integration) targets=(--test '*') ;; + *) targets=(--lib --bins --tests) ;; + esac + cargo nextest run --workspace --locked --features heif "${targets[@]}" --no-fail-fast + shell: bash - name: Doctests + if: matrix.part != 'integration' run: cargo test --workspace --locked --features heif --doc - name: Clippy + if: matrix.part != 'unit' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering + if: matrix.part != 'unit' run: cargo xtask layers # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, From 60d682d65d3978b09c00750502ea52d9a71900b3 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 08:58:53 +0300 Subject: [PATCH 010/171] CI: cache the Windows workspace libraries with sccache rust-cache keeps only the dependencies, so every run recompiled the workspace crates. sccache caches their libraries (test executables can't be cached and still link). Warm, on the Windows runner (EPYC 7763, 2-3 runs), the unit leg built in 7.1 min instead of 11.5 and the integration leg in 4.3 instead of 8.8. Only main writes (PRs use SCCACHE_GHA_RW_MODE=READ_ONLY); a full set of entries was ~90 MB. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3e7176afc..fa3299cca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,26 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' && matrix.part != 'unit' }} cache-on-failure: true + # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: + # warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read + # main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688). + # Test executables can't be cached, so a PR still links its tests. + - name: Install sccache (Windows) + if: runner.os == 'Windows' + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.18.0 + + - name: Use sccache (Windows) + if: runner.os == 'Windows' + env: + MAIN: ${{ github.ref == 'refs/heads/main' }} + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi + shell: bash + - name: Format if: runner.os == 'Linux' run: cargo fmt --all -- --check From 110d58bea91c87bf1d7a3ea9b9d05bebe5d1d80a Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 10:06:39 +0300 Subject: [PATCH 011/171] CI: split the Windows integration tests over two runners; retry flaky GPU tests nextest --partition splits the integration tests in two, so Windows runs three legs: on the Windows runner (EPYC 7763, 2 runs) the integration halves took 9.8 and 10.8 min against 13.8 for one, leaving the unit leg (~12.8) as the longest. A ci nextest profile retries the GPU tests: on WARP, under load, one hit wgpu's poll timeout once in ~40 runs while measuring. A test that passes on retry is reported as FLAKY. Co-Authored-By: Claude Opus 5.5 --- .config/nextest.toml | 7 +++++++ .github/workflows/ci.yml | 32 ++++++++++++++++++-------------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 2585577ec1..bb2a3881b2 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -13,3 +13,10 @@ gpu = { max-threads = 4 } [[profile.default.overrides]] filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' test-group = 'gpu' + +# CI (`--profile ci`): the GPU tests run on WARP, the software adapter, on the Windows runner, and +# under load one occasionally hits that same timeout (1 in ~40 runs while measuring #2933). Retry +# them; nextest reports a test that passes on retry as FLAKY, so it stays visible. +[[profile.ci.overrides]] +filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' +retries = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa3299cca0..337f75ef60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,10 +56,12 @@ jobs: run: | # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and # release.yml builds and tests macOS (a failing release is fixed and rebuilt). - # Windows tests run as two legs in parallel (unit tests, integration tests): together they - # took ~28 min on the Windows runner, each leg takes ~18. + # Windows tests run as three legs in parallel: the unit tests, and the integration tests + # split in two (nextest --partition). As one job they took ~28 min on the Windows runner; + # the legs take ~13, ~10 and ~11. linux='{"os":"ubuntu-latest","part":"all"}' - all="[$linux,{\"os\":\"windows-latest\",\"part\":\"unit\"},{\"os\":\"windows-latest\",\"part\":\"integration\"}]" + win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; } + all="[$linux,$(win unit),$(win integration-1),$(win integration-2)]" if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then echo "legs=$all" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" @@ -114,11 +116,11 @@ jobs: # Only main saves caches; PRs restore main's. Every PR used to save its own set (up to # 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's. # A failing test still saves: the cache holds compiled dependencies, not test results. - # The two Windows legs share one key (same dependencies); the integration leg saves it, + # The Windows legs share one key (same dependencies); the integration-2 leg saves it, # because it also runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@v2 with: - save-if: ${{ github.ref == 'refs/heads/main' && matrix.part != 'unit' }} + save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration-2') }} cache-on-failure: true # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: @@ -156,31 +158,33 @@ jobs: # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so # they get their own step. # - # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every - # integration-test target); the feature selection stays the same, so both legs share the - # dependency cache. + # On Windows the unit leg runs `--lib --bins` and the integration legs `--test '*'` (every + # integration-test target), half of the tests each; the feature selection stays the same, so + # all legs share the dependency cache. The `ci` profile retries the GPU tests + # (.config/nextest.toml). - name: Test env: PART: ${{ matrix.part }} run: | case "$PART" in unit) targets=(--lib --bins) ;; - integration) targets=(--test '*') ;; + integration-1) targets=(--test '*' --partition count:1/2) ;; + integration-2) targets=(--test '*' --partition count:2/2) ;; *) targets=(--lib --bins --tests) ;; esac - cargo nextest run --workspace --locked --features heif "${targets[@]}" --no-fail-fast + cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" --no-fail-fast shell: bash - name: Doctests - if: matrix.part != 'integration' + if: matrix.part == 'all' || matrix.part == 'unit' run: cargo test --workspace --locked --features heif --doc - name: Clippy - if: matrix.part != 'unit' + if: matrix.part == 'all' || matrix.part == 'integration-2' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering - if: matrix.part != 'unit' + if: matrix.part == 'all' || matrix.part == 'integration-2' run: cargo xtask layers # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, @@ -246,7 +250,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast + run: cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests --no-fail-fast corpus: # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are From 39904a27ce2f9c4ec122020f7f59d4cb26c1e4b3 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 11:32:35 +0300 Subject: [PATCH 012/171] Docs: CI runs nextest, the craft-fonts tests have their own job Comments and docs still said CI runs cargo test, that the craft-fonts tests run a second time in the Linux job, and that CI's debuginfo matches the dev profile everywhere. development.md also notes that test builds compile ui-egui unoptimized. Co-Authored-By: Claude Opus 5.5 --- .config/nextest.toml | 3 ++- .github/workflows/ci.yml | 4 ++-- Cargo.toml | 3 ++- docs/development.md | 6 +++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index bb2a3881b2..31d597451a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,5 +1,6 @@ # cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test -# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204). +# binaries. CI runs it (`--profile ci`, below); locally it's optional and `cargo test` keeps working +# (#2204). # # cargo nextest run --workspace # all tests except doctests # cargo test --doc --workspace # nextest does not run doctests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 337f75ef60..dfe21e1498 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,8 @@ env: PHOTOCRAFT_NO_PRINT: "1" LPDEST: photocraft-no-such-printer # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the - # debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough - # for the Linux job's second, craft-fonts build (it ran out of disk with full debug info). + # debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of + # disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job). CARGO_PROFILE_DEV_DEBUG: line-tables-only # A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge diff --git a/Cargo.toml b/Cargo.toml index f945869aa3..e97b3cd474 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,8 @@ opt-level = 2 opt-level = 1 # Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link # time and PDB size for the ~260 test binaries (#2204). CI sets the same through -# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true. +# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a +# debugger, build with CARGO_PROFILE_DEV_DEBUG=true. debug = "line-tables-only" # Test builds compile ui-egui without optimization: its library and unit tests were the biggest diff --git a/docs/development.md b/docs/development.md index 3c539f70bc..cbe9130304 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,7 +44,7 @@ cargo xtask stats # tests and lines per cargo xtask parity # Photoshop menu coverage -> docs/parity-checklist.md ``` -Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. +Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. Test builds compile ui-egui itself at `opt-level 0` (`[profile.test.package.photocraft-ui-egui]` in `Cargo.toml`): it was one of the biggest test-build units, and its tests run no heavy pixel code of their own. The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. @@ -59,7 +59,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins 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. +[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 runs nextest too (`--profile ci`, which retries the GPU tests), and a full `cargo test` stays the final check before a PR. ## Fonts (craft-fonts) @@ -78,7 +78,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa - **UI:** the Japanese fonts (BIZ UDPGothic Regular first) are the first Japanese fallback in the lazy CJK loader (`crates/ui-egui/src/cjk_fonts.rs`), ahead of the system Japanese fonts and in the same locale script order, appended last to every egui family with the usual baseline alignment. - **Type tool:** the text engine registers them in `FontDb::new` (so also with no system fonts) and puts them first in the Japanese slot of the locale-ordered fallback list: BIZ UDPGothic for sans runs, Shippori Mincho / BIZ UDMincho for serif runs. - **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. Instead it loads fonts served next to it, on demand: `fonts/manifest.txt` beside `index.html`, in craft-fonts' manifest format (`crates/text/src/served.rs`; hosting: `packaging/web/README.md` › Fonts). `PHOTOCRAFT_WEB_FONTS_DIR="$PWD/../craft-fonts" packaging/web/package.sh` puts craft-fonts there. Served families are in the font menus and are fetched when picked or when a document's text needs them; they aren't in the script fallback list yet, so Japanese text set in a Latin font still needs a Japanese font picked (#1615). -- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI's Linux job runs the tests a second time with `CRAFT_FONTS_DIR` set. Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. +- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI runs them again with `CRAFT_FONTS_DIR` set, in its own Linux job (`test (with craft-fonts)`). Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. ## Graphics startup and device loss 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 013/171] 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 014/171] 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 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 015/171] 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 e5bf9c22c92acdc02d49cfda06df1aa77c698b8e Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 01:48:31 +0300 Subject: [PATCH 016/171] CI: run the Windows tests on two runners instead of three The integration tests run as one leg again. On the Windows runner (EPYC 7763, 3 runs) that leg took 14.3-15.3 min, against 10.5-10.8 for each half, so the slowest leg is now the integration one at ~15 min instead of the unit one at ~12.8. In exchange each PR that runs Windows takes two Windows runners instead of three and ~7 fewer runner-minutes: every leg pays its own setup (~1 min) and builds the workspace libraries its tests link against (~4 min even from sccache). That matters more while PRs queue for the account's concurrent-job limit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfe21e1498..823232cb3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,12 +56,13 @@ jobs: run: | # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and # release.yml builds and tests macOS (a failing release is fixed and rebuilt). - # Windows tests run as three legs in parallel: the unit tests, and the integration tests - # split in two (nextest --partition). As one job they took ~28 min on the Windows runner; - # the legs take ~13, ~10 and ~11. + # Windows tests run as two legs in parallel: the unit tests and the integration tests. As + # one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting + # the integration tests over a third runner (nextest --partition) saved ~2 min of wall + # time but cost ~7 runner-minutes and one more Windows slot per PR. linux='{"os":"ubuntu-latest","part":"all"}' win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; } - all="[$linux,$(win unit),$(win integration-1),$(win integration-2)]" + all="[$linux,$(win unit),$(win integration)]" if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then echo "legs=$all" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" @@ -116,11 +117,11 @@ jobs: # Only main saves caches; PRs restore main's. Every PR used to save its own set (up to # 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's. # A failing test still saves: the cache holds compiled dependencies, not test results. - # The Windows legs share one key (same dependencies); the integration-2 leg saves it, + # The Windows legs share one key (same dependencies); the integration leg saves it, # because it also runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@v2 with: - save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration-2') }} + save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }} cache-on-failure: true # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: @@ -158,9 +159,9 @@ jobs: # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so # they get their own step. # - # On Windows the unit leg runs `--lib --bins` and the integration legs `--test '*'` (every - # integration-test target), half of the tests each; the feature selection stays the same, so - # all legs share the dependency cache. The `ci` profile retries the GPU tests + # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every + # integration-test target); the feature selection stays the same, so both legs share the + # dependency cache. The `ci` profile retries the GPU tests # (.config/nextest.toml). - name: Test env: @@ -168,8 +169,7 @@ jobs: run: | case "$PART" in unit) targets=(--lib --bins) ;; - integration-1) targets=(--test '*' --partition count:1/2) ;; - integration-2) targets=(--test '*' --partition count:2/2) ;; + integration) targets=(--test '*') ;; *) targets=(--lib --bins --tests) ;; esac cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" --no-fail-fast @@ -180,11 +180,11 @@ jobs: run: cargo test --workspace --locked --features heif --doc - name: Clippy - if: matrix.part == 'all' || matrix.part == 'integration-2' + if: matrix.part == 'all' || matrix.part == 'integration' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering - if: matrix.part == 'all' || matrix.part == 'integration-2' + if: matrix.part == 'all' || matrix.part == 'integration' run: cargo xtask layers # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, From e99f09b306f878f3dd0f637c253a14bd00ae1f16 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 14:28:41 +0300 Subject: [PATCH 017/171] Tests: mac_menu_appkit answers nextest's --list The test runs without a harness (AppKit needs the main thread), so nothing answered the `--list` that cargo-nextest sends each test binary: on macOS the binary ran the whole test as its listing and nextest failed on the output. It now lists itself in libtest's terse format, and nothing for `--ignored`; a run passes `--exact `, which falls through to the test as before. Found by Zach running `cargo nextest run --workspace` on a Mac. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/tests/mac_menu_appkit.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/photocraft/tests/mac_menu_appkit.rs b/apps/photocraft/tests/mac_menu_appkit.rs index feacb4bd3b..db55015943 100644 --- a/apps/photocraft/tests/mac_menu_appkit.rs +++ b/apps/photocraft/tests/mac_menu_appkit.rs @@ -22,6 +22,18 @@ fn main() { use photocraft_ui_egui::native_menu; use serde_json::json; + // Without a harness nothing answers cargo-nextest's `--list`, so it ran the whole test as its + // listing and failed on the output. List the one test, as libtest's terse format would, and + // nothing for `--ignored` (nextest asks twice). Running it passes ` --exact`, which falls + // through to the test below. + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--list") { + if !args.iter().any(|a| a == "--ignored") { + println!("mac_menu_appkit: test"); + } + return; + } + let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread"); let nsapp = NSApplication::sharedApplication(mtm); nsapp.finishLaunching(); From 4a930959393b0d6077b749301765f877d3dcb090 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 14:29:13 +0300 Subject: [PATCH 018/171] CI: keep a test (windows-latest) check that reports the test result Splitting the Windows tests renamed their check from `test (windows-latest)` to one per leg. No status check is required on main today, but if one is added under the old name it would wait forever. A small job under that name now fails unless the platform pick and the whole test matrix passed (`needs` gives one result for all legs, so a failing Linux leg fails it too). It checks the result instead of exiting 0 under `if: always()`, which would pass with every leg red. Suggested by Zach in review. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 823232cb3d..8552311bfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,6 +193,24 @@ jobs: if: runner.os == 'Linux' run: cargo xtask scorecard --check + # The Windows tests used to be one job named `test (windows-latest)`; they're now legs named + # `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old + # name reporting, so a required status check on it (none today) would keep working. It fails + # unless the whole test matrix passed, Linux included, since `needs` reports one result for all + # legs; when a PR runs no Windows legs it passes along the Linux result. + windows-gate: + name: test (windows-latest) + needs: [platforms, test] + if: always() + runs-on: ubuntu-latest + steps: + - env: + PLATFORMS: ${{ needs.platforms.result }} + TEST: ${{ needs.test.result }} + run: | + echo "platforms: $PLATFORMS, test: $TEST" + [ "$PLATFORMS" = success ] && [ "$TEST" = success ] + # The two other builds of the Linux tests, in parallel with the test job instead of after it (they # added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is # that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is From b23a01aa8fc955aa1053b74873566c6c086c7e6b Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 16:03:09 +0300 Subject: [PATCH 019/171] Tests: postscript_only_style waits for the font scan selected_style reads the font engine through try_shared, which returns None while the system font scan is still running. Under cargo test the test passed only because an earlier test in the same process had already finished the scan; run on its own (nextest runs every test that way, and `cargo test -- --exact` does too) it saw no engine and got "Regular" instead of "SemiBold". It now waits for the scan with photocraft_text::shared(), as every_theme_shows_the_type_options does. On main, alone, it failed before this and passes after, under both cargo test and nextest. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 0f5be9e2fa..5f7ae0c542 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1271,6 +1271,9 @@ fn font_styles_keep_metadata_names_and_numeric_labels() { #[test] fn postscript_only_style_shows_actual_subfamily() { + // selected_style reads the engine only once the font scan is done (try_shared); on its own, + // as nextest runs it, nothing else has finished the scan, so wait for it here. + let _ = photocraft_text::shared(); let style = photocraft_doc::text::CharStyle { font_family: "Inter".into(), postscript_name: Some("Inter-SemiBold".into()), ..Default::default() }; assert_eq!(super::selected_style(&style), "SemiBold"); } From 3bd9bc72a7be9a1861f5c228c18ac74ed783a9cd Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 19:22:14 +0300 Subject: [PATCH 020/171] Tests: monitor_macos answers nextest's --list monitor_macos is harness = false, so nothing answered cargo-nextest's discovery: `--list --format terse` ran the whole test and nextest failed to parse its output on macOS. List the one test, and nothing for `--ignored`, as mac_menu_appkit already does; `monitor_macos --exact` still runs the body. Co-Authored-By: Claude Opus 5.5 --- crates/tablet/tests/monitor_macos.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/tablet/tests/monitor_macos.rs b/crates/tablet/tests/monitor_macos.rs index 0a32923aa1..82f371cdb1 100644 --- a/crates/tablet/tests/monitor_macos.rs +++ b/crates/tablet/tests/monitor_macos.rs @@ -14,6 +14,16 @@ fn main() { use photocraft_tablet::Sample; use photocraft_tablet::macos::Monitor; + // Answer cargo-nextest's `--list` (and nothing for `--ignored`), as tests/mac_menu_appkit.rs + // in apps/photocraft does; `monitor_macos --exact` falls through to the test. + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--list") { + if !args.iter().any(|a| a == "--ignored") { + println!("monitor_macos: test"); + } + return; + } + let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread"); let app = NSApplication::sharedApplication(mtm); let event = |kind: CGEventType, pressure: f64, tilt: (f64, f64), subtype: CGEventMouseSubtype| { From f0f0d6f58a233c3b440e573316590e6d7f4e2d7f Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 19:22:15 +0300 Subject: [PATCH 021/171] CI: the gpu test group covers the other ui-egui wgpu tests The canvas|gpu|mip|type_tool name filter missed ui-egui unit tests that build a Harness with a real wgpu device: background_redraw's GPU tests, panel_docking's floating_group_titles_are_opaque_in_studio_themes and swap_arrow's rendered_glyph_has_a_left_head_and_a_lower_right_head. Name them in both overrides, so they share the 4-thread cap and get the CI retries. The other .wgpu() harnesses only render when a fixture env var is set, or are #[ignore]d. Co-Authored-By: Claude Opus 5.5 --- .config/nextest.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 31d597451a..bfd4e0e689 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -7,17 +7,18 @@ # # GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer # serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so -# this group caps them at 4. +# this group caps them at 4. A ui-egui test that builds a `Harness` with `.wgpu()` has a real device: +# name it in the filter (both copies) unless its name already says canvas, gpu, mip or type_tool. [test-groups] gpu = { max-threads = 4 } [[profile.default.overrides]] -filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' +filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))' test-group = 'gpu' # CI (`--profile ci`): the GPU tests run on WARP, the software adapter, on the Windows runner, and # under load one occasionally hits that same timeout (1 in ~40 runs while measuring #2933). Retry # them; nextest reports a test that passes on retry as FLAKY, so it stays visible. [[profile.ci.overrides]] -filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' +filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))' retries = 2 From 691a022b12899e1287060f0ca4c94e793c3ac8bb Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 20:29:16 +0300 Subject: [PATCH 022/171] CI: skip the test (windows-latest) gate in cancelled runs With always() the gate also ran when a newer push cancelled the run, taking a runner slot only to report a red check on the superseded commit. !cancelled() still runs it when a test leg fails (a skipped required check would count as passing), but not in a cancelled run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8552311bfd..5489f07005 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,7 +201,7 @@ jobs: windows-gate: name: test (windows-latest) needs: [platforms, test] - if: always() + if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - env: From b2fafcf68ccca13e0236caa1f7f3d6add03a84b0 Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:27:54 +0200 Subject: [PATCH 023/171] ci: add comfortable runtime limits to unbounded jobs --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/docs.yml | 1 + .github/workflows/packaging-lint.yml | 1 + 3 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec29abaf5..07c11a4eb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: # platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile. # macOS is tested only by release.yml. platforms: + timeout-minutes: 10 name: select platforms runs-on: ubuntu-latest outputs: @@ -77,6 +78,7 @@ jobs: fi test: + timeout-minutes: 100 name: test (${{ matrix.os }}) needs: platforms runs-on: ${{ matrix.os }} @@ -158,6 +160,7 @@ jobs: run: cargo xtask scorecard --check corpus: + timeout-minutes: 30 # 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 # restore and a download; a missing corpus fails the tests. The cache key changes only when a @@ -190,6 +193,7 @@ jobs: run: cargo xtask test-corpus wasm: + timeout-minutes: 30 name: wasm32 check runs-on: ubuntu-latest steps: @@ -206,6 +210,7 @@ jobs: run: cargo xtask wasm web: + timeout-minutes: 60 name: web build and wasm size gate # PRs run it only when web-relevant files change (see `platforms`); main always does. needs: platforms @@ -250,6 +255,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" fuzz: + timeout-minutes: 60 name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }}) # Placeholder: manual trigger only until a nightly schedule is enabled. if: github.event_name == 'workflow_dispatch' && inputs.fuzz diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1d6c4c1f56..67e6b31894 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,6 +27,7 @@ concurrency: jobs: build: + timeout-minutes: 15 name: mdBook build runs-on: ubuntu-latest steps: diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 272715dc81..1d110e28f2 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,6 +23,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main verified-appimage-inputs: + timeout-minutes: 10 name: Verified AppImage inputs runs-on: ubuntu-latest steps: From 9660dca2637acd1252859db0ccf11cf28d9f5fdb Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 14:22:13 +0200 Subject: [PATCH 024/171] CI: run clippy, layers and scorecard checks before the tests (#6119) They take about 1.5 min together but ran after all the test passes, so a clippy failure (15 s) showed red only 41-48 min into the job. Same steps, reordered: no cost on a green run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc2564a59..a0b3360db3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,10 +111,24 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true + # The cheap checks run first: a format, lint, layering or stale-scorecard failure is red + # after a few minutes instead of after the test passes (#6119). Clippy restores main's cache. - name: Format if: runner.os == 'Linux' run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings + + - name: Dependency layering + run: cargo xtask layers + + # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, + # prefs audit). Static: it reads committed files only, no benchmarks run here. + - name: Scorecard up to date + if: runner.os == 'Linux' + run: cargo xtask scorecard --check + # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. - name: Test run: cargo test --workspace --locked --features heif @@ -145,18 +159,6 @@ jobs: CRAFT_FONTS_REQUIRED: "1" run: cargo test --workspace --locked --features heif - - name: Clippy - run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - - - name: Dependency layering - run: cargo xtask layers - - # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, - # prefs audit). Static: it reads committed files only, no benchmarks run here. - - name: Scorecard up to date - if: runner.os == 'Linux' - run: cargo xtask scorecard --check - 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 e22c4ec7ab284c464d4340512e59d9d3c83e0e40 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 14:40:15 +0200 Subject: [PATCH 025/171] CI: run the tests with cargo nextest (#6117) cargo test runs the ~258 test binaries one at a time; nextest runs them in parallel. Its new `ci` profile stops at the first failure (a red PR frees its runner early), fails a test that hangs for 10 minutes instead of letting it hold the runner, and repeats every failure at the end of the log; the GPU test group still applies. Same --workspace --locked --features heif selection, so the build is unchanged. Doctests stay on cargo test --doc. postscript_only_style_shows_actual_subfamily relied on another test in the same process having started the font scan; under nextest it runs alone, so it starts the scan itself (as #2933 does). Co-Authored-By: Claude Opus 5.5 (1M context) --- .config/nextest.toml | 11 ++++++++++- .github/workflows/ci.yml | 15 ++++++++++++--- crates/ui-egui/src/type_tool_tests.rs | 3 +++ docs/development.md | 2 +- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 2585577ec1..a082d13515 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,5 +1,6 @@ # cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test -# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204). +# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test` +# keeps working (#2204). # # cargo nextest run --workspace # all tests except doctests # cargo test --doc --workspace # nextest does not run doctests @@ -13,3 +14,11 @@ gpu = { max-threads = 4 } [[profile.default.overrides]] filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' test-group = 'gpu' + +# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its +# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure +# at the end of the log. The default profile's GPU override still applies. +[profile.ci] +fail-fast = true +slow-timeout = { period = "60s", terminate-after = 10 } +failure-output = "immediate-final" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc2564a59..31323da703 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,15 +115,24 @@ jobs: if: runner.os == 'Linux' run: cargo fmt --all -- --check + # cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and + # its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests. + - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + with: + tool: cargo-nextest@0.9.146 + # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. + # nextest doesn't run doctests, so `cargo test --doc` does, with the same selection. - name: Test - run: cargo test --workspace --locked --features heif + run: | + cargo nextest run --workspace --locked --features heif --profile ci + cargo test --doc --workspace --locked --features heif # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" # error for distributors who leave it out. - name: Test without heif if: runner.os == 'Linux' - run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked + run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci # The same tests built with the optional craft-fonts input (crates/text/build.rs), so the # Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace @@ -143,7 +152,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo test --workspace --locked --features heif + run: cargo nextest run --workspace --locked --features heif --profile ci - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index f56829c6d1..c7b54c8871 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1288,6 +1288,9 @@ fn font_styles_keep_metadata_names_and_numeric_labels() { #[test] fn postscript_only_style_shows_actual_subfamily() { + // selected_style reads the engine only once the font scan is done (try_shared). Under nextest + // this test runs in a process of its own, where nothing else has started the scan, so start it. + let _ = photocraft_text::shared(); let style = photocraft_doc::text::CharStyle { font_family: "Inter".into(), postscript_name: Some("Inter-SemiBold".into()), ..Default::default() }; assert_eq!(super::selected_style(&style), "SemiBold"); } diff --git a/docs/development.md b/docs/development.md index ceb679f5f7..3e7f5fb66e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins 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. +[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 runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. ## Fonts (craft-fonts) From caa28cb7ab5f4ca92444603b41b81164fb4e36c0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 14:41:12 +0200 Subject: [PATCH 026/171] CI: run only the font tests in the craft-fonts pass (#6120) The pass reran every test in the workspace, but CRAFT_FONTS is only read in photocraft-text, photocraft-io (svg) and photocraft-ui-egui. The build selection stays the same (the disk warning above still applies); a nextest filter runs those three packages' tests: ~3,800 of ~8,900. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31323da703..e534abee3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,8 @@ jobs: # --features heif` of the Test step: any other package or feature selection resolves a # different feature set, recompiles the whole dependency graph into the same target dir and # runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800). + # Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs, + # not what is built); engine and the apps only pass text on to them (#6120). - name: Check out craft-fonts if: runner.os == 'Linux' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -152,7 +154,9 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo nextest run --workspace --locked --features heif --profile ci + run: >- + cargo nextest run --workspace --locked --features heif --profile ci + -E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)' - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings From 4e7a17cd5d5723313f459753663d0d155f047e19 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 15 Oct 2026 08:18:21 -0600 Subject: [PATCH 027/171] fix(compose): serialize effect identity cache misses Keep effect-identity memo lookup, pure hashing and insertion atomic across concurrent render callers. Preserve existing identities and add a contention regression with eight synchronized callers. --- crates/compose/src/lib.rs | 12 ++++++------ crates/compose/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f688ffedb3..3cf2f1dc19 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -563,14 +563,14 @@ impl LayerKeys { fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 { let addr = std::ptr::from_ref(layer) as usize; let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr)); - if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) { - return *id; - } - let id = self.compute(layer); if in_doc { - self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id); + let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner()); + // Computing an identity only hashes immutable layer data; it does not run Rayon + // work or re-enter this memo. Keep the miss and insertion atomic across callers. + *ids.entry(addr).or_insert_with(|| self.compute(layer)) + } else { + self.compute(layer) } - id } fn compute(&self, layer: &Layer) -> u64 { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index eb4b250728..ddd4871548 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3190,3 +3190,27 @@ fn effect_cache_keeps_no_pixels_of_a_dropped_layer() { assert_eq!(std::sync::Arc::strong_count(&own), 1); assert_eq!(std::sync::Arc::strong_count(&child), 1); } + +// Concurrent callers share one memoized effect identity. +#[test] +fn concurrent_effect_identity_misses_compute_once() { + let mut doc = identity_doc(SampleType::U8); + // Make the existing pure identity hash long enough to expose overlapping misses. + doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512]; + let layer = &doc.layers[1]; + let keys = LayerKeys::default(); + let start = std::sync::Barrier::new(8); + std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + scope.spawn(|| { + start.wait(); + keys.identity(layer, Some(&doc)) + }) + }) + .collect(); + let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + assert!(ids.iter().all(|id| *id == ids[0])); + }); + assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity"); +} From c06eca9b681ff362bd83c0ca0cf189f1f6e42153 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:23:57 +0300 Subject: [PATCH 028/171] fix(psd): preserve PSB foreign-order block lengths Choose PSB block length width from the canonical big-endian key before converting the key for little-endian TIFF output. Preserve foreign payloads, padding and conversion of following blocks. Addresses #5599, finding 2 only. Fixes #6104. --- crates/psd/src/tiff.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 67142921ba..31c85d4885 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -681,13 +681,12 @@ pub mod transcode { if !self.from.is_little() && TaggedBlock::is_foreign_order_signature(self.r.peek_rest()) { let sig = self.r.array::<4>()?; let mut key = self.r.array::<4>()?; - let long = uses_long_length(self.version, &{ - let mut k = key; - if self.to.is_little() { - k.reverse(); - } - k - }); + // Only reached with `from == Big`: the key is canonical here whatever the + // target order. `TaggedBlock::write` sizes the length field from that same key. + // Reversing it for the target order before the check + // asked about a key `PSB_LONG_KEYS` never contains, so a PSB long key's 8-byte + // length was read as 4 and every later block was copied unconverted. + let long = uses_long_length(self.version, &key); let len = self.r.len_field(long)?; let data = self.r.bytes_u64(len)?; if self.to.is_little() { @@ -2211,6 +2210,25 @@ mod tests { assert_eq!(ImageSourceData::from_bytes(&big).unwrap().0.global_blocks, back.global_blocks); } + /// A PSB keeps `Mt16`/`Mt32`/`Mtrn`/`Alph` blocks of unknown layout verbatim, and their + /// length field is 8 bytes (a PSB long key). Writing them into a little-endian TIFF must + /// read that width; the old code looked the key up after reversing it for the target + /// order, which never matches `PSB_LONG_KEYS`. + #[test] + fn psb_foreign_order_long_key_roundtrips() { + let d = ImageSourceData { + version: Version::Psb, + byte_order: ByteOrder::Little, + global_blocks: vec![TaggedBlock { signature: *b"MIB8", key: *b"Mt16", data: vec![9, 8, 7, 6, 5, 4], padding: None }], + ..Default::default() + }; + let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(back.global_blocks, d.global_blocks, "the 8-byte length and the bytes survive"); + } + #[test] fn rle_counts_follow_the_container_and_samples_do_not() { let file = testgen::layered(Version::Psd, ColorMode::Rgb, 16, Compression::Rle); From bcf2cefd4b2a215167f443933c25368e702b31a2 Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:56:43 +0200 Subject: [PATCH 029/171] ci: preserve nextest failures on Windows --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 192a13d8b9..35ba5ea9a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,10 +139,12 @@ jobs: # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. # nextest doesn't run doctests, so `cargo test --doc` does, with the same selection. + # Separate native commands into steps so Windows reports either command's failure. - name: Test - run: | - cargo nextest run --workspace --locked --features heif --profile ci - cargo test --doc --workspace --locked --features heif + run: cargo nextest run --workspace --locked --features heif --profile ci + + - name: Doctests + run: cargo test --doc --workspace --locked --features heif # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" # error for distributors who leave it out. From 99083fca4c151f3f02c808de6ca865b810c3cac4 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:11:42 -0500 Subject: [PATCH 030/171] =?UTF-8?q?Quick=20Mask:=20=E2=8C=98I=20inverts=20?= =?UTF-8?q?the=20whole=20mask,=20unstored=20black=20included=20(#6095)=20(?= =?UTF-8?q?#6108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095) Image › Adjustments on an alpha channel or the Quick Mask ran on the surface's stored content only. The black outside a selection-made Quick Mask (or a new black alpha channel) is the surface default, never stored, so Invert left it black: the whole mask turned red and leaving Quick Mask selected nothing. Channel targets now go through `pixels::adjust_mask`, as layer masks do, which adjusts the default pixel too. Co-Authored-By: Claude Opus 5.5 * Quick Mask: test ⌘I with Color Indicates Selected Areas too (#6095) A rectangle comes back as everything but the rectangle, and with nothing selected ⌘I selects everything. Both failed on main. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/adjust_cmds/tests.rs | 50 ++++++++++++++++++++++++++ crates/engine/src/commands.rs | 6 ++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 80e97b9f34..d831fbf448 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -661,3 +661,53 @@ fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err(); assert!(err.to_string().contains("bad point"), "{err}"); } + +/// #6095: ⌘I in Quick Mask inverts the whole mask, not only where it was white, so leaving Quick +/// Mask selects everything but the original rectangle, whichever way the colour reads. With +/// Color Indicates Selected Areas and nothing selected, it selects everything. A black alpha +/// channel inverts to white. +#[test] +fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() { + for depth in DEPTHS { + let mut s = session(depth, "rgb"); + let pixels = rgba(&s, 2, 2); + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap(); + let q = &doc(&s).quick_mask.as_ref().unwrap().surface; + let (inside, outside) = (q.sample_channel(15, 12, 0), q.sample_channel(2, 2, 0)); + assert!(inside < 0.01 && outside > 0.99, "{depth}-bit Quick Mask inverted: inside {inside}, outside {outside}"); + s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + let sel = doc(&s).selection.as_ref().expect("an inverse selection after Quick Mask"); + let (inside, outside) = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0)); + assert!(inside < 0.01 && outside > 0.99, "{depth}-bit selection: inside {inside}, outside {outside}"); + assert_eq!(rgba(&s, 2, 2), pixels, "{depth}-bit: the layer is untouched"); + + s.execute("select.deselect", json!({})).unwrap(); + s.execute("channel.new", json!({"name": "Black", "fill": "black"})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": {"channel": 0}})).unwrap(); + let a = doc(&s).channels[0].surface.sample_channel(40, 30, 0); + assert!(a > 0.99, "{depth}-bit black alpha channel inverts to white ({a})"); + s.undo(); + assert!(doc(&s).channels[0].surface.sample_channel(40, 30, 0) < 0.01, "{depth}-bit: undo restores the black"); + + // Color Indicates Selected Areas: a rectangle comes back inverted, and nothing selected + // comes back as everything selected. + for (start, inside, outside) in [("rectangle", 0.0, 1.0), ("nothing", 1.0, 1.0)] { + let mut s = session(depth, "rgb"); + s.execute("channel.options", json!({"channel": "quickMask", "indicates": "selected"})).unwrap(); + if start == "rectangle" { + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap(); + } + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + let sel = doc(&s).selection.as_ref().unwrap_or_else(|| panic!("{depth}-bit Selected Areas, {start}: a selection after Quick Mask")); + let got = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0)); + assert!( + (got.0 - inside).abs() < 0.01 && (got.1 - outside).abs() < 0.01, + "{depth}-bit Selected Areas, {start}: selection (inside, outside) {got:?}" + ); + } + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7ddad80737..a05c5b060c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -381,12 +381,12 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) }); } if crate::channel_cmds::is_channel_target(p) { - // Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel. + // Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel as on + // a layer mask, its unstored black included, so ⌘I in Quick Mask inverts it all (#6095). return s.edit(label, |doc, _| { let sel = doc.selection.clone(); if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? { - pixels::adjust_surface(surf, &adj, sel.as_ref(), ColorMode::Grayscale); - surf.prune(); + pixels::adjust_mask(surf, &adj, sel.as_ref()); } Ok(Value::Null) }); From 1b666f4790f9f2e9907218a8e786a3af5305f200 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 00:42:35 +0530 Subject: [PATCH 031/171] Pen: Auto Add/Delete on the path being drawn (#6140) (#6172) While a path was being drawn, the Pen's Auto Add/Delete was off, so adding a point meant finishing the path and switching to Add Anchor Point. Now a click on a segment of the path in progress adds an anchor, a click on a middle anchor deletes it, and drawing carries on from the last anchor. The first anchor still closes the path and the last still breaks its handle. The hover highlight draws over the path in progress, and the rubber band hides where a click adds or deletes instead of placing an anchor. Claude-Session: https://claude.ai/code/session_01JFTxZ57wCmWEDXxCn1Zuni Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/anchor_tools.rs | 66 +++++++++++++++--------- crates/ui-egui/src/anchor_tools_tests.rs | 64 +++++++++++++++++++++++ crates/ui-egui/src/vector_ui.rs | 10 +++- docs/ui-parity.md | 5 +- 4 files changed, 118 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/anchor_tools.rs b/crates/ui-egui/src/anchor_tools.rs index 9dc8b6cfe5..e6f9ac72d3 100644 --- a/crates/ui-egui/src/anchor_tools.rs +++ b/crates/ui-egui/src/anchor_tools.rs @@ -7,9 +7,10 @@ //! * Delete Anchor Point: hovering an anchor highlights it; a click removes it //! (`path.deleteAnchor`), joining its neighbours. //! * ⌥ swaps the two tools and ⌘/Ctrl is Direct Selection (`direct_select`) while held. -//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144), on the -//! active path only and while no path is being drawn: a click on a segment adds, a click on an -//! anchor deletes, except an open subpath's end points; ⇧ turns it off while held. +//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144): a click on +//! a segment adds, a click on an anchor deletes, except an open subpath's end points; ⇧ turns it +//! off while held. It edits the active path, or while a path is being drawn that path (#6140), +//! and drawing carries on from its last anchor. //! //! Each click is one command and one history step; a click on nothing it can edit does nothing. @@ -48,16 +49,26 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path /// The Pen's Auto Add/Delete (#4144): what a Pen click at `p` does to the active path instead of /// starting a new one, as the anchor tool that does it (Add Anchor Point on a segment, Delete -/// Anchor Point on an anchor), the path and the hit. `None` when the option is off, ⇧, ⌥ or ⌘ is -/// held, a path is being drawn, or the click hits nothing it edits. The end points of an open -/// subpath are left to the Pen, as in Photoshop. +/// Anchor Point on an anchor), the path and the hit. While a path is being drawn it edits that +/// path only (#6140): a click anywhere else places the next anchor. `None` when the option is off, +/// ⇧, ⌥ or ⌘ is held, an anchor's handles are being pulled out, or the click hits nothing it edits. +/// The end points of an open subpath are left to the Pen, as in Photoshop: on the path being +/// drawn, the first closes it and the last breaks its handle. pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) -> Option<(Tool, PathRef, Path, Hit)> { - if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || app.ui.pen.is_some() || mods.shift || mods.alt || mods.command { + if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || mods.shift || mods.alt || mods.command { return None; } let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let (r, path, hit) = - crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let at = Point::new(p[0], p[1]); + let (r, path, hit) = match app.ui.pen.as_ref() { + Some(pen) if pen.dragging || pen.curvature => return None, + Some(pen) => { + let path = crate::vector_ui::pen_path(pen); + let hit = edit::hit(&path, at, tol, &[])?; + (PathRef::Pen, path, hit) + } + None => crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, at, tol, &[]).map(|h| (r, path, h)))?, + }; let tool = match hit { Hit::Segment(..) => Tool::AddAnchorPoint, Hit::Anchor([s, k]) => { @@ -76,10 +87,14 @@ pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) - /// when they aren't theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { if let ToolEvent::Down { x, y, .. } = ev - && let Some((_, r, _, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) + && let Some((_, r, path, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) { - // The release finds no Pen path to finish, so the Pen draws nothing. - apply(app, r, hit); + // The release finds no Pen drag to finish, so the Pen draws nothing. + if r == PathRef::Pen { + edit_pen(app, path, hit); + } else { + apply(app, r, hit); + } return true; } let Some(tool) = effective(app.ui.tool, mods) else { return false }; @@ -90,23 +105,28 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) { - let Some((r, mut path, hit)) = target(app, tool, p) else { return }; + let Some((r, path, hit)) = target(app, tool, p) else { return }; if r == PathRef::Pen { - // The Pen path being drawn isn't in the document yet: it takes the edit directly. - let edited = match hit { - Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(), - Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(), - Hit::Handle(..) => false, - }; - if edited { - app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }; - crate::vector_ui::pen_replace(app, &path); - } + edit_pen(app, path, hit); return; } apply(app, r, hit); } +/// Add or delete an anchor of the Pen path being drawn (`path`, from `pen_path`). It isn't in the +/// document yet, so it takes the edit directly; drawing carries on from its last anchor. +fn edit_pen(app: &mut PhotocraftApp, mut path: Path, hit: Hit) { + let edited = match hit { + Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(), + Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(), + Hit::Handle(..) => false, + }; + if edited { + app.ui.direct_selection = DirectSelection { target: Some(PathRef::Pen), ..Default::default() }; + crate::vector_ui::pen_replace(app, &path); + } +} + /// Add an anchor where `hit` is on a segment of `r`, or delete the anchor it is on: one command. fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { let (id, mut params) = match hit { diff --git a/crates/ui-egui/src/anchor_tools_tests.rs b/crates/ui-egui/src/anchor_tools_tests.rs index 87c045eba9..c21cc5aaf6 100644 --- a/crates/ui-egui/src/anchor_tools_tests.rs +++ b/crates/ui-egui/src/anchor_tools_tests.rs @@ -230,3 +230,67 @@ fn the_pen_cursor_shows_what_auto_add_delete_would_do() { app.ui.tool_options.pen_auto_add_delete = false; assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); } + +fn pen_anchors(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.ui.pen.as_ref().map(|p| p.knots.iter().map(|k| k[0]).collect()).unwrap_or_default() +} + +/// #6140: while drawing, a Pen click on the path being drawn adds an anchor on a segment or +/// deletes a middle anchor, and drawing carries on from the last anchor. Nothing reaches History. +#[test] +fn the_pen_adds_and_deletes_anchors_on_the_path_being_drawn() { + let mut app = app(Tool::Pen); + let before = steps(&app); + for at in [[40.0, 40.0], [120.0, 40.0], [120.0, 120.0]] { + click(&mut app, at, Modifiers::NONE); + } + click(&mut app, [80.0, 40.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app), vec![[40.0, 40.0], [80.0, 40.0], [120.0, 40.0], [120.0, 120.0]], "added on the first segment"); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).last(), Some(&[40.0, 120.0]), "the next click carries on from the last anchor"); + assert_eq!(pen_anchors(&app).len(), 5); + + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app), vec![[40.0, 40.0], [80.0, 40.0], [120.0, 120.0], [40.0, 120.0]], "a middle anchor is deleted"); + assert_eq!(steps(&app), before, "the path being drawn is not in the document yet"); +} + +/// The path being drawn keeps its end points (the first closes it, the last breaks its handle), ⇧ +/// or the option turned off places an anchor on its segment, and another path takes no edit. +#[test] +fn the_pen_draws_on_the_path_being_drawn_where_auto_add_delete_does_not_apply() { + let mut app = app(Tool::Pen); + with_path(&mut app); + let work_before = work(&app); + for at in [[20.0, 160.0], [180.0, 160.0], [180.0, 180.0]] { + click(&mut app, at, Modifiers::NONE); + } + click(&mut app, [180.0, 180.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).len(), 3, "a click on the last anchor breaks its handle"); + click(&mut app, [100.0, 160.0], Modifiers::SHIFT); + assert_eq!(pen_anchors(&app).len(), 4, "⇧ places an anchor"); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, [100.0, 160.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).len(), 5, "with the option off the Pen places an anchor"); + app.ui.tool_options.pen_auto_add_delete = true; + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((pen_anchors(&app).len(), work(&app)), (6, work_before), "another path takes no edit while drawing"); + click(&mut app, [20.0, 160.0], Modifiers::NONE); + assert!(app.ui.pen.is_none(), "the first anchor closes the path"); +} + +#[test] +fn the_pen_cursor_shows_auto_add_delete_on_the_path_being_drawn() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::Pen); + for at in [[40.0, 40.0], [120.0, 40.0], [120.0, 120.0]] { + click(&mut app, at, Modifiers::NONE); + } + let cases = [([80.0, 40.0], Some(Badge::Add)), ([120.0, 40.0], Some(Badge::Subtract)), ([40.0, 40.0], None), ([120.0, 120.0], None), ([60.0, 100.0], None)]; + for (at, want) in cases { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), want, "{at:?}"); + } + app.hover_doc = Some([80.0, 40.0]); + assert_eq!(badge(&app, Tool::Pen, Modifiers::SHIFT), None); +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 01bb3c133b..8b5e914af2 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1016,8 +1016,6 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some((_, p)) = targeted_vector_mask(app) { draw_path(&p, tool == Tool::PathSelection); } - // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete. - crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); crate::corner_radius::draw(app, painter, &to_scr, accent); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While @@ -1038,9 +1036,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform pts.extend(curve_points(w[0][0], w[0][2], w[1][1], w[1][0], &to_scr)); } painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); + // No rubber band where a click adds or deletes an anchor of this path instead (#6140). + let auto = app.hover_doc.is_some_and(|h| crate::anchor_tools::pen_auto(app, tool, painter.ctx().input(|i| i.modifiers), h).is_some()); if let (Some(last), Some(h)) = (pen.knots.last(), app.hover_doc) && !pen.dragging && pen.moving.is_none() + && !auto { if pen.curvature && pen.knots.len() >= 2 { // The Curvature Pen previews the curve the next click makes through the pointer. @@ -1079,6 +1080,11 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.circle_stroke(c + vec2(11.0, 11.0), 3.5, Stroke::new(1.5, accent)); } } + // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete, + // over the path being drawn. While drawing, the other paths can be hidden: it edits none of them. + if paths || app.ui.pen.is_some() { + crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); + } } // --------------------------------------------------------------------------------------------- diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6edff953df..793f09840c 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-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 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, @@ -121,7 +121,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme |---|---|---| | Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | done | TOOL-214-10; `vector_ui.rs` (#3751) | | Handle on the last point, close-drag handle | partial | #1482 | -| Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | +| Pen Auto Add/Delete: click a segment of the active path, or of the path being drawn, to add, an anchor to delete; ⇧ suspends it | done | #4144, #6140 | | 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 | @@ -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 | 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) | | 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H | From b24c0360f1839885812f87feabaa102afcce9c3c Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:37:56 -0400 Subject: [PATCH 032/171] Dependencies: require ruzstd 0.9.1 for RUSTSEC-2026-0344 (#4952) (#5225) ruzstd before 0.9.1 lets a crafted zstd input under 128 KB allocate several gigabytes at once. PhotoCraft decodes zstd when it opens .pcraft and Affinity files, so such a file could end the process with an out-of-memory abort. Raise the minimum in both crates that use it and update the lockfile from 0.9.0 to 0.9.1. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 4 ++-- crates/affinity/Cargo.toml | 2 +- crates/format/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3a268add9..138ca3d814 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6113,9 +6113,9 @@ dependencies = [ [[package]] name = "ruzstd" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" dependencies = [ "twox-hash", ] diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml index e686e737ef..49d859c2f8 100644 --- a/crates/affinity/Cargo.toml +++ b/crates/affinity/Cargo.toml @@ -16,7 +16,7 @@ synth = [] [dependencies] crc32fast = "1.5" flate2 = { workspace = true } -ruzstd = "0.9" +ruzstd = "0.9.1" [dev-dependencies] proptest = { workspace = true } diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 45d7a4ccf3..113d9ba026 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -22,7 +22,7 @@ flate2 = { workspace = true } # Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2. crc32fast = "1.5" blake3 = { version = "1.8", default-features = false, features = ["pure"] } -ruzstd = "0.9" +ruzstd = "0.9.1" # Unix directory cleanup uses no-follow descriptor-relative operations; the same # dependency provides the plain-fsync fallback on Apple file systems. From 64520f645e073b23960b774a677d54f504094843 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:39:19 -0400 Subject: [PATCH 033/171] Control channel: connections must authenticate within 10 s, however active (#4965) (#5229) The only limit on an unauthenticated control connection was the 30 s idle timeout, which starts again with every read. A local client that kept sending bytes or blank lines without ever completing `auth` kept its connection, and one of the 16 connection permits, for as long as it liked; sixteen of them locked every other client out. AuthDeadlineStream wraps the read half of a connection: until the connection authenticates, reads fail once AUTH_DEADLINE (10 s) has passed since it connected, and a blocking read waits no longer than what is left. Both transports use it (the desktop app's control server and the headless `photocraft-cli serve`). After `auth` succeeds only the idle timeout applies, as before. Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/control_server.rs | 45 ++++++++++- crates/automation/src/rpc.rs | 69 ++++++++++++++-- crates/automation/src/security.rs | 110 +++++++++++++++++++++++++- 3 files changed, 213 insertions(+), 11 deletions(-) diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 258d89e1ec..30ae610b70 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -5,11 +5,12 @@ use std::io::{BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::Instant; +use std::time::{Duration, Instant}; use photocraft_automation::budgets::write_reply; use photocraft_automation::security::{ - ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply, + AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, + read_bounded_line, repeat_auth_reply, }; use photocraft_ui_egui::ControlRequest; use photocraft_ui_egui::control::REPLY_DEADLINE; @@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver, ctx: egui::Context) { + serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE); +} + +/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting, +/// however much it sends meanwhile (#4965); after that only the idle timeout applies. +fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, auth_deadline: Duration) { if configure_stream(&stream).is_err() { return; } let Ok(read) = stream.try_clone() else { return }; - let mut reader = BufReader::new(read); + let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline)); let mut out = stream; let mut line = String::new(); let mut authenticated = false; @@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: if !authenticated { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; + if ok && reader.get_mut().authenticated().is_err() { + break; + } if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated { break; } @@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: mod tests { use super::*; use std::io::BufRead; - use std::time::Duration; + + /// #4965: a connection that keeps sending without authenticating is closed at the deadline + /// (releasing its connection permit), and nothing it sent is dispatched. + #[test] + fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let started = Instant::now(); + serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300)); + started.elapsed() + }); + // Blank lines and partial frames, one byte every 20 ms: each read succeeds long before + // the idle timeout. + let mut stream = TcpStream::connect(addr).unwrap(); + for i in 0..150 { + let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " }; + if stream.write_all(byte).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let served = server.join().unwrap(); + assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}"); + assert!(rx.try_recv().is_err(), "nothing was dispatched"); + } #[test] fn oversized_reply_preserves_framing_id_and_the_next_control_request() { diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index dc01f0f2b4..15a0e24e30 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -29,8 +29,8 @@ use serde_json::{Value, json}; use crate::budgets::{BatchReplyBudget, write_reply}; use crate::security::{ - ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line, - read_bounded_line, repeat_auth_reply, + AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, + configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply, }; use crate::{AutomationError, Headless}; @@ -262,10 +262,12 @@ pub fn serve_lines(h: &Mutex, mut r: impl BufRead, mut w: impl Write) } } -fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> { +/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however +/// much it sends meanwhile (#4965); after that only the idle timeout applies. +fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> { configure_stream(&stream)?; let read = stream.try_clone()?; - let mut reader = std::io::BufReader::new(read); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline)); let mut out = stream; let mut line = String::new(); let mut authenticated = false; @@ -290,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: } else { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; + if ok { + reader.get_mut().authenticated()?; + } reply }; write_reply(&mut out, &reply)?; @@ -322,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc>, token: String, ready: impl let token = Arc::clone(&token); std::thread::spawn(move || { let _permit = permit; - let _ = serve_tcp_connection(&h, stream, &token); + let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE); }); } Ok(()) @@ -814,6 +819,60 @@ mod tests { assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch")); } + /// #4965: a connection that keeps sending without authenticating is closed at the deadline, + /// and one that authenticated in time keeps being served after it. + #[test] + fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() { + use std::io::{BufReader, Write as _}; + use std::time::{Duration, Instant}; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let h = Mutex::new(Headless::new()); + let mut results = Vec::new(); + for _ in 0..2 { + let (stream, _) = listener.accept().unwrap(); + let started = Instant::now(); + let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300)); + results.push((r.map_err(|e| e.kind()), started.elapsed())); + } + results + }); + + // Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout. + let mut slow = std::net::TcpStream::connect(addr).unwrap(); + for _ in 0..150 { + if slow.write_all(b" ").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + drop(slow); + + // Authenticates at once, then asks again 300 ms after the deadline. + let mut s = std::net::TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap(); + let mut reader = BufReader::new(s.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["result"]["authenticated"], true); + std::thread::sleep(Duration::from_millis(600)); + writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["id"], "a"); + drop(reader); + drop(s); + + let results = server.join().unwrap(); + let (slow_result, slow_time) = &results[0]; + assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}"); + assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}"); + assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client"); + } + #[test] fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() { use std::io::{BufReader, Write as _}; diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 4659a3ff17..7928420b12 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -1,11 +1,11 @@ //! Shared security primitives for the loopback JSON control transports. use std::fs::OpenOptions; -use std::io::{BufRead, Write}; +use std::io::{BufRead, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; @@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16; pub const MAX_BATCH_STEPS: usize = 256; /// Idle/read and write timeout for loopback TCP connections. pub const IO_TIMEOUT: Duration = Duration::from_secs(30); +/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts +/// again with every read, so a client that kept sending without ever completing `auth` held one +/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965). +pub const AUTH_DEADLINE: Duration = Duration::from_secs(10); const TOKEN_BYTES: usize = 32; const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2; @@ -242,6 +246,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> { stream.set_write_timeout(Some(IO_TIMEOUT)) } +/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail +/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime; +/// the caller then drops the connection and its [`ConnectionPermit`]. +pub struct AuthDeadlineStream { + stream: std::net::TcpStream, + /// `None` once authenticated. + deadline: Option, +} + +impl AuthDeadlineStream { + /// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]). + pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self { + // A limit too large to represent expires at once rather than never. + let now = Instant::now(); + Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) } + } + + /// The connection authenticated: only the idle timeout applies from here on. + pub fn authenticated(&mut self) -> std::io::Result<()> { + self.deadline = None; + self.stream.set_read_timeout(Some(IO_TIMEOUT)) + } +} + +impl Read for AuthDeadlineStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if let Some(deadline) = self.deadline { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time")); + } + // Wait no longer than what is left, so a silent client is dropped at the deadline too. + self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?; + } + self.stream.read(buf) + } +} + /// Environment-aware token inputs shared by the desktop app and CLI. pub fn token_inputs(supplied: Option, token_file: Option) -> (Option, Option) { let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok()); @@ -327,6 +369,70 @@ mod tests { std::fs::remove_file(path).unwrap(); } + /// A connected loopback pair: the accepted (server) end and the client end. + fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + configure_stream(&server).unwrap(); + (server, client) + } + + /// #4965: activity must not extend the time a connection may stay unauthenticated. + #[test] + fn an_active_connection_is_dropped_at_the_authentication_deadline() { + let (server, mut client) = tcp_pair(); + // A byte every 20 ms and never a newline: each read succeeds long before the idle timeout. + let trickle = std::thread::spawn(move || { + for _ in 0..150 { + if client.write_all(b"x").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300))); + let mut line = String::new(); + let started = Instant::now(); + let err = read_bounded_line(&mut reader, &mut line).unwrap_err(); + assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}"); + assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed()); + assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed()); + // It stays expired. + assert!(read_bounded_line(&mut reader, &mut line).is_err()); + drop(reader); + trickle.join().unwrap(); + } + + #[test] + fn a_silent_connection_is_dropped_at_the_authentication_deadline() { + let (server, _client) = tcp_pair(); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); + let mut line = String::new(); + let started = Instant::now(); + assert!(read_bounded_line(&mut reader, &mut line).is_err()); + assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed()); + } + + #[test] + fn an_authenticated_connection_outlives_the_authentication_deadline() { + let (server, mut client) = tcp_pair(); + let sender = std::thread::spawn(move || { + client.write_all(b"one\n").unwrap(); + std::thread::sleep(Duration::from_millis(500)); + 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(); + // 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"); + sender.join().unwrap(); + } + #[test] fn connection_limiter_releases_capacity() { let limiter = ConnectionLimiter::new(1); From e4b2868a02c99afb30ed5c61022e03b13574e873 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 03:40:50 +0800 Subject: [PATCH 034/171] Check deep EXR tile count tables against max_alloc (#5632) * Check deep EXR tile count tables against max_alloc A deep tile's sample-count table was decompressed at the header's full tile size with no budget check, so a 20 KB file could build a 4 MiB table and a scratch copy under a 1 MiB max_alloc. Try that size only when max_alloc has room for both; otherwise read the clipped size. Fixes #4958. Co-Authored-By: Claude Opus 5.5 * Keep deep EXR decompression within the size it was admitted at The count-table check admits the table and its scratch copy, 2x the table, but Vec doubling gave a 4 MiB ZIPS table 8 MiB of capacity (a 3 MiB RLE table 4 MiB), so an admitted table and its copy could take 3x. Grow the decompressed buffer by doubling capped at the expected size, inflating straight into it, and allocate it and the scratch copy fallibly. Tests: decompressed buffers never reserve past the expected size; a near-maximal tile with a clipped table decodes under every budget; per-tile admission; half and uint channels after full-size tables; the 8 MiB boundary for uncompressed tables too. Refs #4958. Co-Authored-By: Claude Opus 5.5 * Tests: show deep tile tables are admitted one tile at a time The 17x17 image in 16x16 tiles had an interior tile, which never reaches the max_alloc check, so only three edge tables (6 KiB) were checked against the 8000-byte budget and a check that summed admissions across tiles still passed. Use a 48x1 image instead: its three tiles are all edge tiles, and under 4096 bytes a summed check fails at the third tile. Refs #4958. Co-Authored-By: Claude Opus 5.5 * Complete deep EXR count-table allocation admission Account for scratch space on clipped and interior compressed tables while preserving raw-table budgets. Borrow packed input before admission and make initial RLE reservations and raw copies fallible. Add single-channel admission boundaries, raw fallback controls, and hostile-size borrowed-read regressions. * ci: run deep EXR regressions independently * Drop the separate deep EXR CI job It was added so these tests could run while main's workspace suite was failing. Main is green again, and the regular test jobs run them. This reverts commit 5aa4869b45ffd8e89cdd3f02c9aa71d64e217929. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/codecs/src/codecs/deep_exr.rs | 195 ++++++++++++++++++++++++--- crates/codecs/tests/deep_exr.rs | 152 ++++++++++++++++++++- 2 files changed, 325 insertions(+), 22 deletions(-) diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs index 0726228be5..29f3762ebc 100644 --- a/crates/codecs/src/codecs/deep_exr.rs +++ b/crates/codecs/src/codecs/deep_exr.rs @@ -23,7 +23,7 @@ use half::f16; use crate::Format; use crate::error::CodecError; use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType}; -use crate::options::Limits; +use crate::options::{Limits, alloc_zeroed}; const F: Format = Format::OpenExr; @@ -39,9 +39,15 @@ fn unsup(reason: impl Into) -> CodecError { // Every length and offset comes from the file, so every read is bounds-checked and a short // read is a malformed file, never a panic. -fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result, CodecError> { - let mut v = vec![0u8; n]; - cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?; +fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> { + // Borrow packed payloads from the input. Copying them here allocates before count-table + // admission and needlessly duplicates the table even when the second pass just skips it. + let truncated = || err(format!("truncated file while reading {what}")); + let start = usize::try_from(cur.position()).map_err(|_| truncated())?; + let end = start.checked_add(n).ok_or_else(truncated)?; + let bytes = *cur.get_ref(); + let v = bytes.get(start..end).ok_or_else(truncated)?; + cur.set_position(end as u64); Ok(v) } @@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) { } /// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half -/// holding every second byte of the original; weave them back together. -fn interleave_byte_blocks(buf: &mut [u8]) { +/// holding every second byte of the original; weave them back together. The scratch copy is +/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error. +fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> { let half = buf.len().div_ceil(2); - let mut out = vec![0u8; buf.len()]; + let mut out = alloc_zeroed::(buf.len())?; let mut k = 0usize; for i in 0..buf.len() / 2 { let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied()); @@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) { } } buf.copy_from_slice(&out); + Ok(()) +} + +/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`, +/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and +/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error. +fn reserve_up_to(out: &mut Vec, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> { + let need = out.len().saturating_add(extra); + if need > limit { + return Err(err(format!("{what}: data expands past {limit} bytes"))); + } + if need > out.capacity() { + let target = out.capacity().saturating_mul(2).max(need).min(limit); + out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?; + } + Ok(()) } /// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative /// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate). fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecError> { - let mut out = Vec::with_capacity(expected.min(1 << 20)); + let mut out = Vec::new(); + reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?; let mut i = 0; while i < src.len() { let count = src[i] as i8; @@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecE if out.len().saturating_add(n) > expected { return Err(err(format!("{what}: RLE data expands past {expected} bytes"))); } + reserve_up_to(&mut out, n, expected, what)?; if count < 0 { let end = i.saturating_add(n); let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?; @@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecE /// has the uncompressed length is stored raw whatever the declared compression. fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result, CodecError> { if packed.len() == expected { - return Ok(packed.to_vec()); + let mut out = Vec::new(); + reserve_up_to(&mut out, expected, expected, what)?; + out.extend_from_slice(packed); + return Ok(out); } match compression { Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))), @@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s // Like ZIPS, RLE runs over the predicted, byte-split stream. let mut out = rle_decode(packed, expected, what)?; differences_to_samples(&mut out); - interleave_byte_blocks(&mut out); + interleave_byte_blocks(&mut out)?; Ok(out) } Compression::ZIP1 => { - let mut out = Vec::with_capacity(expected.min(1 << 20)); - flate2::read::ZlibDecoder::new(packed) - .take(expected as u64 + 1) - .read_to_end(&mut out) - .map_err(|_| err(format!("{what}: zlib data is malformed")))?; + // Inflate straight into `out`, grown with `reserve_up_to` rather than by + // `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are + // in, a one-byte read checks that the stream ends there. + let malformed = || err(format!("{what}: zlib data is malformed")); + let mut zlib = flate2::read::ZlibDecoder::new(packed); + let mut out = Vec::new(); + let mut filled = 0; + while filled < expected { + if filled == out.len() { + let grow = out.len().max(1 << 13).min(expected - filled); + reserve_up_to(&mut out, grow, expected, what)?; + out.resize(filled + grow, 0); + } + let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?; + if n == 0 { + break; + } + filled += n; + } + out.truncate(filled); + if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 { + return Err(err(format!("{what}: zlib data expands past {expected} bytes"))); + } if out.len() != expected { return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len()))); } differences_to_samples(&mut out); - interleave_byte_blocks(&mut out); + interleave_byte_blocks(&mut out)?; Ok(out) } other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))), @@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) { return Err(err("deep chunk sizes point past the end of the file")); } - let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?; + let what = "the sample-count table"; + let table_bytes = read_exact(&mut cur, packed_table as usize, what)?; let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?; + // A compressed clipped table also needs its scratch copy. The data-window check only + // budgets four bytes per channel per pixel, insufficient for a one-channel image. + // An equal-sized payload takes decompress's raw path, which allocates no scratch. + let buffers = if table_bytes.len() == expected_table { 1 } else { 2 }; + let clipped_need = (expected_table as u64).saturating_mul(buffers); + if clipped_need > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!( + "{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}", + limits.max_alloc + ))); + } // An edge tile's table may still be stored at the full tile size (its pixels first, in // rows of the clipped width, then unused entries), as the OpenEXR library writes it. - let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table); - let raw = decompress(compression, &table_bytes, full_table, "the sample-count table") - .or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?; + // That size comes from the header's tile size alone, which nothing ties to the data + // window, so it is tried only when `max_alloc` has room for the table and for the copy + // that undoes its byte split. Otherwise only the clipped size is read. + let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4)); + let raw = match full_table { + // Scan lines and interior tiles: the full size is the clipped size. + Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?, + Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => { + decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))? + } + _ => decompress(compression, table_bytes, expected_table, what).map_err(|_| { + // Four bytes per pixel of the tile, twice: the table and its scratch copy. + let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8); + CodecError::LimitExceeded(format!( + "{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}", + limits.max_alloc + )) + })?, + }; // The table is cumulative within each row of the block (a tile restarts at every row); // `prefix` is cumulative over the whole block, and each pixel's own count is the difference. @@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res // The count table sits between the sizes and the sample data; skip its bytes. read_exact(&mut cur, packed_table as usize, "the sample-count table")?; let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?; - let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; + let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; // Row by row (a scan line block is one row), and within a row channel by channel, as // flat tiles are laid out: each pixel's samples are contiguous within its channel. let mut in_off = 0usize; @@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result { img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples }); Ok(img) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + #[test] + fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() { + let bytes = [1u8, 2, 3, 4]; + let mut cur = Cursor::new(bytes.as_slice()); + let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes"); + assert_eq!(prefix, &[1, 2, 3]); + assert_eq!(prefix.as_ptr(), bytes.as_ptr()); + assert_eq!(cur.position(), 3); + assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. }))); + assert_eq!(cur.position(), 3); + assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. }))); + cur.set_position(u64::MAX); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. }))); + } + + /// What the writer stores before compressing: the byte split, then the +128 predictor. + fn predicted(raw: &[u8]) -> Vec { + let half = raw.len().div_ceil(2); + let mut out = vec![0u8; raw.len()]; + for (i, &v) in raw.iter().enumerate() { + out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v; + } + let mut prev = 0i16; + for (i, b) in out.iter_mut().enumerate() { + let v = i16::from(*b); + if i > 0 { + *b = (v - prev + 128) as u8; + } + prev = v; + } + out + } + + /// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count + /// table, with the copy that undoes its byte split), so decompression must not reserve + /// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table + /// 4 MiB, so the "admitted" table and its copy took up to three times the table size. + #[test] + fn decompressed_buffers_reserve_no_more_than_expected() { + for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] { + let raw = vec![0u8; n]; + let p = predicted(&raw); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(&p).expect("write to a Vec"); + let zips = z.finish().expect("finish a Vec"); + // RLE: the first byte as a literal, then runs of up to 128 copies of the next. + let mut rle = vec![0xFF, p[0]]; + for run in p[1..].chunks(128) { + rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]); + } + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + let out = decompress(compression, packed, n, "the table").expect("decompresses"); + assert_eq!(out, raw, "{compression:?}, {n} bytes"); + assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity()); + } + // One byte short, the same data expands past the expected size and is refused. + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes"); + } + } + } + + /// A size past the limit is malformed data, and a reservation the allocator refuses is an + /// error rather than an abort. + #[test] + fn reservations_past_the_limit_or_the_allocator_are_errors() { + let mut v = Vec::new(); + assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. }))); + assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_)))); + assert_eq!(v.capacity(), 0); + } +} diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/deep_exr.rs index 5a6d6ae75e..f80a42f17b 100644 --- a/crates/codecs/tests/deep_exr.rs +++ b/crates/codecs/tests/deep_exr.rs @@ -118,6 +118,23 @@ fn gen_deep( tile: Option<(usize, usize)>, counts: &[u32], sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + gen_deep_tables(w, h, channels, compression, tile, counts, sample, false) +} + +/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile +/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its +/// clipped width), then zeros. +#[allow(clippy::too_many_arguments)] +fn gen_deep_tables( + w: usize, + h: usize, + channels: &[Chan], + compression: u8, + tile: Option<(usize, usize)>, + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, + full_tile_tables: bool, ) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); @@ -216,6 +233,10 @@ fn gen_deep( table.extend_from_slice(&acc.to_le_bytes()); } } + assert_eq!(table.len(), table_len(b)); + if let (true, Some((tw, th))) = (full_tile_tables, tile) { + table.resize(tw * th * 4, 0); + } let mut data = Vec::new(); for row in b.pixels.chunks(b.row_w) { for (ci, ch) in channels.iter().enumerate() { @@ -237,7 +258,6 @@ fn gen_deep( c.extend_from_slice(&(data.len() as u64).to_le_bytes()); c.extend_from_slice(&ct); c.extend_from_slice(&cd); - assert_eq!(table.len(), table_len(b)); assert_eq!(data.len(), data_len(b)); chunks.push((bi, c)); } @@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() { assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. }))); } +/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes +/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4 +/// image, its table stored at that size, makes a compressed file of tens of KB whose table +/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a +/// budget smaller than both the table must be refused before it is allocated; with room for +/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of +/// ordinary tiles, still decode under a small budget. +#[test] +fn deep_tile_count_table_is_admitted_against_max_alloc() { + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + let tight = budget(1 << 20); + let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + for compression in [0u8, 1, 2] { + let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true); + assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &tight); + assert!( + matches!(got, Err(CodecError::LimitExceeded(_))), + "compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}", + got.map(|d| d.total_samples()) + ); + let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() }); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width())); + // The same tile with its table stored at the clipped size fits the small budget. + let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba); + assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}"); + if compression != 0 { + assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len()); + } + // The table and its scratch copy are admitted together: 8 MiB. + assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &budget(2 * table - 1)); + assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples())); + } + // Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones. + let counts: Vec = (0..35).map(|p| (p * 3) % 4).collect(); + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true); + let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample); + assert_eq!( + decode_deep_exr(&full, &tight).expect("full-size edge tables"), + decode_deep_exr(&clipped, &tight).expect("clipped edge tables"), + "compression {compression}" + ); + } +} + +/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side, +/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits, +/// where its full size passes the check and is only tried as the data arrives. Each tile is +/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over +/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's +/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for +/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint +/// channels decode after full-size tables like float ones. +#[test] +fn deep_tile_table_admission_edges() { + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + let side = (1 << 30) - 2; + for compression in [0u8, 1, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba); + for limits in [Limits::none(), Limits::default(), budget(1 << 20)] { + assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}"); + } + } + let counts: Vec = (0..48).map(|p| (p % 3) as u32).collect(); + let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c }); + let [a, b, g, r, z] = RGBAZ; + let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }]; + for channels in [&RGBAZ[..], &half[..], &with_id[..]] { + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true); + let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample); + let what = format!("{} channels, compression {compression}", channels.len()); + let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables"); + assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}"); + } + } +} + +/// A one-channel data-window check admits only four bytes per pixel, but a compressed count +/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback; +/// the existing multichannel cases have enough pixel allowance to hide this gap. +#[test] +fn clipped_count_tables_include_scratch_in_the_budget() { + let channels = [Chan { name: "Y", ty: 2 }]; + let counts = vec![0u32; 64]; + let sample = |_: usize, _: usize| Vec::new(); + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + for tile in [None, Some((64, 1)), Some((128, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample); + let what = format!("tile {tile:?}, compression {compression}"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + let required = if compression == 0 { 256 } else { 512 }; + assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}"); + assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}"); + let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() }); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}"); + if compression == 0 { + assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}"); + } + } + } +} + +/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's +/// compression tag, decides whether a scratch buffer is needed. +#[test] +fn raw_count_tables_need_no_scratch_even_with_compression() { + let channels = [Chan { name: "Y", ty: 2 }]; + let limits = Limits { max_alloc: 4, ..Limits::none() }; + for tile in [None, Some((1, 1)), Some((2, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new()); + let header_end = header_end_of(&bytes); + let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize; + let table_size = chunk + if tile.is_some() { 16 } else { 4 }; + let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size")); + assert_eq!(packed, 4, "the four-byte count table is stored raw"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}"); + } + } +} + /// Walk the attributes like the format prescribes: the offset table starts after the /// header's terminating zero byte. fn header_end_of(bytes: &[u8]) -> usize { From 77a258b9a0c0a165a7ccca414883e18415ef7323 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 13:10:22 -0700 Subject: [PATCH 035/171] Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles (#5868) * Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles From the layers audit (L2, silently wrong results): - Release Clipping Mask on a clipped layer also releases the clipped layers above it, as Adobe documents ("removes the selected layer and any layers above it from the clipping mask"). Before, they stayed clipped to the released layer. - Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a layer still clipped to their base (it was always unclipped). Merge Down's result also keeps the lower layer's colour label and link; Merge Layers' keeps the top layer's label. - Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule 3, as single Delete already does), instead of leaving them clipped to the layer below. * Merge Layers keeps the result clipped only when the clipped layers share one clip chain * fmt --- crates/engine/src/clip_group.rs | 13 ++++ crates/engine/src/commands.rs | 19 +++++- crates/engine/src/extra_cmds.rs | 5 ++ crates/engine/src/layer_multi_cmds.rs | 6 ++ crates/engine/src/pixels.rs | 5 ++ crates/engine/tests/clipping_semantics.rs | 75 +++++++++++++++++++++++ 6 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/clipping_semantics.rs diff --git a/crates/engine/src/clip_group.rs b/crates/engine/src/clip_group.rs index 4e356efce9..31f62696a3 100644 --- a/crates/engine/src/clip_group.rs +++ b/crates/engine/src/clip_group.rs @@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec { if group.len() > 1 { group } else { Vec::new() } } +/// The base `id` clips to (itself when it is a base), when it is in a clip group. +pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option { + group_of(doc, id).first().copied() +} + /// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself /// clipped (it is not a base) or nothing is clipped to it. fn clipped_to(doc: &Document, id: LayerId) -> Vec { @@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec { } } +/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them +/// with it). +pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec { + let Some(path) = doc.path_of(id) else { return Vec::new() }; + let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() }; + list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect() +} + /// The clip groups of layers about to move or be deleted, read before they leave their slots. #[derive(Default)] pub(crate) struct Before { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a05c5b060c..edb60b06dc 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -253,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { } /// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in /// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which -/// becomes the base; Release releases every selected layer. One history step either way. +/// becomes the base; Release releases every selected layer. Releasing a clipped layer also +/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected +/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer +/// that is no longer clipped. One history step either way. fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" }; let ids: Vec = if p.get("layer").is_some() { @@ -267,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { (false, all) => all.to_vec(), } }; + let ids = if clip { + ids + } else { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let mut all = ids.clone(); + for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) { + for above in crate::clip_group::clipped_above(doc, *id) { + if !all.contains(&above) { + all.push(above); + } + } + } + all + }; s.edit(label, |doc, _| { for id in &ids { doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip; diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 3a27c99be3..65d181b955 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -522,11 +522,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul n } let backup = doc.layers.clone(); + // The clip groups of the doomed layers, read before they go (clip_group rule 3: layers + // clipped to a deleted base are released). + let doomed: Vec = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect(); + let before = crate::clip_group::Before::read(doc, &doomed); let n = rec(&mut doc.layers, pred); if doc.layers.is_empty() { doc.layers = backup; return Err(EngineError::Other("a document must keep at least one layer".into())); } + crate::clip_group::settle(doc, &[], &before, false); if active.is_none_or(|id| doc.layer(id).is_none()) { *active = doc.top_layer(); } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 9a79a72050..bfbd036ec3 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1312,6 +1312,12 @@ fn merge_layers(s: &mut Session) -> Result { 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. diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index e0cea4f39e..b046de03bd 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -415,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { // Merge Down replaces the lower layer, so keep its editing restrictions. // In particular, a merged Background must remain opaque and position-locked. merged.locks = lower.locks; + // And its place: two layers clipped in one chain merge into a layer still clipped to the base, + // with the lower layer's colour label and link. + merged.clipped = lower.clipped && upper.clipped; + merged.label = lower.label; + merged.link_group = lower.link_group; merged } diff --git a/crates/engine/tests/clipping_semantics.rs b/crates/engine/tests/clipping_semantics.rs new file mode 100644 index 0000000000..1a4aa562a7 --- /dev/null +++ b/crates/engine/tests/clipping_semantics.rs @@ -0,0 +1,75 @@ +//! Clip groups through Release Clipping Mask, Merge Down, Merge Layers and Delete Hidden Layers, +//! as in Photoshop (layers audit L2). The stack: Background, B, C1*, C2* (C1 and C2 clipped to B). + +use photocraft_engine::Session; +use serde_json::json; + +fn stack() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + let mut ids = [0u64; 3]; + for (i, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() { + ids[i] = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 4 * i, "y": 4, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": color})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + } + for c in &ids[1..] { + s.execute("layer.setProps", json!({"layer": c, "clipped": true})).unwrap(); + } + (s, ids) +} + +fn clipped(s: &Session, id: u64) -> bool { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped +} + +#[test] +fn releasing_a_middle_layer_releases_the_ones_above_it() { + let (mut s, [_, c1, c2]) = stack(); + let r = s.execute("layer.releaseClippingMask", json!({"layer": c1})).unwrap(); + assert!(!clipped(&s, c1) && !clipped(&s, c2), "C2 isn't left clipped to the released C1: {r}"); + // Releasing the top one leaves the one below it alone. + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.releaseClippingMask", json!({"layer": c2})).unwrap(); + assert!(clipped(&s, c1) && !clipped(&s, c2)); +} + +#[test] +fn merging_clipped_layers_keeps_the_result_clipped() { + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.select", json!({"layer": c2})).unwrap(); + let r = s.execute("layer.mergeDown", json!({})).unwrap(); + let merged = r["layer"].as_u64().unwrap_or(c1); + assert!(clipped(&s, merged), "C2 merged down into C1 stays clipped to B"); + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.select", json!({"layer": c1})).unwrap(); + s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(clipped(&s, r["layer"].as_u64().unwrap()), "C1 + C2 merged stay clipped"); + // Clipped layers of two chains (Background, B, C1*, E, D*: C1 clipped to B, D to E) merge + // into an ordinary layer: kept clipped at D's slot, C1's pixels would clip to E. + let (mut s, [_, c1, e]) = stack(); + s.execute("layer.setProps", json!({"layer": e, "clipped": false})).unwrap(); + let d = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + s.execute("layer.setProps", json!({"layer": d, "clipped": true})).unwrap(); + s.execute("layer.select", json!({"layer": c1})).unwrap(); + s.execute("layer.select", json!({"layer": d, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(!clipped(&s, r["layer"].as_u64().unwrap()), "C1 + D come from two clip chains"); + // Merging the base with its clipped layers gives an ordinary layer. + let (mut s, [b, _, c2]) = stack(); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(!clipped(&s, r["layer"].as_u64().unwrap())); +} + +#[test] +fn deleting_a_hidden_base_releases_its_clipped_layers() { + let (mut s, [b, c1, c2]) = stack(); + s.execute("layer.setProps", json!({"layer": b, "visible": false})).unwrap(); + s.execute("layer.delete.hiddenLayers", json!({})).unwrap(); + assert!(!clipped(&s, c1) && !clipped(&s, c2), "they don't clip to the layer below the deleted base"); +} From c11ef015b7640b4dad43bf775373fd345902e382 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 13:10:28 -0700 Subject: [PATCH 036/171] Paste Layer Style carries Blend If and channels; align to an active selection with several layers (#5881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Paste Layer Style carries Blend If and channels; align to an active selection with several layers From the layers audit (L2): - Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its Blending Options. Across colour modes only Blend If's Gray range carries over and the channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named struct (CopiedStyle) instead of a tuple. - With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to Selection), as it already did for one layer. * Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests --- crates/engine/src/extra_cmds.rs | 36 ++++++++-- crates/engine/src/layer_multi_cmds.rs | 10 +-- crates/engine/src/lib.rs | 17 ++++- crates/engine/tests/style_and_align.rs | 99 ++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/style_and_align.rs diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 65d181b955..91c9a9bc1a 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -883,8 +883,17 @@ pub fn specs() -> Vec { ), spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; - let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?; - let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced); + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let style = crate::CopiedStyle { + effects: l.effects.clone(), + blend: l.blend, + fill: l.fill_opacity, + advanced: l.advanced, + blend_if: l.blend_if.clone(), + excluded_channels: l.excluded_channels, + mode: d.doc.mode, + }; s.style_clipboard = Some(style); Ok(Value::Null) }), @@ -900,17 +909,32 @@ pub fn specs() -> Vec { }, |s, p| { let ids = style_targets(s, p, |_| true)?; - let (mut fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let mut fx = style.effects; s.edit("Paste Layer Style", |doc, _| { // A style copied from a document in another mode takes this one's colours. let mode = doc.mode; fx.items = std::mem::take(&mut fx.items).into_iter().map(|e| e.in_mode(mode)).collect(); + // Blend If's Gray range carries over to any mode; its channel ranges and the + // channel restrictions only to the mode they were set in. + let same_mode = style.mode == mode; + let blend_if = if same_mode { + style.blend_if.clone() + } else { + // Through `set`, which keeps the normalised form (no trailing full + // entries), so a full Gray range pastes as the default. + let mut gray = photocraft_doc::BlendIf::default(); + gray.set(0, style.blend_if.get(0)); + gray + }; for id in ids { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); - l.blend = blend; - l.fill_opacity = fill; - l.advanced = advanced; + l.blend = style.blend; + l.fill_opacity = style.fill; + l.advanced = style.advanced; + l.blend_if = blend_if.clone(); + l.excluded_channels = if same_mode { style.excluded_channels } else { 0 }; } Ok(()) })?; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index bfbd036ec3..87bb507e4d 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -263,7 +263,8 @@ fn can_align(s: &Session) -> std::result::Result<(), String> { let d = doc_state(s)?; match d.selected_layers().len() { 0 => Err("no layer selected".into()), - // Photoshop aligns a single layer to the canvas (or to an active selection); 2+ align to each other. + // Photoshop aligns to an active selection however many layers are selected; without one, + // a single layer aligns to the canvas and 2+ align to each other. _ => Ok(()), } } @@ -961,8 +962,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { "canvas" => doc.bounds(), "selection" => doc.selection.as_ref().map(|m| m.content_bounds()).ok_or_else(|| EngineError::Other("there is no selection to align to".into()))?, "auto" | "layers" => match (&doc.selection, sel.len()) { - // Photoshop: one layer plus an active selection aligns to the selection bounds. - (Some(m), 1) if to == "auto" => m.content_bounds(), + // Photoshop: with an active selection, the layers align to its bounds (Layer › Align + // Layers to Selection), however many are selected. + (Some(m), _) if to == "auto" && !m.content_bounds().is_empty() => m.content_bounds(), // One layer with no selection aligns to the canvas (otherwise it would align to itself). (None, 1) if to == "auto" => doc.bounds(), _ => items.iter().fold(Rect::EMPTY, |a, (_, b)| a.union(b)), @@ -1437,7 +1439,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the selection bounds with one layer and an active selection, else the selected layers' bounds)"##; + const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the active selection's bounds when there is one, however many layers are selected; else one layer aligns to the canvas and several to their combined bounds)"##; vec![ spec!("select.allLayers", "All Layers", &["Select"], Some("Cmd+Alt+A"), "{}", has_doc, |s, _| select_all_layers(s)), spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_doc, |s, _| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d2aecf37db..2e0684de12 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -406,6 +406,21 @@ impl Default for ToolState { } } +/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options, as Photoshop +/// copies them (Paste Layer Style puts them all on each target). +#[derive(Clone, Debug)] +pub struct CopiedStyle { + pub effects: photocraft_doc::Effects, + pub blend: photocraft_color::BlendMode, + pub fill: f32, + pub advanced: photocraft_doc::AdvancedBlending, + pub blend_if: photocraft_doc::BlendIf, + pub excluded_channels: u32, + /// The colour mode it was copied in: Blend If's channel ranges and the channel restrictions + /// only carry over to a document in the same mode. + pub mode: photocraft_color::ColorMode, +} + #[derive(Default)] pub struct Session { docs: Vec, @@ -419,7 +434,7 @@ pub struct Session { pub clipboard: Option, /// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced /// Blending switches. - pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>, + pub style_clipboard: Option, /// Shape path context menu: copied vector fill and stroke styles. pub path_fill_clipboard: Option, pub path_stroke_clipboard: Option, diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/style_and_align.rs new file mode 100644 index 0000000000..ad17330507 --- /dev/null +++ b/crates/engine/tests/style_and_align.rs @@ -0,0 +1,99 @@ +//! Copy / Paste Layer Style carries the whole style, Blending Options included (Blend If and the +//! channel restrictions); align with an active selection aligns to it however many layers are +//! selected (layers audit L2). + +use photocraft_engine::Session; +use serde_json::json; + +fn layer(s: &Session, id: u64) -> photocraft_doc::Layer { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone() +} + +fn new_doc(s: &mut Session, mode: &str) -> (u64, u64) { + s.execute("file.new", json!({"width": 64, "height": 64, "mode": mode, "background": "white"})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + (a, b) +} + +#[test] +fn paste_layer_style_carries_blend_if_and_channels() { + let mut s = Session::new(); + let (a, b) = new_doc(&mut s, "rgb"); + s.execute( + "layer.layerStyle.blendingOptions", + json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}, {"channel": "red", "thisLayer": [10, 200]}]}), + ) + .unwrap(); + s.execute("layer.setProps", json!({"layer": a, "channels": [true, false, true]})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": b})).unwrap(); + let (la, lb) = (layer(&s, a), layer(&s, b)); + assert_eq!(lb.blend_if, la.blend_if, "Blend If, Gray and channel ranges"); + assert_eq!(lb.excluded_channels, la.excluded_channels, "the unchecked G channel"); + // Into a CMYK document: only the Gray range carries over; channels reset to all. + let (c, _) = new_doc(&mut s, "cmyk"); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap(); + let lc = layer(&s, c); + assert_eq!(lc.blend_if.get(0), la.blend_if.get(0)); + assert_eq!(lc.blend_if.ranges.len(), 1, "no RGB channel range on CMYK"); + assert_eq!(lc.excluded_channels, 0); +} + +#[test] +fn paste_layer_style_into_another_mode_keeps_blend_if_normalised() { + // A full Gray range and only a Red range: in CMYK nothing is left, so it is the default + // (which compares equal, so the paste isn't seen as a Blend If change). + let mut s = Session::new(); + let (a, _) = new_doc(&mut s, "rgb"); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "red", "thisLayer": [10, 200]}]})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + let (c, _) = new_doc(&mut s, "cmyk"); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap(); + assert_eq!(layer(&s, c).blend_if, photocraft_doc::BlendIf::default()); +} + +#[test] +fn paste_layer_style_on_several_layers_is_one_undo_step() { + // 16-bit, onto a pixel layer and a group: one History step, and one Undo restores both. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64, "depth": 16, "background": "white"})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}]})).unwrap(); + s.execute("layer.setProps", json!({"layer": a, "fill": 0.5, "blend": "multiply"})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + let before = (layer(&s, b), layer(&s, g)); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": g, "mode": "add"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap(); + let la = layer(&s, a); + for id in [b, g] { + let l = layer(&s, id); + assert_eq!((l.blend_if.clone(), l.fill_opacity, l.blend), (la.blend_if.clone(), la.fill_opacity, la.blend), "layer {id}"); + } + s.execute("edit.undo", json!({})).unwrap(); + let after = (layer(&s, b), layer(&s, g)); + assert_eq!((after.0.blend_if, after.0.blend, after.1.blend_if, after.1.blend), (before.0.blend_if, before.0.blend, before.1.blend_if, before.1.blend)); + assert_eq!((after.0.fill_opacity, after.1.fill_opacity), (before.0.fill_opacity, before.1.fill_opacity)); +} + +#[test] +fn several_layers_align_to_an_active_selection() { + let mut s = Session::new(); + let (a, b) = new_doc(&mut s, "rgb"); + for (id, x) in [(a, 4), (b, 30)] { + s.execute("layer.select", json!({"layer": id})).unwrap(); + s.execute("select.rect", json!({"x": x, "y": 10, "width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + } + s.execute("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + for id in [a, b] { + let x0 = layer(&s, id).surface().unwrap().content_bounds().x0; + assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge"); + } +} From 04db697b5d350b64cc05942fdf7d3c4df2fd4d5e Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 14:17:22 -0700 Subject: [PATCH 037/171] Paste Layer Style: a group's Pass Through pastes onto other layers as Normal; style clipboard comment (#6310) --- crates/engine/src/extra_cmds.rs | 8 +++++++- crates/engine/src/lib.rs | 4 ++-- crates/engine/tests/style_and_align.rs | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 91c9a9bc1a..c291862167 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -930,7 +930,13 @@ pub fn specs() -> Vec { for id in ids { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); - l.blend = style.blend; + // Pass Through is a group's mode: a group's style pastes onto other layers as + // Normal, as a merged group's Pass Through becomes Normal. + l.blend = if style.blend == photocraft_color::BlendMode::PassThrough && !l.is_group() { + photocraft_color::BlendMode::Normal + } else { + style.blend + }; l.fill_opacity = style.fill; l.advanced = style.advanced; l.blend_if = blend_if.clone(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2e0684de12..ba618f6ac8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -432,8 +432,8 @@ pub struct Session { coalesce_request: Option, /// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop). pub clipboard: Option, - /// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced - /// Blending switches. + /// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options + /// ([`CopiedStyle`]: blend mode, fill opacity, Advanced Blending, Blend If, channels). pub style_clipboard: Option, /// Shape path context menu: copied vector fill and stroke styles. pub path_fill_clipboard: Option, diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/style_and_align.rs index ad17330507..9af9bed0fa 100644 --- a/crates/engine/tests/style_and_align.rs +++ b/crates/engine/tests/style_and_align.rs @@ -97,3 +97,19 @@ fn several_layers_align_to_an_active_selection() { assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge"); } } + +#[test] +fn a_groups_pass_through_pastes_onto_a_pixel_layer_as_normal() { + let mut s = Session::new(); + let (a, _) = new_doc(&mut s, "rgb"); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": g})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": a})).unwrap(); + assert_eq!(layer(&s, a).blend, photocraft_color::BlendMode::Normal, "Pass Through isn't a pixel layer's mode"); + // Onto another group it stays Pass Through. + let g2 = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": g2, "blend": "Multiply"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": g2})).unwrap(); + assert_eq!(layer(&s, g2).blend, photocraft_color::BlendMode::PassThrough); +} From 3cd0f6c954952aaf1e4b5f9db4bb344ae0b86b20 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 14:18:14 -0700 Subject: [PATCH 038/171] Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) (#5349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) A key that another command holds now offers Photoshop's two answers under the warning. Accept and Go to Conflict takes the key from the other command at once (its main key or an alternate) and selects it for a new one. Undo Changes puts the edited command's main and further keys back as they were. OK without either still accepts, as in Photoshop. Keys the system takes before the app sees them are refused with a message: Alt+Tab, Alt+F4, Ctrl+Alt+Delete, Ctrl+Esc and Ctrl+Shift+Esc off the Mac; Cmd+Tab, Cmd+` and Cmd+Opt+Esc on it. * Keyboard Shortcuts: a conflict goes stale when the bindings change after it (Reset All, Use Default, Delete, ×, Escape, a set switch); Accept takes the key from every holder; Linux reserves its own keys; doc comment back on shortcut_text --- 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/prefs_ui.rs | 122 ++++++++++++++++++- crates/ui-egui/src/shortcut_capture_tests.rs | 116 ++++++++++++++++++ 22 files changed, 377 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6c8f21418a..376757e866 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3797,3 +3797,10 @@ 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) + 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í. + Put this command's shortcuts back as they were Vrátit zkratky tohoto příkazu do původního stavu + Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho + Undo Changes Vrátit změny + {shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2bb1c57f19..59a9d470cd 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3191,3 +3191,10 @@ 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) + 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. + Put this command's shortcuts back as they were Die Kurzbefehle dieses Befehls wiederherstellen + Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen + Undo Changes Änderungen rückgängig machen + {shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 48d23d13b9..0a2e6ddc7c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3889,3 +3889,10 @@ curves Smooth Εξομάλυνση There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα. set name όνομα συνόλου {set} (modified) {set} (τροποποιημένο) + Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση + Changes undone. Οι αλλαγές αναιρέθηκαν. + Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς. + Put this command's shortcuts back as they were Επαναφορά των συντομεύσεων αυτής της εντολής όπως ήταν + Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της + Undo Changes Αναίρεση αλλαγών + {shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a7338795aa..2069b068e5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4151,3 +4151,10 @@ 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) + 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. + Put this command's shortcuts back as they were Restaurar los métodos abreviados de este comando como estaban + Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo + Undo Changes Deshacer cambios + {shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ba1de7b706..d528f1e7ce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3194,3 +3194,10 @@ curves Smooth Lisser set name nom du jeu {set} (modified) {set} (modifié) Random Aléatoire + 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. + Put this command's shortcuts back as they were Rétablir les raccourcis de cette commande + Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner + Undo Changes Annuler les modifications + {shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e26f3ec3b8..e9f3d33907 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4108,3 +4108,10 @@ 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) + 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. + Put this command's shortcuts back as they were A parancs billentyűparancsainak visszaállítása + Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése + Undo Changes Változások visszavonása + {shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d13db9f0cf..141f956c8e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3181,3 +3181,10 @@ curves Smooth Haluskan set name nama set {set} (modified) {set} (diubah) Random Acak + 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. + Put this command's shortcuts back as they were Kembalikan pintasan perintah ini seperti semula + Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu + Undo Changes Batalkan Perubahan + {shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1779c1259e..714932312e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4310,3 +4310,10 @@ 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) + 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. + Put this command's shortcuts back as they were Ripristina le scorciatoie di questo comando com'erano + Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo + Undo Changes Annulla modifiche + {shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dd0cca727f..a78fe8e7ca 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3865,3 +3865,10 @@ curves Smooth スムーズ There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。 set name セット名 {set} (modified) {set}(変更あり) + Accept and Go to Conflict 適用して競合へ移動 + Changes undone. 変更を取り消しました。 + Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。 + Put this command's shortcuts back as they were このコマンドのショートカットを元に戻す + Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択 + Undo Changes 変更を取り消し + {shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f5c243d1e5..2e12c33799 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3367,3 +3367,10 @@ curves Smooth 매끄럽게 There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요. set name 세트 이름 {set} (modified) {set}(수정됨) + Accept and Go to Conflict 적용 후 충돌 항목으로 이동 + Changes undone. 변경 사항을 취소했습니다. + Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요. + Put this command's shortcuts back as they were 이 명령의 단축키를 원래대로 되돌리기 + Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택 + Undo Changes 변경 사항 취소 + {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index aea44757ca..32df1e20a2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3949,3 +3949,10 @@ 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) + 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. + Put this command's shortcuts back as they were De sneltoetsen van deze opdracht herstellen + Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren + Undo Changes Wijzigingen ongedaan maken + {shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 17feb459aa..8b571ff7d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3787,3 +3787,10 @@ 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) + 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. + Put this command's shortcuts back as they were Przywróć skróty tego polecenia + Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je + Undo Changes Cofnij zmiany + {shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0e9559c80e..8e6dcc4993 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3979,3 +3979,10 @@ 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) + 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. + Put this command's shortcuts back as they were Restaurar os atalhos deste comando como estavam + Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo + Undo Changes Desfazer alterações + {shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 78e10a10c4..6c80c470cf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3159,3 +3159,10 @@ curves Smooth Сгладить set name имя набора {set} (modified) {set} (изменён) Random Случайно + Accept and Go to Conflict Принять и перейти к конфликту + Changes undone. Изменения отменены. + Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации. + Put this command's shortcuts back as they were Вернуть комбинации этой команды + Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её + Undo Changes Отменить изменения + {shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f4a8552fb0..9b30313e22 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4262,3 +4262,10 @@ 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) + 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. + Put this command's shortcuts back as they were Vrati prečice ove komande kakve su bile + Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je + Undo Changes Poništi izmene + {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b11f51a807..8e20ad53dc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3919,3 +3919,10 @@ 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) + 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. + Put this command's shortcuts back as they were Bu komutun kısayollarını eski haline getir + Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç + Undo Changes Değişiklikleri Geri Al + {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e563354fdf..9fcf5e85bc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3888,3 +3888,10 @@ curves Smooth Згладити There are too many sets: delete one first. Забагато наборів: спершу видаліть один. set name назва набору {set} (modified) {set} (змінено) + Accept and Go to Conflict Прийняти й перейти до конфлікту + Changes undone. Зміни скасовано. + Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього. + Put this command's shortcuts back as they were Повернути клавіатурні скорочення цієї команди + Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її + Undo Changes Скасувати зміни + {shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6907be96aa..515966c515 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3675,3 +3675,10 @@ 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) + 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. + Put this command's shortcuts back as they were Khôi phục phím tắt của lệnh này như cũ + Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó + Undo Changes Hoàn tác thay đổi + {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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3962f46d65..cbf3c9d08f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2805,3 +2805,10 @@ curves Smooth 平滑 set name 组名称 {set} (modified) {set}(已修改) Random 随机 + Accept and Go to Conflict 接受并转到冲突 + Changes undone. 已撤消更改。 + Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。 + Put this command's shortcuts back as they were 将此命令的快捷键恢复原样 + Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它 + Undo Changes 撤消更改 + {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 005c992e23..30aa3f5a94 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2790,3 +2790,10 @@ curves Smooth 平滑 set name 組合名稱 {set} (modified) {set}(已修改) Random 隨機 + Accept and Go to Conflict 接受並前往衝突 + Changes undone. 已還原變更。 + Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。 + Put this command's shortcuts back as they were 將此指令的快速鍵恢復原狀 + Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它 + Undo Changes 還原變更 + {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d194803020..99750ae7f1 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -804,6 +804,47 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec, out } +/// A fingerprint of the bindings a shortcut conflict describes. +fn conflict_state(overrides: &BTreeMap, alternates: &BTreeMap>, selected: &str, set: Option<&Value>) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (overrides, alternates, selected, set.map(Value::to_string)).hash(&mut h); + h.finish() +} + +/// Keys the system takes before the app sees them (switching apps, closing the window, force +/// quit): Edit › Keyboard Shortcuts won't assign them, as Photoshop won't. `sc` is in our +/// notation, where `Cmd` is Ctrl off the Mac. +pub fn reserved_shortcut(sc: &str) -> bool { + let Some(n) = prefs::normalize_shortcut(sc) else { return false }; + let reserved: &[&str] = if cfg!(target_os = "macos") { + &["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"] + } else if cfg!(target_os = "windows") { + &["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"] + } else { + // Linux and the BSDs: window switching and closing, and the virtual consoles. + &[ + "Alt+Tab", + "Alt+Shift+Tab", + "Alt+F4", + "Cmd+Alt+Delete", + "Cmd+Alt+F1", + "Cmd+Alt+F2", + "Cmd+Alt+F3", + "Cmd+Alt+F4", + "Cmd+Alt+F5", + "Cmd+Alt+F6", + "Cmd+Alt+F7", + "Cmd+Alt+F8", + "Cmd+Alt+F9", + "Cmd+Alt+F10", + "Cmd+Alt+F11", + "Cmd+Alt+F12", + ] + }; + reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n) +} + /// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers. pub fn shortcut_text(key: egui::Key, m: egui::Modifiers) -> Option { if is_modifier_key(key) { @@ -1687,6 +1728,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = f.get("conflict").filter(|c| c.is_object()).cloned(); let mut hidden: Vec = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut colors: BTreeMap = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1739,6 +1783,7 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = items + let clash_ids: Vec<&str> = items .iter() .filter(|(id, _, _, def)| { let cur = eff(&overrides, id, def); @@ -1757,11 +1806,30 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = items + .iter() + .filter(|(id, ..)| clash_ids.contains(&id.as_str())) .map(|(id, label, path, _)| { let (path, label) = shown(id, label, path); format!("{} › {}", path.join(" › "), label.trim_end_matches('…')) }) .collect(); + // What Undo Changes puts back: the command's main key and further keys as they were. + if let Some(first) = clash_ids.first() { + conflict = Some(json!({ + "shortcut": sc, + "with": first, + // Every command holding the key: Accept takes it from all of them. + "holders": clash_ids, + "command": selected, + "main": overrides.get(&selected), + "alternates": alt_overrides.get(&selected), + })); + } else { + conflict = None; + } if capture_alt { let def = items.iter().find(|i| i.0 == selected).and_then(|i| i.3.clone()); let cur = eff(&overrides, &selected, &def); @@ -1925,6 +1993,53 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = serde_json::from_value(c["holders"].clone()).unwrap_or_else(|_| vec![with.to_string()]); + for holder in &holders { + let def = items.iter().find(|i| i.0 == *holder).and_then(|i| i.3.clone()); + let cur = eff(&overrides, holder, &def); + if cur.as_deref().and_then(prefs::normalize_shortcut).as_deref() == Some(key) { + overrides.insert(holder.clone(), String::new()); + } + let keys = eff_alts(&alt_overrides, holder, eff(&overrides, holder, &def).as_deref()); + if keys.iter().any(|k| k == key) { + alt_overrides.insert(holder.clone(), keys.into_iter().filter(|k| k != key).collect()); + } + } + selected = with.to_string(); + filter.clear(); + message = tl!("Choose a new shortcut for this command, or leave it without one.").into(); + message_warning = false; + conflict = None; + } + if ui.button(tl!("Undo Changes")).on_hover_text(tl!("Put this command's shortcuts back as they were")).clicked() { + match c["main"].as_str() { + Some(m) => overrides.insert(command.to_string(), m.to_string()), + None => overrides.remove(command), + }; + match serde_json::from_value::>(c["alternates"].clone()) { + Ok(list) => alt_overrides.insert(command.to_string(), list), + Err(_) => alt_overrides.remove(command), + }; + selected = command.to_string(); + message = tl!("Changes undone.").into(); + message_warning = false; + conflict = None; + } + }); + } if overrides_ok { f.insert("overrides".into(), json!(overrides)); } @@ -1932,6 +2047,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = crate::shortcut_dispatch::bindings(&app).into_iter().filter(|(_, sc)| *sc == f7).map(|(id, _)| id).collect(); assert_eq!(bound, ["select.modify.feather"]); } + +/// Capture `k` with `m` for the selected command. +fn capture_key(h: &mut Harness<'_, PhotocraftApp>, id: u64, k: Key, m: Modifiers) { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true)); + h.run_steps(2); + key(h, k, true, m); + h.run_steps(2); + key(h, k, false, m); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + h.run_steps(3); +} + +#[test] +fn a_conflict_offers_accept_and_go_to_conflict() { + // Photoshop: the key is taken from Copy now, and Copy is selected to get a new one. + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert_eq!(field(&h, id, "overrides")["edit.cut"], json!("Cmd+C")); + assert_eq!(field(&h, id, "conflict")["with"], json!("edit.copy")); + click(&mut h, "Accept and Go to Conflict"); + assert_eq!(field(&h, id, "selected"), json!("edit.copy")); + assert_eq!(field(&h, id, "overrides")["edit.copy"], json!(""), "Copy no longer holds Cmd+C"); + assert!(field(&h, id, "conflict").is_null()); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.cut", Some("Cmd+X")).as_deref(), Some("Cmd+C")); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")), None); +} + +#[test] +fn undo_changes_puts_the_command_back() { + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + click(&mut h, "Undo Changes"); + assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides")); + assert!(field(&h, id, "conflict").is_null()); + // An alternate added with Add Shortcut is undone the same way. + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("captureAlt".into(), json!(true)); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert_eq!(field(&h, id, "alternates")["edit.cut"], json!(["F2", "Cmd+C"]), "beside Photoshop's F2"); + click(&mut h, "Undo Changes"); + assert!(field(&h, id, "alternates").get("edit.cut").is_none(), "{}", field(&h, id, "alternates")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C")); +} + +#[test] +fn a_system_key_is_refused() { + let (k, m) = if cfg!(target_os = "macos") { (Key::Tab, Modifiers::MAC_CMD | Modifiers::COMMAND) } else { (Key::F4, Modifiers::ALT) }; + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, k, m); + assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides")); + assert!(field(&h, id, "message").as_str().is_some_and(|s| s.contains("reserved")), "{}", field(&h, id, "message")); + assert_eq!(field(&h, id, "messageWarning"), json!(true)); + assert!(crate::prefs_ui::reserved_shortcut(if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" })); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Shift+F4")); + assert!(!crate::prefs_ui::reserved_shortcut("garbage+")); +} + +/// The conflict describes the bindings as the capture left them: Reset All, Use Default or +/// Escape after it makes it stale, so Accept can't take a key nothing holds any more. +#[test] +fn a_conflict_goes_stale_when_the_bindings_change() { + for undo in ["Reset All to Defaults", "Use Default", "escape"] { + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert!(field(&h, id, "conflict").is_object(), "{undo}"); + if undo == "escape" { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true)); + h.run_steps(2); + key(&mut h, Key::Escape, true, Modifiers::NONE); + h.run_steps(2); + key(&mut h, Key::Escape, false, Modifiers::NONE); + h.run_steps(2); + } else { + click(&mut h, undo); + } + assert!(field(&h, id, "conflict").is_null(), "{undo}: {}", field(&h, id, "conflict")); + assert!(h.query_by_label("Accept and Go to Conflict").is_none(), "{undo}"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + if undo != "escape" { + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C"), "{undo}: Copy keeps ⌘C"); + } + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn linux_reserves_its_own_system_keys() { + assert!(crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2")); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Escape")); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_reserves_its_own_system_keys() { + assert!(crate::prefs_ui::reserved_shortcut("Cmd+Escape")); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2")); +} + +/// A key two commands hold (Cut's ⌘X, given to Copy too): Accept takes it from both. +#[test] +fn accept_takes_the_key_from_every_command_holding_it() { + let (mut h, id) = dialog(1.0, "edit.paste", ""); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({"edit.copy": "Cmd+X"})); + h.run_steps(2); + capture_key(&mut h, id, Key::X, Modifiers::COMMAND); + let holders = field(&h, id, "conflict")["holders"].clone(); + assert!(holders.as_array().is_some_and(|a| a.len() == 2), "{holders}"); + click(&mut h, "Accept and Go to Conflict"); + let o = field(&h, id, "overrides"); + assert_eq!((o["edit.cut"].clone(), o["edit.copy"].clone(), o["edit.paste"].clone()), (json!(""), json!(""), json!("Cmd+X")), "{o}"); +} From 54f4baf6f96fd42a5ad0873fc162b862cc7b7a4f Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 14:33:51 -0700 Subject: [PATCH 039/171] Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names (#5856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls changed only the active layer, silently (the layers audit's top panel gap). They now change every selected layer, as in Photoshop, in one undo step. layer.setProps takes "layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a name for several layers is an error that changes nothing. History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity Change, Blending Change, Rename Layer (a mix stays Layer Properties). * Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes * Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856) * Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856) --- crates/engine/src/commands.rs | 119 ++++++++++++++------- crates/engine/tests/layer_props_several.rs | 72 +++++++++++++ crates/ui-egui/src/blend_preview.rs | 46 +++++--- crates/ui-egui/src/blend_preview/tests.rs | 24 ++++- crates/ui-egui/src/layer_props_ui_tests.rs | 78 ++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels.rs | 52 ++++++--- 7 files changed, 319 insertions(+), 74 deletions(-) create mode 100644 crates/engine/tests/layer_props_several.rs create mode 100644 crates/ui-egui/src/layer_props_ui_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index edb60b06dc..2f04187128 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -741,10 +741,32 @@ fn build() -> Vec { "Layer Properties", [], None, - r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, + r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, has_layer, |s, p| { - let id = layer_param(s, p)?; + // Several layers at once (the Layers panel with several selected, as in Photoshop), + // else the given or active one. + let ids: Vec = match p.get("layers") { + None | Some(Value::Null) => vec![layer_param(s, p)?], + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let mut ids = Vec::with_capacity(a.len()); + for v in a { + let id = v.as_u64().map(LayerId).ok_or_else(|| bad("layer.setProps", format!("`layers` holds layer ids, got {v}")))?; + if doc.layer(id).is_none() { + return Err(EngineError::NoLayer(id)); + } + if !ids.contains(&id) { + ids.push(id); + } + } + ids + } + Some(v) => return Err(bad("layer.setProps", format!("`layers` is a non-empty list of layer ids, got {v}"))), + }; + if ids.len() > 1 && p.get("name").is_some() { + return Err(bad("layer.setProps", "`name` renames one layer, not several")); + } // Opacity and Fill are fractions here, while the Layer Style commands take // percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242). let unit = |key: &str| -> Result> { @@ -755,9 +777,19 @@ fn build() -> Vec { } }; let (opacity, fill) = (unit("opacity")?, unit("fill")?); - let only_visibility = - p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce"))); - let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" }; + // Photoshop's History names for a single kind of change; a mix is "Layer Properties". + let changed: Vec<&str> = p + .as_object() + .map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect()) + .unwrap_or_default(); + let label = match changed.as_slice() { + ["visible"] => "Layer Visibility", + ["opacity"] => "Opacity Change", + ["fill"] => "Fill Opacity Change", + ["blend"] => "Blending Change", + ["name"] => "Rename Layer", + _ => "Layer Properties", + }; let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); // Clipping and channel changes reach the layers around it: recomposite everything. let local = p.get("clipped").is_none() && p.get("channels").is_none(); @@ -766,47 +798,54 @@ fn build() -> Vec { // one that changes its look turns it into a normal layer ("Layer 0") first. let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0); let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal); - if num("opacity") || num("fill") || blends { - crate::extra_cmds::background_to_layer(doc, id); - } - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if let Some(v) = p.get("name").and_then(Value::as_str) { - l.name = v.to_string(); - } - if let Some(v) = p.get("visible").and_then(Value::as_bool) { - l.visible = v; - } - if let Some(v) = opacity { - l.opacity = v; - } - if let Some(v) = fill { - l.fill_opacity = v; - } - if let Some(v) = p.get("blend").and_then(Value::as_str) { - l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; - } - if let Some(v) = p.get("clipped").and_then(Value::as_bool) { - l.clipped = v; - } - if let Some(v) = p.get("locked").and_then(Value::as_bool) { - l.locks.all = v; - } - if let Some(Value::Array(ch)) = p.get("channels") { - // Blending Options › Advanced Blending › Channels: unchecked = left out. - l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i); - } - if let Some(Value::Object(m)) = p.get("locks") { - for (k, v) in m { - let v = v.as_bool().unwrap_or(false); - if !l.locks.set(k, v) { - return Err(bad("layer.setProps", format!("unknown lock `{k}`"))); + for &id in &ids { + if num("opacity") || num("fill") || blends { + crate::extra_cmds::background_to_layer(doc, id); + } + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if let Some(v) = p.get("name").and_then(Value::as_str) { + l.name = v.to_string(); + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + l.visible = v; + } + if let Some(v) = opacity { + l.opacity = v; + } + if let Some(v) = fill { + l.fill_opacity = v; + } + if let Some(v) = p.get("blend").and_then(Value::as_str) { + let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; + // Pass Through is a group's mode only, as in Photoshop. + if b == photocraft_color::BlendMode::PassThrough && !l.is_group() { + return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0))); + } + l.blend = b; + } + if let Some(v) = p.get("clipped").and_then(Value::as_bool) { + l.clipped = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + l.locks.all = v; + } + if let Some(Value::Array(ch)) = p.get("channels") { + // Blending Options › Advanced Blending › Channels: unchecked = left out. + l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i); + } + if let Some(Value::Object(m)) = p.get("locks") { + for (k, v) in m { + let v = v.as_bool().unwrap_or(false); + if !l.locks.set(k, v) { + return Err(bad("layer.setProps", format!("unknown lock `{k}`"))); + } } } } Ok(()) })?; if local { - crate::layer_multi_cmds::note_damage(s, &before, &[id]); + crate::layer_multi_cmds::note_damage(s, &before, &ids); } Ok(Value::Null) } diff --git a/crates/engine/tests/layer_props_several.rs b/crates/engine/tests/layer_props_several.rs new file mode 100644 index 0000000000..beee852d1c --- /dev/null +++ b/crates/engine/tests/layer_props_several.rs @@ -0,0 +1,72 @@ +//! `layer.setProps` on several layers at once (the Layers panel with several selected, as in +//! Photoshop), and Photoshop's History names for each kind of change. + +use photocraft_engine::Session; +use serde_json::json; + +fn doc() -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + let ids = (0..3).map(|_| s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()).collect(); + (s, ids) +} + +fn layer(s: &Session, id: u64) -> photocraft_doc::Layer { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone() +} + +fn last_step(s: &Session) -> String { + s.active().unwrap().history.entries().last().cloned().unwrap_or_default() +} + +#[test] +fn one_change_reaches_every_listed_layer_in_one_step() { + let (mut s, ids) = doc(); + let steps = s.active().unwrap().history.entries().len(); + s.execute("layer.setProps", json!({"layers": [ids[0], ids[2]], "opacity": 0.4})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one step"); + assert_eq!(last_step(&s), "Opacity Change"); + assert_eq!((layer(&s, ids[0]).opacity, layer(&s, ids[1]).opacity, layer(&s, ids[2]).opacity), (0.4, 1.0, 0.4)); + s.execute("layer.setProps", json!({"layers": ids, "blend": "Multiply"})).unwrap(); + assert_eq!(last_step(&s), "Blending Change"); + assert!(ids.iter().all(|id| layer(&s, *id).blend == photocraft_color::BlendMode::Multiply)); + s.execute("layer.setProps", json!({"layers": [ids[1], ids[1], ids[2]], "locks": {"all": true}})).unwrap(); + assert!(layer(&s, ids[1]).locks.all && layer(&s, ids[2]).locks.all && !layer(&s, ids[0]).locks.all, "duplicates are harmless"); + s.execute("layer.setProps", json!({"layers": [ids[0]], "fill": 0.5})).unwrap(); + assert_eq!(last_step(&s), "Fill Opacity Change"); + s.execute("layer.setProps", json!({"layer": ids[0], "name": "Sky"})).unwrap(); + assert_eq!(last_step(&s), "Rename Layer"); + s.execute("layer.setProps", json!({"layer": ids[0], "opacity": 0.3, "fill": 0.3})).unwrap(); + assert_eq!(last_step(&s), "Layer Properties", "a mix keeps the general name"); +} + +#[test] +fn bad_layer_lists_change_nothing() { + let (mut s, ids) = doc(); + let before = s.active().unwrap().doc.clone(); + for p in [ + json!({"layers": [], "opacity": 0.5}), + json!({"layers": "all", "opacity": 0.5}), + json!({"layers": ["x"], "opacity": 0.5}), + json!({"layers": [ids[0], 999_999], "opacity": 0.5}), + json!({"layers": [ids[0], ids[1]], "name": "Both"}), + json!({"layers": [ids[0], ids[1]], "opacity": 2.0}), + ] { + assert!(s.execute("layer.setProps", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().doc.layers, before.layers, "{p}"); + } +} + +#[test] +fn pass_through_is_for_groups_only() { + let (mut s, ids) = doc(); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + // A pixel layer, alone or with a group, can't take Pass Through; nothing changes. + assert!(s.execute("layer.setProps", json!({"layer": ids[0], "blend": "Pass Through"})).is_err()); + let before = (layer(&s, ids[0]).blend, layer(&s, g).blend); + assert!(s.execute("layer.setProps", json!({"layers": [g, ids[0]], "blend": "Pass Through"})).is_err()); + assert_eq!((layer(&s, ids[0]).blend, layer(&s, g).blend), before); + s.execute("layer.setProps", json!({"layers": [g], "blend": "Normal"})).unwrap(); + s.execute("layer.setProps", json!({"layer": g, "blend": "Pass Through"})).unwrap(); + assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough); +} diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index e9f010613a..a466e2f375 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -1,5 +1,5 @@ -//! Layers panel: hovering a mode in the open blend-mode list previews it on the active layer -//! (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or +//! Layers panel: hovering a mode in the open blend-mode list previews it on the layers it would +//! change, the active one or every selected one (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or //! closing it without a choice, shows the document again, and choosing a mode commits it through //! `layer.setProps` (one history step). The canvas re-renders once per hovered mode, and only //! where the layer's blending can change pixels. @@ -15,17 +15,17 @@ use crate::PhotocraftApp; /// Preview keys (`canvas::display_doc`) are `BASE` plus a hash of the revision and the mode. const BASE: u64 = 1 << 37; -/// A blend mode hovered for a layer of a document at a revision. +/// A blend mode hovered for layers of a document at a revision. pub(crate) struct BlendPreview { doc: DocId, revision: u64, - layer: LayerId, + layers: Vec, /// The mode under the pointer; `None` once it left the list (the rest stays for the /// canvas's damage on its way back to the document). mode: Option, /// The frame the list was last drawn in: a list no longer drawn previews nothing. frame: u64, - /// Where the layer's blending can change pixels; `None` for anywhere. + /// Where the layers' blending can change pixels; `None` for anywhere. area: Option, /// The document shown for `mode`, with its key. shown: Option<(u64, Arc)>, @@ -33,11 +33,11 @@ pub(crate) struct BlendPreview { /// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the /// pointer in its open list, if any. -pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option) { +pub(crate) fn hover(app: &mut PhotocraftApp, layers: &[LayerId], mode: Option) { let frame = app.frame; let Some(st) = app.session.active() else { return }; let (doc, revision) = (st.doc.id, st.revision); - if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layer == layer) { + if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layers == layers) { p.frame = frame; if mode.is_none() { p.shown = None; @@ -46,8 +46,15 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option = Some(Rect::EMPTY); + for &id in layers { + area = match (area, photocraft_compose::change_bounds(st.doc.layer(id)?, st.doc.bounds())) { + (Some(a), Some(b)) => Some(if a.is_empty() { b } else { a.union(&b) }), + _ => None, + }; + } + Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None }) }); } @@ -57,15 +64,23 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc= frame)?; let mode = p.mode?; - let key = preview_key(p.revision, p.layer, mode); + let key = preview_key(p.revision, &p.layers, mode); if let Some((k, shown)) = &p.shown && *k == key { return Some((shown.clone(), key)); } let mut doc = (*st.doc).clone(); - let l = doc.layer_mut(p.layer).filter(|l| l.blend != mode)?; - l.blend = mode; + let mut changed = false; + for &id in &p.layers { + if let Some(l) = doc.layer_mut(id).filter(|l| l.blend != mode) { + l.blend = mode; + changed = true; + } + } + if !changed { + return None; + } // Duotone documents display through their inks. let doc = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc)); p.shown = Some((key, doc.clone())); @@ -73,7 +88,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc Option { let p = app.blend_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?; @@ -85,8 +100,9 @@ fn is_preview_key(key: u64) -> bool { (BASE..BASE << 1).contains(&key) } -fn preview_key(revision: u64, layer: LayerId, mode: BlendMode) -> u64 { - let h = mode.label().bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b))); +fn preview_key(revision: u64, layers: &[LayerId], mode: BlendMode) -> u64 { + let seed = layers.iter().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, id| h.wrapping_mul(0x100_0000_01b3) ^ id.0); + let h = mode.label().bytes().fold(seed, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b))); (h & (BASE - 1)) | BASE } diff --git a/crates/ui-egui/src/blend_preview/tests.rs b/crates/ui-egui/src/blend_preview/tests.rs index 4eb407aad0..22908ac248 100644 --- a/crates/ui-egui/src/blend_preview/tests.rs +++ b/crates/ui-egui/src/blend_preview/tests.rs @@ -36,7 +36,7 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() { let (s, top) = session(); let mut app = PhotocraftApp::new(s, crate::Services::default()); let (undo, revision) = (steps(&app), app.session.active().unwrap().revision); - hover(&mut app, top, Some(BlendMode::Multiply)); + hover(&mut app, &[top], Some(BlendMode::Multiply)); let (shown, key) = display_doc(&mut app, 0).expect("previewed"); assert_eq!(shown.layer(top).unwrap().blend, BlendMode::Multiply); let (again, same) = display_doc(&mut app, 0).unwrap(); @@ -45,17 +45,17 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() { // From the document to the preview and between previews: only the layer's area redraws. let doc = app.session.active().unwrap().doc.id; assert_eq!(damage(&app, doc, revision, 0, key).map(|r| r.width()), Some(16)); - hover(&mut app, top, Some(BlendMode::Screen)); + hover(&mut app, &[top], Some(BlendMode::Screen)); let (_, screen) = display_doc(&mut app, 0).unwrap(); assert_ne!(screen, key); assert!(damage(&app, doc, revision, key, screen).is_some()); // The current mode, or no mode, shows the document; and so does a list no longer drawn. - hover(&mut app, top, Some(BlendMode::Normal)); + hover(&mut app, &[top], Some(BlendMode::Normal)); assert!(display_doc(&mut app, 0).is_none()); - hover(&mut app, top, None); + hover(&mut app, &[top], None); assert!(display_doc(&mut app, 0).is_none()); assert!(damage(&app, doc, revision, screen, 0).is_some(), "back to the document: the layer's area"); - hover(&mut app, top, Some(BlendMode::Multiply)); + hover(&mut app, &[top], Some(BlendMode::Multiply)); app.frame += 2; assert!(display_doc(&mut app, 0).is_none(), "a stale hover previews nothing"); } @@ -124,3 +124,17 @@ fn moving_between_modes_keeps_a_preview_across_the_gaps_between_them() { } assert_eq!(previewed(&mut h), Some(BlendMode::Multiply)); } + +#[test] +fn a_hovered_mode_previews_on_every_selected_layer() { + let (mut s, top) = session(); + let other = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + hover(&mut app, &[top, other], Some(BlendMode::Multiply)); + let (shown, key) = display_doc(&mut app, 0).expect("previewed"); + assert_eq!((shown.layer(top).unwrap().blend, shown.layer(other).unwrap().blend), (BlendMode::Multiply, BlendMode::Multiply)); + // A different set of layers is a different preview. + hover(&mut app, &[top], Some(BlendMode::Multiply)); + assert_ne!(display_doc(&mut app, 0).unwrap().1, key); +} diff --git a/crates/ui-egui/src/layer_props_ui_tests.rs b/crates/ui-egui/src/layer_props_ui_tests.rs new file mode 100644 index 0000000000..93055ee342 --- /dev/null +++ b/crates/ui-egui/src/layer_props_ui_tests.rs @@ -0,0 +1,78 @@ +//! The Layers panel's blend mode, Opacity, Fill and lock controls change every selected layer, +//! as in Photoshop, not only the active one. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; + +fn app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }); + h.run_steps(6); + h +} + +/// The Layers panel's lock buttons change every selected layer (as do blend mode, Opacity and +/// Fill), not only the active one. +#[test] +fn the_layers_panel_locks_every_selected_layer() { + let mut h = app(); + let ids: Vec = h.state().session.active().unwrap().doc.layers.iter().map(|l| l.id.0).collect(); + let extra = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().run("layer.select", json!({"layer": ids[1], "mode": "add"})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().selected_layers().len(), 2); + h.query_by_label_contains("Lock all").expect("the Lock all button").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all; + assert!(locked(extra) && locked(ids[1]), "both selected layers lock"); + assert!(!locked(ids[0]), "the unselected Background doesn't"); +} + +/// The Basic layout's single lock toggle also locks every selected layer. +#[test] +fn the_basic_lock_toggle_locks_every_selected_layer() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::Studio); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": crate::theme::ThemeKind::Studio.id()})).unwrap(); + app.ui.theme = crate::theme::ThemeKind::Studio; + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app + }); + let a = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().run("layer.select", json!({"layer": a, "mode": "add"})).unwrap(); + h.run_steps(4); + h.get_by_label("Lock layer").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all; + assert!(locked(a) && locked(b), "both selected layers lock"); +} + +/// The Background, selected with other layers, is left out of their change (Opacity here) +/// until Photoshop's behaviour is confirmed (#5856): it stays the Background. +#[test] +fn a_selected_background_is_left_out_of_a_several_layer_change() { + let mut h = app(); + let st = h.state().session.active().unwrap(); + let (bg, top) = (st.doc.layers[0].id.0, st.active_layer.unwrap().0); + h.state_mut().run("layer.select", json!({"layer": bg})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": top, "mode": "add"})).unwrap(); + h.run_steps(3); + h.get_by_label("Lock all").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let l = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().clone(); + assert!(l(top).locks.all, "the layer locks"); + assert!(st.doc.is_background(photocraft_doc::LayerId(bg)) && !l(bg).locks.all, "the Background is left as it was"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d95f2d662..3a981c41f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2638,6 +2638,8 @@ mod pixel_aspect_tests; #[cfg(test)] mod transform_fx_tests; +#[cfg(test)] +mod layer_props_ui_tests; #[cfg(test)] mod transform_undo_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 522c74dc15..ed35a68804 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1518,8 +1518,9 @@ const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W; /// The `layer.setProps` edit of a Layers panel Opacity or Fill (`key`) field at `pct` percent. A /// drag of the pop-up slider (`drag`) coalesces into one history step, as in Photoshop. -fn pct_action(l: &Layer, key: &str, pct: f32, drag: Option) -> (String, Value) { - let mut p = json!({"layer": l.id.0, key: pct / 100.0}); +fn pct_action(target: &Value, key: &str, pct: f32, drag: Option) -> (String, Value) { + let mut p = target.clone(); + p[key] = json!(pct / 100.0); if let Some(n) = drag { p["coalesce"] = json!(format!("layer-{key}-slider:{n}")); } @@ -2159,13 +2160,17 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// any is set, and locks all otherwise. Toggling only "lock all" would leave the Background's /// transparency lock (still showing as locked), and a second click would lock its pixels, /// shutting out the Eraser and every other paint tool (#76). -fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) { +/// The Basic layout's lock toggle: the active layer's state decides, and every target follows it +/// (`targets`: `{"layer": id}` or `{"layers": [ids]}`). +fn simple_lock_toggle(background: bool, l: &Layer, targets: &Value) -> (String, Value) { if background { return ("layer.new.layerFromBackground".into(), json!({})); } let locks = if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; - ("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks})) + let mut p = targets.clone(); + p["locks"] = locks; + ("layer.setProps".into(), p) } /// The Layers panel's blend modes: Photoshop's, plus the layer's own Paint.NET mode when it has one @@ -2231,8 +2236,21 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(4.0); } if let Some(l) = active_layer { - // Photoshop greys blend mode, Opacity and Fill for the Background layer. + // With several layers selected, blend mode, Opacity, Fill and the locks change every one + // of them, as in Photoshop; the controls show the active layer's values. + // The Background stays out of a several-layer change until Photoshop's behaviour is + // confirmed (#5856): Opacity, Fill or a blend mode would turn it into Layer 0. + let ids: Vec = if selection.len() > 1 && selection.contains(&l.id) { + selection.iter().copied().filter(|id| *id == l.id || !doc.layer(*id).is_some_and(|x| crate::doc_props_ui::is_background(&doc, x))).collect() + } else { + vec![l.id] + }; + let targets = if ids.len() > 1 { json!({"layers": ids.iter().map(|id| id.0).collect::>()}) } else { json!({"layer": l.id.0}) }; + // Photoshop greys blend mode, Opacity and Fill for the Background layer. (With the + // Background among several selected, what Photoshop does awaits a check: #5856.) let bg = crate::doc_props_ui::is_background(&doc, l); + // Pass Through is a group's mode: offered only when every target is a group. + let all_groups = ids.iter().all(|id| doc.layer(*id).is_some_and(|x| x.is_group())); ui.horizontal(|ui| { ui.add_enabled_ui(!bg, |ui| { let mut m = l.blend; @@ -2242,13 +2260,15 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") }; 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); + let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(all_groups, 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()}))); + let mut p = targets.clone(); + p["blend"] = json!(m.label()); + actions.push(("layer.setProps".into(), p)); } // Hovering a mode previews it on the canvas (#970). - crate::blend_preview::hover(app, l.id, hovered.filter(|_| chosen.is_empty())); + crate::blend_preview::hover(app, &ids, 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 field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); @@ -2259,7 +2279,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::PopupFieldResponse::default() }; if field.changed || scrub.changed { - actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag))); + actions.push(pct_action(&targets, "opacity", o, scrub.drag.or(field.drag))); } }); }); @@ -2295,7 +2315,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892). let enabled = key != "transparency" || !lk.pixels; if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() { - actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": { key: !on }}))); + let mut p = targets.clone(); + p["locks"] = json!({ key: !on }); + actions.push(("layer.setProps".into(), p)); } } }); @@ -2303,7 +2325,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !t.pro { let locked = l.locks.any(); if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() { - actions.push(simple_lock_toggle(bg, l)); + actions.push(simple_lock_toggle(crate::doc_props_ui::is_background(&doc, l), l, &targets)); } } ui.add_enabled_ui(!bg, |ui| { @@ -2312,7 +2334,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 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, ui.available_width()); if field.changed || scrub.changed { - actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag))); + actions.push(pct_action(&targets, "fill", f, scrub.drag.or(field.drag))); } }) }); @@ -2877,7 +2899,7 @@ fn layer_row( if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock) && ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked() { - actions.push(simple_lock_toggle(doc.is_background(l.id), l)); + actions.push(simple_lock_toggle(doc.is_background(l.id), l, &json!({"layer": l.id.0}))); } let name_color = if l.visible { t.text } else { t.text_faint }; let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) }; @@ -3139,6 +3161,8 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let icon = match e.as_str() { s if s.contains("Brush") || s.contains("Eraser") => "brush", + // The Layers panel's controls (layer.setProps). + "Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers", s if s.contains("Layer") => "layers", s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed", s if s.contains("Fill") => "paint-bucket", @@ -4992,7 +5016,7 @@ mod lock_tests { fn click_lock(app: &mut PhotocraftApp) { let st = app.session.active().unwrap(); let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap(); - let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l); + let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l, &json!({"layer": l.id.0})); app.run(&cmd, p).unwrap(); } From b09aeae2e2d149f62a4e261250cd0cc129d1fb0b Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 15:23:47 -0700 Subject: [PATCH 040/171] Low-Latency Drawing preference (vsync off, opt-in), plus present-mode overrides for testers (#1343) (#5274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PHOTOCRAFT_PRESENT_MODE / PHOTOCRAFT_FRAME_LATENCY launch overrides for latency A/B tests (#1343) Linux traces put the remaining stroke latency in presentation queueing (the app's frame fits in one refresh; the rest waits on the swap chain), and an NVIDIA user cured it by forcing vsync off. Testers can now compare without rebuilding: PHOTOCRAFT_PRESENT_MODE (fifo, mailbox, immediate, autovsync, autonovsync) and PHOTOCRAFT_FRAME_LATENCY (1-3), applied over the platform default. Bad values are logged and ignored. The active mode is logged at start, kept in GpuInfo.present and shown in every stroke trace line. RTX 5090, Vulkan, 120 Hz: default autovsync/latency 1 waits 6 ms per 8.3 ms frame; mailbox renders every 2.3 ms with 0.2 ms wait, so each vsync shows the newest frame. * docs: present mode and frame latency overrides * Present mode overrides never request a mode the driver may lack (Mailbox crashed on X11/NVIDIA) * Preferences › Performance › Low-Latency Drawing, on by default: no vsync wait while drawing (#1343) Testers' traces put the ~50 ms stroke lag in the swap chain's vsync wait. With vsync off it disappears (X11/NVIDIA; Windows/NVIDIA via the driver), and a shorter queue doesn't help. The surface now presents with AutoNoVsync by default: wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers, so no unsupported mode is ever requested. Turning the preference off brings vsync back (smooth, but late). It applies at the next launch, like the other graphics settings. Where only Immediate exists the canvas may tear while it changes; frames render as fast as input arrives during a stroke and stop when idle. * Runtime and launch controls: ui.set strokeTrace, --stroke-trace, --present-mode, --frame-latency The retro's remaining gap: the stroke trace and the presentation overrides were env-only. The trace now switches at runtime over the control channel (ui.set {"strokeTrace": true|false|"stderr"|path}) and with --stroke-trace; --present-mode / --frame-latency sit beside their env vars and win over them. Bad values are reported and ignored; none fails a launch. * docs: strokeTrace beside the other ui.set fields * docs: regenerate the scorecard after rebasing onto main * Low-Latency Drawing: frames capped at 240 fps without vsync (after #4472); ui.set strokeTrace takes on/off only (a CSV path is launch-only); tearing documented as usual (Immediate); pref hidden on web; doc comment order * stroke_trace: from_value doc says ui.set passes only on or off * Low-Latency Drawing ships off (opt-in), per the owner's decision * docs: present mode default is vsync (Low-Latency Drawing ships off) --- apps/photocraft/src/gpu_startup.rs | 25 ++++++ apps/photocraft/src/main.rs | 42 +++++++++- apps/photocraft/src/presentation.rs | 121 ++++++++++++++++++++++++++++ crates/engine/src/prefs.rs | 8 ++ crates/ui-egui/src/control.rs | 39 ++++++++- crates/ui-egui/src/gpu_canvas.rs | 3 + 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 | 28 ++++++- crates/ui-egui/src/prefs_ui.rs | 7 ++ crates/ui-egui/src/stroke_trace.rs | 38 ++++++++- docs/control-protocol.md | 1 + docs/development.md | 6 +- docs/scorecard.md | 4 +- 32 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 apps/photocraft/src/presentation.rs diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 66acb9a42f..e79b4bafac 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -443,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin .unwrap_or_default() } +/// 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 { + 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); + let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing; + v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default) +} + /// Read the policy leniently at startup, including old settings without renderingMode. pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) { 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); @@ -463,6 +471,23 @@ mod tests { Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() } } + #[test] + fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() { + let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", 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_low_latency(Some(&file)) + }; + assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#)); + assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#)); + assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)"); + assert!(!read("not json")); + assert!(!read_low_latency(None), "no settings file: the default (off)"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn display_server_preference_is_read_before_the_window_opens() { use photocraft_engine::prefs::LinuxDisplayServer; diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index de1155edbd..0b82237a39 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -2,7 +2,13 @@ //! //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] -//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [files…]` +//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [--present-mode ] +//! [--frame-latency ] [--stroke-trace ] [files…]` +//! +//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how +//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`; +//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`; +//! `photocraft_ui_egui::stroke_trace`). //! //! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window //! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that @@ -44,6 +50,7 @@ mod linux_libs; mod logging; mod monitor_profile; mod photoshop_settings; +mod presentation; mod screen_color; mod services; // Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener. @@ -191,6 +198,7 @@ fn main() -> eframe::Result { // 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 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() { @@ -211,6 +219,18 @@ fn main() -> eframe::Result { Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from), Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from), Some("--safe-gpu") => safe_gpu = true, + Some(flag @ ("--present-mode" | "--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") => in_window_menus = true, Some("--version") => { println!("photocraft {}", photocraft_engine::build_info::long_version()); @@ -364,6 +384,21 @@ 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()); + // 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()) { + options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync; + } + // Testers' present mode and frame latency overrides (#1343), after the preference. + let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref()); + options.wgpu_options.surface = present.apply(options.wgpu_options.surface); + let present_desc = presentation::describe(&options.wgpu_options.surface, &present); + // Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out. + let uncapped = !matches!( + options.wgpu_options.surface.present_mode, + eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync + ); + log::info!("presentation: {present_desc}"); let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0; log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan); let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu; @@ -392,6 +427,7 @@ fn main() -> eframe::Result { } } let mut app = PhotocraftApp::new(Session::new(), services); + app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = custom_titlebar; // Only the title bar's free gap drags the window, never the menus (mac_window.rs). @@ -423,6 +459,10 @@ fn main() -> eframe::Result { // composites on the CPU). let info = &mut app.perf.gpu_info; info.preference = pref.name().to_string(); + info.present = present_desc.clone(); + if let Some(v) = &stroke_trace_arg { + app.stroke_trace.set(v); + } info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() }; info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) { (Some(reason), Some(note)) => Some(format!("{reason}. {note}")), diff --git a/apps/photocraft/src/presentation.rs b/apps/photocraft/src/presentation.rs new file mode 100644 index 0000000000..c6f7e4196a --- /dev/null +++ b/apps/photocraft/src/presentation.rs @@ -0,0 +1,121 @@ +//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel +//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`, +//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo +//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may +//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a +//! warning in the log, never a failed start; a mode the driver may not offer is never requested. +//! The result is logged at start and shown in the stroke trace. + +use eframe::egui_wgpu::SurfaceConfig; +use eframe::wgpu::PresentMode; + +/// The present mode and frame latency this launch asked for (`None`: the default). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Overrides { + pub mode: Option, + pub latency: Option, +} + +/// Parse the two variables' values. Returns the overrides and a warning per rejected value. +pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec) { + let mut warnings = Vec::new(); + let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| { + let m = match v.to_ascii_lowercase().as_str() { + // Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or + // FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without + // vsync, wgpu picks Immediate, else Mailbox, else Fifo. + "fifo" | "vsync" => PresentMode::Fifo, + "autovsync" => PresentMode::AutoVsync, + "novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync, + _ => { + warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored")); + return None; + } + }; + Some(m) + }); + let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::() { + Ok(n @ 1..=3) => Some(n), + _ => { + warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored")); + None + } + }); + (Overrides { mode, latency }, warnings) +} + +impl Overrides { + /// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` / + /// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged. + pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self { + let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok(); + let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok(); + let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref())); + for w in warnings { + log::warn!("{w}"); + } + o + } + + /// `surface` with the overrides applied. + pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig { + SurfaceConfig { + present_mode: self.mode.unwrap_or(surface.present_mode), + desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency), + } + } +} + +/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with +/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency. +pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String { + let mode = match surface.present_mode { + PresentMode::AutoVsync => "autovsync", + PresentMode::AutoNoVsync => "autonovsync", + PresentMode::Fifo => "fifo", + PresentMode::FifoRelaxed => "fiforelaxed", + PresentMode::Immediate => "immediate", + PresentMode::Mailbox => "mailbox", + }; + let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string()); + let tag = |set: bool| if set { " (override)" } else { "" }; + format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_parse_and_bad_ones_are_ignored_with_a_warning() { + assert_eq!(parse(None, None), (Overrides::default(), vec![])); + assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![])); + let (o, w) = parse(Some(" Mailbox "), Some("1")); + assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0)); + assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo)); + // Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support. + for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] { + assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}"); + } + for (mode, latency) in + [(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))] + { + let (o, w) = parse(mode, latency); + assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}"); + } + } + + #[test] + fn overrides_replace_only_what_they_set_and_are_described() { + let default = SurfaceConfig::HIGH_THROUGHPUT; + assert_eq!(Overrides::default().apply(default), default); + let latency_only = Overrides { mode: None, latency: Some(1) }; + let s = latency_only.apply(default); + assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1))); + assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)"); + let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None }; + assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2"); + let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None }; + assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default"); + } +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c732daeefd..838e6fe25e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -529,6 +529,13 @@ pub struct Performance { /// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off: /// they render at full resolution. pub low_resolution_previews: bool, + /// Low-latency drawing (applies at next launch): frames reach the screen without waiting for + /// vsync, so a stroke follows the pen about 50 ms sooner (the swap chain's wait, #1343). wgpu + /// picks Immediate where the driver has it (most Vulkan and DX12 drivers on Windows and X11), + /// so the canvas usually tears while it changes; Mailbox (no tearing) only where Immediate is + /// missing. Frames are capped at 240 fps. Off (the default): vsync, smooth but late. Desktop + /// only. + pub low_latency_drawing: bool, /// Linux display server (applies at next launch; see [`LinuxDisplayServer`]). pub linux_display_server: LinuxDisplayServer, /// Memory budget of the layer-effect cache, in MB. @@ -560,6 +567,7 @@ impl Default for Performance { rendering_mode: None, gpu_backend: GpuBackend::Auto, low_resolution_previews: true, + low_latency_drawing: false, linux_display_server: LinuxDisplayServer::Auto, effect_cache_mb: 768, legacy_compositing: false, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ff786be197..8cddc60b23 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -131,8 +131,9 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 37] = [ +pub const UI_SET_FIELDS: [&str; 38] = [ "tool", + "strokeTrace", "panels", "dock", "dockTabs", @@ -492,6 +493,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) None => None, }; let gradient_classic = bool_field(p, "gradientClassic")?; + // The stroke latency trace (`stroke_trace`), at runtime: on (stderr) or off. A CSV + // file is only for the user's own launch (`--stroke-trace `): a path over the + // control channel would write outside the automation write root. + let stroke_trace = match p.get("strokeTrace") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(if *b { "stderr" } else { "off" }), + Some(Value::String(v)) => match v.trim() { + "stderr" | "on" | "true" => Some("stderr"), + "off" | "false" => Some("off"), + _ => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"; a CSV file is set at launch (--stroke-trace )".into()), + }, + Some(_) => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"".into()), + }; // The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring. let eyedropper_size = match p.get("eyedropperSampleSize") { Some(v) => Some( @@ -663,6 +677,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(mode) = gradient_blend { app.ui.tool_options.gradient_blend_mode = mode; } + if let Some(v) = &stroke_trace { + app.stroke_trace.set(v); + } if let Some(classic) = gradient_classic { app.ui.tool_options.gradient_classic = classic; } @@ -1385,6 +1402,26 @@ mod tests { } } + #[test] + fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert!(!app.stroke_trace.enabled()); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": true}))["ok"], true); + assert!(app.stroke_trace.enabled()); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": "off"}))["ok"], true); + assert!(!app.stroke_trace.enabled()); + let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": 3, "tool": "Pencil"})); + assert_eq!(bad["ok"], false); + // A file path isn't accepted over the channel (it would write outside the automation + // write root); nothing is created. + let path = std::env::temp_dir().join(format!("photocraft-trace-{}.csv", std::process::id())); + let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": path.to_str().unwrap(), "tool": "Pencil"})); + assert_eq!(bad["ok"], false); + assert!(!path.exists()); + assert!(!app.stroke_trace.enabled() && app.ui.tool != crate::state::Tool::Pencil, "a rejected call applies nothing"); + } + #[test] fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() { use photocraft_raw::testgen::{DngSpec, mosaic, scene}; diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 2e583e8e5b..391b0b64a3 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -945,6 +945,9 @@ pub struct GpuInfo { pub canvas: String, /// Why the GPU canvas was dropped during this session (device lost or a GPU error). pub lost: Option, + /// Present mode and frame latency ("fifo, latency 2"; launch overrides marked, see the + /// app's `presentation`). Empty in hosts that don't set it. + pub present: String, } impl GpuInfo { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 376757e866..1329378dc2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3804,3 +3804,4 @@ navigator Zoom Zvětšení Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho Undo Changes Vrátit změny {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í) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 59a9d470cd..5f56421233 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3198,3 +3198,4 @@ curves Smooth Glätten Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen Undo Changes Änderungen rückgängig machen {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) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a2e6ddc7c..715e41cb1f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3896,3 +3896,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} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί. + Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2069b068e5..4a0f1c8556 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4158,3 +4158,4 @@ navigator Zoom Zoom Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo Undo Changes Deshacer cambios {shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar. + Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d528f1e7ce..2402aec160 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3201,3 +3201,4 @@ curves Smooth Lisser Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner Undo Changes Annuler les modifications {shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué. + Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e9f3d33907..343dcadc0c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4115,3 +4115,4 @@ cameraRaw Yellow Sárga Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése Undo Changes Változások visszavonása {shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá. + Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 141f956c8e..e4ba61e1a1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3188,3 +3188,4 @@ curves Smooth Haluskan Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu Undo Changes Batalkan Perubahan {shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan. + Low-Latency Drawing (vsync off, applies at next launch) Menggambar latensi rendah (tanpa vsync, berlaku saat peluncuran berikutnya) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 714932312e..6b933287c6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4317,3 +4317,4 @@ shape Appearance Aspetto Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo Undo Changes Annulla modifiche {shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata. + Low-Latency Drawing (vsync off, applies at next launch) Disegno a bassa latenza (senza vsync, si applica al prossimo avvio) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a78fe8e7ca..2f6b7d7f5e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3872,3 +3872,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} はシステムで予約されているため割り当てられません。 + Low-Latency Drawing (vsync off, applies at next launch) 低遅延描画(垂直同期オフ、次回起動時に適用) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2e12c33799..3c056c627b 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}은(는) 시스템에서 예약되어 할당할 수 없습니다. + Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 32df1e20a2..c80f86bf81 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3956,3 +3956,4 @@ curves Smooth Vloeiend maken Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren Undo Changes Wijzigingen ongedaan maken {shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen. + Low-Latency Drawing (vsync off, applies at next launch) Tekenen met lage latentie (zonder vsync, na de volgende start) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8b571ff7d6..85df6b8157 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3794,3 +3794,4 @@ curves Smooth Wygładź Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je Undo Changes Cofnij zmiany {shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać. + Low-Latency Drawing (vsync off, applies at next launch) Rysowanie z małym opóźnieniem (bez synchronizacji pionowej, od następnego uruchomienia) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8e6dcc4993..211442ba20 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3986,3 +3986,4 @@ layerLabel No Color Sem cor Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo Undo Changes Desfazer alterações {shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído. + Low-Latency Drawing (vsync off, applies at next launch) Desenho de baixa latência (sem sincronização vertical, aplicado na próxima inicialização) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6c80c470cf..9dfcc85093 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3166,3 +3166,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} зарезервирована системой и не может быть назначена. + Low-Latency Drawing (vsync off, applies at next launch) Рисование с малой задержкой (без вертикальной синхронизации, после перезапуска) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9b30313e22..bbc0ef528d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4269,3 +4269,4 @@ shape Appearance Izgled Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je Undo Changes Poništi izmene {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti. + Low-Latency Drawing (vsync off, applies at next launch) Crtanje sa malim kašnjenjem (bez vsync-a, primenjuje se pri sledećem pokretanju) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8e20ad53dc..b63bdd7349 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3926,3 +3926,4 @@ cameraData Lens Lens Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç Undo Changes Değişiklikleri Geri Al {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz. + Low-Latency Drawing (vsync off, applies at next launch) Düşük gecikmeli çizim (dikey eşitleme kapalı, sonraki açılışta geçerli) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9fcf5e85bc..923ec694af 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3895,3 +3895,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} зарезервовано системою, його не можна призначити. + Low-Latency Drawing (vsync off, applies at next launch) Малювання з малою затримкою (без вертикальної синхронізації, після перезапуску) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 515966c515..be40230ca3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3682,3 +3682,4 @@ curves Smooth Làm mượt Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó Undo Changes Hoàn tác thay đổi {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) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cbf3c9d08f..7c563780db 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} 由系统保留,无法指定。 + Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 30aa3f5a94..9376cb05bb 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} 由系統保留,無法指定。 + Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a981c41f3..34b346af00 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -800,6 +800,12 @@ pub struct PhotocraftApp { /// `jobs_ui`). The desktop app turns it on; off (the default), everything runs inline as /// before, which tests and scripts rely on. pub background_jobs: bool, + /// Shortest time between frames (ms), or `None` for no cap. A present mode that doesn't wait + /// for the display (Low-Latency Drawing) would otherwise redraw as fast as it can while + /// anything keeps requesting repaints (a drag hovering, autoscroll, Liquify held still). + /// After #4472 (astigmata). + pub min_frame_ms: Option, + last_frame_start_ms: f64, /// Background job bookkeeping: opening tabs, control replies waiting on a job. pub jobs: jobs_ui::JobsUi, #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] @@ -935,6 +941,8 @@ impl PhotocraftApp { allow_close: false, stylus: Default::default(), background_jobs: false, + min_frame_ms: None, + last_frame_start_ms: 0.0, jobs: Default::default(), #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] live_tokens: theme::live::LiveTokens::from_env(), @@ -1759,6 +1767,20 @@ impl PhotocraftApp { } } +impl PhotocraftApp { + /// Wait out the rest of [`Self::min_frame_ms`] since the last frame started. + fn cap_frame_rate(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(min) = self.min_frame_ms.filter(|m| m.is_finite() && *m > 0.0) { + let wait = min.min(100.0) - (gpu_canvas::now_ms() - self.last_frame_start_ms); + if wait > 0.0 { + std::thread::sleep(std::time::Duration::from_secs_f64(wait / 1000.0)); + } + } + self.last_frame_start_ms = gpu_canvas::now_ms(); + } +} + impl eframe::App for PhotocraftApp { fn on_exit(&mut self) { if let Err(err) = tool_memory::save_on_exit(self) { @@ -1767,6 +1789,7 @@ impl eframe::App for PhotocraftApp { } fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + self.cap_frame_rate(); i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); // Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas // cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`. @@ -2004,7 +2027,10 @@ impl eframe::App for PhotocraftApp { if self.stroke_trace.enabled() { self.stroke_trace.canvas(self.perf.frame_refresh_ms); let info = &self.perf.gpu_info; - self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || format!("{} {}", info.backend, info.adapter).trim().to_string()); + self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || { + let gpu = format!("{} {}", info.backend, info.adapter).trim().to_string(); + if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) } + }); } // Synthetic input is injected one press/release step per frame: keep frames coming until // the queue is empty, then release control replies waiting on it. diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 99750ae7f1..795ea84cea 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1120,6 +1120,11 @@ fn humanize(key: &str) -> String { if key == "nudgeBundlePauseMs" { return "Nudge Bundle Pause (ms)".into(); } + // Performance: presentation without vsync (#1343); like the other graphics settings, it + // takes effect when the window next opens. + if key == "lowLatencyDrawing" { + return "Low-Latency Drawing (vsync off, applies at next launch)".into(); + } let mut s = String::new(); for (i, ch) in key.chars().enumerate() { if i == 0 { @@ -1556,6 +1561,8 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) // View › Rulers (⌘R) sets it, as in Photoshop. || path == "unitsAndRulers.showRulers" || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "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)) || (section == "tools" && !tools_field_visible(obj, k)); (query.is_empty() || preference_matches(section, k, query)) diff --git a/crates/ui-egui/src/stroke_trace.rs b/crates/ui-egui/src/stroke_trace.rs index 206cc7e81a..809414ed49 100644 --- a/crates/ui-egui/src/stroke_trace.rs +++ b/crates/ui-egui/src/stroke_trace.rs @@ -113,13 +113,32 @@ impl StrokeTrace { /// Reads `PHOTOCRAFT_STROKE_TRACE` (off on the web, where there is no clock). pub fn from_env() -> Self { #[cfg(not(target_arch = "wasm32"))] - if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE").filter(|v| !v.is_empty()) { - let output = if v == "1" || v == "stderr" { Output::Stderr } else { Output::Csv(v.into()) }; - return Self { output, ..Default::default() }; + if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE") { + return Self::from_value(&v.to_string_lossy()); } Self::default() } + /// A trace set by value: `""`, `"0"` or `"off"` is off, `"1"` or `"stderr"` prints + /// summaries, anything else is a CSV path (`--stroke-trace`, `PHOTOCRAFT_STROKE_TRACE`; the + /// control channel's `ui.set {"strokeTrace"}` passes only on or off, never a path). + pub fn from_value(v: &str) -> Self { + let v = v.trim(); + let output = match v { + _ if cfg!(target_arch = "wasm32") => Output::Off, + "" | "0" | "off" | "false" => Output::Off, + "1" | "stderr" | "true" => Output::Stderr, + path => Output::Csv(path.into()), + }; + Self { output, ..Default::default() } + } + + /// Switch the output, keeping the last summary. + pub fn set(&mut self, v: &str) { + let last = self.last.take(); + *self = Self { last, ..Self::from_value(v) }; + } + pub fn enabled(&self) -> bool { self.output != Output::Off } @@ -265,6 +284,19 @@ mod tests { assert!(off.last.is_none() && off.rows.is_empty()); } + #[test] + fn a_trace_is_set_by_value() { + assert_eq!(StrokeTrace::from_value("off").output, Output::Off); + assert_eq!(StrokeTrace::from_value(" 0 ").output, Output::Off); + assert_eq!(StrokeTrace::from_value("stderr").output, Output::Stderr); + assert_eq!(StrokeTrace::from_value("true").output, Output::Stderr); + assert_eq!(StrokeTrace::from_value("t.csv").output, Output::Csv("t.csv".into())); + let mut t = on(); + t.last = Some(Summary::default()); + t.set("off"); + assert!(!t.enabled() && t.last.is_some(), "turning it off keeps the last summary"); + } + #[test] fn csv_rows_and_odd_numbers() { let rows = [Row { t: 1.0, interval: 16.5, moves: 2, stroke: 0.25, canvas: 1.0, ui: 3.0, cpu_prev: None }]; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8536507104..2bbcab2469 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -53,6 +53,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. +- `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root - `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. - 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. diff --git a/docs/development.md b/docs/development.md index 3e7f5fb66e..76b4a2e31c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -119,7 +119,9 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `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). | | `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild | -| `PHOTOCRAFT_STROKE_TRACE=1` (or `=`) | 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_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_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | ### Live design tokens @@ -148,7 +150,7 @@ stroke trace: 62 frames on vulkan NVIDIA GeForce RTX 5090 (p50/p95 ms): interval | `cpu` | eframe's CPU time per frame: UI, tessellation and GPU submission, without the vsync wait | | `wait` | `interval − cpu`: waiting on vsync or the swap chain, or idle | -What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`. +What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend and how frames are presented. To compare presentation settings without rebuilding, launch with `PHOTOCRAFT_PRESENT_MODE` and `PHOTOCRAFT_FRAME_LATENCY` (see [Environment variables](#environment-variables)): with `novsync` the app no longer waits on vsync (`wait` near 0): each refresh shows the newest frame, and where the driver only offers Immediate, the screen may tear. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`. ## Driving the app programmatically diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..8dbbea612e 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,7 +177,7 @@ 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`. From 8376363d8ce1c8d7f70760636227425753845dc2 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 15:26:09 -0700 Subject: [PATCH 041/171] Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files (#5801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files A retro against "everything is drivable headless; the UI is a layer" found the shortcut work leaning on the UI. Fixed: - The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set. Tools may share a key (Shift cycling); a command given a tool's key takes it from them. - Reserved system keys are refused by the engine (the dialog uses the same check) and left out of imported sets. - importKys / exportKys / summarize take {"path": …} to read or write files, like actions.save; the automation gate refuses those paths for MCP. * Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools * Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one * docs: reserved keys per platform * Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched * Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc * Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front --- crates/automation/src/workspace.rs | 10 + crates/engine/src/kys.rs | 27 +- crates/engine/src/lib.rs | 1 + crates/engine/src/prefs.rs | 384 ++++++++++++++++++++-- crates/engine/src/prefs/tests.rs | 51 +++ crates/engine/src/shell_commands.rs | 194 +++++++++++ crates/engine/src/shortcut_sets.rs | 40 ++- crates/engine/tests/shortcuts_headless.rs | 146 ++++++++ crates/ui-egui/src/kys_import.rs | 3 +- crates/ui-egui/src/menus.rs | 88 +---- crates/ui-egui/src/prefs_ui.rs | 28 +- crates/ui-egui/src/tool_shortcuts.rs | 14 + docs/control-protocol.md | 2 +- 13 files changed, 815 insertions(+), 173 deletions(-) create mode 100644 crates/engine/src/shell_commands.rs create mode 100644 crates/engine/tests/shortcuts_headless.rs diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 23b64ae30e..f73a702385 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -369,6 +369,10 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool { } // With a `path` the file is written there; without one the bytes come back as `data`. "actions.save" | "swatches.export" => params.get("path").is_some(), + // Shortcut sets: a .kys read or written by path, or a Summarize page written by path. + "edit.keyboardShortcuts" => { + params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some()) + } "plugin.reload" => true, _ => false, } @@ -712,6 +716,12 @@ 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()); + // Shortcut sets: text in and out is fine; a path reads or writes the filesystem. + let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p); + assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": ""})).is_ok()); + for key in ["importKys", "exportKys", "summarize"] { + assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}"); + } 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()); diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index f996e23475..6a0ff99c84 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -160,12 +160,14 @@ fn key(label: &str) -> String { l.trim_end_matches('…').trim().to_lowercase() } -/// The engine's own candidates: every command in a menu, in command order. +/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in +/// command order. pub fn command_candidates() -> Vec { - crate::command_specs() - .iter() - .filter(|c| !c.menu.is_empty()) - .map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) }) + let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut)); + engine + .chain(crate::shell_commands::SHELL_COMMANDS.iter().copied()) + .filter(|c| !c.2.is_empty()) + .map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) }) .collect() } @@ -219,8 +221,8 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl } /// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is -/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no -/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped. +/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no +/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers is skipped. pub fn plan_tools<'a>(tools: impl IntoIterator, set: &KysSet) -> (BTreeMap, Vec) { let tools: Vec<(&str, &str)> = tools.into_iter().collect(); let mut keys = BTreeMap::new(); @@ -232,11 +234,10 @@ pub fn plan_tools<'a>(tools: impl IntoIterator, set: unknown.push(row.name.clone()); continue; }; - let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars(); - if let (Some(c), None) = (letters.next(), letters.next()) - && c.is_ascii_alphanumeric() - { - keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string()); + // One key without modifiers (Photoshop's letters; our tools may also take an F-key). + let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim())); + if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") { + keys.insert((*id).to_string(), k); } } (keys, unknown) @@ -448,7 +449,7 @@ mod tests { assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V")); assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased"); assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id"); - assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter"); + assert!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers"); assert_eq!(unknown, vec!["Imaginary Tool".to_string()]); // Our own export reads back by id. let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ba618f6ac8..41915c544d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -103,6 +103,7 @@ pub mod sample_cmds; pub mod sampler_cmds; pub mod select_extra_cmds; pub mod selection_cmds; +pub mod shell_commands; pub mod shortcut_sets; pub mod slice_cmds; pub mod smart_cmds; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 838e6fe25e..beccc97b49 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1503,9 +1503,65 @@ pub fn default_alternates(id: &str) -> impl Iterator + '_ { ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc) } -/// Every bindable id with its default: commands, then the temporary tools. +/// Keys the system takes before the app sees them (switching apps, closing the window, force +/// quit): `edit.keyboardShortcuts` won't bind them, as Photoshop won't. `sc` is in our notation, +/// where `Cmd` is Ctrl off the Mac. +pub fn reserved_shortcut(sc: &str) -> bool { + let Some(n) = normalize_shortcut(sc) else { return false }; + let reserved: &[&str] = if cfg!(target_os = "macos") { + &["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"] + } else if cfg!(target_os = "windows") { + &["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"] + } else { + // Linux and the BSDs: window switching and closing, and the virtual consoles. + &[ + "Alt+Tab", + "Alt+Shift+Tab", + "Alt+F4", + "Cmd+Alt+Delete", + "Cmd+Alt+F1", + "Cmd+Alt+F2", + "Cmd+Alt+F3", + "Cmd+Alt+F4", + "Cmd+Alt+F5", + "Cmd+Alt+F6", + "Cmd+Alt+F7", + "Cmd+Alt+F8", + "Cmd+Alt+F9", + "Cmd+Alt+F10", + "Cmd+Alt+F11", + "Cmd+Alt+F12", + ] + }; + reserved.iter().filter_map(|r| normalize_shortcut(r)).any(|r| r == n) +} + +/// What a bindable id is: a command (the engine's or the shell's), a tool, or a held tool. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BindKind { + Command, + Tool, + Hold, +} + +/// Every bindable id: (id, label, menu path, default key, kind). The engine's commands, the +/// shell's ([`crate::shell_commands::SHELL_COMMANDS`]), the tools and the held temporary tools, +/// so Edit › Keyboard Shortcuts works the same with or without the app's UI. +pub fn bindable_items() -> impl Iterator, BindKind)> { + let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut, BindKind::Command)); + let shell = crate::shell_commands::SHELL_COMMANDS.iter().map(|c| (c.0, c.1, c.2, c.3, BindKind::Command)); + let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1, &["Tools"][..], t.2, BindKind::Tool)); + let hold = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), BindKind::Hold)); + engine.chain(shell).chain(tools).chain(hold) +} + +/// Every bindable id with its default. fn bindable() -> impl Iterator)> { - crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2)))) + bindable_items().map(|i| (i.0, i.3)) +} + +fn is_tool(id: &str) -> bool { + crate::shell_commands::tool_default(id).is_some() } /// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut). @@ -1874,18 +1930,45 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let imported; let mut import = None; let p = match p.get("importKys") { - Some(xml) => { - let xml = xml.as_str().ok_or_else(|| bad(cmd, "`importKys` is the text of a .kys file"))?; + Some(v) => { + // The file's text, or {"path": …} to read it. + let read; + let xml = match v { + Value::String(s) => s.as_str(), + Value::Object(o) => { + let path = + o.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, "`importKys.path` is a .kys file"))?; + read = read_text_file(path, crate::kys::MAX_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + read.as_str() + } + _ => return Err(bad(cmd, "`importKys` is the text of a .kys file, or {\"path\": …}")), + }; let set = crate::kys::parse(xml).map_err(|e| bad(cmd, e))?; let plan = crate::kys::plan(crate::kys::command_candidates(), &set); let mut merged = serde_json::Map::new(); - for (id, sc) in &plan.set { + // A key the system reserves is left out (reported as `reserved`), not a failed import. + let mut skipped: Vec = Vec::new(); + for (id, sc) in plan.set.iter().filter(|(id, sc)| { + let bad = reserved_shortcut(sc); + if bad { + skipped.push(format!("{id}: {sc}")); + } + !bad + }) { let default = crate::command_specs().iter().find(|c| c.id == id).and_then(|c| c.shortcut).and_then(normalize_shortcut); merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) }); } + // Tool keys: a tool's own letter restores it (and its Shift cycling). + let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)); + let (tool_keys, unknown_tools) = crate::kys::plan_tools(tools, &set); + for (id, key) in &tool_keys { + let own = crate::shell_commands::tool_default(id).flatten().is_some_and(|d| d.eq_ignore_ascii_case(key)); + merged.insert(id.clone(), if own { Value::Null } else { json!(key) }); + } merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default()); let mut alternates = serde_json::Map::new(); for (id, keys) in &plan.alternate_keys { + let keys: &Vec = &keys.iter().filter(|k| !reserved_shortcut(k)).cloned().collect(); let main = plan.set.get(id).map(String::as_str); let default = Preferences::default().alternates(id, main); alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) }); @@ -1900,6 +1983,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { "alternates": plan.alternates, "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, + "tools": tool_keys, + "unknownTools": unknown_tools, + "reserved": skipped, "windows": set.windows, })); let mut with_import = p.clone(); @@ -1922,6 +2008,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { if !set.is_empty() || !alts.is_empty() { let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false); changed = true; + // Reserved system keys can't be bound, by hand or from a file. + let reserved = |v: &Value| -> Option { + let keys = match v { + Value::String(s) => vec![s.clone()], + other => alternate_keys(other).unwrap_or_default(), + }; + keys.into_iter().find(|k| reserved_shortcut(k)) + }; + if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) { + return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned"))); + } for (id, v) in set { if !bindable().any(|(b, _)| b == id) && !allow_unknown { return Err(bad(cmd, format!("unknown command `{id}`"))); @@ -1943,9 +2040,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { .chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k)))) .collect(); for (id, sc) in assigned { - // Shell commands (View › Zoom In) have alternates but no engine default. - let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None)); - for (c, def) in bindable().chain(shell) { + for (c, def) in bindable() { + // Tools may share a key: they cycle with Shift, as Photoshop's groups do. + if is_tool(id) && is_tool(c) { + continue; + } // Never strip a command that this same call is assigning: // two entries for one key are a clash inside the call, which // the returned conflicts list reports (#719). Stripping @@ -1967,16 +2066,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { } // Then the sets are saved, deleted or switched, from the bindings as they now stand. changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?; - if changed { - s.edit_prefs(|p| *p = next); - } - let prefs = s.prefs(); + // The reply and the exports read the bindings as they will be; they are committed only once + // the exports are written, so a bad export target changes nothing. + let prefs = &next; let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); - let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true)); - let list: Vec = crate::command_specs() - .iter() - .map(|c| (c.id, c.label, c.menu, c.shortcut, false)) - .chain(temporary) + let list: Vec = bindable_items() .filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f))) .filter(|c| { p.get("list").and_then(Value::as_bool).unwrap_or(false) @@ -1984,15 +2078,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { || prefs.shortcuts.contains_key(c.0) || prefs.alternate_shortcuts.contains_key(c.0) }) - .map(|(id, label, menu, def, hold)| { + .map(|(id, label, menu, def, kind)| { let main = prefs.shortcut(id, def); let defaults: Vec<&str> = default_alternates(id).collect(); let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)}); if !defaults.is_empty() { v["defaultAlternates"] = json!(defaults); } - if hold { - v["hold"] = json!(true); + match kind { + BindKind::Hold => v["hold"] = json!(true), + BindKind::Tool => v["tool"] = json!(true), + BindKind::Command => {} } v }) @@ -2003,30 +2099,59 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { bindings.extend(main.map(|m| (id, m.to_string()))); bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k))); } - let conflicts: Vec = - conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect(); + // Tools sharing a key are a cycling group, not a conflict. + let conflicts: Vec = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))) + .into_iter() + .filter(|(_, ids)| !ids.iter().all(|id| is_tool(id))) + .map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})) + .collect(); let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts}); if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) { o.extend(sets); } - // The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the - // app's dialog writes both with the shell's commands and tools added). + // The bindings as a `.kys` file, or Photoshop's Summarize page: every bindable item (the + // engine's commands, the shell's commands and the tools). let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() }; - if p.get("exportKys").and_then(Value::as_bool) == Some(true) { + // Both targets are resolved and their texts made before anything is written, and a path that + // can't be written is refused before either file is: a bad export changes nothing. + let kys_target = file_target(cmd, p.get("exportKys"))?; + let html_target = file_target(cmd, p.get("summarize"))?; + let mut files: Vec<(&str, String, String)> = Vec::new(); + if let Some(target) = kys_target { let entries: Vec = - crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect(); - out["kys"] = json!(crate::kys::write(set_name, &entries)); + crate::shortcut_sets::bound(prefs).into_iter().map(|b| crate::kys::Entry { id: b.id, label: b.label, tool: b.tool, keys: b.keys }).collect(); + let text = crate::kys::write(set_name, &entries); + match target { + Some(path) => files.push(("kysPath", path, text)), + None => out["kys"] = json!(text), + } } - if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) { + if let Some(target) = html_target { + let v = p.get("summarize").cloned().unwrap_or(Value::Null); let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos")); let mut sections: Vec = Vec::new(); - for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) { + for crate::shortcut_sets::Bound { label, menu, keys, .. } in crate::shortcut_sets::bound(prefs) { match sections.iter_mut().find(|(m, _)| *m == menu) { Some((_, rows)) => rows.push((label, keys)), None => sections.push((menu, vec![(label, keys)])), } } - out["html"] = json!(crate::shortcut_sets::summarize(set_name, §ions, mac)); + let html = crate::shortcut_sets::summarize(set_name, §ions, mac); + match target { + Some(path) => files.push(("htmlPath", path, html)), + None => out["html"] = json!(html), + } + } + for (_, path, _) in &files { + writable(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + } + let list: Vec<(&str, &str)> = files.iter().map(|(_, path, text)| (path.as_str(), text.as_str())).collect(); + write_files_atomically(&list, &rename_path).map_err(|e| bad(cmd, e))?; + for (key, path, _) in &files { + out[*key] = json!(path); + } + if changed { + s.edit_prefs(|p| *p = next); } if let Some(import) = import { out["import"] = import; @@ -2034,6 +2159,203 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// An output parameter (`exportKys`, `summarize`): absent or false → `None`; true (or an object +/// without `path`) → `Some(None)`, the text comes back in the reply; `{"path": …}` → +/// `Some(Some(path))`, written there. +fn file_target(cmd: &str, v: Option<&Value>) -> Result>> { + match v { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(Value::Bool(true)) => Ok(Some(None)), + Some(Value::Object(o)) => match o.get("path") { + None | Some(Value::Null) => Ok(Some(None)), + Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(Some(s.clone()))), + Some(_) => Err(bad(cmd, "`path` must be a non-empty string")), + }, + Some(_) => Err(bad(cmd, "expected true or {\"path\": …}")), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_text_file(path: &str, max: usize) -> std::result::Result { + let meta = std::fs::metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.len() > max as u64 { + return Err(format!("not a file of at most {} KiB", max / 1024)); + } + std::fs::read_to_string(path).map_err(|e| e.to_string()) +} + +#[cfg(target_arch = "wasm32")] +fn read_text_file(_: &str, _: usize) -> std::result::Result { + Err("paths can't be read on the web; pass the file's text instead".into()) +} + +/// Whether `path` can be written: not a folder, in a folder that exists. +#[cfg(not(target_arch = "wasm32"))] +fn writable(path: &str) -> std::result::Result<(), String> { + let p = std::path::Path::new(path); + if p.is_dir() { + return Err("is a folder".into()); + } + if std::fs::metadata(p).is_ok_and(|m| m.permissions().readonly()) { + return Err("is read-only".into()); + } + match p.parent().filter(|d| !d.as_os_str().is_empty()) { + Some(d) if !d.is_dir() => Err("its folder doesn't exist".into()), + _ => Ok(()), + } +} + +#[cfg(target_arch = "wasm32")] +fn writable(_: &str) -> std::result::Result<(), String> { + Err("paths can't be written on the web; omit `path` to get the text back".into()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn rename_path(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> { + std::fs::rename(from, to) +} + +#[cfg(target_arch = "wasm32")] +fn rename_path(_: &std::path::Path, _: &std::path::Path) -> std::io::Result<()> { + Err(std::io::Error::other("paths can't be written on the web")) +} + +/// Where `path` points, for telling whether two targets are one file: its folder resolved, its +/// name compared without case where the file system ignores it. +#[cfg(not(target_arch = "wasm32"))] +fn path_key(path: &str) -> String { + let p = std::path::Path::new(path); + let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); + let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + let key = dir.join(p.file_name().unwrap_or_default()).to_string_lossy().into_owned(); + if cfg!(any(target_os = "windows", target_os = "macos")) { key.to_lowercase() } else { key } +} + +/// A new, empty file beside `path` with a name nothing else uses (`create_new`), for staging +/// (`kind` "tmp") or a backup ("bak"). +#[cfg(not(target_arch = "wasm32"))] +fn sibling(path: &str, kind: &str) -> std::io::Result<(std::path::PathBuf, std::fs::File)> { + for n in 0..1000u32 { + let p = std::path::PathBuf::from(format!("{path}.{}-{n}.photocraft-{kind}", std::process::id())); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&p) { + Ok(f) => return Ok((p, f)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::other("no free name beside it")) +} + +/// Write `files` ((path, text)) all or nothing. Each text is staged in a new file beside its +/// target; once every one is written, each existing target is moved aside to a backup and the +/// staged file moved in. Any failure moves the backups back and removes what was staged, so the +/// user's files end as they began; on success the backups go. Two targets naming one file are +/// refused. `rename` is `std::fs::rename` (a test fails it on purpose). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write_files_atomically( + files: &[(&str, &str)], + rename: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>, +) -> std::result::Result<(), String> { + use std::io::Write; + use std::path::{Path, PathBuf}; + for (i, (a, _)) in files.iter().enumerate() { + if files.iter().skip(i + 1).any(|(b, _)| path_key(a) == path_key(b)) { + return Err(format!("{a}: two exports to the same file")); + } + } + let mut staged: Vec = Vec::new(); + let drop_all = |paths: &[PathBuf]| { + for p in paths { + let _ = std::fs::remove_file(p); + } + }; + for (path, text) in files { + let (tmp, mut f) = match sibling(path, "tmp") { + Ok(v) => v, + Err(e) => { + drop_all(&staged); + return Err(format!("{path}: {e}")); + } + }; + staged.push(tmp); + if let Err(e) = f.write_all(text.as_bytes()).and_then(|()| f.sync_all()) { + drop_all(&staged); + return Err(format!("{path}: {e}")); + } + } + // (target, its backup) of each file moved in so far. + let mut moved: Vec<(&Path, Option)> = Vec::new(); + // Put each backup back over its target (a target that had none is removed). What couldn't be + // put back is named, so the user can find their previous file. + let undo = |moved: &[(&Path, Option)]| -> String { + let mut stranded = Vec::new(); + for (target, backup) in moved.iter().rev() { + match backup { + Some(b) => { + if rename(b, target).is_err() { + stranded.push(format!("the previous {} is at {}", target.display(), b.display())); + } + } + None => { + let _ = std::fs::remove_file(target); + } + } + } + if stranded.is_empty() { String::new() } else { format!(" ({})", stranded.join("; ")) } + }; + for ((path, _), tmp) in files.iter().zip(&staged) { + let target = Path::new(*path); + let backup = if target.exists() { + let moved_aside = sibling(path, "bak").and_then(|(b, f)| { + drop(f); + match rename(target, &b) { + Ok(()) => Ok(b), + Err(e) => { + // The placeholder the backup name reserved goes too. + let _ = std::fs::remove_file(&b); + Err(e) + } + } + }); + match moved_aside { + Ok(b) => Some(b), + Err(e) => { + let stranded = undo(&moved); + drop_all(&staged); + return Err(format!("{path}: {e}{stranded}")); + } + } + } else { + None + }; + if let Err(e) = rename(tmp, target) { + let mut stranded = undo(&moved); + if let Some(b) = &backup + && rename(b, target).is_err() + { + stranded = format!("{stranded} (the previous {path} is at {})", b.display()); + } + drop_all(&staged); + return Err(format!("{path}: {e}{stranded}")); + } + moved.push((target, backup)); + } + for (_, backup) in moved { + if let Some(b) = backup { + let _ = std::fs::remove_file(b); + } + } + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn write_files_atomically( + files: &[(&str, &str)], + _: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>, +) -> std::result::Result<(), String> { + if files.is_empty() { Ok(()) } else { Err("paths can't be written on the web; omit `path` to get the text back".into()) } +} + /// Edit › Menus: hide/show items and give them colours. fn menus(s: &mut Session, p: &Value) -> Result { let ids = |k: &str| -> Vec { @@ -2159,7 +2481,7 @@ pub fn specs() -> Vec { "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+Shift+K"), - r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":""|""(defaults)?,"saveSet":""?,"deleteSet":""?,"sets":{"":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":""|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##, + r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"|{"path":".kys"}?,"useSet":""|""(defaults)?,"saveSet":""?,"deleteSet":""?,"sets":{"":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":""|""?,"exportKys":true|{"path":".kys"}?,"summarize":true|{"mac":bool,"path":".html"}?} (with a path the file is written and the reply has kysPath/htmlPath instead of kys/html)"##, keyboard_shortcuts, true ), diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index bf2fe52efd..374131e85c 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -827,3 +827,54 @@ fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() { let stored: Tools = serde_json::from_value(json!({"useShiftKeyForToolSwitch": false})).unwrap(); assert!(!stored.use_shift_key_for_tool_switch); } + +/// Shortcut exports are all or nothing (#5801): a rename that fails after the first file was +/// moved in puts the user's files back as they were and leaves nothing staged. +#[test] +fn exports_are_written_all_or_nothing() { + use super::write_files_atomically; + let dir = std::env::temp_dir().join(format!("photocraft-atomic-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.kys"), dir.join("b.html")); + std::fs::write(&a, "old A").unwrap(); + std::fs::write(&b, "old B").unwrap(); + let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap()); + let staged_moves = std::cell::Cell::new(0); + let failing = |from: &std::path::Path, to: &std::path::Path| { + if from.to_string_lossy().ends_with("photocraft-tmp") { + staged_moves.set(staged_moves.get() + 1); + if staged_moves.get() == 2 { + return Err(std::io::Error::other("disk gone")); + } + } + std::fs::rename(from, to) + }; + assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing).is_err()); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into())); + let leftovers = || std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().contains(".photocraft-")).count(); + assert_eq!(leftovers(), 0); + // A backup move that fails leaves no empty placeholder behind. + let backups = std::cell::Cell::new(0); + let failing_backup = |from: &std::path::Path, to: &std::path::Path| { + if to.to_string_lossy().ends_with("photocraft-bak") { + backups.set(backups.get() + 1); + if backups.get() == 2 { + return Err(std::io::Error::other("locked")); + } + } + std::fs::rename(from, to) + }; + assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing_backup).is_err()); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into())); + assert_eq!(leftovers(), 0); + // The same target twice is refused before anything is written. + assert!(write_files_atomically(&[(pa, "x"), (pa, "y")], &|f, t| std::fs::rename(f, t)).is_err()); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "old A"); + // A target named like a staging file is just a file. + let odd = dir.join("c.kys.photocraft-tmp"); + std::fs::write(&odd, "old C").unwrap(); + write_files_atomically(&[(pa, "new A"), (odd.to_str().unwrap(), "new C")], &|f, t| std::fs::rename(f, t)).unwrap(); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&odd).unwrap()), ("new A".into(), "new C".into())); + assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs new file mode 100644 index 0000000000..2bd765673f --- /dev/null +++ b/crates/engine/src/shell_commands.rs @@ -0,0 +1,194 @@ +//! The commands the app's shell carries out itself (view, panels, windows: they change no +//! document), and the tools, as data: what they're called and which keys they have by default. +//! The shell implements them (`photocraft_ui_egui::menus::UI_COMMANDS`, its `Tool`); the engine +//! knows them so a headless session sees every bindable command: Edit › Keyboard Shortcuts' +//! list, conflicts, `.kys` import and export all work without the UI (the UI is a layer on +//! top). The app's tests keep these tables equal to its own. + +/// Shell commands: id, label, menu path, default shortcut. +pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ + ("ui.symmetryTransform", "Transform Symmetry", &[], None), + // Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first. + ("tool.reset", "Reset Tool", &[], None), + ("tool.resetAll", "Reset All Tools", &[], None), + ("window.panel.group", "Change Panel Group", &[], None), + ("window.panel.layout", "Change Panel Layout", &[], None), + ("window.panel.move", "Move Panel", &[], None), + ("window.panel.float", "Float Panel", &[], None), + ("window.panel.dock", "Dock Panel", &[], None), + ("window.panel.activate", "Activate Panel", &[], None), + ("window.panel.close", "Close Panel", &[], None), + ("file.open", "Open…", &["File"], Some("Cmd+O")), + ("file.save", "Save", &["File"], Some("Cmd+S")), + ("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")), + ("file.exit", "Exit", &["File"], Some("Cmd+Q")), + ("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")), + ("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")), + ("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None), + ("edit.transform.scale", "Scale", &["Edit", "Transform"], None), + ("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None), + ("edit.transform.skew", "Skew", &["Edit", "Transform"], None), + ("edit.transform.distort", "Distort", &["Edit", "Transform"], None), + ("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None), + ("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")), + ("view.rulers", "Rulers", &["View"], Some("Cmd+R")), + ("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")), + ("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")), + ("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")), + ("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")), + ("view.extras", "Extras", &["View"], Some("Cmd+H")), + ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), + ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), + // Photoshop's interface brightness shortcuts: step through the four Pro themes. + ("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")), + ("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")), + ("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")), + ("type.editText", "Edit Type", &[], None), + ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), + ("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")), + ("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")), + ("view.actualPixels", "100%", &["View"], Some("Cmd+1")), + ("view.rotateView", "Rotate View", &[], None), + ("view.resetView", "Reset View", &["View"], None), + ("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None), + ("window.toggle.layers", "Layers", &["Window"], Some("F7")), + ("window.toggle.history", "History", &["Window"], None), + ("window.toggle.properties", "Properties", &["Window"], None), + ("window.toggle.color", "Color", &["Window"], Some("F6")), + ("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")), + ("window.toggle.brushes", "Brushes", &["Window"], None), + ("window.toggle.navigator", "Navigator", &["Window"], None), + ("window.toggle.toolbar", "Tools", &["Window"], None), + // Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock. + ("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")), + ("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")), + ("window.toggle.options", "Options", &["Window"], None), + // Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340). + ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), + ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), + ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), + ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), + ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), + ("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None), + ("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None), + ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), + ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), + ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), + ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), + ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), + ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), + ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), + ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), + ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), + ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), + ("window.theme.system", "Sync with system", &["Window", "Theme"], None), + ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), + ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), + ("help.website", "PhotoCraft Website", &["Help"], None), + ("help.artcraftWebsite", "ArtCraft Website", &["Help"], None), + ("help.github", "PhotoCraft on GitHub", &["Help"], None), + ("help.reportIssue", "Report an Issue…", &["Help"], None), + ("help.systemInfo", "System Info…", &["Help"], None), + ("help.about", "About PhotoCraft", &["Help"], None), +]; + +/// The tools, in toolbar order: shortcut id (`tools.select.`), Photoshop's name for it +/// (ours too), and its default key. Tools that share a key cycle with Shift (Photoshop's +/// tool groups), so a shared default letter isn't a conflict. +pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ + ("tools.select.move", "Move Tool", Some("V")), + ("tools.select.artboard", "Artboard Tool", Some("V")), + ("tools.select.rectMarquee", "Rectangular Marquee Tool", Some("M")), + ("tools.select.ellipseMarquee", "Elliptical Marquee Tool", Some("M")), + ("tools.select.singleRowMarquee", "Single Row Marquee Tool", None), + ("tools.select.singleColumnMarquee", "Single Column Marquee Tool", None), + ("tools.select.lasso", "Lasso Tool", Some("L")), + ("tools.select.polygonLasso", "Polygonal Lasso Tool", Some("L")), + ("tools.select.magneticLasso", "Magnetic Lasso Tool", Some("L")), + ("tools.select.magicWand", "Magic Wand Tool", Some("W")), + ("tools.select.crop", "Crop Tool", Some("C")), + ("tools.select.perspectiveCrop", "Perspective Crop Tool", Some("C")), + ("tools.select.eyedropper", "Eyedropper Tool", Some("I")), + ("tools.select.colorSampler", "Color Sampler Tool", Some("I")), + ("tools.select.ruler", "Ruler Tool", Some("I")), + ("tools.select.note", "Note Tool", Some("I")), + ("tools.select.count", "Count Tool", Some("I")), + ("tools.select.brush", "Brush Tool", Some("B")), + ("tools.select.pencil", "Pencil Tool", Some("B")), + ("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")), + ("tools.select.colorReplacement", "Color Replacement Tool", Some("B")), + ("tools.select.eraser", "Eraser Tool", Some("E")), + ("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")), + ("tools.select.magicEraser", "Magic Eraser Tool", Some("E")), + ("tools.select.gradient", "Gradient Tool", Some("G")), + ("tools.select.paintBucket", "Paint Bucket Tool", Some("G")), + ("tools.select.type", "Horizontal Type Tool", Some("T")), + ("tools.select.verticalType", "Vertical Type Tool", Some("T")), + ("tools.select.horizontalTypeMask", "Horizontal Type Mask Tool", Some("T")), + ("tools.select.verticalTypeMask", "Vertical Type Mask Tool", Some("T")), + ("tools.select.hand", "Hand Tool", Some("H")), + ("tools.select.rotateView", "Rotate View Tool", Some("R")), + ("tools.select.zoom", "Zoom Tool", Some("Z")), + ("tools.select.remove", "Remove Tool", Some("J")), + ("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")), + ("tools.select.healing", "Healing Brush Tool", Some("J")), + ("tools.select.patch", "Patch Tool", Some("J")), + ("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")), + ("tools.select.redEye", "Red Eye Tool", Some("J")), + ("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")), + ("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")), + ("tools.select.historyBrush", "History Brush Tool", Some("Y")), + ("tools.select.blur", "Blur Tool", None), + ("tools.select.sharpen", "Sharpen Tool", None), + ("tools.select.smudge", "Smudge Tool", None), + ("tools.select.dodge", "Dodge Tool", Some("O")), + ("tools.select.burn", "Burn Tool", Some("O")), + ("tools.select.sponge", "Sponge Tool", Some("O")), + ("tools.select.quickSelection", "Quick Selection Tool", Some("W")), + ("tools.select.objectSelection", "Object Selection Tool", Some("W")), + ("tools.select.pen", "Pen Tool", Some("P")), + ("tools.select.freeformPen", "Freeform Pen Tool", Some("P")), + ("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")), + ("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")), + ("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")), + ("tools.select.convertPoint", "Convert Point Tool", Some("P")), + ("tools.select.pathSelection", "Path Selection Tool", Some("A")), + ("tools.select.directSelection", "Direct Selection Tool", Some("A")), + ("tools.select.rectangle", "Rectangle Tool", Some("U")), + ("tools.select.ellipseShape", "Ellipse Tool", Some("U")), + ("tools.select.triangle", "Triangle Tool", Some("U")), + ("tools.select.polygon", "Polygon Tool", Some("U")), + ("tools.select.line", "Line Tool", Some("U")), + ("tools.select.customShape", "Custom Shape Tool", Some("U")), + ("tools.select.slice", "Slice Tool", Some("C")), + ("tools.select.sliceSelect", "Slice Select Tool", Some("C")), + ("tools.select.frame", "Frame Tool", Some("K")), +]; + +/// The default key of tool shortcut `id`, if it's a tool. +pub fn tool_default(id: &str) -> Option> { + TOOLS.iter().find(|t| t.0 == id).map(|t| t.2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_unique_and_keys_are_valid() { + let mut ids: Vec<&str> = SHELL_COMMANDS.iter().map(|c| c.0).chain(TOOLS.iter().map(|t| t.0)).collect(); + let n = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate ids"); + for (id, _, _, sc) in SHELL_COMMANDS { + assert!(sc.is_none_or(|k| crate::prefs::normalize_shortcut(k).is_some()), "{id}: {sc:?}"); + } + for (id, name, key) in TOOLS { + assert!(id.starts_with("tools.select.") && name.ends_with("Tool"), "{id} {name}"); + assert!(key.is_none_or(|k| k.len() == 1), "{id}: {key:?}"); + } + assert_eq!(tool_default("tools.select.move"), Some(Some("V"))); + assert_eq!(tool_default("edit.copy"), None); + } +} diff --git a/crates/engine/src/shortcut_sets.rs b/crates/engine/src/shortcut_sets.rs index c5fc4993fe..26062def96 100644 --- a/crates/engine/src/shortcut_sets.rs +++ b/crates/engine/src/shortcut_sets.rs @@ -234,20 +234,28 @@ pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String { out } -/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id, -/// label, top-level menu, keys with the main one first). The app's dialog adds the commands -/// only the shell knows (View › Zoom In, the tools). -pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec)> { - let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2))); - crate::command_specs() - .iter() - .map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut)) - .chain(temporary) - .filter_map(|(id, label, menu, def)| { +/// A command (or tool) with a key under some bindings. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Bound { + pub id: String, + pub label: String, + /// Its top-level menu ("Tools" for tools). + pub menu: String, + /// Main key first, then the further keys. + pub keys: Vec, + pub tool: bool, +} + +/// Every bindable command with a key under the bindings of `p`: the engine's and the shell's +/// commands, the tools and the temporary tools ([`crate::prefs::bindable_items`]). +pub fn bound(p: &Preferences) -> Vec { + crate::prefs::bindable_items() + .filter_map(|(id, label, menu, def, kind)| { let main = p.shortcut(id, def); let mut keys: Vec = main.and_then(normalize_shortcut).into_iter().collect(); keys.extend(p.alternates(id, main)); - (!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys)) + let menu = menu.first().copied().unwrap_or("Other").to_string(); + (!keys.is_empty()).then(|| Bound { id: id.to_string(), label: label.to_string(), menu, keys, tool: kind == crate::prefs::BindKind::Tool }) }) .collect() } @@ -340,8 +348,12 @@ mod tests { fn bound_lists_main_keys_then_alternates() { let p = prefs(); let b = bound(&p); - let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys"); - assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]); - assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand")); + let copy = b.iter().find(|x| x.id == "edit.copy").expect("Copy has keys"); + assert_eq!(copy.keys, vec!["Cmd+C".to_string(), "F3".to_string()]); + assert!(b.iter().any(|x| x.id == "tools.temporary.hand")); + // The shell's commands and the tools too: a set saved headless is the whole set. + assert!(b.iter().any(|x| x.id == "view.zoomIn" && x.keys.len() >= 2), "Zoom In with its Cmd++"); + let mv = b.iter().find(|x| x.id == "tools.select.move").expect("Move Tool"); + assert!(mv.tool && mv.keys == ["V"] && mv.menu == "Tools"); } } diff --git a/crates/engine/tests/shortcuts_headless.rs b/crates/engine/tests/shortcuts_headless.rs new file mode 100644 index 0000000000..2c8bbd2ca0 --- /dev/null +++ b/crates/engine/tests/shortcuts_headless.rs @@ -0,0 +1,146 @@ +//! Edit › Keyboard Shortcuts without the UI: the shell's commands and the tools are bindable, +//! listed, imported and exported; tools share keys without conflicts; system keys are refused; +//! sets read and write files by path. + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn ks(s: &mut Session, p: Value) -> Value { + s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts") +} + +fn listed<'a>(r: &'a Value, id: &str) -> &'a Value { + r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("{id} listed")) +} + +#[test] +fn shell_commands_and_tools_are_bindable_without_the_ui() { + let mut s = Session::new(); + let r = ks(&mut s, json!({"list": true})); + assert_eq!(listed(&r, "view.zoomIn")["alternates"], json!(["Cmd++"]), "the shell's View › Zoom In"); + let mv = listed(&r, "tools.select.move"); + assert_eq!((mv["tool"].clone(), mv["shortcut"].clone(), mv["menu"].clone()), (json!(true), json!("V"), json!(["Tools"]))); + assert!( + r["conflicts"].as_array().unwrap().iter().all(|c| c["commands"] + .as_array() + .unwrap() + .iter() + .any(|id| !id.as_str().unwrap().starts_with("tools.select."))), + "tools sharing a letter cycle, they don't conflict: {}", + r["conflicts"] + ); + // No allowUnknown needed any more. + let r = ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "view.zoomIn": "Cmd+Shift+Z"}})); + assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["view.zoomIn"].clone()), (json!("F6"), json!("Cmd+Shift+Z"))); +} + +#[test] +fn tools_may_share_a_key_but_a_command_takes_it() { + let mut s = Session::new(); + // Pencil joins the V group: Move and Artboard keep V. + let r = ks(&mut s, json!({"set": {"tools.select.pencil": "V"}})); + assert!(r["overrides"].get("tools.select.move").is_none(), "{}", r["overrides"]); + // A command taking V removes it from every tool that held it. + let r = ks(&mut s, json!({"set": {"edit.copy": "V"}})); + for t in ["tools.select.move", "tools.select.artboard", "tools.select.pencil"] { + assert_eq!(r["overrides"][t], json!(""), "{t}: {}", r["overrides"]); + } +} + +#[test] +fn system_keys_are_refused_and_dropped_from_imports() { + let mut s = Session::new(); + let key = if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" }; + let before = s.prefs().clone(); + assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": key}})).is_err()); + assert!(s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9", key]}})).is_err()); + assert_eq!(s.prefs(), &before); + let set = format!( + "F9{key}{key}NV" + ); + let r = ks(&mut s, json!({"importKys": set})); + assert_eq!(r["overrides"]["edit.copy"], json!("F9")); + assert!(r["overrides"].get("edit.cut").is_none(), "the reserved key is left out: {}", r["overrides"]); + assert_eq!(r["import"]["reserved"].as_array().map(Vec::len), Some(1)); + assert_eq!(r["overrides"]["tools.select.pencil"], json!("N"), "tool keys import headless"); + assert!(r["overrides"].get("tools.select.move").is_none(), "Move's own V keeps its cycling"); +} + +#[test] +fn sets_read_and_write_files_and_export_everything() { + let dir = std::env::temp_dir().join(format!("photocraft-shortcuts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let kys = dir.join("mine.kys"); + let html = dir.join("mine.html"); + let mut s = Session::new(); + ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "edit.copy": "F9"}, "saveSet": "Mine"})); + let r = ks(&mut s, json!({"exportKys": {"path": kys.to_str().unwrap()}, "summarize": {"path": html.to_str().unwrap(), "mac": false}})); + assert!(r.get("kys").is_none() && r["kysPath"].is_string() && r["htmlPath"].is_string(), "{r}"); + let text = std::fs::read_to_string(&kys).unwrap(); + assert!(text.contains("pc-id=\"tools.select.pencil\">F6") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}"); + assert!(std::fs::read_to_string(&html).unwrap().contains("

Tools

")); + // Back to the defaults, then the file restores the set, tools included. + ks(&mut s, json!({"useSet": ""})); + let r = ks(&mut s, json!({"reset": true, "importKys": {"path": kys.to_str().unwrap()}})); + assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["edit.copy"].clone()), (json!("F6"), json!("F9"))); + // Bad paths and shapes are errors, not panics. + for p in [ + json!({"importKys": {"path": dir.join("missing.kys").to_str().unwrap()}}), + json!({"importKys": {"path": ""}}), + json!({"importKys": 5}), + json!({"exportKys": "yes"}), + json!({"exportKys": {"path": 3}}), + json!({"summarize": {"path": ""}}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + } + // A change paired with an export that fails (a folder that doesn't exist, a bad summarize + // value) changes nothing. + let before = ks(&mut s, json!({}))["overrides"].clone(); + for p in [ + json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("no").join("such").join("x.kys").to_str().unwrap()}}), + json!({"set": {"edit.copy": "F10"}, "summarize": 7}), + json!({"set": {"edit.copy": "F10"}, "saveSet": "Other", "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}), + // A good .kys path with a bad summarize: neither file is written. + json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}), + json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": 7}), + json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.to_str().unwrap()}}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + let r = ks(&mut s, json!({})); + assert_eq!(r["overrides"], before, "{p}"); + assert!(r["sets"].get("Other").is_none(), "{p}: {r}"); + assert!(!dir.join("both.kys").exists(), "{p}: no file written"); + } + // A second file that passes the checks but can't be written (a NUL in its name): the user's + // existing first file is left exactly as it was, and no staged file stays behind. + let mine = dir.join("existing.kys"); + std::fs::write(&mine, "the user's own file").unwrap(); + let html = format!("{}/a\u{0}.html", dir.to_str().unwrap()); + let p = json!({"exportKys": {"path": mine.to_str().unwrap()}, "summarize": {"path": html}}); + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + assert_eq!(std::fs::read_to_string(&mine).unwrap(), "the user's own file"); + let left: Vec<_> = + std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("photocraft-tmp")).collect(); + assert!(left.is_empty(), "{left:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_read_only_target_is_refused_before_anything_is_written() { + let dir = std::env::temp_dir().join(format!("photocraft-readonly-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let ro = dir.join("locked.kys"); + std::fs::write(&ro, "locked").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&ro, perms.clone()).unwrap(); + let mut s = Session::new(); + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": ro.to_str().unwrap()}})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("read-only")), "{r:?}"); + assert_eq!(std::fs::read_to_string(&ro).unwrap(), "locked"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = std::fs::set_permissions(&ro, perms); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/kys_import.rs b/crates/ui-egui/src/kys_import.rs index 0b9165abd5..75f46a775b 100644 --- a/crates/ui-egui/src/kys_import.rs +++ b/crates/ui-egui/src/kys_import.rs @@ -35,8 +35,7 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan { /// The set's tool keys matched to our tools by name (Photoshop's names are ours): tool /// shortcut id (`tools.select.move`) → letter, and the tools PhotoCraft doesn't have. pub fn plan_tools(set: &KysSet) -> (BTreeMap, Vec) { - let tools: Vec<(String, &str)> = crate::state::Tool::ALL.into_iter().map(|t| (crate::tool_shortcuts::id(t), t.label())).collect(); - photocraft_engine::kys::plan_tools(tools.iter().map(|(id, name)| (id.as_str(), *name)), set) + photocraft_engine::kys::plan_tools(photocraft_engine::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)), set) } /// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f47e6ea64..2839e51f13 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -20,91 +20,9 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]; /// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut. -pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ - ("ui.symmetryTransform", "Transform Symmetry", &[], None), - // Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first. - ("tool.reset", "Reset Tool", &[], None), - ("tool.resetAll", "Reset All Tools", &[], None), - ("window.panel.group", "Change Panel Group", &[], None), - ("window.panel.layout", "Change Panel Layout", &[], None), - ("window.panel.move", "Move Panel", &[], None), - ("window.panel.float", "Float Panel", &[], None), - ("window.panel.dock", "Dock Panel", &[], None), - ("window.panel.activate", "Activate Panel", &[], None), - ("window.panel.close", "Close Panel", &[], None), - ("file.open", "Open…", &["File"], Some("Cmd+O")), - ("file.save", "Save", &["File"], Some("Cmd+S")), - ("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")), - ("file.exit", "Exit", &["File"], Some("Cmd+Q")), - ("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")), - ("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")), - ("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None), - ("edit.transform.scale", "Scale", &["Edit", "Transform"], None), - ("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None), - ("edit.transform.skew", "Skew", &["Edit", "Transform"], None), - ("edit.transform.distort", "Distort", &["Edit", "Transform"], None), - ("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None), - ("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")), - ("view.rulers", "Rulers", &["View"], Some("Cmd+R")), - ("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")), - ("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")), - ("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")), - ("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")), - ("view.extras", "Extras", &["View"], Some("Cmd+H")), - ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), - ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), - // Photoshop's interface brightness shortcuts: step through the four Pro themes. - ("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")), - ("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")), - ("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")), - ("type.editText", "Edit Type", &[], None), - ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), - ("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")), - ("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")), - ("view.actualPixels", "100%", &["View"], Some("Cmd+1")), - ("view.rotateView", "Rotate View", &[], None), - ("view.resetView", "Reset View", &["View"], None), - ("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None), - ("window.toggle.layers", "Layers", &["Window"], Some("F7")), - ("window.toggle.history", "History", &["Window"], None), - ("window.toggle.properties", "Properties", &["Window"], None), - ("window.toggle.color", "Color", &["Window"], Some("F6")), - ("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")), - ("window.toggle.brushes", "Brushes", &["Window"], None), - ("window.toggle.navigator", "Navigator", &["Window"], None), - ("window.toggle.toolbar", "Tools", &["Window"], None), - // Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock. - ("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")), - ("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")), - ("window.toggle.options", "Options", &["Window"], None), - // Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340). - ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), - ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), - ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), - ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), - ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None), - ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), - ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), - ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), - ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), - ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), - ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), - ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), - ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), - ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), - ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), - ("window.theme.system", "Sync with system", &["Window", "Theme"], None), - ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), - ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), - ("help.website", "PhotoCraft Website", &["Help"], None), - ("help.artcraftWebsite", "ArtCraft Website", &["Help"], None), - ("help.github", "PhotoCraft on GitHub", &["Help"], None), - ("help.reportIssue", "Report an Issue…", &["Help"], None), - ("help.systemInfo", "System Info…", &["Help"], None), - ("help.about", "About PhotoCraft", &["Help"], None), -]; +/// The table is the engine's ([`photocraft_engine::shell_commands::SHELL_COMMANDS`]), so the +/// commands and their keys exist without this UI (headless shortcut sets, lists, conflicts). +pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = photocraft_engine::shell_commands::SHELL_COMMANDS; /// Window › : the open documents, listed at the end of the Window menu as in Photoshop. /// The id ends in the document's index, which is its tab's position. diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 795ea84cea..b59e7803f5 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -816,33 +816,7 @@ fn conflict_state(overrides: &BTreeMap, alternates: &BTreeMap bool { - let Some(n) = prefs::normalize_shortcut(sc) else { return false }; - let reserved: &[&str] = if cfg!(target_os = "macos") { - &["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"] - } else if cfg!(target_os = "windows") { - &["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"] - } else { - // Linux and the BSDs: window switching and closing, and the virtual consoles. - &[ - "Alt+Tab", - "Alt+Shift+Tab", - "Alt+F4", - "Cmd+Alt+Delete", - "Cmd+Alt+F1", - "Cmd+Alt+F2", - "Cmd+Alt+F3", - "Cmd+Alt+F4", - "Cmd+Alt+F5", - "Cmd+Alt+F6", - "Cmd+Alt+F7", - "Cmd+Alt+F8", - "Cmd+Alt+F9", - "Cmd+Alt+F10", - "Cmd+Alt+F11", - "Cmd+Alt+F12", - ] - }; - reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n) + prefs::reserved_shortcut(sc) } /// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers. diff --git a/crates/ui-egui/src/tool_shortcuts.rs b/crates/ui-egui/src/tool_shortcuts.rs index 345b7f57b3..91f66a0f07 100644 --- a/crates/ui-egui/src/tool_shortcuts.rs +++ b/crates/ui-egui/src/tool_shortcuts.rs @@ -94,3 +94,17 @@ mod tests { assert_eq!(h.state().ui.status, "q"); } } + +#[cfg(test)] +mod engine_table_tests { + use super::*; + + /// The engine knows the tools as data (headless shortcut sets); it must match the toolbar. + #[test] + fn the_engines_tool_table_matches_the_tools() { + let ours: Vec<(String, &str, Option)> = Tool::ALL.into_iter().map(|t| (id(t), t.label(), default(t))).collect(); + let engine: Vec<(String, &str, Option)> = + photocraft_engine::shell_commands::TOOLS.iter().map(|(i, n, k)| (i.to_string(), *n, k.map(str::to_string))).collect(); + assert_eq!(ours, engine, "update photocraft_engine::shell_commands::TOOLS"); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2bbcab2469..14d416f19b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -228,7 +228,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca | `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key | | `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.` or `menus.colors.` reset removes the override and returns `null`, including when it is already absent | | `edit.preferences.
` | the section's values; in the app (no params) it opens the Preferences dialog on that section | -| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by name in the app's dialog import (a tool's own letter keeps its Shift cycling); this command doesn't know the tools and leaves them. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of the engine's commands as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. The app's dialog exports and summarizes the shell's commands and tools as well. A bad parameter anywhere in the call changes nothing | +| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by `pc-id` or name (a tool's own letter keeps its Shift cycling); `import.tools` lists the keys given. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of every bindable command and tool as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. A bad parameter anywhere in the call, an export path that can't be written included, changes nothing. **Headless parity:** the shell's commands (View › Zoom In, the panels; `photocraft_engine::shell_commands::SHELL_COMMANDS`) and the tools (`tools.select.`, listed with `"tool": true`) are bindable, listed, imported, exported and checked for conflicts without the UI; tools may share a key (they cycle with Shift, so a shared letter is no conflict), while a command given a tool's key takes it from the tools. Keys the system takes first (Alt+Tab, Alt+F4, Ctrl+Alt+Delete everywhere but the Mac, plus Ctrl+Esc and Ctrl+Shift+Esc on Windows and Ctrl+Alt+F1–F12 on Linux; ⌘Tab, ⌘`, ⌘⌥Esc on the Mac) are refused, and left out of an imported set (`import.reserved`). Files: `importKys: {"path": …}` reads a `.kys`, `exportKys: {"path": …}` and `summarize: {"path": …}` write the file (the reply has `kysPath` / `htmlPath`); automation (MCP) refuses these paths, as it does `actions.save`'s | | `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` | | `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt | From fdaa9b8316be77ab69538654e3df44f922484acf Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 15:26:13 -0700 Subject: [PATCH 042/171] Free Transform: New Layer applies the transform first; tests for switching tool, layer or New Layer (#5665, #5667) (#5859) * Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667) Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment. * New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order * Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal * Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied * Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute * Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines) --- crates/engine/src/jobs.rs | 12 +- crates/engine/src/layer_multi_cmds.rs | 10 +- crates/engine/src/lib.rs | 53 +++++ crates/engine/src/new_layer.rs | 64 ++++-- crates/engine/tests/precheck.rs | 74 ++++++ crates/ui-egui/src/lib.rs | 17 ++ crates/ui-egui/src/transform_switch_tests.rs | 223 +++++++++++++++++++ 7 files changed, 418 insertions(+), 35 deletions(-) create mode 100644 crates/engine/tests/precheck.rs create mode 100644 crates/ui-egui/src/transform_switch_tests.rs diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 527bc6f22b..70b95f2908 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -671,17 +671,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)?; 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; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 87bb507e4d..1b71e966a4 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1487,7 +1487,15 @@ pub fn specs() -> Vec { link_layers(s) }), spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)), - spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers), + spec!( + "layer.new.groupFromLayers", + "Group from Layers…", + &["Layer", "New"], + None, + r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##, + has_layer, + group_layers + ), spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)), spec!( "layer.lockLayers", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 41915c544d..e45826f34a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -422,6 +422,12 @@ pub struct CopiedStyle { pub mode: photocraft_color::ColorMode, } +/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does +/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and +/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group +/// or fill layer) is known only when they run. +pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"]; + #[derive(Default)] pub struct Session { docs: Vec, @@ -660,6 +666,53 @@ impl Session { self.live_commit = None; } + /// Check a call without running it: the command exists, its params have the shape + /// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does + /// something first only when the command will go through: Free Transform applies its box + /// before a new layer is made. + /// + /// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute` + /// accepts (a test holds the two together). For others it checks only what they share. + pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> { + let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if !(params.is_object() || params.is_null()) { + return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() }); + } + crate::param_check::check(spec, params)?; + crate::filters::validate_params(id, params)?; + if id == "layer.new.layer" { + crate::new_layer::parse(params)?; + } + // A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the + // document doesn't have. + if spec.params.contains("\"layer\":id") + && let Some(l) = commands::explicit_layer(self, params)? + && self.active().is_none_or(|st| st.doc.layer(l).is_none()) + { + return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) }); + } + self.admit(id, spec, params).map(|_| ()) + } + + /// What `dispatch` checks once the params are valid and a floating selection has dropped: + /// the precondition (on a named layer for commands that take one), a job running on the + /// document, a hidden target. The run params (the Channels panel's target filled in) when it + /// may run. + pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result { + let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone())); + if let Err(why) = self.explicit_layer_enabled(spec, params).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) = hidden_target::refusal(self, id, params) { + return Err(EngineError::Other(why.into())); + } + Ok(run_params) + } + /// Is the command currently runnable? (drives menu enablement) pub fn is_enabled(&self, id: &str) -> bool { self.is_enabled_with(id, &Value::Null) diff --git a/crates/engine/src/new_layer.rs b/crates/engine/src/new_layer.rs index a6f4cefd38..cb1b151aba 100644 --- a/crates/engine/src/new_layer.rs +++ b/crates/engine/src/new_layer.rs @@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> { } } +/// A New Layer call's params, checked. +pub(crate) struct NewLayer { + clip: bool, + mode: BlendMode, + opacity: f32, + label: LabelColor, + neutral: Option<[f32; 3]>, + below: bool, + name: Option, +} + /// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the /// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in. pub fn run(s: &mut Session, p: &Value) -> Result { + let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?; + let id = s.edit("New Layer", |doc, active| { + let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer")); + let mut layer = Layer::raster(name, doc.pixel_format()); + layer.blend = mode; + layer.opacity = opacity; + layer.label = label; + if let Some(rgb) = neutral { + let bounds = doc.bounds(); + let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?; + let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]); + surface.fill_rect(bounds, &px); + } + // Nothing may sit under the Background: below it the layer lands above it instead. + let id = match *active { + Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer), + _ => doc.insert_above(*active, layer), + }; + crate::clip_group::settle(doc, &[id], &Default::default(), clip); + *active = Some(id); + Ok(id) + })?; + crate::layer_multi_cmds::note_inert_insert(s, id); + Ok(json!({ "layer": id.0 })) +} + +/// Check a New Layer call's params without running it (Free Transform applies its box before a +/// new layer only when the new layer can be made). +pub(crate) fn parse(p: &Value) -> Result { let flag = |k: &str| match p.get(k) { None | Some(Value::Null) => Ok(false), Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))), @@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()), }; - let id = s.edit("New Layer", |doc, active| { - let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer")); - let mut layer = Layer::raster(name, doc.pixel_format()); - layer.blend = mode; - layer.opacity = opacity; - layer.label = label; - if let Some(rgb) = neutral { - let bounds = doc.bounds(); - let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?; - let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]); - surface.fill_rect(bounds, &px); - } - // Nothing may sit under the Background: below it the layer lands above it instead. - let id = match *active { - Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer), - _ => doc.insert_above(*active, layer), - }; - crate::clip_group::settle(doc, &[id], &Default::default(), clip); - *active = Some(id); - Ok(id) - })?; - crate::layer_multi_cmds::note_inert_insert(s, id); - Ok(json!({ "layer": id.0 })) + Ok(NewLayer { clip, mode, opacity, label, neutral, below, name }) } #[cfg(test)] diff --git a/crates/engine/tests/precheck.rs b/crates/engine/tests/precheck.rs new file mode 100644 index 0000000000..a28726dfeb --- /dev/null +++ b/crates/engine/tests/precheck.rs @@ -0,0 +1,74 @@ +//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers +//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859). + +use photocraft_engine::{PRECHECKED, Session}; +use serde_json::{Value, json}; + +/// Documents in different states: a Background only, a pixel layer active, a group active, a +/// selection, no document. +fn states() -> Vec<(&'static str, Session)> { + let mut out = Vec::new(); + out.push(("no document", Session::new())); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + out.push(("background only", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + out.push(("pixel layer", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); + s.execute("layer.new.group", json!({})).unwrap(); + out.push(("group, no background", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + out.push(("selection", s)); + out +} + +fn params() -> Vec { + vec![ + Value::Null, + json!({}), + json!([1]), + json!("x"), + json!({"mode": "Bogus"}), + json!({"mode": "Multiply"}), + json!({"fillNeutral": true}), + json!({"mode": "Overlay", "fillNeutral": true}), + json!({"position": "x"}), + json!({"position": "below"}), + json!({"opacity": "half"}), + json!({"name": 5}), + json!({"name": "ok"}), + json!({"color": "chartreuse"}), + json!({"layer": 999_999}), + json!({"layer": "Layer 1"}), + json!({"layer": "no such layer"}), + json!({"layer": true}), + json!({"ifPresent": true}), + ] +} + +#[test] +fn precheck_agrees_with_execute() { + let mut disagreements = Vec::new(); + for id in PRECHECKED { + for p in params() { + for (state, _) in states() { + // A fresh copy of the state for each call: execute changes it. + let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap(); + let pre = s.precheck(id, &p).is_ok(); + let ran = s.execute(id, p.clone()).is_ok(); + if pre != ran { + disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}")); + } + } + } + } + assert!(disagreements.is_empty(), "{disagreements:#?}"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34b346af00..27a2004012 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1052,6 +1052,21 @@ impl PhotocraftApp { { authorize(id, ¶ms)?; } + // A new layer while Free Transform is open applies the transform first, as Photoshop does + // (#5667): History reads the transform (or Place Embedded), then New Layer. Only for a + // command that can run; a transform refused or still being applied keeps the box open and + // the command doesn't run. + if photocraft_engine::PRECHECKED.contains(&id) && self.ui.transform.is_some() { + // Bad params or a command that can't run leave the box as it is. + self.session.precheck(id, ¶ms).map_err(|e| e.to_string())?; + transform_tool::commit(self); + if transform_tool::committing(self).is_some() { + return Err("the transform is still being applied; try again when it has finished".into()); + } + if self.ui.transform.is_some() { + return Err(if self.ui.status.is_empty() { "the transform couldn't be applied".into() } else { self.ui.status.clone() }); + } + } if let Some(result) = panel_docking::command(self, id, ¶ms) { return result; } @@ -2667,6 +2682,8 @@ mod transform_fx_tests; #[cfg(test)] mod layer_props_ui_tests; #[cfg(test)] +mod transform_switch_tests; +#[cfg(test)] mod transform_undo_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/transform_switch_tests.rs b/crates/ui-egui/src/transform_switch_tests.rs new file mode 100644 index 0000000000..f5be113d2a --- /dev/null +++ b/crates/ui-egui/src/transform_switch_tests.rs @@ -0,0 +1,223 @@ +//! Free Transform with the whole app: picking another tool or layer, or New Layer, while the box +//! is open applies the transform as it stands, as Photoshop does (#5441, #5665, #5667). + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; + +/// The app with a 400 × 300 document: a red 100 × 60 rectangle at (100, 100) on its own layer +/// above the Background. +fn app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).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(); + app.run("select.deselect", json!({})).unwrap(); + app + }); + h.run_steps(6); + h +} + +/// Open Free Transform and move the box 50 px right, 40 px down. +fn transform_and_move(h: &mut Harness<'static, PhotocraftApp>) { + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); + h.run_steps(2); + let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open"); + for p in &mut t.quad { + p[0] += 50.0; + p[1] += 40.0; + } + h.run_steps(2); +} + +fn red_bounds(h: &Harness<'static, PhotocraftApp>) -> photocraft_geom::Rect { + let st = h.state().session.active().unwrap(); + let top = st.doc.layers.last().unwrap(); + top.surface().unwrap().content_bounds() +} + +#[test] +fn picking_another_tool_in_the_toolbar_applies_the_transform() { + let mut h = app(); + transform_and_move(&mut h); + let hand = h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool"); + hand.click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "the box closed"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "the move was applied, not cancelled"); +} + +/// The app with the red rectangle on a layer the last step made (Layer via Copy, which deselects in +/// the same step), as Place Embedded leaves it for the box `begin_placed` opens: cancelling the +/// place takes that step back. +fn placed_app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("layer.new.layerViaCopy", json!({})).unwrap(); + app + }); + h.run_steps(6); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "Layer via Copy deselects in its own step"); + h +} + +fn history(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().history.entries().to_vec() +} + +/// The box on a just-placed layer (a file dropped on the canvas), moved. +fn placed_and_moved(h: &mut Harness<'static, PhotocraftApp>) { + let ctx = h.ctx.clone(); + crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap(); + h.run_steps(2); + let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open"); + for p in &mut t.quad { + p[0] += 50.0; + p[1] += 40.0; + } + h.run_steps(2); +} + +#[test] +fn a_placed_image_is_applied_when_another_tool_is_picked() { + let mut h = placed_app(); + placed_and_moved(&mut h); + h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none()); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200)); +} + +#[test] +fn picking_another_layer_in_the_panel_applies_the_transform() { + for placed in [false, true] { + let mut h = if placed { placed_app() } else { app() }; + if placed { + placed_and_moved(&mut h); + } else { + transform_and_move(&mut h); + } + h.get_by_label("Background").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "placed {placed}"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "placed {placed}"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}"); + } +} + +#[test] +fn new_layer_applies_the_transform_first() { + for placed in [false, true] { + let mut h = if placed { placed_app() } else { app() }; + let before = history(&h); + if placed { + placed_and_moved(&mut h); + } else { + transform_and_move(&mut h); + } + // The Layers panel's New Layer button (the menu item opens the New Layer dialog). + h.get_by_label("Create a new layer").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "placed {placed}"); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 3, "placed {placed}"); + let red = st.doc.layers[1].surface().unwrap().content_bounds(); + assert_eq!(red, photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}: the moved image, not the original"); + // History: the transform (folded into Place Embedded for a placed layer), then New Layer. + let after = history(&h); + let added: Vec<&str> = after.iter().skip(before.len().saturating_sub(usize::from(placed))).map(String::as_str).collect(); + let first = if placed { photocraft_engine::file_cmds::PLACE_EMBEDDED } else { "Free Transform" }; + assert_eq!(added, [first, "New Layer"], "placed {placed}: {after:?}"); + } +} + +#[test] +fn an_untouched_placed_image_stays_when_another_tool_or_layer_is_picked() { + for via_layer in [false, true] { + let mut h = placed_app(); + let ctx = h.ctx.clone(); + crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap(); + h.run_steps(2); + if via_layer { + let bg = h.state().session.active().unwrap().doc.layers[0].id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + } else { + h.query_by_label_contains("Hand Tool").expect("Hand Tool").click(); + } + h.run_steps(4); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 2, "via layer {via_layer}: the placed image stays"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160)); + } +} + +/// The placed fixture is a real place: cancelling the box takes the layer back, so the tests +/// above would see a switch that cancelled it. +#[test] +fn cancelling_the_placed_fixture_removes_its_layer() { + let mut h = placed_app(); + placed_and_moved(&mut h); + crate::transform_tool::cancel(h.state_mut()); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1); +} + +/// A new-layer command that can't run (Artboard from Group with a pixel layer active) leaves the box and the +/// placed layer as they were. +#[test] +fn a_new_layer_command_that_cant_run_leaves_the_box_open() { + let mut h = placed_app(); + placed_and_moved(&mut h); + let before = history(&h); + assert!(h.state_mut().run("layer.new.artboardFromGroup", json!({})).is_err()); + h.run_steps(2); + assert!(h.state().ui.transform.is_some(), "the box stays open"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays"); + assert_eq!(history(&h), before); +} + +/// A transform the engine refuses (a result far over the pixel budget, #3773) keeps the box open +/// with the placed layer, and the new layer isn't made: the refusal comes back. +#[test] +fn a_refused_transform_keeps_the_placed_layer_and_refuses_the_new_layer() { + let mut h = placed_app(); + placed_and_moved(&mut h); + let t = h.state_mut().ui.transform.as_mut().unwrap(); + t.quad = [[0.0, 0.0], [4.0e6, 0.0], [4.0e6, 4.0e6], [0.0, 4.0e6]]; + h.run_steps(2); + let before = history(&h); + let r = h.state_mut().run("layer.new.layer", json!({})); + h.run_steps(2); + assert!(r.is_err(), "{r:?}"); + assert!(h.state().ui.transform.is_some(), "the box stays open"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays, no new layer"); + assert_eq!(history(&h), before); +} + +/// New Layer with bad params, while a moved box is open, is refused before the transform is +/// applied: the box, the layer and History stay as they were. +#[test] +fn new_layer_with_bad_params_leaves_the_box_open() { + for bad in [json!({"mode": "Bogus"}), json!({"fillNeutral": true}), json!({"position": "x"}), json!([1])] { + let mut h = app(); + transform_and_move(&mut h); + let before = history(&h); + assert!(h.state_mut().run("layer.new.layer", bad.clone()).is_err(), "{bad}"); + h.run_steps(2); + assert!(h.state().ui.transform.is_some(), "{bad}: the box stays open"); + assert_eq!(history(&h), before, "{bad}: nothing applied"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160), "{bad}"); + } +} From 2b3d05636f1c8b2c3ad46ced89b5c40687e6caef Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 15:32:08 -0700 Subject: [PATCH 043/171] Follow-ups to #5801/#5859: precheck docs name the group nesting limit; precheck test covers valid ids and null; Free Transform refusals translated; stranded-backup error tested (#6365) --- crates/engine/src/lib.rs | 10 ++++---- crates/engine/src/prefs/tests.rs | 37 +++++++++++++++++++++++++++++ crates/engine/tests/precheck.rs | 6 ++++- 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 | 4 ++-- 24 files changed, 110 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e45826f34a..f13d61b979 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -422,8 +422,10 @@ pub struct CopiedStyle { pub mode: photocraft_color::ColorMode, } -/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does -/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and +/// The commands whose [`Session::precheck`] is complete: it accepts what `execute` does (Free +/// Transform applies its box before these, and only when they will run), but for the group +/// nesting limit ([`photocraft_doc::MAX_GROUP_DEPTH`]): grouping layers already that deep is +/// refused only as the edit is made. Layer via Copy and /// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group /// or fill layer) is known only when they run. pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"]; @@ -671,8 +673,8 @@ impl Session { /// something first only when the command will go through: Free Transform applies its box /// before a new layer is made. /// - /// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute` - /// accepts (a test holds the two together). For others it checks only what they share. + /// Complete for the commands in [`PRECHECKED`]: there it accepts what `execute` accepts, but + /// for the group nesting limit (a test holds the two together). For others it checks only what they share. pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> { let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if !(params.is_object() || params.is_null()) { diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 374131e85c..5314d1f197 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -878,3 +878,40 @@ fn exports_are_written_all_or_nothing() { assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself"); let _ = std::fs::remove_dir_all(&dir); } + +/// A backup that can't be put back after a failed export is named in the error, and stays where +/// it is with the user's previous text. +#[test] +fn a_stranded_backup_is_named_in_the_error() { + use super::write_files_atomically; + let dir = std::env::temp_dir().join(format!("photocraft-stranded-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.kys"), dir.join("b.html")); + std::fs::write(&a, "old A").unwrap(); + std::fs::write(&b, "old B").unwrap(); + let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap()); + let staged = std::cell::Cell::new(0); + // The second staged move fails, and so does putting a.kys's backup back. + let rename = |from: &std::path::Path, to: &std::path::Path| { + let f = from.to_string_lossy(); + if f.ends_with("photocraft-tmp") { + staged.set(staged.get() + 1); + if staged.get() == 2 { + return Err(std::io::Error::other("disk gone")); + } + } + if f.ends_with("photocraft-bak") && to == a.as_path() { + return Err(std::io::Error::other("locked")); + } + std::fs::rename(from, to) + }; + let e = write_files_atomically(&[(pa, "new A"), (pb, "new B")], &rename).unwrap_err(); + assert!(e.contains(&format!("the previous {} is at", a.display())), "{e}"); + let backup: Vec<_> = + std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with("photocraft-bak")).collect(); + assert_eq!(backup.len(), 1, "{backup:?}"); + assert!(e.contains(&backup[0].display().to_string()), "{e}"); + assert_eq!(std::fs::read_to_string(&backup[0]).unwrap(), "old A"); + assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/precheck.rs b/crates/engine/tests/precheck.rs index a28726dfeb..f549fbb8c8 100644 --- a/crates/engine/tests/precheck.rs +++ b/crates/engine/tests/precheck.rs @@ -1,4 +1,5 @@ -//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers +//! `Session::precheck` accepts what `execute` accepts for the commands it covers (all but the +//! group nesting limit, which only the edit checks) //! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859). use photocraft_engine::{PRECHECKED, Session}; @@ -47,6 +48,9 @@ fn params() -> Vec { json!({"name": "ok"}), json!({"color": "chartreuse"}), json!({"layer": 999_999}), + json!({"layer": 1}), + json!({"layer": 2}), + json!({"layer": null}), json!({"layer": "Layer 1"}), json!({"layer": "no such layer"}), json!({"layer": true}), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1329378dc2..c73c032df7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3805,3 +3805,6 @@ navigator Zoom Zvětšení Undo Changes Vrátit změny {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) + 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. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5f56421233..bfa6d5f8f1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3199,3 +3199,6 @@ curves Smooth Glätten Undo Changes Änderungen rückgängig machen {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) + 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. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 715e41cb1f..eccfd0508c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3897,3 +3897,6 @@ curves Smooth Εξομάλυνση Undo Changes Αναίρεση αλλαγών {shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί. Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Ο Ελεύθερος μετασχηματισμός εφαρμόζεται ακόμη· δοκιμάστε ξανά όταν ολοκληρωθεί. + The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a0f1c8556..da9337eaea 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4159,3 +4159,6 @@ navigator Zoom Zoom Undo Changes Deshacer cambios {shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar. Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine. + The transform couldn't be applied. No se pudo aplicar la transformación. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2402aec160..8b25af722e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3202,3 +3202,6 @@ curves Smooth Lisser Undo Changes Annuler les modifications {shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué. Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée. + The transform couldn't be applied. Impossible d'appliquer la transformation. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 343dcadc0c..2870dc6f99 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4116,3 +4116,6 @@ cameraRaw Yellow Sárga Undo Changes Változások visszavonása {shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá. Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött. + The transform couldn't be applied. Az átalakítás nem alkalmazható. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e4ba61e1a1..feca83a9c7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3189,3 +3189,6 @@ curves Smooth Haluskan Undo Changes Batalkan Perubahan {shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan. Low-Latency Drawing (vsync off, applies at next launch) Menggambar latensi rendah (tanpa vsync, berlaku saat peluncuran berikutnya) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Transformasi Bebas masih diterapkan; coba lagi setelah selesai. + The transform couldn't be applied. Transformasi tidak dapat diterapkan. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6b933287c6..a8796e64e7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4318,3 +4318,6 @@ shape Appearance Aspetto Undo Changes Annulla modifiche {shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata. Low-Latency Drawing (vsync off, applies at next launch) Disegno a bassa latenza (senza vsync, si applica al prossimo avvio) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Trasformazione libera è ancora in corso di applicazione; riprova quando è terminata. + The transform couldn't be applied. Impossibile applicare la trasformazione. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2f6b7d7f5e..13efa7517d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3873,3 +3873,6 @@ curves Smooth スムーズ Undo Changes 変更を取り消し {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) + Free Transform is still being applied; try again when it has finished. 自由変形を適用中です。完了してからもう一度お試しください。 + The transform couldn't be applied. 変形を適用できませんでした。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3c056c627b..5354b04a31 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3375,3 +3375,6 @@ curves Smooth 매끄럽게 Undo Changes 변경 사항 취소 {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) + Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요. + The transform couldn't be applied. 변형을 적용할 수 없습니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c80f86bf81..5a013c7862 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3957,3 +3957,6 @@ curves Smooth Vloeiend maken Undo Changes Wijzigingen ongedaan maken {shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen. Low-Latency Drawing (vsync off, applies at next launch) Tekenen met lage latentie (zonder vsync, na de volgende start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Vrije transformatie wordt nog toegepast; probeer het opnieuw als die klaar is. + The transform couldn't be applied. De transformatie kon niet worden toegepast. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 85df6b8157..8707b5fb2e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3795,3 +3795,6 @@ curves Smooth Wygładź Undo Changes Cofnij zmiany {shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać. Low-Latency Drawing (vsync off, applies at next launch) Rysowanie z małym opóźnieniem (bez synchronizacji pionowej, od następnego uruchomienia) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Przekształcanie swobodne jest nadal stosowane; spróbuj ponownie, gdy się zakończy. + The transform couldn't be applied. Nie można zastosować przekształcenia. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 211442ba20..ef10787ceb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3987,3 +3987,6 @@ layerLabel No Color Sem cor Undo Changes Desfazer alterações {shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído. Low-Latency Drawing (vsync off, applies at next launch) Desenho de baixa latência (sem sincronização vertical, aplicado na próxima inicialização) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. A Transformação livre ainda está sendo aplicada; tente novamente quando terminar. + The transform couldn't be applied. Não foi possível aplicar a transformação. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9dfcc85093..449f741e21 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3167,3 +3167,6 @@ curves Smooth Сгладить Undo Changes Отменить изменения {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) + Free Transform is still being applied; try again when it has finished. Свободное трансформирование ещё применяется; повторите попытку, когда оно завершится. + The transform couldn't be applied. Не удалось применить трансформирование. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index bbc0ef528d..4dedfaf7a1 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4270,3 +4270,6 @@ shape Appearance Izgled Undo Changes Poništi izmene {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti. Low-Latency Drawing (vsync off, applies at next launch) Crtanje sa malim kašnjenjem (bez vsync-a, primenjuje se pri sledećem pokretanju) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primenjuje; pokušajte ponovo kad se završi. + The transform couldn't be applied. Transformacija nije mogla da se primeni. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b63bdd7349..f7dc6961fa 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3927,3 +3927,6 @@ cameraData Lens Lens Undo Changes Değişiklikleri Geri Al {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz. Low-Latency Drawing (vsync off, applies at next launch) Düşük gecikmeli çizim (dikey eşitleme kapalı, sonraki açılışta geçerli) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Serbest Dönüştürme hâlâ uygulanıyor; bittiğinde yeniden deneyin. + The transform couldn't be applied. Dönüştürme uygulanamadı. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 923ec694af..9a018c744a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3896,3 +3896,6 @@ curves Smooth Згладити Undo Changes Скасувати зміни {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) + Free Transform is still being applied; try again when it has finished. Вільне трансформування ще застосовується; спробуйте знову, коли воно завершиться. + The transform couldn't be applied. Не вдалося застосувати трансформування. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index be40230ca3..b798849d72 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3683,3 +3683,6 @@ curves Smooth Làm mượt Undo Changes Hoàn tác thay đổi {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) + 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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7c563780db..766497d67b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2813,3 +2813,6 @@ curves Smooth 平滑 Undo Changes 撤消更改 {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) + Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。 + The transform couldn't be applied. 无法应用变换。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9376cb05bb..3627c785e8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2798,3 +2798,6 @@ curves Smooth 平滑 Undo Changes 還原變更 {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) + Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。 + The transform couldn't be applied. 無法套用變形。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 27a2004012..bde51ff27a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1061,10 +1061,10 @@ impl PhotocraftApp { self.session.precheck(id, ¶ms).map_err(|e| e.to_string())?; transform_tool::commit(self); if transform_tool::committing(self).is_some() { - return Err("the transform is still being applied; try again when it has finished".into()); + return Err(tl!("Free Transform is still being applied; try again when it has finished.").into()); } if self.ui.transform.is_some() { - return Err(if self.ui.status.is_empty() { "the transform couldn't be applied".into() } else { self.ui.status.clone() }); + return Err(if self.ui.status.is_empty() { tl!("The transform couldn't be applied.").into() } else { self.ui.status.clone() }); } } if let Some(result) = panel_docking::command(self, id, ¶ms) { From b630d7cb9aeb2a79ab5224ebf6e8266f1d858873 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 15 Oct 2026 18:19:26 -0600 Subject: [PATCH 044/171] Security: enforce the automation read boundary on stored smart-filter params (#5684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Security: enforce the automation read boundary on stored smart-filter params Smart filters store a command id and params inside the document, so a crafted .pcraft could resolve stored ambient paths (displace mapPath, color-lookup LUT files) at render time without the authorize gate: synchronous smart-object rendering has no Session argument, spawned job threads drop the requesting session's authority, and a trusted UI render could warm the process-wide stack cache with a stage the gate never saw. Thread the requesting session's gate through a thread-local in jobs.rs (entered around command execution, reinstalled on spawned workers and on job apply, restored on drop), re-check stored filter commands in render_in before source resolution and any cache lookup and in apply_filter_to_surface, and re-authorize layer.smartFilter.setParam with the complete merged params after merging stored document params — including edits whose request contains no path and actions.play steps. Denial is conservative: the document is unchanged and no worker starts. Regression tests in the automation crate cover rerender, cached-stage reuse, merged-param edits, action playback, background jobs, and filter transfer, for both the displace map and color-lookup LUT vectors. * Review: carry render authority through Session::edit and surface denials Session::edit is the engine's universal mutation entry (linked-file refresh, transforms, place); renders reached through it ran with no authorization scope installed, so stored smart-filter params read ambiently there even after the execute-path fix. Install the scope in Session::edit as well, keyed on the session's authorize gate. apply_filter_to_surface also conflated a denied filter with an unknown one: authorize_render_command's Result was discarded with .ok()?, so a denial silently rendered unfiltered pixels and the stage entered the process-wide stack cache. The signature becomes Result>: Err for denials (callers propagate), None for ids that aren't pixel filters (pass-through semantics unchanged), and an unreadable map keeps its deliberate pass-through. apply_smart_filter and apply_smart_filters propagate instead of cur.clone(). Two tests: a refresh driven outside command execution (Session::edit) is denied and leaves the document unchanged; a denied render caches no stage. Nine authorization tests pass, engine suite and strict Clippy clean, panic hunt green. * ui_set description: name the layerFilter field main added The Layers-panel filter dropdown (#5048) added layerFilter to the control method's UI_SET_FIELDS without extending the ui_set tool's description, so the drift guard (mcp.rs, ui_set_description_names_every_ accepted_field) fails on Windows CI for any branch built on main. Name the field with its patch shape: by (kind, name or mode), kinds (a list of layer kinds), name (matches part of the layer name), mode (a blend mode name). The 40 MCP tests pass; strict Clippy clean. * wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate Main's browser build is 171 KB over the 24 MiB wasm gate in CI (25,337,343 bytes), so every branch built on current main fails the web size check. Demote three pixel crates in the wasm-release profile: - cms: transforms are built once and cached (transform::cached), so "z" costs little runtime (measured -42 KB). - compose: the browser app composites per frame on the GPU (crates/gpu, WebGPU/WebGL2), so the CPU compositor is not the browser's hot path there (measured -122 KB). - algo: filters run per pixel on every interactive filter application. "z" there is the lever of last resort (measured -331 KB), taken because the gate is enforced in CI; if browser filters feel too slow, restore 3 here and reclaim the bytes from a feature-gated decoder. Final size 24,811,949 bytes locally, 354 KB under the gate; the native profiles are untouched. If a maintainer prefers keeping algo at 3, the alternative is raising the self-imposed 24 MiB early-warning limit (Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/ package.sh, not a code fix. --- Cargo.toml | 15 +- crates/automation/src/server.rs | 9 +- .../tests/smart_filter_authorization.rs | 261 ++++++++++++++++++ crates/engine/src/filters.rs | 26 +- crates/engine/src/jobs.rs | 42 +++ crates/engine/src/lib.rs | 3 + crates/engine/src/smart_cmds.rs | 44 ++- .../src/smart_cmds/tests/stack_cache.rs | 2 +- 8 files changed, 370 insertions(+), 32 deletions(-) create mode 100644 crates/automation/tests/smart_filter_authorization.rs diff --git a/Cargo.toml b/Cargo.toml index 663d268c3a..3c5ad2e0ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,17 +127,24 @@ opt-level = "z" [profile.wasm-release.package.naga] opt-level = "z" -# Pixel processing keeps its speed-oriented optimization level. +# Pixel processing keeps its speed-oriented optimization level, with two exceptions. +# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs +# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2), +# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm +# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on +# every interactive filter application; "z" there is the size lever of last resort (−331 KB), +# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here +# and reclaim the bytes from a feature-gated decoder instead. [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] opt-level = 3 [profile.wasm-release.package.photocraft-cms] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-compose] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-algo] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-paint] opt-level = 3 diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index f223bc5109..f52376a9c5 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -325,10 +325,11 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). layerFilter (an object patching the Layers panel filter: - /// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string; - /// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep - /// their current values. Other fields are an error. + /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or + /// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a + /// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off": + /// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not + /// over this channel). Other fields are an error. pub fields: Value, } diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs new file mode 100644 index 0000000000..a5c1681a52 --- /dev/null +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -0,0 +1,261 @@ +//! Stored smart-filter parameters must not grant automation ambient filesystem access. +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds}; +use serde_json::{Value, json}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&base).unwrap(); + std::fs::create_dir(base.join("workspace")).unwrap(); + std::fs::create_dir(base.join("outside")).unwrap(); + let f = Self(base); + std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap(); + std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap(); + std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap(); + std::fs::write(f.outside("black.cube"), cube(0)).unwrap(); + std::fs::write(f.outside("white.cube"), cube(1)).unwrap(); + f + } + + fn root(&self) -> PathBuf { + self.0.join("workspace") + } + fn outside(&self, name: &str) -> PathBuf { + self.0.join("outside").join(name) + } + fn headless(&self) -> Headless { + Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap()) + } + fn setup(&self, lookup: bool) -> (Headless, LayerId) { + let mut h = self.headless(); + h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap(); + h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap(); + let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap()); + let (id, params) = if lookup { + ("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"})) + } else { + ("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0})) + }; + h.session.execute(id, params).unwrap(); + (h, layer) + } + fn saved_filter(&self, lookup: bool) -> (Document, LayerId) { + let (h, layer) = self.setup(lookup); + let mut doc = (*h.session.active().unwrap().doc).clone(); + let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") }; + sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" })); + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap(); + (doc, layer) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn png(value: impl Fn(u32) -> u8) -> Vec { + let mut pixels = Vec::new(); + for _ in 0..32 { + for x in 0..32 { + pixels.extend([value(x); 3]); + } + } + let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap(); + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} +fn cube(value: u8) -> String { + format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8)) +} +fn path_params(lookup: bool, path: &Path) -> Value { + if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) } +} +fn assert_denied(result: Result) { + match result { + Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"), + Ok(value) => panic!("ambient file access was accepted: {value:?}"), + } +} + +#[test] +fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() { + for lookup in [false, true] { + let f = Fixture::new(); + let (mut h, layer) = f.setup(lookup); + let before = h.render_png(None, 64).unwrap(); + let doc = h.session.active().unwrap().doc.clone(); + let journal = h.session.journal.len(); + for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } { + let params = path_params(lookup, &f.outside(name)); + let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" }; + assert_denied(h.session.execute(id, params.clone())); + assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}}))); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); + assert_eq!(h.session.journal.len(), journal); + assert_eq!(h.render_png(None, 64).unwrap(), before); + } + // Safe numeric and embedded-data edits remain usable. + let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) }; + h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap(); + } +} + +#[test] +fn action_playback_cannot_hide_a_smart_filter_path_edit() { + let f = Fixture::new(); + let (mut h, layer) = f.setup(false); + let doc = h.session.active().unwrap().doc.clone(); + h.session.actions.list.push(Action::new( + "edit filter", + vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))], + )); + let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap(); + assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}"); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); +} + +#[test] +fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() { + for lookup in [false, true] { + let f = Fixture::new(); + let (_, layer) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let doc = h.session.active().unwrap().doc.clone(); + assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}}))); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); + // Removing an ambient input makes the filter usable again. + let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) }; + h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap(); + } +} + +#[test] +fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() { + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + for warm_cache in [false, true] { + if warm_cache { + let mut trusted = doc.clone(); + assert!(smart_cmds::refresh(&mut trusted, layer).unwrap()); + } + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let before = h.session.active().unwrap().doc.clone(); + assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); + assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + } + } +} + +#[test] +fn background_rerender_keeps_the_desktop_request_authority() { + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut s = Session::new(); + s.add_document(doc, None); + s.authorize = Some(authorize_desktop_engine_step); + let before = s.active().unwrap().doc.clone(); + let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap(); + // The desktop restores UI authority as soon as the automation request returns. + s.authorize = None; + let Started::Job(job) = started else { panic!("expected a background render") }; + assert_denied(s.wait_job(job)); + assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); + // The worker's restriction must not leak into subsequent trusted UI work. + s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap(); + } +} + +#[test] +fn transferring_stored_filters_cannot_read_outside_files() { + for lookup in [false, true] { + let f = Fixture::new(); + let (_, from) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap(); + let before = h.session.active().unwrap().doc.clone(); + for copy in [false, true] { + assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy}))); + assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + } + } +} + +#[test] +fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() { + for lookup in [false, true] { + let f = Fixture::new(); + let (mut h, layer) = f.setup(lookup); + let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)}); + assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png")))); + let mut trusted = Headless::trusted_local(); + trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None); + let mut renders = Vec::new(); + for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } { + trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap(); + renders.push(trusted.render_png(None, 64).unwrap()); + } + assert_ne!(renders[0], renders[1], "trusted path-based filter still works"); + assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic"); + } +} + +#[test] +fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() { + // link-watch and transform paths re-render through Session::edit, not Session::execute or + // jobs::run; the gate must reach them and the denial must not silently pass pixels through + // into the process-wide stack cache. + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + 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!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + // A trusted session (no gate) still renders the same document. + let mut trusted = Session::new(); + trusted.add_document(doc, None); + let mut d = (*trusted.active().unwrap().doc).clone(); + assert!(smart_cmds::refresh(&mut d, layer).is_ok()); + } +} + +#[test] +fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() { + // The denial path must fail the render before any stage is cached process-wide. + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut s = Session::new(); + s.add_document(doc, None); + s.authorize = Some(authorize_desktop_engine_step); + let bytes = smart_cmds::stack_cache_bytes(); + assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); + assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); + } +} diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 9cd6ff4600..e40be7b6ac 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -174,8 +174,9 @@ pub fn params_for(id: &str, p: &Value) -> Option { /// Runs filter command `id` with JSON `params` on `surf` (the pixel function behind every /// `filter.*` command; smart filters re-run it on re-render). `bounds` frames distortions; /// `selection` limits the result. Like layer filters, neighbourhood filters repeat the edge pixels -/// of `canvas` ∪ the surface's content instead of fading in transparency, and the output is -/// clipped to it. `None` for ids that aren't pixel filters. +/// `None` for ids that aren't pixel filters. `Err` when the requesting session's authority denies +/// the filter's params (an ambient path the session may not read): the caller must surface the +/// error rather than render unfiltered pixels. pub fn apply_filter_to_surface( id: &str, params: &Value, @@ -183,31 +184,36 @@ pub fn apply_filter_to_surface( bounds: photocraft_geom::Rect, selection: Option<&photocraft_raster::Surface>, canvas: photocraft_geom::Rect, -) -> Option { +) -> Result> { + // Replays bypass command dispatch; nested paths must still obey the requesting session's + // authority, including adjustment LUTs and plug-ins, before any resolver can read them. + crate::jobs::authorize_render_command(id, params)?; // Liquify / Puppet Warp / Perspective Warp smart filters (distort_cmds). if let Some(out) = crate::distort_cmds::apply_to_surface(id, params, surf, canvas) { - return Some(out); + return Ok(Some(out)); } // Lens Correction / Adaptive Wide Angle / Camera Raw smart filters (lens_cmds). if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) { - return Some(out); + return Ok(Some(out)); } // Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD). if let Some(kind) = id.strip_prefix("image.adjustments.") { - return crate::adjust_cmds::adjust_as_filter(kind, params, surf); + return Ok(crate::adjust_cmds::adjust_as_filter(kind, params, surf)); } // WebAssembly plug-in smart filters (plugin_cmds). if id == crate::plugin_cmds::RUN { - return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas); + return Ok(crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas)); } - let mut fp = params_for(id, params)?; + let Some(mut fp) = params_for(id, params) else { return Ok(None) }; // A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an // unknown filter; the command itself already refused an unreadable map when it was added. - crate::filters_ext::resolve_map_path(&mut fp, params).ok()?; + if crate::filters_ext::resolve_map_path(&mut fp, params).is_err() { + return Ok(None); + } let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds); let content = surf.content_bounds(); let area = algo::output_area(&fp, content, bounds, sel_bounds); - Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content))) + Ok(Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content)))) } pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 70b95f2908..de2afd1556 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -29,6 +29,43 @@ use serde_json::{Value, json}; use crate::{EngineError, Result, Session}; +type Authorization = fn(&str, &Value) -> Result<()>; + +thread_local! { + // Smart-filter rendering has no Session argument. Carry the requesting session's gate + // through synchronous rendering and explicitly reinstall it on background workers. + static RENDER_AUTHORIZATION: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +pub(crate) struct AuthorizationScope(Option); + +impl AuthorizationScope { + fn enter(gate: Option) -> Self { + Self(RENDER_AUTHORIZATION.with(|current| current.replace(gate.or(current.get())))) + } +} + +impl Drop for AuthorizationScope { + fn drop(&mut self) { + RENDER_AUTHORIZATION.with(|current| current.set(self.0)); + } +} + +/// [`AuthorizationScope`] entered with `gate` (or the one already installed, so trusted work on +/// this thread keeps ambient paths usable). [`Session::edit`] and the job paths use this to carry +/// the requesting session's authority into every smart-object re-render. +pub(crate) fn render_authorization_scope(gate: Option) -> AuthorizationScope { + AuthorizationScope::enter(gate) +} + +/// Recheck a stored filter's command and parameters before evaluating it or using its cache. +pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> { + match RENDER_AUTHORIZATION.with(std::cell::Cell::get) { + Some(gate) => gate(id, params), + None => Ok(()), + } +} + /// Identifies one background job for the session's lifetime. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)] pub struct JobId(pub u64); @@ -284,6 +321,8 @@ pub fn run( work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { + let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); + let _authorization = AuthorizationScope::enter(authorization); if !s.jobs.spawn || cfg!(target_arch = "wasm32") { let t = work(&s.jobs.inline.clone().unwrap_or_default())?; return apply(s, t); @@ -299,6 +338,7 @@ pub fn run( let ctx = JobCtx::new(); let (tx, rx) = std::sync::mpsc::channel::>(); let erased: Apply = Box::new(move |s, any| { + let _authorization = AuthorizationScope::enter(authorization); let t = any.downcast::().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?; apply(s, *t) }); @@ -311,6 +351,7 @@ pub fn run( // The work closure is moved into the thread. If spawning fails (thread limits), there is // 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 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. @@ -652,6 +693,7 @@ impl Session { if let Some(gate) = self.authorize { gate(id, ¶ms)?; } + let _authorization = AuthorizationScope::enter(self.authorize); // 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/lib.rs b/crates/engine/src/lib.rs index f13d61b979..b8d6167c3d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -779,6 +779,9 @@ impl Session { /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + // 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 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/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e2c1cf2bfa..6ffc89e67d 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -298,28 +298,35 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) out } -/// Runs the smart filter stack on the placed (unfiltered) content. -pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface { +/// Runs the smart filter stack on the placed (unfiltered) content. A filter the session's +/// authority denies surfaces its error (the trusted compositor path has no gate installed, so +/// this reads ambiently only when the caller established one and it refuses). +pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Result { if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) { - return placed.clone(); + return Ok(placed.clone()); } let mut cur = placed.clone(); // 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, canvas, extent)?; } - match sm.filter_mask.as_ref().filter(|m| m.enabled) { + Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), None => cur, - } + }) } -/// 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. - let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent) else { return cur.clone() }; - if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) } +/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the +/// requesting session's authority denies the filter's params: callers surface the error instead of +/// rendering (and caching) unfiltered pixels. +fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result { + let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? { + // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. + Some(out) => out, + None => cur.clone(), + }; + if f.blend == BlendMode::Normal && f.opacity >= 1.0 { Ok(out) } else { Ok(blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0))) } } /// The source placed in document pixels, before any smart filter. @@ -377,6 +384,11 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result> { /// is cancelled. Gives the same pixels as [`apply_smart_filters`] on the placed source, but /// starts from the deepest stage of the stack already rendered (see [`stack_cache`]). fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result> { + // Stored filters can come from a file or another layer. Check before looking up cached + // stages too: a trusted UI render may have populated this process-wide cache earlier. + for filter in sm.smart_filters.iter().filter(|f| sm.filters_enabled && f.visible) { + crate::jobs::authorize_render_command(&filter.command, &filter.params)?; + } let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) }; let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas); @@ -410,7 +422,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result Result { fn set_filter_params(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.smartFilter.setParams"; let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?; + let authorize = s.authorize; edit_filters(s, p, "Edit Smart Filter", |sm| { let i = filter_index(CMD, p, sm)?; let f = &mut sm.smart_filters[i]; @@ -1010,6 +1023,11 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result { (Value::Object(old), Value::Object(n)) => old.extend(n), (slot, n) => *slot = n, } + // Authorize the underlying command with the complete merged params, including paths + // already stored in the document, before committing the edit or starting a worker. + if let Some(gate) = authorize { + gate(&f.command, &f.params)?; + } Ok(()) }) } diff --git a/crates/engine/src/smart_cmds/tests/stack_cache.rs b/crates/engine/src/smart_cmds/tests/stack_cache.rs index 7675d71843..9465fb8387 100644 --- a/crates/engine/src/smart_cmds/tests/stack_cache.rs +++ b/crates/engine/src/smart_cmds/tests/stack_cache.rs @@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface { let doc = &s.active().unwrap().doc; let sm = active_smart(s); let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap(); - apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()) + apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()).unwrap() } #[test] From 3cb3580a46ba080d7ce4a037efc0a921724bd939 Mon Sep 17 00:00:00 2001 From: PerchenkoAnton <101743951+PerchAnton@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:14:05 +0300 Subject: [PATCH 045/171] fix(psd): recover layered imports with damaged merged data (#6170) --- crates/io/src/lib.rs | 6 +- crates/io/src/psd_import.rs | 12 +++ crates/io/tests/corpus.rs | 2 +- crates/io/tests/psd_damaged_merged.rs | 125 ++++++++++++++++++++++++++ crates/psd/src/file.rs | 44 ++++++++- crates/psd/tests/corpus.rs | 3 +- crates/psd/tests/merged_recovery.rs | 43 +++++++++ 7 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 crates/io/tests/psd_damaged_merged.rs create mode 100644 crates/psd/tests/merged_recovery.rs diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a620ddcf33..24b8b3f948 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -275,7 +275,11 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m } if is_psd(bytes) { // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. - let (file, merged) = PsdFile::from_bytes_and_merged(bytes, |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f))?; + let (file, merged) = PsdFile::from_bytes_and_merged_recovering( + bytes, + |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f), + psd_import::can_recover_layers, + )?; // Nesting past the document model's cap could never be saved; reject the file with the // actionable limit. psd_import::check_group_depth(&file)?; diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index c20c1b7fe7..12307f32ba 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -764,6 +764,12 @@ pub(crate) fn merged_needed(file: &PsdFile) -> bool { !layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha()) } +/// A damaged merged preview need not prevent opening independently stored layers (#6152). +/// Other modes are imported flattened, so their merged image cannot be discarded. +pub(crate) fn can_recover_layers(file: &PsdFile) -> bool { + matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty() +} + /// [`psd_to_document_with`] with the merged image already decoded (by /// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again. pub(crate) fn psd_to_document_decoded( @@ -901,9 +907,15 @@ pub(crate) fn psd_to_document_decoded( ctl.progress(0.1); if let Err(e) = &m { cx.warn(format!("merged image could not be decoded: {e}")); + if can_recover_layers(file) { + cx.warn("extra alpha/spot channels in the merged image could not be recovered"); + } } Some(m) } else { + if let Some(Err(e)) = decoded { + cx.warn(format!("merged image could not be decoded; opened the available layers instead: {e}")); + } None }; diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index dd96d96c7f..710b2a8644 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -132,7 +132,7 @@ thread_local! { } /// Files known to be invalid upstream: their parse error is expected. -const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")]; +const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "malformed merged image data; recovery is tested in psd_damaged_merged.rs (#6152)")]; /// One corpus directory with its own floors. struct Source { diff --git a/crates/io/tests/psd_damaged_merged.rs b/crates/io/tests/psd_damaged_merged.rs new file mode 100644 index 0000000000..e3d0970dd9 --- /dev/null +++ b/crates/io/tests/psd_damaged_merged.rs @@ -0,0 +1,125 @@ +//! #6152: intact layers can be recovered when only the merged preview is damaged. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::Size; +use photocraft_io::{ExportOptions, export, import}; +use photocraft_psd::{Compression, GroupSpec, PsdBuilder, PsdFile}; + +fn layered(depth: SampleType, extension: &str) -> PsdFile { + layered_in_mode(ColorMode::Rgb, depth, extension) +} + +fn layered_in_mode(mode: ColorMode, depth: SampleType, extension: &str) -> PsdFile { + let mut doc = Document::new("recovery", Size::new(3, 2), mode, depth); + let format = doc.pixel_format(); + let mut layer = Layer::raster("red pixels", doc.pixel_format()); + let mut pixel = vec![0.0; format.channels()]; + photocraft_raster::from_rgba_into(&format, [1.0, 0.0, 0.0, 1.0], &mut pixel); + layer.surface_mut().unwrap().fill_rect(doc.bounds(), &pixel); + doc.layers.push(layer); + PsdFile::from_bytes(&export(&doc, extension, &ExportOptions::default()).unwrap().bytes).unwrap() +} + +#[test] +fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() { + use ColorMode::{Cmyk, Grayscale, Lab, Rgb}; + use SampleType::{F32, U8, U16}; + for extension in ["x.psd", "x.psb"] { + for (mode, depth) in + [(Rgb, U8), (Rgb, U16), (Rgb, F32), (Grayscale, U8), (Grayscale, U16), (Grayscale, F32), (Cmyk, U8), (Cmyk, U16), (Lab, U8), (Lab, U16)] + { + for compression in Compression::ALL { + let mut file = layered_in_mode(mode, depth, extension); + let intact = import(extension, &file.to_bytes().unwrap()).unwrap(); + let expected = photocraft_compose::flatten(&intact.document).px; + let pixels = file.decode_merged().unwrap(); + file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap(); + file.image_data.data.truncate(file.image_data.data.len() / 2); + let bytes = file.to_bytes().unwrap(); + assert!(PsdFile::from_bytes(&bytes).is_err(), "strict parsing stays strict"); + let result = import(extension, &bytes).expect("recover the intact layers"); + assert_eq!(result.document.layer_count(), 1); + assert_eq!(result.document.depth, depth); + assert!(result.warnings.iter().any(|w| w.contains("merged image")), "{:?}", result.warnings); + let rendered = photocraft_compose::flatten(&result.document); + assert_eq!(rendered.px, expected, "{extension} {mode:?} {depth:?} {compression:?}"); + // Saving the recovered document replaces the bad preview with a valid one. + let saved = export(&result.document, extension, &ExportOptions::default()).unwrap(); + assert!(PsdFile::from_bytes(&saved.bytes).unwrap().decode_merged().is_ok()); + } + } + } +} + +#[test] +fn corrupt_zip_merged_image_keeps_the_layers() { + let mut file = layered(SampleType::U8, "x.psd"); + file.image_data = photocraft_psd::ImageData::encode(Compression::Zip, &file.decode_merged().unwrap(), &file.header).unwrap(); + *file.image_data.data.last_mut().unwrap() ^= 0xff; + let result = import("x.psd", &file.to_bytes().unwrap()).unwrap(); + assert_eq!(result.document.layer_count(), 1); + assert!(result.warnings.iter().any(|w| w.contains("merged image"))); +} + +#[test] +fn an_empty_group_survives_a_truncated_preview() { + // The reported psd-tools fixture has only the two records of an empty group. + let mut builder = PsdBuilder::new(100, 100); + builder.begin_group(GroupSpec::new("empty group")); + builder.end_group().unwrap(); + let mut file = builder.build().unwrap(); + file.image_data.data.truncate(file.image_data.data.len() / 2); + let result = import("empty.psd", &file.to_bytes().unwrap()).unwrap(); + assert_eq!(result.document.layer_count(), 1); + assert_eq!(result.document.layers[0].name, "empty group"); + assert!(result.warnings.iter().any(|w| w.contains("merged image"))); +} + +#[test] +fn a_flattened_file_with_no_recoverable_layers_still_fails() { + let mut file = layered(SampleType::U8, "x.psd"); + file.layer_info = None; + file.image_data.data.truncate(file.image_data.data.len() / 2); + assert!(import("flat.psd", &file.to_bytes().unwrap()).is_err()); +} + +#[test] +fn modes_imported_flattened_do_not_recover_from_unusable_layer_records() { + for mode in [photocraft_psd::ColorMode::Indexed, photocraft_psd::ColorMode::Multichannel, photocraft_psd::ColorMode::Duotone] { + let mut file = layered(SampleType::U8, "x.psd"); + file.header.color_mode = mode; + file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() }; + assert!(import("flat-mode.psd", &file.to_bytes().unwrap()).is_err(), "{mode:?}"); + } +} + +#[test] +fn lost_extra_channels_are_reported_while_layers_remain_usable() { + let mut file = layered(SampleType::U8, "x.psd"); + file.header.channels = 5; + file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() }; + let result = import("extra-channel.psd", &file.to_bytes().unwrap()).unwrap(); + assert_eq!(result.document.layer_count(), 1); + assert!(result.document.channels.is_empty()); + assert!(result.warnings.iter().any(|w| w.contains("alpha/spot channels")), "{:?}", result.warnings); +} + +#[test] +fn truncated_layer_sections_are_not_recovered() { + let file = layered(SampleType::U8, "x.psd"); + let mut bytes = file.to_bytes().unwrap(); + bytes.truncate(bytes.len() - file.image_data.data.len() - 3); + assert!(import("broken-layers.psd", &bytes).is_err()); +} + +#[cfg(feature = "corpus")] +#[test] +fn reported_group_divider_fixture_opens_with_its_group() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/blend-modes/group-divider-blend-mode.psd"); + let bytes = std::fs::read(path).expect("run cargo xtask corpus --psd-tools"); + let result = import("group-divider-blend-mode.psd", &bytes).expect("#6152 opens"); + assert_eq!(result.document.layer_count(), 1); + assert!(matches!(result.document.layers[0].content, photocraft_doc::LayerContent::Group(_))); + assert!(result.warnings.iter().any(|w| w.contains("merged image"))); +} diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 5a1ee15c1c..e14bc45aaf 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -102,6 +102,22 @@ impl PsdFile { /// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but /// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it. pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option>>)> { + Self::from_bytes_and_merged_recovering(data, want, |_| false) + } + + /// Parses like [`Self::from_bytes_and_merged`], but lets `recover` accept damage confined to + /// the merged image after all layer records and channel data have been read successfully. + /// Callers should opt in only when they can reconstruct the document from its layers. + /// + /// The original encoded image bytes are preserved and its parse error is returned as + /// `Some(Err(..))`, even when `want` did not request a decode. The caller must report that + /// error rather than silently discarding the merged image (which may hold extra channels). + /// Header/layer errors, missing compression fields and parse safety-limit errors stay fatal. + pub fn from_bytes_and_merged_recovering( + data: &[u8], + want: impl FnOnce(&PsdFile) -> bool, + recover: impl FnOnce(&PsdFile) -> bool, + ) -> Result<(Self, Option>>)> { let mut r = Reader::new(data); let header = Header::read(&mut r)?; let psb = header.version.is_psb(); @@ -161,10 +177,30 @@ impl PsdFile { let image_data = ImageData { compression: Compression::Raw, data: Vec::new() }; let mut file = PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data }; - let merged = if want(&file) { Some(file.read_merged_decoded(&mut r)?) } else { None }; - if merged.is_none() { - file.image_data = ImageData::read(&mut r, &file.header)?; - } + let mut encoded = r.clone(); + let merged = if want(&file) { + file.read_merged_decoded(&mut r).map(Some) + } else { + ImageData::read(&mut r, &file.header).map(|image| { + file.image_data = image; + None + }) + }; + let merged = match merged { + Ok(merged) => merged, + Err(error) => { + if matches!(error, PsdError::LimitExceeded(_)) || !recover(&file) { + return Err(error); + } + let compression = Compression::from_u16(encoded.u16()?); + let source = encoded.peek_rest(); + let mut data = Vec::new(); + data.try_reserve_exact(source.len()).map_err(|_| PsdError::LimitExceeded("not enough memory to retain the damaged merged image"))?; + data.extend_from_slice(source); + file.image_data = ImageData { compression, data }; + Some(Err(error)) + } + }; Ok((file, merged)) } diff --git a/crates/psd/tests/corpus.rs b/crates/psd/tests/corpus.rs index 19b1e56600..90abb4fefd 100644 --- a/crates/psd/tests/corpus.rs +++ b/crates/psd/tests/corpus.rs @@ -21,7 +21,8 @@ fn collect(dir: &Path, out: &mut Vec) { /// Files known to be invalid upstream, with the reason. They are reported but /// do not fail the test. -const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")]; +const KNOWN_BAD: &[(&str, &str)] = + &[("group-divider-blend-mode.psd", "malformed merged image data; strict parsing rejects it, but the importer recovers its group (#6152)")]; #[test] fn corpus_parse_and_byte_stable() { diff --git a/crates/psd/tests/merged_recovery.rs b/crates/psd/tests/merged_recovery.rs new file mode 100644 index 0000000000..40e310d8a4 --- /dev/null +++ b/crates/psd/tests/merged_recovery.rs @@ -0,0 +1,43 @@ +//! Opt-in merged-image recovery must not weaken strict parsing or lose encoded bytes. + +use photocraft_psd::{Compression, PsdError, PsdFile, Version, compression, testgen}; + +#[test] +fn recovery_is_explicit_and_preserves_the_original_file() { + for version in [Version::Psd, Version::Psb] { + for compression in Compression::ALL { + let mut original = testgen::small(version, compression); + original.image_data.data.pop(); + let bytes = original.to_bytes().unwrap(); + let strict_error = PsdFile::from_bytes(&bytes).unwrap_err(); + for want in [false, true] { + let rejected = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| false).unwrap_err(); + assert_eq!(rejected, strict_error); + let (file, merged) = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true).unwrap(); + assert_eq!(merged.unwrap().unwrap_err(), strict_error); + assert_eq!(file, original); + assert_eq!(file.to_bytes().unwrap(), bytes, "{version:?} {compression:?}"); + } + } + } +} + +#[test] +fn recovery_does_not_accept_missing_compression_or_incomplete_layers() { + let original = testgen::small(Version::Psd, Compression::Raw); + let bytes = original.to_bytes().unwrap(); + let merged_offset = bytes.len() - original.image_data.data.len() - 2; + for cut in [merged_offset - 1, merged_offset, merged_offset + 1] { + assert!(PsdFile::from_bytes_and_merged_recovering(&bytes[..cut], |_| true, |_| true).is_err(), "cut {cut}"); + } +} + +#[test] +fn recovery_never_suppresses_a_merged_stream_limit_error() { + let mut original = testgen::small(Version::Psd, Compression::Zip); + original.image_data.data = compression::zip_compress(&vec![0; (1 << 20) + 4096]); + let bytes = original.to_bytes().unwrap(); + for want in [false, true] { + assert!(matches!(PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true), Err(PsdError::LimitExceeded(_)))); + } +} From 0cc161e2a85ba01fcc2e92fb82cc5267ea280374 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 19:53:32 -0400 Subject: [PATCH 046/171] ci: delegate FreeBSD workflow to craft-common-actions and add nix check --- .github/workflows/freebsd.yml | 42 ++++--------------- .github/workflows/nix.yml | 33 +++++++++++++++ .../tests/smart_filter_authorization.rs | 8 ++-- 3 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index fa0693f58e..970c6577a4 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,38 +27,10 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test + package) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build, test and package in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - set -e - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui - CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib - CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc - # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU - # device at once, so the GPU integration tests run one at a time here. - CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1 - # The release package, as release.yml builds it. Free the debug build's disk first. - rm -rf target/debug - CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh - tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \ - | grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$' + # 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/freebsd.yml@main + with: + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + cargo-build-extra-args: "--features heif" + custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1" + run-package-check: true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..18392587a8 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + 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 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 57437ea1a9828298b46673bda891f2a520de92fd Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 21:05:39 -0700 Subject: [PATCH 047/171] fix(ui-egui): canvas textures fit the device's texture limit; GPU tests hold the GPU lock on the app's device (#5954) - The CPU canvas texture and the crop's beyond-canvas bands were reduced to fit MAX_TEXTURE (4096) whatever the device allows. On a device with a smaller limit (2048 on some older GPUs and WebGL contexts) a 2600 px document was uploaded at full size: egui asserts and the backend can't create it. They now fit the smaller of the two. Test: cpu_canvas_texture_fits_the_device_limit. - background_redraw and navigator GPU tests built their harness with egui's default wgpu limits instead of the app's wgpu_setup (the adapter's own limits), and the GPU canvas lost the device on it ("Device is lost", every run on an RTX 5090). They now use wgpu_setup. - Eleven tests built a wgpu device without photocraft_testkit::gpu_lock, which testkit requires so devices aren't created concurrently; they now hold it. --- crates/ui-egui/src/background_redraw_tests.rs | 25 ++++++++------ crates/ui-egui/src/canvas.rs | 34 +++++++++++++++++-- .../ui-egui/src/clipping_indicator_tests.rs | 1 + .../src/discard_ui/tests/tiff_tests.rs | 1 + crates/ui-egui/src/gpu_canvas_mip_tests.rs | 1 + crates/ui-egui/src/navigator_gpu_tests.rs | 34 +++++++++++-------- crates/ui-egui/src/panel_docking_tests.rs | 3 ++ crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/swap_arrow.rs | 1 + crates/ui-egui/src/type_tool_tests.rs | 1 + crates/ui-egui/src/widgets.rs | 1 + 11 files changed, 76 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index df5d467e10..650fccaa33 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -41,16 +41,21 @@ fn gpu_app() -> Option { let observer = Arc::new(std::sync::Mutex::new(None)); let observer_out = observer.clone(); let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.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(), Default::default()); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - *observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs)); - app.set_wgpu(rs.clone()); - } - app.background_jobs = true; - app - }) + egui_kittest::Harness::builder() + .with_size(egui::vec2(1000.0, 700.0)) + .with_pixels_per_point(1.0) + .with_max_steps(64) + .wgpu_setup(crate::gpu_canvas::wgpu_setup()) + .build_eframe(move |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() { + *observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs)); + app.set_wgpu(rs.clone()); + } + app.background_jobs = true; + app + }) })); let Ok(harness) = built else { eprintln!("skipping: no GPU adapter"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..3f4ae7a34a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -15,6 +15,13 @@ use crate::state::{Tool, View}; /// Largest texture side we upload; bigger documents display downsampled until the GPU path lands. pub const MAX_TEXTURE: u32 = 4096; +/// The largest texture side to upload on this device: [`MAX_TEXTURE`], or less where the GPU +/// allows less (2048 on some older or low-end GPUs and WebGL contexts). Past the device's limit +/// egui asserts and the backend fails to create the texture. +fn texture_side(ctx: &egui::Context) -> u32 { + u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE) +} + pub(crate) struct ClonePreviewCache { key: String, texture: egui::TextureHandle, @@ -1421,7 +1428,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key { let t0 = crate::gpu_canvas::now_ms(); let longest = doc.size.width.max(doc.size.height); - let factor = longest.div_ceil(MAX_TEXTURE).max(1); + let factor = longest.div_ceil(texture_side(ctx)).max(1); let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1)); let current = cache.texture.as_mut().filter(|t| t.size() == [w as usize, h as usize]); if let (Some(d), Some(t)) = (damage, current) { @@ -4520,8 +4527,8 @@ fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie None => { let t0 = crate::gpu_canvas::now_ms(); let extent = photocraft_engine::extra_cmds::reveal_all_bounds(&doc); - // One texel per `k` document pixels keeps every texture within MAX_TEXTURE. - let k = extent.width().max(extent.height()).div_ceil(MAX_TEXTURE).max(1); + // One texel per `k` document pixels keeps every texture within the device's side. + let k = extent.width().max(extent.height()).div_ceil(texture_side(&ctx)).max(1); let proxy = (k > 1).then(|| photocraft_compose::proxy::proxy_document(&doc, k)); let src = proxy.as_ref().unwrap_or(&*doc); let k = k as i32; @@ -6525,6 +6532,25 @@ mod tests { assert_eq!(uploads(&ctx), vec![(true, crisp)], "a partial update keeps the crisp sampling"); } + /// A device that allows smaller textures than MAX_TEXTURE (2048 on some older GPUs and WebGL + /// contexts, and on the fallback adapter the GPU tests sometimes get under load): the canvas + /// texture is reduced to fit it. A 2600 px document was uploaded at full size, which egui + /// asserts on and the backend can't create. + #[test] + fn cpu_canvas_texture_fits_the_device_limit() { + for (side, (w, h), want) in [(2048, (2600, 1700), [1300, 850]), (2048, (5000, 2000), [1666, 666]), (8192, (5000, 2000), [2500, 1000])] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": w, "height": h})).unwrap(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = side); + let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap(); + let mut delta = ctx.tex_manager().write().take_delta(); + let sizes: Vec<_> = delta.set.get(&tex).into_iter().flatten().map(|d| d.image.size()).collect(); + delta.clear(); + assert_eq!(sizes, vec![want], "{w}x{h} on a {side} px device"); + } + } + #[test] fn cpu_canvas_of_a_reduced_document_stays_bilinear() { // Over MAX_TEXTURE the texture holds one texel per `factor` pixels: a texel is not a @@ -6542,6 +6568,7 @@ mod tests { #[test] fn gpu_display_luts_are_per_display() { + let _gpu = photocraft_testkit::gpu_lock(); 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; @@ -7460,6 +7487,7 @@ mod tests { // must update only the reduced pixels it touched. let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 8192); app.run("file.new", json!({"width": 4097, "height": 90, "background": "transparent"})).unwrap(); let tilt = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "shapeDynamics": {"enabled": true, "size": {"control": "penTilt"}}}); app.run("tools.setBrush", json!({ "brush": tilt })).unwrap(); 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/discard_ui/tests/tiff_tests.rs b/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs index 73571fa751..f42de68ea0 100644 --- a/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs +++ b/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs @@ -7,6 +7,7 @@ use std::{cell::RefCell, rc::Rc}; type Writes = Rc)>>>; fn tiff_prompt(command: &str) -> (Prompted, Writes, DocId, DocId) { + let _gpu = photocraft_testkit::gpu_lock(); let mut app = app_with_docs(2); make_dirty(&mut app, 0); let (saved, other) = (doc_id(&app, 0), doc_id(&app, 1)); 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..454181ff72 100644 --- a/crates/ui-egui/src/navigator_gpu_tests.rs +++ b/crates/ui-egui/src/navigator_gpu_tests.rs @@ -9,20 +9,25 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>; fn harness() -> Option { let built = std::panic::catch_unwind(|| { - egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.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(), crate::Services::default()); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - app.set_wgpu(rs.clone()); - } - app.ui.panels.navigator = true; - app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); - app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap(); - app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); - app.run("select.deselect", json!({})).unwrap(); - app.sync_views(); - app - }) + egui_kittest::Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .with_pixels_per_point(1.0) + .with_max_steps(64) + .wgpu_setup(crate::gpu_canvas::wgpu_setup()) + .build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app.ui.panels.navigator = true; + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.sync_views(); + app + }) }); match built { Ok(h) => Some(h), @@ -47,6 +52,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/panels.rs b/crates/ui-egui/src/panels.rs index ed35a68804..73eb4c971f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -6003,6 +6003,7 @@ mod compact_visual_regressions { #[test] fn capture_compact_visual_fixtures() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; std::fs::create_dir_all(&directory).unwrap(); for kind in crate::theme::ThemeKind::ALL { diff --git a/crates/ui-egui/src/swap_arrow.rs b/crates/ui-egui/src/swap_arrow.rs index ef0bc76fb6..a5bd105232 100644 --- a/crates/ui-egui/src/swap_arrow.rs +++ b/crates/ui-egui/src/swap_arrow.rs @@ -66,6 +66,7 @@ mod tests { #[test] fn rendered_glyph_has_a_left_head_and_a_lower_right_head() { + let _gpu = photocraft_testkit::gpu_lock(); let mut h = egui_kittest::Harness::builder().with_size(vec2(32.0, 32.0)).wgpu().build_ui(|ui| { paint(ui.painter(), pos2(8.0, 8.0), Color32::WHITE); }); 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/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6e841292e7..2acbdf3c8b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1934,6 +1934,7 @@ mod tests { /// `%`'s, so the number lines up with the box and its unit (#5150). #[test] fn value_field_digits_ink_is_centred_and_level_with_the_unit() { + let _gpu = photocraft_testkit::gpu_lock(); let scale = 4.0; for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { let mut h = Harness::builder().with_size(egui::vec2(100.0, 40.0)).with_pixels_per_point(scale).wgpu().build_ui_state( From 297520b9c82d91f9b8c8a44ddd13dce0289b1c87 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 16 Oct 2026 05:05:51 +0100 Subject: [PATCH 048/171] perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory (#5234) * perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory The compositor and canvas budgets came from system RAM only, so a 16 GB card in a 96 GB machine planned for up to 8 GB of compositor pages plus 24 GB of display textures. Detect a discrete adapter's dedicated memory (DXGI on Windows through winsafe, amdgpu sysfs on Linux) and cap each budget at three eighths of it. PHOTOCRAFT_VRAM_MB simulates a smaller card. Fixes #4536. Co-Authored-By: Claude Opus 5.5 * perf(gpu): keep a 16-bit canvas on the GPU in 8-bit when floats don't fit With the canvas budget capped by a small card's memory, a 16-bit document under the float-canvas pixel limit could need more than the budget as Rgba16Float and fall back to the CPU canvas although its Rgba8Unorm set fits. Choose the float canvas only when it fits the budget, which is now shared atomically so format_for can read it while the renderer is locked. Co-Authored-By: Claude Opus 5.5 * perf(gpu): detect a card's memory once per adapter, and test the Linux reader video_memory built a DXGI factory each time it was asked, three times at startup; it now remembers the answer per (vendor, device), so a GPU retry that picks another adapter still gets its own. The amdgpu sysfs reader takes the drm folder as a parameter and is tested against a temporary folder: the matching card's VRAM, a card without the file, garbage ids, a malformed size, no match and no folder. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/examples/huge_doc_bench.rs | 12 +- crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/gpu_canvas.rs | 216 ++++++++++++++++++---- crates/ui-egui/tests/canvas_16f.rs | 21 +++ 5 files changed, 221 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aeefacaf2f..61f21885f9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -47,7 +47,7 @@ sys-locale = "=0.3.2" # Installed RAM for the GPU memory budget (`gpu_canvas::physical_memory`): the safe binding the # desktop app already uses, pinned to the same version. [target.'cfg(target_os = "windows")'.dependencies] -winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", "dxgi"] } [dev-dependencies] # egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with diff --git a/crates/ui-egui/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 0454aefc3f..6b0d03cda2 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -200,9 +200,17 @@ fn main() { .map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::()) .sum(); let ram = photocraft_ui_egui::gpu_canvas::physical_memory(); - let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram); + let vram = g.video_memory(); + let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram, vram); g.set_memory_budget(budget); - println!("RAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", ram.unwrap_or(0) >> 20, pixels >> 20, allowance >> 20, budget >> 20); + println!( + "RAM {} MB, VRAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", + ram.unwrap_or(0) >> 20, + vram.unwrap_or(0) >> 20, + pixels >> 20, + allowance >> 20, + budget >> 20 + ); let first_view = Rect::from_xywh((w as f32 * 0.1) as i32, (h as f32 * 0.1) as i32, 2560, 1440); g.set_focus(Some(if op == "view" { first_view } else { doc.bounds() })); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3f4ae7a34a..a85c25c3c4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1674,8 +1674,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: } /// Point the wgpu compositor at what the view shows, and size its memory budget from Memory -/// Usage, the document's pixels and History, and physical memory: on full refreshes (pixels -/// counted once per structural change, not per brush dab) and whenever the preference changes. +/// Usage, the document's pixels and History, physical memory and the GPU's own: on full refreshes +/// (pixels counted once per structural change, not per brush dab) and whenever the preference +/// changes. fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) { gpu.set_focus(Some(visible)); let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20); @@ -1684,7 +1685,7 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: } let Some(st) = app.session.documents().get(idx) else { return }; let pixels = st.memory_bytes() as u64; - let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory()); + let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory()); gpu.set_memory_budget(budget); app.perf.gpu_budget_mb = budget >> 20; app.perf.gpu_budget_allowance = allowance; diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 391b0b64a3..e53e89888f 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -27,6 +27,7 @@ use std::collections::HashMap; use std::num::NonZeroU64; +use std::sync::atomic::Ordering; use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, RenderState, ScreenDescriptor}; use eframe::wgpu; @@ -110,6 +111,11 @@ pub struct GpuCanvas { tile: u32, high: HighPolicy, health: photocraft_gpu::DeviceHealth, + /// [`video_memory`] of the adapter, detected once. + vram: Option, + /// [`canvas_budget`], shared with the paint callback's resources: [`GpuCanvas::format_for`] + /// reads it while the renderer is locked. + canvas_budget: std::sync::Arc, } /// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on @@ -158,9 +164,16 @@ impl GpuCanvas { res.health = health.clone(); let info = rs.adapter.get_info(); res.separate_mip_targets = separate_mip_targets(info.backend, info.vendor, std::env::var("PHOTOCRAFT_SEPARATE_MIPS").ok().as_deref()); + let vram = video_memory(&info); + res.canvas_budget.store(canvas_budget(physical_memory(), vram), Ordering::Relaxed); + let budget = res.canvas_budget.clone(); rs.renderer.write().callback_resources.insert(res); - log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format); - Self { rs: rs.clone(), tile, high, health } + log::info!( + "gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}, video memory {}", + rs.target_format, + vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20)) + ); + Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget } } /// The device's health flag (shared with the wgpu compositor and the paint callback). @@ -192,6 +205,12 @@ impl GpuCanvas { self.rs.adapter.get_info() } + /// The adapter's own memory where it has one and the platform reports it (see + /// [`video_memory`]); it caps [`memory_budget`] and [`canvas_budget`]. + pub fn video_memory(&self) -> Option { + self.vram + } + /// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit /// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else /// `Rgba8Unorm`. @@ -204,7 +223,11 @@ impl GpuCanvas { let px = size[0] as u64 * size[1] as u64; let high = match self.high { HighPolicy::Off => false, - HighPolicy::Budget => px <= F16_BUDGET_PX, + // Within the canvas budget too: a 16-bit canvas a small card can't hold as floats + // stays on the GPU as `Rgba8Unorm` rather than falling back to the CPU canvas. + HighPolicy::Budget => { + px <= F16_BUDGET_PX && canvas_texture_bytes(size, self.tile, texel_bytes(FORMAT_HIGH)) <= self.canvas_budget.load(Ordering::Relaxed) + } HighPolicy::Always => true, }; if high && depth != photocraft_doc::SampleType::U8 { FORMAT_HIGH } else { FORMAT } @@ -240,10 +263,7 @@ impl GpuCanvas { /// Set the GPU bytes one document's display texture set may take (see [`canvas_budget`]). pub fn set_canvas_budget(&self, bytes: u64) { - let mut r = self.rs.renderer.write(); - if let Some(res) = r.callback_resources.get_mut::() { - res.canvas_budget = bytes; - } + self.canvas_budget.store(bytes, Ordering::Relaxed); } /// Whether `doc`'s display textures fit [`canvas_budget`]; when they don't, nothing is @@ -252,7 +272,7 @@ impl GpuCanvas { let size = [doc.size.width, doc.size.height]; let bytes = canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size))); let r = self.rs.renderer.read(); - r.callback_resources.get::().is_some_and(|res| bytes <= res.canvas_budget) + r.callback_resources.get::().is_some_and(|res| bytes <= res.canvas_budget.load(Ordering::Relaxed)) } /// Cap lower-stack composite reuse; zero disables it for profiling the uncached path. @@ -934,6 +954,9 @@ pub struct GpuInfo { pub device_type: String, /// Driver name and version as the driver reports them. pub driver: String, + /// The adapter's own memory in MB ([`video_memory`]): discrete GPUs where the platform + /// reports it. It caps the GPU memory budgets. + pub video_memory_mb: Option, /// `performance.gpuBackend` at launch. pub preference: String, /// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`). @@ -964,6 +987,7 @@ impl GpuInfo { } .to_string(); self.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" "); + self.video_memory_mb = video_memory(info).map(|b| b >> 20); } /// Human-readable lines for Help › System Info. @@ -1196,13 +1220,22 @@ pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh /// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage /// (`allowance`, Preferences › Performance) leaves after the document's pixels and History -/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown), and at -/// least [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the -/// document, so a huge document keeps its layers on the GPU only as far as memory allows; the -/// rest is uploaded per refresh as the view needs it. -pub fn memory_budget(allowance: u64, pixels: u64, ram: Option) -> u64 { +/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown) and +/// [`vram_share`] of a discrete GPU's own memory (`vram`, from [`video_memory`]), and at least +/// [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the document, so +/// a huge document keeps its layers on the GPU only as far as memory allows; the rest is +/// uploaded per refresh as the view needs it. +pub fn memory_budget(allowance: u64, pixels: u64, ram: Option, vram: Option) -> u64 { let ram = ram.unwrap_or(16 << 30); - allowance.saturating_sub(pixels).min(ram / 4).max(MIN_GPU_BUDGET) + allowance.saturating_sub(pixels).min(ram / 4).min(vram.map_or(u64::MAX, vram_share)).max(MIN_GPU_BUDGET) +} + +/// The share of a discrete GPU's memory each of [`memory_budget`] and [`canvas_budget`] may +/// plan for: three eighths, so the two together leave a quarter of the card to the window's +/// swapchain, egui and other applications instead of over-committing it and letting the driver +/// page (#4536). +pub fn vram_share(vram: u64) -> u64 { + vram / 8 * 3 } /// Why a document whose display textures don't fit [`canvas_budget`] is drawn on the CPU canvas. @@ -1220,11 +1253,12 @@ pub const REFRESH_DEFERRED: &str = "deferred"; pub const DEFER_PX: u64 = 1 << 22; /// GPU bytes one document's display texture set (every tile and mip level) may take: a quarter -/// of physical memory (`ram`; 16 GB assumed when unknown), at least [`MIN_GPU_BUDGET`]. The set -/// covers the whole canvas at full resolution, so its size follows the declared canvas, not the -/// layer pixels; a larger canvas is drawn on the (reduced) CPU canvas instead (#2934). -pub fn canvas_budget(ram: Option) -> u64 { - (ram.unwrap_or(16 << 30) / 4).max(MIN_GPU_BUDGET) +/// of physical memory (`ram`; 16 GB assumed when unknown) and at most [`vram_share`] of a +/// discrete GPU's own memory (`vram`), at least [`MIN_GPU_BUDGET`]. The set covers the whole +/// canvas at full resolution, so its size follows the declared canvas, not the layer pixels; a +/// larger canvas is drawn on the (reduced) CPU canvas instead (#2934). +pub fn canvas_budget(ram: Option, vram: Option) -> u64 { + (ram.unwrap_or(16 << 30) / 4).min(vram.map_or(u64::MAX, vram_share)).max(MIN_GPU_BUDGET) } /// GPU bytes of a display texture set for a `size` canvas in `tile`-sided tiles of `bpp` bytes @@ -1288,6 +1322,69 @@ fn detect_physical_memory() -> Option { None } +/// The memory of `adapter` itself, in bytes, when it is a discrete GPU and the platform reports +/// it: DXGI's dedicated video memory on Windows, amdgpu's VRAM size on Linux. `None` for +/// integrated and software adapters, whose textures live in system memory (the RAM-based +/// budgets already cover them), and where nothing reports it. `PHOTOCRAFT_VRAM_MB` overrides it +/// for any adapter (to simulate a smaller card). +pub fn video_memory(adapter: &wgpu::AdapterInfo) -> Option { + if let Some(mb) = std::env::var("PHOTOCRAFT_VRAM_MB").ok().and_then(|v| v.trim().parse::().ok()) { + return mb.checked_mul(1 << 20).filter(|b| *b > 0); + } + if adapter.device_type != wgpu::DeviceType::DiscreteGpu { + return None; + } + // Detected once per adapter, as `physical_memory` is: on Windows each detection builds a + // DXGI factory, and startup asks three times. Keyed by the adapter, since a GPU retry can + // pick another one. + /// (vendor, device) ids and the memory found for them. + type Seen = Vec<((u32, u32), Option)>; + static SEEN: std::sync::Mutex = std::sync::Mutex::new(Vec::new()); + let key = (adapter.vendor, adapter.device); + let mut seen = SEEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((_, bytes)) = seen.iter().find(|(k, _)| *k == key) { + return *bytes; + } + let bytes = detect_video_memory(key.0, key.1).filter(|b| *b > 0); + seen.push((key, bytes)); + bytes +} + +#[cfg(windows)] +fn detect_video_memory(vendor: u32, device: u32) -> Option { + use winsafe::prelude::*; + let factory = winsafe::CreateDXGIFactory1().ok()?; + factory + .EnumAdapters1() + .filter_map(|a| a.ok()?.GetDesc1().ok()) + .find(|d| d.VendorId == vendor && d.DeviceId == device) + .and_then(|d| u64::try_from(d.DedicatedVideoMemory).ok()) +} + +#[cfg(target_os = "linux")] +fn detect_video_memory(vendor: u32, device: u32) -> Option { + drm_video_memory(std::path::Path::new("/sys/class/drm"), vendor, device) +} + +/// amdgpu's VRAM size (`/device/mem_info_vram_total`, bytes) of the card under `drm` (the +/// sysfs `/sys/class/drm` folder) whose PCI `vendor` and `device` ids (hex files beside it) match. +#[cfg(any(target_os = "linux", test))] +fn drm_video_memory(drm: &std::path::Path, vendor: u32, device: u32) -> Option { + let hex = |p: std::path::PathBuf| u32::from_str_radix(std::fs::read_to_string(p).ok()?.trim().trim_start_matches("0x"), 16).ok(); + std::fs::read_dir(drm).ok()?.flatten().find_map(|card| { + let dev = card.path().join("device"); + if hex(dev.join("vendor"))? != vendor || hex(dev.join("device"))? != device { + return None; + } + std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?.trim().parse().ok() + }) +} + +#[cfg(not(any(target_os = "linux", windows)))] +fn detect_video_memory(_vendor: u32, _device: u32) -> Option { + None +} + struct Resources { view_bgl: wgpu::BindGroupLayout, tile_bgl: wgpu::BindGroupLayout, @@ -1318,7 +1415,7 @@ struct Resources { compositor_cache_budget: Option, compositor_focus: Option, /// GPU bytes one document's display texture set may take (see [`canvas_budget`]). - canvas_budget: u64, + canvas_budget: std::sync::Arc, encode_bgl: wgpu::BindGroupLayout, encode_pipeline: wgpu::RenderPipeline, lut_bgl: wgpu::BindGroupLayout, @@ -1584,7 +1681,7 @@ impl Resources { compositor_budget: None, compositor_cache_budget: None, compositor_focus: None, - canvas_budget: canvas_budget(physical_memory()), + canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))), encode_bgl, encode_pipeline, } @@ -1623,7 +1720,7 @@ 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 { + if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget.load(Ordering::Relaxed) { return None; } let mut tiles = Vec::new(); @@ -2241,15 +2338,72 @@ mod tests { fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; // What Memory Usage leaves after the document, within a quarter of RAM. - assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G)), 5 * G); - assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G)), 4 * G); - assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G)), 4 * G); + assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), None), 5 * G); + assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G), None), 4 * G); + assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G), None), 4 * G); // Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown. - assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G)), MIN_GPU_BUDGET); - assert_eq!(memory_budget(64 * G, 0, None), 4 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G), None), MIN_GPU_BUDGET); + assert_eq!(memory_budget(64 * G, 0, None, None), 4 * G); assert!(physical_memory().is_none_or(|b| b >= 256 << 20)); } + /// An 8 GB card in a 64 GB machine: both budgets used to plan for 16 GB, twice the card (#4536). + #[test] + fn gpu_budgets_fit_a_discrete_gpus_own_memory() { + const G: u64 = 1 << 30; + let (ram, vram) = (Some(64 * G), Some(8 * G)); + assert_eq!(memory_budget(64 * G, 3 * G, ram, vram), 3 * G); + assert_eq!(canvas_budget(ram, vram), 3 * G); + assert!(memory_budget(64 * G, 0, ram, vram) + canvas_budget(ram, vram) <= 6 * G, "a quarter of the card stays free"); + // A card larger than a quarter of RAM changes nothing; a tiny one keeps the floor. + assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), Some(24 * G)), 5 * G); + assert_eq!(canvas_budget(Some(16 * G), Some(24 * G)), 4 * G); + assert_eq!(memory_budget(64 * G, 0, ram, Some(G)), MIN_GPU_BUDGET); + assert_eq!(canvas_budget(ram, Some(G)), MIN_GPU_BUDGET); + } + + /// DXGI reports each adapter's dedicated memory, found by the vendor and device ids wgpu gives. + #[cfg(windows)] + #[test] + fn windows_reports_the_adapters_own_memory() { + use winsafe::prelude::*; + let descs: Vec<_> = winsafe::CreateDXGIFactory1().unwrap().EnumAdapters1().map(|a| a.unwrap().GetDesc1().unwrap()).collect(); + assert!(!descs.is_empty(), "DXGI lists at least its software adapter"); + for d in descs.iter().filter(|d| d.DedicatedVideoMemory > 0) { + // The first adapter with these ids is the one matched. + let first = descs.iter().find(|e| e.VendorId == d.VendorId && e.DeviceId == d.DeviceId).unwrap(); + assert_eq!(detect_video_memory(d.VendorId, d.DeviceId), Some(first.DedicatedVideoMemory as u64)); + } + assert_eq!(detect_video_memory(0xdead, 0xbeef), None, "no such adapter"); + } + + /// 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] + fn drm_video_memory_reads_the_matching_cards_vram() { + let drm = std::env::temp_dir().join(format!("pc-drm-{}", std::process::id())); + let card = |name: &str, vendor: &str, device: &str, vram: Option<&str>| { + let dev = drm.join(name).join("device"); + std::fs::create_dir_all(&dev).unwrap(); + std::fs::write(dev.join("vendor"), vendor).unwrap(); + std::fs::write(dev.join("device"), device).unwrap(); + if let Some(v) = vram { + std::fs::write(dev.join("mem_info_vram_total"), v).unwrap(); + } + }; + // An Intel iGPU (no VRAM file), an AMD card, and an entry with garbage ids. + card("card0", "0x8086\n", "0x9a49\n", None); + card("card1", "0x1002\n", "0x73bf\n", Some("17163091968\n")); + card("card2", "not hex\n", "0x73bf\n", Some("1\n")); + card("card3", "0x10de\n", "0x2684\n", Some("lots\n")); + assert_eq!(drm_video_memory(&drm, 0x1002, 0x73bf), Some(17_163_091_968)); + assert_eq!(drm_video_memory(&drm, 0x8086, 0x9a49), None, "no amdgpu VRAM file"); + assert_eq!(drm_video_memory(&drm, 0x10de, 0x2684), None, "a malformed size"); + assert_eq!(drm_video_memory(&drm, 0xdead, 0xbeef), None, "no such card"); + assert_eq!(drm_video_memory(&drm.join("missing"), 0x1002, 0x73bf), None, "no drm folder"); + std::fs::remove_dir_all(&drm).unwrap(); + } + /// Windows used to report no RAM at all, so the budget assumed 16 GB on every machine. #[cfg(windows)] #[test] @@ -2280,12 +2434,12 @@ mod tests { assert_eq!(canvas_texture_bytes([0, 500], 64, 4), 0); // A canvas declared in a few bytes of a file is priced without building (or looping over) it. let huge = canvas_texture_bytes([300_000, 300_000], 8192, 4); - assert!(huge > 300_000u64 * 300_000 * 4 && huge > canvas_budget(Some(1 << 40))); + assert!(huge > 300_000u64 * 300_000 * 4 && huge > canvas_budget(Some(1 << 40), None)); assert_eq!(canvas_texture_bytes([u32::MAX, u32::MAX], 64, 8), u64::MAX, "saturates"); // A quarter of RAM (16 GB assumed), never below the compositor's floor. - assert_eq!(canvas_budget(None), 4 << 30); - assert_eq!(canvas_budget(Some(1 << 30)), MIN_GPU_BUDGET); - assert!(canvas_texture_bytes([12_000, 8_000], DEFAULT_TILE, 8) < canvas_budget(None), "a 96 MP 16-bit canvas stays on the GPU"); + assert_eq!(canvas_budget(None, None), 4 << 30); + assert_eq!(canvas_budget(Some(1 << 30), None), MIN_GPU_BUDGET); + assert!(canvas_texture_bytes([12_000, 8_000], DEFAULT_TILE, 8) < canvas_budget(None, None), "a 96 MP 16-bit canvas stays on the GPU"); } #[test] diff --git a/crates/ui-egui/tests/canvas_16f.rs b/crates/ui-egui/tests/canvas_16f.rs index 068de36c49..279a6dac6e 100644 --- a/crates/ui-egui/tests/canvas_16f.rs +++ b/crates/ui-egui/tests/canvas_16f.rs @@ -259,3 +259,24 @@ fn thirty_two_bit_preview_exposes_values_above_one() { eprintln!("shown {got}, want {want:.1} (clipped would be {clipped:.1})"); assert!((got - want).abs() <= 3.0, "shown {got}, want {want:.1} (clipped {clipped:.1})"); } + +/// A 16-bit canvas whose float textures don't fit the canvas budget (a small graphics card, +/// #4536) stays on the GPU as `Rgba8Unorm` instead of falling back to the CPU canvas. +#[test] +fn sixteen_bit_canvas_too_big_for_floats_stays_on_the_gpu_in_8_bit() { + let _gpu = gpu_lock(); + let Some((g, _rs)) = canvas() else { return }; + let doc = gradient(SampleType::U16, 512, 256, 0.0, 1.0); + let size = [512, 256]; + let floats = gpu_canvas::canvas_texture_bytes(size, gpu_canvas::DEFAULT_TILE, 8); + assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba16Float, "fits by default"); + // Room for the 8-bit set, not the float one. + g.set_canvas_budget(floats - 1); + assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba8Unorm); + assert!(g.fits_canvas(&doc)); + assert_ne!(g.refresh(doc.id.0, &doc, None, None).kind, gpu_canvas::REFRESH_OVER_BUDGET); + assert_eq!(g.texture_info(doc.id.0).map(|(f, _)| f), Some(TextureFormat::Rgba8Unorm)); + g.set_canvas_budget(floats); + assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba16Float); + g.release(); +} From 6d3443c09810ac2244f921154f0b8d8b74cafe54 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 21:22:46 -0700 Subject: [PATCH 049/171] GPU memory: discrete cards aren't charged for the document; GPU Memory preference; GPU Memory Full status (#5973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * GPU memory: a graphics card's own memory isn't charged for the document; GPU Memory preference; GPU Memory Full in the status bar Many layers hit a cliff: past what the GPU memory budget leaves for layer pages, every edit that redraws the whole document (opacity, visibility, a nudge) goes to the CPU compositor. Measured on 3000x2000 full-canvas layers (RTX 5090, 31 GB RAM, Memory Usage 8 GB): 150 layers took 2.6 s per such edit, 300 layers 6 s. The budget was Memory Usage minus the document's pixels and History, which is right where the GPU shares system memory, but a discrete card's memory doesn't hold the document, so 150 layers of 6 MP (3.6 GB) squeezed the budget below what they needed. - Automatic budget: discrete adapters are no longer charged for the document's pixels (integrated and software adapters still are). 150 layers: 2.6 s -> 25 ms. - Preferences › Performance › GPU Memory (MB, 0 = Automatic; performance.gpuMemoryMb) sets the budget outright. 300 layers with 12 GB: 6 s -> 45-72 ms. - The status bar shows "GPU Memory Full" while the active document's layers don't fit (both status bar styles); hovering gives the numbers, clicking opens Preferences › Performance. Its Graphics rows show the budget and what the layers need. The slow CPU redraw notice gives the numbers and points at GPU Memory. - Headless: the preference is a normal pref (prefs.set), and ui.state's perf.timings reports gpuLayerFit {doc, needed, budget} next to gpuBudgetMb. Next (owner-approved plan): after #5370, read the card's real limit from the driver (wgpu memory budget thresholds) and shrink on out-of-memory; then redraw the visible area on the GPU first when layers still don't fit. * GPU memory: typed discrete check; the status and budget follow a GPU Memory change at once (measure_fit); perf rows P60/P61 for whole-document edits among 150 full-canvas layers under the app's budget * GPU memory on top of #5234: memory_budget takes the card's memory (vram) and the GPU Memory value; the document's pixels are charged unless the card's own memory is known; a set GPU Memory is capped at the card's share; benches use video_memory --- crates/engine/src/prefs.rs | 6 ++ crates/gpu/src/lib.rs | 5 + crates/ui-egui/examples/huge_doc_bench.rs | 2 +- crates/ui-egui/examples/perf_scenarios.rs | 67 +++++++++++- crates/ui-egui/src/canvas.rs | 33 ++++-- crates/ui-egui/src/chrome_ui.rs | 51 ++++++++++ crates/ui-egui/src/gpu_canvas.rs | 118 +++++++++++++++++----- crates/ui-egui/src/i18n/cs.tsv | 10 +- crates/ui-egui/src/i18n/de.tsv | 10 +- crates/ui-egui/src/i18n/el.tsv | 10 +- crates/ui-egui/src/i18n/es.tsv | 10 +- crates/ui-egui/src/i18n/fr.tsv | 10 +- crates/ui-egui/src/i18n/hu.tsv | 10 +- crates/ui-egui/src/i18n/id.tsv | 10 +- crates/ui-egui/src/i18n/it.tsv | 10 +- crates/ui-egui/src/i18n/ja.tsv | 10 +- crates/ui-egui/src/i18n/ko.tsv | 10 +- crates/ui-egui/src/i18n/nl.tsv | 10 +- crates/ui-egui/src/i18n/pl.tsv | 10 +- crates/ui-egui/src/i18n/pt-br.tsv | 10 +- crates/ui-egui/src/i18n/ru.tsv | 10 +- crates/ui-egui/src/i18n/sr.tsv | 10 +- crates/ui-egui/src/i18n/tr.tsv | 10 +- crates/ui-egui/src/i18n/uk.tsv | 10 +- crates/ui-egui/src/i18n/vi.tsv | 10 +- crates/ui-egui/src/i18n/zh-hans.tsv | 10 +- crates/ui-egui/src/i18n/zh-hant.tsv | 10 +- crates/ui-egui/src/notices.rs | 13 ++- crates/ui-egui/src/panels.rs | 2 + crates/ui-egui/src/prefs_ui.rs | 29 +++++- docs/scorecard.md | 8 +- perf/budgets.toml | 18 ++++ 32 files changed, 491 insertions(+), 61 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index beccc97b49..2c2507226d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -538,6 +538,10 @@ pub struct Performance { pub low_latency_drawing: bool, /// Linux display server (applies at next launch; see [`LinuxDisplayServer`]). pub linux_display_server: LinuxDisplayServer, + /// GPU memory the canvas compositor may hold for layer pages and effect maps, in MB; 0 is + /// automatic (a graphics card's own memory isn't charged for the document's pixels, memory + /// shared with the system is). Layers that don't fit redraw the whole document on the CPU. + pub gpu_memory_mb: u32, /// Memory budget of the layer-effect cache, in MB. pub effect_cache_mb: u32, pub legacy_compositing: bool, @@ -569,6 +573,7 @@ impl Default for Performance { low_resolution_previews: true, low_latency_drawing: false, linux_display_server: LinuxDisplayServer::Auto, + gpu_memory_mb: 0, effect_cache_mb: 768, legacy_compositing: false, } @@ -1107,6 +1112,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "interface.notificationDurationSeconds" => (1.0, 120.0), "export.jpegQuality" | "export.webpQuality" => (1.0, 100.0), "performance.memoryUsageMb" => (256.0, 1_048_576.0), + "performance.gpuMemoryMb" => (0.0, 1_048_576.0), "performance.historyStates" => (1.0, 1000.0), "performance.cacheLevels" => (1.0, 8.0), "performance.cacheTileSize" => (256.0, 16384.0), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8f8839b9bd..690d144d68 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -794,6 +794,11 @@ impl Compositor { total } + /// The GPU memory resident layer pages may hold (bytes; see [`Compositor::set_memory_budget`]). + pub fn resident_budget(&self) -> u64 { + self.resident_budget + } + /// Whether a render of `region` keeps every layer page it needs within the budget; when it /// doesn't, each render re-uploads what was evicted. pub fn fits_budget(&self, doc: &Document, region: Rect) -> bool { diff --git a/crates/ui-egui/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 6b0d03cda2..3ddbc5afab 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -201,7 +201,7 @@ fn main() { .sum(); let ram = photocraft_ui_egui::gpu_canvas::physical_memory(); let vram = g.video_memory(); - let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram, vram); + let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram, vram, 0); g.set_memory_budget(budget); println!( "RAM {} MB, VRAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 1b71258473..969d9533c1 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -242,6 +242,8 @@ struct Sizes { big_layer: (u32, u32), /// Layers in the "150-layer document". many_layers: usize, + /// The full-canvas layers document (L3): size and layer count. + full_layers: (u32, u32, usize), /// The 16-bit document (15000 × 10000). huge16: (u32, u32), /// The 32-bit document with 20 layers. @@ -258,6 +260,7 @@ const FULL: Sizes = Sizes { layered: (5472, 3648), big_layer: (4243, 2828), many_layers: 150, + full_layers: (3000, 2000, 150), huge16: (15000, 10000), float32: (4000, 3000), ten_mp: (3872, 2592), @@ -269,6 +272,7 @@ const QUICK: Sizes = Sizes { layered: (1600, 1066), big_layer: (1200, 800), many_layers: 40, + full_layers: (800, 600, 20), huge16: (2400, 1600), float32: (1200, 900), ten_mp: (1200, 800), @@ -357,9 +361,10 @@ fn main() { // A panic in one group (a crash the scenario found) is reported against the row that was // running, and the other groups still run. type Group = fn(&mut Bench, &Sizes); - let groups: [(&str, Group); 5] = [ + let groups: [(&str, Group); 6] = [ ("layered", layered_scenarios), ("many layers", many_layer_scenarios), + ("full-canvas layers", full_canvas_layer_scenarios), ("bit depths", depth_scenarios), ("long operations", job_scenarios), ("algorithms", algorithm_scenarios), @@ -539,6 +544,66 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { } /// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there. +/// Layers overhaul L3: edits that redraw the whole document (a layer's opacity, hiding it) with +/// many full-canvas layers, under the app's own GPU memory budget (Memory Usage at its default, +/// the adapter's kind; `gpu_canvas::memory_budget`). Past what the budget holds, each one went to +/// the CPU compositor (2.6 s at 150 layers of 6 MP on a discrete card). +fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) { + let opacity = "opacity of a layer among many full-canvas layers + refresh"; + let hide = "hide a layer among many full-canvas layers + refresh"; + if !b.wanted(opacity) && !b.wanted(hide) { + return; + } + b.current = Some(opacity.to_string()); + let (w, h, n) = sz.full_layers; + let mut s = Session::new(); + let built = (|| -> Res { + exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?; + let mut mid = 0; + for i in 0..n as u32 { + exec(&mut s, "layer.new.layer", json!({"name": format!("full {i}")}))?; + exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}{:02x}80", (i * 37) % 256, (i * 91) % 256), "opacity": 3}))?; + if i % 7 == 3 { + exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?; + } + if i as usize == n / 2 { + mid = active_layer(&s)?; + } + } + Ok(mid) + })(); + let mid = match built { + Ok(v) => v, + Err(e) => { + b.errors.push((opacity.to_string(), e)); + return; + } + }; + if let (Some((g, _)), Some(st)) = (&b.gpu, s.active()) { + let pixels = st.history.pixel_bytes(&st.doc) as u64; + let allowance = u64::from(photocraft_engine::prefs::Performance::default().memory_usage_mb) << 20; + g.set_memory_budget(photocraft_ui_egui::gpu_canvas::memory_budget( + allowance, + pixels, + photocraft_ui_egui::gpu_canvas::physical_memory(), + g.video_memory(), + 0, + )); + } + let _ = b.refresh(&s, true); + let reps = b.reps; + b.time(opacity, &mut s, reps, true, |b, s, i| { + let t = Instant::now(); + exec(s, "layer.setProps", json!({"layer": mid, "opacity": if i % 2 == 0 { 0.5 } else { 1.0 }}))?; + Ok(ms(t) + b.refresh(s, false)?) + }); + b.time(hide, &mut s, reps, true, |b, s, i| { + let t = Instant::now(); + exec(s, "layer.setProps", json!({"layer": mid, "visible": i % 2 == 1}))?; + Ok(ms(t) + b.refresh(s, false)?) + }); +} + fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) { // Row names stay stable across modes: the layer count is in the mode, not the key. let name = "nudge 1 px in the many-layer document + refresh".to_string(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a85c25c3c4..1ee7f02db9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1615,7 +1615,13 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: grid: None, }); let present = cache.on_gpu && gpu.has(id.0, size); - if present && cache.revision == revision && cache.preview_key == preview_key { + let current = cache.revision == revision && cache.preview_key == preview_key; + // GPU Memory or Memory Usage changed with nothing to redraw: the new budget, and whether the + // layers fit it, show at once (the status bar's GPU Memory Full). + if present && app.perf.gpu_budget_allowance != budget_allowance(app) { + gpu_budget(app, &gpu, idx, false, visible); + } + if present && current { // 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()); @@ -1633,6 +1639,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: gpu_budget(app, &gpu, idx, partial, visible); let defer = crate::background_redraw::enabled(app); let r = gpu.refresh_with(id.0, &doc, if partial { damage } else { None }, display.as_deref(), defer); + app.perf.gpu_layer_fit = gpu.layer_fit(); if r.kind == "lost" || r.kind == crate::gpu_canvas::REFRESH_OVER_BUDGET { // The device is gone (`gpu_status` then drops the GPU canvas) or the canvas is over the // texture budget: draw this frame on the CPU path. @@ -1673,22 +1680,32 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: true } -/// Point the wgpu compositor at what the view shows, and size its memory budget from Memory -/// Usage, the document's pixels and History, physical memory and the GPU's own: on full refreshes -/// (pixels counted once per structural change, not per brush dab) and whenever the preference -/// changes. +/// Point the wgpu compositor at what the view shows, and size its memory budget from GPU Memory, +/// or Memory Usage, the document's pixels and History (charged where the GPU shares the system's +/// memory), physical memory and the GPU's own: on full refreshes (pixels counted once per +/// structural change, not per brush dab) and whenever either preference changes. fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) { gpu.set_focus(Some(visible)); - let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20); - if partial && app.perf.gpu_budget_allowance == allowance && gpu.memory_budget().is_some() { + let allowance = budget_allowance(app); + let changed = app.perf.gpu_budget_allowance != allowance; + if partial && !changed && gpu.memory_budget().is_some() { return; } let Some(st) = app.session.documents().get(idx) else { return }; let pixels = st.memory_bytes() as u64; - let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory()); + let budget = crate::gpu_canvas::memory_budget(allowance.0, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory(), allowance.1); gpu.set_memory_budget(budget); app.perf.gpu_budget_mb = budget >> 20; app.perf.gpu_budget_allowance = allowance; + if changed && let Some(fit) = gpu.measure_fit(&st.doc) { + app.perf.gpu_layer_fit = Some(fit); + } +} + +/// Memory Usage and GPU Memory (Preferences › Performance), in bytes. +fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) { + let performance = &app.session.prefs().performance; + (u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20)) } /// Live preview for an open filter dialog: run the filter on the proxy and upload it. With diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index bbdce8fa78..02fa202cc5 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -183,6 +183,24 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { status_message(app, ui, &t); } +/// The active document's layers don't fit the GPU memory budget (`perf.gpuLayerFit`): edits that +/// redraw the whole document do it on the CPU, so say so in the status bar until they fit again. +/// Clicking it opens Preferences › Performance, where GPU Memory raises the budget. +pub fn gpu_memory_status(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let Some(doc) = app.session.active().map(|st| st.doc.id.0) else { return }; + let Some(fit) = app.perf.gpu_layer_fit.filter(|f| f.doc == doc && !f.fits()) else { return }; + let t = Tokens::get(ui.ctx()); + let resp = ui + .add(egui::Label::new(RichText::new(tl!("GPU Memory Full")).color(t.warning).size(12.0)).sense(Sense::click())) + .on_hover_text(crate::i18n::fmt( + tl!("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", &fmt_bytes(fit.needed)), ("budget", &fmt_bytes(fit.budget))], + )); + if resp.clicked() { + crate::shortcut_dispatch::dispatch(app, ui.ctx(), "edit.preferences.performance"); + } +} + /// The latest status message after a separator, in the warning colour when it is an error. fn status_message(app: &PhotocraftApp, ui: &mut egui::Ui, t: &Tokens) { if app.ui.status.is_empty() { @@ -293,6 +311,39 @@ mod tests { assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), "722.7 pt x 451.7 pt (240 ppi)"); } + /// L3: layers over the GPU memory budget show "GPU Memory Full" in the status bar, for that + /// document only, and clicking it opens Preferences › Performance (GPU Memory). + #[test] + fn status_bar_says_when_layers_dont_fit_gpu_memory() { + use egui_kittest::{Harness, kittest::Queryable}; + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut h = Harness::builder().with_size(vec2(1200.0, 700.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.theme = theme; + app + }); + h.state_mut().run("file.new", json!({"width": 800, "height": 600})).unwrap(); + h.state_mut().sync_views(); + h.run_steps(4); + let doc = h.state().session.active().unwrap().doc.id.0; + assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: nothing measured yet"); + let over = crate::gpu_canvas::LayerFit { doc, needed: 3 << 30, budget: 2 << 30 }; + h.state_mut().perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { doc: doc + 1, ..over }); + h.run_steps(2); + assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: another document's layers"); + h.state_mut().perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { budget: 4 << 30, ..over }); + h.run_steps(2); + assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: they fit"); + h.state_mut().perf.gpu_layer_fit = Some(over); + h.run_steps(2); + h.get_by_label("GPU Memory Full").click(); + h.run_steps(4); + let open = h.state().ui.dialogs.iter().any(|d| d.fields.get("section") == Some(&json!("performance"))); + assert!(open, "{theme:?}: Preferences › Performance opens"); + } + } + #[test] fn status_bar_updates_dimensions_after_a_ruler_preference_change() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index e53e89888f..8ef3973305 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -303,6 +303,28 @@ impl GpuCanvas { Some((c.resident_bytes(), c.fx_cache_bytes().saturating_add(usize::try_from(c.composite_cache_bytes()).unwrap_or(usize::MAX)))) } + /// Whether `doc`'s layers fit the budget as it now stands, measured without a refresh (a GPU + /// Memory change shows in the status bar at once). `None` before the compositor exists. + pub fn measure_fit(&self, doc: &photocraft_doc::Document) -> Option { + let mut r = self.rs.renderer.write(); + let res = r.callback_resources.get_mut::()?; + let budget = res.compositor_budget; + let comp = res.compositor.as_mut()?; + if let Some(b) = budget { + comp.set_memory_budget(b); + } + let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, doc.bounds()), budget: comp.resident_budget() }; + res.layer_fit = Some(fit); + Some(fit) + } + + /// Whether the layers of the last full (or large) refresh fit the GPU memory budget, and by + /// how much (`None` before the first one). + pub fn layer_fit(&self) -> Option { + let r = self.rs.renderer.read(); + r.callback_resources.get::()?.layer_fit + } + /// Set the checkerboard and gamut warning colours. pub fn set_style(&self, style: CanvasStyle) { let mut r = self.rs.renderer.write(); @@ -471,9 +493,13 @@ impl GpuCanvas { // compositor does it (in the background on desktop). So does a large damage rect, such // as hiding a big layer (#4024); small edits in the view stay on the GPU. let large = region.width() as u64 * region.height() as u64 >= DEFER_PX; - if (region == doc.bounds() || large) && !comp.fits_budget(doc, region) { - res.compositor = Some(comp); - return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into())); + if region == doc.bounds() || large { + let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, region), budget: comp.resident_budget() }; + res.layer_fit = Some(fit); + if !fit.fits() { + res.compositor = Some(comp); + return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into())); + } } if fresh { let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else { @@ -926,9 +952,13 @@ pub struct Perf { /// GPU memory the compositor may hold for layer pages and effect maps (MB; see /// [`memory_budget`]). pub gpu_budget_mb: u64, - /// The Memory Usage allowance (bytes) `gpu_budget_mb` was computed from. + /// The Memory Usage allowance and the GPU Memory preference (bytes) `gpu_budget_mb` was + /// computed from. #[serde(skip)] - pub gpu_budget_allowance: u64, + pub gpu_budget_allowance: (u64, u64), + /// The layer pages the last full refresh needed against the part of the budget they may + /// hold; when they don't fit, edits that redraw the whole document do it on the CPU. + pub gpu_layer_fit: Option, /// Last command run through the app and its duration (ms). pub last_command: String, pub command_ms: f64, @@ -1212,22 +1242,45 @@ fn texel_to_f32(format: wgpu::TextureFormat, b: &[u8]) -> [f32; 4] { // --------------------------------------------------------------------------------------------- // GPU resources +/// The GPU memory a refresh's layer pages need against what they may hold (bytes). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LayerFit { + /// The document refreshed. + pub doc: u64, + pub needed: u64, + pub budget: u64, +} + +impl LayerFit { + pub fn fits(&self) -> bool { + self.needed <= self.budget + } +} + /// 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"; -/// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage -/// (`allowance`, Preferences › Performance) leaves after the document's pixels and History -/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown) and -/// [`vram_share`] of a discrete GPU's own memory (`vram`, from [`video_memory`]), and at least -/// [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the document, so -/// a huge document keeps its layers on the GPU only as far as memory allows; the rest is -/// uploaded per refresh as the view needs it. -pub fn memory_budget(allowance: u64, pixels: u64, ram: Option, vram: Option) -> u64 { +/// GPU memory the wgpu compositor may hold for layer pages and effect maps. +/// +/// `fixed` (Preferences › Performance › GPU Memory, bytes) sets it when it isn't 0, within +/// [`vram_share`] of a discrete GPU's own memory (`vram`, from [`video_memory`]). Otherwise it is +/// Memory Usage (`allowance`) within a quarter of physical memory (`ram`; 16 GB assumed when +/// unknown) and the VRAM share. The document's pixels and History (`pixels`) come off Memory Usage +/// unless the card's own memory is known: a discrete card doesn't hold them, while integrated and +/// software adapters (and a card whose memory can't be read) share the system's. Never below +/// [`MIN_GPU_BUDGET`]. Layers beyond it redraw full refreshes on the CPU ([`OVER_BUDGET`]). +pub fn memory_budget(allowance: u64, pixels: u64, ram: Option, vram: Option, fixed: u64) -> u64 { + let card = vram.map_or(u64::MAX, vram_share); + if fixed > 0 { + return fixed.min(card).max(MIN_GPU_BUDGET); + } let ram = ram.unwrap_or(16 << 30); - allowance.saturating_sub(pixels).min(ram / 4).min(vram.map_or(u64::MAX, vram_share)).max(MIN_GPU_BUDGET) + let charged = if vram.is_some() { 0 } else { pixels }; + allowance.saturating_sub(charged).min(ram / 4).min(card).max(MIN_GPU_BUDGET) } /// The share of a discrete GPU's memory each of [`memory_budget`] and [`canvas_budget`] may @@ -1412,6 +1465,9 @@ struct Resources { /// GPU memory the compositor may hold (`None`: its default), and the document area the /// view shows; applied before every composite. compositor_budget: Option, + /// The layer pages the last full (or large) refresh needed and the budget they had to fit + /// (bytes): when they don't fit, full refreshes go to the CPU ([`OVER_BUDGET`]). + layer_fit: Option, compositor_cache_budget: Option, compositor_focus: Option, /// GPU bytes one document's display texture set may take (see [`canvas_budget`]). @@ -1679,6 +1735,7 @@ impl Resources { compositor_failed: None, health: photocraft_gpu::DeviceHealth::new(), compositor_budget: None, + layer_fit: None, compositor_cache_budget: None, compositor_focus: None, canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))), @@ -2337,13 +2394,23 @@ mod tests { #[test] fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; - // What Memory Usage leaves after the document, within a quarter of RAM. - assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), None), 5 * G); - assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G), None), 4 * G); - assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G), None), 4 * G); + // Card memory unknown (integrated, software, or unreadable): what Memory Usage leaves + // after the document, within a quarter of RAM. + assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), None, 0), 5 * G); + assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G), None, 0), 4 * G); + assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G), None, 0), 4 * G); // Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown. - assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G), None), MIN_GPU_BUDGET); - assert_eq!(memory_budget(64 * G, 0, None, None), 4 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G), None, 0), MIN_GPU_BUDGET); + assert_eq!(memory_budget(64 * G, 0, None, None, 0), 4 * G); + // A card's own memory, known, doesn't hold the document's pixels: 10 GB of layers no + // longer squeeze the budget, which stays within 3/8 of the card (#4536). + assert_eq!(memory_budget(8 * G, 10 * G, Some(32 * G), Some(32 * G), 0), 8 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(32 * G), Some(16 * G), 0), 6 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), Some(32 * G), 0), 4 * G); + // GPU Memory set: that much, within the card's share, above the floor. + assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), None, 12 * G), 12 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), Some(16 * G), 12 * G), 6 * G); + assert_eq!(memory_budget(8 * G, 0, None, Some(32 * G), 1 << 20), MIN_GPU_BUDGET); assert!(physical_memory().is_none_or(|b| b >= 256 << 20)); } @@ -2352,13 +2419,14 @@ mod tests { fn gpu_budgets_fit_a_discrete_gpus_own_memory() { const G: u64 = 1 << 30; let (ram, vram) = (Some(64 * G), Some(8 * G)); - assert_eq!(memory_budget(64 * G, 3 * G, ram, vram), 3 * G); + assert_eq!(memory_budget(64 * G, 3 * G, ram, vram, 0), 3 * G); assert_eq!(canvas_budget(ram, vram), 3 * G); - assert!(memory_budget(64 * G, 0, ram, vram) + canvas_budget(ram, vram) <= 6 * G, "a quarter of the card stays free"); - // A card larger than a quarter of RAM changes nothing; a tiny one keeps the floor. - assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), Some(24 * G)), 5 * G); + assert!(memory_budget(64 * G, 0, ram, vram, 0) + canvas_budget(ram, vram) <= 6 * G, "a quarter of the card stays free"); + // A card larger than a quarter of RAM caps nothing (its own memory isn't charged for the + // document's 3 GB); a tiny one keeps the floor. + assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), Some(24 * G), 0), 8 * G); assert_eq!(canvas_budget(Some(16 * G), Some(24 * G)), 4 * G); - assert_eq!(memory_budget(64 * G, 0, ram, Some(G)), MIN_GPU_BUDGET); + assert_eq!(memory_budget(64 * G, 0, ram, Some(G), 0), MIN_GPU_BUDGET); assert_eq!(canvas_budget(ram, Some(G)), MIN_GPU_BUDGET); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c73c032df7..e9cc357fc9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2442,7 +2442,6 @@ cameraRaw Color Barvy Redrawing Překreslování This redraw took {seconds} s on the CPU. Toto překreslení na CPU trvalo {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Úpravy, které mění celý dokument, například režim prolnutí, krytí nebo viditelnost vrstvy, jej překreslí celý. - 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. Vrstvy se nevejdou do rozpočtu paměti GPU. Zvýšením hodnoty Využití paměti v Předvolbách › Výkon se rozpočet zvětší, nejvýše na čtvrtinu paměti tohoto počítače. The GPU compositor wasn't used: {reason} Kompozitor GPU nebyl použit: {reason} fontWeight Thin Jemná fontWeight ExtraLight Extrajemná @@ -3808,3 +3807,12 @@ navigator Zoom Zvětšení # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Vrstvy potřebují {needed} paměti GPU; rozpočet jim ponechává {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme (Automaticky se řídí Využitím paměti, nejvýše čtvrtinou paměti tohoto počítače). Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná. + 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} + 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 bfa6d5f8f1..23374a2993 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2481,7 +2481,6 @@ layerLabel No Color Keine Farbe Redrawing Neuzeichnen This redraw took {seconds} s on the CPU. Dieses Neuzeichnen auf der CPU dauerte {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Änderungen, die das ganze Dokument betreffen, etwa Füllmethode, Deckkraft oder Sichtbarkeit einer Ebene, zeichnen es vollständig neu. - 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. Die Ebenen passen nicht in das GPU-Speicherbudget. Eine höhere Speichernutzung unter Voreinstellungen › Leistung erhöht das Budget, bis zu einem Viertel des Arbeitsspeichers dieses Computers. The GPU compositor wasn't used: {reason} Der GPU-Compositor wurde nicht verwendet: {reason} cameraRaw Light Licht fontWeight Thin Haarfein @@ -3202,3 +3201,12 @@ curves Smooth Glätten # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Die Ebenen brauchen {needed} GPU-Speicher; das Budget lässt ihnen {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann (Automatisch folgt der Speichernutzung, bis zu einem Viertel des Arbeitsspeichers dieses Computers). Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll. + 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} + 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 eccfd0508c..f3743db5c8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3439,7 +3439,6 @@ cameraRaw Yellow Κίτρινο HSB/HSL HSB/HSL Import Swatches… Εισαγωγή δειγμάτων χρωμάτων… Impressionist Ιμπρεσιονιστικό - 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. Τα επίπεδά του δεν χωρούν στο όριο μνήμης της GPU. Αυξάνοντας τη Χρήση μνήμης στις Προτιμήσεις › Επιδόσεις αυξάνεται το όριο, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή. Knockout Διάτρηση Large document: redrawing on the CPU Μεγάλο έγγραφο: επανασχεδίαση στη CPU Redrawing Επανασχεδίαση @@ -3900,3 +3899,12 @@ curves Smooth Εξομάλυνση # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Τα επίπεδά του χρειάζονται {needed} μνήμης GPU· το όριο τους αφήνει {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας (το Αυτόματο ακολουθεί τη Χρήση μνήμης, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή). Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης. + 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} + 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 da9337eaea..3d00c696de 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2512,7 +2512,6 @@ cameraRaw Amount Cantidad Redrawing Redibujando This redraw took {seconds} s on the CPU. Este redibujado en la CPU tardó {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Las ediciones que cambian todo el documento, como el modo de fusión, la opacidad o la visibilidad de una capa, lo redibujan por completo. - 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. Las capas no caben en el presupuesto de memoria de la GPU. Aumentar Uso de memoria en Preferencias › Rendimiento amplía el presupuesto, hasta una cuarta parte de la memoria de este equipo. The GPU compositor wasn't used: {reason} No se usó el compositor de GPU: {reason} fontWeight Thin Ultrafina fontWeight ExtraLight Extrafina @@ -4162,3 +4161,12 @@ navigator Zoom Zoom # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine. The transform couldn't be applied. No se pudo aplicar la transformación. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sus capas necesitan {needed} de memoria de GPU; el presupuesto les deja {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica (Automático sigue Uso de memoria, hasta una cuarta parte de la memoria de este equipo). Mientras no quepan, la barra de estado muestra Memoria de GPU llena. + 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} + 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 8b25af722e..2aa12b16e6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2862,7 +2862,6 @@ cameraRaw Blending Fusion Redrawing Redessin This redraw took {seconds} s on the CPU. Ce redessin sur le CPU a pris {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Les modifications qui touchent tout le document, comme le mode de fusion, l'opacité ou la visibilité d'un calque, le redessinent entièrement. - 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. Les calques dépassent le budget de mémoire GPU. Augmenter Utilisation de la mémoire dans Préférences › Performances relève ce budget, jusqu'au quart de la mémoire de cet ordinateur. The GPU compositor wasn't used: {reason} Le compositeur GPU n'a pas été utilisé : {reason} fontWeight Thin Ultrafin fontWeight ExtraLight Extrafin @@ -3205,3 +3204,12 @@ curves Smooth Lisser # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée. The transform couldn't be applied. Impossible d'appliquer la transformation. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Ses calques ont besoin de {needed} de mémoire GPU ; le budget leur laisse {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir (Automatique suit Utilisation de la mémoire, jusqu'au quart de la mémoire de cet ordinateur). Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine. + 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} + 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 2870dc6f99..6d9eff4751 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3312,7 +3312,6 @@ cameraRaw Yellow Sárga Interpret Footage Videoanyag értelmezése Interval Intervallum Invalid {mode} profile Érvénytelen profil ({mode}) - 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. A dokumentum rétegei nem férnek el a GPU memóriakeretében. Ha a Beállítások › Teljesítmény alatt növeled a memóriahasználatot, a keret is nő, legfeljebb a számítógép memóriájának negyedéig. Join us on Discord Csatlakozz hozzánk a Discordon Keep Using CPU Marad a CPU Kernel Szűrőmag @@ -4119,3 +4118,12 @@ cameraRaw Yellow Sárga # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött. The transform couldn't be applied. Az átalakítás nem alkalmazható. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. A rétegeknek {needed} GPU-memória kell; a keret {budget} hagy nekik. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír (az Automatikus a memóriahasználatot követi, legfeljebb a számítógép memóriájának negyedéig). Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja. + 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} + 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 feca83a9c7..dd972b82d8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2849,7 +2849,6 @@ cameraRaw Blending Pembauran Redrawing Menggambar ulang This redraw took {seconds} s on the CPU. Penggambaran ulang di CPU ini memakan waktu {seconds} dtk. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Suntingan yang mengubah seluruh dokumen, seperti mode campuran, opasitas, atau visibilitas layer, menggambar ulang semuanya. - 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. Layer tidak muat dalam anggaran memori GPU. Menaikkan Pemakaian memori di Preferensi › Performa menaikkan anggaran, hingga seperempat memori komputer ini. The GPU compositor wasn't used: {reason} Kompositor GPU tidak digunakan: {reason} fontWeight Thin Sangat Tipis fontWeight ExtraLight Ekstra Tipis @@ -3192,3 +3191,12 @@ curves Smooth Haluskan # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Transformasi Bebas masih diterapkan; coba lagi setelah selesai. The transform couldn't be applied. Transformasi tidak dapat diterapkan. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Layer-nya membutuhkan {needed} memori GPU; anggaran menyisakan {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda (Otomatis mengikuti Pemakaian memori, hingga seperempat memori komputer ini). Selama belum muat, bilah status menampilkan Memori GPU Penuh. + 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} + 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 a8796e64e7..1b76fdee65 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2512,7 +2512,6 @@ cameraRaw Amount Quantità Redrawing Ridisegno This redraw took {seconds} s on the CPU. Questo ridisegno sulla CPU ha richiesto {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Le modifiche che cambiano tutto il documento, come metodo di fusione, opacità o visibilità di un livello, lo ridisegnano per intero. - 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. I livelli non rientrano nel budget di memoria della GPU. Aumentare Uso memoria in Preferenze › Prestazioni alza il budget, fino a un quarto della memoria di questo computer. The GPU compositor wasn't used: {reason} Il compositore GPU non è stato usato: {reason} fontWeight Thin Sottile fontWeight ExtraLight Extraleggero @@ -4321,3 +4320,12 @@ shape Appearance Aspetto # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Trasformazione libera è ancora in corso di applicazione; riprova quando è terminata. The transform couldn't be applied. Impossibile applicare la trasformazione. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. I livelli richiedono {needed} di memoria GPU; il budget ne lascia {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Imposta Memoria GPU in Preferenze › Prestazioni su quanto può contenere la tua scheda grafica (Automatico segue Uso memoria, fino a un quarto della memoria di questo computer). Finché non ci stanno, la barra di stato mostra Memoria GPU piena. + 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} + 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 13efa7517d..2b064edcdf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2413,7 +2413,6 @@ cameraRaw Color カラー Redrawing 再描画中 This redraw took {seconds} s on the CPU. この再描画は CPU で {seconds} 秒かかりました。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. レイヤーの描画モード、不透明度、表示/非表示など、ドキュメント全体に関わる編集では全体が再描画されます。 - 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. レイヤーが GPU メモリの予算に収まりません。環境設定 › パフォーマンスのメモリ使用量を増やすと、このコンピューターのメモリの4分の1まで予算が増えます。 The GPU compositor wasn't used: {reason} GPU コンポジターは使用されませんでした: {reason} fontWeight Thin 極細 fontWeight ExtraLight 特細 @@ -3876,3 +3875,12 @@ curves Smooth スムーズ # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. レイヤーには {needed} の GPU メモリが必要ですが、予算で使えるのは {budget} です。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量に設定してください(自動はメモリ使用量に従い、このコンピューターのメモリの4分の1までです)。収まらない間、ステータスバーに「GPU メモリ不足」と表示されます。 + 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} + 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 5354b04a31..5248924d6e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3022,7 +3022,6 @@ cameraRaw Color 색상 Redrawing 다시 그리는 중 This redraw took {seconds} s on the CPU. 이번 다시 그리기는 CPU에서 {seconds}초 걸렸습니다. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 레이어의 혼합 모드, 불투명도, 표시 여부처럼 문서 전체를 바꾸는 편집은 문서 전체를 다시 그립니다. - 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. 레이어가 GPU 메모리 예산에 맞지 않습니다. 환경 설정 › 성능에서 메모리 사용량을 늘리면 이 컴퓨터 메모리의 4분의 1까지 예산이 늘어납니다. The GPU compositor wasn't used: {reason} GPU 합성기를 사용하지 않았습니다: {reason} fontWeight Thin 아주 가늘게 fontWeight ExtraLight 더 가늘게 @@ -3378,3 +3377,12 @@ curves Smooth 매끄럽게 # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. 레이어에 GPU 메모리 {needed}가 필요하지만 예산은 {budget}만 남겨 둡니다. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(자동은 메모리 사용량을 따르며 이 컴퓨터 메모리의 4분의 1까지입니다). 맞지 않는 동안 상태 표시줄에 GPU 메모리 부족이 표시됩니다. + 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} + 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 5a013c7862..efb03ed9f4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2441,7 +2441,6 @@ layerLabel No Color Geen kleur Redrawing Opnieuw tekenen This redraw took {seconds} s on the CPU. Opnieuw tekenen duurde {seconds} s op de CPU. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bewerkingen die het hele document veranderen, zoals de overvloeimodus, dekking of zichtbaarheid van een laag, tekenen alles opnieuw. - 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. De lagen passen niet binnen het GPU-geheugenbudget. Door Geheugengebruik te verhogen in Voorkeuren › Prestaties wordt het budget groter, tot een kwart van het geheugen van deze computer. The GPU compositor wasn't used: {reason} De GPU-compositor is niet gebruikt: {reason} cameraRaw Light Licht fontWeight Thin Dun @@ -3960,3 +3959,12 @@ curves Smooth Vloeiend maken # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Vrije transformatie wordt nog toegepast; probeer het opnieuw als die klaar is. The transform couldn't be applied. De transformatie kon niet worden toegepast. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. De lagen hebben {needed} GPU-geheugen nodig; het budget laat ze {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan (Automatisch volgt Geheugengebruik, tot een kwart van het geheugen van deze computer). Zolang ze niet passen, toont de statusbalk GPU-geheugen vol. + 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} + 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 8707b5fb2e..31ed39fab3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3471,7 +3471,6 @@ cameraRaw Roughness Chropowatość Redrawing Odświeżanie This redraw took {seconds} s on the CPU. To odświeżenie na CPU trwało {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Zmiany obejmujące cały dokument, takie jak tryb mieszania, krycie lub widoczność warstwy, odświeżają go w całości. - 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. Warstwy nie mieszczą się w budżecie pamięci GPU. Zwiększenie wartości Użycie pamięci w Preferencje › Wydajność zwiększa budżet, maksymalnie do jednej czwartej pamięci tego komputera. The GPU compositor wasn't used: {reason} Kompozytor GPU nie został użyty: {reason} # Pattern Stamp tool Pattern Stamp Tool Narzędzie Pieczątka wzoru @@ -3798,3 +3797,12 @@ curves Smooth Wygładź # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Przekształcanie swobodne jest nadal stosowane; spróbuj ponownie, gdy się zakończy. The transform couldn't be applied. Nie można zastosować przekształcenia. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Warstwy potrzebują {needed} pamięci GPU; budżet zostawia im {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ustaw Pamięć GPU w Preferencje › Wydajność na tyle, ile zmieści Twoja karta graficzna (Automatycznie podąża za Użyciem pamięci, maksymalnie do jednej czwartej pamięci tego komputera). Dopóki się nie mieszczą, pasek stanu pokazuje Pamięć GPU pełna. + 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} + 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 ef10787ceb..5f0b254f64 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2865,7 +2865,6 @@ cameraRaw Whites Brancos Redrawing Redesenhando This redraw took {seconds} s on the CPU. Este redesenho na CPU levou {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Edições que mudam o documento inteiro, como modo de mesclagem, opacidade ou visibilidade de uma camada, redesenham tudo. - 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. As camadas não cabem no orçamento de memória da GPU. Aumentar Uso de memória em Preferências › Desempenho eleva o orçamento, até um quarto da memória deste computador. The GPU compositor wasn't used: {reason} O compositor de GPU não foi usado: {reason} fontWeight Thin Fina fontWeight ExtraLight Extrafina @@ -3990,3 +3989,12 @@ layerLabel No Color Sem cor # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. A Transformação livre ainda está sendo aplicada; tente novamente quando terminar. The transform couldn't be applied. Não foi possível aplicar a transformação. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. As camadas precisam de {needed} de memória da GPU; o orçamento lhes deixa {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ajuste Memória da GPU em Preferências › Desempenho para o que sua placa de vídeo comporta (Automático segue Uso de memória, até um quarto da memória deste computador). Enquanto não couberem, a barra de status mostra Memória da GPU cheia. + 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} + 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 449f741e21..03247c0c49 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2712,7 +2712,6 @@ cameraRaw Roughness Неровность Redrawing Перерисовка This redraw took {seconds} s on the CPU. Эта перерисовка на CPU заняла {seconds} с. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Правки, затрагивающие весь документ, например режим наложения, непрозрачность или видимость слоя, перерисовывают его целиком. - 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. Слои не помещаются в бюджет памяти GPU. Увеличение параметра «Память» в разделе Настройки › Производительность повышает бюджет, но не больше четверти памяти этого компьютера. The GPU compositor wasn't used: {reason} Компоновщик GPU не использовался: {reason} fontWeight Thin Тонкий fontWeight ExtraLight Сверхтонкий @@ -3170,3 +3169,12 @@ curves Smooth Сгладить # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Слоям нужно {needed} памяти GPU; бюджет оставляет им {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта («Автоматически» следует параметру «Память», но не больше четверти памяти этого компьютера). Пока слои не помещаются, в строке состояния показано «Память GPU заполнена». + 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} + 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 4dedfaf7a1..d63ea98c5c 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2836,7 +2836,6 @@ cameraRaw Blending Stapanje Large document: redrawing on the CPU Veliki dokument: ponovno iscrtavanje procesorom This redraw took {seconds} s on the CPU. Ovo iscrtavanje je trajalo {seconds} s na procesoru. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Izmene koje menjaju ceo dokument, kao što su režim stapanja, neprozirnost ili vidljivost sloja, ponovo iscrtavaju ceo dokument. - 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. Njegovi slojevi ne staju u budžet GPU memorije. Povećanje vrednosti Korišćenje memorije u Podešavanja › Performanse povećava budžet, do četvrtine memorije ovog računara. The GPU compositor wasn't used: {reason} GPU kompozitor nije korišćen: {reason} fontWeight Thin Tanko fontWeight ExtraLight Ekstra lagano @@ -4273,3 +4272,12 @@ shape Appearance Izgled # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primenjuje; pokušajte ponovo kad se završi. The transform couldn't be applied. Transformacija nije mogla da se primeni. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Njegovim slojevima treba {needed} GPU memorije; budžet im ostavlja {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Podesite GPU memoriju u Podešavanja › Performanse na onoliko koliko vaša grafička kartica može da primi (Automatski prati Korišćenje memorije, do četvrtine memorije ovog računara). Dok ne staju, statusna traka prikazuje GPU memorija je puna. + 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} + 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 f7dc6961fa..aaa50e4791 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2410,7 +2410,6 @@ layerLabel No Color Renk Yok 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} cameraRaw Light Açık Tonlar fontWeight Thin İnce @@ -3930,3 +3929,12 @@ cameraData Lens Lens # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Serbest Dönüştürme hâlâ uygulanıyor; bittiğinde yeniden deneyin. The transform couldn't be applied. Dönüştürme uygulanamadı. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Katmanlarının {needed} GPU belleğine ihtiyacı var; bütçe onlara {budget} bırakıyor. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Tercihler › Performans içindeki GPU Belleği'ni ekran kartınızın kaldırabileceği miktara ayarlayın (Otomatik, Bellek kullanımını izler; en fazla bu bilgisayarın belleğinin dörtte biri). Sığmadıkları sürece durum çubuğu GPU Belleği Dolu gösterir. + 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} + 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 9a018c744a..aa0fabff4d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2679,7 +2679,6 @@ cameraRaw Roughness Шорсткість Redrawing Перемальовування This redraw took {seconds} s on the CPU. Це перемальовування на CPU тривало {seconds} с. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Зміни, що впливають на весь документ, як-от режим накладання, непрозорість чи видимість шару, перемальовують його повністю. - 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. Його шари не вміщуються в бюджет пам'яті GPU. Збільшення використання пам'яті в Налаштування › Продуктивність підвищує бюджет до чверті пам'яті цього комп'ютера. The GPU compositor wasn't used: {reason} Компонувальник GPU не використовувався: {reason} fontWeight Thin Тонкий fontWeight ExtraLight Екстралегкий @@ -3899,3 +3898,12 @@ curves Smooth Згладити # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Його шарам потрібно {needed} пам'яті GPU; бюджет залишає їм {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта («Автоматично» стежить за використанням пам'яті, до чверті пам'яті цього комп'ютера). Поки шари не вміщуються, рядок стану показує «Пам'ять GPU заповнена». + 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} + 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 b798849d72..49ad7f59fc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2156,7 +2156,6 @@ layers Fill: Độ tô: Imported {n} shortcuts from {file}. Click OK to keep them. Đã nhập {n} phím tắt từ {file}. Nhấp OK để giữ. Indexed Color… Màu lập chỉ mục… Install Plug-in… Cài plug-in… - 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. Các lớp không vừa ngân sách bộ nhớ GPU. Tăng mức sử dụng bộ nhớ trong Tùy chọn › Hiệu năng sẽ nâng ngân sách, tối đa một phần tư bộ nhớ máy này. Labels Nhãn Large document: redrawing on the CPU Tài liệu lớn: đang vẽ lại trên CPU Redrawing Đang vẽ lại @@ -3686,3 +3685,12 @@ curves Smooth Làm mượt # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Các lớp cần {needed} bộ nhớ GPU; ngân sách chỉ dành cho chúng {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Đặt Bộ nhớ GPU trong Tùy chọn › Hiệu năng bằng mức card đồ họa của bạn chứa được (Tự động theo mức sử dụng bộ nhớ, tối đa một phần tư bộ nhớ máy này). Khi chúng chưa vừa, thanh trạng thái hiện Bộ nhớ GPU đầy. + 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} + 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 766497d67b..0186d4cf7e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2454,7 +2454,6 @@ cameraRaw Blending 混合 Redrawing 正在重绘 This redraw took {seconds} s on the CPU. 此次 CPU 重绘耗时 {seconds} 秒。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 更改整个文档的编辑(如图层的混合模式、不透明度或可见性)会重绘整个文档。 - 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. 图层超出 GPU 内存预算。在 首选项 › 性能 中提高内存使用量可增加预算,最多为本机内存的四分之一。 The GPU compositor wasn't used: {reason} 未使用 GPU 合成器:{reason} fontWeight Thin 极细 fontWeight ExtraLight 特细 @@ -2816,3 +2815,12 @@ curves Smooth 平滑 # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其图层需要 {needed} GPU 内存;预算只留给它们 {budget}。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小(自动跟随内存使用量,最多为本机内存的四分之一)。在图层放不下期间,状态栏显示“GPU 内存已满”。 + 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} + 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 3627c785e8..f87c71ccd9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2424,7 +2424,6 @@ cameraRaw Blending 混合 Redrawing 正在重繪 This redraw took {seconds} s on the CPU. 此次 CPU 重繪耗時 {seconds} 秒。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 變更整份文件的編輯(例如圖層的混合模式、不透明度或可見度)會重繪整份文件。 - 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. 圖層超出 GPU 記憶體預算。在 偏好設定 › 效能 中提高記憶體使用量可增加預算,最多為本機記憶體的四分之一。 The GPU compositor wasn't used: {reason} 未使用 GPU 合成器:{reason} fontWeight Thin 極細 fontWeight ExtraLight 特細 @@ -2801,3 +2800,12 @@ curves Smooth 平滑 # Free Transform: a new layer while the box is open (#5859) 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其圖層需要 {needed} GPU 記憶體;預算只留給它們 {budget}。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小(自動會跟隨記憶體使用量,最多為本機記憶體的四分之一)。在圖層放不下期間,狀態列會顯示「GPU 記憶體已滿」。 + 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} + 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/notices.rs b/crates/ui-egui/src/notices.rs index 146b8b8180..d1ed6d37cc 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -105,8 +105,14 @@ pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Opti tl!("Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it.").into(), ]; if reason == Some(crate::gpu_canvas::OVER_BUDGET) { + if let Some(fit) = app.perf.gpu_layer_fit.filter(|f| f.doc == doc && !f.fits()) { + lines.push(crate::i18n::fmt( + tl!("Its layers need {needed} of GPU memory; the budget leaves them {budget}."), + &[("needed", &crate::chrome_ui::fmt_bytes(fit.needed)), ("budget", &crate::chrome_ui::fmt_bytes(fit.budget))], + )); + } lines.push( - tl!("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.") + tl!("Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit.") .into(), ); } else if let Some(r) = reason { @@ -354,12 +360,15 @@ mod tests { slow_cpu_refresh(&mut app, 1, f64::NAN, Some(crate::gpu_canvas::OVER_BUDGET)); assert!(app.ui.notices.is_empty(), "a quick refresh needs no explanation"); + // The layers of document 1 need 3.6 GB and may hold 3.4 GB: the notice says so. + app.perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { doc: 1, needed: 3600 << 20, budget: 3400 << 20 }); slow_cpu_refresh(&mut app, 1, 55_000.0, Some(crate::gpu_canvas::OVER_BUDGET)); assert_eq!(app.ui.notices.len(), 1); let text = app.ui.notices[0].lines.join(" "); assert!(text.contains("55.0 s"), "{text}"); assert!(text.contains("blend mode, opacity or visibility"), "{text}"); - assert!(text.contains("Memory usage in Preferences › Performance"), "{text}"); + assert!(text.contains("need 3.5G of GPU memory; the budget leaves them 3.3G"), "{text}"); + assert!(text.contains("GPU Memory in Preferences › Performance"), "{text}"); assert!(!app.ui.notices[0].error); slow_cpu_refresh(&mut app, 1, 55_000.0, Some(crate::gpu_canvas::OVER_BUDGET)); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 73eb4c971f..4793554150 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1613,6 +1613,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.with_layout(crate::direction::trailing(), |ui| { crate::jobs_ui::status_progress(app, ui); crate::background_redraw::status(app, ui); + crate::chrome_ui::gpu_memory_status(app, ui); }); return; } @@ -1653,6 +1654,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::jobs_ui::status_progress(app, ui); } crate::background_redraw::status(app, ui); + crate::chrome_ui::gpu_memory_status(app, ui); }); }); }); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index b59e7803f5..e9282e44c1 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1053,7 +1053,9 @@ pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missi pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { match f.get("__prefsui").and_then(Value::as_str).unwrap_or("") { "prefs" => { - f.insert("__gpuInfo".into(), json!(app.perf.gpu_info.lines())); + let mut info = app.perf.gpu_info.lines(); + info.extend(gpu_memory_lines(app)); + f.insert("__gpuInfo".into(), json!(info)); let system = system_theme(app, ui.ctx()); prefs_body(ui, f, system); } @@ -1065,6 +1067,27 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Vec { + let mut v = Vec::new(); + if app.perf.gpu_budget_mb > 0 { + let budget = crate::chrome_ui::fmt_bytes(app.perf.gpu_budget_mb.saturating_mul(1 << 20)); + v.push(crate::i18n::fmt(tl!("GPU memory budget: {budget}"), &[("budget", &budget)])); + } + let doc = app.session.active().map(|st| st.doc.id.0); + if let Some(fit) = app.perf.gpu_layer_fit.filter(|f| Some(f.doc) == doc) { + let args = [("needed", crate::chrome_ui::fmt_bytes(fit.needed)), ("budget", crate::chrome_ui::fmt_bytes(fit.budget))]; + let args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + v.push(if fit.fits() { + crate::i18n::fmt(tl!("This document's layers: {needed} of {budget} for layers"), &args) + } else { + crate::i18n::fmt(tl!("This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU"), &args) + }); + } + v +} + fn humanize(key: &str) -> String { match key { "appearanceMode" => return "Appearance Mode".into(), @@ -1099,6 +1122,10 @@ fn humanize(key: &str) -> String { if key == "lowLatencyDrawing" { return "Low-Latency Drawing (vsync off, applies at next launch)".into(); } + // Performance: the canvas compositor's GPU memory, 0 for the automatic budget (L3). + if key == "gpuMemoryMb" { + return "GPU Memory (MB, 0 = Automatic)".into(); + } let mut s = String::new(); for (i, ch) in key.chars().enumerate() { if i == 0 { diff --git a/docs/scorecard.md b/docs/scorecard.md index 8dbbea612e..377ce8583d 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,10 +6,10 @@ 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 | 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 167 (target 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%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | @@ -75,6 +75,8 @@ 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 | | +| P60 | Layer opacity among 150 full-canvas layers, 6 MP (app GPU budget) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#5973](https://github.com/storytold/photocraft/issues/5973) | +| P61 | Hide a layer among 150 full-canvas layers, 6 MP (app GPU budget) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#5973](https://github.com/storytold/photocraft/issues/5973) | **Not measurable yet** (the code can't do it yet; no number is recorded): @@ -177,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 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. +**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. 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`. diff --git a/perf/budgets.toml b/perf/budgets.toml index a95c1c83dc..da47aa0503 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -518,3 +518,21 @@ 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 = "P60" +title = "Layer opacity among 150 full-canvas layers, 6 MP (app GPU budget)" +issue = 5973 +bench = "perf_scenarios" +row = "opacity of a layer among many full-canvas layers + refresh" +budget_ms = 30 +enforce = false # new; no baseline yet + +[[scenario]] +id = "P61" +title = "Hide a layer among 150 full-canvas layers, 6 MP (app GPU budget)" +issue = 5973 +bench = "perf_scenarios" +row = "hide a layer among many full-canvas layers + refresh" +budget_ms = 30 +enforce = false # new; no baseline yet From 6368e6a851da8836a1f0ac978d171b7d0a7643d6 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 21:37:05 -0700 Subject: [PATCH 050/171] GPU memory follow-ups (#5973 review): fit cleared on device loss and on the CPU canvas; measured when switching to a document whose canvas is current; notice names the 3/8 card cap; VRAM override documented; perf rows count memory like the app (#6458) --- crates/ui-egui/examples/perf_scenarios.rs | 5 +++-- crates/ui-egui/src/canvas.rs | 12 ++++++++++++ crates/ui-egui/src/gpu_canvas.rs | 3 ++- crates/ui-egui/src/gpu_status.rs | 2 ++ 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/notices.rs | 2 +- 25 files changed, 40 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 969d9533c1..72808cfabc 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -543,7 +543,6 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { }); } -/// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there. /// Layers overhaul L3: edits that redraw the whole document (a layer's opacity, hiding it) with /// many full-canvas layers, under the app's own GPU memory budget (Memory Usage at its default, /// the adapter's kind; `gpu_canvas::memory_budget`). Past what the budget holds, each one went to @@ -580,7 +579,8 @@ fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) { } }; if let (Some((g, _)), Some(st)) = (&b.gpu, s.active()) { - let pixels = st.history.pixel_bytes(&st.doc) as u64; + // As the app counts it (`canvas::gpu_budget`). + let pixels = st.memory_bytes() as u64; let allowance = u64::from(photocraft_engine::prefs::Performance::default().memory_usage_mb) << 20; g.set_memory_budget(photocraft_ui_egui::gpu_canvas::memory_budget( allowance, @@ -604,6 +604,7 @@ fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) { }); } +/// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there. fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) { // Row names stay stable across modes: the layer count is in the mode, not the key. let name = "nudge 1 px in the many-layer document + refresh".to_string(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1ee7f02db9..57d3575111 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1621,6 +1621,14 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: if present && app.perf.gpu_budget_allowance != budget_allowance(app) { gpu_budget(app, &gpu, idx, false, visible); } + // Switched to a document whose canvas is already current: size the budget for it and measure + // whether its layers fit, so GPU Memory Full shows (or clears) at once rather than at its + // next full refresh. Once per switch: the fit then names this document. + if present && app.perf.gpu_layer_fit.is_none_or(|f| f.doc != id.0) { + gpu_budget(app, &gpu, idx, false, visible); + let fit = gpu.measure_fit(&doc).unwrap_or(crate::gpu_canvas::LayerFit { doc: id.0, needed: 0, budget: 0 }); + app.perf.gpu_layer_fit = Some(fit); + } if present && current { // 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) { @@ -1633,6 +1641,10 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: // draws it (checked before the memory budget, which walks the history, every frame). if !gpu.fits_canvas(&doc) { crate::background_redraw::cancel(app, id); + // Drawn on the CPU canvas: no layer budget applies to it. + if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) { + app.perf.gpu_layer_fit = None; + } return false; } let partial = present && damage.is_some(); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 8ef3973305..012bcf801c 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1379,7 +1379,8 @@ fn detect_physical_memory() -> Option { /// it: DXGI's dedicated video memory on Windows, amdgpu's VRAM size on Linux. `None` for /// integrated and software adapters, whose textures live in system memory (the RAM-based /// budgets already cover them), and where nothing reports it. `PHOTOCRAFT_VRAM_MB` overrides it -/// for any adapter (to simulate a smaller card). +/// for any adapter, simulating a discrete card of that size: on an integrated GPU the budget then +/// stops charging the document's pixels too, as for a real card (a development switch). pub fn video_memory(adapter: &wgpu::AdapterInfo) -> Option { if let Some(mb) = std::env::var("PHOTOCRAFT_VRAM_MB").ok().and_then(|v| v.trim().parse::().ok()) { return mb.checked_mul(1 << 20).filter(|b| *b > 0); diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index bbbc997fc1..337283bdf8 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -50,6 +50,8 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) { app.prefs_rt.gpu_style = None; app.perf.gpu = false; app.perf.gpu_fallback = Some(fault.to_string()); + // The CPU canvas has no GPU memory budget: GPU Memory Full no longer applies. + app.perf.gpu_layer_fit = None; app.perf.gpu_info.canvas = "cpu".into(); app.perf.gpu_info.lost = Some(fault.to_string()); let title = if fault.is_lost() { LOST_MESSAGE } else { ERROR_MESSAGE }; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9cc357fc9..f3fb8c8c40 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3809,7 +3809,7 @@ navigator Zoom Zvětšení The transform couldn't be applied. Transformaci nelze použít. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Vrstvy potřebují {needed} paměti GPU; rozpočet jim ponechává {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme (Automaticky se řídí Využitím paměti, nejvýše čtvrtinou paměti tohoto počítače). Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme, nejvýše 3/8 její paměti. Automaticky se řídí Využitím paměti, v rámci čtvrtiny paměti tohoto počítače a 3/8 paměti karty, a u karty se známou pamětí do ní dokument nezapočítává. Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná. 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} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 23374a2993..48ebfba678 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3203,7 +3203,7 @@ curves Smooth Glätten The transform couldn't be applied. Die Transformation konnte nicht angewendet werden. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Die Ebenen brauchen {needed} GPU-Speicher; das Budget lässt ihnen {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann (Automatisch folgt der Speichernutzung, bis zu einem Viertel des Arbeitsspeichers dieses Computers). Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann, höchstens 3/8 ihres Speichers. Automatisch folgt der Speichernutzung, innerhalb eines Viertels des Arbeitsspeichers dieses Computers und 3/8 des Kartenspeichers, und rechnet das Dokument bei einer Karte mit bekanntem Speicher nicht an. Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll. 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} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f3743db5c8..f2c67c2c41 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3901,7 +3901,7 @@ curves Smooth Εξομάλυνση The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Τα επίπεδά του χρειάζονται {needed} μνήμης GPU· το όριο τους αφήνει {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας (το Αυτόματο ακολουθεί τη Χρήση μνήμης, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή). Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας, το πολύ στα 3/8 της μνήμης της. Το Αυτόματο ακολουθεί τη Χρήση μνήμης, εντός του ενός τετάρτου της μνήμης αυτού του υπολογιστή και των 3/8 της κάρτας, και δεν χρεώνει το έγγραφο σε κάρτα με γνωστή μνήμη. Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης. 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} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3d00c696de..45804fa2a4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4163,7 +4163,7 @@ navigator Zoom Zoom The transform couldn't be applied. No se pudo aplicar la transformación. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sus capas necesitan {needed} de memoria de GPU; el presupuesto les deja {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica (Automático sigue Uso de memoria, hasta una cuarta parte de la memoria de este equipo). Mientras no quepan, la barra de estado muestra Memoria de GPU llena. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica, como máximo 3/8 de su memoria. Automático sigue Uso de memoria, dentro de una cuarta parte de la memoria de este equipo y 3/8 de la de la tarjeta, y no descuenta el documento en una tarjeta cuya memoria se conoce. Mientras no quepan, la barra de estado muestra Memoria de GPU llena. 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} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2aa12b16e6..3e3451e096 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3206,7 +3206,7 @@ curves Smooth Lisser The transform couldn't be applied. Impossible d'appliquer la transformation. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Ses calques ont besoin de {needed} de mémoire GPU ; le budget leur laisse {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir (Automatique suit Utilisation de la mémoire, jusqu'au quart de la mémoire de cet ordinateur). Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir, au plus 3/8 de sa mémoire. Automatique suit Utilisation de la mémoire, dans la limite du quart de la mémoire de cet ordinateur et des 3/8 de celle de la carte, et ne compte pas le document sur une carte dont la mémoire est connue. Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine. 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} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6d9eff4751..9dc9a47fbc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4120,7 +4120,7 @@ cameraRaw Yellow Sárga The transform couldn't be applied. Az átalakítás nem alkalmazható. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. A rétegeknek {needed} GPU-memória kell; a keret {budget} hagy nekik. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír (az Automatikus a memóriahasználatot követi, legfeljebb a számítógép memóriájának negyedéig). Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír, legfeljebb a memóriája 3/8-ára. Az Automatikus a memóriahasználatot követi, a számítógép memóriájának negyedén és a kártya 3/8-án belül, és ismert memóriájú kártyánál nem számítja bele a dokumentumot. Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja. 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} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dd972b82d8..81e850f293 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3193,7 +3193,7 @@ curves Smooth Haluskan The transform couldn't be applied. Transformasi tidak dapat diterapkan. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Layer-nya membutuhkan {needed} memori GPU; anggaran menyisakan {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda (Otomatis mengikuti Pemakaian memori, hingga seperempat memori komputer ini). Selama belum muat, bilah status menampilkan Memori GPU Penuh. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda, paling banyak 3/8 memorinya. Otomatis mengikuti Pemakaian memori, dalam seperempat memori komputer ini dan 3/8 memori kartu, dan tidak membebankan dokumen pada kartu yang memorinya diketahui. Selama belum muat, bilah status menampilkan Memori GPU Penuh. 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} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1b76fdee65..c097ded29d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4322,7 +4322,7 @@ shape Appearance Aspetto The transform couldn't be applied. Impossibile applicare la trasformazione. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. I livelli richiedono {needed} di memoria GPU; il budget ne lascia {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Imposta Memoria GPU in Preferenze › Prestazioni su quanto può contenere la tua scheda grafica (Automatico segue Uso memoria, fino a un quarto della memoria di questo computer). Finché non ci stanno, la barra di stato mostra Memoria GPU piena. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Imposta Memoria GPU in Preferenze › Prestazioni su quanto può contenere la tua scheda grafica, al massimo 3/8 della sua memoria. Automatico segue Uso memoria, entro un quarto della memoria di questo computer e 3/8 di quella della scheda, e non conta il documento su una scheda la cui memoria è nota. Finché non ci stanno, la barra di stato mostra Memoria GPU piena. 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} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2b064edcdf..b460965128 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3877,7 +3877,7 @@ curves Smooth スムーズ The transform couldn't be applied. 変形を適用できませんでした。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. レイヤーには {needed} の GPU メモリが必要ですが、予算で使えるのは {budget} です。 - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量に設定してください(自動はメモリ使用量に従い、このコンピューターのメモリの4分の1までです)。収まらない間、ステータスバーに「GPU メモリ不足」と表示されます。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量(最大でメモリの 3/8)に設定してください。自動はメモリ使用量に従い、このコンピューターのメモリの4分の1とカードの 3/8 の範囲内で、メモリ量がわかるカードではドキュメント分を差し引きません。収まらない間、ステータスバーに「GPU メモリ不足」と表示されます。 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} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5248924d6e..361cef2e95 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3379,7 +3379,7 @@ curves Smooth 매끄럽게 The transform couldn't be applied. 변형을 적용할 수 없습니다. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. 레이어에 GPU 메모리 {needed}가 필요하지만 예산은 {budget}만 남겨 둡니다. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(자동은 메모리 사용량을 따르며 이 컴퓨터 메모리의 4분의 1까지입니다). 맞지 않는 동안 상태 표시줄에 GPU 메모리 부족이 표시됩니다. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(최대 카드 메모리의 3/8). 자동은 메모리 사용량을 따르며, 이 컴퓨터 메모리의 4분의 1과 카드 메모리의 3/8 이내이고, 메모리를 알 수 있는 카드에서는 문서를 차감하지 않습니다. 맞지 않는 동안 상태 표시줄에 GPU 메모리 부족이 표시됩니다. 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} diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index efb03ed9f4..f1ac67961e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3961,7 +3961,7 @@ curves Smooth Vloeiend maken The transform couldn't be applied. De transformatie kon niet worden toegepast. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. De lagen hebben {needed} GPU-geheugen nodig; het budget laat ze {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan (Automatisch volgt Geheugengebruik, tot een kwart van het geheugen van deze computer). Zolang ze niet passen, toont de statusbalk GPU-geheugen vol. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan, hooguit 3/8 van haar geheugen. Automatisch volgt Geheugengebruik, binnen een kwart van het geheugen van deze computer en 3/8 van dat van de kaart, en rekent het document niet af van een kaart waarvan het geheugen bekend is. Zolang ze niet passen, toont de statusbalk GPU-geheugen vol. 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} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 31ed39fab3..04eb15609d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3799,7 +3799,7 @@ curves Smooth Wygładź The transform couldn't be applied. Nie można zastosować przekształcenia. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Warstwy potrzebują {needed} pamięci GPU; budżet zostawia im {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ustaw Pamięć GPU w Preferencje › Wydajność na tyle, ile zmieści Twoja karta graficzna (Automatycznie podąża za Użyciem pamięci, maksymalnie do jednej czwartej pamięci tego komputera). Dopóki się nie mieszczą, pasek stanu pokazuje Pamięć GPU pełna. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ustaw Pamięć GPU w Preferencje › Wydajność na tyle, ile zmieści Twoja karta graficzna, najwyżej 3/8 jej pamięci. Automatycznie podąża za Użyciem pamięci, w granicach jednej czwartej pamięci tego komputera i 3/8 pamięci karty, i nie wlicza dokumentu przy karcie o znanej pamięci. Dopóki się nie mieszczą, pasek stanu pokazuje Pamięć GPU pełna. 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} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5f0b254f64..8d6b88fb54 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3991,7 +3991,7 @@ layerLabel No Color Sem cor The transform couldn't be applied. Não foi possível aplicar a transformação. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. As camadas precisam de {needed} de memória da GPU; o orçamento lhes deixa {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ajuste Memória da GPU em Preferências › Desempenho para o que sua placa de vídeo comporta (Automático segue Uso de memória, até um quarto da memória deste computador). Enquanto não couberem, a barra de status mostra Memória da GPU cheia. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memória da GPU em Preferências › Desempenho para o que sua placa de vídeo comporta, no máximo 3/8 da memória dela. Automático segue Uso de memória, dentro de um quarto da memória deste computador e 3/8 da memória da placa, e não desconta o documento de uma placa cuja memória é conhecida. Enquanto não couberem, a barra de status mostra Memória da GPU cheia. 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} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 03247c0c49..9d733f11a7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3171,7 +3171,7 @@ curves Smooth Сгладить The transform couldn't be applied. Не удалось применить трансформирование. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Слоям нужно {needed} памяти GPU; бюджет оставляет им {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта («Автоматически» следует параметру «Память», но не больше четверти памяти этого компьютера). Пока слои не помещаются, в строке состояния показано «Память GPU заполнена». + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта, не больше 3/8 её памяти. «Автоматически» следует параметру «Память», в пределах четверти памяти этого компьютера и 3/8 памяти видеокарты, и не учитывает документ на видеокарте с известным объёмом памяти. Пока слои не помещаются, в строке состояния показано «Память GPU заполнена». 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} diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d63ea98c5c..d384c9badc 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4274,7 +4274,7 @@ shape Appearance Izgled The transform couldn't be applied. Transformacija nije mogla da se primeni. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Njegovim slojevima treba {needed} GPU memorije; budžet im ostavlja {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Podesite GPU memoriju u Podešavanja › Performanse na onoliko koliko vaša grafička kartica može da primi (Automatski prati Korišćenje memorije, do četvrtine memorije ovog računara). Dok ne staju, statusna traka prikazuje GPU memorija je puna. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Podesite GPU memoriju u Podešavanja › Performanse na onoliko koliko vaša grafička kartica može da primi, najviše 3/8 njene memorije. Automatski prati Korišćenje memorije, u okviru četvrtine memorije ovog računara i 3/8 memorije kartice, i ne računa dokument kod kartice čija je memorija poznata. Dok ne staju, statusna traka prikazuje GPU memorija je puna. 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} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index aaa50e4791..5c35aba71c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3931,7 +3931,7 @@ cameraData Lens Lens The transform couldn't be applied. Dönüştürme uygulanamadı. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Katmanlarının {needed} GPU belleğine ihtiyacı var; bütçe onlara {budget} bırakıyor. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Tercihler › Performans içindeki GPU Belleği'ni ekran kartınızın kaldırabileceği miktara ayarlayın (Otomatik, Bellek kullanımını izler; en fazla bu bilgisayarın belleğinin dörtte biri). Sığmadıkları sürece durum çubuğu GPU Belleği Dolu gösterir. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Tercihler › Performans içindeki GPU Belleği'ni ekran kartınızın kaldırabileceği miktara, en fazla belleğinin 3/8'ine ayarlayın. Otomatik, Bellek kullanımını izler; bu bilgisayarın belleğinin dörtte biri ve kartın belleğinin 3/8'i içinde kalır, belleği bilinen bir kartta belgeyi hesaba katmaz. Sığmadıkları sürece durum çubuğu GPU Belleği Dolu gösterir. 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} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index aa0fabff4d..cb6f883eb2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3900,7 +3900,7 @@ curves Smooth Згладити The transform couldn't be applied. Не вдалося застосувати трансформування. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Його шарам потрібно {needed} пам'яті GPU; бюджет залишає їм {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта («Автоматично» стежить за використанням пам'яті, до чверті пам'яті цього комп'ютера). Поки шари не вміщуються, рядок стану показує «Пам'ять GPU заповнена». + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта, не більше 3/8 її пам'яті. «Автоматично» стежить за використанням пам'яті, у межах чверті пам'яті цього комп'ютера й 3/8 пам'яті відеокарти, і не враховує документ на відеокарті з відомим обсягом пам'яті. Поки шари не вміщуються, рядок стану показує «Пам'ять GPU заповнена». 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} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 49ad7f59fc..e2e55ab1ba 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3687,7 +3687,7 @@ curves Smooth Làm mượ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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. Các lớp cần {needed} bộ nhớ GPU; ngân sách chỉ dành cho chúng {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Đặt Bộ nhớ GPU trong Tùy chọn › Hiệu năng bằng mức card đồ họa của bạn chứa được (Tự động theo mức sử dụng bộ nhớ, tối đa một phần tư bộ nhớ máy này). Khi chúng chưa vừa, thanh trạng thái hiện Bộ nhớ GPU đầy. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Đặt Bộ nhớ GPU trong Tùy chọn › Hiệu năng bằng mức card đồ họa của bạn chứa được, tối đa 3/8 bộ nhớ của card. Tự động theo mức sử dụng bộ nhớ, trong giới hạn một phần tư bộ nhớ máy này và 3/8 bộ nhớ card, và không tính tài liệu vào card đã biết dung lượng bộ nhớ. Khi chúng chưa vừa, thanh trạng thái hiện Bộ nhớ GPU đầy. 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} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0186d4cf7e..eebeabaa6c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2817,7 +2817,7 @@ curves Smooth 平滑 The transform couldn't be applied. 无法应用变换。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其图层需要 {needed} GPU 内存;预算只留给它们 {budget}。 - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小(自动跟随内存使用量,最多为本机内存的四分之一)。在图层放不下期间,状态栏显示“GPU 内存已满”。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小,最多为其显存的 3/8。自动跟随内存使用量,不超过本机内存的四分之一和显卡显存的 3/8,且在显存已知的显卡上不把文档计入。在图层放不下期间,状态栏显示“GPU 内存已满”。 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} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f87c71ccd9..b35556b6fe 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2802,7 +2802,7 @@ curves Smooth 平滑 The transform couldn't be applied. 無法套用變形。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其圖層需要 {needed} GPU 記憶體;預算只留給它們 {budget}。 - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小(自動會跟隨記憶體使用量,最多為本機記憶體的四分之一)。在圖層放不下期間,狀態列會顯示「GPU 記憶體已滿」。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小,最多為其記憶體的 3/8。自動會跟隨記憶體使用量,不超過本機記憶體的四分之一和顯示卡記憶體的 3/8,且在記憶體已知的顯示卡上不把文件計入。在圖層放不下期間,狀態列會顯示「GPU 記憶體已滿」。 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} diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index d1ed6d37cc..cd974fbbe7 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -112,7 +112,7 @@ pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Opti )); } lines.push( - tl!("Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit.") + tl!("Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit.") .into(), ); } else if let Some(r) = reason { From 58f855a40f67a9b6a7e9168ea03c261226845705 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:47:17 -0500 Subject: [PATCH 051/171] Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394) (#6151) * Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394) The GTK file chooser behind the desktop portal keeps the typed name when another file type is picked, and rfd 0.17.2 reads only the file name from the portal's answer, so Untitled-1.psd with PNG picked was written as a PSD. On Linux, Save As now asks the portal's FileChooser.SaveFile itself (over the zbus already used for the portal), reads current_filter, and gives the name that type's extension: an extension PhotoCraft saves is replaced, none or another is appended. If that names an existing file, the dialog opens again under the new name so the portal asks before replacing it. rfd remains the fallback without a portal, and this can go once an rfd release reads current_filter (PolyMeilex/rfd#341). Co-Authored-By: Claude Opus 5.5 * Save As on Linux: keep typed extensions, open on the stem, follow the request handle Review follow-ups, checked on real GNOME 46 and KDE Plasma 6.7.5 sessions (Wayland and X11): - An extension the user typed is kept, as in PolyMeilex/rfd#341: only a name with none, or still with the extension the dialog opened with, takes the picked type's. GTK reports the type even when it is untouched, so art.png with the PSD type left alone was saved as art.psd. - The dialog opens on the stem (test), as on Windows (#4407): with test.psd in the name and PNG picked, GTK asked to replace test.psd, which the save leaves alone. A name that then exists is asked about again under that name; the rule compares against the suggested name, so a test.psd GTK completes back still becomes test.png. - Response 2 (GNOME's dialog closed with Esc) is a cancel: it fell back to rfd, which opened a second Save As. - The answer is awaited on the handle SaveFile returns, which a portal older than 0.9 picks itself (the Request documentation). Co-Authored-By: Claude Opus 5.5 * style(automation): format smart filter refresh closure --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: skylar taylor-barrick --- apps/photocraft/src/main.rs | 3 + apps/photocraft/src/portal_save.rs | 183 ++++++++++++++++++ apps/photocraft/src/portal_save/tests.rs | 232 +++++++++++++++++++++++ apps/photocraft/src/services.rs | 131 +++++++++++-- 4 files changed, 535 insertions(+), 14 deletions(-) create mode 100644 apps/photocraft/src/portal_save.rs create mode 100644 apps/photocraft/src/portal_save/tests.rs diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 0b82237a39..837332962f 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -50,6 +50,9 @@ mod linux_libs; mod logging; mod monitor_profile; mod photoshop_settings; +// Save As through the desktop portal, which reports the file type picked (#6121). +#[cfg(target_os = "linux")] +mod portal_save; mod presentation; mod screen_color; mod services; diff --git a/apps/photocraft/src/portal_save.rs b/apps/photocraft/src/portal_save.rs new file mode 100644 index 0000000000..42588e2bc4 --- /dev/null +++ b/apps/photocraft/src/portal_save.rs @@ -0,0 +1,183 @@ +//! File › Save As through the XDG desktop portal on Linux (#6121, #4328, #4394). +//! +//! The GTK file chooser behind the portal keeps the typed name when the user picks another file +//! type, and rfd 0.17 reads only the file name from the portal's answer, so `image.psd` with PNG +//! picked came back as `image.psd` and was written as a PSD. The answer does say which type was +//! picked (`current_filter`), so Save As asks the portal itself and gives a name the dialog left +//! alone that type's extension ([`crate::services::with_chosen_type`]). rfd stays the fallback +//! (zenity, or no portal), and this can go once an rfd release reads the picked type +//! (PolyMeilex/rfd#341). +//! +//! The dialog is attached to the window on X11. On Wayland it has no parent: naming the window to +//! the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +/// One file type in the dialog: its label and extensions (`("PNG", ["png"])`). +pub type Filter = (String, Vec); + +/// A Save dialog to show. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SaveRequest { + /// The portal's parent window identifier (`x11:`), or empty. + pub parent: String, + /// The name the dialog shows: the stem of the suggested file (`test`), see + /// `services::dialog_file_name`. + pub name: Option, + /// The suggested file's name (`test.psd`): a name that comes back with its extension still + /// takes the extension of the type picked ([`crate::services::with_chosen_type`]). + pub suggested: Option, + pub folder: Option, + pub filters: Vec, + /// The filter the dialog starts on. + pub current: Option, +} + +/// What the user chose: the path, as typed, and the label of the file type picked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Saved { + pub path: PathBuf, + pub filter: Option, +} + +/// The portal's parent window identifier for `window`: `x11:` on X11, empty elsewhere. +pub fn parent_id(window: Option) -> String { + use eframe::wgpu::rwh::RawWindowHandle; + match window { + Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window), + Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()), + _ => String::new(), + } +} + +/// Shows the portal's Save dialog and waits for it. `Ok(None)` is Cancel; `Err` means the portal +/// couldn't show it (no portal, no FileChooser), so the caller falls back to rfd. +pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result, String> { + use futures_lite::StreamExt; + use zbus::blocking::Proxy; + let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_"); + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let token = format!("photocraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}"); + // Listen before asking, so a quick answer isn't missed. + let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?; + let chooser = Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser") + .map_err(|e| e.to_string())?; + let handle: OwnedObjectPath = chooser.call("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?; + // A portal older than 0.9 ignores `handle_token` and answers on a request path of its own, + // which it returns: listen there instead, as the Request documentation asks. + let reply = if handle.as_str() == path { + futures_lite::future::block_on(replies.next()) + } else { + let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?; + futures_lite::future::block_on(replies.next()) + } + .ok_or("The save dialog closed without an answer")?; + let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?; + answer(code, &results) +} + +/// The `SaveFile` options for `req`. +fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> { + let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]); + if let Some(name) = &req.name { + o.insert("current_name", Value::from(name.as_str())); + } + if let Some(folder) = &req.folder { + // A byte string with its terminating NUL, as the portal specifies. + use std::os::unix::ffi::OsStrExt; + let mut bytes = folder.as_os_str().as_bytes().to_vec(); + bytes.push(0); + o.insert("current_folder", Value::from(bytes)); + } + let wire = |(label, exts): &Filter| (label.clone(), exts.iter().map(|e| (0u32, format!("*.{e}"))).collect::>()); + if !req.filters.is_empty() { + o.insert("filters", Value::from(req.filters.iter().map(wire).collect::>())); + } + if let Some(f) = req.current.and_then(|i| req.filters.get(i)) { + o.insert("current_filter", Value::from(wire(f))); + } + o +} + +/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog +/// closed some other way, such as with Esc in GNOME's: no file either, and not a reason to show +/// rfd's dialog instead (it did, so Esc opened a second Save As). +fn answer(code: u32, results: &HashMap) -> Result, String> { + match code { + 0 => {} + 1 | 2 => return Ok(None), + _ => return Err(format!("the save dialog failed (portal response {code})")), + } + let uris: Vec = results + .get("uris") + .ok_or("no file in the portal's answer")? + .try_clone() + .map_err(|e| e.to_string())? + .try_into() + .map_err(|e: zbus::zvariant::Error| e.to_string())?; + let uri = uris.first().ok_or("no file in the portal's answer")?; + let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?; + let filter = results.get("current_filter").and_then(|v| { + let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; + Some(label) + }); + Ok(Some(Saved { path, filter })) +} + +/// `file:///home/me/A%20b.psd` → `/home/me/A b.psd`; `None` for other schemes. +fn file_uri_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + // Only local files: `file:///…` or `file://localhost/…`. + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + if !rest.starts_with('/') { + return None; + } + let bytes = rest.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && let Some(b) = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) + { + out.push(b); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + use std::os::unix::ffi::OsStringExt; + Some(PathBuf::from(std::ffi::OsString::from_vec(out))) +} + +/// 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). +pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result, String> { + 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, + _ => &[], + }; + let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts); + if path == saved.path || !path.exists() { + return Ok(Some(path)); + } + req.name = path.file_name().map(|n| n.to_string_lossy().into_owned()); + req.suggested = req.name.clone(); + req.folder = path.parent().map(Path::to_path_buf); + req.current = picked; + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs new file mode 100644 index 0000000000..ed44a10d9c --- /dev/null +++ b/apps/photocraft/src/portal_save/tests.rs @@ -0,0 +1,232 @@ +use super::*; +use std::sync::{Arc, Mutex}; + +#[test] +fn file_uris_become_local_paths() { + assert_eq!(file_uri_path("file:///home/me/Untitled-1.psd"), Some(PathBuf::from("/home/me/Untitled-1.psd"))); + assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.png"), Some(PathBuf::from("/home/me/A bé.png"))); + assert_eq!(file_uri_path("file://localhost/tmp/x.png"), Some(PathBuf::from("/tmp/x.png"))); + assert_eq!(file_uri_path("sftp://host/x.png"), None); + assert_eq!(file_uri_path("file://host/x.png"), None); +} + +#[test] +fn x11_windows_are_named_to_the_portal() { + use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle}; + assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f"); + assert_eq!(parent_id(None), ""); +} + +/// What the mock portal answers to one `SaveFile` call: the response code, and the file and +/// filter label the user "picked". +type Script = Vec<(u32, Option<(&'static str, &'static str)>)>; + +/// The options of each `SaveFile` call the mock portal received: current_name and the label of +/// current_filter. +type Seen = Arc, Option)>>>; + +/// A private session bus (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` that +/// answers `SaveFile` from `script`. `None` when there is no `dbus-daemon` to run. +fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { + mock_portal_with(script, false) +} + +/// [`mock_portal`]; with `own_paths` it is a portal older than 0.9, which ignores `handle_token`, +/// answers on a request path of its own and returns that from `SaveFile`, as a real dialog does +/// 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 mut daemon = + std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?; + 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(); + std::thread::spawn(move || { + let mut script = script.into_iter(); + for msg in zbus::blocking::MessageIterator::from(&server) { + 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") { + continue; + } + let (_parent, _title, options): (String, String, HashMap) = msg.body().deserialize().unwrap(); + let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok()); + let filter = options.get("current_filter").and_then(|v| { + let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; + Some(label) + }); + log.lock().unwrap().push((text("current_name"), filter)); + let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_"); + let token = if own_paths { format!("old{}", log.lock().unwrap().len()) } else { text("handle_token").unwrap() }; + let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); + server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap(); + if own_paths { + std::thread::sleep(std::time::Duration::from_millis(300)); + } + let (code, pick) = script.next().unwrap_or((2, None)); + let mut results: HashMap<&str, Value<'_>> = HashMap::new(); + if let Some((uri, label)) = pick { + results.insert("uris", Value::from(vec![uri.to_string()])); + results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())]))); + } + server + .emit_signal( + header.sender().cloned().map(zbus::names::BusName::from), + path.as_str(), + "org.freedesktop.portal.Request", + "Response", + &(code, results), + ) + .unwrap(); + } + }); + let client = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().build().unwrap(); + Some((client, seen, daemon)) +} + +fn request(name: &str) -> SaveRequest { + let filters = [("PSD Document", &["psd", "psb"][..]), ("PNG", &["png"]), ("JPEG", &["jpg"])]; + SaveRequest { + parent: String::new(), + name: Some(name.into()), + suggested: Some(name.into()), + folder: Some(PathBuf::from("/tmp")), + filters: filters.iter().map(|(l, e)| (l.to_string(), e.iter().map(|s| s.to_string()).collect())).collect(), + current: Some(0), + } +} + +/// #6121 end to end through a real D-Bus: the name comes back as typed with the type picked, +/// and Save As writes it with that type's extension. +#[test] +fn save_as_follows_the_file_type_picked_in_the_portal_dialog() { + let dir = std::env::temp_dir().join(format!("photocraft-portal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let typed = format!("file://{}/Untitled-1.psd", dir.display()); + let typed: &'static str = Box::leak(typed.into_boxed_str()); + let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (1, None), (0, Some((typed, "PSD Document")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + // PNG picked with `Untitled-1.psd` still in the name: written as Untitled-1.png. + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.png"))); + assert_eq!(seen.lock().unwrap()[0], (Some("Untitled-1.psd".into()), Some("PSD Document".into())), "the request carried the name and type"); + // Cancel. + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), None); + // The type the name already has: unchanged. + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.psd"))); + daemon.kill().ok(); + std::fs::remove_dir_all(&dir).ok(); +} + +/// The dialog opens on the bare name (`test`, see `services::dialog_file_name`), so GTK doesn't +/// ask to replace test.psd when PNG is picked: the type picked gives the extension, and a name +/// that then exists is asked about under that name. +#[test] +fn a_bare_name_takes_the_picked_type_and_an_existing_target_is_asked_about() { + let dir = std::env::temp_dir().join(format!("photocraft-portal-bare-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.psd"), b"open document").unwrap(); + let uri = |name: &str| -> &'static str { Box::leak(format!("file://{}/{name}", dir.display()).into_boxed_str()) }; + let Some((conn, seen, mut daemon)) = mock_portal(vec![ + (0, Some((uri("test"), "PNG"))), + (0, Some((uri("test.psd"), "PNG"))), + (0, Some((uri("test"), "PSD Document"))), + (0, Some((uri("test.psd"), "PSD Document"))), + ]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + // As File › Save As opens it: `test` shown, `test.psd` suggested. + let opened = || SaveRequest { suggested: Some("test.psd".into()), ..request("test") }; + // PNG picked: test.png, and nothing was asked about test.psd. + assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png"))); + // The suggested extension back in the name (typed again, or GTK's completion) with PNG + // picked: test.png too, not a PSD written over test.psd. + assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png"))); + // The type untouched: test.psd, which exists, so the dialog asks again as test.psd (the + // portal's replace prompt is then about the file that is written). + assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.psd"))); + let seen = seen.lock().unwrap().clone(); + let names: Vec<_> = seen.iter().map(|(name, _)| name.as_deref()).collect(); + assert_eq!(names, [Some("test"), Some("test"), Some("test"), Some("test.psd")], "{seen:?}"); + assert_eq!(seen[3].1.as_deref(), Some("PSD Document")); + daemon.kill().ok(); + std::fs::remove_dir_all(&dir).ok(); +} + +/// GNOME's dialog closed with Esc answers 2 ("ended some other way"): no file, like Cancel, and +/// no fallback to rfd, which opened a second Save As. +#[test] +fn a_dialog_closed_some_other_way_is_a_cancel() { + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(2, None)]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_as(&conn, request("test")), Ok(None)); + daemon.kill().ok(); +} + +/// An extension the user typed is theirs: `art.png` with the PSD type left as the dialog opened +/// saves a PNG, as it did before the type was read. +#[test] +fn a_typed_extension_wins_over_an_untouched_file_type() { + let typed: &'static str = "file:///tmp/photocraft-none/art.png"; + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PSD Document")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/art.png"))); + daemon.kill().ok(); +} + +/// A portal older than 0.9 answers on a request path of its own: the wait follows the handle +/// `SaveFile` returns, as the Request documentation asks, instead of the predicted path. +#[test] +fn the_answer_is_awaited_on_the_handle_the_portal_returns() { + let typed: &'static str = "file:///tmp/photocraft-none/Untitled-1.psd"; + let Some((conn, _seen, mut daemon)) = mock_portal_with(vec![(0, Some((typed, "PNG")))], true) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Untitled-1.png"))); + daemon.kill().ok(); +} + +/// When the extension the type gives names a file that exists, the dialog opens again under that +/// name (so the portal asks before replacing it) instead of overwriting it unasked. +#[test] +fn a_renamed_file_that_exists_is_confirmed_in_the_dialog() { + let dir = std::env::temp_dir().join(format!("photocraft-portal-exists-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("art.png"), b"old").unwrap(); + let typed: &'static str = Box::leak(format!("file://{}/art.psd", dir.display()).into_boxed_str()); + let confirmed: &'static str = Box::leak(format!("file://{}/art.png", dir.display()).into_boxed_str()); + let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (0, Some((confirmed, "PNG")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_as(&conn, request("art.psd")).unwrap(), Some(dir.join("art.png"))); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 2, "asked again: {seen:?}"); + assert_eq!(seen[1], (Some("art.png".into()), Some("PNG".into())), "under the new name, on the picked type"); + assert_eq!(std::fs::read(dir.join("art.png")).unwrap(), b"old", "nothing written by the dialog"); + daemon.kill().ok(); + std::fs::remove_dir_all(&dir).ok(); +} + +/// With only one type offered (the Swatches panel's .aco) a typed extension is kept. +#[test] +fn a_single_file_type_keeps_the_typed_extension() { + let typed: &'static str = "file:///tmp/photocraft-none/Swatches.ase"; + 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.ase"))); + daemon.kill().ok(); +} diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9de1c475ea..6c34fda33a 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -95,18 +95,51 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec)> { v } -/// The name the save dialog starts with. On Windows a visible extension stays put when the user -/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed -/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog -/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A -/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case +/// `path` with the extension of the file type picked in the save dialog (`chosen`), where the +/// dialog left the name alone (#6121): the format follows the extension, and the GTK file chooser +/// keeps the typed name when another type is picked. Only a name that still has the extension the +/// dialog opened with (`opened_with`, `Untitled-1.psd` with PNG picked) or none gets the type's +/// extension, as PolyMeilex/rfd#341 does. An extension the user typed that PhotoCraft saves +/// (`art.png` with the PSD type untouched, the way to save a PNG before this) is theirs and stays, +/// and so does one of the type's own (`.psb` for PSD). One PhotoCraft doesn't save (`scan.2024`) +/// is part of the name, and the type's extension is added after it. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf { + let Some(first) = chosen.first() else { return path.to_path_buf() }; + let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + let opened_ext = opened_with.and_then(|n| Path::new(n).extension()).and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + match ext { + Some(ext) if chosen.contains(&ext) => path.to_path_buf(), + Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first), + Some(ext) if SAVE_FILTERS.iter().chain(OTHER_SAVE_FILTERS).any(|(_, exts)| exts.contains(&ext.as_str())) => path.to_path_buf(), + _ => { + let mut name = path.as_os_str().to_owned(); + name.push("."); + name.push(first); + PathBuf::from(name) + } + } +} + +/// The name the save dialog starts with. Where the type picked gives a bare name its extension +/// (`adds_extension`), the dialog gets the stem and the suggested type (listed first by +/// [`save_filters`]) supplies `.psd`: +/// - On Windows a visible extension stays put when the user picks another type (with File name +/// extensions shown, `image.psd` saved as PhotoCraft stayed `image.psd`, #4394). +/// - On Linux the portal's dialog keeps the name as typed, and before returning asks whether to +/// replace a file of that name, so `test.psd` with PNG picked asked to replace test.psd, which +/// the save leaves alone (#6121). From the stem [`with_chosen_type`] gives the picked type's +/// extension, and a name that then exists is asked about under that name +/// (`portal_save::save_as`). +/// +/// A stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case /// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the -/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked. -fn dialog_file_name(suggested: &str, windows: bool) -> Option { +/// macOS panel has no type list, and rfd's Linux dialogs don't say which type was picked. +fn dialog_file_name(suggested: &str, adds_extension: bool) -> Option { let path = Path::new(suggested); let name = path.file_name()?.to_string_lossy().into_owned(); let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) }; - let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase(); + let bare = adds_extension && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase(); Some(if bare { stem.to_string_lossy().into_owned() } else { name }) } @@ -148,17 +181,52 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) }) } FileDialogRequest::Save { suggested } => { - for (name, exts) in save_filters(&suggested) { - dialog = dialog.add_filter(name, &exts); + let filters = save_filters(&suggested); + let name = dialog_file_name(&suggested, cfg!(windows)); + let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf); + for (name, exts) in &filters { + dialog = dialog.add_filter(name, exts); } - if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) { + if let Some(name) = &name { dialog = dialog.set_file_name(name); } - if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) { + if let Some(dir) = &dir { dialog = dialog.set_directory(dir); } - let picked = dialog.save_file(); - Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) }) + // On Linux the portal's own dialog, which reports the file type picked (#6121); rfd if + // the portal can't show it. + #[cfg(target_os = "linux")] + { + use eframe::wgpu::rwh::HasWindowHandle; + let parent = crate::portal_save::parent_id(parent.and_then(|p| p.window_handle().ok()).map(|h| h.as_raw())); + let req = crate::portal_save::SaveRequest { + parent, + name: dialog_file_name(&suggested, true), + suggested: Path::new(&suggested).file_name().map(|n| n.to_string_lossy().into_owned()), + folder: dir, + filters, + current: Some(0), + }; + Box::pin(async move { + let portal = zbus::blocking::connection::Builder::session() + .and_then(|b| b.build()) + .map_err(|e| e.to_string()) + .and_then(|conn| crate::portal_save::save_as(&conn, req)); + match portal { + Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())), + Err(e) => { + log::info!("portal save dialog unavailable ({e}), using rfd"); + dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) + } + } + }) + } + #[cfg(not(target_os = "linux"))] + { + let _ = filters; + let picked = dialog.save_file(); + Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) }) + } } }; // Without the thread the reply is dropped unanswered, which the app takes as Cancel. @@ -585,6 +653,41 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + /// #6121: the file type picked in the save dialog gives the name its extension where the + /// dialog left the one it opened with, and only there. + #[test] + fn the_picked_file_type_gives_the_name_its_extension() { + let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::>(); + let (png, psd) = (exts(&["png"]), exts(&["psd", "psb"])); + let opened = Some("Untitled-1.psd"); + let cases = [ + // PNG picked, the name as the dialog opened (or renamed, its extension kept). + ("/pics/Untitled-1.psd", opened, &png, "/pics/Untitled-1.png"), + ("/pics/art.psd", opened, &png, "/pics/art.png"), + ("/pics/ART.PSD", opened, &png, "/pics/ART.png"), + // No extension: the type's. One PhotoCraft doesn't save is part of the name. + ("/pics/art", opened, &png, "/pics/art.png"), + ("/pics/scan.2024", opened, &png, "/pics/scan.2024.png"), + // Typed, an extension PhotoCraft saves is the user's: kept, whatever the type says + // (the PSD type untouched, or PNG picked and .jpg typed). + ("/pics/art.png", opened, &psd, "/pics/art.png"), + ("/pics/art.jpg", opened, &png, "/pics/art.jpg"), + ("/pics/art.pcraft", opened, &png, "/pics/art.pcraft"), + // Already the type's: kept, including its other extensions. + ("/pics/art.png", opened, &png, "/pics/art.png"), + ("/pics/art.PNG", opened, &png, "/pics/art.PNG"), + ("/pics/big.psb", opened, &psd, "/pics/big.psb"), + // The dialog opened on a .pcraft: that is the extension the type replaces. + ("/pics/art.pcraft", Some("art.pcraft"), &png, "/pics/art.png"), + ("/pics/art.psd", Some("art.pcraft"), &png, "/pics/art.psd"), + ]; + for (typed, opened, chosen, want) in cases { + assert_eq!(with_chosen_type(Path::new(typed), opened, chosen), PathBuf::from(want), "{typed} (opened {opened:?})"); + } + // No type picked: the name as typed. + assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd")); + } + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn open_filters_cover_uppercase_and_mixed_case_extensions() { From d12be8ecd04cb7287467e26dbc82bc5b4de0c8c8 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 22:33:14 -0700 Subject: [PATCH 052/171] test(ui-egui): canvas_16f and rotation preview tests run on the app's wgpu device (#6454) Both harnesses built their device with egui_kittest's .wgpu() (egui's default limits) instead of gpu_canvas::wgpu_setup() (the adapter's own limits, what the app requests), as #5954 fixed for the background_redraw and navigator tests. On that device: - canvas_16f's sixteen_bit_gradient_on_screen and thirty_two_bit_preview_exposes_values_above_one panicked in egui-wgpu ("Failed to create staging buffer for index data", renderer.rs:984), every run, single-threaded too; - arbitrary_rotation_preview's two tests failed "the fixture must use the GPU canvas". Both binaries pass on the app's device. --- .../tests/arbitrary_rotation_preview.rs | 21 ++++++++++------ crates/ui-egui/tests/canvas_16f.rs | 25 +++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/tests/arbitrary_rotation_preview.rs b/crates/ui-egui/tests/arbitrary_rotation_preview.rs index a3549e495c..388c941f16 100644 --- a/crates/ui-egui/tests/arbitrary_rotation_preview.rs +++ b/crates/ui-egui/tests/arbitrary_rotation_preview.rs @@ -11,14 +11,19 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>; fn harness() -> Option { let built = std::panic::catch_unwind(|| { - egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_pixels_per_point(1.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()); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - app.set_wgpu(rs.clone()); - } - app - }) + egui_kittest::Harness::builder() + .with_size(egui::vec2(1280.0, 800.0)) + .with_pixels_per_point(1.0) + .with_max_steps(64) + .wgpu_setup(photocraft_ui_egui::gpu_canvas::wgpu_setup()) + .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), diff --git a/crates/ui-egui/tests/canvas_16f.rs b/crates/ui-egui/tests/canvas_16f.rs index 279a6dac6e..ff97cff680 100644 --- a/crates/ui-egui/tests/canvas_16f.rs +++ b/crates/ui-egui/tests/canvas_16f.rs @@ -181,16 +181,21 @@ fn screen(doc: Document, setup: &[(&str, serde_json::Value)], name: &str) -> Opt let ok2 = ok.clone(); let setup: Vec<(String, serde_json::Value)> = setup.iter().map(|(a, b)| (a.to_string(), b.clone())).collect(); let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.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(), Default::default()); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - ok2.store(gpu_canvas::supports_f16_canvas(&rs.adapter) && std::env::var("PHOTOCRAFT_CANVAS_F16").as_deref() != Ok("0"), Ordering::SeqCst); - app.set_wgpu(rs.clone()); - } - app.session.open_document(doc, None); - app - }) + egui_kittest::Harness::builder() + .with_size(egui::vec2(1000.0, 700.0)) + .with_pixels_per_point(1.0) + .with_max_steps(64) + .wgpu_setup(gpu_canvas::wgpu_setup()) + .build_eframe(move |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() { + ok2.store(gpu_canvas::supports_f16_canvas(&rs.adapter) && std::env::var("PHOTOCRAFT_CANVAS_F16").as_deref() != Ok("0"), Ordering::SeqCst); + app.set_wgpu(rs.clone()); + } + app.session.open_document(doc, None); + app + }) })); let Ok(mut harness) = built else { eprintln!("skipping: no GPU adapter"); From 2a952f164c0b4cf936b1c6497db10dd095341c00 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:48:30 -0400 Subject: [PATCH 053/171] Windows/Linux: open files in the running PhotoCraft instead of a second instance (#3708) (#3755) Fixes #3708; part of #1540 (Flatpak needs its own follow-up: each sandbox has its own loopback). A second launch hands its files to the running instance over an authenticated loopback channel (random port, 32-byte token in a private instance.json, constant-time check, bounded clients, requests and paths). Files are acknowledged only once the UI has taken them; the instance shuts the hand-off down before a CPU-compatibility relaunch; it runs only in the per-user settings mode. --new-instance and PHOTOCRAFT_NEW_INSTANCE=1 opt out. Thanks to @MrCloudy2 (#2358), whose Linux version first handled the relaunch and per-user endpoints, and @Desayuno64 (#5273), whose shutdown flag and read deadline informed the final design. --- apps/photocraft/src/main.rs | 92 +++- apps/photocraft/src/single_instance.rs | 648 +++++++++++++++++++++++++ crates/ui-egui/src/file_open.rs | 17 +- crates/ui-egui/src/file_open/tests.rs | 22 + docs/development.md | 4 + 5 files changed, 776 insertions(+), 7 deletions(-) create mode 100644 apps/photocraft/src/single_instance.rs diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 837332962f..c0f93bd319 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -3,7 +3,11 @@ //! 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 ] [files…]` +//! [--frame-latency ] [--stroke-trace ] [--new-instance] [files…]` +//! +//! 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 +//! `--control` launch (see `single_instance`). //! //! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how //! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`; @@ -56,6 +60,9 @@ mod portal_save; mod presentation; mod screen_color; mod services; +// macOS gets one instance from LaunchServices (open-documents Apple events). +#[cfg(any(not(target_os = "macos"), test))] +mod single_instance; // Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener. #[cfg(any(target_os = "macos", target_os = "linux", test))] mod tablet; @@ -201,6 +208,9 @@ fn main() -> eframe::Result { // 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); @@ -219,10 +229,24 @@ fn main() -> eframe::Result { None => control_token = None, }, Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from), - Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from), - Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from), - Some("--safe-gpu") => safe_gpu = true, + 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() { @@ -234,7 +258,11 @@ fn main() -> eframe::Result { _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), } } - Some("--in-window-menus") => in_window_menus = true, + 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(()); @@ -266,6 +294,48 @@ fn main() -> eframe::Result { } } + // 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. + #[cfg(not(target_os = "macos"))] + let single_instance = { + let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok(); + let context = single_instance::LaunchContext { + per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform, + new_instance_flag: new_instance, + new_instance_env: new_instance_env.as_deref(), + control: control_port.is_some(), + unreadable_paths: !unreadable_paths.is_empty(), + launch_options: &launch_options, + }; + match (single_instance::skip_reason(&context), services::config_dir()) { + (Some(reason), _) => { + log::info!("running as a separate PhotoCraft: {reason}"); + None + } + (None, None) => None, + (None, Some(dir)) => { + let paths: Vec = files.iter().map(|f| single_instance::absolute(f)).collect(); + match single_instance::claim(&dir, &paths) { + single_instance::Launch::Forwarded => { + log::info!("handed {} file(s) to the running PhotoCraft", paths.len()); + return Ok(()); + } + single_instance::Launch::Primary(primary) => Some(primary), + single_instance::Launch::Standalone(reason) => { + log::warn!("starting a separate PhotoCraft: {reason}"); + None + } + } + } + } + }; + // Lives until the event loop returns, then `shutdown` lets the next launch become the primary. + #[cfg(not(target_os = "macos"))] + let single_instance = &single_instance; + #[cfg(target_os = "macos")] + let _ = (new_instance, &launch_options); + // The display server to open the window on: Xwayland for a pen on Wayland, which gives this // app no pen input (#639). #[cfg(target_os = "linux")] @@ -528,6 +598,11 @@ fn main() -> eframe::Result { } #[cfg(not(target_os = "macos"))] let _ = in_window_menus; + // Files from later launches (Explorer, file managers) open here as new tabs. + #[cfg(not(target_os = "macos"))] + if let Some(primary) = single_instance { + app.services.os_events = Some(primary.connect(&cc.egui_ctx)); + } // Where file drags and drops are (winit 0.30 doesn't say). app.services.cursor_pos = cursor::service(cc); // The custom title bar's undecorated window must stay borderless, or its caption and @@ -565,6 +640,13 @@ fn main() -> eframe::Result { Ok(Box::new(app)) }), ); + // The window is gone: refuse further files and free the lock, so a launch that is waiting (or + // the CPU-compatibility relaunch below) becomes the primary instead of handing its files to a + // process that is exiting. + #[cfg(not(target_os = "macos"))] + if let Some(primary) = single_instance { + primary.shutdown(); + } // Closed or failed outside graphics initialization: not a driver crash. A renderer error // keeps the marker, so the next start tries a safer backend, unless there is no safer one: // a failed CPU start would otherwise pin every later start to CPU. diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs new file mode 100644 index 0000000000..d2cd65c678 --- /dev/null +++ b/apps/photocraft/src/single_instance.rs @@ -0,0 +1,648 @@ +//! One PhotoCraft per user on Windows and Linux (#3708): a file opened from Explorer or a file +//! manager lands as a new tab in the running window, like Photoshop, instead of starting a second +//! 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 +//! 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. +//! +//! The primary answers `ok` only once the UI has taken the paths. A launch that hears anything else +//! (the primary is still starting, hung, or quitting) keeps retrying the lock, so it becomes the +//! primary itself as soon as the old one lets go, and when its time runs out it starts a separate +//! instance: a file is never acknowledged and then dropped. [`Primary::shutdown`] runs when the +//! event loop ends (and before the CPU-compatibility relaunch), so a closing PhotoCraft refuses new +//! files and frees the lock. +//! +//! Only for the per-user settings folder ([`skip_reason`]): a portable copy's folder beside the +//! executable, or a `PHOTOCRAFT_CONFIG_DIR`, may be shared between users. +//! macOS doesn't need this: LaunchServices already sends a running app an open-documents event. + +// Only the tests use it on macOS. +#![cfg_attr(target_os = "macos", allow(dead_code))] + +use std::fs::{File, OpenOptions}; +use std::io::{BufReader, Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{SyncSender, sync_channel}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use photocraft_automation::security::{LineRead, generate_token, read_bounded_line, token_matches}; +use photocraft_ui_egui::{OsEvent, OsEventsFn}; +use serde_json::{Value, json}; + +const LOCK_FILE: &str = "instance.lock"; +const INFO_FILE: &str = "instance.json"; +/// 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. +const MAX_CLIENTS: usize = 8; +/// Kept out of the hand-off's budget, so the primary answers before the launch gives up on it. +const REPLY_MARGIN: Duration = Duration::from_millis(500); + +/// How long each side waits (shortened in tests). +#[derive(Clone, Copy, Debug)] +pub struct Timing { + /// A later launch's whole budget for handing its paths over (or taking the lock over). + pub handoff: Duration, + /// How long the primary waits for its UI to take a launch's paths before refusing them. + pub ack: Duration, + /// Total time a connection gets to send its request line. + pub request: Duration, +} + +/// 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) }; + +/// Outcome of [`claim`]. +pub enum Launch { + /// This process is the running instance; keep [`Primary`] alive and attach it to the app. + Primary(Primary), + /// The running instance took the paths; this process should exit. + Forwarded, + /// Couldn't do either (reason given): run as an independent instance. + Standalone(String), +} + +/// What decides whether a launch takes part (see [`skip_reason`]). +#[derive(Clone, Copy)] +pub struct LaunchContext<'a> { + /// The settings folder is the platform's per-user one (not portable, not an override). + pub per_user_settings: bool, + /// `--new-instance`. + pub new_instance_flag: bool, + /// `PHOTOCRAFT_NEW_INSTANCE`, if set. + pub new_instance_env: Option<&'a str>, + /// `--control` or `PHOTOCRAFT_CONTROL_PORT`: automation gets its own process. + 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`, …). + pub launch_options: &'a [&'static str], +} + +/// 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 { + return Some("the settings folder isn't the per-user one (portable or PHOTOCRAFT_CONFIG_DIR)".into()); + } + if c.new_instance_flag || c.new_instance_env.is_some_and(|v| !v.is_empty() && v != "0") { + return Some("--new-instance".into()); + } + if c.control { + return Some("automation (--control) runs its own process".into()); + } + if c.unreadable_paths { + return Some("some paths aren't valid Unicode".into()); + } + if !c.launch_options.is_empty() { + return Some(format!("{} only applies to a new process", c.launch_options.join(", "))); + } + None +} + +/// The running instance: holds the lock and receives other launches' paths. +pub struct Primary { + lock: Mutex>, + shared: Arc, +} + +struct Shared { + inbox: Mutex, + /// Set by [`Primary::shutdown`]: new connections are dropped, waiting ones refused. + closed: AtomicBool, +} + +#[derive(Default)] +struct Inbox { + pending: Vec, + ctx: Option, + next_id: u64, +} + +/// Paths waiting for the UI; `taken` tells the connection that sent them. +struct Pending { + id: u64, + paths: Vec, + taken: SyncSender<()>, +} + +impl Primary { + /// The queue the UI drains (see `Services::os_events`); a later launch wakes `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn { + let mut inbox = self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner); + if !inbox.pending.is_empty() { + ctx.request_repaint(); + } + inbox.ctx = Some(ctx.clone()); + drop(inbox); + let shared = Arc::clone(&self.shared); + Box::new(move || shared.take()) + } + + /// Stop taking files and release the lock: launches waiting on this one are refused and take + /// the lock over themselves. Call when the event loop has ended, and before relaunching. + pub fn shutdown(&self) { + 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(); + drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take()); + } +} + +impl Drop for Primary { + fn drop(&mut self) { + self.shutdown(); + } +} + +impl Shared { + /// Everything queued, acknowledged to the senders while the inbox is locked (see `hand_over`). + fn take(&self) -> Vec { + let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); + inbox + .pending + .drain(..) + .map(|p| { + let _ = p.taken.try_send(()); + OsEvent::Open(p.paths) + }) + .collect() + } + + /// Queue `paths` for the UI and wait up to `wait` for it to take them. False (and nothing left + /// queued) when it didn't: the sender then retries or starts its own instance. + fn hand_over(&self, paths: Vec, wait: Duration) -> bool { + let (taken, rx) = sync_channel(1); + let id = { + let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); + if self.closed.load(Ordering::Acquire) { + return false; + } + let id = inbox.next_id; + inbox.next_id = id.wrapping_add(1); + inbox.pending.push(Pending { id, paths, taken }); + if let Some(ctx) = &inbox.ctx { + ctx.request_repaint(); + } + id + }; + if rx.recv_timeout(wait).is_ok() { + return true; + } + // Timed out or refused: withdraw, unless the UI took them in the meantime (it acknowledges + // while holding the lock, so the answer is settled here). + let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); + if rx.try_recv().is_ok() { + return true; + } + inbox.pending.retain(|p| p.id != id); + false + } +} + +/// Become the running instance under `dir` or hand `paths` (absolute) to the one that is. +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. +pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { + if let Err(e) = std::fs::create_dir_all(dir) { + return Launch::Standalone(format!("{}: {e}", dir.display())); + } + let lock_path = dir.join(LOCK_FILE); + let lock = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) { + Ok(lock) => lock, + Err(e) => return Launch::Standalone(format!("{}: {e}", lock_path.display())), + }; + let deadline = Instant::now() + timing.handoff; + let mut last = "no instance.json".to_string(); + loop { + match lock.try_lock() { + Ok(()) => { + return match serve(dir, lock, timing) { + Ok(primary) => Launch::Primary(primary), + Err(e) => Launch::Standalone(e), + }; + } + 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) { + Ok(()) => return Launch::Forwarded, + Err(e) => last = e, + } + } + if Instant::now() >= deadline { + return Launch::Standalone(format!("the running PhotoCraft didn't take the files ({last})")); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +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(); + let token = generate_token().map_err(|e| e.to_string())?; + write_info(dir, port, &token)?; + 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 || { + let token = Arc::new(token); + let clients = Arc::new(AtomicUsize::new(0)); + loop { + let stream = match listener.accept() { + 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)); + continue; + } + }; + // Closing: the sender sees the connection drop and takes the lock over. + if listening.closed.load(Ordering::Acquire) { + continue; + } + // Each launch on its own thread, so one that connects and stalls doesn't hold the + // ones after it; past the cap a connection is dropped and that launch retries. + if clients.fetch_add(1, Ordering::AcqRel) >= MAX_CLIENTS { + clients.fetch_sub(1, Ordering::AcqRel); + continue; + } + let (token, shared, slot) = (Arc::clone(&token), Arc::clone(&listening), Arc::clone(&clients)); + let spawned = std::thread::Builder::new().name("single-instance-client".into()).spawn(move || { + serve_client(stream, &token, &shared, timing); + slot.fetch_sub(1, Ordering::AcqRel); + }); + if spawned.is_err() { + clients.fetch_sub(1, Ordering::AcqRel); + } + } + }); + spawned.map_err(|e| format!("single-instance listener thread: {e}"))?; + Ok(Primary { lock: Mutex::new(Some(lock)), shared }) +} + +/// 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) { + 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)), + None => false, + }, + None => return, + }; + let reply = if taken { json!({"ok": true}) } else { json!({"ok": false, "error": "not taken"}) }; + let mut out = stream; + let _ = out.set_write_timeout(Some(timing.request)); + let _ = writeln!(out, "{reply}").and_then(|()| out.flush()); +} + +/// A reader that fails once `until` passes, however slowly the bytes trickle in. +struct Deadline<'a> { + stream: &'a TcpStream, + until: Instant, +} + +impl Read for Deadline<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + 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) + } +} + +/// The request line, if it arrives complete within `total`. +fn read_request(stream: &TcpStream, 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. +/// 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> { + 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(); + // 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(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let written = options.open(&tmp).and_then(|mut f| f.write_all(body.as_bytes())).and_then(|()| std::fs::rename(&tmp, &path)); + written.map_err(|e| { + let _ = std::fs::remove_file(&tmp); + format!("{}: {e}", path.display()) + }) +} + +fn read_info(dir: &Path) -> Option<(u16, String)> { + let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?; + let port = u16::try_from(v.get("port")?.as_u64()?).ok()?; + Some((port, v.get("token")?.as_str()?.to_string())) +} + +/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI. +fn parse_request(line: &str, token: &str) -> Option<(Vec, Duration)> { + let v: Value = serde_json::from_str(line).ok()?; + if !token_matches(token, v.get("token")?.as_str()?) { + return None; + } + let open = v.get("open")?.as_array()?; + if open.len() > MAX_PATHS { + return None; + } + let paths = open.iter().map(|p| p.as_str().map(str::to_string)).collect::>>()?; + let wait = Duration::from_millis(v.get("wait_ms").and_then(Value::as_u64).unwrap_or(0)); + 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> { + 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())?; + // 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}); + stream.set_write_timeout(Some(left)).map_err(|e| e.to_string())?; + writeln!(stream, "{request}").and_then(|()| stream.flush()).map_err(|e| e.to_string())?; + let mut reader = BufReader::new(Deadline { stream: &stream, until: deadline }); + let mut line = String::new(); + match read_bounded_line(&mut reader, &mut line) { + Ok(LineRead::Line) => {} + Ok(_) => return Err("no reply".into()), + Err(e) => return Err(e.to_string()), + } + let ok = serde_json::from_str::(&line).ok().and_then(|v| v.get("ok")?.as_bool()) == Some(true); + if ok { Ok(()) } else { Err("not taken".into()) } +} + +/// Absolute form of a command-line path, so the running instance (another working directory) +/// opens the same file. +pub fn absolute(path: &str) -> String { + std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::sync::mpsc::channel; + + const FAST: Timing = Timing { handoff: Duration::from_millis(1500), ack: Duration::from_millis(500), request: Duration::from_millis(800) }; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("photocraft-single-instance-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + /// Launch with `paths` on another thread (it blocks until the primary's UI takes them). + fn launch(dir: &Path, paths: &[&str], timing: Timing) -> std::thread::JoinHandle { + let (dir, paths): (PathBuf, Vec) = (dir.to_path_buf(), paths.iter().map(|p| p.to_string()).collect()); + std::thread::spawn(move || claim_with(&dir, &paths, timing)) + } + + /// Play the UI: drain until `n` events arrived (or 5 s passed). + fn drain(ui: &mut OsEventsFn, n: usize) -> Vec { + let mut got = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(5); + while got.len() < n && Instant::now() < deadline { + got.extend(ui()); + std::thread::sleep(Duration::from_millis(10)); + } + got + } + + fn primary(dir: &Path) -> Primary { + match claim_with(dir, &[], FAST) { + Launch::Primary(p) => p, + _ => panic!("first launch should be primary"), + } + } + + #[test] + fn second_launch_hands_its_paths_to_the_first() { + let dir = temp_dir("handoff"); + let primary = primary(&dir); + let mut ui = primary.connect(&egui::Context::default()); + let paths = ["C:\\art\\one.psd", "/home/a/two é.png"]; + let second = launch(&dir, &paths, TIMING); + assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.iter().map(|p| p.to_string()).collect())]); + assert!(matches!(second.join().unwrap(), Launch::Forwarded)); + // A plain second start (no files) just brings the window forward. + let third = launch(&dir, &[], TIMING); + assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(Vec::new())]); + assert!(matches!(third.join().unwrap(), Launch::Forwarded)); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn simultaneous_launches_all_land_in_one_instance() { + let dir = temp_dir("race"); + let (tx, rx) = channel(); + for i in 0..6 { + let (dir, tx) = (dir.clone(), tx.clone()); + std::thread::spawn(move || { + let _ = tx.send(claim(&dir, &[format!("file{i}.psd")])); + }); + } + drop(tx); + let (mut primaries, mut forwarded, mut events) = (Vec::new(), 0, Vec::new()); + let mut ui: Option = None; + let deadline = Instant::now() + Duration::from_secs(10); + while primaries.len() + forwarded < 6 && Instant::now() < deadline { + while let Ok(launch) = rx.try_recv() { + match launch { + Launch::Primary(p) => primaries.push(p), + Launch::Forwarded => forwarded += 1, + Launch::Standalone(reason) => panic!("standalone: {reason}"), + } + } + if ui.is_none() + && let Some(p) = primaries.first() + { + ui = Some(p.connect(&egui::Context::default())); + } + if let Some(ui) = ui.as_mut() { + events.extend(ui()); + } + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!((primaries.len(), forwarded, events.len()), (1, 5, 5)); + drop(primaries); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_crashed_instance_leaves_nothing_in_the_way() { + let dir = temp_dir("stale"); + std::fs::create_dir_all(&dir).unwrap(); + // 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)); + drop(primary); + // Released with the handle, as on process exit. + assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_))); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review of #3755, point 1 (and the quitting half of point 3): a launch waiting on a primary + /// that shuts down (closing, or about to relaunch itself in CPU mode) isn't told `ok`; it takes + /// the lock over and opens the file itself. + #[test] + fn a_launch_waiting_on_a_closing_instance_takes_over() { + let dir = temp_dir("closing"); + let primary = primary(&dir); + // The UI never drains (it is shutting down, or never came up). + let waiting = launch(&dir, &["a.psd"], FAST); + std::thread::sleep(Duration::from_millis(200)); + 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(); + drop(primary); + let short = Instant::now() + Duration::from_millis(300); + assert!(send(port, &token, &["b.psd".into()], short).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review of #3755, point 3: a primary whose UI never takes the paths (hung) doesn't + /// acknowledge them, and doesn't keep them queued to open later as well. + #[test] + fn a_hung_instance_never_swallows_a_file() { + let dir = temp_dir("hung-ui"); + let primary = primary(&dir); + let mut ui = primary.connect(&egui::Context::default()); + let started = Instant::now(); + assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_))); + assert!(started.elapsed() < FAST.handoff + Duration::from_secs(1), "{:?}", started.elapsed()); + assert!(ui().is_empty(), "withdrawn, not opened later"); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn requests_without_the_token_or_malformed_are_rejected() { + let token = generate_token().unwrap(); + let ok = parse_request(&json!({"token": token, "open": ["a"], "wait_ms": 250}).to_string(), &token); + assert_eq!(ok, Some((vec!["a".to_string()], Duration::from_millis(250)))); + let wrong = generate_token().unwrap(); + assert_eq!(parse_request(&json!({"token": wrong, "open": ["a"]}).to_string(), &token), None); + for bad in ["", "not json", "{}", r#"{"token":5}"#, &json!({"token": token}).to_string(), &json!({"token": token, "open": [1]}).to_string()] { + assert_eq!(parse_request(bad, &token), None, "{bad:?}"); + } + let many: Vec = (0..=MAX_PATHS).map(|i| i.to_string()).collect(); + assert_eq!(parse_request(&json!({"token": token, "open": many}).to_string(), &token), None); + } + + #[test] + fn a_stalled_client_does_not_hold_up_the_next_launch() { + let dir = temp_dir("stalled"); + let primary = primary(&dir); + let mut ui = primary.connect(&egui::Context::default()); + let (port, _) = read_info(&dir).unwrap(); + // 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(); + let next = launch(&dir, &["b.psd"], FAST); + assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(vec!["b.psd".into()])]); + assert!(matches!(next.join().unwrap(), Launch::Forwarded)); + assert!(started.elapsed() < FAST.request, "took {:?}", started.elapsed()); + drop(stalled); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A client that trickles a byte at a time can't keep its slot past the request deadline. + #[test] + 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 mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); + let started = Instant::now(); + let mut cut_off = false; + while started.elapsed() < FAST.request * 4 { + if slow.write_all(b"{").is_err() { + cut_off = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + assert!(cut_off, "still connected after {:?}", started.elapsed()); + assert!(started.elapsed() < FAST.request * 3, "{:?}", started.elapsed()); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn the_token_file_is_private_to_the_user() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("mode"); + std::fs::create_dir_all(&dir).unwrap(); + // A wide leftover temporary file from a crashed start doesn't widen the new one. + 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(); + let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_unreachable_instance_falls_back_to_a_standalone_launch() { + let dir = temp_dir("unreachable"); + std::fs::create_dir_all(&dir).unwrap(); + // Someone holds the lock but nothing listens on the advertised port. + 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(); + assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_))); + drop(held); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review of #3755, point 2 and the smaller points: who takes part in single-instance. + #[test] + fn only_per_user_launches_without_process_options_take_part() { + let base = LaunchContext { + per_user_settings: true, + new_instance_flag: false, + new_instance_env: None, + control: false, + unreadable_paths: false, + launch_options: &[], + }; + assert_eq!(skip_reason(&base), None); + // A portable copy's folder or a PHOTOCRAFT_CONFIG_DIR may be shared between users. + assert!(skip_reason(&LaunchContext { per_user_settings: false, ..base }).is_some()); + assert!(skip_reason(&LaunchContext { new_instance_flag: true, ..base }).is_some()); + assert!(skip_reason(&LaunchContext { new_instance_env: Some("1"), ..base }).is_some()); + assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some("0"), ..base }), None); + assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None); + assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some()); + assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some()); + let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base }); + assert!(reason.is_some_and(|r| r.contains("--safe-gpu"))); + } +} diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index d67e56dbf0..13105a321d 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -262,8 +262,21 @@ impl PhotocraftApp { match e { OsEvent::Open(paths) => { self.open_paths(&paths); - // Bring the window forward when another app (Finder, the Dock) asked us to open. - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + // Bring the window forward when another app (Finder, the Dock, Explorer via a + // second launch) asked us to open; restore it first if it was minimized. + if ctx.input(|i| i.viewport().minimized) == Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + } + // Native Wayland lets a window come forward only with the launcher's activation + // token, which winit can't apply to an existing window (its focus is a no-op + // there): mark the window as wanting attention instead, so the taskbar + // highlights it rather than the file opening out of sight. + let raise = if self.services.is_wayland { + egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational) + } else { + egui::ViewportCommand::Focus + }; + ctx.send_viewport_cmd(raise); ctx.request_repaint(); } OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index b1eb1e7b72..4d6581cd6d 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -317,6 +317,28 @@ fn os_open_events_open_files_with_paths() { let _ = std::fs::remove_dir_all(&dir); } +/// The viewport commands an `Open` event sends: focus, or an attention request on native Wayland +/// where winit can't focus (#3708). +fn raise_commands(is_wayland: bool) -> Vec { + let (mut app, _) = app_with(Vec::new()); + app.services.is_wayland = is_wayland; + let mut queue = vec![OsEvent::Open(Vec::new())]; + app.services.os_events = Some(Box::new(move || std::mem::take(&mut queue))); + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| app.drain_os_events(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() +} + +#[test] +fn an_os_open_brings_the_window_forward_or_asks_for_attention_on_wayland() { + use egui::{UserAttentionType, ViewportCommand}; + let attention = ViewportCommand::RequestUserAttention(UserAttentionType::Informational); + let x11 = raise_commands(false); + assert!(x11.contains(&ViewportCommand::Focus) && !x11.contains(&attention), "{x11:?}"); + let wayland = raise_commands(true); + assert!(wayland.contains(&attention) && !wayland.contains(&ViewportCommand::Focus), "{wayland:?}"); +} + #[test] fn os_quit_and_empty_events_do_not_panic() { let (mut app, _) = app_with(Vec::new()); diff --git a/docs/development.md b/docs/development.md index 3bfe4095e5..67f688b6b9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -80,6 +80,10 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa - **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. Instead it loads fonts served next to it, on demand: `fonts/manifest.txt` beside `index.html`, in craft-fonts' manifest format (`crates/text/src/served.rs`; hosting: `packaging/web/README.md` › Fonts). `PHOTOCRAFT_WEB_FONTS_DIR="$PWD/../craft-fonts" packaging/web/package.sh` puts craft-fonts there. Served families are in the font menus and are fetched when picked or when a document's text needs them; they aren't in the script fallback list yet, so Japanese text set in a Latin font still needs a Japanese font picked (#1615). - Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI runs them again with `CRAFT_FONTS_DIR` set, in its own Linux job (`test (with craft-fonts)`). Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. +## 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`. + ## 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. From 87918a6c850b7a3f89f2b2bd84b1c648b0ed0517 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:53:13 +0000 Subject: [PATCH 054/171] 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 055/171] 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 056/171] 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 54a4782849e9571b4d6f7bdd6744674d76c4857a Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:26:19 -0400 Subject: [PATCH 057/171] =?UTF-8?q?perf(camera-raw):=20Shadows/Highlights?= =?UTF-8?q?=201.9=C3=97=20faster,=20bit=20for=20bit=20the=20same=20(#5964)?= =?UTF-8?q?=20(#6247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local Laplacian filter behind Shadows and Highlights builds a whole pyramid per reference value (up to 96). Three costs dominated it: - blur_step called mirror() (an integer remainder) for every tap of every pixel; the source rows are now found once per row and columns mirror only within two pixels of an edge. - up() blurred a zero-stuffed plane, three samples in four zero; it now adds only the nonzero taps, in the same order. - the small pyramid levels were split into one-row rayon tasks, costing more in scheduling than the rows; planes under 32K samples run on one thread. The output is bit for bit the same: a new test checks blur_step and up against the plain versions on odd, even, tiny and parallel-sized planes. --- crates/algo/src/camera_raw/local_tone.rs | 140 ++++++++++++++++++++--- 1 file changed, 126 insertions(+), 14 deletions(-) diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs index 7844f8d74b..e1305c5d49 100644 --- a/crates/algo/src/camera_raw/local_tone.rs +++ b/crates/algo/src/camera_raw/local_tone.rs @@ -79,23 +79,48 @@ fn mirror(i: isize, n: usize) -> usize { const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0]; +/// Planes smaller than this run their rows on one thread: the filter builds a whole pyramid per +/// reference (up to 96), and splitting its small levels into one-row tasks cost more in rayon's +/// scheduling than the rows themselves (a fifth of a Shadows render on 36 MP, measured). +const PAR_MIN: usize = 1 << 15; + +/// [`par_rows`] for a plane, on one thread when it is small (see [`PAR_MIN`]). +fn rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) { + if buf.len() < PAR_MIN { + buf.chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| f(y, row)); + } else { + par_rows(buf, w, 1, f); + } +} + /// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution). fn blur_step(p: &Plane, step: usize) -> Plane { let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step)); - // Columns first at the kept rows, then rows at the kept columns. + // Columns first at the kept rows, then rows at the kept columns. Each sum adds the same terms + // in the same order as a `mirror` per tap would, so the result is bit for bit the same; the + // source rows are found once per row and the columns mirrored only near the edges. let mut tmp = vec![0.0f32; p.w * h2]; - par_rows(&mut tmp, p.w, 1, |y2, row| { + rows(&mut tmp, p.w, |y2, row| { let y = (y2 * step) as isize; + let src: [&[f32]; 5] = std::array::from_fn(|k| { + let r = mirror(y + k as isize - 2, p.h); + &p.v[r * p.w..(r + 1) * p.w] + }); for (x, o) in row.iter_mut().enumerate() { - *o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum(); + *o = (0..5).map(|k| TAPS[k] * src[k][x]).sum(); } }); let mut out = vec![0.0f32; w2 * h2]; - par_rows(&mut out, w2, 1, |y2, row| { + rows(&mut out, w2, |y2, row| { let src = &tmp[y2 * p.w..(y2 + 1) * p.w]; for (x2, o) in row.iter_mut().enumerate() { - let x = (x2 * step) as isize; - *o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum(); + let x = x2 * step; + *o = if x >= 2 && x + 2 < p.w { + let s = &src[x - 2..x + 3]; + (0..5).map(|k| TAPS[k] * s[k]).sum() + } else { + (0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum() + }; } }); Plane { w: w2, h: h2, v: out } @@ -107,14 +132,45 @@ fn down(p: &Plane) -> Plane { } /// `p` brought up to `w × h`: zeros between samples, then the blur times four. +/// +/// Three samples in four are zero, so their taps are skipped instead of blurring a zero-filled +/// plane: the nonzero terms are added in the same order (skipping `+0.0` terms changes at most the +/// sign of a zero, and the filter only adds and exponentiates these values). fn up(p: &Plane, w: usize, h: usize) -> Plane { - let mut z = Plane { w, h, v: vec![0.0; w * h] }; - for y in 0..p.h.min(h.div_ceil(2)) { - for x in 0..p.w.min(w.div_ceil(2)) { - z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0; - } + let (lh, lw) = (p.h.min(h.div_ceil(2)), p.w.min(w.div_ceil(2))); + // The source sample behind index `i` of a zero-stuffed axis of length `n` holding `lim` samples. + let src = |i: isize, n: usize, lim: usize| { + let m = mirror(i, n); + (m.is_multiple_of(2) && m / 2 < lim).then_some(m / 2) + }; + // Columns first, only at the even columns (the odd ones are all zero). + let mut tmp = vec![0.0f32; lw * h]; + if lw > 0 { + rows(&mut tmp, lw, |y, row| { + let taps: [Option<&[f32]>; 5] = std::array::from_fn(|k| src(y as isize + k as isize - 2, h, lh).map(|r| &p.v[r * p.w..r * p.w + lw])); + for (j, o) in row.iter_mut().enumerate() { + *o = (0..5).filter_map(|k| taps[k].map(|s| TAPS[k] * (s[j] * 4.0))).sum(); + } + }); } - blur_step(&z, 1) + let mut out = vec![0.0f32; w * h]; + rows(&mut out, w, |y, row| { + let t = &tmp[y * lw..(y + 1) * lw]; + for (x, o) in row.iter_mut().enumerate() { + *o = 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] * t[i / 2]) + }) + .sum() + } else { + (0..5).filter_map(|k| src(x as isize + k as isize - 2, w, lw).map(|j| TAPS[k] * t[j])).sum() + }; + } + }); + Plane { w, h, v: out } } /// Levels down to about 2 pixels on the short side. @@ -154,7 +210,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { for k in 0..refs { let r = lo + step * k as f32; let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() }; - par_rows(&mut m.v, u.w, 1, |_, row| { + rows(&mut m.v, u.w, |_, row| { for v in row.iter_mut() { *v = remap(*v, r); } @@ -163,7 +219,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { 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); - par_rows(&mut lap[l], lw, 1, |y, row| { + rows(&mut lap[l], lw, |y, row| { 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); @@ -337,6 +393,62 @@ mod tests { const SRGB: Encoding = Encoding(0.0); + /// The plain blur: a `mirror` per tap over the whole plane, the reference for [`blur_step`]. + fn blur_step_reference(p: &Plane, step: usize) -> Plane { + let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step)); + let mut tmp = vec![0.0f32; p.w * h2]; + for y2 in 0..h2 { + for x in 0..p.w { + tmp[y2 * p.w + x] = (0..5).map(|k| TAPS[k] * p.v[mirror((y2 * step) as isize + k as isize - 2, p.h) * p.w + x]).sum(); + } + } + let mut out = vec![0.0f32; w2 * h2]; + for y2 in 0..h2 { + for x2 in 0..w2 { + out[y2 * w2 + x2] = (0..5).map(|k| TAPS[k] * tmp[y2 * p.w + mirror((x2 * step) as isize + k as isize - 2, p.w)]).sum(); + } + } + Plane { w: w2, h: h2, v: out } + } + + /// The plain upsampling: a zero-stuffed plane blurred whole, the reference for [`up`]. + fn up_reference(p: &Plane, w: usize, h: usize) -> Plane { + let mut z = Plane { w, h, v: vec![0.0; w * h] }; + for y in 0..p.h.min(h.div_ceil(2)) { + for x in 0..p.w.min(w.div_ceil(2)) { + z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0; + } + } + blur_step_reference(&z, 1) + } + + fn noise_plane(w: usize, h: usize, seed: u32) -> Plane { + let mut s = seed.wrapping_mul(2_654_435_761).max(1); + let v = (0..w * h) + .map(|_| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s % 20_000) as f32 / 1000.0 - 16.0 + }) + .collect(); + Plane { w, h, v } + } + + #[test] + fn the_fast_pyramid_matches_the_plain_one_exactly() { + // Odd, even, tiny and large-enough-to-run-in-parallel sizes, as the pyramid's levels are. + for (i, (w, h)) in [(1, 1), (1, 7), (2, 2), (3, 5), (5, 3), (8, 6), (37, 23), (64, 64), (301, 211)].into_iter().enumerate() { + let p = noise_plane(w, h, i as u32 + 1); + for step in [1, 2] { + 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}"); + } + } + } + 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 19227022ca5a3f908c4412a8acde5ef806825433 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:36:58 +0300 Subject: [PATCH 058/171] Histogram: scale bar heights linearly to the tallest bin, not sorted.get(250) (#6638) --- crates/ui-egui/src/tone.rs | 42 ++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index ba17755bd9..aaf742096a 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -217,11 +217,17 @@ pub fn keep_after_commit(app: &mut PhotocraftApp, layer: LayerId, before: Option } } +/// Display scale for a 256-bin histogram: bar heights are `v / top` clamped to 1, linear +/// to the tallest bin like Photoshop. A percentile of the sorted bins degenerates on +/// sparse histograms (few occupied levels sort 0 into every low index), drawing tiny +/// counts as tall as the real peaks (#6574). +/// Extracted from [`draw_histogram`] so the scaling is unit-testable. +pub fn histogram_scale_top(h: &[u32; 256]) -> f32 { + h.iter().copied().max().unwrap_or(1).max(1) as f32 * 1.1 +} + pub fn draw_histogram(p: &egui::Painter, r: Rect, h: &[u32; 256], color: Color32) { - // Scale to a high percentile so a single spike (e.g. pure white) doesn't flatten the rest. - let mut sorted: Vec = h.to_vec(); - sorted.sort_unstable(); - let top = sorted.get(250).copied().unwrap_or(1).max(1) as f32 * 1.1; + let top = histogram_scale_top(h); let mut pts = vec![egui::pos2(r.left(), r.bottom())]; for (i, v) in h.iter().enumerate() { let x = r.left() + (i as f32 + 0.5) / 256.0 * r.width(); @@ -452,6 +458,34 @@ mod tests { ); assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb), tag(HistSource::Layer(layer), ToneSpace::Rgb)); } + + /// #6574: bar heights scale linearly to the tallest bin. A 90/10 black/white + /// histogram draws the small bar at a ninth of the big one, not full height. + #[test] + fn histogram_scale_is_proportional_for_two_level_images() { + let mut h = [0u32; 256]; + h[0] = 100_000; + h[255] = 900_000; + let top = histogram_scale_top(&h); + let small = (h[0] as f32 / top).min(1.0); + let big = (h[255] as f32 / top).min(1.0); + assert!((small / big - 1.0 / 9.0).abs() < 0.02, "small/big = {}, top = {top}", small / big); + } + + /// #6574: a sparse histogram (few occupied levels) must not scale to a bin + /// index that is 0: the old `sorted.get(250)` degenerated to `top = 1.1`, + /// drawing a few hundred samples as tall as hundreds of thousands. + #[test] + fn histogram_scale_does_not_degenerate_on_sparse_histograms() { + let mut h = [0u32; 256]; + h[0] = 500_000; + h[128] = 300; + h[255] = 500_000; + let top = histogram_scale_top(&h); + assert!(top > h[128] as f32, "scale {top} must exceed the tiny middle bin"); + let tiny = (h[128] as f32 / top).min(1.0); + assert!(tiny < 0.05, "tiny bin draws near-flat, got {tiny}"); + } } #[cfg(test)] From 3c75db7a80eeb7c8b21fb48cced99f044627895a Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:37:17 +0300 Subject: [PATCH 059/171] fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392) (#6394) * fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392) * test(linux): cover AM / AppMan launcher detection in AppRun smoke test (#6392) --- packaging/linux/AppRun | 10 ++++++++++ packaging/linux/apprun-test.sh | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index 43d0eeef7d..b0c58d9d31 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -53,6 +53,16 @@ integrate() { break fi done + # AM / AppMan (#6392) install their own launcher, whose Exec runs a command on + # PATH rather than this AppImage, so the check above misses it. AM's author + # names the only two places it writes one: system-wide for AM, per user for + # AppMan and rootless installs. + for f in /usr/local/share/applications/photocraft-AM.desktop "$apps/photocraft-AM.desktop"; do + if [ -f "$f" ]; then + external=1 + break + fi + done if [ "$external" = 1 ]; then if [ -f "$apps/$DESK_ID.desktop" ]; then rm -f "$apps/$DESK_ID.desktop" 2>/dev/null || true diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh index 8c9e33e6bd..18f920560b 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -129,4 +129,27 @@ out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exi [ "$out" = "stub: x" ] || fail "odd path: $out" [ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping" +# 12. AM / AppMan (#6392): a photocraft-AM.desktop launcher in the user's applications +# dir owns the menu entry. AppRun removes its own entry left from an earlier run, +# writes no second one and still installs the icons. +rm -rf "$XDG_DATA_HOME" +"$APPDIR/AppRun" >/dev/null || fail "launch before AM install exited non-zero" +[ -f "$DESKTOP" ] || fail "no desktop entry before AM install" +am_desktop="$XDG_DATA_HOME/applications/photocraft-AM.desktop" +printf '[Desktop Entry] +Type=Application +Name=PhotoCraft +Exec=photocraft %%F +Icon=%s +' "$APP_ID" >"$am_desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch with AM launcher exited non-zero" +[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept alongside AM launcher (#6392)" +[ -f "$am_desktop" ] || fail "AM launcher was removed" +[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with AM launcher" + +# 13. Once AM removes its launcher, AppRun writes its own entry again. +rm -f "$am_desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch after AM removal exited non-zero" +[ -f "$DESKTOP" ] || fail "no desktop entry after the AM launcher was removed" + echo "AppRun desktop integration ok" From ad6463b50f6844001c7927a0d36fbf6acfae783b Mon Sep 17 00:00:00 2001 From: Diego Chagas Date: Fri, 16 Oct 2026 19:37:45 -0300 Subject: [PATCH 060/171] Paragraph text: a word wider than the box breaks at the box edge (#6694) (#6696) parley's default overflow wrap only breaks at UAX #14 opportunities, so a word longer than the paragraph box ran out past its edge and resizing the box changed nothing. Paragraph text now sets OverflowWrap::BreakWord, so such a word wraps at the box edge as Photoshop does; a space or hyphen still wins over a mid-word break, a box that fits the word lays out as before, and point text is untouched. --- crates/text/src/layout.rs | 6 ++++++ crates/text/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index e2bd223b3b..19d7b8c72d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -586,6 +586,12 @@ impl Layouter { for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) { b.push_default(p); } + // Paragraph text breaks a word wider than the box at the box edge, as Photoshop + // does (#6694): parley's default only breaks at UAX #14 opportunities and lets the + // word run out of the box. Point text has no width to overflow. + if is_box { + b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord)); + } for (ri, st) in out.styles.iter().enumerate() { let rs = run_starts[ri]; let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end)); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..7afb4d6cd1 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2008,3 +2008,37 @@ fn hyphenless_postscript_names_split_trailing_styles() { assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}"); } } + +/// 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] +fn a_word_wider_than_the_paragraph_box_breaks_at_the_box_edge() { + let mut e = TextEngine::new(); + let word = "BLOQUEADO?!"; + let alone = e.layout(&point(word, 12.0), 72.0); + assert_eq!(alone.lines.len(), 1, "{:?}", alone.lines); + let word_width = width(&alone); + let narrow = 30.0; + assert!(word_width > narrow * 2.0, "{word_width}"); + + let mut t = point(word, 12.0); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 2, "{:?}", l.lines); + for line in &l.lines { + assert!(line.x1 <= narrow + 0.5, "{line:?}"); + } + let joined: String = l.lines.iter().map(|l| &word[l.range.clone()]).collect(); + assert_eq!(joined, word); + + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: word_width + 20.0, height: 200.0 }; + let fits = e.layout(&t, 72.0); + assert_eq!(fits.lines.len(), 1, "{:?}", fits.lines); + assert!((width(&fits) - word_width).abs() < 0.01, "{} vs {word_width}", width(&fits)); + + let mut spaced = point("ab cd", 12.0); + spaced.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 }; + let l = e.layout(&spaced, 72.0); + assert_eq!(l.lines.len(), 2, "{:?}", l.lines); + assert_eq!(l.lines[1].range.start, 3, "{:?}", l.lines); +} From 97f50b471467611dbecec63ad0c9a2b6b304e6e1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:38:11 +0200 Subject: [PATCH 061/171] Pen: the path in progress stays visible while panning with Space (fixes #6592) (#6625) --- crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/pen_draw_tests.rs | 56 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 57d3575111..acbf95063f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4091,6 +4091,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if primary && hand_pan && response.dragged() { crate::transform_tool::draw_content(app, &painter, &xf); crate::transform_tool::draw_overlay(app, &painter, &xf); + // The Pen's path in progress survives a Space pan, and stays on screen through it (#6592). + crate::vector_ui::draw_overlay(app, &painter, &xf, &doc); } app.tool_override = None; if let Some(b) = layer_edges_rect(app, idx) { diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 155450eac6..57be940bd6 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -219,3 +219,59 @@ fn delete_with_the_pen_deletes_the_finished_work_path_not_the_pixels() { h.run_steps(2); assert!(work(&h), "another tool's Delete is Edit › Clear, not Delete Path"); } + +fn space(h: &mut Harness<'static, PhotocraftApp>, down: bool) { + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: down, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +/// Did the last frame paint the Pen's path in progress: an accent polyline starting at `at`, its +/// first anchor on screen (`vector_ui::draw_overlay`)? +fn pen_path_painted(h: &Harness<'static, PhotocraftApp>, at: Pos2) -> bool { + let accent = crate::theme::Tokens::get(&h.ctx).accent; + h.output().shapes.iter().any(|c| match &c.shape { + egui::Shape::Path(p) => { + p.stroke.width == 1.5 + && matches!(p.stroke.color, egui::epaint::ColorMode::Solid(col) if col == accent) + && p.points.len() >= 2 + && p.points[0].distance(at) < 1.0 + } + _ => false, + }) +} + +/// #6592: holding Space to pan with the Hand keeps the Pen's path in progress, and shows it, as in +/// Photoshop. +#[test] +fn space_pan_keeps_the_pen_path_in_progress_visible() { + let mut h = harness(); + click(&mut h, [20.0, 20.0]); + click(&mut h, [100.0, 20.0]); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + let p = screen(&h, [150.0, 100.0]); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + assert!(pen_path_painted(&h, screen(&h, [20.0, 20.0])), "drawn before Space"); + space(&mut h, true); + assert!(pen_path_painted(&h, screen(&h, [20.0, 20.0])), "drawn with Space held, before the drag"); + let c0 = h.state().ui.views[0].center; + button(&mut h, p, true, Modifiers::NONE); + for i in 1..=5 { + // A pan frame paints the image and its overlays with the view it started with. + let first = screen(&h, [20.0, 20.0]); + h.event(egui::Event::PointerMoved(p + vec2(10.0 * i as f32, 0.0))); + h.run_steps(1); + let pen = h.state().ui.pen.as_ref().map(|p| p.knots.len()); + let painted = pen_path_painted(&h, first); + assert_eq!(pen, Some(2), "the path in progress is kept while panning (step {i})"); + assert!(painted, "the path in progress is drawn while panning (step {i})"); + } + button(&mut h, p + vec2(50.0, 0.0), false, Modifiers::NONE); + space(&mut h, false); + h.run_steps(1); + let c1 = h.state().ui.views[0].center; + assert!((c1[0] - (c0[0] - 50.0)).abs() < 0.5, "Space-drag panned: {c0:?} -> {c1:?}"); + assert_eq!(h.state().ui.tool, Tool::Pen); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2), "the path in progress survives the pan"); + assert!(pen_path_painted(&h, screen(&h, [20.0, 20.0])), "drawn after the pan"); +} From 6a7fc88c79d81a8d7c6c6c1ec8c06326fb43b3e8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:38:35 +0200 Subject: [PATCH 062/171] Crop tool: custom ratios keep three decimals, 1.414 : 1 no longer becomes 1.4 : 1 (fixes #6487) (#6503) --- crates/ui-egui/src/crop_presets.rs | 2 +- crates/ui-egui/src/crop_size.rs | 19 ++++++++++++++- crates/ui-egui/src/crop_ui.rs | 3 ++- crates/ui-egui/src/panels.rs | 39 ++++++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/crop_presets.rs b/crates/ui-egui/src/crop_presets.rs index e049fdfa8c..e30b0d1cfc 100644 --- a/crates/ui-egui/src/crop_presets.rs +++ b/crates/ui-egui/src/crop_presets.rs @@ -109,7 +109,7 @@ pub fn savable(o: &ToolOptions) -> Option<(Map, String)> { if !(a.is_finite() && b.is_finite() && a > 0.0 && b > 0.0) { return None; } - let name = format!("{} : {}", crate::widgets::fmt_num(a), crate::widgets::fmt_num(b)); + let name = format!("{} : {}", crop_size::fmt_ratio(a), crop_size::fmt_ratio(b)); let mut fields = Map::new(); fields.insert("crop_ratio".into(), json!(o.crop_ratio)); Some((fields, name)) diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index 28a6a0e12f..b30cdd8c4a 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -146,6 +146,14 @@ pub fn ratio(o: &ToolOptions, doc_dpi: f64) -> Option { Some(w / h).filter(|k| k.is_finite() && *k > 0.0) } +/// Decimals a custom ratio side keeps and shows: 1.414 : 1 (ISO A sizes) as in Photoshop (#6487). +pub const RATIO_DECIMALS: u32 = 3; + +/// A ratio side as stored in a `crop_ratio` key ("1.414", "16"). +pub fn fmt_ratio(v: f64) -> String { + crate::widgets::fmt_num_prec(v, RATIO_DECIMALS) +} + /// Editable W and H shown for a custom aspect ratio. An existing valid ratio is authoritative; /// when either side is zero, use the in-progress values retained in ToolOptions. pub fn custom_ratio_fields(o: &ToolOptions, doc_size: (f64, f64)) -> (f32, f32) { @@ -170,7 +178,7 @@ pub fn set_custom_ratio_fields(o: &mut ToolOptions, width: f32, height: f32) { o.crop_custom_ratio_width = width; o.crop_custom_ratio_height = height; if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { - o.crop_ratio = format!("{}:{}", crate::widgets::fmt_num(f64::from(width)), crate::widgets::fmt_num(f64::from(height))); + o.crop_ratio = format!("{}:{}", fmt_ratio(f64::from(width)), fmt_ratio(f64::from(height))); } else { o.crop_ratio.clear(); } @@ -347,6 +355,15 @@ mod tests { assert_eq!(width / height, 4.0 / 3.0); } + #[test] + fn custom_ratio_keeps_three_decimals() { + // #6487: 1.414 : 1 (ISO A sizes) stays 1.414, not 1.4, as in Photoshop. + let mut options = ToolOptions::default(); + set_custom_ratio_fields(&mut options, 1.414, 1.0); + assert_eq!(options.crop_ratio, "1.414:1"); + assert_eq!(custom_ratio_fields(&options, (0.0, 0.0)), (1.414, 1.0)); + } + #[test] fn selecting_free_ratio_clears_previous_custom_dimensions() { let mut options = ToolOptions { crop_ratio: "16:9".into(), ..ToolOptions::default() }; diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 78f8d44b50..f1df63ff14 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -645,7 +645,7 @@ pub fn swapped(r: [f64; 4]) -> [f64; 4] { /// no ratio is set; "original" becomes the document's size turned on its side. pub fn swapped_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option { let (w, h) = crate::chrome_ui::crop_ratio(key, doc_w, doc_h).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0)?; - Some(format!("{}:{}", crate::widgets::fmt_num(h), crate::widgets::fmt_num(w))) + Some(format!("{}:{}", crate::crop_size::fmt_ratio(h), crate::crop_size::fmt_ratio(w))) } /// X: swaps the pending frame between portrait and landscape about its centre, keeping its angle, @@ -1949,6 +1949,7 @@ mod tests { app.ui.tool_options.crop_ratio = String::new(); assert!(swap_orientation(&mut app)); assert_eq!(app.ui.tool_options.crop_ratio, "", "no ratio stays none"); + assert_eq!(swapped_ratio("1.414:1", 1.0, 1.0).as_deref(), Some("1:1.414"), "decimals survive the swap (#6487)"); assert_eq!(swapped_ratio("0:5", 1.0, 1.0), None); assert_eq!(swapped_ratio("nonsense", 1.0, 1.0), None); // No frame, or another tool: X is Switch Foreground and Background Colors. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4793554150..7f2eadd084 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1279,9 +1279,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Keep partial edits in ToolOptions so the first side survives a frame; // 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 cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed(); + 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 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(); + let ch = widgets::value_field_prec(ui, &mut rh, 0.0..=99_999.0, "", 54.0, decimals).changed(); if swap { std::mem::swap(&mut rw, &mut rh); } @@ -6217,6 +6218,40 @@ mod compact_visual_regressions { } } + #[test] + fn the_crop_ratio_fields_show_three_decimals() { + // #6487: a 1.414 : 1 ratio reads 1.414 in the bar, not 1.4. + 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(), Default::default()); + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_ratio = "1.414:1".into(); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 120.0)); + let mut out = ctx.run_ui(input_at_scale(screen, 1.0), |ui| options_bar(&mut app, ui)); + for _ in 0..3 { + out.textures_delta.clear(); + out = ctx.run_ui(input_at_scale(screen, 1.0), |ui| options_bar(&mut app, ui)); + } + out.textures_delta.clear(); + let texts: Vec<&str> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.job.text.as_str()), + _ => None, + }) + .collect(); + assert!(texts.contains(&"1.414"), "{texts:?}"); + assert_eq!(app.ui.tool_options.crop_ratio, "1.414:1", "showing the ratio leaves it alone"); + } + #[test] fn the_narrow_options_bar_preference_shortens_the_bar() { assert_eq!(options_bar_height(true, false), 36.0); From f1c45b887ee9fedbcd00613c8fdb804f32eb75ec Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:38:54 -0700 Subject: [PATCH 063/171] fix(codecs): respect PAM ENDHDR line boundaries (#5874) --- crates/codecs/src/codecs/pnm.rs | 17 ++++++- crates/codecs/tests/pnm_headers.rs | 82 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 crates/codecs/tests/pnm_headers.rs diff --git a/crates/codecs/src/codecs/pnm.rs b/crates/codecs/src/codecs/pnm.rs index ca36db8109..c16cccfd02 100644 --- a/crates/codecs/src/codecs/pnm.rs +++ b/crates/codecs/src/codecs/pnm.rs @@ -66,6 +66,21 @@ impl<'a> Reader<'a> { } } + /// PAM ends its header with an LF-terminated line, unlike P4–P6's single separator. + fn pam_endhdr(&mut self) -> Result<(), CodecError> { + while let Some(&c) = self.b.get(self.pos) { + self.pos += 1; + if c == b'\n' { + // Stop at the first LF: whitespace and '#' after it are raster bytes. + return Ok(()); + } + if !c.is_ascii_whitespace() { + return Err(err("unexpected data after PAM ENDHDR")); + } + } + Err(err("unterminated PAM ENDHDR line")) + } + fn rest(&self) -> &'a [u8] { &self.b[self.pos.min(self.b.len())..] } @@ -269,7 +284,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result _ => return Err(err("unknown PAM header field")), } } - r.single_ws()?; + r.pam_endhdr()?; let (w, h, depth, maxval) = match (w, h, depth, maxval) { (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d), _ => return Err(err("incomplete PAM header")), diff --git a/crates/codecs/tests/pnm_headers.rs b/crates/codecs/tests/pnm_headers.rs new file mode 100644 index 0000000000..b6d9c076f5 --- /dev/null +++ b/crates/codecs/tests/pnm_headers.rs @@ -0,0 +1,82 @@ +use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, decode, decode_as}; + +const PAM_GRAY: &[u8] = b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 1\nMAXVAL 255\nENDHDR"; +const ENDINGS: &[&[u8]] = &[b"\n", b" \n", b"\t \n", b"\r\n", b" \t\r\n"]; + +#[test] +fn pam_consumes_the_complete_endhdr_line() { + for ending in ENDINGS { + let mut bytes = PAM_GRAY.to_vec(); + bytes.extend_from_slice(ending); + bytes.push(0x80); + let img = decode(&bytes).unwrap(); + assert_eq!(img.dimensions(), (1, 1)); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.data(), &[0x80], "ENDHDR ending {ending:?}"); + } +} + +#[test] +fn pam_preserves_every_possible_first_raster_byte() { + // In particular, LF, CR, space, tab and '#' must not be skipped as header syntax. + for ending in ENDINGS { + for first in 0..=255u8 { + let mut bytes = PAM_GRAY.to_vec(); + bytes.extend_from_slice(ending); + bytes.push(first); + assert_eq!(decode(&bytes).unwrap().data(), &[first], "ENDHDR ending {ending:?}, raster byte {first}"); + } + } +} + +#[test] +fn pam_crlf_header_preserves_u16_samples_and_alpha() { + for ending in ENDINGS { + let mut bytes = b"P7\r\nWIDTH 1\r\nHEIGHT 1\r\nDEPTH 2\r\nMAXVAL 65535\r\nTUPLTYPE GRAYSCALE_ALPHA\r\nENDHDR".to_vec(); + bytes.extend_from_slice(ending); + bytes.extend_from_slice(b" \n#\r"); + let img = decode(&bytes).unwrap(); + assert_eq!(img.layout(), ChannelLayout::GrayA); + assert_eq!(img.sample_type(), SampleType::U16); + assert_eq!(img.to_u16_samples().unwrap(), vec![0x200a, 0x230d], "ENDHDR ending {ending:?}"); + } +} + +#[test] +fn pam_rejects_unterminated_or_invalid_endhdr_lines() { + for suffix in [b"".as_slice(), b" ", b"\r", b" \t\r", b" \x80", b" \x80\n\x7f", b"#comment\n\x7f"] { + let mut bytes = PAM_GRAY.to_vec(); + bytes.extend_from_slice(suffix); + assert!(matches!(decode_as(Format::Pnm, &bytes), Err(CodecError::Malformed { .. })), "ENDHDR suffix {suffix:?}"); + } +} + +#[test] +fn pam_truncation_never_uses_header_whitespace_as_pixels() { + for ending in ENDINGS { + let mut bytes = PAM_GRAY.to_vec(); + bytes.extend_from_slice(ending); + bytes.push(0x80); + for cut in 0..bytes.len() { + assert!(decode_as(Format::Pnm, &bytes[..cut]).is_err(), "ENDHDR ending {ending:?}, truncated at {cut}"); + } + } +} + +#[test] +fn binary_pnm_keeps_its_single_whitespace_separator() { + for separator in *b" \t\r\n" { + for first in *b" \t\r\n#" { + for (header, channels) in [(b"P5\n1 1\n255".as_slice(), 1), (b"P6\n1 1\n255", 3)] { + let mut bytes = header.to_vec(); + bytes.push(separator); + bytes.extend(std::iter::repeat_n(first, channels)); + assert_eq!(decode(&bytes).unwrap().data(), vec![first; channels]); + } + let mut bytes = b"P4\n8 1".to_vec(); + bytes.extend_from_slice(&[separator, first]); + let expected: Vec = (0..8).map(|bit| if first & (0x80 >> bit) == 0 { 255 } else { 0 }).collect(); + assert_eq!(decode(&bytes).unwrap().data(), expected); + } + } +} From 651647fdf68e0ca6cbfb3131187eeeed936382eb Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:56:48 +0400 Subject: [PATCH 064/171] docs(cli): write product name as PhotoCraft in USAGE and description (#5449) --- apps/photocraft-cli/Cargo.toml | 2 +- apps/photocraft-cli/src/lib.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index a6d3b49389..f4848f379a 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photocraft-cli" -description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server" +description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server" version.workspace = true edition.workspace = true license.workspace = true diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index b93d4ccc8f..03001ab02d 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -1,4 +1,4 @@ -//! Headless Photocraft command line. `run` is the whole program, so it can be +//! Headless PhotoCraft command line. `run` is the whole program, so it can be //! tested in-process as well as through the binary. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -12,7 +12,7 @@ use photocraft_io::ExportOptions; use serde_json::{Value, json}; pub const USAGE: &str = "\ -photocraft-cli: headless Photocraft +photocraft-cli: headless PhotoCraft USAGE: photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] From f360cb0b4a9c31085d3dcbe7877b64c8de5fed62 Mon Sep 17 00:00:00 2001 From: PerchenkoAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:57:33 +0300 Subject: [PATCH 065/171] fix(engine): give artboard exports unique indexed filenames (#6655) --- crates/engine/src/artboard_cmds.rs | 7 +- crates/engine/src/artboard_cmds/tests.rs | 4 +- crates/engine/tests/artboard_export_names.rs | 103 +++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/artboard_export_names.rs diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 5cebe86aa9..c3e3e079f7 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -528,7 +528,7 @@ fn chosen_artboards(doc: &Document, p: &Value) -> Vec { } } -/// File › Export › Artboards to Files: `_.` per board. +/// File › Export › Artboards to Files: `__.` per board. fn artboards_to_files(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.artboardsToFiles"; let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string(); @@ -538,8 +538,9 @@ fn artboards_to_files(s: &mut Session, p: &Value) -> Result { let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name)); let doc = d.doc.clone(); let mut files = Vec::new(); - for one in chosen_documents(&doc, p)? { - let name = if prefix.is_empty() { sanitize(&one.name) } else { format!("{}_{}", sanitize(&prefix), sanitize(&one.name)) }; + for (i, one) in chosen_documents(&doc, p)?.into_iter().enumerate() { + // Names may be identical, or collide after sanitizing or filesystem case folding. + let name = if prefix.is_empty() { format!("{i:04}_{}", sanitize(&one.name)) } else { format!("{}_{i:04}_{}", sanitize(&prefix), sanitize(&one.name)) }; let path = join(&dir, &format!("{name}.{format}")); save_doc(&one, &path, save)?; files.push(path); diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index 1faf4cb75d..c7695f228e 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -146,8 +146,8 @@ fn export_to_files_and_pdf() { let r = s.execute("file.export.artboardsToFiles", json!({"dir": d, "prefix": "site"})).unwrap(); let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); assert_eq!(files.len(), 2); - assert!(files[0].ends_with("site_Empty_One.png"), "{files:?}"); - assert!(files[1].ends_with("site_Red Board.png"), "{files:?}"); + assert!(files[0].ends_with("site_0000_Empty_One.png"), "{files:?}"); + assert!(files[1].ends_with("site_0001_Red Board.png"), "{files:?}"); let img = photocraft_io::import("x.png", &std::fs::read(&files[1]).unwrap()).unwrap().document; assert_eq!((img.size.width, img.size.height), (10, 10)); let img2 = photocraft_io::import("x.png", &std::fs::read(&files[0]).unwrap()).unwrap().document; diff --git a/crates/engine/tests/artboard_export_names.rs b/crates/engine/tests/artboard_export_names.rs new file mode 100644 index 0000000000..f9c46f3eb7 --- /dev/null +++ b/crates/engine/tests/artboard_export_names.rs @@ -0,0 +1,103 @@ +//! #6613: each artboard must have its own output even when names collide on disk. +#![cfg(not(target_arch = "wasm32"))] + +use std::collections::HashSet; +use std::path::Path; + +use photocraft_engine::Session; +use serde_json::json; + +struct Board { + id: u64, + width: u32, + color: [f32; 4], +} + +fn fixture(depth: u32, names: &[&str]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", json!({"name": "Board Export", "width": 96, "height": 32, "depth": depth})).unwrap(); + let mut boards = Vec::new(); + for (i, name) in names.iter().enumerate() { + let width = 3 + i as u32; + let color = if i % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }; + let result = s.execute("layer.new.artboard", json!({"name": name, "rect": [12 * i, 0, width, 2], "background": "custom", "color": color})).unwrap(); + boards.push(Board { id: result["layer"].as_u64().unwrap(), width, color }); + } + (s, boards) +} + +fn check_export(depth: u32, names: &[&str], prefix: Option<&str>, format: &str, selected: Option<&[usize]>) { + let (mut s, boards) = fixture(depth, names); + let dir = tempfile::tempdir().unwrap(); + let mut params = json!({"dir": dir.path(), "format": format}); + if let Some(prefix) = prefix { + params["prefix"] = json!(prefix); + } + if let Some(selected) = selected { + params["artboards"] = json!(selected.iter().map(|i| boards[*i].id).collect::>()); + } + let expected: Vec<_> = boards.iter().enumerate().rev().filter(|(i, _)| selected.is_none_or(|ids| ids.contains(i))).map(|(_, b)| b).collect(); + let before = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.past_len(); + let result = s.execute("file.export.artboardsToFiles", params).unwrap(); + let files: Vec<_> = result["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(files.len(), expected.len()); + // Also require uniqueness on filesystems that compare names without letter case. + assert_eq!(files.iter().map(|f| f.to_lowercase()).collect::>().len(), files.len(), "export paths collide: {files:?}"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), files.len(), "one physical output per returned path"); + for (file, board) in files.iter().zip(expected) { + assert_eq!(Path::new(file).parent(), Some(dir.path())); + let bytes = std::fs::read(file).unwrap(); + let doc = photocraft_io::import(file, &bytes).unwrap().document; + assert_eq!((doc.size.width, doc.size.height), (board.width, 2), "wrong artboard in {file}"); + let px = photocraft_compose::flatten(&doc).px[0]; + for (actual, expected) in px.iter().zip(board.color) { + assert!((actual - expected).abs() < 1e-3, "wrong pixels in {file}: {px:?}"); + } + } + let after = s.active().unwrap(); + assert_eq!(photocraft_format::save_to_bytes(&after.doc, &Default::default()).unwrap(), before); + assert_eq!(after.revision, revision, "export must not edit the source document"); + assert_eq!(after.history.past_len(), history); +} + +#[test] +fn duplicate_names_preserve_each_board_at_every_depth_and_prefix() { + for depth in [8, 16, 32] { + for prefix in [None, Some(""), Some("Launch/Run")] { + check_export(depth, &["Artboard 1", "Artboard 1"], prefix, "png", None); + } + } +} + +#[test] +fn sanitized_and_empty_names_preserve_each_tiff() { + check_export(16, &["Hero/1", "Hero:1", "Hero?1", ".", " ", "layer"], Some(""), "tiff", None); +} + +#[test] +fn case_only_and_index_like_names_remain_distinct() { + check_export(8, &["Logo", "logo", "Logo_0001", "0000_Logo", "Лого", "лого"], Some("site"), "png", None); +} + +#[test] +fn selected_duplicate_names_keep_their_psd_contents_in_stack_order() { + check_export(16, &["Same", "Skip", "Same"], Some("chosen"), "psd", Some(&[0, 2])); +} + +#[test] +fn distinct_names_keep_their_corresponding_contents() { + check_export(8, &["Red", "Blue"], None, "png", None); +} + +#[test] +fn invalid_export_params_fail_before_creating_files() { + let (mut s, _) = fixture(8, &["One", "Two"]); + let dir = tempfile::tempdir().unwrap(); + for params in [json!({"dir": 3}), json!({"dir": dir.path(), "artboards": []}), json!({"dir": dir.path(), "artboards": [u64::MAX]})] { + assert!(s.execute("file.export.artboardsToFiles", params).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + assert!(Session::new().execute("file.export.artboardsToFiles", json!({"dir": dir.path()})).is_err()); +} From e33384c9bae25f9bc5c75970d1234ddf44e5ddd4 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:58:50 +0400 Subject: [PATCH 066/171] docs: write product name as PhotoCraft in crate module docs (#5455) --- apps/photocraft-web/src/main.rs | 2 +- apps/photocraft/src/main.rs | 2 +- crates/cms/src/lib.rs | 2 +- crates/cms/src/lutfile.rs | 2 +- crates/cms/src/synth.rs | 2 +- crates/doc/src/lib.rs | 2 +- crates/geom/src/lib.rs | 2 +- crates/paint/src/brush.rs | 2 +- crates/text/src/fonts.rs | 2 +- crates/text/src/lib.rs | 2 +- crates/ui-egui/examples/snapshot.rs | 2 +- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/widgets.rs | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 5ba75f5254..428143c880 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -1,4 +1,4 @@ -//! Photocraft in the browser. +//! PhotoCraft in the browser. //! //! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web //! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index c0f93bd319..abd70eb418 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -1,4 +1,4 @@ -//! Photocraft desktop app. +//! PhotoCraft desktop app. //! //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs index d6911ccd24..f93b00372b 100644 --- a/crates/cms/src/lib.rs +++ b/crates/cms/src/lib.rs @@ -1,4 +1,4 @@ -//! Photocraft colour management: a pure-Rust ICC v2/v4 colour management module. +//! PhotoCraft colour management: a pure-Rust ICC v2/v4 colour management module. //! //! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA ` //! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles. diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 0a68ef8bce..47ff164fda 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -245,7 +245,7 @@ fn smooth(x: f32) -> f32 { x * x * (3.0 - 2.0 * x) } -/// Generated looks shipped with Photocraft (procedural, CC0): `(id, label)`. +/// Generated looks shipped with PhotoCraft (procedural, CC0): `(id, label)`. pub const BUILTIN: [(&str, &str); 8] = [ ("warm", "Warm Filter"), ("cool", "Cool Filter"), diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index 96a23dbfce..390e509dce 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -2,7 +2,7 @@ //! //! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the //! freely downloadable characterisation-based profiles come with redistribution conditions, so -//! Photocraft ships a CMYK profile it generates itself from a documented printer model: +//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model: //! //! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16 //! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4f7f523d1..a1214e7938 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1,4 +1,4 @@ -//! The Photocraft document model: pure data, no rendering, no UI. +//! The PhotoCraft document model: pure data, no rendering, no UI. //! //! Layer order: inside every group (and the root), `children[0]` is the **bottom** layer, matching //! compositing order and the PSD file order. UIs display the list reversed. diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 0e76736013..d95e4b847f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -1,4 +1,4 @@ -//! Geometry primitives shared by every Photocraft crate. +//! Geometry primitives shared by every PhotoCraft crate. //! //! Document space is integer pixels with the origin at the canvas top-left. //! Layers may extend beyond the canvas (negative coordinates are valid). diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index d60412a58d..54e74e2e1f 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -678,7 +678,7 @@ impl BrushSettings { pub struct BrushPreset { pub name: String, pub brush: BrushSettings, - /// Shipped with Photocraft (built-ins can be deleted from a session but are regenerated on reset). + /// Shipped with PhotoCraft (built-ins can be deleted from a session but are regenerated on reset). #[serde(default)] pub builtin: bool, /// Preset group (folder) in the Brushes panel, e.g. "General" or an imported file's name. diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 5ce71e59ac..756ce87455 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -35,7 +35,7 @@ pub static INTER_MEDIUM: LazyLock> = LazyLock::new(|| inflate_font(bundl pub static INTER_SEMIBOLD: LazyLock> = LazyLock::new(|| inflate_font(bundled_font!("Inter-SemiBold.ttf"))); pub static JETBRAINS_MONO_REGULAR: LazyLock> = LazyLock::new(|| inflate_font(bundled_font!("JetBrainsMono-Regular.ttf"))); -/// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`). +/// Fonts shipped with PhotoCraft (OFL; licences in `assets/fonts`). pub static BUNDLED: [(&str, &LazyLock>); 4] = [ ("Inter-Regular.ttf", &INTER_REGULAR), ("Inter-Medium.ttf", &INTER_MEDIUM), diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ad5e90ae35..af4de19f52 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1,4 +1,4 @@ -//! Photocraft's type engine. +//! PhotoCraft's type engine. //! //! * [`fonts::FontDb`]: bundled fonts (Inter, JetBrains Mono; always available, including on the //! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list, diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 31e4c60301..0d227255db 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -1,4 +1,4 @@ -//! Render the full Photocraft UI offscreen (no window, no focus stealing) and save a PNG. +//! Render the full PhotoCraft UI offscreen (no window, no focus stealing) and save a PNG. //! //! ```sh //! cargo run --release -p photocraft-ui-egui --example snapshot -- \ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..4e82be7107 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1,4 +1,4 @@ -//! Photocraft's first UI shell, built on egui/eframe. +//! PhotoCraft's first UI shell, built on egui/eframe. //! //! This crate is deliberately thin. Every action goes through //! [`photocraft_engine::Session::execute`], and all UI state lives in [`state::UiState`] (plain diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2acbdf3c8b..7235790cbe 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,4 +1,4 @@ -//! Custom widgets for the Photocraft look: cards with pill tabs, thin sliders with round knobs, +//! Custom widgets for the PhotoCraft look: cards with pill tabs, thin sliders with round knobs, //! monospace value fields with dimmed units, toggle switches, primary/secondary buttons. use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; From 93f6376c5eec66389878d7981d9855f4ed1fead6 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:59:41 +0400 Subject: [PATCH 067/171] docs: write product name as PhotoCraft in package descriptions and comments (#5615) --- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft/Cargo.toml | 2 +- crates/automation/Cargo.toml | 2 +- crates/gpu/src/compose.wgsl | 2 +- crates/io/tests/text_corpus.rs | 2 +- packaging/macos/Info.plist.in | 2 +- packaging/macos/entitlements.plist | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index a4ded64181..786165b532 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photocraft-web" -description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" +description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" version.workspace = true edition.workspace = true license.workspace = true diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index e5af663471..8b7c832bb2 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true default-run = "photocraft" -description = "Photocraft: an open-source, native image editor" +description = "PhotoCraft: an open-source, native image editor" build = "build.rs" [features] diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 0738140a41..fb718e5668 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photocraft-automation" -description = "MCP server (rmcp) over the Photocraft engine, plus a bridge to a running GUI" +description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 2b9c1bbdf4..6457b1192c 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1,4 +1,4 @@ -// Photocraft GPU compositor kernels. +// PhotoCraft GPU compositor kernels. // // Every pass renders one chunk of the canvas into a straight-alpha RGBA32F accumulator, reading // its inputs with `textureLoad` at the same pixel. Effect-map kernels (`fs_m*`) render diff --git a/crates/io/tests/text_corpus.rs b/crates/io/tests/text_corpus.rs index a2efe1aec7..8597ac49d8 100644 --- a/crates/io/tests/text_corpus.rs +++ b/crates/io/tests/text_corpus.rs @@ -169,7 +169,7 @@ fn corpus_photoshop_txt2_survives_an_unedited_save() { println!("Txt2 kept byte-for-byte in {checked} Photoshop type files"); } -/// A text layer created in Photocraft (no PSD data) exports as an editable type layer and +/// A text layer created in PhotoCraft (no PSD data) exports as an editable type layer and /// imports back with the same model. #[test] fn created_text_layer_roundtrips_through_psd() { diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index d325b2b9d6..b3cde0ea1c 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -71,7 +71,7 @@ NSHumanReadableCopyright Copyright © the PhotoCraft authors. MIT OR Apache-2.0. - + UTExportedTypeDeclarations diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist index 05e3194d84..c32269c1d3 100644 --- a/packaging/macos/entitlements.plist +++ b/packaging/macos/entitlements.plist @@ -1,6 +1,6 @@ - From 8fe2c74f58dcebfd5f6400e91a3b362f8c63cf00 Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Sat, 17 Oct 2026 07:00:43 +0800 Subject: [PATCH 068/171] perf(testkit): sample RSS from /proc/self/statm, not smaps, on Linux (#5399) memory-stats reads /proc/self/smaps by default on Linux: every sample formats and parses one block per mapping while holding the mmap lock. The benches' RssSampler polls it every 2 ms, so on a profile of bevel_bench --size 4000x3000 the rss-sampler thread was ~22% of all samples (smaps_account 14.7%, smaps_pte_entry 6.3%), contending with the measured threads. Enable the crate's always_use_statm feature: one short read of the kernel's RSS counters, the same number VmRSS reports. The measured code is unchanged; only the harness overhead goes. --- crates/testkit/Cargo.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index 0d69300d90..3e8bbed919 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -14,5 +14,7 @@ photocraft-raster = { workspace = true } serde_json = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0). -memory-stats = "1.2" +# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0). `always_use_statm`: +# on Linux read /proc/self/statm, not /proc/self/smaps, which walks every mapping under the mmap +# lock and made the 2 ms sampler thread a fifth of a large bench's CPU time. +memory-stats = { version = "1.2", features = ["always_use_statm"] } From 462adeadd26a83ec1eb16c6a59cde8ae83b0e6e3 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:01:10 -0700 Subject: [PATCH 069/171] test(io): cover mixed-region CMYK TIFF composites (#5588) --- crates/io/tests/cmyk_composite_export.rs | 95 +++++++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/crates/io/tests/cmyk_composite_export.rs b/crates/io/tests/cmyk_composite_export.rs index 9f537dabfd..31c0b6969f 100644 --- a/crates/io/tests/cmyk_composite_export.rs +++ b/crates/io/tests/cmyk_composite_export.rs @@ -1,8 +1,8 @@ use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{ChannelLayout, SampleType as CodecSample, decode}; -use photocraft_color::{ColorMode, SampleType}; -use photocraft_doc::{Document, Layer, LayerContent}; -use photocraft_geom::Size; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; +use photocraft_geom::{Rect, Size}; use photocraft_io::{ExportOptions, document_to_image, export}; use photocraft_raster::Surface; @@ -15,6 +15,32 @@ fn document(depth: SampleType, alpha: f32) -> Document { doc } +/// Three bands exercise opaque, translucent and empty pixels in the same composite. +fn mixed_region_document(depth: SampleType, opaque: bool) -> Document { + let mut doc = Document::new("CMYK regions", Size::new(48, 16), ColorMode::Cmyk, depth); + let mut bottom = Surface::new(doc.pixel_format()); + bottom.fill_rect(Rect::new(0, 0, 16, 16), &[0.2, 0.6, 0.1, 0.15, 1.0]); + bottom.fill_rect(Rect::new(16, 0, 32, 16), &[0.1, 0.2, 0.7, 0.25, 0.5]); + if opaque { + bottom.fill_rect(doc.bounds(), &[0.2, 0.6, 0.1, 0.15, 1.0]); + } + doc.layers.push(Layer::new("Ink", LayerContent::Raster(bottom))); + let mut surface = Surface::new(doc.pixel_format()); + surface.fill_rect(Rect::new(0, 0, 16, 16), &[0.7, 0.1, 0.3, 0.2, 1.0]); + let mut top = Layer::new("Masked ink", LayerContent::Raster(surface)); + top.opacity = 0.5; + top.mask = Some(LayerMask { + surface: Surface::with_default(PixelFormat::new(ColorMode::Grayscale, depth, false), &[0.5]), + enabled: true, + linked: true, + density: 1.0, + feather: 0.0, + }); + doc.layers.push(top); + doc.resolution_dpi = 300.0; + doc +} + fn tag(bytes: &[u8], id: u16) -> Option { assert_eq!(&bytes[..2], b"II"); let start = usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).unwrap(); @@ -71,6 +97,69 @@ fn tiff_keeps_true_zero_alpha_when_composited() { } } +#[test] +fn flat_and_layered_tiff_agree_for_mixed_cmyk_regions() { + let custom = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams { + description: "Mixed regions uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }); + let coated = Builtin::CoatedCmyk.profile().to_bytes(); + for (name, profile) in [("untagged", None), ("coated", Some(coated.clone())), ("uncoated", Some(custom.to_bytes()))] { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + for opaque in [false, true] { + let context = format!("{name}, {depth:?}, opaque={opaque}"); + let mut doc = mixed_region_document(depth, opaque); + doc.icc_profile = profile.clone(); + let flat = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + let layered = export(&doc, "tiff", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap(); + let image = decode(&flat.bytes).unwrap(); + let reference = decode(&layered.bytes).unwrap(); + assert!(!flat.warnings.iter().any(|warning| warning.contains("sRGB")), "{context}: {:?}", flat.warnings); + // Flat composites embed the fallback profile; the layered path keeps the source tag. + assert_eq!(image.icc.as_deref(), Some(profile.as_ref().unwrap_or(&coated).as_slice()), "{context}"); + assert_eq!(reference.icc.as_deref(), profile.as_deref().map(Vec::as_slice), "{context}"); + assert!(image.meta.photoshop_layers.is_none(), "{context}"); + assert!(reference.meta.photoshop_layers.is_some(), "{context}"); + for (decoded, bytes) in [(&image, &flat.bytes), (&reference, &layered.bytes)] { + assert_eq!(decoded.dimensions(), (48, 16), "{context}"); + assert_eq!(decoded.layout(), if opaque { ChannelLayout::Cmyk } else { ChannelLayout::CmykA }, "{context}"); + assert_eq!(decoded.sample_type(), sample, "{context}"); + assert_eq!(decoded.meta.dpi, Some((300.0, 300.0)), "{context}"); + assert_eq!(tag(bytes, 262), Some(5), "{context}"); + assert_eq!(tag(bytes, 277), Some(if opaque { 4 } else { 5 }), "{context}"); + assert_eq!(tag(bytes, 338), if opaque { None } else { Some(2) }, "{context}"); + } + // The PSD-backed layered path inverts CMYK samples twice, so allow one stored level. + let tolerance = match depth { + SampleType::U8 => 1.0 / 255.0, + SampleType::U16 => 1.0 / 65535.0, + SampleType::F32 => 1e-6, + }; + let actual = image.to_normalized(); + let expected = reference.to_normalized(); + assert_eq!(actual.len(), expected.len(), "{context}"); + let difference = actual.iter().zip(&expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(difference <= tolerance + 1e-6, "{context}: maximum sample difference {difference}"); + if !opaque { + for (i, px) in actual.as_chunks::<5>().0.iter().enumerate() { + match i % 48 { + 0..16 => assert_eq!(px[4], 1.0, "{context}: opaque region"), + 16..32 => assert!((px[4] - 0.5).abs() <= tolerance, "{context}: translucent region: {px:?}"), + _ => assert_eq!(px[4], 0.0, "{context}: empty region"), + } + } + } + let back = photocraft_io::import("regions.tiff", &flat.bytes).unwrap().document; + assert_eq!((back.mode, back.depth), (ColorMode::Cmyk, depth), "{context}"); + assert_eq!(back.resolution_dpi, 300.0, "{context}"); + } + } + } +} + #[test] fn psd_keeps_cmyk_mode_depth_and_profile_when_composited() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { From 840edc311c8d237b2cfabfb5bc552c3a04a174b6 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:01:52 -0700 Subject: [PATCH 070/171] fix(io): preserve linear grayscale profiles for selected EXR parts (#5867) --- crates/io/src/exr_parts.rs | 10 +-- crates/io/tests/exr_part_profile.rs | 117 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 crates/io/tests/exr_part_profile.rs diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs index 7e25feb984..3e9d2e1154 100644 --- a/crates/io/src/exr_parts.rs +++ b/crates/io/src/exr_parts.rs @@ -181,7 +181,8 @@ fn layer_surface(img: &Image, max: &mut (u32, u32)) -> Result<(Surface, ColorMod } /// Imports the chosen entries as the layers of one document: parts whole, groups from their -/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB. +/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB +/// or linear gray. /// `precomp` adds the additive sum of the file's AOV groups as a top layer and fails, /// naming the missing AOVs, when the light-path set is incomplete. pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result { @@ -270,10 +271,9 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result Open. + +use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform}; +use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CodecSample}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::Document; +use photocraft_io::{ExportOptions, ImportResult, export, exr_parts, import}; + +fn exr(layout: ChannelLayout, depth: CodecSample) -> Vec { + // Exactly representable in half precision, including negative and HDR luminance. + let samples: Vec = [-0.25, 0.25, 0.5, 2.0] + .into_iter() + .zip([1.0, 0.5, 0.25, 0.0]) + .flat_map(|(v, a)| match layout { + ChannelLayout::Gray => vec![v], + ChannelLayout::GrayA => vec![v, a], + ChannelLayout::Rgb => vec![v, 0.125, 0.75], + ChannelLayout::Rgba => vec![v, 0.125, 0.75, a], + _ => panic!("not an EXR layout"), + }) + .collect(); + let image = Image::from_f32(4, 1, layout, &samples).unwrap().convert(layout, depth); + codecs::encode(&image, Format::OpenExr, &Default::default()).unwrap() +} + +fn selected_part(bytes: &[u8]) -> ImportResult { + let entries = exr_parts::entries(bytes).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, exr_parts::EntryKind::Part); + let selection = exr_parts::Selection { parts: vec![entries[0].name.clone()], groups: vec![], view: "both".into(), precomp: false }; + exr_parts::import("selected", bytes, &selection).unwrap() +} + +fn samples(doc: &Document) -> Vec { + assert_eq!(doc.layers.len(), 1); + doc.layers[0].surface().unwrap().read_region(doc.bounds()) +} + +#[test] +fn selected_gray_exr_part_keeps_linear_profile_and_float_samples() { + for depth in [CodecSample::F16, CodecSample::F32] { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA] { + let bytes = exr(layout, depth); + let selected = selected_part(&bytes); + let ordinary = import("ordinary.exr", &bytes).unwrap(); + let doc = &selected.document; + assert_eq!((doc.mode, doc.depth), (ColorMode::Grayscale, SampleType::F32)); + assert_eq!(doc.size, ordinary.document.size); + assert_eq!(selected.warnings, ordinary.warnings); + assert_eq!(selected.warnings.len(), usize::from(depth == CodecSample::F16)); + let expected = codecs::decode(&bytes).unwrap().convert(ChannelLayout::GrayA, CodecSample::F32).to_f32_samples().unwrap(); + assert_eq!(samples(doc), expected, "{layout:?} {depth:?}: samples and alpha stay unchanged"); + assert_eq!(samples(doc), samples(&ordinary.document)); + assert_eq!(doc.icc_profile, Some(photocraft_cms::builtin::linear_gray().to_bytes()), "{layout:?} {depth:?}: linear luminance"); + assert_eq!(doc.icc_profile, ordinary.document.icc_profile); + + // The tagged samples convert to display gray, rather than being interpreted as sGray. + let profile = Profile::parse(doc.icc_profile.as_deref().unwrap()).unwrap(); + assert_eq!(profile.color_space, ColorSpace::Gray); + let transform = Transform::new(&profile, Builtin::SGray.profile(), Intent::RelativeColorimetric, false).unwrap(); + let mut display = samples(doc); + transform.apply(&mut display, 2); + for i in [1, 2] { + let expected_gray = photocraft_color::convert::linear_to_srgb(expected[i * 2]); + assert!((display[i * 2] - expected_gray).abs() < 1e-4, "{layout:?} {depth:?}: display gray"); + assert_eq!(display[i * 2 + 1], expected[i * 2 + 1], "alpha is not gamma encoded"); + } + } + } +} + +#[test] +fn selected_gray_exr_part_exports_like_ordinary_import() { + for depth in [CodecSample::F16, CodecSample::F32] { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA] { + let bytes = exr(layout, depth); + let selected = selected_part(&bytes).document; + let ordinary = import("ordinary.exr", &bytes).unwrap().document; + for ext in ["exr", "tga"] { + let output = export(&selected, ext, &ExportOptions::default()).unwrap(); + let control = export(&ordinary, ext, &ExportOptions::default()).unwrap(); + let output = codecs::decode(&output.bytes).unwrap(); + let control = codecs::decode(&control.bytes).unwrap(); + assert_eq!(output.layout(), control.layout()); + assert_eq!(output.to_normalized(), control.to_normalized(), "{layout:?} {depth:?} {ext}: same colours as File > Open"); + let output = output.convert(ChannelLayout::GrayA, CodecSample::F32).to_f32_samples().unwrap(); + let original = samples(&selected); + if ext == "exr" { + assert_eq!(output, original, "linear EXR samples, including negative/HDR and alpha, survive re-export"); + } else { + for i in [1, 2] { + let expected = photocraft_color::convert::linear_to_srgb(original[i * 2]); + assert!((output[i * 2] - expected).abs() <= 1.0 / 255.0, "TGA uses display-encoded gray"); + assert!((output[i * 2 + 1] - original[i * 2 + 1]).abs() <= 1.0 / 255.0, "TGA preserves alpha"); + } + } + } + } + } +} + +#[test] +fn selected_rgb_exr_part_keeps_linear_srgb() { + for depth in [CodecSample::F16, CodecSample::F32] { + for layout in [ChannelLayout::Rgb, ChannelLayout::Rgba] { + let bytes = exr(layout, depth); + let doc = selected_part(&bytes).document; + let ordinary = import("ordinary.exr", &bytes).unwrap().document; + assert_eq!((doc.mode, doc.depth), (ColorMode::Rgb, SampleType::F32)); + assert_eq!(doc.icc_profile, Some(Builtin::LinearSrgb.profile().to_bytes())); + assert_eq!(doc.icc_profile, ordinary.icc_profile); + let expected = codecs::decode(&bytes).unwrap().convert(ChannelLayout::Rgba, CodecSample::F32).to_f32_samples().unwrap(); + assert_eq!(samples(&doc), expected, "{layout:?} {depth:?}: RGB and alpha stay unchanged"); + assert_eq!(samples(&doc), samples(&ordinary)); + } + } +} From f61ed26e14cf7f39fa3fa9f0cddecf420ea8b0af Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:02:23 -0700 Subject: [PATCH 071/171] fix(io): honor version-aware OpenRaster group isolation (#5872) --- crates/io/src/ora.rs | 37 +++++++++++++++--- crates/io/src/ora/tests.rs | 78 ++++++++++++++++++++++++++++++++++++++ docs/ora.md | 12 +++++- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index 42b2ab7a33..f365154333 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -258,9 +258,23 @@ fn props(node: roxmltree::Node<'_, '_>, layer: &mut Layer, index: usize, warning Ok(()) } +/// OpenRaster 0.0.6 defaults to isolated groups and makes `auto` explicitly pass-through. +/// Older files used inference from the group's blend mode; keep that behavior when the +/// version is absent or unrecognized, too. Compare the numeric core, not version strings. +fn uses_explicit_group_isolation(version: Option<&str>) -> bool { + let Some(core) = version.and_then(|v| v.trim().split(['-', '+']).next()) else { return false }; + let mut parts = core.split('.'); + let number = |part: Option<&str>| part.and_then(|s| s.parse::().ok()); + match (number(parts.next()), number(parts.next()), number(parts.next()), parts.next()) { + (Some(major), Some(minor), Some(patch), None) => [major, minor, patch] >= [0, 0, 6], + _ => false, + } +} + struct Reader<'a, 'c> { zip: ZipReader<'a>, ctl: &'c Interrupt<'c>, + explicit_group_isolation: bool, nodes: usize, budget: usize, warnings: Warnings, @@ -325,10 +339,15 @@ impl Reader<'_, '_> { "stack" => { let mut layer = Layer::group("", Vec::new()); props(child, &mut layer, index, &mut self.warnings)?; - // `auto` (the default) isolates only when something needs it: an - // OpenRaster group without its own blend mode is a pass-through group. - let isolate = child.attribute("isolation").map(str::trim) == Some("isolate"); - if !isolate && layer.blend == BlendMode::Normal { + let isolation = child.attribute("isolation").map(str::trim); + let pass_through = if self.explicit_group_isolation { + // Since 0.0.6 only explicit `auto` passes through, ignoring the + // group's composite-op. An omitted isolation means `isolate`. + isolation == Some("auto") + } else { + isolation != Some("isolate") && layer.blend == BlendMode::Normal + }; + if pass_through { layer.blend = BlendMode::PassThrough; } let children = self.stack(child, depth + 1)?; @@ -415,7 +434,15 @@ pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result"))?; - let mut reader = Reader { zip, ctl, nodes: 0, budget: MAX_PIXEL_BYTES, warnings: Warnings::default(), images: Vec::new() }; + let mut reader = Reader { + zip, + ctl, + explicit_group_isolation: uses_explicit_group_isolation(image.attribute("version")), + nodes: 0, + budget: MAX_PIXEL_BYTES, + warnings: Warnings::default(), + images: Vec::new(), + }; let nodes = reader.stack(root, 1)?; ctl.progress(0.8); let depth = document_depth(&reader.images); diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index 319414a94d..4f0b1c4c47 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -205,6 +205,84 @@ fn gray16_png(w: u32, h: u32) -> Vec { codecs::encode(&Image::from_raw(w, h, ChannelLayout::Gray, CSample::U16, data).unwrap(), Format::Png, &Default::default()).unwrap() } +/// A green Multiply layer over a red backdrop distinguishes isolation (green) from +/// pass-through (black), even though both groups use Normal compositing by default. +fn group_isolation_document(version: Option<&str>, attributes: &str, depth: SampleType) -> Document { + let version = version.map(|v| format!("version=\"{v}\"")).unwrap_or_default(); + let xml = format!( + r#" + + + + + "# + ); + let fmt = PixelFormat::new(ColorMode::Rgb, depth, true); + let png = |rgba| { + let image = Image::from_raw(1, 1, ChannelLayout::Rgba, csample(depth), px(fmt, rgba)).unwrap(); + codecs::encode(&image, Format::Png, &Default::default()).unwrap() + }; + let bytes = archive(&xml, &[("green.png", png([0.0, 1.0, 0.0, 1.0])), ("red.png", png([1.0, 0.0, 0.0, 1.0]))]); + let result = crate::import("isolation.ora", &bytes).unwrap(); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + assert_eq!(result.document.depth, depth); + result.document +} + +#[test] +fn omitted_group_isolation_defaults_to_isolated_since_version_0_0_6() { + for depth in [SampleType::U8, SampleType::U16] { + for version in ["0.0.6", " 0.0.6 ", "0.0.6+app.1", "0.0.6-rc.1", "0.0.10", "0.1.0", "1.0.0"] { + let d = group_isolation_document(Some(version), "", depth); + assert_eq!(d.layers[1].blend, BlendMode::Normal, "{version}, {depth:?}"); + assert_eq!(photocraft_compose::flatten(&d).px, [[0.0, 1.0, 0.0, 1.0]], "{version}, {depth:?}"); + } + } +} + +#[test] +fn explicit_group_isolation_preserves_isolate_and_auto() { + for depth in [SampleType::U8, SampleType::U16] { + for version in ["0.0.3", "0.0.5", "0.0.6"] { + for (attributes, blend, expected) in [ + ("isolation=' isolate '", BlendMode::Normal, [0.0, 1.0, 0.0, 1.0]), + ("isolation=' auto '", BlendMode::PassThrough, [0.0, 0.0, 0.0, 1.0]), + ("isolation='isolate' composite-op='svg:multiply'", BlendMode::Multiply, [0.0, 0.0, 0.0, 1.0]), + ] { + let d = group_isolation_document(Some(version), attributes, depth); + assert_eq!(d.layers[1].blend, blend, "{version}, {attributes}, {depth:?}"); + assert_eq!(photocraft_compose::flatten(&d).px, [expected], "{version}, {attributes}, {depth:?}"); + } + } + } +} + +#[test] +fn auto_group_ignores_its_composite_op_since_version_0_0_6() { + for depth in [SampleType::U8, SampleType::U16] { + let d = group_isolation_document(Some("0.0.6"), "isolation='auto' composite-op='svg:screen'", depth); + assert_eq!(d.layers[1].blend, BlendMode::PassThrough); + assert_eq!(photocraft_compose::flatten(&d).px, [[0.0, 0.0, 0.0, 1.0]], "{depth:?}"); + } +} + +#[test] +fn legacy_group_isolation_keeps_inference_from_blend_mode() { + for depth in [SampleType::U8, SampleType::U16] { + for version in [None, Some("0.0.3"), Some("0.0.5"), Some("unknown"), Some("0.0"), Some("0.0.6.0")] { + for (attributes, blend, expected) in [ + ("", BlendMode::PassThrough, [0.0, 0.0, 0.0, 1.0]), + ("composite-op='svg:screen'", BlendMode::Screen, [1.0, 1.0, 0.0, 1.0]), + ("isolation='auto' composite-op='svg:screen'", BlendMode::Screen, [1.0, 1.0, 0.0, 1.0]), + ] { + let d = group_isolation_document(version, attributes, depth); + assert_eq!(d.layers[1].blend, blend, "{version:?}, {attributes}, {depth:?}"); + assert_eq!(photocraft_compose::flatten(&d).px, [expected], "{version:?}, {attributes}, {depth:?}"); + } + } + } +} + #[test] fn reads_other_applications_stacks() { // The shapes Krita, MyPaint and GIMP write: a root stack with attributes, `auto` diff --git a/docs/ora.md b/docs/ora.md index 19f3c7503e..2bf4f5112d 100644 --- a/docs/ora.md +++ b/docs/ora.md @@ -12,7 +12,7 @@ entry, so an OpenRaster file with another extension still opens as one. |---|---|---| | Pixel layers (any size and position, also off-canvas) | yes | yes, cropped to their pixels | | Groups, nested | yes | yes | -| Pass-through groups | `isolation="auto"` without a blend mode | `isolation="auto"` | +| Pass-through groups | `isolation="auto"` (before 0.0.6, only without a blend mode) | `isolation="auto"` | | Names, visibility, opacity | yes | yes | | Blend modes | the `svg:*` names, `svg:plus`, and Krita's `krita:*` names | `svg:*` where the spec has the mode, else `krita:*` (read back by Krita, Normal elsewhere) | | Locks | `edit-locked`, `alpha-preserve` | the same | @@ -20,6 +20,12 @@ entry, so an OpenRaster file with another extension still opens as one. | Resolution | `xres` | `xres` and `yres` | | Merged image and thumbnail | not read (we render our own) | written (`mergedimage.png`, `Thumbnails/thumbnail.png`) | +Files declaring OpenRaster 0.0.6 or newer default to isolated groups; an explicit +`isolation="auto"` passes through regardless of the group's own composite operation. Older +files and files without a recognized version keep the legacy inference from the group's +blend mode. Pass-through group opacity still uses PhotoCraft's interpolation against the +backdrop, which can differ from 0.0.6's per-child opacity multiplication for overlapping children. + Saving reports what OpenRaster can't hold, layer by layer: pixel masks are applied to the layer's pixels, fill opacity is folded into the pixels, text, shape, fill and smart object layers are written as their rendered pixels, and adjustment layers, layer styles, vector masks, @@ -53,7 +59,9 @@ are covered by tests. Unit tests (`crates/io/src/ora/tests.rs`) round-trip layers, groups, pass-through, offsets, opacity, visibility, locks and every blend mode at 8 and 16 bit, check masks, rendered layer -kinds, the archive layout and malformed input. `crates/automation/tests/openraster.rs` saves, +kinds, the archive layout and malformed input. Synthetic 8/16-bit pixel fixtures check modern +and legacy group-isolation defaults, explicit isolation and pass-through. +`crates/automation/tests/openraster.rs` saves, reopens, edits and saves in place through the agent surface, and checks that a document with an adjustment layer never overwrites its `.ora`; the CLI test converts both ways. From 06ad07ab9b1a90b9ab2ec8e923e74a5b1640b4c1 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:02:54 -0700 Subject: [PATCH 072/171] fix(io): color-manage grayscale WebP exports (#5875) --- crates/io/src/flat.rs | 7 +++-- crates/io/tests/flat.rs | 67 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index f61c28a40b..a18367467a 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -559,9 +559,10 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti } else if let Some(linear) = convert_gray(&img, photocraft_cms::builtin::linear_gray(), CSample::F32)? { img = linear; } - } else if !format.caps().icc { - // Untagged files read back as sRGB/sGray: convert to it (as Quick Export does) rather than - // write values that only mean something under the dropped profile. + } else if !format.caps().icc || (img.layout().is_gray() && !format.caps().layouts.iter().any(|l| l.is_gray())) { + // Untagged files read back as sRGB/sGray. Even an ICC-capable format like WebP loses a + // gray profile when the codec expands gray to RGB. Convert first (as Quick Export does) + // rather than write values that only mean something under the dropped profile. if let Some(srgb) = convert_rgb(&img, Builtin::Srgb.profile(), Intent::Perceptual, true, img.sample_type())? { img = srgb; warnings.push(format!("colours converted to sRGB; {format:?} can't embed the document's colour profile")); diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index b82b41ffc3..8cdecde369 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -610,6 +610,73 @@ fn linear_gray_export_preserves_alpha_and_embeddable_profiles() { } } +/// WebP can embed RGB profiles, but expanding gray to RGB drops a gray profile. Its untagged +/// RGB samples must use the sRGB transfer function, not the source gray encoding. +#[test] +fn linear_gray_webp_exports_use_display_encoding() { + use photocraft_codecs::{ChannelLayout, EncodeOptions}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [1.0, 0.5] { + let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, alpha], &[0.5, alpha]]); + d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes()); + let stored = column_pixels(&d, 2); + for lossless in [true, false] { + let opts = ExportOptions { encode: EncodeOptions { webp_lossless: lossless, webp_quality: 100, ..Default::default() }, ..Default::default() }; + let r = export(&d, "webp", &opts).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + let what = format!("{depth:?}, alpha {alpha}, lossless {lossless}"); + assert_eq!(img.layout(), if alpha == 1.0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba }, "{what}"); + assert_eq!(img.icc, None, "{what}: a gray ICC cannot describe RGB samples"); + let values = img.to_normalized(); + let stride = img.layout().channels(); + let tolerance = if lossless { 1.5 / 255.0 } else { 3.0 / 255.0 }; + for (i, src) in stored.iter().enumerate() { + let offset = (8 * img.width() as usize + 16 * i + 8) * stride; + let expected = photocraft_color::convert::linear_to_srgb(src[0]); + for &value in &values[offset..offset + 3] { + assert!((value - expected).abs() <= tolerance, "{what}, pixel {i}: got {value}, expected {expected}"); + } + if img.layout().has_alpha() { + assert!((values[offset + 3] - src[1]).abs() <= 1.0 / 255.0, "{what}: alpha changed"); + } + } + assert!(r.warnings.iter().any(|w| w.contains("converted to sGray")), "{what}: {:?}", r.warnings); + } + } + } +} + +#[test] +fn default_gray_webp_exports_keep_samples() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for icc in [None, Some(photocraft_cms::Builtin::SGray.profile().to_bytes())] { + let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 0.5], &[0.5, 1.0]]); + d.icc_profile = icc; + let stored = column_pixels(&d, 2); + let r = export(&d, "webp", &ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(img.icc, None, "{depth:?}: a gray ICC cannot describe RGB samples"); + let back = import("gray.webp", &r.bytes).unwrap().document; + let want: Vec> = stored.iter().map(|p| vec![p[0], p[0], p[0], p[1]]).collect(); + assert_colors(&column_pixels(&back, 2), &want, 1.0 / 255.0, &format!("{depth:?}: sGray samples")); + assert!(!r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?}: {:?}", r.warnings); + } + } +} + +#[test] +fn rgb_webp_exports_keep_embeddable_profiles() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut d = columns(ColorMode::Rgb, depth, &[&[0.25, 0.5, 0.75, 0.5]]); + d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()); + let r = export(&d, "webp", &ExportOptions::default()).unwrap(); + let back = import("rgb.webp", &r.bytes).unwrap().document; + assert_eq!(back.icc_profile, d.icc_profile, "{depth:?}: WebP keeps RGB ICC"); + assert_colors(&column_pixels(&back, 1), &column_pixels(&d, 1), 1.0 / 255.0, &format!("{depth:?}: RGB samples")); + assert!(!r.warnings.iter().any(|w| w.contains("converted to sRGB")), "{depth:?}: {:?}", r.warnings); + } +} + /// #518: a JPEG cut off inside its image data opens with a warning, never silently. #[test] fn truncated_jpeg_imports_with_a_warning() { From db46713c2ca0140e119956ed695cf92837fc50e3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:03:15 +0200 Subject: [PATCH 073/171] Unsaved-changes prompt: the default answer takes keyboard focus, so arrows and Enter work (part of #6441) (#6466) --- crates/ui-egui/src/discard_ui.rs | 74 ++++++++++++++++++++++++-------- crates/ui-egui/src/widgets.rs | 13 +++++- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 30e5f47deb..6720015458 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -21,6 +21,8 @@ pub struct Prompt { /// The document the command was aimed at (`document` param, else the active one), if it has one. target: Option, docs: Vec, + /// The document whose question has already put keyboard focus on its default answer. + focused: Option, } fn index_of(app: &PhotocraftApp, id: DocId) -> Option { @@ -69,7 +71,7 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool { if docs.is_empty() { return false; } - let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs }; + let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs, focused: None }; match &app.discard { None => ask(app, prompt), // Quitting overrides whatever is pending: it covers every document, so nothing is lost. @@ -226,6 +228,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Save can ask for a path and then TIFF or Targa Options, and then write in the background. // Wait for all three; cancellation or a failed save brings us back. if app.file_dialog_open() || app.save_options_open() || app.saving() { + // Hidden meanwhile: the question takes focus again when it comes back. + if let Some(p) = app.discard.as_mut() { + p.focused = None; + } return; } let Some(p) = &app.discard else { return }; @@ -246,6 +252,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }; (tl!("Unsaved changes"), crate::i18n::fmt(template, &[("name", &name)])) }; + // Each question comes up with its default answer focused, as in Photoshop: the arrow keys then + // move between the answers and Enter presses the focused one (#6441). + let focus = p.focused != Some(doc); + if let Some(p) = app.discard.as_mut() { + p.focused = Some(doc); + } let mac = ctx.os() == egui::os::OperatingSystem::Mac; // The answers in the platform's words; `dialog_buttons` puts them in its order. Windows (and // Linux) ask Yes / No / Cancel with Y, N and Esc, as Photoshop does there; macOS asks @@ -266,7 +278,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }; let mut answer = ctx.input_mut(|i| buttons.iter().find(|b| b.2.is_some_and(|k| i.consume_key(egui::Modifiers::NONE, k))).map(|b| b.4)); let labels = button_labels(mac, &buttons.iter().map(|b| (b.1, b.2)).collect::>()); - let row: Vec = buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3)).collect(); + let row: Vec = + buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3).focus(focus && b.0 == ButtonRole::Default)).collect(); let modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| { ui.set_max_width(420.0); ui.label(egui::RichText::new(tl!(&title)).font(crate::theme::semibold(15.0))); @@ -582,31 +595,60 @@ mod tests { v.into_iter().map(|(_, l)| l).collect() } - /// Tab walks `labels` left to right and wraps; Shift+Tab steps back. - fn tab_walks(h: &mut Prompted, labels: [&str; 3]) { - use egui_kittest::kittest::Queryable; - let focused = |h: &Prompted| labels.into_iter().find(|l| h.get_by_label(l).is_focused()); - for want in [labels[0], labels[1], labels[2], labels[0]] { + /// Tab walks `labels` left to right from the focused default answer, `labels[default]`, and + /// wraps; Shift+Tab steps back. + fn tab_walks(h: &mut Prompted, labels: [&str; 3], default: usize) { + assert_eq!(focused(h, &labels), Some(labels[default])); + for step in 1..=3 { h.key_press(Key::Tab); h.run_steps(2); - assert_eq!(focused(h), Some(want)); + assert_eq!(focused(h, &labels), Some(labels[(default + step) % 3])); } h.key_press_modifiers(egui::Modifiers::SHIFT, Key::Tab); h.run_steps(2); - assert_eq!(focused(h), Some(labels[2])); + assert_eq!(focused(h, &labels), Some(labels[(default + 2) % 3])); } fn docs_left(h: &Prompted) -> Option { h.state().discard.as_ref().map(|p| p.docs.len()) } + /// Which of `labels` has keyboard focus. + fn focused<'a>(h: &Prompted, labels: &[&'a str]) -> Option<&'a str> { + use egui_kittest::kittest::Queryable; + labels.iter().copied().find(|l| h.get_by_label(l).is_focused()) + } + + /// The default answer has keyboard focus as each question comes up, so the arrow keys move + /// between the answers and Enter presses the one they reached, as in Photoshop (#6441). + #[test] + fn each_question_comes_up_with_the_default_answer_focused() { + let mut h = prompt_on(egui::os::OperatingSystem::Windows); + let labels = ["(Y)es", "(N)o", "(C)ancel"]; + assert_eq!(focused(&h, &labels), Some("(Y)es")); + h.key_press(Key::ArrowRight); + h.run_steps(2); + assert_eq!(focused(&h, &labels), Some("(N)o")); + h.key_press(Key::Enter); + h.run_steps(2); + assert_eq!(docs_left(&h), Some(1), "Enter pressed No"); + assert_eq!(focused(&h, &labels), Some("(Y)es"), "the next question starts on the default again"); + + let mut h = prompt_on(egui::os::OperatingSystem::Mac); + let labels = ["Don't Save", "Cancel", "Save"]; + assert_eq!(focused(&h, &labels), Some("Save")); + h.key_press(Key::ArrowLeft); + h.run_steps(2); + assert_eq!(focused(&h, &labels), Some("Cancel")); + } + #[test] fn windows_and_linux_ask_yes_no_cancel_with_the_default_first() { for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { let mut h = prompt_on(os); let labels = ["(Y)es", "(N)o", "(C)ancel"]; assert_eq!(drawn_order(&h, labels), labels, "{os:?}"); - tab_walks(&mut h, labels); + tab_walks(&mut h, labels, 0); h.key_press(Key::N); h.run_steps(2); assert_eq!(docs_left(&h), Some(1), "N answered the first document"); @@ -699,7 +741,7 @@ mod tests { let mut h = prompt_on(egui::os::OperatingSystem::Mac); let labels = ["Don't Save", "Cancel", "Save"]; assert_eq!(drawn_order(&h, labels), labels); - tab_walks(&mut h, labels); + tab_walks(&mut h, labels, 2); h.key_press(Key::D); h.run_steps(2); assert_eq!(docs_left(&h), Some(1), "D answered the first document"); @@ -736,11 +778,9 @@ mod tests { h.run_steps(2); assert_eq!(asked.borrow().len(), 1, "Enter answered Yes, which saves"); assert_eq!(docs_left(&h), Some(2)); - // Tab to No: Enter now presses it. - for _ in 0..2 { - h.key_press(Key::Tab); - h.run_steps(2); - } + // Tab from the focused Yes to No: Enter now presses it. + h.key_press(Key::Tab); + h.run_steps(2); h.key_press(Key::Enter); h.run_steps(2); assert_eq!(asked.borrow().len(), 1, "no second dialog"); @@ -776,7 +816,7 @@ mod tests { assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the dialog"); answer(&mut h, None); assert_eq!(docs_left(&h), Some(1)); - assert!(h.query_by_label("(Y)es").is_some()); + assert_eq!(focused(&h, &["(Y)es", "(N)o", "(C)ancel"]), Some("(Y)es"), "the prompt comes back with Yes focused"); // Saving closes the document. h.key_press(Key::Y); h.run_steps(3); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 7235790cbe..48d7cc5ad0 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1193,16 +1193,24 @@ pub struct DialogButton<'a> { pub label: &'a str, pub min_width: f32, pub enabled: bool, + /// Take keyboard focus this frame (see [`DialogButton::focus`]). + pub focus: bool, } impl<'a> DialogButton<'a> { pub fn new(role: ButtonRole, label: &'a str, min_width: f32) -> Self { - Self { role, label, min_width, enabled: true } + Self { role, label, min_width, enabled: true, focus: false } } pub fn enabled(self, enabled: bool) -> Self { Self { enabled, ..self } } + + /// Asks for keyboard focus for this button: from there the arrow keys move between the row's + /// buttons and Enter or Space presses the focused one. + pub fn focus(self, focus: bool) -> Self { + Self { focus, ..self } + } } /// A dialog's button row at the cursor, in the platform's order (see [`ButtonRole`]): every modal @@ -1225,6 +1233,9 @@ pub fn dialog_buttons(ui: &mut Ui, buttons: &[DialogButton]) -> Option Date: Sat, 17 Oct 2026 07:13:51 +0700 Subject: [PATCH 074/171] =?UTF-8?q?fix(ui):=20Layer=20=E2=80=BA=20New=20Fi?= =?UTF-8?q?ll=20Layer=20=E2=80=BA=20Solid=20Color=20opens=20the=20Color=20?= =?UTF-8?q?Picker,=20creating=20the=20layer=20on=20OK=20(#5144)=20(#5166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a Solid Color fill from the Layer menu or the Layers panel footer now opens the shared Color Picker seeded with the foreground colour. The layer is created on OK in one history step; Cancel leaves the document and history untouched. Parameterised calls (CLI, MCP, actions) still create the layer directly. Fixes #5144. --- crates/ui-egui/src/color_picker_ui.rs | 3 + crates/ui-egui/src/menus.rs | 7 +++ crates/ui-egui/src/panels.rs | 8 ++- crates/ui-egui/src/solid_fill_ui.rs | 30 +++++++++ crates/ui-egui/src/solid_fill_ui/tests.rs | 76 +++++++++++++++++++++++ 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 09a3ce8d4c..05c4dd5c17 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -783,6 +783,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let dialog = crate::solid_fill_ui::open_new(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } // Solid Color fills and solid-colour shapes use the Color Picker, Pattern fills the Pattern // Fill dialog; other adjustment / fill / shape controls use Properties. "layer.layerContentOptions" => { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7f2eadd084..177ad521ba 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2607,10 +2607,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.dock.collapsed.retain(|g| *g != crate::dock::Group::Properties); continue; } - if p.is_null() { - // Context-menu items behave like their menu-bar twins (dialogs included). + if p.is_null() || id == "layer.newFillLayer.solidColor" { + // Context-menu items and the Layers footer's Solid Color… action behave like + // their menu-bar twins. In particular, a new Solid Color fill opens its picker. let ctx = ui.ctx().clone(); - if let Err(e) = crate::menus::invoke(app, &ctx, &id, json!({})) { + let params = if p.is_null() { json!({}) } else { p }; + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; app.ui.status_error = true; } diff --git a/crates/ui-egui/src/solid_fill_ui.rs b/crates/ui-egui/src/solid_fill_ui.rs index 2ae44d50c8..5d9112abab 100644 --- a/crates/ui-egui/src/solid_fill_ui.rs +++ b/crates/ui-egui/src/solid_fill_ui.rs @@ -30,6 +30,36 @@ pub(crate) fn open(app: &mut PhotocraftApp) -> Option { Some(color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params)) } +/// Creating a Solid Color fill is transactional: the document is unchanged until OK. +/// The picker is seeded with the foreground colour, matching the engine's default. +pub(crate) fn open_new(app: &mut PhotocraftApp) -> Option { + let document = app.session.active()?.doc.id.0; + let foreground = app.session.tools.foreground; + Some(color_picker_ui::open_for_command( + app, + "Color Picker (Solid Color)", + [foreground[0], foreground[1], foreground[2]], + "layer.newFillLayer.solidColor", + json!({"document": document}), + )) +} + +/// A creation picker is distinct from the editor opened by a fill layer's thumbnail. +pub(crate) fn owns_new(f: &Map) -> bool { + f.get("__command").and_then(Value::as_str) == Some("layer.newFillLayer.solidColor") + && f.get("__params").and_then(|p| p.get("document")).and_then(Value::as_u64).is_some() +} + +pub(crate) fn confirm_new(app: &mut PhotocraftApp, f: &Map) -> Result { + let expected = f.get("__params").and_then(|p| p.get("document")).and_then(Value::as_u64).ok_or("missing Solid Color document")?; + let st = app.session.active().ok_or("no document open")?; + if st.doc.id.0 != expected { + return Err("the target document is no longer active".into()); + } + let color = f.get("color").and_then(Value::as_str).ok_or("missing Solid Color colour")?; + app.run("layer.newFillLayer.solidColor", json!({"color": color})) +} + pub(crate) fn owns(f: &Map) -> bool { f.get("__command").and_then(Value::as_str) == Some(cmds::SET) } diff --git a/crates/ui-egui/src/solid_fill_ui/tests.rs b/crates/ui-egui/src/solid_fill_ui/tests.rs index 9b919be934..3317875153 100644 --- a/crates/ui-egui/src/solid_fill_ui/tests.rs +++ b/crates/ui-egui/src/solid_fill_ui/tests.rs @@ -41,6 +41,82 @@ fn row(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> crate::layer_row_ui::RowR crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == id.0).unwrap() } +#[test] +fn new_solid_color_from_menu_cancel_keeps_document_and_history_unchanged() { + let (mut h, _) = harness(1.0); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let before = app.session.active().unwrap().doc.clone(); + let active = app.session.active().unwrap().active_layer; + let steps = app.session.active().unwrap().history.entries().len(); + + let opened = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({})).unwrap(); + assert!(opened.get("layer").is_none(), "no layer exists until OK"); + let dialog = opened["dialog"].as_u64().unwrap(); + let picker = app.ui.dialogs.last().unwrap(); + assert_eq!(picker.id, dialog); + assert!(owns_new(&picker.fields), "new Solid Color fill opens the shared Color Picker"); + assert_eq!(picker.fields["__params"]["document"], before.id.0); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps); + + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + crate::dialogs::cancel(app, dialog).unwrap(); + assert!(app.ui.dialogs.iter().all(|d| d.id != dialog)); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc), "Cancel must not create or delete a layer"); + assert_eq!(app.session.active().unwrap().active_layer, active); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps, "Cancel must not record a history entry"); +} + +#[test] +fn new_solid_color_from_menu_ok_creates_one_layer_with_chosen_or_default_color() { + let (mut h, _) = harness(1.0); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let steps = app.session.active().unwrap().history.entries().len(); + + // OK with the initial foreground colour still creates the fill layer. + let first = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({})).unwrap(); + let created = crate::dialogs::confirm(app, first["dialog"].as_u64().unwrap()).unwrap(); + let id = LayerId(created["layer"].as_u64().unwrap()); + assert!(matches!(app.session.active().unwrap().doc.layer(id).unwrap().content, LayerContent::Fill(Fill::Solid(_)))); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1); + + let second = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({})).unwrap(); + let dialog = second["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + let created = crate::dialogs::confirm(app, dialog).unwrap(); + let id = LayerId(created["layer"].as_u64().unwrap()); + let LayerContent::Fill(Fill::Solid(color)) = app.session.active().unwrap().doc.layer(id).unwrap().content else { + panic!("expected the newly created Solid Color fill"); + }; + assert_eq!(color.to_rgba8()[..3], [255, 136, 0]); + assert_eq!(app.session.active().unwrap().active_layer, Some(id)); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 2); + + let dialogs = app.ui.dialogs.len(); + let explicit = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({"color": "#123456"})).unwrap(); + assert!(explicit["layer"].as_u64().is_some()); + assert!(explicit.get("dialog").is_none()); + assert_eq!(app.ui.dialogs.len(), dialogs, "parameterised creation stays non-interactive"); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 3); +} + +#[test] +fn new_solid_color_picker_rejects_a_different_active_document() { + let (mut h, _) = harness(1.0); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let dialog = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.entries().len(); + assert!(crate::dialogs::confirm(app, dialog).is_err()); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps); +} + #[test] fn only_the_colour_thumbnail_opens_the_solid_fill_picker() { for ppp in [1.0, 2.0] { From c7d2c85faf6c3ff7b6c006d2ba5141e50267481b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 17 Oct 2026 11:19:03 +1100 Subject: [PATCH 075/171] fix(ui): Color Picker's Only Web Colors snaps every edit and is remembered across pickers (#4995) (#5442) With Only Web Colors on, every picker edit (field, sliders, hex, Lab/CMYK, screen sampler, Current) snaps through the clamped 216-colour ramp. The checkbox is remembered on the Color panel state so the foreground, field, command, Layer Style and type-style pickers open with the same setting. Opening with it on snaps the colour and its original together, so OK without an edit leaves a fill layer unchanged. The hex field follows the colour when another path changes it while focused. Fixes #4995. --- crates/ui-egui/src/color_picker_ui.rs | 90 ++++++++++++++----- crates/ui-egui/src/dialogs.rs | 3 + .../ui-egui/src/layer_style/color_picker.rs | 2 +- crates/ui-egui/src/state.rs | 3 + .../src/type_panels_ui/color_picker.rs | 2 +- 5 files changed, 75 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 05c4dd5c17..19272a62ed 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -171,7 +171,7 @@ pub fn open(app: &mut PhotocraftApp, target: &str) -> u64 { let c = if target == "background" { app.session.tools.background } else { app.session.tools.foreground }; let rgb = [c[0], c[1], c[2]]; let label = if target == "background" { "Color Picker (Background Color)" } else { "Color Picker (Foreground Color)" }; - let mut fields = initial_fields(target, label, rgb); + let mut fields = fields_with_web(app, target, label, rgb); // A colour that was picked as CMYK shows the inks it was picked as, not what its sRGB stand-in converts to (#4196). if let Some(cmyk) = app.session.tools.picked_cmyk(rgb) { fields.insert("__cmyk".into(), json!(cmyk)); @@ -192,10 +192,23 @@ pub(crate) fn initial_fields(target: &str, label: &str, rgb: [f32; 3]) -> Map Map { + let mut f = initial_fields(target, label, rgb); + f.insert("__webOnly".into(), json!(app.ui.color_panel.web_only)); + if app.ui.color_panel.web_only { + set_rgb(&mut f, rgb, Keep::Nothing); + f.insert("__orig".into(), f["color"].clone()); + } + f +} + /// Open the picker on `rgb` for something other than the tool colours: OK runs `command` with /// `params` plus `"color": "#rrggbb"` (e.g. a gradient stop's colour). pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], command: &str, params: Value) -> u64 { - let mut f = initial_fields("command", label, rgb); + let mut f = fields_with_web(app, "command", label, rgb); f.insert("__command".into(), json!(command)); f.insert("__params".into(), params); app.ui.open_dialog(DialogKind::Command, f) @@ -204,33 +217,16 @@ pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], com /// Open the picker on `rgb` for a colour field of another dialog (e.g. Edit › Fill's Color…): OK /// writes `"#rrggbb"` into `field` of dialog `dialog`, which stays open; Cancel leaves it alone. pub fn open_for_field(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], dialog: u64, field: &str) -> u64 { - let hsv = rgb_to_hsv(rgb); - let mut f = Map::new(); - f.insert("__colorPicker".into(), json!("field")); - f.insert("__label".into(), json!(label)); + let mut f = fields_with_web(app, "field", label, rgb); f.insert("__dialog".into(), json!(dialog)); f.insert("__field".into(), json!(field)); - f.insert("color".into(), json!(hex(rgb))); - f.insert("__orig".into(), json!(hex(rgb))); - f.insert("__hsv".into(), json!(hsv)); - f.insert("__mode".into(), json!("h")); - f.insert("__webOnly".into(), json!(false)); app.ui.open_dialog(DialogKind::Command, f) } /// Open the picker on `rgb` for a colour the shell keeps itself (e.g. the Crop shield's custom /// colour): OK hands `"#rrggbb"` to `target`'s setter in [`confirm`]; Cancel leaves it alone. pub fn open_for_target(app: &mut PhotocraftApp, target: &str, label: &str, rgb: [f32; 3]) -> u64 { - let hsv = rgb_to_hsv(rgb); - let mut f = Map::new(); - f.insert("__colorPicker".into(), json!(target)); - f.insert("__label".into(), json!(label)); - f.insert("color".into(), json!(hex(rgb))); - f.insert("__orig".into(), json!(hex(rgb))); - f.insert("__hsv".into(), json!(hsv)); - f.insert("__mode".into(), json!("h")); - f.insert("__webOnly".into(), json!(false)); - app.ui.open_dialog(DialogKind::Command, f) + app.ui.open_dialog(DialogKind::Command, fields_with_web(app, target, label, rgb)) } /// Set by a dialog body (to the picker's title) to open the Color Picker on its `color` field. @@ -367,7 +363,7 @@ pub fn current(f: &Map) -> Components { fn set_rgb(f: &mut Map, rgb: [f32; 3], keep: Keep) { let web = f.get("__webOnly").and_then(Value::as_bool).unwrap_or(false); - let (rgb, keep) = if web { (rgb.map(|v| (v * 5.0).round() / 5.0), Keep::Nothing) } else { (rgb, keep) }; + let (rgb, keep) = if web { (web_safe(rgb), Keep::Nothing) } else { (rgb, keep) }; f.insert("color".into(), json!(hex(rgb))); for key in ["__hsv", "__lab", "__cmyk"] { f.remove(key); @@ -688,9 +684,14 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo let focused = ui.memory(|m| m.has_focus(id)); let pasted = if focused { take_hex_paste(ui) } else { None }; let typing = if focused { ui.data(|d| d.get_temp::(id)) } else { None }; + // Drop stale typing when another path (web snap, field drag, Current) changed `color`. + let shown = hex(rgb); let mut h = match pasted { Some(c) => hex(c).trim_start_matches('#').to_ascii_uppercase(), - None => typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()), + None => match typing { + Some(t) if parse_hex(&t).is_none() || parse_hex(&t).map(hex).as_deref() == Some(shown.as_str()) => t, + _ => shown.trim_start_matches('#').to_ascii_uppercase(), + }, }; let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6)); if let Some(c) = pasted { @@ -1044,6 +1045,7 @@ mod tests { h.get_by_label("Only Web Colors").click(); h.run_steps(2); assert_eq!(color(&h), "#003366"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("003366"), "hex field follows the snap while it has focus"); h.get_by_label("Only Web Colors").click(); h.run_steps(2); h.get_by_label("Click to restore the current colour").click(); @@ -1051,6 +1053,48 @@ mod tests { assert_eq!(color(&h), "#123456"); } + #[test] + fn web_only_snaps_hex_field_and_component_edits() { + let mut f = Map::new(); + f.insert("color".into(), json!("#123456")); + 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])); + 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])); + assert_eq!(not_web_safe(parse_hex(f["color"].as_str().unwrap()).unwrap()), None); + assert!(f.get("__lab").is_none()); + } + + #[test] + fn web_only_survives_reopen_and_other_picker_targets() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.color_panel.web_only = true; + let id = open(&mut app, "foreground"); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["__webOnly"], json!(true)); + assert_eq!(f["color"], json!(hex(web_safe([app.session.tools.foreground[0], app.session.tools.foreground[1], app.session.tools.foreground[2]])))); + assert_eq!(f["__orig"], f["color"], "the opening snap moves __orig too, so OK with no edit is unchanged"); + app.ui.dialogs.clear(); + let id = open_for_target(&mut app, "noteColor", "Color Picker", [1.0, 0.53, 0.0]); + let f = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert_eq!(f["__webOnly"], json!(true)); + assert_eq!(f["color"], json!("#ff9900")); + assert_eq!(f["__orig"], json!("#ff9900"), "a non-web-safe start colour is not committed by opening the picker"); + } + + #[test] + fn sample_at_snaps_when_only_web_colors_is_on() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let id = open(&mut app, "foreground"); + let f = &mut app.ui.dialog_mut(id).unwrap().fields; + f.insert("__webOnly".into(), json!(true)); + set_rgb(f, [0.07, 0.2, 0.34], Keep::Nothing); + assert_eq!(f["color"], json!("#003366")); + } + #[test] fn side_buttons_and_keyboard_confirm_or_cancel_without_changing_the_other_color() { for target in ["foreground", "background"] { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 013c79e825..182b4be8c7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -334,6 +334,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { 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); + if let Some(web) = fields.get("__webOnly").and_then(Value::as_bool) { + app.ui.color_panel.web_only = web; + } } DialogKind::Command if crate::color_range_ui::owns(&fields) => crate::color_range_ui::body(app, ui, &mut fields), DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields), diff --git a/crates/ui-egui/src/layer_style/color_picker.rs b/crates/ui-egui/src/layer_style/color_picker.rs index b0edb16379..341e659c9e 100644 --- a/crates/ui-egui/src/layer_style/color_picker.rs +++ b/crates/ui-egui/src/layer_style/color_picker.rs @@ -92,7 +92,7 @@ pub(crate) fn open(app: &mut PhotocraftApp, parent: u64, effect: &str, field: &s let t = Target { parent, document: st.doc.id, layer, effect: effect.into(), field: field.into(), color: color.clone(), source }; validate(app, &t)?; let rgb = color_picker_ui::parse_hex(&color).ok_or("invalid effect color")?; - let mut fields = color_picker_ui::initial_fields("dialog", "Color Picker (Layer Style Color)", rgb); + let mut fields = color_picker_ui::fields_with_web(app, "dialog", "Color Picker (Layer Style Color)", rgb); fields.insert(TARGET.into(), serde_json::to_value(t).map_err(|e| e.to_string())?); Ok(app.ui.open_dialog(DialogKind::Command, fields)) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..373b6a4794 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1054,6 +1054,9 @@ pub struct DockTabs { pub struct ColorPanelState { /// The panel edits the background colour (its chip was clicked), not the foreground. pub background: bool, + /// Color Picker › Only Web Colors, kept across opens like Photoshop (#4995). + #[serde(default)] + pub web_only: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/type_panels_ui/color_picker.rs b/crates/ui-egui/src/type_panels_ui/color_picker.rs index 3c68f72e4c..7df76c0cd5 100644 --- a/crates/ui-egui/src/type_panels_ui/color_picker.rs +++ b/crates/ui-egui/src/type_panels_ui/color_picker.rs @@ -82,7 +82,7 @@ pub(crate) fn open(app: &mut PhotocraftApp, kind: Kind, id: u32) -> Result Date: Sat, 17 Oct 2026 05:50:53 +0530 Subject: [PATCH 076/171] fix(ui): Type tool caret stays solid after typing, navigation or a click, then resumes blinking (#5511) (#5512) The caret's blink phase was tied to the wall clock, so an arrow key or keystroke that landed in the off half-cycle moved an invisible caret. A transient caret_blink_time on UiState is reset on every handled key event, on a click that places the caret, and while dragging; draw_overlay computes the phase from it so the caret is solid for 530 ms after each interaction, and the repaint is scheduled to the next phase boundary. Fixes #5511. --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/state.rs | 4 +++ crates/ui-egui/src/type_tool.rs | 62 +++++++++++++++++++++++++++++++-- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index acbf95063f..ad2ddafa55 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5498,6 +5498,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { e.dragging = false; e.resize = None; + app.ui.caret_blink_time = app.last_frame_time; } if tool == Tool::Pen { crate::vector_ui::pen_up(app); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e82be7107..e68a6d555b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -657,7 +657,7 @@ pub struct PhotocraftApp { /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, pub fps: f32, - last_frame_time: f64, + pub(crate) last_frame_time: f64, thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>, /// The document area each layer thumbnail in `thumbs` frames (the canvas, or the layer's /// bounds with Clip Thumbnails to Layer Bounds; #4281). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 373b6a4794..d464cb3cc8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1076,6 +1076,9 @@ pub struct UiState { /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, + /// Clock time in seconds when type cursor blink was last reset (e.g. on keypress/caret move). + #[serde(skip)] + pub caret_blink_time: f64, /// Tool to restore when an inline type edit session commits or cancels (e.g. Move tool, #1132). #[serde(skip)] pub text_edit_return_tool: Option, @@ -1310,6 +1313,7 @@ impl Default for UiState { spring: None, recent_files: Vec::new(), text_edit: None, + caret_blink_time: 0.0, text_edit_return_tool: None, type_transform: None, type_transform_pivot: None, diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..a8125201d2 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -301,6 +301,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { if let Some(e) = app.ui.text_edit.as_mut() { e.dragging = false; e.resize = None; + app.ui.caret_blink_time = app.last_frame_time; return; } let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); @@ -340,6 +341,7 @@ 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.caret_blink_time = app.last_frame_time; 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_return_tool = None; } @@ -789,7 +791,11 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { e.caret = e.caret.min(n); e.anchor = e.anchor.min(n); } - ctx.request_repaint_after(std::time::Duration::from_millis(530)); // caret blink + let time = ctx.input(|i| i.time); + let elapsed = (time - app.ui.caret_blink_time).max(0.0); + let period = 0.530; + let remaining = period - (elapsed % period); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(remaining.clamp(0.010, period))); let events = ctx.input(|i| i.events.clone()); let vertical_flow = is_vertical(app, id); let mut handled = vec![false; events.len()]; @@ -942,6 +948,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } } if handled.iter().any(|h| *h) { + app.ui.caret_blink_time = ctx.input(|i| i.time); let mut k = 0; ctx.input_mut(|i| { i.events.retain(|_| { @@ -1000,6 +1007,9 @@ pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { /// 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 }; + if ed.dragging { + app.ui.caret_blink_time = painter.ctx().input(|i| i.time); + } let id = LayerId(ed.layer); let Some((l, aff, text)) = layout(app, id) else { return }; if ed.mask { @@ -1083,9 +1093,10 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX } } } else { - // Blinking caret (Photoshop's ~0.53 s rhythm), solid while dragging. + // Blinking caret (Photoshop's ~0.53 s rhythm), solid while dragging or immediately after interaction. let time = painter.ctx().input(|i| i.time); - if ed.dragging || (time * 1000.0 / 530.0) as i64 % 2 == 0 { + let elapsed = (time - app.ui.caret_blink_time).max(0.0); + if ed.dragging || (elapsed * 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 }; @@ -2700,6 +2711,51 @@ mod tests { assert_eq!(word_boundary("hello big world", 7, false), 6); assert_eq!(word_boundary("hello big world", 15, false), 10); } + #[test] + fn arrow_keys_reset_caret_blink_phase() { + let mut app = app(); + pointer_up(&mut app, [10.0, 50.0], [10.0, 50.0]); + insert(&mut app, "Hello"); + app.ui.caret_blink_time = 0.0; + let ctx = egui::Context::default(); + + let has_caret = |app: &mut PhotocraftApp, ctx: &egui::Context, time: f64| -> bool { + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + let xf = ViewXform { rect, zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + 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)) + }; + + // Before resetting, the caret is in the off phase at t = 10.5 s (10500 / 530 = 19, odd phase) + assert!(!has_caret(&mut app, &ctx, 10.5), "caret should be off at t = 10.5 with phase 0.0"); + + ctx.begin_pass(egui::RawInput { + time: Some(10.5), + events: vec![egui::Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }); + handle_keys(&mut app, &ctx); + assert_eq!(app.ui.caret_blink_time, 10.5); + + // draw_overlay paints the solid caret in the new phase + assert!(has_caret(&mut app, &ctx, 10.5), "caret should be painted right after arrow key"); + } + + #[test] + fn pointer_up_click_resets_caret_blink_phase() { + let mut app = app(); + pointer_up(&mut app, [10.0, 50.0], [10.0, 50.0]); + insert(&mut app, "Hello"); + app.ui.caret_blink_time = 0.0; + app.last_frame_time = 12.3; + pointer_up(&mut app, [10.0, 50.0], [10.0, 50.0]); + assert_eq!(app.ui.caret_blink_time, 12.3); + } + #[test] fn double_click_from_move_tool_restores_move_tool_on_commit_and_cancel() { let (mut app, _) = variants(); From dd69f0797dd2c0dc82a206f5e7eddd21b56ff048 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:23:08 +0400 Subject: [PATCH 077/171] fix(engine): reserve the define-brush tip and selection-mask buffers fallibly (#212) (#5469) brush.defineFromSelection allocated the sampled tip and the selection mask infallibly over the whole selection, so a large selection could abort the process. Both now go through the engine's fallible allocator and return a normal error that leaves the brush, presets, history and document untouched. A failpoint test asserts all four. Part of #212. --- crates/engine/src/brush_cmds.rs | 8 ++++++-- crates/engine/src/brush_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index b76f8c836d..f2c9e28b2c 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -863,9 +863,13 @@ 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); - let mut v = vec![0.0f32; w * h]; + // 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")?; let sc = sel.channels(); - let sv = sel.read_region(area); + // 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. + let sv = crate::allocation::read_region(sel, area, "defining a brush from the selection")?; for y in 0..h { for x in 0..w { let c = surf.rgba(area.x0 + x as i32, area.y0 + y as i32); diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index eaf69281bc..5977603f94 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -442,6 +442,26 @@ fn define_brush_from_selection() { assert!(rgba(&s, 30, 32)[3] > 0.9); } +#[test] +fn define_brush_from_selection_survives_allocation_failure() { + // The sampled tip is one f32 buffer over the whole selection, and the selection mask is a + // second of the same size. Both are reserved through the fallible allocator, so an allocation + // failure must be a graceful error (brush, presets, history and document untouched), never a + // panic or an OOM abort. + let mut s = session(40, 40); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap(); + 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_next_for_test(); + 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_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"); +} + #[test] fn works_on_cmyk_and_16_bit() { for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] { From 8a4ed48354fb893034938e299b9ec61db20d09f9 Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:25:50 -0400 Subject: [PATCH 078/171] fix(ui): numeric fields drop oversized text, paste and IME events before layout (part of #2150) (#2258) A single oversized input event reached egui text layout in a numeric field even though it could never become a valid value. number_edit now drops Text, Paste and IME events longer than 128 scalar values while the field has focus, and the custom parser rejects accumulated text over the cap before normalizing. DragValue keeps focus, accessibility, drag and arithmetic behaviour. Six harness tests cover the event kinds, the Unicode boundary, accumulation, undo, and document text fields staying unrestricted. Part of #2150. --- crates/ui-egui/src/widgets.rs | 29 +++ .../src/widgets/numeric_input_tests.rs | 195 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 crates/ui-egui/src/widgets/numeric_input_tests.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 48d7cc5ad0..533f1ee052 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -793,6 +793,8 @@ pub fn focus_first_field(ctx: &egui::Context, on: bool) { }); } +const NUMERIC_INPUT_LIMIT: usize = 128; + /// The number in a [`value_field`]. A typed number applies as it's typed; arithmetic waits for /// Enter, Tab or click-away, because per keystroke `5/2` would land first and a caller that /// rounds it would cut `5/2*2` short. `changed()` means a new value, not just a keystroke. @@ -814,6 +816,25 @@ fn number_edit( if ui.is_enabled() { crate::field_tab::register(&ctx, id); } + // Use DragValue's focus check before it builds its TextEdit: oversized events must never + // reach text layout. Registering interest also handles focus arriving by Tab this frame. + let editing = ui.is_enabled() + && ui.memory_mut(|m| { + m.interested_in_focus(id, ui.layer_id()); + m.has_focus(id) + }); + if editing { + ui.input_mut(|input| { + input.events.retain(|event| { + let text = match event { + egui::Event::Text(text) | egui::Event::Paste(text) | egui::Event::Ime(egui::ImeEvent::Commit(text)) => text, + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => text, + _ => return true, + }; + text.chars().nth(NUMERIC_INPUT_LIMIT).is_none() + }); + }); + } let held = id.with("arithmetic"); let math = ui.memory(|m| m.has_focus(id)) && ui.data(|d| d.get_temp(held)).unwrap_or(false); ui.data_mut(|d| d.insert_temp(held, math)); @@ -837,6 +858,10 @@ fn number_edit( .update_while_editing(!math) // Focus is read when parsing, not above: Tab hands it on inside `ui.add`. .custom_parser(move |s| { + // Short events can accumulate: reject before either parser normalizes the text. + if s.chars().nth(NUMERIC_INPUT_LIMIT).is_some() { + return None; + } let v = parse_num_in(s, units); if ctx.memory(|m| m.has_focus(id)) && plain(s).is_none() { // Still typing arithmetic: hold it, and stop applying keystrokes once it parses. @@ -2654,3 +2679,7 @@ mod screen_color_tests { } } } + +#[cfg(test)] +#[path = "widgets/numeric_input_tests.rs"] +mod numeric_input_tests; diff --git a/crates/ui-egui/src/widgets/numeric_input_tests.rs b/crates/ui-egui/src/widgets/numeric_input_tests.rs new file mode 100644 index 0000000000..41eff2ed4c --- /dev/null +++ b/crates/ui-egui/src/widgets/numeric_input_tests.rs @@ -0,0 +1,195 @@ +//! Reject oversized numeric events before layout while preserving DragValue behavior. + +use egui::{Event, ImeEvent, Key, Modifiers, accesskit::Role}; +use egui_kittest::{Harness, kittest::Queryable}; + +const LIMIT: usize = super::NUMERIC_INPUT_LIMIT; +type Fields = (f32, String); + +fn fields() -> Harness<'static, Fields> { + let mut h = Harness::builder().with_size(egui::vec2(400.0, 180.0)).build_ui_state( + |ui, (number, text): &mut Fields| { + super::value_field(ui, number, 0.01..=12800.0, "%", 90.0); + ui.add(egui::TextEdit::multiline(text).hint_text("Document text")); + }, + (100.0, String::new()), + ); + h.run(); + h +} + +fn focused() -> Harness<'static, Fields> { + let mut h = fields(); + h.get_by_role(Role::SpinButton).click(); + h.run(); + h +} + +fn numeric_text(h: &Harness<'_, Fields>) -> String { + h.query_by_role(Role::TextInput).or_else(|| h.query_by_role(Role::SpinButton)).expect("numeric editor").value().expect("numeric text") +} + +fn select_all(h: &mut Harness<'_, Fields>) { + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run(); +} + +fn assert_bounded(h: &Harness<'_, Fields>) { + fn longest(shape: &egui::Shape) -> usize { + match shape { + egui::Shape::Text(text) => text.galley.job.text.chars().count(), + egui::Shape::Vec(shapes) => shapes.iter().map(longest).max().unwrap_or(0), + _ => 0, + } + } + let drawn = h.output().shapes.iter().map(|shape| longest(&shape.shape)).max().unwrap_or(0); + assert!(drawn <= LIMIT, "numeric input laid out {drawn} characters; expected at most {LIMIT}"); + let retained = numeric_text(h).chars().count(); + assert!(retained <= LIMIT, "numeric editor retained {retained} characters; expected at most {LIMIT}"); +} + +#[test] +fn oversized_numeric_text_paste_and_ime_are_bounded_before_layout() { + for (index, event) in [ + Event::Text("9".repeat(16 * 1024)), + Event::Paste("x".repeat(16 * 1024)), + Event::Ime(ImeEvent::Preedit { text: "é".repeat(8 * 1024), active_range_chars: None }), + Event::Ime(ImeEvent::Commit("é".repeat(8 * 1024))), + ] + .into_iter() + .enumerate() + { + let mut h = focused(); + select_all(&mut h); + if matches!(&event, Event::Ime(_)) { + // egui 0.36 assigns IME events to the focused widget; Enabled is deprecated and + // ignored. Prove both live composition and commitment reach this field first. + h.event(Event::Ime(ImeEvent::Preedit { text: "に".into(), active_range_chars: Some(0..1) })); + h.run(); + assert_eq!(numeric_text(&h), "に", "short IME composition reaches the numeric editor"); + h.event(Event::Ime(ImeEvent::Commit("é".into()))); + h.run(); + assert_eq!(numeric_text(&h), "é", "IME commit replaces its composition"); + select_all(&mut h); + } + h.event(event); + h.run(); + if let Some(dir) = std::env::var_os("CRAFT_NUMERIC_INPUT_FIXTURES") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).expect("create numeric fixture directory"); + h.render().expect("render numeric fixture").save(dir.join(format!("event-{index}.png"))).expect("save numeric fixture"); + } + assert_bounded(&h); + assert_eq!(h.state().0, 100.0, "invalid input leaves the numeric value unchanged"); + h.run(); + assert_bounded(&h); + } +} + +#[test] +fn numeric_replacement_clear_and_undo_preserve_unicode_input() { + let mut h = focused(); + select_all(&mut h); + for _ in 0..4 { + h.event(Event::Text("é".repeat(16))); + h.run(); + assert_bounded(&h); + } + let retained = numeric_text(&h); + assert!(!retained.is_empty(), "ordinary short inputs still enter the editor"); + // Let egui's text undoer store the stable text before clearing it. + h.input_mut().time = Some(h.ctx.input(|i| i.time) + 2.0); + h.run(); + select_all(&mut h); + h.key_press(Key::Backspace); + h.run(); + assert_eq!(numeric_text(&h), ""); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run(); + assert_eq!(numeric_text(&h), retained, "undo restores only the bounded input"); + assert_bounded(&h); + select_all(&mut h); + h.event(Event::Text("−4+6.5".into())); + h.run(); + assert_eq!(numeric_text(&h), "−4+6.5", "replacement accepts Unicode arithmetic"); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().0, 2.5); +} + +#[test] +fn numeric_input_limit_does_not_truncate_document_text() { + for switch_from_numeric in [false, true] { + let mut h = if switch_from_numeric { focused() } else { fields() }; + let text = "é".repeat(8 * 1024); + h.get_by_role(Role::MultilineTextInput).click(); + h.run(); + h.event(Event::Paste(text.clone())); + h.run(); + assert_eq!(h.state().1, text, "document paste preserved when switching from numeric: {switch_from_numeric}"); + assert_eq!(h.get_by_role(Role::MultilineTextInput).value().as_deref(), Some(text.as_str())); + assert_eq!(h.state().0, 100.0); + } +} + +#[test] +fn numeric_event_limit_counts_unicode_characters_and_rejects_without_truncating() { + let mut h = focused(); + select_all(&mut h); + let boundary = "é".repeat(LIMIT); + h.event(Event::Paste(boundary.clone())); + h.run(); + assert_eq!(numeric_text(&h), boundary); + select_all(&mut h); + h.event(Event::Text("9".repeat(LIMIT + 1))); + h.run(); + assert_eq!(numeric_text(&h), boundary, "oversized input leaves the selection intact"); + h.event(Event::Text("42".into())); + h.run(); + assert_eq!(h.state().0, 42.0); +} + +#[test] +fn numeric_parser_rejects_oversized_accumulated_text_before_normalizing() { + let mut h = focused(); + select_all(&mut h); + // Each event is under the cap, but the resulting input is too long. Normalizing first + // would hide all the spaces and apply 2. DragValue still owns its editable text. + h.event(Event::Text(" ".repeat(LIMIT / 2))); + h.run(); + h.event(Event::Text(format!("{}2", " ".repeat(LIMIT / 2)))); + h.run(); + assert_eq!(numeric_text(&h).chars().count(), LIMIT + 1); + assert_eq!(h.state().0, 100.0, "oversized plain input must not apply live"); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().0, 100.0, "oversized input must not apply on commit"); +} + +#[test] +fn tab_and_paste_in_one_frame_filter_only_the_receiving_numeric_field() { + let mut h = focused(); + let text = "x".repeat(16 * 1024); + h.key_press(Key::Tab); + h.event(Event::Paste(text.clone())); + h.run(); + assert_eq!(h.state().1, text, "Tab to document text preserves the paste"); + // Put the numeric field after a text field to exercise incoming forward Tab too. + // egui deliberately delays backwards traversal until the next frame. + let mut h = Harness::builder().with_size(egui::vec2(400.0, 180.0)).build_ui_state( + |ui, (number, text): &mut Fields| { + ui.add(egui::TextEdit::multiline(text).hint_text("Document text")); + super::value_field(ui, number, 0.01..=12800.0, "%", 90.0); + }, + (100.0, String::new()), + ); + h.run(); + h.get_by_role(Role::MultilineTextInput).click(); + h.run(); + h.key_press(Key::Tab); + h.event(Event::Paste("9".repeat(16 * 1024))); + h.run(); + assert_eq!(h.state().0, 100.0); + assert!(h.state().1.is_empty()); + assert_bounded(&h); +} From 1b3bc48267700b299437ac4ea8fdc3f404a83f09 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 17 Oct 2026 02:28:51 +0200 Subject: [PATCH 079/171] docs(readme): Community packages section with the Gentoo ::snakebyte overlay (#1149) Adds a Community packages heading to the README, noting these installers are maintained by community members, with the Gentoo recipe for media-gfx/photocraft-bin from the ::snakebyte overlay. The overlay's ebuild fetches the official Linux x86_64 release tarball with Manifest-pinned hashes that match the release's SHA256SUMS. --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index d1e839323c..2ab84f644b 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,19 @@ tar -xzf photocraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr photocraft ``` +## Community packages + +These installers are maintained by community members, not by the PhotoCraft team. + +On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-gfx/photocraft-bin`: + +```sh +eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git +emaint sync -r snakebyte +echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/photocraft +emerge --ask media-gfx/photocraft-bin +``` + Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. > [!IMPORTANT] From a6a1d2ed2221e3bfa82d055b35d93b482fae22d8 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 17 Oct 2026 02:31:02 +0200 Subject: [PATCH 080/171] feat(algo): face detection with libfacedetection's CNN in pure Rust, and image.detectFaces (part of #3603) (#4513) photocraft_algo::faces::detect finds faces in an RGBA image with a score, a box and five landmarks, through a pure-Rust port of libfacedetection's YuNet network (Shiqi Yu, BSD-3-Clause; licence, NOTICE and ATTRIBUTION rows included). The weights are the library's, converted to half precision (107 KB), parsed defensively. image.detectFaces reports the document's faces to scripts and agents; it is read-only with no history step and no menu entry. The network is compiled out of the browser build, where the command reports itself unavailable. Head outputs are pinned against the C++ reference. Part of #3603. --- ATTRIBUTION.md | 1 + NOTICE | 1 + crates/algo/data/LICENSE-libfacedetection.txt | 35 ++ crates/algo/data/libfacedetection.bin | Bin 0 -> 106643 bytes crates/algo/src/faces.rs | 90 +++++ crates/algo/src/faces/cnn.rs | 323 ++++++++++++++++++ crates/algo/src/lib.rs | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/face_cmds.rs | 115 +++++++ crates/engine/src/lib.rs | 1 + 10 files changed, 568 insertions(+) create mode 100644 crates/algo/data/LICENSE-libfacedetection.txt create mode 100644 crates/algo/data/libfacedetection.bin create mode 100644 crates/algo/src/faces.rs create mode 100644 crates/algo/src/faces/cnn.rs create mode 100644 crates/engine/src/face_cmds.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 26eca62464..1ae19efa66 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -57,6 +57,7 @@ next to them. | `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) | | `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work | | `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work | +| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) | ### Optional build input: craft-fonts (not files in this repo) diff --git a/NOTICE b/NOTICE index 887010d4a6..a7302a940c 100644 --- a/NOTICE +++ b/NOTICE @@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/ JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt. SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt. +libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt. Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and diff --git a/crates/algo/data/LICENSE-libfacedetection.txt b/crates/algo/data/LICENSE-libfacedetection.txt new file mode 100644 index 0000000000..33abd64f06 --- /dev/null +++ b/crates/algo/data/LICENSE-libfacedetection.txt @@ -0,0 +1,35 @@ +By downloading, copying, installing or using the software you agree to this license. +If you do not agree to this license, do not download, install, +copy or use the software. + + + License Agreement For libfacedetection + (3-clause BSD License) + +Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. +shiqi.yu@gmail.com + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the names of the copyright holders nor the names of the contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +This software is provided by the copyright holders and contributors "as is" and +any express or implied warranties, including, but not limited to, the implied +warranties of merchantability and fitness for a particular purpose are disclaimed. +In no event shall copyright holders or contributors be liable for any direct, +indirect, incidental, special, exemplary, or consequential damages +(including, but not limited to, procurement of substitute goods or services; +loss of use, data, or profits; or business interruption) however caused +and on any theory of liability, whether in contract, strict liability, +or tort (including negligence or otherwise) arising in any way out of +the use of this software, even if advised of the possibility of such damage. \ No newline at end of file diff --git a/crates/algo/data/libfacedetection.bin b/crates/algo/data/libfacedetection.bin new file mode 100644 index 0000000000000000000000000000000000000000..0941c4f689dae8012c98b16bbc530f2d59173a92 GIT binary patch literal 106643 zcmWG=c5`%gHDoYlP+$;XVE8ZcR3b;heZs7X9Fs0i&Y2-OV}{rsNl8iNDYa8OrixA9 zH0#>TDT3F<#Kb)(GEZ4Pky9dAFiw;K4h~Ez?JXC&J?Vdsx{%m}@13E7X1%|;-iTFC zUf!D|@~A7AYq_Y)q-PVDg&$30op!gqc0x&ip&(p4Ljun{F3+jAg$0BjPJSS8gzx{9 zvjRMPCR4dYzX&N$*(UUiKW^$*0SkfEQ|<~~6)>5)T;LV&v#ALE6BxWFiu8Twd)H|y z94$1D!A|t5fJ?)oCcDX2(|RV>Pw$;6FilgmmETUtL||6;d105y^QQN8A@u7{ixchZ zGMSno%FnuTf`j-`0p%GlgsLZQoZ-(GJlS$)M*Fd;(#O0@QbkvGooXX6fN|Xzpn7&Cgp=b4s0~}XHo=udO z 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] //! [--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 112/171] 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 113/171] 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 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 114/171] 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 235e7578f397d5fb3962d0c72bb0d22d7a1ab7c8 Mon Sep 17 00:00:00 2001 From: Diego Chagas Date: Sat, 17 Oct 2026 18:55:54 -0300 Subject: [PATCH 115/171] =?UTF-8?q?Print=20through=20CUPS=20over=20IPP=20a?= =?UTF-8?q?nd=20the=20print=20portal,=20so=20File=20=E2=80=BA=20Print=20wo?= =?UTF-8?q?rks=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 ead6d2197f5d0b82d70f3fddb70e472fc27ce577 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 17:20:33 -0600 Subject: [PATCH 116/171] 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 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 117/171] 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 8407d9ccb125af52944fca9d228383459f94aaef Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 17 Oct 2026 21:28:00 -0400 Subject: [PATCH 118/171] 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 119/171] 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 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 120/171] 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 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 121/171] =?UTF-8?q?perf(stack):=20one=20loop=20per=20stati?= =?UTF-8?q?stic,=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 363d38511430963c13bba388bf106993dd8427ac Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:06:40 +0200 Subject: [PATCH 122/171] 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 123/171] 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 124/171] 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 125/171] 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 126/171] 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 127/171] 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 128/171] 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 129/171] 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 130/171] 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 131/171] 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 132/171] 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 133/171] 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 134/171] 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 135/171] 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 136/171] 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 137/171] 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 175bdce6590c6350b30facbdc762b8728ace10af Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 09:32:53 +0200 Subject: [PATCH 138/171] 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 139/171] 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 140/171] 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 141/171] 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 142/171] 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 143/171] 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 144/171] 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 145/171] 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 146/171] 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 147/171] 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 148/171] 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 149/171] 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 150/171] 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 151/171] 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 152/171] 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 153/171] 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 154/171] 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 155/171] 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 156/171] 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 157/171] 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 158/171] 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 159/171] 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 160/171] 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 161/171] 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 162/171] 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 163/171] 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 164/171] 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 165/171] 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 166/171] 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 167/171] 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 168/171] 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 169/171] =?UTF-8?q?Document=20tabs:=20keep=20whole=20names?= =?UTF-8?q?,=20hide=20what=20doesn't=20fit,=20list=20every=20document=20in?= =?UTF-8?q?=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 170/171] 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 171/171] 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? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар?

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 Mx0=s1!%*530Nn3$wEzGB literal 0 HcmV?d00001 diff --git a/crates/algo/src/faces.rs b/crates/algo/src/faces.rs new file mode 100644 index 0000000000..aa246fcaf8 --- /dev/null +++ b/crates/algo/src/faces.rs @@ -0,0 +1,90 @@ +//! Face detection: a port of libfacedetection's CNN (Shiqi Yu, BSD-3; `data/LICENSE-libfacedetection.txt`). +//! +//! The network is the YuNet design: a backbone of 1×1 pointwise and 3×3 depthwise convolutions with +//! 2×2 max pooling, a feature pyramid at strides 8, 16 and 32, and per-cell heads for the face score, +//! the box and five landmarks (eyes, nose tip, mouth corners). The weights (`data/libfacedetection.bin`, +//! converted to half precision from the library's `facedetectcnn-data.cpp`) are about 100 KB, and +//! inference is plain Rust: no runtime, so it works offline. +//! +//! [`detect`] takes straight RGBA in `0..=1` and returns faces in pixel coordinates of that image. +//! +//! The browser build (wasm32) leaves the network and its weights out, as `photocraft-raw` does with +//! its JPEG XL decoder: they would push the wasm over the per-file cap of static hosts +//! (packaging/web/package.sh). There [`AVAILABLE`] is false and [`detect`] finds nothing. + +#[cfg(not(target_arch = "wasm32"))] +mod cnn; + +/// Whether this build can detect faces (not the browser build: see the module docs). +pub const AVAILABLE: bool = cfg!(not(target_arch = "wasm32")); + +/// Longest side the network looks at; larger images are scaled down first (faces smaller than +/// about 10 px at this scale are missed). +pub const MAX_SIDE: usize = 1280; + +/// One detected face, in pixels of the image passed to [`detect`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Face { + /// Confidence `0..=1` (the library reports faces above 0.5 as reliable). + pub score: f32, + /// Left, top, width and height. + pub rect: [f32; 4], + /// Right eye, left eye (as seen in the image: the first is on the image's left), nose tip, + /// right mouth corner, left mouth corner. + pub landmarks: [[f32; 2]; 5], +} + +/// Faces in `px` (straight RGBA `0..=1`, `w × h`, row-major) scoring at least `min_score`, best +/// first. Transparent pixels count as white. `None` when `px` doesn't match the size. +#[cfg(not(target_arch = "wasm32"))] +pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Option> { + if w == 0 || h == 0 || px.len() != w.checked_mul(h)? { + return None; + } + // Scale down (box filter) so the longer side is at most MAX_SIDE. + let step = w.max(h).div_ceil(MAX_SIDE).max(1); + let (sw, sh) = (w.div_ceil(step), h.div_ceil(step)); + let mut bgr = vec![[0.0f32; 3]; sw * sh]; + for y in 0..sh { + for x in 0..sw { + let (mut acc, mut n) = ([0.0f32; 3], 0.0f32); + for yy in y * step..((y + 1) * step).min(h) { + for xx in x * step..((x + 1) * step).min(w) { + let [r, g, b, a] = px[yy * w + xx]; + let a = a.clamp(0.0, 1.0); + for (k, v) in [b, g, r].into_iter().enumerate() { + acc[k] += v.clamp(0.0, 1.0) * a + (1.0 - a); + } + n += 1.0; + } + } + bgr[y * sw + x] = acc.map(|v| (v / n.max(1.0) * 255.0).round()); + } + } + let s = step as f32; + let faces = cnn::network(&bgr, sw, sh)? + .into_iter() + .filter(|f| f.score >= min_score) + .map(|f| Face { score: f.score, rect: f.rect.map(|v| v * s), landmarks: f.landmarks.map(|[x, y]| [x * s, y * s]) }) + .collect(); + Some(faces) +} + +/// The browser build has no detector (see the module docs): always `None`. +#[cfg(target_arch = "wasm32")] +pub fn detect(_px: &[[f32; 4]], _w: usize, _h: usize, _min_score: f32) -> Option> { + None +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn no_faces_in_flat_or_tiny_images_and_bad_sizes_are_refused() { + assert_eq!(detect(&vec![[0.5, 0.5, 0.5, 1.0]; 64 * 48], 64, 48, 0.5), Some(vec![])); + assert_eq!(detect(&[[1.0, 1.0, 1.0, 1.0]], 1, 1, 0.5), Some(vec![])); + assert_eq!(detect(&[[1.0; 4]; 3], 2, 2, 0.5), None); + assert_eq!(detect(&[], 0, 0, 0.5), None); + } +} diff --git a/crates/algo/src/faces/cnn.rs b/crates/algo/src/faces/cnn.rs new file mode 100644 index 0000000000..b86cac7eb2 --- /dev/null +++ b/crates/algo/src/faces/cnn.rs @@ -0,0 +1,323 @@ +//! The network itself (native builds only: see the parent module's docs). + +use super::Face; +use crate::par; + +/// The weights: `PCFACE1\0`, the layer count (u16), then per layer its input channels (u16), filters +/// (u16) and kind (u8: 0 pointwise, 1 depthwise), the weights and the biases, all little-endian f16. +static WEIGHTS: &[u8] = include_bytes!("../../data/libfacedetection.bin"); + +const MAGIC: &[u8] = b"PCFACE1\0"; +const LAYERS: usize = 53; +/// Faces whose boxes overlap more than this (intersection over union) are one face. +const NMS_OVERLAP: f32 = 0.45; +/// Candidates kept before non-maximum suppression, as the library does. +const TOP_K: usize = 1000; + +struct Layer { + channels: usize, + filters: usize, + depthwise: bool, + weights: Vec, + biases: Vec, +} + +/// A feature map: `rows × cols` cells of `channels` values each. +#[derive(Clone)] +struct Blob { + rows: usize, + cols: usize, + channels: usize, + data: Vec, +} + +impl Blob { + fn new(rows: usize, cols: usize, channels: usize) -> Self { + Blob { rows, cols, channels, data: vec![0.0; rows * cols * channels] } + } +} + +fn f16_to_f32(h: u16) -> f32 { + let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 }; + let exp = i32::from((h >> 10) & 0x1f); + let frac = f32::from(h & 0x3ff); + match exp { + 0 => sign * frac * (2.0f32).powi(-24), + 31 => { + if frac == 0.0 { + sign * f32::INFINITY + } else { + f32::NAN + } + } + _ => sign * (1.0 + frac / 1024.0) * (2.0f32).powi(exp - 15), + } +} + +fn parse(bytes: &[u8]) -> Option> { + let rest = bytes.strip_prefix(MAGIC)?; + let mut at = 0usize; + let u16_at = |n: &mut usize| -> Option { + let b = rest.get(*n..*n + 2)?; + *n += 2; + Some(u16::from_le_bytes([b[0], b[1]])) + }; + let count = usize::from(u16_at(&mut at)?); + if count != LAYERS { + return None; + } + let mut layers = Vec::with_capacity(count); + for _ in 0..count { + let channels = usize::from(u16_at(&mut at)?); + let filters = usize::from(u16_at(&mut at)?); + let depthwise = *rest.get(at)? == 1; + at += 1; + let nw = if depthwise { 9 * channels } else { filters * channels }; + let mut read = |n: usize| -> Option> { (0..n).map(|_| u16_at(&mut at).map(f16_to_f32)).collect() }; + let weights = read(nw)?; + let biases = read(filters)?; + if depthwise && filters != channels { + return None; + } + layers.push(Layer { channels, filters, depthwise, weights, biases }); + } + (at == rest.len()).then_some(layers) +} + +/// A 1×1 or 3×3 depthwise convolution with bias, then ReLU when `relu`. +fn conv(x: &Blob, l: &Layer, relu: bool) -> Option { + if x.channels != l.channels { + return None; + } + let mut out = Blob::new(x.rows, x.cols, l.filters); + let (rows, cols, cin, cout) = (x.rows, x.cols, x.channels, l.filters); + par::chunks_mut(&mut out.data, cols * cout, rows * cols * cout > 1 << 16, |row, line| { + for col in 0..cols { + let o = &mut line[col * cout..(col + 1) * cout]; + if l.depthwise { + o.copy_from_slice(&l.biases); + for r in row.saturating_sub(1)..(row + 2).min(rows) { + for c in col.saturating_sub(1)..(col + 2).min(cols) { + let k = (r + 1 - row) * 3 + (c + 1 - col); + let input = &x.data[(r * cols + c) * cin..(r * cols + c + 1) * cin]; + let w = &l.weights[k * cin..(k + 1) * cin]; + for ((o, i), w) in o.iter_mut().zip(input).zip(w) { + *o += i * w; + } + } + } + } else { + let input = &x.data[(row * cols + col) * cin..(row * cols + col + 1) * cin]; + for (f, o) in o.iter_mut().enumerate() { + let w = &l.weights[f * cin..(f + 1) * cin]; + *o = l.biases[f] + input.iter().zip(w).map(|(i, w)| i * w).sum::(); + } + } + if relu { + for v in o.iter_mut() { + *v = v.max(0.0); + } + } + } + }); + Some(out) +} + +/// Pointwise then depthwise (ReLU after the depthwise one when `relu`). +fn conv_dp(x: &Blob, p: &Layer, d: &Layer, relu: bool) -> Option { + conv(&conv(x, p, false)?, d, relu) +} + +fn unit(x: &Blob, l: &[Layer]) -> Option { + let [p1, d1, p2, d2] = l else { return None }; + conv_dp(&conv_dp(x, p1, d1, true)?, p2, d2, true) +} + +fn max_pool(x: &Blob) -> Blob { + let rows = x.rows.saturating_sub(3).div_ceil(2) + 1; + let cols = x.cols.saturating_sub(3).div_ceil(2) + 1; + let mut out = Blob::new(rows, cols, x.channels); + for r in 0..rows { + for c in 0..cols { + let o = (r * cols + c) * x.channels; + for ch in 0..x.channels { + let mut m = f32::NEG_INFINITY; + for rr in r * 2..(r * 2 + 2).min(x.rows) { + for cc in c * 2..(c * 2 + 2).min(x.cols) { + m = m.max(x.data[(rr * x.cols + cc) * x.channels + ch]); + } + } + out.data[o + ch] = m; + } + } + } + out +} + +/// `small` upsampled ×2 (nearest) plus `big`. +fn up_add(small: &Blob, big: &Blob) -> Option { + if small.rows * 2 != big.rows || small.cols * 2 != big.cols || small.channels != big.channels { + return None; + } + let mut out = big.clone(); + let ch = big.channels; + for r in 0..big.rows { + for c in 0..big.cols { + let s = ((r / 2) * small.cols + c / 2) * ch; + let o = (r * big.cols + c) * ch; + for k in 0..ch { + out.data[o + k] += small.data[s + k]; + } + } + } + Some(out) +} + +/// The network's input: each cell holds the 3×3 neighbourhood (stride 2) of BGR values `0..=255`, +/// so the first, 3×3 stride-2 convolution is a pointwise one. The size is padded to 32. +fn input(bgr: &[[f32; 3]], w: usize, h: usize) -> Blob { + let rows = h.div_ceil(32) * 16; + let cols = w.div_ceil(32) * 16; + let mut b = Blob::new(rows, cols, 32); + for r in 0..rows { + for c in 0..cols { + let cell = (r * cols + c) * 32; + for fy in 0..3 { + let Some(y) = (r * 2 + fy).checked_sub(1).filter(|&y| y < h) else { continue }; + for fx in 0..3 { + let Some(x) = (c * 2 + fx).checked_sub(1).filter(|&x| x < w) else { continue }; + let o = cell + (fy * 3 + fx) * 3; + b.data[o..o + 3].copy_from_slice(&bgr[y * w + x]); + } + } + } + } + b +} + +/// The heads of one pyramid level, decoded to candidate faces (in network pixels). +fn candidates(feat: &Blob, l: &[Layer], heads: [usize; 4], stride: f32, out: &mut Vec) -> Option<()> { + let head = |i: usize| conv_dp(feat, l.get(i)?, l.get(i + 1)?, false); + let (cls, reg, obj, kps) = (head(heads[0])?, head(heads[1])?, head(heads[2])?, head(heads[3])?); + if cls.channels != 1 || obj.channels != 1 || reg.channels != 4 || kps.channels != 10 { + return None; + } + let sigmoid = |v: f32| 1.0 / (1.0 + (-v.clamp(-88.0, 88.0)).exp()); + for r in 0..feat.rows { + for c in 0..feat.cols { + let i = r * feat.cols + c; + let score = (sigmoid(cls.data[i]) * sigmoid(obj.data[i])).sqrt(); + if score < 0.2 { + continue; + } + let (px, py) = (c as f32 * stride, r as f32 * stride); + let b = ®.data[i * 4..i * 4 + 4]; + let (cx, cy) = (b[0] * stride + px, b[1] * stride + py); + let (bw, bh) = (b[2].exp() * stride, b[3].exp() * stride); + let k = &kps.data[i * 10..i * 10 + 10]; + let mut landmarks = [[0.0; 2]; 5]; + for (n, lm) in landmarks.iter_mut().enumerate() { + *lm = [k[2 * n] * stride + px, k[2 * n + 1] * stride + py]; + } + out.push(Face { score, rect: [cx - bw / 2.0, cy - bh / 2.0, bw, bh], landmarks }); + } + } + Some(()) +} + +fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 { + let w = ((a[0] + a[2]).min(b[0] + b[2]) - a[0].max(b[0])).max(0.0); + let h = ((a[1] + a[3]).min(b[1] + b[3]) - a[1].max(b[1])).max(0.0); + let i = w * h; + if i <= 0.0 { 0.0 } else { i / (a[2] * a[3] + b[2] * b[3] - i) } +} + +/// The heads (cls, reg, obj, kps: their first layer) of the stride 8, 16 and 32 levels. +const HEADS: [[usize; 4]; 3] = [[29, 35, 41, 47], [31, 37, 43, 49], [33, 39, 45, 51]]; + +/// The backbone and feature pyramid: the stride 8, 16 and 32 feature maps the heads read. +fn features(bgr: &[[f32; 3]], w: usize, h: usize, l: &[Layer]) -> Option<[Blob; 3]> { + let x = conv(&input(bgr, w, h), l.first()?, true)?; + let x = max_pool(&conv_dp(&x, l.get(1)?, l.get(2)?, true)?); + let x = unit(&unit(&x, l.get(3..7)?)?, l.get(7..11)?)?; + let f8 = unit(&max_pool(&x), l.get(11..15)?)?; + let f16 = unit(&max_pool(&f8), l.get(15..19)?)?; + let f32_ = unit(&max_pool(&f16), l.get(19..23)?)?; + let f32_ = conv_dp(&f32_, l.get(27)?, l.get(28)?, true)?; + let f16 = conv_dp(&up_add(&f32_, &f16)?, l.get(25)?, l.get(26)?, true)?; + let f8 = conv_dp(&up_add(&f16, &f8)?, l.get(23)?, l.get(24)?, true)?; + Some([f8, f16, f32_]) +} + +pub(super) fn network(bgr: &[[f32; 3]], w: usize, h: usize) -> Option> { + let l = parse(WEIGHTS)?; + let levels = features(bgr, w, h, &l)?; + let mut faces = Vec::new(); + // Heads (cls, reg, obj, kps) per level, as in the library's model. + for ((feat, heads), stride) in levels.iter().zip(HEADS).zip([8.0, 16.0, 32.0]) { + candidates(feat, &l, heads, stride, &mut faces)?; + } + faces.sort_by(|a, b| b.score.total_cmp(&a.score)); + faces.truncate(TOP_K); + let mut kept: Vec = Vec::new(); + for f in faces { + if kept.iter().all(|k| iou(&k.rect, &f.rect) <= NMS_OVERLAP) { + kept.push(f); + } + } + Some(kept) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weights_parse() { + let l = parse(WEIGHTS).expect("bundled weights"); + assert_eq!(l.len(), LAYERS); + assert_eq!((l[0].channels, l[0].filters, l[0].depthwise), (32, 16, false)); + assert_eq!((l[2].channels, l[2].filters, l[2].depthwise), (16, 16, true)); + assert!(l.iter().all(|l| l.weights.iter().chain(&l.biases).all(|v| v.is_finite()))); + // Damaged weights are refused, not read past their end. + assert!(parse(&WEIGHTS[..WEIGHTS.len() - 1]).is_none()); + assert!(parse(b"nope").is_none()); + } + + /// The sum of every head's raw output on a fixed test image matches libfacedetection's own C++ + /// (computed with its f32 weights; ours are f16, hence the tolerance). + #[test] + fn heads_match_the_reference_implementation() { + let (w, h) = (96usize, 72usize); + let bgr: Vec<[f32; 3]> = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (r, g, b) = ((x * 7 + y * 3) % 256, (x * x + y * 5) % 256, (x * y * 3 + 11) % 256); + [b as f32, g as f32, r as f32] + }) + .collect(); + let l = parse(WEIGHTS).unwrap(); + let levels = features(&bgr, w, h, &l).unwrap(); + let reference = + [27.647039, 549.579923, -1645.299678, 914.784787, -6.149318, 35.705980, -454.742148, 163.847111, 1.267630, 28.415509, -85.662257, 62.294208]; + let mut got = Vec::new(); + for (feat, heads) in levels.iter().zip(HEADS) { + for first in heads { + let o = conv_dp(feat, &l[first], &l[first + 1], false).unwrap(); + got.push(o.data.iter().map(|&v| f64::from(v)).sum::()); + } + } + for (g, r) in got.iter().zip(reference) { + assert!((g - r).abs() <= 0.01 * r.abs().max(10.0), "{got:?}"); + } + } + + #[test] + fn f16_decoding() { + assert_eq!(f16_to_f32(0x3c00), 1.0); + assert_eq!(f16_to_f32(0xc000), -2.0); + assert_eq!(f16_to_f32(0x0000), 0.0); + assert_eq!(f16_to_f32(0x3555), 0.333_251_95); + assert_eq!(f16_to_f32(0x0001), 2.0f32.powi(-24)); + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 49ab15871c..c2ff6d2e6c 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -32,6 +32,7 @@ mod distort; mod distort2; pub mod erase; pub mod exif; +pub mod faces; pub mod features; mod fxutil; mod gallery; diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f04187128..48b576dcb4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1422,6 +1422,7 @@ fn build() -> Vec { v.extend(crate::edit_menu_cmds::specs()); v.extend(crate::fill_key_cmds::specs()); v.extend(crate::sample_cmds::specs()); + v.extend(crate::face_cmds::specs()); v.extend(crate::sampler_cmds::specs()); v.extend(crate::brush_key_cmds::specs()); v.extend(crate::stamp_cmds::specs()); diff --git a/crates/engine/src/face_cmds.rs b/crates/engine/src/face_cmds.rs new file mode 100644 index 0000000000..d48df95b84 --- /dev/null +++ b/crates/engine/src/face_cmds.rs @@ -0,0 +1,115 @@ +//! Face detection (#3603): the faces in the document, with their box and five landmarks, from +//! [`photocraft_algo::faces`]. Select › Color Range's Detect Faces and Face-Aware Liquify read +//! faces through [`faces_in`]; `image.detectFaces` reports them to scripts and agents. + +use photocraft_algo::faces::{self, Face}; +use photocraft_doc::{Document, LayerId}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// Faces scoring below this are left out unless asked for (libfacedetection's own threshold for a +/// reliable face). +pub const MIN_SCORE: f32 = 0.5; + +/// Why there are no faces in the browser build (see [`photocraft_algo::faces`]). +const UNAVAILABLE: &str = "face detection isn't available in the browser version of PhotoCraft; use the desktop app"; + +/// The faces in `px` (`w × h` straight RGBA), as [`faces::detect`] finds them. +pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Result> { + if !faces::AVAILABLE { + return Err(EngineError::Other(UNAVAILABLE.into())); + } + faces::detect(px, w, h, min_score).ok_or_else(|| EngineError::Other("face detection failed".into())) +} + +/// The faces in `doc`, best first: in the composite, or in the active layer's pixels when +/// `all_layers` is off. Coordinates are document pixels. +pub fn faces_in(doc: &Document, active: Option, all_layers: bool, min_score: f32) -> Result> { + let area = doc.bounds(); + let (w, h) = (area.width() as usize, area.height() as usize); + let surface = active.and_then(|id| doc.layer(id)).and_then(|l| l.surface()).filter(|_| !all_layers); + let px = match surface { + Some(surf) => { + let mut px = vec![[0.0f32; 4]; w * h]; + surf.read_rgba_into(area, &mut px); + px + } + None => photocraft_compose::render(doc, area).px, + }; + let found = detect(&px, w, h, min_score)?; + let (x0, y0) = (area.x0 as f32, area.y0 as f32); + Ok(found + .into_iter() + .map(|f| Face { rect: [f.rect[0] + x0, f.rect[1] + y0, f.rect[2], f.rect[3]], landmarks: f.landmarks.map(|[x, y]| [x + x0, y + y0]), ..f }) + .collect()) +} + +fn face_json(f: &Face) -> Value { + let pt = |i: usize| json!([f.landmarks[i][0], f.landmarks[i][1]]); + json!({ + "score": f.score, + "rect": f.rect, + "landmarks": {"rightEye": pt(0), "leftEye": pt(1), "nose": pt(2), "mouthRight": pt(3), "mouthLeft": pt(4)}, + }) +} + +fn detect_faces(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "image.detectFaces"; + let bad = |msg: &str| EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }; + let min_score = match p.get("minScore") { + None => MIN_SCORE, + Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad("minScore must be a number in 0..1"))? as f32, + }; + let all_layers = match p.get("sampleAllLayers") { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad("sampleAllLayers must be true or false"))?, + }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let found = faces_in(&d.doc, d.active_layer, all_layers, min_score)?; + Ok(json!({ "faces": found.iter().map(face_json).collect::>() })) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: "image.detectFaces", + label: "Detect Faces", + menu: &[], + shortcut: None, + params: r##"{"minScore":0..1=0.5,"sampleAllLayers":bool=true} → {"faces":[{"score","rect":[x,y,w,h],"landmarks":{"rightEye","leftEye","nose","mouthRight","mouthLeft"}: [x,y] each},…]} (best first; "rightEye" is the one on the image's left)"##, + enabled: |s| { + if !faces::AVAILABLE { + return Err(UNAVAILABLE.into()); + } + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) + }, + run: detect_faces, + journal: false, + }] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn detect_faces_reports_no_faces_on_a_plain_image_and_validates_params() { + let mut s = Session::new(); + assert!(s.execute("image.detectFaces", json!({})).is_err(), "no document"); + assert!(!s.is_enabled("image.detectFaces")); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap(); + assert!(s.is_enabled("image.detectFaces")); + s.execute("select.all", json!({})).unwrap(); + assert_eq!(s.execute("image.detectFaces", json!({})).unwrap(), json!({"faces": []})); + assert_eq!(s.execute("image.detectFaces", json!({"minScore": 0, "sampleAllLayers": false})).unwrap(), json!({"faces": []})); + // No history step of its own: Undo undoes the Select All before it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); + for bad in [json!({"minScore": 2}), json!({"minScore": "high"}), json!({"sampleAllLayers": 1})] { + assert!(s.execute("image.detectFaces", bad.clone()).is_err(), "{bad}"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8d6167c3d..0bf4d1b40f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -41,6 +41,7 @@ pub mod eraser_cmds; pub mod export_preset_cmds; pub mod exr_cmds; pub mod extra_cmds; +pub mod face_cmds; pub mod file_cmds; pub mod fill_cmds; pub mod fill_key_cmds; From 5a33441af3bf5d9b93550055fe540a4ed3b448b8 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 17 Oct 2026 02:34:40 +0200 Subject: [PATCH 081/171] fix(engine): an unknown layer id or name lists the document's layers, and a name matches regardless of case as a fallback (#5058) (#5133) A command that fails on a layer id or name the document doesn't have now says which layers there are, top-down as id and name (at most 20, names cut at 64 characters), so an agent can recover from the error alone: ids are not positions, and a freshly opened PNG's Background is layer 2. NoLayer becomes UnknownLayer { id, known } at both command entry points, keeping the 'no such layer' prefix. A layer name that matches no layer exactly matches regardless of case; an exact match always wins. Fixes #5058. --- crates/engine/src/commands.rs | 35 +++++++++-- crates/engine/src/jobs.rs | 16 ++++- crates/engine/src/lib.rs | 4 ++ crates/engine/src/type_caret_cmds.rs | 2 +- crates/engine/tests/missing_layer_error.rs | 68 ++++++++++++++++++++++ 5 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/missing_layer_error.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 48b576dcb4..ced8462c9e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -295,7 +295,8 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { /// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that /// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays -/// in another document). `None` when the param is absent. +/// in another document; a name that matches no layer exactly matches regardless of case, so +/// `"background"` finds the Background). `None` when the param is absent. pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> { match p.get("layer") { Some(Value::Number(n)) => { @@ -303,19 +304,41 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> } Some(Value::String(name)) => { let d = s.active().ok_or(EngineError::NoDocument)?; - d.doc - .walk() - .into_iter() - .rev() + let layers = d.doc.walk(); + let mut top_down = layers.iter().rev(); + top_down + .clone() .find(|(_, _, l)| l.name == *name) + .or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase())) .map(|(_, _, l)| Some(l.id)) - .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\""))) + .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc)))) } Some(Value::Null) | None => Ok(None), Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))), } } +/// The layers of `doc` as a caller can name them, for an error about a layer that isn't there: +/// `the document's layers are 2 "Background", 3 "Layer 1"` (top-down, at most 20). +pub(crate) fn layer_listing(doc: &Document) -> String { + const MAX: usize = 20; + let layers = doc.walk(); + if layers.is_empty() { + return "the document has no layers".into(); + } + // Names are unbounded input: each one is cut at NAME characters so the error stays short. + const NAME: usize = 64; + let name = |n: &str| match n.char_indices().nth(NAME) { + Some((cut, _)) => format!("{}…", &n[..cut]), + None => n.to_string(), + }; + let mut list: Vec = layers.iter().rev().take(MAX).map(|(_, _, l)| format!("{} {:?}", l.id.0, name(&l.name))).collect(); + if layers.len() > MAX { + list.push(format!("… {} more (see `document.inspect`)", layers.len() - MAX)); + } + format!("the document's layers are {} (a `layer` param takes an id or a name)", list.join(", ")) +} + /// The layer a command targets: [`explicit_layer`], else the active layer. pub(crate) fn layer_param(s: &Session, p: &Value) -> Result { match explicit_layer(s, p)? { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index de2afd1556..17d8288f8b 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -543,7 +543,7 @@ impl Session { job.ctx.cancel(); Err(e) } - (Err(e), None) => Err(e), + (Err(e), None) => Err(self.explain_missing_layer(e)), (Ok(_), Some(mut job)) => { job.command = command.to_string(); job.params = params; @@ -740,7 +740,7 @@ impl Session { job.ctx.cancel(); Err(e) } - (Err(e), None) => Err(e), + (Err(e), None) => Err(self.explain_missing_layer(e)), (Ok(_), Some(mut job)) => { job.command = id.to_string(); job.params = params; @@ -756,6 +756,18 @@ impl Session { } } + /// 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. + fn explain_missing_layer(&self, e: EngineError) -> EngineError { + match (e, self.active()) { + (EngineError::NoLayer(id), Some(st)) if st.doc.layer(id).is_none() => { + EngineError::UnknownLayer { id, known: crate::commands::layer_listing(&st.doc) } + } + (e, _) => e, + } + } + /// Bookkeeping after a command (or a job's apply) succeeded. /// /// `nested_from` is the journal length when the command started. A replayable command stands diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0bf4d1b40f..22b1e6d728 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -156,6 +156,10 @@ pub enum EngineError { NoDocument, #[error("no such layer {0:?}")] NoLayer(LayerId), + /// [`Self::NoLayer`] as a command reports it: with the layers the document does have, as + /// ids are not positions (#5058). + #[error("no such layer {}; {known}", id.0)] + UnknownLayer { id: LayerId, known: String }, #[error("{0}")] Other(String), /// A background job was cancelled (see [`jobs`]); nothing changed. diff --git a/crates/engine/src/type_caret_cmds.rs b/crates/engine/src/type_caret_cmds.rs index 419d305ee6..3b9b244f70 100644 --- a/crates/engine/src/type_caret_cmds.rs +++ b/crates/engine/src/type_caret_cmds.rs @@ -482,7 +482,7 @@ mod tests { bad(&mut s, "type.navigate", json!({"layer": id, "index": 9, "move": "end"})); bad(&mut s, "type.navigate", json!({"layer": id, "index": 0, "move": "lineNext", "x": "no"})); bad(&mut s, "type.navigate", json!({"layer": pixel, "index": 0, "move": "start"})); - assert!(matches!(s.execute("type.caret", json!({"layer": u64::MAX, "index": 0})), Err(EngineError::NoLayer(_)))); + assert!(matches!(s.execute("type.caret", json!({"layer": u64::MAX, "index": 0})), Err(EngineError::UnknownLayer { .. }))); // A point on the named layer but outside its text still returns a caret, marked outside. let far = s.execute("type.hitTest", json!({"layer": id, "x": 390, "y": 290})).unwrap(); assert_eq!(far["inside"], false); diff --git a/crates/engine/tests/missing_layer_error.rs b/crates/engine/tests/missing_layer_error.rs new file mode 100644 index 0000000000..5764f5d85c --- /dev/null +++ b/crates/engine/tests/missing_layer_error.rs @@ -0,0 +1,68 @@ +//! A `"layer"` param that names no layer says which layers there are (#5058): ids are not +//! positions (a freshly opened PNG's Background is layer 2), and an agent should be able to +//! recover from the error alone. A name matches regardless of case as a fallback. + +use photocraft_engine::{EngineError, Session}; +use serde_json::json; + +fn session() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + let bg = s.active().unwrap().active_layer.unwrap().0; + (s, bg) +} + +fn unused_id(s: &Session) -> u64 { + let doc = &s.active().unwrap().doc; + (1..).find(|id| doc.layer(photocraft_doc::LayerId(*id)).is_none()).unwrap() +} + +#[test] +fn an_unknown_layer_id_lists_the_documents_layers() { + let (mut s, bg) = session(); + let layer = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let missing = unused_id(&s); + let err = s.execute("layer.setProps", json!({"layer": missing, "visible": false})).unwrap_err(); + assert!(matches!(err, EngineError::UnknownLayer { id, .. } if id.0 == missing), "{err}"); + let err = err.to_string(); + assert!(err.contains(&format!("no such layer {missing};")), "{err}"); + assert!(err.contains(&format!("{layer} \"Layer 1\", {bg} \"Background\"")), "top-down ids and names: {err}"); +} + +#[test] +fn an_unknown_layer_name_lists_the_documents_layers() { + let (mut s, bg) = session(); + let err = s.execute("layer.setProps", json!({"layer": "Nope", "visible": false})).unwrap_err().to_string(); + assert!(err.contains("no layer named \"Nope\""), "{err}"); + assert!(err.contains(&format!("{bg} \"Background\"")), "{err}"); +} + +#[test] +fn a_layer_name_matches_regardless_of_case() { + let (mut s, bg) = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.setProps", json!({"layer": "background", "name": "Base"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(bg)).unwrap().name, "Base"); +} + +#[test] +fn an_exact_name_wins_over_a_case_insensitive_one() { + let (mut s, _) = session(); + let lower = s.execute("layer.new.layer", json!({"name": "shadow"})).unwrap()["layer"].as_u64().unwrap(); + let upper = s.execute("layer.new.layer", json!({"name": "Shadow"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": "shadow", "visible": false})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(!doc.layer(photocraft_doc::LayerId(lower)).unwrap().visible); + assert!(doc.layer(photocraft_doc::LayerId(upper)).unwrap().visible); +} + +#[test] +fn a_long_layer_name_is_shortened_in_the_listing() { + let (mut s, _) = session(); + let long = "é".repeat(500); + let layer = s.execute("layer.new.layer", json!({"name": long})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute("layer.setProps", json!({"layer": "Nope", "visible": false})).unwrap_err().to_string(); + let shown = format!("{layer} \"{}…\"", "é".repeat(64)); + assert!(err.contains(&shown), "the name is cut at 64 characters: {err}"); + assert!(err.chars().count() < 300, "{err}"); +} From 3722dfbc63e862366ed3ba1cc38d8868d6e888fd Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:36:06 -0400 Subject: [PATCH 082/171] fix(ui): Arabic and Hebrew layer names and the title read right to left (#5159) (#5385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit egui places font runs left to right with no bidi reordering, so a right-to-left layer name showed every word correctly but in reversed order. bidi_text::visual puts a label into display order with the Unicode Bidirectional Algorithm (unicode-bidi, already in the tree via usvg): right-to-left words stay in logical order for the shaper, everything else in a right-to-left run is reversed around them and brackets are mirrored. The Layers panel's rows and the app-drawn title bar use it; the title keeps a left-to-right paragraph so '— PhotoCraft' stays at the right, and a long right-to-left name is cut at its logical end so its start survives. Display only: stored names and the OS title keep the logical text. Fixes #5159. Co-authored-by: ahmedhosnypro --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/bidi_text.rs | 203 +++++++++++++++++++++++++++++ crates/ui-egui/src/layer_row_ui.rs | 21 ++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 3 + 6 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/bidi_text.rs diff --git a/Cargo.lock b/Cargo.lock index 138ca3d814..0167dea3b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5037,6 +5037,7 @@ dependencies = [ "serde_json", "sys-locale", "toml", + "unicode-bidi", "winsafe", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 61f21885f9..e5bf497c81 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -35,6 +35,8 @@ eframe = { workspace = true, features = ["wgpu", "accesskit"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +# Display order of right-to-left labels (`bidi_text.rs`); already linked through usvg. +unicode-bidi = "0.3" base64 = "0.22" # Inflating the translation catalogs, stored deflated (build.rs); already linked through photocraft-io. flate2 = { workspace = true } diff --git a/crates/ui-egui/src/bidi_text.rs b/crates/ui-egui/src/bidi_text.rs new file mode 100644 index 0000000000..babb0d0c48 --- /dev/null +++ b/crates/ui-egui/src/bidi_text.rs @@ -0,0 +1,203 @@ +//! Right-to-left text in labels that egui draws (#5159). +//! +//! egui shapes text in runs of one font face and places the runs left to right. An Arabic or +//! Hebrew word is one run, which the shaper draws right to left, but the space between two words +//! comes from another font, so a phrase came out with every word correct and the word order +//! reversed ("THIS IS A TEXT" read as "TEXT A IS THIS"). [`visual`] puts a label into display +//! order with the Unicode Bidirectional Algorithm before egui lays it out: words of a +//! right-to-left script stay in logical order (the shaper reverses them itself), and everything +//! else in a right-to-left run is reversed around them, with brackets mirrored. +//! +//! For display only: never store the result or put it in an editable field. + +use std::borrow::Cow; + +use unicode_bidi::{BidiClass, BidiInfo, Level, bidi_class}; + +/// `text` in the order egui should lay it out from left to right. Text without right-to-left +/// characters comes back unchanged, without allocating. +pub fn visual(text: &str) -> Cow<'_, str> { + visual_at(text, None) +} + +/// [`visual`] for a line that reads left to right as a whole even when it starts with a +/// right-to-left name, such as the title bar's "*صورة.psd — PhotoCraft": with the paragraph +/// direction taken from the name, the "— PhotoCraft" tail moved to the left. +pub fn visual_ltr(text: &str) -> Cow<'_, str> { + visual_at(text, Some(Level::ltr())) +} + +/// Whether `text` reads right to left as a paragraph: its first strong letter is Hebrew, Arabic… +pub fn is_rtl_paragraph(text: &str) -> bool { + text.chars() + .find_map(|c| match bidi_class(c) { + BidiClass::L => Some(false), + BidiClass::R | BidiClass::AL => Some(true), + _ => None, + }) + .unwrap_or(false) +} + +/// `text` in display order, cut with "…" where it doesn't fit (`fits` measures a display-order +/// string). A right-to-left name is cut in logical order before it is reordered, so its *start* +/// stays and the "…" lands at the left, the end of a right-to-left line; cutting after the +/// reordering lost the start of the name instead. `None`: `text` fits as it is (or a +/// left-to-right one, which the caller elides at its right end as usual). +pub fn elide(text: &str, fits: impl Fn(&str) -> bool) -> Option { + if !is_rtl_paragraph(text) || fits(&visual(text)) { + return None; + } + // The longest logical prefix (whole characters) that fits with "…" after it. + let ends: Vec = text.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(text.len())).collect(); + let cut = |n: usize| -> String { + let end = if n == 0 { 0 } else { ends.get(n - 1).copied().unwrap_or(text.len()) }; + visual(&format!("{}…", text.get(..end).unwrap_or_default().trim_end())).into_owned() + }; + let (mut lo, mut hi) = (0usize, ends.len()); + while lo < hi { + let mid = (lo + hi).div_ceil(2); + if fits(&cut(mid)) { lo = mid } else { hi = mid - 1 } + } + Some(cut(lo)) +} + +fn visual_at(text: &str, level: Option) -> Cow<'_, str> { + if !text.chars().any(is_rtl) { + return Cow::Borrowed(text); + } + let info = BidiInfo::new(text, level); + let mut out = String::with_capacity(text.len()); + for para in &info.paragraphs { + let (levels, runs) = info.visual_runs(para, para.range.clone()); + for run in runs { + let Some(slice) = text.get(run.clone()) else { continue }; + if levels.get(run.start).is_some_and(|level| level.is_rtl()) { + push_reversed(slice, &mut out); + } else { + out.push_str(slice); + } + } + } + Cow::Owned(out) +} + +/// A strong right-to-left letter, or any character of a right-to-left script block (Arabic +/// punctuation such as "،" belongs with the word it follows: the same font shapes both). +fn is_rtl(c: char) -> bool { + matches!(bidi_class(c), BidiClass::R | BidiClass::AL) || matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}') +} + +/// Characters that stay inside a right-to-left word: its letters, combining marks, and joiners. +fn keeps_with_word(c: char) -> bool { + is_rtl(c) || matches!(bidi_class(c), BidiClass::NSM | BidiClass::BN) +} + +/// Append a right-to-left run in left-to-right display order: right-to-left words stay whole and +/// in logical order, every other character is reversed around them, and brackets are mirrored. +fn push_reversed(run: &str, out: &mut String) { + let mut units: Vec<&str> = Vec::new(); + let mut word: Option = None; + for (i, c) in run.char_indices() { + if keeps_with_word(c) { + word.get_or_insert(i); + continue; + } + if let Some(start) = word.take() + && let Some(w) = run.get(start..i) + { + units.push(w); + } + if let Some(single) = run.get(i..i + c.len_utf8()) { + units.push(single); + } + } + if let Some(start) = word + && let Some(w) = run.get(start..) + { + units.push(w); + } + for unit in units.into_iter().rev() { + let mut chars = unit.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if !keeps_with_word(c) => out.push(mirror(c)), + _ => out.push_str(unit), + } + } +} + +/// The mirrored form of a bracket shown in right-to-left text. +fn mirror(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn left_to_right_text_is_untouched_and_not_copied() { + for text in ["PhotoCraft", "Layer 1", "", "Background copy (2)", "日本語のレイヤー"] { + assert!(matches!(visual(text), Cow::Borrowed(t) if t == text), "{text}"); + } + } + + /// #5159: the reported layer name. Each word stays as typed; the word order flips so that + /// egui, placing words left to right, shows the first word at the right. + #[test] + fn arabic_words_are_placed_right_to_left() { + assert_eq!(visual("لاشي مكتوب هنا"), "هنا مكتوب لاشي"); + assert_eq!(visual("שלום עולם"), "עולם שלום"); + } + + #[test] + fn numbers_and_latin_keep_their_own_order() { + // A number inside right-to-left text reads left to right, at the start of the line. + assert_eq!(visual("طبقة 2"), "2 طبقة"); + assert_eq!(visual("طبقة 12"), "12 طبقة"); + // A left-to-right name with an Arabic word at the end is left as it is. + assert_eq!(visual("Layer 1 نص"), "Layer 1 نص"); + // Latin inside an Arabic name stays readable. + assert_eq!(visual("نص PhotoCraft هنا"), "هنا PhotoCraft نص"); + } + + /// #5385 review: the title bar's line is left to right as a whole, so a right-to-left + /// document name keeps "— PhotoCraft" after it, at the right. + #[test] + fn the_title_line_stays_left_to_right_around_a_right_to_left_name() { + assert_eq!(visual_ltr("*صورة.psd — PhotoCraft"), "*صورة.psd — PhotoCraft"); + assert_eq!(visual_ltr("لاشي مكتوب.png — PhotoCraft"), "مكتوب لاشي.png — PhotoCraft"); + assert!(matches!(visual_ltr("Untitled-1 — PhotoCraft"), Cow::Borrowed(_))); + } + + /// #5385 review: a right-to-left name that doesn't fit keeps its first words and ends with + /// "…" at the left; a left-to-right or fitting name is left to the caller. + #[test] + fn right_to_left_names_are_cut_at_their_logical_end() { + let fits = |n: usize| move |s: &str| s.chars().count() <= n; + // Cut anywhere, as egui elides: the first word and the start of the second. + assert_eq!(elide("لاشي مكتوب هنا", fits(8)).as_deref(), Some("…مك لاشي")); + assert_eq!(elide("לשלום עולם גדול", fits(11)).as_deref(), Some("…עולם לשלום")); + assert_eq!(elide("لاشي مكتوب هنا", fits(40)), None, "fits"); + assert_eq!(elide("Layer copy 12", fits(4)), None, "left to right: elided by egui at its end"); + assert_eq!(elide("نص", fits(0)).as_deref(), Some("…"), "nothing fits: just the ellipsis"); + assert!(is_rtl_paragraph("12 نص") && !is_rtl_paragraph("Layer نص") && !is_rtl_paragraph("123")); + } + + #[test] + fn brackets_mirror_and_arabic_punctuation_stays_with_its_word() { + assert_eq!(visual("نص (1)"), "(1) نص"); + assert_eq!(visual("مرحبا، عالم"), "عالم مرحبا،"); + } +} diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index 5a6e60f792..4bed6a7cc4 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -94,10 +94,23 @@ pub fn truncated(painter: &Painter, text: &str, font: FontId, color: egui::Color if max_width < 8.0 { return None; } - let mut job = egui::text::LayoutJob::default(); - job.append(text, 0.0, egui::TextFormat { font_id: font, color, italics, ..Default::default() }); - job.wrap = egui::text::TextWrapping { max_width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; - Some(painter.layout_job(job)) + let format = egui::text::TextFormat { font_id: font, color, italics, ..Default::default() }; + let layout = |s: &str, wrap: bool| { + let mut job = egui::text::LayoutJob::default(); + job.append(s, 0.0, format.clone()); + if wrap { + job.wrap = egui::text::TextWrapping { max_width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; + } + painter.layout_job(job) + }; + // A right-to-left name (Arabic, Hebrew) too long for the row is cut at its logical end and + // then put in display order, so its start stays and "…" is at the left (`bidi_text::elide`). + if let Some(shown) = crate::bidi_text::elide(text, |s| layout(s, false).size().x <= max_width) { + return Some(layout(&shown, false)); + } + // Right-to-left names in display order: egui lays words out left to right. Left-to-right + // names are cut at their right end by egui. + Some(layout(&crate::bidi_text::visual(text), true)) } /// Paint `l`'s right-hand indicators in `row` and handle the effects triangle. Returns the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e68a6d555b..4c8d64832b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,6 +25,7 @@ pub mod anchor_tools; pub mod artboard_tool; pub mod artboard_ui; pub(crate) mod background_redraw; +pub mod bidi_text; mod blend_keys; pub(crate) mod blend_preview; pub mod blur_pins; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 177ad521ba..6fc8e2d8f4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -694,6 +694,9 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if system { return; } + // A right-to-left document name in display order (the OS title keeps the logical text), + // in a left-to-right line: "— PhotoCraft" stays after the name, at the right. + let title = crate::bidi_text::visual_ltr(&title).into_owned(); let font = theme::medium(13.0); let galley = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text_dim); if let Some(x) = title_x(full.center().x, free.0, free.1, galley.size().x) { From d900daa0ba4376b59edcfab92d5b712695b1bc90 Mon Sep 17 00:00:00 2001 From: Gustavo Ziger <44277841+zi-ger@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:38:56 -0300 Subject: [PATCH 083/171] =?UTF-8?q?fix(web):=20File=20=E2=80=BA=20Place=20?= =?UTF-8?q?Embedded=20is=20enabled=20in=20the=20browser=20and=20places=20t?= =?UTF-8?q?he=20picked=20file's=20bytes=20(#1209,=20#5749)=20(#5585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a document open in the web app, Place Embedded was disabled because its menu enablement inherited the engine's native-filesystem check, although the menu path reads the picker's bytes and never touches a path. Place Embedded is now enabled whenever a document and a file-dialog service exist; Place Linked keeps the engine's native guard, since the web could only link to a bare file name (caught in #5817 by Higgs32584). An enablement test runs with the native check forced off, and a file-dialog test covers the browser answer shape, cancel, invalid bytes and the Free Transform handoff. Fixes #1209. Fixes #5749. --- Cargo.toml | 8 +++++ apps/photocraft-web/src/main.rs | 6 ++-- crates/ui-egui/src/file_dialog/tests.rs | 47 +++++++++++++++++++++++++ crates/ui-egui/src/view_cmds.rs | 10 +++++- crates/ui-egui/src/view_cmds/tests.rs | 26 ++++++++++++++ docs/development.md | 4 +-- 6 files changed, 95 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0d0f93ae7c..4716812f03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,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, with two exceptions. # Colour-management transforms are built once and cached (`transform::cached`), so "z" costs # little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2), diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 428143c880..0802a02ebf 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -6,10 +6,10 @@ //! //! Differences from the desktop app: //! - no TCP control server (browsers can't listen on sockets); -//! - File → Open uses the browser file picker; bytes arrive asynchronously through -//! `Services::inbox`; +//! - file pickers return browser file contents asynchronously through `Services::file_dialog`; +//! this includes File → Open and commands such as Place Embedded; //! - saving/exporting triggers a browser download; -//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. +//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`. //! //! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`); //! `?webgl` forces the WebGL2 backend instead of WebGPU. diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 2a325ea2ce..9e4c878f68 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -687,6 +687,53 @@ fn smart_source(app: &PhotocraftApp, id: u64) -> photocraft_doc::SmartSource { } } +#[test] +fn place_embedded_uses_the_single_file_picker_and_accepts_contents() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + assert!(menus::is_enabled(&app, "file.placeEmbedded")); + let source = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + let layers_before = app.session.active().unwrap().doc.layers.len(); + + // Cancel leaves the document alone and does not report an error. + menus::invoke(&mut app, &ctx, "file.placeEmbedded", 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!(app.session.active().unwrap().doc.layers.len(), layers_before); + assert!(!app.ui.status_error, "{}", app.ui.status); + + // The Web picker returns the selected file's name and bytes instead of a filesystem path. + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("source.png".into(), source))); + app.poll_file_dialog(&ctx, None); + assert!(!app.ui.status_error, "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers_before + 1); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert_eq!(layer.name, "source"); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert!(matches!( + &layer.content, + photocraft_doc::LayerContent::Smart(sm) + if matches!(&sm.source, photocraft_doc::SmartSource::Embedded { file_name, .. } if file_name == "source.png") + )); + + // Finish the successful placement before starting the next picker. + crate::transform_tool::commit(&mut app); + assert!(app.ui.transform.is_none()); + + // Invalid contents are reported without adding a layer. + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("broken.png".into(), b"not an image".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.status_error); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers_before + 1); +} + #[test] fn replace_contents_picks_a_file_for_every_instance() { let (mut app, open, _, ids) = smart_app(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 8acd676f95..1da1f918d2 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -459,6 +459,11 @@ pub fn handles(id: &str) -> bool { } pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { + is_enabled_with_engine(app, id, || app.session.is_enabled(id)) +} + +/// Keep picker enablement testable with the engine's native filesystem check disabled. +fn is_enabled_with_engine(app: &PhotocraftApp, id: &str, engine_enabled: impl FnOnce() -> bool) -> Option { if !handles(id) && !wraps(id) { return None; } @@ -476,7 +481,10 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { || (doc && matches!(&i["window.arrange.".len()..], "floatAllInWindows" | "cascade" | "tile" | "tileAllVertically" | "tileAllHorizontally")) } }, - i if wraps(i) => app.session.is_enabled(i), + // Embedded placement reads the picker's bytes on every platform. Linked placement keeps + // the engine's native filesystem guard because its source must remain reachable. + "file.placeEmbedded" => doc && app.services.file_dialog.is_some(), + i if wraps(i) => engine_enabled(), _ => true, }) } diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..1abe863558 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -14,6 +14,32 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option { menu_items(app).into_iter().find(|i| i.id == id).and_then(|i| i.checked) } +/// #1209: the browser picker supplies bytes even when the engine cannot read native paths. +#[test] +fn place_embedded_enablement_does_not_require_native_doc() { + for has_doc in [true, false] { + for has_picker in [true, false] { + let (mut app, _) = app_with(usize::from(has_doc)); + if has_picker { + app.services.file_dialog = Some(Box::new(|_, _, _| {})); + } + for native_filesystem in [false, true] { + let native_doc = native_filesystem && has_doc; + assert_eq!( + is_enabled_with_engine(&app, "file.placeEmbedded", || native_doc), + Some(has_doc && has_picker), + "Place Embedded: doc={has_doc}, picker={has_picker}, native_doc={native_doc}" + ); + assert_eq!( + is_enabled_with_engine(&app, "file.placeLinked", || native_doc), + Some(native_doc), + "Place Linked must retain the engine's native_doc guard" + ); + } + } + } +} + #[test] fn show_snap_extras_and_screen_modes_are_state() { let (mut app, ctx) = app_with(1); diff --git a/docs/development.md b/docs/development.md index 67f688b6b9..035efbbcf0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -399,9 +399,9 @@ HTTPS/reverse-proxy deployment and browser limitations. How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop: -- **Open** uses `rfd::AsyncFileDialog`. The bytes arrive asynchronously in `Services::inbox`, which the app drains every frame. +- **File dialogs** use `rfd::AsyncFileDialog`. Chosen file contents arrive asynchronously through `Services::file_dialog`; this includes File › Open and single-file commands such as Place Embedded. - **Save / Save As / Export** trigger a browser download of the encoded bytes. The shell does this with a Blob, an object URL and a temporary ``, all created from Rust. There is no save dialog, so the suggested name becomes the download name. -- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them. It reads each file with `DroppedFile::bytes_async` and pushes the bytes into the inbox. +- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them. It reads each file with `DroppedFile::bytes_async` and pushes the bytes into `Services::inbox`. - **Startup failures:** `index.html` shows "Loading PhotoCraft…" until the app removes it, or replaces it with the error when the web runner fails to start. If the `.wasm` never downloads or doesn't match the page's `.js` (a stale cached `index.html` after a deploy), no Rust runs at all, so the page itself (CSS only, no script) adds a hint with a Reload link after 20 seconds. - **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. `Page.setInterceptFileChooserDialog` plus `DOM.setFileInputFiles` covers Open, `Input.dispatchDragEvent` with `files` covers drops, and `Browser.setDownloadBehavior` captures downloads. - Headless Chrome on macOS (`--headless=new --enable-unsafe-webgpu`) gets a real WebGPU adapter. From 10fdcbbefcab2eda64f89d0f5ea95d4ec5b00460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=C2=B7=20VanTan?= <77483789+VanTan666@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:42:36 +0200 Subject: [PATCH 084/171] chore: fix workspace Clippy collapsible_match warnings (#5190) Strict workspace Clippy stopped on collapsible_match in affinity, raw, plugins, engine, ui-egui and xtask. Pure conditions become guarded match arms (each falling through to an existing empty arm), and the ruler drag state and the measurement command result are bound to locals, preserving evaluation order, error messages and fallbacks. The same lint fixes also appear as a side commit in #5157 (David2766), which can drop it on rebase. --- crates/affinity/src/preview.rs | 7 ++----- crates/engine/src/color_to_alpha_cmds.rs | 5 ++--- crates/plugins/src/manifest.rs | 6 ++---- crates/raw/src/cr3.rs | 6 +----- crates/ui-egui/src/analysis_ui.rs | 6 ++++-- crates/ui-egui/src/canvas.rs | 14 ++++++-------- xtask/src/layers.rs | 7 ++----- 7 files changed, 19 insertions(+), 32 deletions(-) diff --git a/crates/affinity/src/preview.rs b/crates/affinity/src/preview.rs index f50cb8c3e9..d3341eeb1c 100644 --- a/crates/affinity/src/preview.rs +++ b/crates/affinity/src/preview.rs @@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result, Error> { } image_data = true; } - b"IEND" => { - if !image_data { - return Err(Error::Malformed("PNG has no image data")); - } - } + b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")), + b"IEND" => {} _ if kind.first().is_some_and(u8::is_ascii_uppercase) => { return Err(Error::Unsupported("unknown critical PNG chunk")); } diff --git a/crates/engine/src/color_to_alpha_cmds.rs b/crates/engine/src/color_to_alpha_cmds.rs index a6208e8246..34356a3bfb 100644 --- a/crates/engine/src/color_to_alpha_cmds.rs +++ b/crates/engine/src/color_to_alpha_cmds.rs @@ -64,10 +64,9 @@ fn validate(p: &Value) -> Result<()> { Some(Value::String(s)) => { 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()) && a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) => {} 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")); - } + return Err(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/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index ccabf47d55..52994bc740 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -122,10 +122,8 @@ impl Manifest { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); } match spec { - ParamSpec::Number { min, max, default } => { - if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max { - return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); - } + ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => { + return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); } ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))), ParamSpec::Choice { options, default } => { diff --git a/crates/raw/src/cr3.rs b/crates/raw/src/cr3.rs index bb2119e7d4..04e7e2ec5f 100644 --- a/crates/raw/src/cr3.rs +++ b/crates/raw/src/cr3.rs @@ -141,11 +141,7 @@ fn walk<'a>(raw: &'a [u8], b: &'a [u8], depth: usize, budget: &mut usize, found: } boxes(b, budget, |item, budget| match item.kind { b"moov" => walk(raw, item.body, depth + 1, budget, found), - b"trak" => { - if found.previews.len() < 128 { - found.previews.extend(track_preview(raw, item.body, budget, &found.media)); - } - } + b"trak" if found.previews.len() < 128 => found.previews.extend(track_preview(raw, item.body, budget, &found.media)), b"uuid" => { if item.body.get(..16) == Some(META_UUID) && let Some(children) = item.body.get(16..) diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index 09aaf17670..527d58eda0 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -281,7 +281,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> set_ruler(app, r); } (Tool::Ruler, ToolEvent::Up { .. }) => { - if app.ui.analysis.drag.take() == Some(Drag::RulerNew) && doc.measurement.ruler.is_some_and(|r| dist(r.start, r.end) < 0.5) { + let drag = app.ui.analysis.drag.take(); + if drag == Some(Drag::RulerNew) && doc.measurement.ruler.is_some_and(|r| dist(r.start, r.end) < 0.5) { let _ = app.run("image.analysis.rulerTool", json!({"clear": true})); } } @@ -743,7 +744,8 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { } } Some((id, p)) => { - if app.run(id, p).is_ok() { + let result = app.run(id, p); + if result.is_ok() { app.ui.analysis.log_selected.clear(); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad2ddafa55..38c3b439b5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5846,14 +5846,12 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { let _ = app.run("select.deselect", json!({})); } } - Tool::Gradient => { - if (end[0] - d.start[0]).abs() + (end[1] - d.start[1]).abs() >= 2.0 { - let o = app.ui.tool_options.clone(); - let _ = app.run( - "paint.gradient", - json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "opacity": o.fill_opacity, "mode": o.gradient_blend_mode.label(), "target": paint_target(app)}), - ); - } + Tool::Gradient if (end[0] - d.start[0]).abs() + (end[1] - d.start[1]).abs() >= 2.0 => { + let o = app.ui.tool_options.clone(); + let _ = app.run( + "paint.gradient", + json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "opacity": o.fill_opacity, "mode": o.gradient_blend_mode.label(), "target": paint_target(app)}), + ); } Tool::Move => { let (dx, dy) = ((end[0] - d.start[0]).round(), (end[1] - d.start[1]).round()); diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index ce34e8c25a..3d162dd458 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -200,11 +200,8 @@ pub fn check(crates: &[Crate]) -> Vec { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } - } + Some(Class::Testkit) if d.kind != DepKind::Dev => out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }), + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { From 132257912a0159ab5d8cbde2d9a6b901f472acb9 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 16 Oct 2026 17:52:24 -0700 Subject: [PATCH 085/171] README: put Community packages after the Get started status note (follow-up to #1149) (#6886) The Community packages heading from #1149 landed before the maintainers' releasing note and the IMPORTANT status admonition, so those two paragraphs rendered under Community packages instead of Get started. Move the section to just above Documentation; the content is unchanged. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2ab84f644b..973fb5330d 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,11 @@ tar -xzf photocraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr photocraft ``` +Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. + +> [!IMPORTANT] +> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft). + ## Community packages These installers are maintained by community members, not by the PhotoCraft team. @@ -308,11 +313,6 @@ echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/p emerge --ask media-gfx/photocraft-bin ``` -Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. - -> [!IMPORTANT] -> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft). - ## Documentation Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/). From a05daee325a15833c8d6368b92df8673e2ab980b Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:14:50 +0200 Subject: [PATCH 086/171] Filters: the live preview is never coarser than the screen (#6326) (#6351) On a document over about 5 MP with Low Resolution Previews on, a filter dialog's live preview ran on a reduced proxy at every zoom, so at 100 % the canvas showed the image in k-pixel blocks and hid exactly what Unsharp Mask, High Pass and Smart Blur do. ensure_filter_preview now caps its factor with proxy::view_factor(k, zoom), the same rule adjustment previews got in #5764, so from 100 % the preview is full resolution and stays reduced when zoomed out. The preview already runs on a cancellable background worker, so the full-resolution path does not block the UI thread. Also in this change: - canvas.rs and filter_preview_ui.rs share one canvas::filter_preview_factor, so the in-dialog preview window cannot size textures from an uncapped factor and rebuild them when the first result arrives. - The cap is gated on cfg!(target_arch = "wasm32"), so the web build keeps its proxy rather than running a full-resolution filter inline on the UI thread. - The end-to-end test moved into mod tests and covers Low Resolution Previews off; a new factor test covers 25/50/75/100/400 %, NaN and zero zoom. --- crates/ui-egui/src/canvas.rs | 77 +++++++++++++++++++++++- crates/ui-egui/src/filter_preview_ui.rs | 7 ++- crates/ui-egui/tests/full_res_preview.rs | 19 ++++-- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 38c3b439b5..914d395acc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1720,6 +1720,27 @@ fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) { (u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20)) } +/// The proxy factor of a live preview of filter `cmd` with `params` on `doc`, the active document: +/// reduced on a large document (Preferences › Performance › Low Resolution Previews), except for +/// filters whose features don't scale with a proxy (#2063), and never coarser than the active view, +/// so at 100 % the preview shows every pixel instead of k-pixel blocks (#6326, as adjustment +/// previews have since #5764). Zoomed out, the proxy keeps previews of large documents fast. +/// +/// The in-dialog preview window ([`crate::filter_preview_ui`]) sizes its own textures from this +/// too, so the canvas and the window never disagree about the factor. +/// +/// Full resolution costs a whole filter per parameter change, which only the background worker +/// absorbs. The web build has no worker and computes the preview inline on the UI thread (#1676), +/// where that would freeze the page: there the document's proxy stands. The test and +/// `PHOTOCRAFT_INLINE_JOBS` inline paths keep the cap deliberately, because `background_jobs` is +/// flipped mid-session (a shell save during `actions.play`): a factor that followed it would change +/// [`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))); + if cfg!(target_arch = "wasm32") { k } else { crate::proxy::view_factor(k, app.current_zoom()) } +} + /// Live preview for an open filter dialog: run the filter on the proxy and upload it. With /// background jobs (the desktop app) it computes on one background worker /// (`filter_preview_worker`); otherwise (web, tests, `PHOTOCRAFT_INLINE_JOBS`) it keeps the @@ -1751,8 +1772,7 @@ 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::preview_factor(&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, revision: st.revision, @@ -6108,6 +6128,59 @@ mod tests { assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); } + /// #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 + /// actually requests is the one under test. + #[test] + fn filter_preview_at_100_percent_is_full_resolution() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 2400, "height": 2200})).unwrap(); + assert_eq!(crate::proxy::preview_factor(&app.session.active().unwrap().doc, true), 2, "big enough for a proxy"); + crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap(); + let ctx = egui::Context::default(); + let preview_at = |app: &mut PhotocraftApp, zoom: f32| { + app.ui.views.get_mut(0).expect("the new document's view").zoom = zoom; + assert_eq!(app.current_zoom(), zoom); + let (k, _, size) = ensure_filter_preview(app, 0, &ctx).expect("a preview"); + (k, size) + }; + assert_eq!(preview_at(&mut app, 1.0), (1, [2400, 2200]), "100 %: every pixel"); + assert_eq!(preview_at(&mut app, 0.25), (2, [1200, 1100]), "zoomed out: the document's proxy"); + // Low Resolution Previews off: full resolution at any zoom, exactly as before #6326. + app.run("prefs.set", json!({"path": "performance.lowResolutionPreviews", "value": false})).unwrap(); + assert_eq!(preview_at(&mut app, 0.25), (1, [2400, 2200]), "the preference still decides"); + } + + /// The factor itself over the zoom range, and that Preferences › Performance › Low + /// Resolution Previews still decides whether there is a proxy at all (#6326). + #[test] + fn filter_preview_factor_caps_at_the_view_without_overriding_the_preference() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // 9 MP: reduced previews halve it (empty tiles, so nothing is allocated). + app.run("file.new", json!({"width": 3000, "height": 3000, "background": "transparent"})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let params = json!({"amount": 100, "radius": 4.0, "threshold": 0}); + let k = |app: &mut PhotocraftApp, zoom: f32| { + app.ui.views.get_mut(0).expect("the new document's view").zoom = zoom; + super::filter_preview_factor(app, &doc, "filter.sharpen.unsharpMask", ¶ms) + }; + assert_eq!(k(&mut app, 0.25), 2, "zoomed out the preview stays reduced"); + assert_eq!(k(&mut app, 0.5), 2); + assert_eq!(k(&mut app, 0.75), 1, "past the proxy's resolution it shows every pixel"); + assert_eq!(k(&mut app, 1.0), 1); + assert_eq!(k(&mut app, 4.0), 1); + // A corrupt or unset zoom must not pick a coarser proxy than the document's own. + assert_eq!(k(&mut app, f32::NAN), 1); + assert_eq!(k(&mut app, 0.0), 1); + // Low Resolution Previews off: full resolution at any zoom, the cap a no-op. + app.run("prefs.set", json!({"path": "performance.lowResolutionPreviews", "value": false})).unwrap(); + for zoom in [0.1, 0.25, 1.0, 4.0] { + assert_eq!(k(&mut app, zoom), 1, "zoom {zoom}"); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn every_filter_previews_off_the_ui_thread_and_a_newer_value_cancels_the_old() { diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 90eaf4f6fe..cf290cf190 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -46,9 +46,14 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu (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() }); // The proxy factor the preview runs at (the filtered key's when it exists, else the request's). + // The fallback must be the factor the canvas will ask for, not the document's own: since #6326 + // the canvas caps it with the view's zoom, and a window that sized its textures from the + // uncapped factor built a proxy the canvas never asked for, then rebuilt it (a full-size copy + // and upload on the UI thread at 100 %) the moment the first result arrived, shifting this + // window's 100 % mapping and pan clamp for a frame. let base_k = filtered.as_ref().map(|(k, _)| k.k).unwrap_or_else(|| { let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(f)); - crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))) + crate::canvas::filter_preview_factor(app, &st.doc, &cmd, ¶ms) }); let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/full_res_preview.rs b/crates/ui-egui/tests/full_res_preview.rs index 191a071b9d..f472dd2ad3 100644 --- a/crates/ui-egui/tests/full_res_preview.rs +++ b/crates/ui-egui/tests/full_res_preview.rs @@ -145,21 +145,30 @@ fn adjustment_layer_drag_previews_at_full_resolution_when_low_res_is_off() { assert!((full - committed).abs() < 8.0, "low-res previews off: the drag matches the committed view ({full} vs {committed})"); } +/// A filter dialog's live preview is never coarser than the screen either (#6326): at 100 % every +/// column shows with low-resolution previews on; zoomed out to 50 % the default preview runs on +/// the reduced copy (only the black columns), and with them off it matches the committed view. #[test] fn filter_dialog_previews_at_full_resolution_when_low_res_is_off() { let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; - setup(&mut h, 1.0); + setup(&mut h, 0.5); if !on_gpu(&mut h) { return; } + let committed = mean(&row(&mut h)); + assert!(committed > 60.0, "zoomed out the columns average to grey ({committed})"); + control(&mut h, "ui.set", json!({"zoom": 1.0, "center": [W / 2, H / 2]})); // Offset 0, 0 leaves the pixels as they are: any change is the preview's resolution. photocraft_ui_egui::filter_dialog::open(h.state_mut(), "filter.other.offset").expect("Offset dialog"); - let reduced = row(&mut h); - assert!(columns(&reduced) < 0.1, "default: the reduced copy loses the columns ({})", columns(&reduced)); + let at_100 = row(&mut h); + assert!(columns(&at_100) > 0.9, "default at 100 %: the preview is never coarser than the screen ({})", columns(&at_100)); + control(&mut h, "ui.set", json!({"zoom": 0.5, "center": [W / 2, H / 2]})); + let reduced = mean(&row(&mut h)); + assert!(reduced < 30.0, "default at 50 %: the reduced copy keeps only the black columns ({reduced})"); low_res(&mut h, false); - let full = row(&mut h); - assert!(columns(&full) > 0.9, "low-res previews off: every column shows ({})", columns(&full)); + let full = mean(&row(&mut h)); + assert!((full - committed).abs() < 8.0, "low-res previews off: the preview matches the committed view ({full} vs {committed})"); } #[test] From 4e5c6a018ac54a14ae4443377ee132f179036ea4 Mon Sep 17 00:00:00 2001 From: Ahmed Magdy <110637215+ahmed0magdy@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:19:47 +0300 Subject: [PATCH 087/171] fix(io): SVG text placed by a transform keeps it on import (#4806) (#5334) usvg builds the glyph outlines of a text element (Text::flattened) with identity absolute transforms, and the SVG importer read each path's abs_transform(). Text positioned by its own or an ancestor's transform, as Illustrator writes every text element (matrix(1 0 0 1 x y)), lost it: it landed off the canvas, so nothing showed, or at its untransformed place and size. The converter now composes each node's transform from its parent chain, which equals abs_transform() outside text, and sizes raster fallbacks from the same transform. A new test checks translate, matrix, scale and transformed groups against resvg's own render; it fails on main. Co-authored-by: Claude Opus 5.5 --- crates/io/src/svg.rs | 38 ++++++++++++++++++++++--------- crates/io/tests/svg.rs | 51 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04fc4bee3a..72b2318675 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -337,6 +337,11 @@ impl Conv { /// `nodes` in paint order (first = bottom), as layers in the same order. `parent` is the /// absolute transform of the group holding them (what resvg needs to render one of them /// alone, since a node's own data is in its parent's space). + /// + /// Transforms are composed from `parent` down rather than read from `abs_transform()`: the + /// glyph outlines usvg builds for a text element (`Text::flattened`) keep identity absolute + /// transforms, so text placed by its own or an ancestor's `transform` (Illustrator writes + /// `matrix(1 0 0 1 x y)` on every text element) would lose it (#4806). Elsewhere the two agree. fn convert_children(&mut self, nodes: &[usvg::Node], depth: usize, rec: usize, parent: tiny_skia::Transform) -> Vec { let mut out = Vec::new(); for node in nodes { @@ -350,7 +355,7 @@ impl Conv { usvg::Node::Group(g) => { // A clip-path of one plain path becomes the group's vector mask (#4266); others // are rasterised with the group. - let clip = g.clip_path().and_then(|cp| self.clip_mask(g, cp)); + let clip = g.clip_path().and_then(|cp| self.clip_mask(parent.pre_concat(g.transform()), cp)); let dropped: [(bool, &'static str); 3] = [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some() && clip.is_none(), "clip-path"), (g.mask().is_some(), "mask")]; let too_deep = self.max_group_depth.is_some_and(|limit| depth >= limit) || depth >= MAX_GROUP_DEPTH || rec >= MAX_RECURSION; @@ -370,7 +375,8 @@ impl Conv { // A group that only carries a transform (baked into its paths by usvg) or that // usvg inserted for its own bookkeeping adds nothing to the layer tree. let plain = clip.is_none() && g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; - let children = self.convert_children(g.children(), if plain { depth } else { depth + 1 }, rec + 1, g.abs_transform()); + let abs = parent.pre_concat(g.transform()); + let children = self.convert_children(g.children(), if plain { depth } else { depth + 1 }, rec + 1, abs); if children.is_empty() { return; } @@ -404,7 +410,9 @@ impl Conv { out.extend(self.raster_layer(node, name, parent)); } usvg::Node::Text(t) => { - let children = self.convert_children(t.flattened().children(), depth + 1, rec + 1, t.flattened().abs_transform()); + // The outlines are in the text element's own space. + let abs = t.abs_transform().pre_concat(t.flattened().transform()); + let children = self.convert_children(t.flattened().children(), depth + 1, rec + 1, abs); if children.is_empty() { self.note("text", "SVG: text needs a font the drawing can use; none matched, so it was dropped".to_string()); return; @@ -421,10 +429,10 @@ impl Conv { } } - /// The vector mask for group `g` clipped by `cp`, when the clip is one visible path with no - /// clip of its own: what resvg draws for the clip (the group's transform, then the clip's, - /// then the path), in canvas pixels, with the clip rule as its fill rule. - fn clip_mask(&self, g: &usvg::Group, cp: &usvg::ClipPath) -> Option { + /// The vector mask for a group clipped by `cp`, when the clip is one visible path with no + /// clip of its own: what resvg draws for the clip (the group's absolute transform `abs`, then + /// the clip's, then the path), in canvas pixels, with the clip rule as its fill rule. + fn clip_mask(&self, abs: tiny_skia::Transform, cp: &usvg::ClipPath) -> Option { if cp.clip_path().is_some() { return None; } @@ -432,7 +440,7 @@ impl Conv { if !p.is_visible() { return None; } - let ts = g.abs_transform().pre_concat(cp.transform()).post_concat(self.global); + let ts = abs.pre_concat(cp.transform()).post_concat(self.global); let subpaths = knots(&(*p.data()).clone().transform(ts)?); if subpaths.is_empty() { return None; @@ -455,7 +463,8 @@ impl Conv { let name = self.name(p.id(), 0); return self.raster_layer(node, name, parent); } - let ts = p.abs_transform().post_concat(self.global); + // A path has no transform of its own: its data is in its parent's space. + let ts = parent.post_concat(self.global); let data = (*p.data()).clone().transform(ts)?; let subpaths = knots(&data); if subpaths.is_empty() { @@ -542,7 +551,14 @@ impl Conv { /// The node rendered by resvg into a pixel layer over its bounding box, clipped to the canvas. /// `parent` is the absolute transform of the node's parent group. fn raster_layer(&mut self, node: &usvg::Node, name: String, parent: tiny_skia::Transform) -> Option { - let bb = node.abs_layer_bounding_box()?; + // Where the node lands, from its box in its parent's space (as usvg computes its absolute + // box, but through `parent`, which is right inside text too). + let bb = match node { + usvg::Node::Group(g) => g.layer_bounding_box().transform(parent.pre_concat(g.transform()))?, + _ => node.bounding_box().to_non_zero_rect()?.transform(parent)?, + }; + // What resvg shifts the node by before drawing it. + let shift = node.abs_layer_bounding_box()?; let k = self.global.sx; let x0 = ((bb.x() * k).floor() as i32).max(self.canvas.x0); let y0 = ((bb.y() * k).floor() as i32).max(self.canvas.y0); @@ -559,7 +575,7 @@ impl Conv { // to the origin: undo that shift, apply the parent's transform, then scale to the canvas // and shift so the pixmap starts at (x0, y0). let canvas = tiny_skia::Transform::from_row(k, 0.0, 0.0, k, -(x0 as f32), -(y0 as f32)); - let ts = canvas.pre_concat(parent).pre_translate(bb.x(), bb.y()); + let ts = canvas.pre_concat(parent).pre_translate(shift.x(), shift.y()); resvg::render_node(node, ts, &mut pixmap.as_mut())?; Some(Layer::new(name, LayerContent::Raster(surface(&pixmap, Rect::new(x0, y0, x1, y1), self.fmt)))) } diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index d1baf418fa..4f21bd5dcb 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -221,6 +221,57 @@ 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 d213273c744cd34787866e1b76d6bf5700353f2f Mon Sep 17 00:00:00 2001 From: Matthew Santiago Date: Fri, 16 Oct 2026 20:19:53 -0500 Subject: [PATCH 088/171] GPU composite: lower-stack cache chunks have no texture view (#5423) The prefix cache's chunk textures are only copied to and from (COPY_SRC | COPY_DST), but went through Tex::new, which always creates a default view. Vulkan rejects a view on an image with no sampled, storage or attachment usage (VUID-VkImageViewCreateInfo-image-04441); wgpu doesn't check it, so it only shows with the validation layers on, once per new chunk while painting. The view was never used. Chunks now hold a bare wgpu::Texture. No rendering change. Co-authored-by: Claude Opus 5.5 --- crates/gpu/src/prefix.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/gpu/src/prefix.rs b/crates/gpu/src/prefix.rs index 34c70916e0..cf53a62850 100644 --- a/crates/gpu/src/prefix.rs +++ b/crates/gpu/src/prefix.rs @@ -18,15 +18,16 @@ use std::collections::HashMap; use photocraft_doc::{Document, Layer}; use photocraft_geom::Rect; -use crate::Tex; use crate::plan::{Checkpoint, Plan}; pub(crate) const MAX_BYTES: u64 = 512 << 20; /// One whole grid chunk, with the document rectangles that hold valid pixels: a whole-chunk /// capture covers the cell in one entry, partial captures accumulate alongside each other. +/// The texture is only copied to and from, so it has no view: Vulkan rejects a view on an image +/// without a sampled/storage/attachment usage (VUID-VkImageViewCreateInfo-image-04441). pub(crate) struct Chunk { - tex: Tex, + tex: wgpu::Texture, covered: Vec, /// The checkpoint the covered pixels hold. checkpoint: Checkpoint, @@ -172,7 +173,7 @@ impl Prefix { let Some((chunk, (ox, oy))) = self.covered_by(rect) else { return false }; encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: &chunk.tex.texture, + texture: &chunk.tex, mip_level: 0, origin: wgpu::Origin3d { x: ox.unsigned_abs(), y: oy.unsigned_abs(), z: 0 }, aspect: wgpu::TextureAspect::All, @@ -198,8 +199,16 @@ impl Prefix { if cell_bytes == 0 || cell_bytes > self.budget.saturating_sub(self.bytes) { return; } - let tex = - Tex::new(device, "pc_compose_prefix", cell.width(), cell.height(), src.format(), wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST); + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_compose_prefix"), + size: wgpu::Extent3d { width: cell.width().max(1), height: cell.height().max(1), depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: src.format(), + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); self.bytes = self.bytes.saturating_add(cell_bytes); self.chunks.insert(cell, Chunk { tex, covered: Vec::new(), checkpoint, bytes: cell_bytes }); } @@ -221,7 +230,7 @@ impl Prefix { encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: src, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, wgpu::TexelCopyTextureInfo { - texture: &chunk.tex.texture, + texture: &chunk.tex, mip_level: 0, origin: wgpu::Origin3d { x: ox.unsigned_abs(), y: oy.unsigned_abs(), z: 0 }, aspect: wgpu::TextureAspect::All, From a1e19a0b878c1bb4bc0609e1bd7db56f3688512c Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 04:19:59 +0300 Subject: [PATCH 089/171] fix(text): break paragraph lines at the horizontally scaled width (#5611) Horizontal scale was applied after line breaking, so paragraph text below 100% wrapped early and text above 100% overflowed the box. Break box text at the available width divided by the paragraph's horizontal scale (the largest one when runs mix), scale the first-line indent and alignment offset back to the box, and keep the scaled trailing space out of the line extent. Fixes storytold/photocraft#5565 Co-authored-by: Cursor --- crates/text/src/layout.rs | 48 +++++++++++++++++++++++++++++++++------ crates/text/src/tests.rs | 38 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 19d7b8c72d..ae1bd552af 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -442,6 +442,8 @@ struct KernSlot { blank: bool, /// Advance along the line (px, horizontal scale included). advance: f32, + /// What horizontal scale adds to the shaped advance (px; negative below 100%). + growth: f32, /// A tab, shaped as a space; it advances to the next tab stop. tab: bool, /// Vertical type, upright glyph (its outline doesn't run along the column). @@ -646,11 +648,29 @@ impl Layouter { // Box extent along the lines: width, or height for vertical type (columns). let (line_origin, line_len) = if vertical { (box_rect.1, box_rect.3) } else { (box_rect.0, box_rect.2) }; let avail = if is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None }; + // Horizontal scale is applied after shaping, so paragraph text breaks at the box + // width divided by it (#5565). Mixed scales break at the largest, so no line + // overflows the box. + let break_scale = if is_box { + let si0 = style_at(prange.start); + (0..out.styles.len()) + .filter(|&ri| { + let rs = run_starts.get(ri).copied().unwrap_or(0); + let end = rs.saturating_add(runs.get(ri).map_or(0, |r| r.len)); + ri == si0 || (rs.max(prange.start) < end.min(content_end)) + }) + .filter_map(|ri| out.styles.get(ri)) + .map(effective_horizontal_scale) + .fold(0.0f32, f32::max) + } else { + 1.0 + }; + let break_scale = if break_scale > 0.0 { break_scale } else { 1.0 }; let break_lines = |layout: &mut Layout| { if first_indent != 0.0 { - layout.set_text_indent(first_indent, IndentOptions::default()); + layout.set_text_indent(first_indent / break_scale, IndentOptions::default()); } - layout.break_all_lines(avail); + layout.break_all_lines(avail.map(|a| a / break_scale)); }; // Paragraph text: a tab is never wider than a space plus one tab interval, so breaking // lines at that width keeps the expanded tabs inside the box, but can wrap early. @@ -779,6 +799,7 @@ impl Layouter { blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= prefix.len(), upright: vertical && c.first_style().brush.1 != VClass::Rotate as u8, advance: c.advance() * hs, + growth: c.advance() * (hs - 1.0), tab: tabs.binary_search(&c.text_range().start).is_ok(), }); } @@ -823,10 +844,15 @@ impl Layouter { // moves the whole line). let mut pos = indent_start + if li == 0 { first_indent } else { 0.0 }; let mut trailing_tabs = 0.0f32; + let mut trailing_growth = 0.0f32; for (j, s) in slots.iter().enumerate() { + // Trailing whitespace is outside the line's extent (`adv`). + let trailing = s.blank && slots[j + 1..].iter().all(|n| n.blank); + if trailing { + trailing_growth += s.growth; + } if s.tab { kern_px[j] = next_tab_stop(pos, tab_px) - pos - s.advance; - // Trailing whitespace is outside the line's extent (`adv`). if slots[j + 1..].iter().all(|n| n.blank) { trailing_tabs += kern_px[j]; } @@ -864,15 +890,18 @@ impl Layouter { }; let dx = if is_box { let base = line_origin + indent_start - kern_align; - let slack = avail.unwrap_or(0.0) - adv - line_kern; + let slack = avail.unwrap_or(0.0) - adv * break_scale - line_kern; + // Parley aligned the unscaled line in `avail / break_scale`: scale its + // indent and alignment offset back to the box. + let scaled_offset = m.offset * (break_scale - 1.0); base + if last_line { match ps.align { TextAlign::JustifyCenter => slack * 0.5 - m.offset, TextAlign::JustifyRight => slack - m.offset, - _ => 0.0, + _ => scaled_offset, } } else { - 0.0 + scaled_offset } } else { let target = match ps.align { @@ -1037,7 +1066,7 @@ impl Layouter { range: map(lr.start)..map(lr.end).min(content_end), baseline, x0, - x1: x0 + adv + extra - trailing_tabs, + x1: x0 + adv + extra - trailing_tabs - trailing_growth, ascent, descent, paragraph: pi, @@ -1049,6 +1078,11 @@ impl Layouter { } } +/// A style's horizontal scale; unset (zero or negative) is 100%. +fn effective_horizontal_scale(st: &CharStyle) -> f32 { + if st.horizontal_scale > 0.0 && st.horizontal_scale.is_finite() { st.horizontal_scale } else { 1.0 } +} + /// The first tab stop after `pos` (px from the anchor or box edge); stops are `interval` apart. fn next_tab_stop(pos: f32, interval: f32) -> f32 { if interval > 0.0 && pos.is_finite() { ((pos + 1e-3) / interval).floor() * interval + interval } else { pos } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 7afb4d6cd1..894ecf4e4f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -272,6 +272,44 @@ fn box_text_wraps_inside_width() { assert_eq!(l2.lines.len(), 2); } +/// #5565: paragraph text breaks at its horizontally scaled width. At 60% it breaks like 100% +/// text in a box 1/0.6 as wide, and the lines fill the box instead of wrapping early. +#[test] +fn horizontal_scale_breaks_paragraph_lines_at_the_scaled_width() { + let mut e = TextEngine::new(); + let text = "The quick brown fox jumps over the lazy dog again and again until the paragraph wraps"; + let boxed = |scale: f32, width: f32, align: TextAlign| { + let style = CharStyle { font_family: "Inter".into(), size_pt: 12.0, horizontal_scale: scale, ..Default::default() }; + let t = TextLayer { shape: TextShape::Box { x: 10.0, y: 0.0, width, height: 1000.0 }, ..styled(text, style) }; + with_para(t, ParagraphStyle { align, ..Default::default() }) + }; + let ranges = |l: &crate::TextLayout| l.lines.iter().map(|ln| ln.range.clone()).collect::>(); + for scale in [0.6f32, 1.5] { + let scaled = e.layout(&boxed(scale, 120.0, TextAlign::Left), 72.0); + let plain = e.layout(&boxed(1.0, 120.0 / scale, TextAlign::Left), 72.0); + assert!(scaled.lines.len() > 1, "{scale}"); + assert_eq!(ranges(&scaled), ranges(&plain), "{scale}: same breaks as unscaled text in the widened box"); + for (li, ln) in scaled.lines.iter().enumerate() { + assert!(ln.x0 >= 10.0 - 1e-3 && ln.x1 <= 130.0 + 1e-3, "{scale}: {ln:?}"); + // The extent ends at the last glyph, not after the scaled trailing space. + let ink = scaled.clusters.iter().filter(|c| c.line == li && !text[c.range.clone()].trim().is_empty()); + assert!((ink.map(|c| c.x + c.advance).fold(0.0, f32::max) - ln.x1).abs() < 0.01, "{scale}: {ln:?}"); + } + } + // At 60% the lines use the box: the first would not fit at 100%. + let scaled = e.layout(&boxed(0.6, 120.0, TextAlign::Left), 72.0); + assert!(scaled.lines[0].x1 > 110.0, "{:?}", scaled.lines[0]); + // Centred and right-aligned lines are placed by their scaled width. + let c = e.layout(&boxed(0.6, 120.0, TextAlign::Center), 72.0); + for ln in &c.lines { + assert!(((ln.x0 + ln.x1) * 0.5 - 70.0).abs() < 0.5, "{ln:?}"); + } + let r = e.layout(&boxed(0.6, 120.0, TextAlign::Right), 72.0); + for ln in &r.lines { + assert!((ln.x1 - 130.0).abs() < 0.5, "{ln:?}"); + } +} + #[test] fn alignment_point_and_box() { let mut e = TextEngine::new(); From c61cf9f0bd668ded8308e3c802d5439e7d62e02f Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 04:20:06 +0300 Subject: [PATCH 090/171] fix(engine): load each persisted preset set independently (#5616) One malformed set in the preferences file (gradients, swatches, styles...) made load_presets_json ignore the whole presets blob, so saved New Document presets vanished at every launch: the shell merges back only changed keys, leaving the damaged set on disk. Each set now loads on its own; null and malformed sets keep their defaults. Refs storytold/photocraft#5602 Co-authored-by: Cursor --- crates/engine/src/presets/mod.rs | 24 +++++++++++++++++++++--- crates/engine/src/presets/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 6c722c62ef..03696291dd 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -168,10 +168,28 @@ impl PresetState { } impl Session { - /// Restore presets saved by [`PresetState::to_json`]. Missing sets keep their defaults; a - /// malformed blob is ignored (presets are never worth refusing to start over). + /// Restore presets saved by [`PresetState::to_json`]. Missing or malformed sets keep their + /// defaults (presets are never worth refusing to start over). Each set loads on its own: the + /// shell writes back only the keys that changed, so one damaged set would otherwise discard + /// every other set, saved document presets included, at each launch (#5602). pub fn load_presets_json(&mut self, v: Value) { - let Ok(p) = serde_json::from_value::(v) else { return }; + let Value::Object(mut m) = v else { return }; + fn take(m: &mut serde_json::Map, key: &str) -> Option { + m.remove(key).filter(|v| !v.is_null()).and_then(|v| serde_json::from_value(v).ok()) + } + let p = Persisted { + gradients: take(&mut m, "gradients"), + gradient: take(&mut m, "gradient"), + styles: take(&mut m, "styles"), + shapes: take(&mut m, "shapes"), + pattern_groups: take(&mut m, "pattern_groups"), + tool_presets: take(&mut m, "tool_presets"), + documents: take(&mut m, "documents"), + recent_documents: take(&mut m, "recent_documents"), + custom_shapes: take(&mut m, "custom_shapes"), + layer_defaults: take(&mut m, "layer_defaults"), + swatches: take(&mut m, "swatches"), + }; let st = &mut self.presets; if let Some(g) = p.gradients { st.gradients = g; diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index dcfc4e94d8..b7c0a3ede2 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -748,3 +748,29 @@ fn clone_source_errors_and_overlay() { assert_eq!(v["width"], json!(1000.0)); assert_eq!(v["rotation"], json!(-90.0)); } + +// ------------------------------------------------------------------ persistence + +/// #5602: one damaged preset set in the preferences file must not discard the saved New +/// Document presets (or any other set) at the next launch. +#[test] +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 saved = s.presets.to_json(&s); + for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "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.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`"); + } + // 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()})); + 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); +} From 75978a72f04ec9b9cefcfcd5ac365d36d74cf8b9 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:15:32 +0200 Subject: [PATCH 091/171] Targa: RLE packets end with their scanline (#6382) (#6386) The image crate's Targa encoder let run-length packets run on from the end of one row into the next. TGA 2.0 says a packet must not cross a scanline, and readers that decode a row at a time misread such files: Pillow fails on them with "buffer overrun", and texture tools and game engines reject them. Targa is now written by our own encoder (codecs::tga), as Photoshop writes it: RLE true colour or grayscale, every packet within its row, rows stored bottom-up (the origin readers that ignore the descriptor assume), the alpha bits in the descriptor and the TGA 2.0 footer. Reading stays with the image crate. --- crates/codecs/README.md | 2 +- crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/codecs/tga.rs | 90 ++++++++++++++++++++++++++ crates/codecs/src/codecs/via_image.rs | 7 ++- crates/codecs/tests/tga.rs | 91 +++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 crates/codecs/src/codecs/tga.rs create mode 100644 crates/codecs/tests/tga.rs diff --git a/crates/codecs/README.md b/crates/codecs/README.md index d4f7094894..46baf50f82 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -38,7 +38,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | WebP | yes | yes | U8 | RGB, RGBA | yes | yes | yes | yes | no | no | yes (VP8, when `webp_lossless` is off) | `image-webp` / built-in VP8 | | GIF | yes | yes | U8 | RGBA | 1-bit | no | no | no | no | no | yes (256-colour palette) | `image` | | BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | -| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` | +| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) | | ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` | | DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in | diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index afb7f96791..32e68b336a 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod jpeg; pub(crate) mod pcx; pub(crate) mod png; pub(crate) mod pnm; +pub(crate) mod tga; pub(crate) mod tiff; pub(crate) mod tiff_exif; pub(crate) mod tiff_ifd; diff --git a/crates/codecs/src/codecs/tga.rs b/crates/codecs/src/codecs/tga.rs new file mode 100644 index 0000000000..7b52533d7b --- /dev/null +++ b/crates/codecs/src/codecs/tga.rs @@ -0,0 +1,90 @@ +//! Targa writer. The `image` crate still reads Targa ([`super::via_image`]); writing is ours +//! because its run-length packets ran on from the end of one row into the next. TGA 2.0 says a +//! packet must not cross a scanline, and readers that decode a row at a time (Pillow, texture +//! converters, game engines) misread such files (#6382). +//! +//! Like Photoshop's Targa: RLE (types 10 and 11), rows stored bottom-up (the bottom-left origin +//! every reader assumes, also those that ignore the origin bits), alpha bits in the descriptor, +//! and the TGA 2.0 footer. + +use crate::Format; +use crate::error::CodecError; +use crate::image::{ChannelLayout, Image, SampleType}; + +/// Most pixels one packet holds (a 7-bit count, plus one). +const MAX_PACKET: usize = 128; + +/// The TGA 2.0 footer: no extension or developer area, then the signature. +const FOOTER: &[u8; 26] = b"\0\0\0\0\0\0\0\0TRUEVISION-XFILE.\0"; + +/// Encodes an 8-bit Gray, Gray+alpha, RGB or RGBA image as a run-length encoded Targa. +pub(crate) fn encode(img: &Image) -> Result, CodecError> { + let (w, h) = img.dimensions(); + if img.sample_type() != SampleType::U8 { + return Err(CodecError::encode(Format::Tga, format!("{:?} samples (Targa stores 8 bits)", img.sample_type()))); + } + let (bpp, image_type, alpha_bits) = match img.layout() { + ChannelLayout::Gray => (1, 11, 0), + ChannelLayout::GrayA => (2, 11, 8), + ChannelLayout::Rgb => (3, 10, 0), + ChannelLayout::Rgba => (4, 10, 8), + l => return Err(CodecError::encode(Format::Tga, format!("no Targa layout for {l:?}"))), + }; + let (Ok(w16), Ok(h16)) = (u16::try_from(w), u16::try_from(h)) else { + return Err(CodecError::encode(Format::Tga, format!("dimensions exceed 65535x65535: {w}x{h}"))); + }; + let row = w as usize * bpp; + let data = img.data(); + if data.len() != row * h as usize { + return Err(CodecError::InvalidImage("buffer size mismatch".into())); + } + let mut out = Vec::with_capacity(18 + data.len() + data.len() / MAX_PACKET + FOOTER.len()); + out.extend_from_slice(&[0, 0, image_type, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + out.extend_from_slice(&w16.to_le_bytes()); + out.extend_from_slice(&h16.to_le_bytes()); + // Bits per pixel; the descriptor holds the alpha bits, origin bits 0 (bottom-left). + out.extend_from_slice(&[(bpp * 8) as u8, alpha_bits]); + let mut px = vec![0u8; row]; + for y in (0..h as usize).rev() { + px.copy_from_slice(&data[y * row..(y + 1) * row]); + // Targa stores colour as BGR. + if bpp >= 3 { + for p in px.chunks_exact_mut(bpp) { + p.swap(0, 2); + } + } + rle_row(&px, bpp, &mut out); + } + out.extend_from_slice(FOOTER); + Ok(out) +} + +/// One scanline as run-length packets that end with it: a run of two or more equal pixels is a +/// run packet, anything else goes out in raw packets. +fn rle_row(row: &[u8], bpp: usize, out: &mut Vec) { + let px: Vec<&[u8]> = row.chunks_exact(bpp).collect(); + let n = px.len(); + let mut i = 0; + while i < n { + let mut run = 1; + while i + run < n && run < MAX_PACKET && px[i + run] == px[i] { + run += 1; + } + if run >= 2 { + out.push(0x80 | (run - 1) as u8); + out.extend_from_slice(px[i]); + i += run; + continue; + } + // Raw until two equal pixels start a run, the packet is full or the row ends. The first + // pixel always goes in: it differs from the one after it, or it is the row's last. + let start = i; + while i < n && i - start < MAX_PACKET && !(i > start && i + 1 < n && px[i] == px[i + 1]) { + i += 1; + } + out.push((i - start - 1) as u8); + for p in &px[start..i] { + out.extend_from_slice(p); + } + } +} diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 7e7eb76b2f..fd45f6a173 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -1,5 +1,6 @@ //! Formats delegated to the `image` crate (pure-Rust features only): -//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). +//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by +//! [`super::tga`] and only read here. use std::io::Cursor; @@ -163,6 +164,10 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) - return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}"))); } let img = src.converted(plan.layout, plan.sample); + // Targa is written by our encoder: the image crate's RLE packets cross scanlines (#6382). + if f == Format::Tga { + return super::tga::encode(&img); + } let dynimg = to_dynamic(&img)?; let mut cursor = Cursor::new(Vec::new()); match f { diff --git a/crates/codecs/tests/tga.rs b/crates/codecs/tests/tga.rs new file mode 100644 index 0000000000..279aa47f8b --- /dev/null +++ b/crates/codecs/tests/tga.rs @@ -0,0 +1,91 @@ +//! Targa writing (#6382): run-length packets end with their scanline, rows are stored bottom-up, +//! and the file reads back exactly. + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, decode_as, encode}; + +/// The packets of a run-length encoded Targa body, as (pixels, is a run), from just after the +/// 18-byte header (no image ID or colour map) up to `pixels` pixels. +fn packets(file: &[u8], bpp: usize, pixels: usize) -> Vec<(usize, bool)> { + let mut at = 18; + let mut seen = 0; + let mut out = Vec::new(); + while seen < pixels { + let head = file[at]; + let count = usize::from(head & 0x7f) + 1; + let run = head & 0x80 != 0; + at += 1 + if run { bpp } else { count * bpp }; + seen += count; + out.push((count, run)); + } + out +} + +/// A `w`×`h` image whose rows mix long runs (wider than a packet), short ones and noise, and +/// whose last pixel equals the next row's first, so a packet would run on if it could. +fn sample(w: u32, h: u32, layout: ChannelLayout) -> Image { + let n = layout.channels(); + let mut data = Vec::new(); + for y in 0..h { + for x in 0..w { + let v = match x { + _ if x < 150 => 40, + _ if x % 3 == 0 => (x * 7 + y * 13) as u8, + _ => 200, + }; + let px = [v, v.wrapping_add(y as u8), v ^ 0x5a, 255 - v]; + data.extend_from_slice(&px[..n]); + } + } + Image::from_u8(w, h, layout, data).unwrap() +} + +#[test] +fn rle_packets_end_with_their_scanline() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + let (w, h) = (300u32, 4u32); + let file = encode(&sample(w, h, layout), Format::Tga, &EncodeOptions::default()).unwrap(); + let bpp = layout.channels(); + let mut col = 0; + for (count, _) in packets(&file, bpp, (w * h) as usize) { + assert!(count <= 128, "{layout:?}: a packet holds at most 128 pixels"); + assert!(col + count <= w as usize, "{layout:?}: a packet runs from column {col} past the row's end"); + col = (col + count) % w as usize; + } + // A solid image is one run per row (three packets of 128, 128 and 44), not one long run. + let solid = Image::from_u8(w, h, layout, vec![90; (w * h) as usize * bpp]).unwrap(); + let file = encode(&solid, Format::Tga, &EncodeOptions::default()).unwrap(); + let p = packets(&file, bpp, (w * h) as usize); + assert_eq!(p.len(), 3 * h as usize, "{layout:?}"); + assert!(p.iter().all(|&(_, run)| run)); + } +} + +#[test] +fn the_file_is_bottom_up_rle_with_its_alpha_bits_and_the_tga2_footer() { + let mut data = vec![0u8; 2 * 2 * 4]; + // Top row red, bottom row blue, opaque. + for (i, px) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() { + *px = if i < 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }; + } + let file = encode(&Image::from_u8(2, 2, ChannelLayout::Rgba, data).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap(); + assert_eq!(file[2], 10, "run-length true colour"); + assert_eq!((file[16], file[17]), (32, 8), "32 bits per pixel, 8 alpha bits, bottom-left origin"); + // The first stored row is the bottom one: a run of two blue pixels, in BGRA. + assert_eq!(&file[18..23], &[0x81, 255, 0, 0, 255]); + assert!(file.ends_with(b"TRUEVISION-XFILE.\0")); + let gray = encode(&Image::from_u8(2, 1, ChannelLayout::Gray, vec![1, 2]).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap(); + assert_eq!((gray[2], gray[16], gray[17]), (11, 8, 0), "run-length grayscale, no alpha bits"); +} + +#[test] +fn every_layout_reads_back_exactly() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for (w, h) in [(1, 1), (300, 4), (129, 3), (5, 130)] { + let img = sample(w, h, layout); + let file = encode(&img, Format::Tga, &EncodeOptions::default()).unwrap(); + let back = decode_as(Format::Tga, &file).unwrap(); + assert_eq!((back.dimensions(), back.layout()), ((w, h), layout), "{layout:?} {w}x{h}"); + assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}"); + } + } +} From 5bd428a23983ec9a3dedfffc38ede18a28424b86 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:16:45 -0400 Subject: [PATCH 092/171] =?UTF-8?q?perf(stack):=20Stack=20Mode=2011=C3=97?= =?UTF-8?q?=20faster=20(#6462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer › Smart Objects › Stack Mode (`algo::stack::combine`) was one serial per-pixel loop (1.0x parallelism, identical at 1 and 16 threads), and Entropy built a HashMap per channel per pixel. - Output pixels are computed in parallel in 4096-pixel chunks (`par_rows`, so wasm keeps its serial path). Each chunk owns its scratch buffers. - Each pixel uses exactly the same arithmetic in the same order as before, so every mode except Entropy is bit-identical: 110 output hashes (10 modes x 11 cases: 3 x 24 MP, 7 layers of odd/mismatched sizes, transparency, HDR values, 1 layer, no layers) match the old code. - Entropy: a `[u32; 256]` histogram plus a short list of the bins touched replaces the HashMap; touched bins are summed in ascending order, so the result is now deterministic. The old HashMap summed in random iteration order and differed run to run (up to 1.2e-7 between two runs of the old code). New vs old output: max abs difference 1.19e-7 (one f32 ulp near 1.0), on up to ~10% of values in the 7-layer cases; the 3-layer 24 MP case is identical. - Modes that never use the mean (Median, Min, Max, Range, Summation, Entropy) no longer compute it; Median uses an unstable sort (same result under total_cmp). 3 x 24 MP, release, median of 5 interleaved before/after runs (load average ~3.5-4.5 from an unrelated build running alongside), ms: | Mode | before | after (16 thr) | speedup | after (1 thr) | |------------|-------:|---------------:|--------:|--------------:| | Median | 1100 | 99 | 11.1x | 1064 | | Mean | 308 | 36 | 8.6x | 338 | | StdDev | 342 | 41 | 8.3x | 380 | | Entropy | 4727 | 269 | 17.5x | 2730 | | Kurtosis | 458 | 55 | 8.3x | 488 | | Maximum | 368 | 36 | 10.1x | 346 | The other modes land between 8x and 10x. Max RSS for Median is unchanged (1.50 GB, of which 1.13 GB is the input layers). Tests: hard-coded bit patterns recorded from the old code for every mode but Entropy; Entropy against hand-computed values and run-to-run determinism; and a chunk-boundary test comparing a multi-chunk image to per-pixel results. Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu Co-authored-by: Claude Opus 5.5 --- crates/algo/src/stack.rs | 387 ++++++++++++++++++++++++++++++++++----- 1 file changed, 338 insertions(+), 49 deletions(-) diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 2eec57e598..aa7ff9bfa1 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -23,24 +23,83 @@ pub enum Stat { Variance, } -fn stat(v: &mut [f32], s: Stat) -> f32 { +/// Reusable per-chunk scratch for [`Stat::Entropy`]: an 8-bit histogram plus the list of bins the +/// current pixel touched. Allocated once per parallel chunk instead of one `HashMap` per channel +/// per pixel (the old cost: ~4.6 s for 3 × 24 MP). +struct EntropyScratch { + counts: [u32; 256], + touched: Vec, +} + +impl EntropyScratch { + fn new() -> Self { + Self { counts: [0; 256], touched: Vec::new() } + } +} + +/// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`. +fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { + let n = v.len() as f32; + for x in v { + let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8; + let c = &mut scratch.counts[usize::from(bin)]; + if *c == 0 { + scratch.touched.push(bin); + } + *c += 1; + } + // Sum the bins in ascending order. The old HashMap summed in its (randomly seeded) iteration + // order, and float addition is not associative, so the old result could differ in the last + // bit from run to run; a fixed order makes it deterministic. Sorting only the few touched + // bins keeps this O(samples), not O(256), per pixel. + scratch.touched.sort_unstable(); + let h: f32 = scratch + .touched + .iter() + .map(|&bin| { + let p = scratch.counts[usize::from(bin)] as f32 / n; + -p * p.log2() + }) + .sum(); + // Reset only the bins used, so the 256-entry array is never cleared wholesale. + for &bin in &scratch.touched { + scratch.counts[usize::from(bin)] = 0; + } + scratch.touched.clear(); + h / n.log2() +} + +fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { let n = v.len() as f32; if v.is_empty() { return 0.0; } + // Modes that need neither the mean nor the moments return before computing them (the old code + // computed the mean for every mode and threw it away; skipping it changes no result). + match s { + Stat::Summation => return v.iter().sum::(), + Stat::Minimum => return v.iter().copied().fold(f32::MAX, f32::min), + Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max), + Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min), + Stat::Median => { + // `total_cmp` is a total order whose ties are bit-identical values, so an unstable + // sort gives exactly the stable sort's result. + v.sort_unstable_by(f32::total_cmp); + let k = v.len(); + return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }; + } + Stat::Entropy => { + if v.len() < 2 { + return 0.0; + } + return entropy(v, scratch); + } + _ => {} + } let mean = v.iter().sum::() / n; let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::() / n; match s { Stat::Mean => mean, - Stat::Summation => v.iter().sum::(), - Stat::Minimum => v.iter().copied().fold(f32::MAX, f32::min), - Stat::Maximum => v.iter().copied().fold(f32::MIN, f32::max), - Stat::Range => v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min), - Stat::Median => { - v.sort_by(f32::total_cmp); - let k = v.len(); - if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 } - } Stat::Variance => m(2), Stat::StandardDeviation => m(2).sqrt(), Stat::Skewness => { @@ -53,55 +112,55 @@ fn stat(v: &mut [f32], s: Stat) -> f32 { // 0..10 → 0..1. if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) } } - Stat::Entropy => { - if v.len() < 2 { - return 0.0; - } - let mut counts: std::collections::HashMap = std::collections::HashMap::new(); - for x in v.iter() { - *counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1; - } - let h: f32 = counts - .values() - .map(|&c| { - let p = c as f32 / n; - -p * p.log2() - }) - .sum(); - h / n.log2() - } + // Handled above; a no-op keeps the match exhaustive without a panic path. + Stat::Summation | Stat::Minimum | Stat::Maximum | Stat::Range | Stat::Median | Stat::Entropy => 0.0, } } +/// Output pixels per parallel task: big enough to amortise the per-task scratch buffers and +/// rayon's scheduling, small enough (6000 tasks for 24 MP) to balance across many cores. +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]> { let len = layers.iter().map(Vec::len).max().unwrap_or(0); let mut out = vec![[0.0f32; 4]; len]; - let mut samples: [Vec; 3] = Default::default(); - for (i, o) in out.iter_mut().enumerate() { - for v in samples.iter_mut() { - v.clear(); - } - 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]); - } - alpha = alpha.max(p[3]); + // Every output pixel depends only on the input pixels at the same index, so chunks of the + // output are independent: `par_rows` over a `CHUNK`-wide "row" with one channel (it runs + // serially on wasm). The per-pixel arithmetic is unchanged, hence the result is bit-identical + // to the serial loop for every mode (Entropy: see [`entropy`]). + 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())); + 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 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]); + } + alpha = alpha.max(p[3]); + } + } + if alpha <= 0.0 { + continue; + } + for c in 0..3 { + o[c] = stat(&mut samples[c], s, &mut scratch); + } + o[3] = alpha; } - if alpha <= 0.0 { - continue; - } - for c in 0..3 { - o[c] = stat(&mut samples[c], s); - } - o[3] = alpha; - } + }); out } @@ -168,4 +227,234 @@ mod tests { let layers = vec![px(0.5), px(0.5), px(1.0)]; assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5); } + + /// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent, + /// fully transparent, HDR / negative, single-sample and tied-value pixels. + fn fixture() -> Vec> { + vec![ + vec![[0.10, 0.20, 0.30, 1.0], [0.5, 0.5, 0.5, 1.0], [0.9, 0.9, 0.9, 0.0], [-2.0, 4.0, 0.0, 1.0], [0.7, 0.2, 0.1, 0.5], [0.25, 0.25, 0.25, 1.0]], + vec![[0.90, 0.60, 0.30, 1.0], [0.0, 0.0, 0.0, 0.0], [0.8, 0.8, 0.8, 0.0], [3.0, -1.0, 0.5, 0.75], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.25, 1.0]], + vec![[0.20, 0.90, 0.30, 0.5], [0.1, 0.2, 0.3, 1.0], [0.7, 0.7, 0.7, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.5, 0.75, 0.25, 0.25]], + vec![[0.30, 0.05, 0.30, 0.25], [0.3, 0.4, 0.5, 0.5], [0.6, 0.6, 0.6, 0.0], [1.5, 1.5, 1.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.5, 1.0]], + vec![[0.10, 0.70, 0.99, 1.0], [0.9, 0.1, 0.2, 1.0], [0.5, 0.5, 0.5, 0.0], [0.0, 0.0, 0.0, 1.0]], + ] + } + + fn bits(px: &[[f32; 4]]) -> Vec<[u32; 4]> { + px.iter().map(|p| p.map(f32::to_bits)).collect() + } + + /// Every mode except Entropy is bit-for-bit what the old serial loop produced. The expected + /// bit patterns were recorded by running the unmodified `combine` on [`fixture`]. + #[test] + fn statistics_are_bit_identical_to_the_serial_implementation() { + let layers = fixture(); + let expected: Vec<(Stat, Vec<[u32; 4]>)> = vec![ + ( + Stat::Kurtosis, + [ + [0x3e96956b, 0x3e182e3f, 0x3ea66664, 0x3f800000], + [0x3e3d004c, 0x3e0b4396, 0x3e030e1f, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3e594eb2, 0x3e6ef420, 0x3e7ffffe, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x3f000000], + [0x3e6eeeee, 0x3e6eeeee, 0x3e6eeeee, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Maximum, + [ + [0x3f666666, 0x3f666666, 0x3f7d70a4, 0x3f800000], + [0x3f666666, 0x3f000000, 0x3f000000, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x40400000, 0x40800000, 0x3fc00000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3f000000, 0x3f400000, 0x3f000000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Mean, + [ + [0x3ea3d70a, 0x3efae148, 0x3ee0418a, 0x3f800000], + [0x3ee66666, 0x3e99999a, 0x3ec00000, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3f19999a, 0x3f800000, 0x3f000000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3ea00000, 0x3f200000, 0x3ea00000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Median, + [ + [0x3e4ccccd, 0x3f19999a, 0x3e99999a, 0x3f800000], + [0x3ecccccd, 0x3e99999a, 0x3ecccccd, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3f000000, 0x3f000000, 0x3f000000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3e800000, 0x3f400000, 0x3e800000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Minimum, + [ + [0x3dcccccd, 0x3d4ccccd, 0x3e99999a, 0x3f800000], + [0x3dcccccd, 0x3dcccccd, 0x3e4ccccd, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0xc0000000, 0xbf800000, 0x00000000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3e800000, 0x3e800000, 0x3e800000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Range, + [ + [0x3f4ccccc, 0x3f599999, 0x3f30a3d7, 0x3f800000], + [0x3f4ccccc, 0x3ecccccd, 0x3e99999a, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x40a00000, 0x40a00000, 0x3fc00000, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x3f000000], + [0x3e800000, 0x3f000000, 0x3e800000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Skewness, + [ + [0x3f522026, 0x3ee7cf76, 0x3f600000, 0x3f800000], + [0x3f1bd152, 0x3efffffe, 0x3ee4a11b, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3eedb65a, 0x3f2ea754, 0x3f3a6c7a, 0x3f800000], + [0x3f000000, 0x3f000000, 0x3f000000, 0x3f000000], + [0x3f49e69e, 0x3e58658a, 0x3f49e69e, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::StandardDeviation, + [ + [0x3e99421e, 0x3ea23b6b, 0x3e8d4fdf, 0x3f800000], + [0x3e97739e, 0x3e21e89b, 0x3e050581, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3fd3e0b1, 0x3fd9f9e5, 0x3f0c378c, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x3f000000], + [0x3dddb3d7, 0x3e5db3d7, 0x3dddb3d7, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Summation, + [ + [0x3fcccccd, 0x401ccccd, 0x400c28f6, 0x3f800000], + [0x3fe66666, 0x3f99999a, 0x3fc00000, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x40400000, 0x40a00000, 0x40200000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3fa00000, 0x40200000, 0x3fa00000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Variance, + [ + [0x3db78033, 0x3dcd9e83, 0x3d9c022a, 0x3f800000], + [0x3db33332, 0x3ccccccd, 0x3c8a3d70, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x402f5c29, 0x4039999a, 0x3e99999a, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x3f000000], + [0x3c400000, 0x3d400000, 0x3c400000, 0x3f800000], + ] + .to_vec(), + ), + ]; + for (mode, want) in expected { + assert_eq!(bits(&combine(&layers, mode)), want, "{mode:?}"); + } + } + + /// Entropy against values worked out by hand (bits of the 8-bit histogram, over log2(N)). + #[test] + fn entropy_matches_hand_computed_values() { + let out = combine(&fixture(), Stat::Entropy); + let h = |ps: &[f64]| -> f64 { + let n: f64 = ps.iter().sum(); + ps.iter().map(|c| -(c / n) * (c / n).log2()).sum::() / n.log2() + }; + // Pixel 1: four distinct values -> 2 bits / log2(4) = exactly 1. + assert_eq!(out[1][0], 1.0); + // Pixel 2: every sample transparent -> untouched. + assert_eq!(out[2], [0.0; 4]); + // Pixel 3, red: -2 and 0 land in bin 0, 3 and 1.5 clamp into bin 255, 0.5 is alone. + assert!((f64::from(out[3][0]) - h(&[2.0, 2.0, 1.0])).abs() < 1e-6, "{}", out[3][0]); + // Pixel 4: a single contributing sample has no entropy. + assert_eq!(out[4], [0.0, 0.0, 0.0, 0.5]); + // Pixel 5: three samples share a bin, one is alone, in every channel. + for (c, v) in out[5].iter().take(3).enumerate() { + assert!((f64::from(*v) - h(&[3.0, 1.0])).abs() < 1e-6, "channel {c}: {v}"); + } + } + + /// The old HashMap summed in a random order, so Entropy could differ by an ulp between runs. + /// Now the bins are summed in ascending order: repeated runs (and any thread count) agree. + #[test] + fn entropy_is_deterministic() { + let layers: Vec> = (0..7) + .map(|k| { + (0..3 * CHUNK + 17).map(|i| [((i * 7 + k * 13) % 11) as f32 / 10.0, ((i + k) % 5) as f32 / 4.0, ((i * k) % 9) as f32 / 8.0, 1.0]).collect() + }) + .collect(); + let first = bits(&combine(&layers, Stat::Entropy)); + for _ in 0..8 { + assert_eq!(bits(&combine(&layers, Stat::Entropy)), first); + } + } + + /// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal + /// length) gives, pixel by pixel, what combining that pixel on its own gives. + #[test] + fn chunked_result_equals_per_pixel_result() { + let len = 2 * CHUNK + 5; + let layer = |k: usize, n: usize| -> Vec<[f32; 4]> { + (0..n) + .map(|i| { + [ + ((i * 31 + k * 7) % 101) as f32 / 100.0, + ((i * 17 + k) % 53) as f32 / 52.0 - 0.2, + ((i + 3 * k) % 29) as f32 / 28.0, + if (i + k).is_multiple_of(4) { 0.0 } else { 0.5 + ((i + k) % 3) as f32 / 4.0 }, + ] + }) + .collect() + }; + let layers = vec![layer(1, len), layer(2, CHUNK + 3), layer(3, len - 1), layer(4, 0), layer(5, len)]; + for mode in [ + Stat::Entropy, + Stat::Kurtosis, + Stat::Maximum, + Stat::Mean, + Stat::Median, + Stat::Minimum, + Stat::Range, + Stat::Skewness, + Stat::StandardDeviation, + Stat::Summation, + Stat::Variance, + ] { + let whole = combine(&layers, mode); + assert_eq!(whole.len(), len); + for i in (0..len).step_by(97).chain([CHUNK - 1, CHUNK, CHUNK + 1, 2 * CHUNK - 1, 2 * CHUNK, len - 1]) { + let one: Vec> = layers.iter().map(|l| l.get(i).map(|p| vec![*p]).unwrap_or_default()).collect(); + assert_eq!(whole[i].map(f32::to_bits), combine(&one, mode)[0].map(f32::to_bits), "{mode:?} pixel {i}"); + } + } + } + + #[test] + fn no_layers_gives_an_empty_result() { + assert!(combine(&[], Stat::Median).is_empty()); + } } From 3d2914368d38aa8e5c336415241ceadc88f5ab00 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 04:17:27 +0200 Subject: [PATCH 093/171] PSD type: clamp warp bend and distortion before the f32 cast (#6856) (#6857) A TySh warpValue, warpPerspective or warpPerspectiveOther beyond f32 became inf and the warp was silently dropped; they are now clamped to -100..100 like type.warpText. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 0092fcf92e..8c502c75af 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -351,11 +351,14 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { if let Some(w) = &t.warp { let style = enum_value(w, "warpStyle").unwrap_or_else(|| "warpNone".into()); if style != "warpNone" { + // Bend and distortion are percentages in -100..100 (as `type.warpText` clamps them); + // clamping before the cast keeps a value beyond f32 from becoming inf. + let pct = |k: &str| dnum(w.get(k)).unwrap_or(0.0).clamp(-100.0, 100.0) as f32; layer.warp = Some(TextWarp { style, - value: dnum(w.get("warpValue")).unwrap_or(0.0) as f32, - horizontal_distortion: dnum(w.get("warpPerspective")).unwrap_or(0.0) as f32, - vertical_distortion: dnum(w.get("warpPerspectiveOther")).unwrap_or(0.0) as f32, + value: pct("warpValue"), + horizontal_distortion: pct("warpPerspective"), + vertical_distortion: pct("warpPerspectiveOther"), horizontal: enum_value(w, "warpRotate").as_deref() != Some("Vrtc"), }); } @@ -1362,6 +1365,19 @@ mod nonfinite_tests { assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); } + #[test] + fn warp_values_beyond_f32_clamp_to_the_warp_range() { + let warp = Descriptor::new("warp") + .with("warpStyle", enumv("warpStyle", "warpArc")) + .with("warpValue", D::Double(1e39)) + .with("warpPerspective", D::Double(-1e39)) + .with("warpPerspectiveOther", D::Double(250.0)) + .with("warpRotate", enumv("Ornt", "Hrzn")); + let tysh = write_tysh(&TySh { transform: Affine::IDENTITY, text: Descriptor::new("TxLr"), warp: Some(warp), bounds: [0.0; 4] }); + let w = text_layer_from_tysh(&tysh, 72.0).and_then(|l| l.warp).unwrap(); + assert_eq!((w.value, w.horizontal_distortion, w.vertical_distortion), (100.0, -100.0, 100.0)); + } + #[test] fn infinite_dpi_does_not_write_a_zero_size() { let layer = From 20013ef9bd8040f09b6a646241333502cd4a22f1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:18:17 +0200 Subject: [PATCH 094/171] Linked smart objects: don't refresh a document while a background job runs on it (#4973) (#5074) --- crates/engine/src/link_watch.rs | 60 ++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 5c235fb919..dbbfec2be7 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -16,6 +16,9 @@ //! keeps waiting for Update Modified Content, as in Photoshop); one that can't be read (another app //! is still writing it) is tried again once it changes again, not on every look. Only smart objects //! of the document itself follow their files, not ones nested in another smart object's contents. +//! While a background job runs on the document, its files aren't looked at: an update would replace +//! the snapshot the job works on and its result would be thrown away. The first look after the job +//! ends catches up. use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex, PoisonError}; @@ -103,6 +106,12 @@ impl Session { if self.active().map(|d| d.doc.id.0) != Some(doc) { return Ok(json!({"updated": []})); } + // A background job holds the document: refreshing now would replace the snapshot it works + // on and its result would be discarded. The stamps stay as they were, so the change is + // seen again by the first look after the job ends. + if self.active_job().is_some() { + return Ok(json!({"updated": []})); + } // This document's last stamps; those of closed documents are dropped. let open: HashSet = self.documents().iter().map(|d| d.doc.id.0).collect(); let mut before: HashMap = HashMap::new(); @@ -155,7 +164,7 @@ mod tests { use photocraft_geom::Rect; use serde_json::json; - use crate::Session; + use crate::{EngineError, Session}; fn dir(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("photocraft-link-watch-{name}-{}", std::process::id())); @@ -280,4 +289,53 @@ mod tests { assert!(is(centre(&s), BLUE)); assert!(s.link_scan.is_none()); } + + #[test] + fn a_refresh_waits_for_a_running_job_on_the_document() { + let d = dir("job"); + let path = d.join("art.png"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, id) = placed(&path); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + let (release, job) = gated_job(&mut s); + save(&path, &png(10, 10, "#0000ff")); + let snapshot = s.active().unwrap().doc.clone(); + // The path the desktop app's timer takes: it doesn't go through the command conflict check. + assert_eq!(super::update_changed(&mut s, &json!({})).unwrap(), json!({"updated": []})); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &snapshot), "the job's snapshot must stay in place"); + release.send(()).unwrap(); + s.wait_job(job).unwrap(); + // The job finished; the change that came meanwhile is picked up exactly once. + assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": [id]})); + assert!(is(centre(&s), BLUE)); + assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []})); + } + + #[test] + fn a_job_on_another_document_does_not_hold_up_a_refresh() { + let d = dir("job-other"); + let path = d.join("art.png"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, id) = placed(&path); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + s.execute("file.new", json!({"width": 10, "height": 10})).unwrap(); + let (release, job) = gated_job(&mut s); + s.set_active(0); + save(&path, &png(10, 10, "#0000ff")); + assert_eq!(super::update_changed(&mut s, &json!({})).unwrap(), json!({"updated": [id]})); + assert!(is(centre(&s), BLUE)); + release.send(()).unwrap(); + s.wait_job(job).unwrap(); + } + + /// A background job that locks the active document until something is sent on the returned + /// sender. + fn gated_job(s: &mut Session) -> (std::sync::mpsc::Sender<()>, crate::jobs::JobId) { + let (release, gate) = std::sync::mpsc::channel::<()>(); + let r = s.start_job("test.gated", json!({}), "Gated job", true, move |_| gate.recv().map_err(|_| EngineError::Cancelled), |_, ()| Ok(json!(null))); + match r.unwrap() { + crate::jobs::Started::Job(j) => (release, j), + other => panic!("expected a background job, got {other:?}"), + } + } } From fbfc72ceb59f14ef0610e8335d230fc4cc26f392 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:18:47 +0200 Subject: [PATCH 095/171] Close All, Close Others and Quit ask again about documents dirtied during the prompt (#4954) (#5079) --- crates/ui-egui/src/discard_ui.rs | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 6720015458..3dbf33c905 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -157,6 +157,25 @@ fn advance(app: &mut PhotocraftApp, ctx: &egui::Context) { if next.is_some() { return; } + // A save or close hook may have dirtied a document that was clean when the prompt opened (or + // made a new one): ask about those before the final close, which discards without checking. + if one_by_one && let Some(p) = app.discard.as_ref() { + let (id, target) = (p.id.clone(), p.target); + let rest: Vec = app + .session + .documents() + .iter() + .filter(|d| d.is_dirty() && (matches!(reach(&id), Some(Reach::All)) || Some(d.doc.id) != target)) + .map(|d| d.doc.id) + .collect(); + if !rest.is_empty() { + show_tab(app, rest.first().copied()); + if let Some(p) = app.discard.as_mut() { + p.docs = rest; + } + return; + } + } let Some(Prompt { id, mut params, target, .. }) = app.discard.take() else { return }; if id == EXIT { app.allow_close = true; @@ -495,6 +514,38 @@ mod tests { assert!(app.session.documents().is_empty()); } + /// A document that became dirty while the prompt was up is asked about before the final close (#4954). + #[test] + fn a_document_dirtied_during_the_prompt_is_asked_about_before_the_final_close() { + for command in ["file.closeAll", EXIT] { + let mut app = app_with_docs(2); + let ctx = egui::Context::default(); + make_dirty(&mut app, 0); + let late = doc_id(&app, 1); + crate::menus::invoke(&mut app, &ctx, command, json!({})).unwrap(); + make_dirty(&mut app, 1); + advance(&mut app, &ctx); + assert_eq!(app.discard.as_ref().map(|p| p.docs.clone()), Some(vec![late]), "{command}: asks about the newly dirty document"); + assert!(!app.allow_close, "{command}"); + assert_eq!(app.session.documents().len(), 1, "{command}: it is not discarded unasked"); + advance(&mut app, &ctx); + assert!(app.discard.is_none(), "{command}"); + } + // Close Others asks about the others too, but still not about the document it keeps. + let mut app = app_with_docs(3); + let ctx = egui::Context::default(); + make_dirty(&mut app, 0); + let (late, kept) = (doc_id(&app, 1), doc_id(&app, 2)); + crate::menus::invoke(&mut app, &ctx, "file.closeOthers", json!({"document": 2})).unwrap(); + make_dirty(&mut app, 1); + make_dirty(&mut app, 2); + advance(&mut app, &ctx); + assert_eq!(app.discard.as_ref().map(|p| p.docs.clone()), Some(vec![late]), "file.closeOthers: asks about the newly dirty other document"); + advance(&mut app, &ctx); + assert!(app.discard.is_none()); + assert_eq!(app.session.documents().iter().map(|d| d.doc.id).collect::>(), [kept]); + } + #[test] fn close_others_closes_each_answered_document_and_keeps_its_target() { let mut app = app_with_docs(3); From 4c881326ea6f0f4ab77722cd1048ff69c38b51ff Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:19:06 +0300 Subject: [PATCH 096/171] fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648) (#6412) * fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648) * test(windows): cover the local recovery folder and moving old autosaves (#5648) --- apps/photocraft/src/app_dirs.rs | 670 ++++++++++++++++++++++++++++++++ apps/photocraft/src/services.rs | 8 +- docs/control-protocol.md | 4 +- packaging/windows/portable.txt | 8 +- 4 files changed, 682 insertions(+), 8 deletions(-) diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 2897bebdb0..5775e254df 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -9,6 +9,9 @@ //! folder can't be written (read-only medium, Program Files), the app warns and falls back. //! 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`]). use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -60,6 +63,379 @@ pub fn config_dir() -> Option { current().dir.clone() } +/// The crash-recovery folder's name. +const RECOVERY: &str = "Recovery"; + +/// Where crash-recovery autosaves live, with any left in the settings directory by an earlier +/// version moved there first ([`adopt_legacy_recovery`]). Call it once, at startup. +pub fn recovery_dir() -> Option { + let data = current(); + let dir = resolve_recovery(data, |k| std::env::var_os(k))?; + Some(match data.dir.as_ref().map(|d| d.join(RECOVERY)) { + Some(legacy) => adopt_legacy_recovery(&legacy, &dir), + None => dir, + }) +} + +/// 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. +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)); + } + data.dir.as_ref().map(|d| d.join(RECOVERY)) +} + +/// The lock file, beside the recovery folder, that keeps two PhotoCraft processes starting at +/// once (`--new-instance`, control launches) from moving the same autosaves. +const MIGRATION_LOCK: &str = "recovery-migration.lock"; + +/// How long a launch waits for another one's move before going ahead without moving anything. +const MIGRATION_WAIT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Suffix of a copy staged for publishing ([`stage_copy`]): disposable, the original still exists. +const STAGED: &str = ".moving"; + +/// Suffix of an original set aside while it is copied to another volume ([`move_across`]): it +/// may be the only copy, so it is put back, never thrown away, if the move doesn't finish. +const HELD: &str = ".held"; + +/// Move autosaves from `legacy` (the recovery folder of versions before #5648) into `dir`, so a +/// crash just before an update is still offered for recovery. Returns the folder this process +/// uses for its recovery store. That is `legacy` only when `dir` doesn't exist and no process can +/// be moving autosaves (the move failed, or its lock can't be made), so nothing deletes what is +/// written there. A launch that finds another one moving uses `dir`, which the move never +/// replaces or removes. See [`adopt_locked`] for how they move. +pub fn adopt_legacy_recovery(legacy: &Path, dir: &Path) -> PathBuf { + adopt_legacy_recovery_waiting(legacy, dir, MIGRATION_WAIT) +} + +/// [`adopt_legacy_recovery`], waiting up to `wait` for another process's move. +fn adopt_legacy_recovery_waiting(legacy: &Path, dir: &Path, wait: std::time::Duration) -> PathBuf { + let held_aside = || legacy.parent().is_some_and(|p| !side_entries(p, legacy, HELD).is_empty()); + if legacy == dir || (!legacy.is_dir() && !held_aside()) { + return dir.to_path_buf(); + } + // An empty old folder has nothing to move (`remove_dir` only removes an empty one). + if !held_aside() && std::fs::remove_dir(legacy).is_ok() { + return dir.to_path_buf(); + } + let unmoved = || if dir.exists() || !legacy.is_dir() { dir.to_path_buf() } else { legacy.to_path_buf() }; + let Some(parent) = dir.parent() else { return unmoved() }; + let lock = + std::fs::create_dir_all(parent).and_then(|()| std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(parent.join(MIGRATION_LOCK))); + let Ok(lock) = lock else { + // No lock file means no other process can be moving: the old folder is safe to use. + log::warn!("couldn't open the recovery move lock in {}; autosaves stay where they are", parent.display()); + return unmoved(); + }; + let deadline = std::time::Instant::now() + wait; + loop { + match lock.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(50)), + Err(std::fs::TryLockError::WouldBlock) => { + // Another process is moving and may yet remove the old folder: never write there. + log::info!("another PhotoCraft is moving crash-recovery autosaves; using {}", dir.display()); + return dir.to_path_buf(); + } + Err(std::fs::TryLockError::Error(e)) => { + // Locking fails for every process here alike, so none can be moving. + log::warn!("couldn't lock {}: {e}; autosaves stay where they are", parent.join(MIGRATION_LOCK).display()); + return unmoved(); + } + } + } + // The lock file stays (removing it would race a process about to lock it); the OS releases + // the lock when `lock` is dropped or the process ends. + let used = adopt_locked(legacy, dir); + if used == legacy { + // This process writes its autosaves to the old folder: it keeps the lock for as long as it + // runs, so no other launch moves (and removes) the folder under it. + KEPT_LOCKS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(lock); + } + used +} + +/// Migration locks of the old folders this process uses (see [`adopt_legacy_recovery`]), kept +/// until it ends. +static KEPT_LOCKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// The move itself, under the migration lock. First, what a process that ended mid-move left is +/// tidied: originals it set aside are put back, copies it staged are removed. An empty `legacy` +/// is then just removed. Otherwise the whole folder moves if `dir` doesn't exist yet; when both +/// exist, each autosave (`.pcraft` bundle and `.json` sidecar, see +/// `photocraft_format::autosave`) moves as a whole, bundle first, unless `dir` already has either +/// part, and one that stays is left in `legacy` untouched. Every move is a [`move_path`]. +fn adopt_locked(legacy: &Path, dir: &Path) -> PathBuf { + if let Some(parent) = legacy.parent() { + for held in side_entries(parent, legacy, HELD) { + put_back(&held, legacy); + } + } + // Autosave parts held aside inside the old folder by a per-autosave move. + let held_parts: Vec<(PathBuf, String)> = std::fs::read_dir(legacy) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| e.file_name().to_str().and_then(|n| side_of(n, HELD)).map(|orig| (e.path(), orig.to_owned()))) + .collect(); + for (held, original) in held_parts { + put_back(&held, &legacy.join(original)); + } + for folder in [dir.parent(), Some(dir)].into_iter().flatten() { + remove_stale_staging(folder); + } + // Another process may have finished the move while this one waited. + if !legacy.is_dir() { + return dir.to_path_buf(); + } + if std::fs::remove_dir(legacy).is_ok() { + return dir.to_path_buf(); + } + if !dir.exists() { + // The common case: the whole folder moves at once. + return match move_path(legacy, dir) { + Ok(()) => { + log::info!("moved crash-recovery autosaves from {} to {}", legacy.display(), dir.display()); + dir.to_path_buf() + } + Err(e) => { + log::warn!("couldn't move crash-recovery autosaves from {} to {} ({e}); using the old folder", legacy.display(), dir.display()); + if legacy.is_dir() { legacy.to_path_buf() } else { dir.to_path_buf() } + } + }; + } + move_entries(legacy, dir); + // Only an emptied folder goes; anything that couldn't move stays for a later launch. + let _ = std::fs::remove_dir(legacy); + dir.to_path_buf() +} + +/// Move each autosave of `from` into `to` (both folders): bundle first, then sidecar, skipped if +/// `to` has either part. Temp files of an interrupted write (`photocraft_format::atomic`) go +/// along too, for the recovery store's startup sweep there. +fn move_entries(from: &Path, to: &Path) { + let Ok(entries) = std::fs::read_dir(from) else { return }; + let names: Vec = entries.flatten().filter_map(|e| e.file_name().to_str().map(str::to_owned)).collect(); + let keys: std::collections::BTreeSet<&str> = names.iter().filter_map(|n| n.strip_suffix(".pcraft").or_else(|| n.strip_suffix(".json"))).collect(); + for key in keys { + let parts = [format!("{key}.pcraft"), format!("{key}.json")]; + if parts.iter().any(|p| std::fs::symlink_metadata(to.join(p)).is_ok()) { + continue; + } + // A bundle that moves without its sidecar is still listed (`list_recovery` reads a + // committed bundle's manifest); a sidecar must never move without its bundle. + for part in &parts { + let src = from.join(part); + if std::fs::symlink_metadata(&src).is_ok() + && let Err(e) = move_path(&src, &to.join(part)) + { + log::warn!("couldn't move {} to {}: {e}", src.display(), to.display()); + break; + } + } + } + for name in names.iter().filter(|n| n.ends_with(".pcsave.tmp")) { + let _ = move_path(&from.join(name), &to.join(name)); + } +} + +/// How deep [`copy_tree`] follows folders: an autosave bundle is a few levels deep. +const MAX_COPY_DEPTH: u32 = 16; + +fn already_exists(path: &Path) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", path.display())) +} + +/// A path beside `of`, named `..-`, that no other call uses. +fn side_path(of: &Path, suffix: &str) -> std::io::Result { + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let parent = of.parent().ok_or_else(|| std::io::Error::other(format!("{} has no parent folder", of.display())))?; + let name = of.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Ok(parent.join(format!(".{name}.{}-{}{suffix}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)))) +} + +/// The name `side` was made from, if it is a [`side_path`] name with `suffix`. +fn side_of<'a>(side: &'a str, suffix: &str) -> Option<&'a str> { + let rest = side.strip_prefix('.')?.strip_suffix(suffix)?; + let (name, ids) = rest.rsplit_once('.')?; + let (pid, n) = ids.split_once('-')?; + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + (!name.is_empty() && digits(pid) && digits(n)).then_some(name) +} + +/// The side paths with `suffix` in `folder` made from `of`. +fn side_entries(folder: &Path, of: &Path, suffix: &str) -> Vec { + let Some(name) = of.file_name().and_then(|n| n.to_str()) else { return Vec::new() }; + std::fs::read_dir(folder) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.file_name().to_str().and_then(|n| side_of(n, suffix)) == Some(name)) + .map(|e| e.path()) + .collect() +} + +/// Move the file or folder `from` to `to` without ever replacing anything at `to`. On one volume +/// a folder is renamed (Windows refuses an existing target; elsewhere only an empty folder could +/// be replaced) and a file is hard-linked, which fails on an existing target, and then unlinked +/// at `from`. Otherwise (another volume, e.g. a roaming profile redirected to a network share +/// while `%LOCALAPPDATA%` stays local) it is copied ([`move_across`]). +fn move_path(from: &Path, to: &Path) -> std::io::Result<()> { + if std::fs::symlink_metadata(to).is_ok() { + return Err(already_exists(to)); + } + let meta = std::fs::symlink_metadata(from)?; + let moved = if meta.is_dir() { std::fs::rename(from, to) } else { std::fs::hard_link(from, to).and_then(|()| std::fs::remove_file(from)) }; + match moved { + Ok(()) => Ok(()), + Err(e) if matches!(e.kind(), std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty) => Err(e), + Err(_) => move_across(from, to), + } +} + +/// Move `from` to `to` by copying. `from` is first renamed aside on its own volume +/// (`..-.held`, [`hold`]), so a process still writing to the old path (another +/// launch that couldn't take the lock, an older PhotoCraft) starts a new `from` there instead of +/// adding to what is copied, and nothing it writes is removed. The held original is copied to a +/// staged path ([`stage_copy`]), published without replacing anything ([`publish`]), and only +/// then removed. If the copy or publish fails, the held original is put back ([`put_back`]). +fn move_across(from: &Path, to: &Path) -> std::io::Result<()> { + let held = hold(from)?; + match stage_copy(&held, to).and_then(|staged| publish(&staged, to)) { + Ok(()) => { + let removed = if std::fs::symlink_metadata(&held)?.is_dir() { std::fs::remove_dir_all(&held) } else { std::fs::remove_file(&held) }; + if let Err(e) = removed { + log::warn!("copied {} to {} but couldn't remove {}: {e}", from.display(), to.display(), held.display()); + } + Ok(()) + } + Err(e) => { + put_back(&held, from); + Err(e) + } + } +} + +/// Rename `from` aside on its own volume (see [`move_across`]); returns where it is now. +fn hold(from: &Path) -> std::io::Result { + let held = side_path(from, HELD)?; + std::fs::rename(from, &held)?; + Ok(held) +} + +/// Return the held original `held` to `original`. If `original` has been made again meanwhile (a +/// writer that kept using the old path), the held folder's entries move into it without replacing +/// anything ([`move_entries`]); whatever can't move stays held, logged, and is tried again under +/// the next move's lock ([`adopt_locked`] puts back what a process that ended mid-move held). +/// Nothing held is ever removed with data in it. +fn put_back(held: &Path, original: &Path) { + if std::fs::symlink_metadata(original).is_err() && std::fs::rename(held, original).is_ok() { + return; + } + if held.is_dir() && original.is_dir() { + move_entries(held, original); + if std::fs::remove_dir(held).is_ok() { + return; + } + } + log::warn!("{} is kept aside: {} exists", held.display(), original.display()); +} + +/// Copy `from` to a staging path beside `to` that this call creates exclusively, so nothing +/// another process made is ever written to or cleaned up. On failure the staged copy is removed. +fn stage_copy(from: &Path, to: &Path) -> std::io::Result { + let staged = side_path(to, STAGED)?; + let is_dir = std::fs::symlink_metadata(from)?.is_dir(); + let copied = if is_dir { copy_tree(from, &staged, 0) } else { copy_file(from, &staged) }; + if let Err(e) = copied { + // `copy_tree` and `copy_file` create `staged` exclusively, and everything under a + // fresh folder is new: `AlreadyExists` means `staged` was someone else's to keep. + if e.kind() != std::io::ErrorKind::AlreadyExists { + let _ = if is_dir { std::fs::remove_dir_all(&staged) } else { std::fs::remove_file(&staged) }; + } + return Err(e); + } + Ok(staged) +} + +/// Remove copies a process left staged in `folder` when it ended mid-move: disposable, since the +/// original is still in place or held aside. Called only under the migration lock, which every +/// staging happens under. Links are never followed. +fn remove_stale_staging(folder: &Path) { + let Ok(entries) = std::fs::read_dir(folder) else { return }; + for entry in entries.flatten() { + if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) { + continue; + } + let removed = match entry.file_type() { + Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()), + Ok(t) if t.is_file() => std::fs::remove_file(entry.path()), + _ => continue, + }; + if let Err(e) = removed { + log::warn!("couldn't remove the stale staged copy {}: {e}", entry.path().display()); + } + } +} + +/// Give the staged copy its final name `to` without replacing anything there (see [`move_path`] +/// for the folder and file cases). On failure the staged copy is removed and `to` is untouched. +fn publish(staged: &Path, to: &Path) -> std::io::Result<()> { + let is_dir = std::fs::symlink_metadata(staged)?.is_dir(); + let published = if std::fs::symlink_metadata(to).is_ok() { + Err(already_exists(to)) + } else if is_dir { + std::fs::rename(staged, to) + } else { + std::fs::hard_link(staged, to).and_then(|()| std::fs::remove_file(staged)) + }; + if published.is_err() { + let _ = if is_dir { std::fs::remove_dir_all(staged) } else { std::fs::remove_file(staged) }; + } + published +} + +/// Copy the folder `from` to the new folder `to` (created here; an existing `to` is an error): +/// plain files and folders only (a link or other special file is refused), at most +/// [`MAX_COPY_DEPTH`] levels. +fn copy_tree(from: &Path, to: &Path, depth: u32) -> std::io::Result<()> { + if depth > MAX_COPY_DEPTH { + return Err(std::io::Error::other(format!("{} is nested too deep", from.display()))); + } + std::fs::create_dir(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &target, depth.saturating_add(1))?; + } else { + copy_file(&entry.path(), &target)?; + } + } + Ok(()) +} + +/// Copy one plain file (a link or other special file is refused) to the new file `to` (an +/// existing `to` is an error), flushed to disk. +fn copy_file(from: &Path, to: &Path) -> std::io::Result<()> { + if !std::fs::symlink_metadata(from)?.file_type().is_file() { + return Err(std::io::Error::other(format!("{} is not a plain file", from.display()))); + } + let mut src = std::fs::File::open(from)?; + let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?; + std::io::copy(&mut src, &mut dst)?; + dst.sync_all() +} + /// Resolve the data directory from the environment (`env`) and the executable's folder. pub fn resolve(env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> DataDir { if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) { @@ -223,6 +599,300 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// #5648: on Windows, autosaves go to the machine-local `%LOCALAPPDATA%`, not the roaming + /// profile; an override or portable folder keeps them inside itself. + #[test] + fn autosaves_stay_out_of_the_roaming_profile() { + let root = temp("recovery-local"); + let settings = root.join("appdata").join("Photocraft"); + let local = root.join("local"); + let env = env_of(&[("LOCALAPPDATA", &local)]); + let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None }; + let expected = if cfg!(windows) { local.join("Photocraft").join("Recovery") } else { settings.join("Recovery") }; + assert_eq!(resolve_recovery(&platform, &env), Some(expected)); + for mode in [Mode::Override, Mode::Portable] { + let d = DataDir { dir: Some(settings.clone()), mode, warning: None }; + assert_eq!(resolve_recovery(&d, &env), Some(settings.join("Recovery")), "{mode:?}"); + } + // No (or an empty) LOCALAPPDATA: beside the settings, as before. + let empty = PathBuf::new(); + assert_eq!(resolve_recovery(&platform, env_of(&[("LOCALAPPDATA", &empty)])), Some(settings.join("Recovery"))); + assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery"))); + let nowhere = DataDir { dir: None, mode: Mode::Platform, warning: None }; + assert_eq!(resolve_recovery(&nowhere, |_| None), None); + 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(); + std::fs::write(dir.join(format!("{key}.pcraft")).join("manifest.json"), key).unwrap(); + std::fs::write(dir.join(format!("{key}.json")), key).unwrap(); + } + + #[test] + fn autosaves_from_the_old_folder_move_to_the_new_one() { + let root = temp("recovery-move"); + let legacy = root.join("roaming").join("Recovery"); + let dir = root.join("local").join("Photocraft").join("Recovery"); + autosave(&legacy, "a"); + assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir); + assert!(!legacy.exists(), "the old folder is gone"); + assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a"); + assert!(dir.join("a.json").is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn autosaves_merge_into_an_existing_folder_without_overwriting() { + let root = temp("recovery-merge"); + let legacy = root.join("roaming").join("Recovery"); + let dir = root.join("local").join("Recovery"); + autosave(&legacy, "old"); + autosave(&legacy, "both"); + // A temp file of an interrupted write moves along, for the recovery store's sweep. + let temp_file = ".old.json.4294967294-0.pcsave.tmp"; + std::fs::write(legacy.join(temp_file), b"partial").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("both.json"), "newer").unwrap(); + assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir); + assert!(dir.join(temp_file).is_file() && !legacy.join(temp_file).exists()); + assert!(dir.join("old.pcraft").is_dir() && dir.join("old.json").is_file(), "a whole autosave moves"); + assert_eq!(std::fs::read_to_string(dir.join("both.json")).unwrap(), "newer", "never overwritten"); + assert!(!dir.join("both.pcraft").exists(), "a clashing autosave isn't split"); + assert!(legacy.join("both.pcraft").is_dir() && legacy.join("both.json").is_file(), "it stays behind whole"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn autosaves_that_cannot_move_keep_being_used_where_they_are() { + let root = temp("recovery-stuck"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "a"); + // A file where the new folder's parent should be: nothing can be created there. + std::fs::write(root.join("blocked"), b"in the way").unwrap(); + let dir = root.join("blocked").join("Recovery"); + assert_eq!(adopt_legacy_recovery(&legacy, &dir), legacy); + assert!(legacy.join("a.json").is_file(), "nothing lost"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn an_empty_old_folder_is_removed_even_where_it_cannot_move() { + let root = temp("recovery-empty"); + let legacy = root.join("roaming").join("Recovery"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(root.join("blocked"), b"in the way").unwrap(); + let dir = root.join("blocked").join("Recovery"); + assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir, "nothing to keep listed"); + assert!(!legacy.exists(), "the empty old folder is gone"); + let _ = std::fs::remove_dir_all(&root); + } + + /// Where a rename can't cross volumes (a roaming profile on a network share), an autosave + /// is copied whole, nested folders included, and only then removed. + #[test] + fn a_move_across_volumes_copies_then_removes() { + let root = temp("recovery-copy"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "a"); + std::fs::create_dir_all(legacy.join("a.pcraft").join("tiles")).unwrap(); + std::fs::write(legacy.join("a.pcraft").join("tiles").join("t0.zst"), b"tile").unwrap(); + let dir = root.join("local").join("Recovery"); + std::fs::create_dir_all(root.join("local")).unwrap(); + move_across(&legacy, &dir).unwrap(); + assert!(!legacy.exists(), "the original is removed after the copy"); + assert_eq!(side_leftovers(&root.join("roaming")), 0, "nothing is left held aside"); + assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a"); + assert_eq!(std::fs::read(dir.join("a.pcraft").join("tiles").join("t0.zst")).unwrap(), b"tile"); + assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a"); + // One file on its own, as the per-autosave moves do. + move_across(&dir.join("a.json"), &root.join("a.json")).unwrap(); + assert!(!dir.join("a.json").exists() && root.join("a.json").is_file()); + assert_eq!(side_leftovers(&dir) + side_leftovers(&root), 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_failed_copy_puts_the_original_back_and_leaves_no_partial_copy() { + let root = temp("recovery-copy-fail"); + let legacy = root.join("Recovery"); + autosave(&legacy, "a"); + // Deeper than any bundle: the copy gives up part way. + let mut deep = legacy.join("a.pcraft"); + for _ in 0..=MAX_COPY_DEPTH { + deep = deep.join("d"); + } + std::fs::create_dir_all(&deep).unwrap(); + let dir = root.join("Local"); + assert!(move_across(&legacy, &dir).is_err()); + assert!(!dir.exists(), "nothing is published"); + assert_eq!(side_leftovers(&root), 0, "no staged copy, nothing left held aside"); + assert!(legacy.join("a.json").is_file() && deep.is_dir(), "the original is back in place"); + // A target that already exists is never copied into or removed. + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("keep"), b"mine").unwrap(); + assert!(move_across(&legacy.join("a.json"), &dir).is_err()); + assert_eq!(std::fs::read(dir.join("keep")).unwrap(), b"mine"); + assert!(legacy.join("a.json").is_file()); + assert_eq!(side_leftovers(&root) + side_leftovers(&legacy), 0); + let _ = std::fs::remove_dir_all(&root); + } + + /// Staged (`.moving`) and held (`.held`) side paths left in `dir`. + fn side_leftovers(dir: &Path) -> usize { + std::fs::read_dir(dir) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some() || side_of(n, HELD).is_some())) + .count() + } + + /// The target appears after the copy is staged (another process got there first): publishing + /// fails, that process's target keeps its contents, the staged copy is removed and the + /// original is left as it was. Folder and file alike. + #[test] + fn a_target_that_appears_while_copying_is_never_replaced_or_removed() { + let root = temp("recovery-race"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "a"); + let local = root.join("local"); + std::fs::create_dir_all(&local).unwrap(); + // A whole folder. + let dir = local.join("Recovery"); + let staged = stage_copy(&legacy, &dir).unwrap(); + autosave(&dir, "theirs"); + let e = publish(&staged, &dir).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_to_string(dir.join("theirs.json")).unwrap(), "theirs", "their folder is intact"); + assert!(!dir.join("a.json").exists(), "nothing was copied into it"); + assert!(!staged.exists() && side_leftovers(&local) == 0, "only the staged copy is removed"); + assert!(legacy.join("a.json").is_file(), "the original is untouched"); + // One file. + let to = dir.join("a.json"); + let staged = stage_copy(&legacy.join("a.json"), &to).unwrap(); + std::fs::write(&to, "theirs").unwrap(); + assert_eq!(publish(&staged, &to).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_to_string(&to).unwrap(), "theirs"); + assert!(!staged.exists() && legacy.join("a.json").is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + /// A process holds the lock and is copying the old folder (held aside, copy staged) when a + /// second launch gives up waiting. The second launch must get the new folder, never the old + /// one, and what it writes there survives the first one finishing: the publish refuses the + /// 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 root = temp("recovery-timeout"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "old"); + let local = root.join("local"); + std::fs::create_dir_all(&local).unwrap(); + let dir = local.join("Recovery"); + // First process: takes the lock, sets the original aside and stages its copy, then pauses. + let first = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(local.join(MIGRATION_LOCK)).unwrap(); + first.lock().unwrap(); + let held = hold(&legacy).unwrap(); + let staged = stage_copy(&held, &dir).unwrap(); + // Second launch: times out and writes a newer autosave where it was told to. + let chosen = adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO); + assert_eq!(chosen, dir, "a launch that couldn't get the lock never uses the old folder"); + autosave(&chosen, "new"); + // First process resumes: publish, and on failure put the original back (as `move_across`). + assert_eq!(publish(&staged, &dir).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + put_back(&held, &legacy); + drop(first); + assert_eq!(std::fs::read_to_string(dir.join("new.json")).unwrap(), "new", "the newer autosave survives"); + assert_eq!(std::fs::read_to_string(legacy.join("old.json")).unwrap(), "old", "the old one is back in place"); + assert_eq!(side_leftovers(&local) + side_leftovers(&root.join("roaming")), 0); + // The next launch merges them. + assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir); + assert!(dir.join("old.json").is_file() && dir.join("new.json").is_file() && !legacy.exists()); + let _ = std::fs::remove_dir_all(&root); + } + + /// A writer that still uses the old path while it is being copied (an older PhotoCraft) + /// starts a new folder there; the move removes only the original it held aside. + #[test] + fn a_write_to_the_old_path_during_a_copy_is_not_removed() { + let root = temp("recovery-old-writer"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "old"); + let local = root.join("local"); + std::fs::create_dir_all(&local).unwrap(); + let dir = local.join("Recovery"); + let held = hold(&legacy).unwrap(); + let staged = stage_copy(&held, &dir).unwrap(); + autosave(&legacy, "new"); + publish(&staged, &dir).unwrap(); + std::fs::remove_dir_all(&held).unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("old.json")).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(legacy.join("new.json")).unwrap(), "new", "the new write is untouched"); + let _ = std::fs::remove_dir_all(&root); + } + + /// What a process killed mid-move left is tidied at the next move: a held original is put + /// back (it may be the only copy) and moved, a staged copy is removed; other dot-files and + /// look-alike names are not touched. + #[test] + fn a_killed_move_is_tidied_next_time() { + let root = temp("recovery-stale"); + let roaming = root.join("roaming"); + let legacy = roaming.join("Recovery"); + autosave(&legacy, "a"); + let held = hold(&legacy).unwrap(); + assert!(!legacy.exists() && held.is_dir()); + let local = root.join("local"); + let dir = local.join("Recovery"); + let stale = local.join(".Recovery.4294967294-3.moving"); + autosave(&stale, "half"); + let kept = [local.join(".Recovery.moving"), local.join(".keep.1-x.moving"), local.join("notes.txt")]; + for k in &kept { + std::fs::write(k, b"x").unwrap(); + } + assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir); + assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a", "the held original was put back and moved"); + assert!(!stale.exists(), "the stale staged copy is gone"); + assert_eq!(side_leftovers(&roaming), 0); + assert!(kept.iter().all(|k| k.is_file()), "only side names are touched"); + assert_eq!(side_of(".a.json.12-0.moving", STAGED), Some("a.json")); + assert!(side_of("a.json.12-0.moving", STAGED).is_none() && side_of(".a.json.-0.moving", STAGED).is_none()); + assert!(side_of(".a.json.12-0.held", STAGED).is_none() && side_of(".a.json.12-0.held", HELD) == Some("a.json")); + let _ = std::fs::remove_dir_all(&root); + } + + /// While another process holds the move lock, a launch moves nothing and uses the new folder; + /// once the lock is released, the move happens. + #[test] + fn a_move_waits_for_another_process_holding_the_lock() { + let root = temp("recovery-lock"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "a"); + let dir = root.join("local").join("Recovery"); + std::fs::create_dir_all(root.join("local")).unwrap(); + let held = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(root.join("local").join(MIGRATION_LOCK)).unwrap(); + held.lock().unwrap(); + assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir); + assert!(legacy.join("a.json").is_file() && !dir.exists(), "nothing moved under another's lock"); + drop(held); + assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir); + assert!(dir.join("a.json").is_file() && !legacy.exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn no_old_folder_or_the_same_folder_changes_nothing() { + let root = temp("recovery-noop"); + let dir = root.join("Recovery"); + assert_eq!(adopt_legacy_recovery(&root.join("missing"), &dir), dir); + assert!(!dir.exists(), "nothing is created up front"); + autosave(&dir, "a"); + assert_eq!(adopt_legacy_recovery(&dir, &dir), dir); + assert!(dir.join("a.json").is_file()); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn missing_exe_dir_or_env_never_panics() { let d = resolve(|_| None, None); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 6c34fda33a..d4a1611a18 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -264,8 +264,8 @@ 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; see -/// [`crate::app_dirs`]. +/// portable mode, else the platform convention. Everything the app persists lives under it +/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`]. pub fn config_dir() -> Option { crate::app_dirs::config_dir() } @@ -280,8 +280,10 @@ 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`]). fn recovery_dir() -> Option { - config_dir().map(|d| d.join("Recovery")) + crate::app_dirs::recovery_dir() } /// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 14d416f19b..15334ac3a2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -246,7 +246,9 @@ log text file. GPU on/off and the GPU tile size apply at the next launch. The desktop app stores them in `preferences.json` in the platform config directory (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft`; override with `PHOTOCRAFT_CONFIG_DIR`); autosaves go to its -`Recovery` folder, and the window size and position, the dock width and floating-panel geometry +`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 +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 executable instead. The web build keeps them in `localStorage`. A save writes only the values diff --git a/packaging/windows/portable.txt b/packaging/windows/portable.txt index 9372e88b71..853874bc2c 100644 --- a/packaging/windows/portable.txt +++ b/packaging/windows/portable.txt @@ -2,8 +2,8 @@ PhotoCraft portable mode While this file sits next to photocraft.exe, PhotoCraft keeps its preferences, brush presets, crash-recovery autosaves and startup state in the PhotoCraftData folder beside it, and writes -nothing to %APPDATA%. Copy the whole folder to move PhotoCraft with its settings. +nothing to %APPDATA% or %LOCALAPPDATA%. Copy the whole folder to move PhotoCraft with its settings. -Delete this file to use the normal per-user settings folder (%APPDATA%\Photocraft) instead. -If PhotoCraftData can't be written (for example on a read-only drive), PhotoCraft says so and -uses %APPDATA%\Photocraft instead. +Delete this file to use the normal per-user folders instead: %APPDATA%\Photocraft for settings +and %LOCALAPPDATA%\Photocraft\Recovery for crash-recovery autosaves. If PhotoCraftData can't be +written (for example on a read-only drive), PhotoCraft says so and uses those folders instead. From fbcb70b317337d17614c94db08250de280793a34 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:19:32 -0500 Subject: [PATCH 097/171] Save: ask before overwriting a file another app changed (#6596) (#6652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Save: ask before overwriting a file another app changed (#6596) [WIP] Co-Authored-By: Claude Opus 5.5 * Save: ask before overwriting a file another app changed (#6596) File › Save now notes the size and modification time of a document's own file when it is opened or saved, and checks them again before writing back. If another app changed the file in between, a prompt asks whether to replace it (Cancel keeps both; Save As saves elsewhere) instead of silently overwriting the other app's changes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 5 ++ crates/ui-egui/src/dialogs.rs | 4 ++ crates/ui-egui/src/disk_change_prompt.rs | 57 ++++++++++++++++ crates/ui-egui/src/file_dialog/tests.rs | 87 ++++++++++++++++++++++++ crates/ui-egui/src/file_open.rs | 2 + 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 | 3 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + 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 | 47 ++++++++++++- crates/ui-egui/src/menus.rs | 6 +- 27 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/disk_change_prompt.rs diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a1611a18..d4e7a01553 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -506,6 +506,11 @@ pub fn native(automation: Option) -> save_file: Some(Arc::new(save_file)), file_dialog: Some(Box::new(show_file_dialog)), write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))), + file_stamp: Some(Box::new(|path: &str| { + // Follows a symlink, as Save writes through it to the file it points at. + let meta = std::fs::metadata(path).ok()?; + Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) + })), automation_read, automation_write, automation_open_pcraft, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 182b4be8c7..43b4f3d3f9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -321,6 +321,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::channel_options_ui::owns_new_spot(&fields) => crate::channel_options_ui::new_spot_body(ui, &mut fields), 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::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), @@ -404,6 +405,8 @@ 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) { + tl!("Replace") } else if d.fields.contains_key("__export") { tl!("Export") } else { @@ -617,6 +620,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::channel_options_ui::owns_new_spot(&d.fields) => crate::channel_options_ui::confirm_new_spot(app, &d.fields), 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::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), diff --git a/crates/ui-egui/src/disk_change_prompt.rs b/crates/ui-egui/src/disk_change_prompt.rs new file mode 100644 index 0000000000..dd9b8215b9 --- /dev/null +++ b/crates/ui-egui/src/disk_change_prompt.rs @@ -0,0 +1,57 @@ +//! File › Save asks before replacing the document's own file when another app changed it after +//! the document was opened from it or last saved to it (#6596). Replace writes the document over +//! it anyway; Cancel leaves the file and the document as they are, and File › Save As keeps both. +//! +//! The prompt is an ordinary dialog in the UI state: `ui.dialog.confirm` replaces the file and +//! `ui.dialog.cancel` keeps it. Recorded actions and automation saves don't ask. + +use photocraft_doc::DocId; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::state::DialogKind; + +const MARK: &str = "__diskChanged"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Opens the prompt instead of saving `doc` to its own file at `path` when another app changed +/// that file; `None` saves as usual. `flat` is a flat file confirmed in the Save dialog, which +/// is saved (and recorded) the way [`PhotocraftApp::save_dialog_flow`] does it. +pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: bool) -> Option> { + if !app.changed_on_disk(doc, path) { + return None; + } + if app.ui.dialogs.iter().any(|d| owns(&d.fields)) { + return Some(Ok(json!({"confirmationPending": true}))); + } + let name = crate::file_open::display_name(path); + let message = crate::i18n::fmt(tl!("{name} was changed by another app after it was opened or saved here."), &[("name", name.as_str())]); + let fields = json!({MARK: true, "__label": "Save", "message": message, "document": doc.0, "path": path, "flat": flat}); + let id = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Some(Ok(json!({"dialog": id, "confirmationPending": true}))) +} + +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(tl!("Replace it with this document, or cancel and use File › Save As to keep both.")); +} + +/// Replace: the save the prompt held back. +pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + let doc = fields.get("document").and_then(Value::as_u64).map(DocId).ok_or(tl!("The document is closed"))?; + let path = fields.get("path").and_then(Value::as_str).unwrap_or_default().to_string(); + let flat = fields.get("flat").and_then(Value::as_bool) == Some(true); + app.with_document(doc, |app| { + if flat { + let r = app.save_to(path.clone(), None)?; + app.journal_as_plain_save(&path); + Ok(r) + } else { + app.save_in_place(path) + } + }) +} diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 9e4c878f68..6a3ccfcf9e 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1010,3 +1010,90 @@ fn render_video_menu_asks_for_directory_and_writes_frames_only_after_confirmatio assert!(!app.file_dialog_open()); std::fs::remove_dir_all(&dir).unwrap(); } + +/// How many times each path was written, standing in for its size and modification time. +type Disk = Rc>>; + +/// Gives `app` file stamps from a fake disk that its own writes update too. +fn on_disk(app: &mut PhotocraftApp) -> Disk { + let disk = Disk::default(); + let (read, wrote) = (disk.clone(), disk.clone()); + app.services.file_stamp = Some(Box::new(move |path| read.borrow().get(path).map(|&n| crate::FileStamp { len: n, modified: None }))); + let mut write = app.services.write.take().unwrap(); + app.services.write = Some(Box::new(move |path, bytes| { + write(path, bytes)?; + *wrote.borrow_mut().entry(path.to_string()).or_default() += 1; + Ok(()) + })); + disk +} + +fn changed_by_another_app(disk: &Disk, path: &str) { + *disk.borrow_mut().entry(path.to_string()).or_default() += 1; +} + +fn disk_prompt(app: &PhotocraftApp) -> Option<&crate::state::Dialog> { + app.ui.dialogs.iter().find(|d| crate::disk_change_prompt::owns(&d.fields)) +} + +#[test] +fn save_asks_before_replacing_a_file_another_app_changed() { + let ctx = egui::Context::default(); + let (mut app, _open, written) = app(); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, "/pics/a.psd"); + app.opened_from("/pics/a.psd"); + app.run("layer.new.layer", json!({})).unwrap(); + // Unchanged on disk: Save writes at once. + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + assert_eq!(*written.borrow(), ["/pics/a.psd"]); + assert!(disk_prompt(&app).is_none()); + + // Our own save isn't a change; another app's write after it is. + changed_by_another_app(&disk, "/pics/a.psd"); + app.run("layer.new.layer", json!({})).unwrap(); + let r = menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + assert_eq!(r["confirmationPending"], true); + assert_eq!(written.borrow().len(), 1, "nothing is written before the answer"); + let prompt = disk_prompt(&app).expect("Save asks first"); + assert!(prompt.fields["message"].as_str().unwrap().contains("a.psd")); + + // Cancel leaves the file and the unsaved document alone. + let id = prompt.id; + app.ui.close_dialog(id); + assert_eq!(written.borrow().len(), 1); + assert!(app.session.active().unwrap().is_dirty()); + + // Replace writes it, and the next Save doesn't ask again. + let id = menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap()["dialog"].as_u64().unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(written.borrow().len(), 2); + assert!(!app.session.active().unwrap().is_dirty()); + app.run("layer.new.layer", json!({})).unwrap(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + assert_eq!(written.borrow().len(), 3); + assert!(disk_prompt(&app).is_none()); +} + +#[test] +fn save_of_a_flat_file_asks_after_the_dialog_when_another_app_changed_it() { + let ctx = egui::Context::default(); + // Lowercase: the Save dialog lowercases a typed extension by default. + let path = "/pics/My image.jpg"; + let (mut app, open, written) = jpeg_app(None, false); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, path); + app.opened_from(path); + app.run("image.adjustments.invert", json!({})).unwrap(); + changed_by_another_app(&disk, path); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(path.into()))); + app.poll_file_dialog(&ctx, None); + assert!(written.borrow().is_empty(), "the changed file is not replaced before the answer"); + let id = disk_prompt(&app).expect("Save asks first").id; + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), [path]); + assert!(!app.session.active().unwrap().is_dirty()); + assert_eq!(app.session.journal.last().map(|s| s.0.as_str()), Some("file.save"), "still recorded as a plain Save"); +} diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 13105a321d..d752f7ce76 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -89,6 +89,8 @@ impl PhotocraftApp { && !st.source_read_only { st.path = Some(path.to_string()); + let doc = st.doc.id; + self.remember_stamp(doc, path); } self.push_recent(path); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..f50c0249b7 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) + {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í. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..b1ea8977e2 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) + {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. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2c67c2c41..8229806307 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 = Αυτόματο) + {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. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45804fa2a4..6f3d85002b 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) + {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. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e3451e096..4a819b0e1b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3213,3 +3213,6 @@ 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) + 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. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dc9a47fbc..86a6b30acb 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) + {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. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81e850f293..49777913c1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3200,3 +3200,6 @@ 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) + 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. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c097ded29d..f1144c0fdd 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) + {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. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b460965128..351699ea89 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3884,3 +3884,6 @@ 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 = 自動) + 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. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..3eaf143979 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3386,3 +3386,6 @@ 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 = 자동) + 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. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f1ac67961e..09dd568009 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) + {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. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04eb15609d..c4827aa829 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3806,3 +3806,6 @@ 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) + 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. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d6b88fb54..f96542d642 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) + {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. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d733f11a7..f49200b973 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3178,3 +3178,6 @@ 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 = автоматически) + 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. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d384c9badc..c207facb72 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) + {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. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c35aba71c..a8aa0c3c58 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) + {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. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb6f883eb2..48fb60343c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3907,3 +3907,6 @@ 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 = автоматично) + 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. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..f33ef15011 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3694,3 +3694,6 @@ 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) + 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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..75eacc994d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2824,3 +2824,6 @@ 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 = 自动) + 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. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..95a11dc593 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2809,3 +2809,6 @@ 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 = 自動) + 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. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c8d64832b..04c37282e6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -75,6 +75,7 @@ pub mod dialogs; pub mod direct_select; pub mod direction; pub mod discard_ui; +pub mod disk_change_prompt; pub mod distort_ui; pub mod doc_props_ui; pub mod dock; @@ -350,6 +351,15 @@ pub(crate) fn plain_raster(doc: &Document) -> Option<&photocraft_doc::Layer> { /// Encode a document: (file bytes, warnings about anything approximated or dropped). pub type ExportFn = Box Result<(Vec, Vec), String>>; pub type WriteFn = Box Result<(), String>>; +/// What a file on disk looked like when a document last read or wrote it: File › Save asks +/// before replacing a file another app changed since (#6596). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FileStamp { + pub len: u64, + pub modified: Option, +} +/// The [`FileStamp`] of the file at a path, `None` when it can't be read. +pub type FileStampFn = Box Option>; /// 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. @@ -447,6 +457,9 @@ pub struct Services { pub file_dialog: Option, /// Write bytes to a path (native) or trigger a download (web). pub write: Option, + /// Size and modification time of a file (native); `None` on the web, where Save never + /// writes back to a file. + pub file_stamp: 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, @@ -788,6 +801,9 @@ pub struct PhotocraftApp { /// Where each document was last saved or exported to through a dialog (#1826): its next Save /// As or export dialog starts there rather than beside the document. Session-only. pub(crate) save_dirs: HashMap, + /// Each document's own file as it was when the document was opened from it or saved to it + /// (see [`FileStamp`]). + pub(crate) disk_stamps: HashMap, /// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`). pub(crate) targa_options: Option, /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it @@ -937,6 +953,7 @@ impl PhotocraftApp { file_dialog: None, tiff_options: None, save_dirs: HashMap::new(), + disk_stamps: HashMap::new(), targa_options: None, held_opens: Default::default(), allow_close: false, @@ -1439,6 +1456,13 @@ impl PhotocraftApp { self.pick_save(&suggested, move |app, path| { app.with_document(doc, |app| { let own = app.session.active().and_then(|st| st.path.clone()); + // Confirming the document's own file: ask first if another app changed it (#6596). + if plain_save + && own.as_deref() == Some(path.as_str()) + && let Some(r) = crate::disk_change_prompt::intercept(app, doc, &path, true) + { + return r; + } let r = app.save_to(path.clone(), tga_bits)?; if plain_save && own.as_deref() == Some(path.as_str()) { app.journal_as_plain_save(&path); @@ -1449,7 +1473,7 @@ impl PhotocraftApp { } /// [`Self::save_as_with`] once the path is known. - fn save_to(&mut self, path: String, tga_bits: Option) -> Result { + pub(crate) fn save_to(&mut self, path: String, tga_bits: Option) -> Result { // An RGB Targa asks for its bits per pixel, unless the caller chose them; the save // continues from the prompt (still a copy when it would be one without the prompt), // which records the step once answered. @@ -1493,7 +1517,7 @@ impl PhotocraftApp { } /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]). - fn journal_as_plain_save(&mut self, path: &str) { + pub(crate) fn journal_as_plain_save(&mut self, path: &str) { if let Some(last) = self.session.journal.last_mut() && last.0 == "file.saveAs" && last.1.get("path").and_then(Value::as_str) == Some(path) @@ -1634,6 +1658,7 @@ impl PhotocraftApp { st.saved_to(path.to_string()); // The job locked the document, but record the revision that was written. st.saved_revision = revision; + app.remember_stamp(doc, path); } // "Save Document" script events and File › Generate › Image Assets. if hooks @@ -1653,6 +1678,9 @@ impl PhotocraftApp { if hooks { self.push_recent(path); } + } else if self.disk_stamps.get(&doc).is_some_and(|(own, _)| own == path) { + // A copy written over the document's own file is this app's change too. + self.remember_stamp(doc, path); } self.ui.status_error = false; let what = crate::i18n::fmt(tl!("Saved {name}"), &[("name", file_open::display_name(path).as_str())]); @@ -1660,6 +1688,21 @@ impl PhotocraftApp { self.sync_views(); } + /// Note how `doc`'s own file at `path` looks now that the document read or wrote it. + pub(crate) fn remember_stamp(&mut self, doc: photocraft_doc::DocId, path: &str) { + match self.services.file_stamp.as_ref().and_then(|stamp| stamp(path)) { + Some(now) => self.disk_stamps.insert(doc, (path.to_string(), now)), + None => self.disk_stamps.remove(&doc), + }; + } + + /// Another app changed `doc`'s own file at `path` since the document was opened from it or + /// last saved to it. A file that is gone, or one never stamped, counts as unchanged. + pub(crate) fn changed_on_disk(&self, doc: photocraft_doc::DocId, path: &str) -> bool { + let Some((own, then)) = self.disk_stamps.get(&doc) else { return false }; + own == path && self.services.file_stamp.as_ref().and_then(|stamp| stamp(path)).is_some_and(|now| now != *then) + } + /// TIFF Options or Targa Options is waiting on an answer before a save is written. pub(crate) fn save_options_open(&self) -> bool { self.tiff_options.is_some() || self.targa_options.is_some() diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f2baba7cf8..d322e26418 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -231,7 +231,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: None => { let own = app.session.active().and_then(|d| d.path.clone().map(|p| (photocraft_engine::file_cmds::holds_at(&d.doc, &p), p, d))); match own { - Some((true, path, _)) => app.save_in_place(path), + Some((true, path, d)) => { + // Another app changed the file since it was opened or saved: ask first (#6596). + let doc = d.doc.id; + crate::disk_change_prompt::intercept(app, doc, &path, false).unwrap_or_else(|| app.save_in_place(path)) + } Some((false, path, d)) if photocraft_engine::file_cmds::saves_in_place(&path) => { // Its own file is layered but can't hold what the document has now. let losses = photocraft_engine::file_cmds::losses_at(&d.doc, &path); From 7c44b2bc152bd2c6269942ee4be09f6779be9f12 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 02:19:47 +0000 Subject: [PATCH 098/171] 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 bd3b5d9192e6a0a5fa70d3f780a8abe19679328d Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:20:03 +0400 Subject: [PATCH 099/171] fix(automation): refuse a group/world-readable control-token file (#4966) (#5459) --- crates/automation/src/security.rs | 43 ++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 7928420b12..6198f22851 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -135,7 +135,27 @@ pub fn repeat_auth_reply(line: &str) -> Option { } fn read_token_file(path: &Path) -> Result { - let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + // Validate what we actually opened, not just the name: an existing control-token file must be + // a regular file that no other local principal can read. A freshly created one is 0600 + // (`create_token_file`); an operator-supplied one is checked here so a group- or world-readable + // token is refused rather than trusted as the control credential (#4966). + let mut file = std::fs::File::open(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + let meta = file.metadata().map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + if !meta.is_file() { + return Err(AutomationError::Io(format!("{}: control token file is not a regular file", path.display()))); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if meta.mode() & 0o077 != 0 { + return Err(AutomationError::Io(format!( + "{}: control token file is readable by group or others (mode {:o}); restrict it to its owner (e.g. chmod 600)", + path.display(), + meta.mode() & 0o777 + ))); + } + } + let token = std::io::read_to_string(&mut file).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let token = token.trim().to_owned(); validate_token(&token)?; Ok(token) @@ -433,6 +453,27 @@ mod tests { sender.join().unwrap(); } + #[cfg(unix)] + #[test] + fn an_existing_token_file_must_be_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("photocraft-token-perm-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("token.txt"); + let token = "0123456789abcdef".repeat(4); // 64 hex chars, passes validate_token + std::fs::write(&path, &token).unwrap(); + + // A group- or world-readable token is refused: the control credential must not be shared (#4966). + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(read_token_file(&path).is_err(), "a 0644 token file must be refused"); + + // Owner-only is opened, read and validated. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!(read_token_file(&path).unwrap(), token, "a 0600 token file must be read"); + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn connection_limiter_releases_capacity() { let limiter = ConnectionLimiter::new(1); From 5f65579ecb44047c2a619036c18ff273202e1389 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:20:22 -0400 Subject: [PATCH 100/171] =?UTF-8?q?perf(panorama):=20Photomerge=204=C3=97?= =?UTF-8?q?=20faster,=20bit=20for=20bit=20the=20same=20(#6489)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N is the whole algo-side Photomerge pipeline on 3 × 8 MP photos (register at the engine's scale with the Auto layout, gain + vignette fit, seams, multi-band blend), 16 threads: 1042 ms before, 264 ms after, 3.9×, rounded to 4×. The engine's own glue (reading regions, building weights, writing layers) is not in this number. Registration and multi-band blending ran almost entirely on one thread per image (four images on 16 threads), so most of the machine sat idle. Every output is bit for bit what the serial code produced: a probe that hashes the public Photomerge entry points (detect_many, match_pairs, align for every layout, photometric, seam_labels, multiband at 1 to 7 levels with 1 and 3 channels, with holes in the alpha, with images sticking out of the canvas, plus direct harris / describe calls) matches on all 349 outputs before and after, on 4 × 2 MP, 3 × 8 MP, 97×61, 211×157, 333×251, 320×240 two-image and 40×35 sets, at 16, 3 and 1 threads. - features `blur` (Harris window and BRIEF smoothing): the row pass runs rows in parallel; the column pass cuts the image into bands of 64 columns, each holding the `&mut` row segments of its own columns (no unsafe), run in parallel. Every column keeps its own f32 running sum, started from its first window summed in order and updated in the same order, so only the loop nest changes. - `harris`: Sobel, the corner response and the non-maximum scan are parallel by rows (the candidates are fully ordered by score, y, x afterwards, so arrival order is irrelevant). `describe`: one descriptor per corner in parallel, kept in corner order. - `reduce`, `expand`, `push_pull`: per-row maps in parallel. `reduce`'s column pass adds five whole rows in tap order (same per-element sum, now vectorised); `expand` works out its source columns once and truncates instead of calling libm `floor` (the clamped position is never negative); `push_pull` builds each coarse row from its two fine rows in raster order, which is the order the serial scatter-add used. - `multiband`: grid fill per image, Laplacian difference, band accumulation (every row walks the bands in the original order), normalisation, collapse and the coverage union are parallel. Float additions are never reordered. Left serial on purpose: the peak fold in `harris` (about 1 ms, an order-free max but not worth the proof), the corner spacing grid, `seam_labels` (a dynamic-programming path whose rows depend on the previous one), `photometric` sample gathering, and the pyramid levels themselves (each level needs the one above). Median of 5 (8 MP rows: 7), release, ms, interleaved before/after runs. The laptop was busy with unrelated builds the whole time (load average 11 to 18 on 16 threads), so the 1-thread rows are noisy (a single thread is time-sliced against them) and rows can move by 10 to 20 percent. | Case (probe) | before (16 thr) | after (16 thr) | speedup | before (1 thr) | after (1 thr) | |--------------------------------|----------------:|---------------:|--------:|---------------:|--------------:| | register, 4 × 2 MP | 154 | 60 | 2.6× | 336 | 167 | | multiband, 4 × 2 MP, 3 ch | 407 | 108 | 3.8× | 591 | 407 | | multiband, 4 × 2 MP, 1 ch | 307 | 73 | 4.2× | 813 | 566 | | whole pipeline, 4 × 2 MP | 437 | 160 | 2.7× | 716 | 504 | | register, 3 × 8 MP (full size) | 327 | 124 | 2.6× | 978 | 391 | | multiband, 3 × 8 MP, 3 ch | 2348 | 916 | 2.6× | 1742 | 1150 | | whole pipeline, 3 × 8 MP | 1042 | 264 | 3.9× | 2264 | 1280 | The 8 MP multiband row at 16 threads is the noisiest (minimum of 7: 959 to 224 ms, 4.3×); the whole-pipeline row is steady. Max RSS for the 3 × 8 MP whole pipeline (the probe's inputs are about 1.05 GB of it): 2.79 GB before, 2.79 GB after (three runs each). Tests: the serial `blur`, `reduce`, `expand`, `push_pull` and `multiband` are kept in the test modules as references and compared bit for bit over sizes that cross every band and row boundary (1×1, 1-wide, 1-high, odd sizes, 63 / 64 / 65 / 129 columns, radius 0 to wider than the image, holes, partial and zero weights, ROIs outside the canvas, 1 to 7 levels). Hard-coded hashes recorded from the old code cover multiband, push_pull, reduce + expand everywhere (plain f32 arithmetic), and harris + describe on Linux x86-64 (they go through the platform's hypot / ln / cos / atan2 / sin_cos). Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu Co-authored-by: Claude Opus 5.5 --- crates/algo/src/features.rs | 313 ++++++++++++++--- crates/algo/src/panorama.rs | 677 +++++++++++++++++++++++++++++++----- 2 files changed, 846 insertions(+), 144 deletions(-) diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index ab98de757e..e8e45bcaf2 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -60,25 +60,98 @@ impl Rng { } } +/// Columns per band of the vertical blur pass: 64 f32 = 256 bytes = four cache lines per row, so +/// walking down a band touches whole lines, and the band's working set stays in cache. +const BLUR_BAND: usize = 64; + +/// Box blur of radius `r` (edges clamped): a running f32 sum along rows, then along columns. +/// +/// Both passes are parallel and give bit for bit what the one-line-at-a-time version gave: every +/// output sums its window in the same order. Rows are independent. For the column pass, the +/// image is cut into bands of columns; each column keeps its own f32 running sum, started from +/// its first window summed in order and updated in the same order, so only the loop nest changes +/// (the inner loop runs across a band's columns and vectorises), never any column's arithmetic. fn blur(w: usize, h: usize, v: &[f32], r: usize) -> 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) }; - for line in 0..n { - let at = |k: i64| { - let k = k.clamp(0, m as i64 - 1) as usize; - if horizontal { src[line * w + k] } else { src[k * w + line] } - }; - let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum(); - for k in 0..m { - let i = if horizontal { line * w + k } else { k * w + line }; - dst[i] = acc / (2 * r + 1) as f32; - acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64); + if w == 0 || h == 0 { + return Vec::new(); + } + let den = (2 * r + 1) as f32; + let mut tmp = vec![0.0f32; w * h]; + par_rows(&mut tmp, w, |line, dst| { + let src = &v[line * w..(line + 1) * w]; + let at = |k: i64| src[k.clamp(0, w as i64 - 1) as usize]; + let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum(); + for (k, o) in dst.iter_mut().enumerate() { + *o = acc / den; + acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64); + } + }); + let mut dst = vec![0.0f32; w * h]; + // One list of row segments per band, each segment a `&mut` slice of `dst`: no `unsafe`. + let mut bands: Vec> = (0..w.div_ceil(BLUR_BAND)).map(|_| Vec::with_capacity(h)).collect(); + for row in dst.chunks_exact_mut(w) { + for (band, seg) in bands.iter_mut().zip(row.chunks_mut(BLUR_BAND)) { + band.push(seg); + } + } + let src = &tmp; + let run = |bi: usize, segs: &mut [&mut [f32]], acc: &mut Vec| { + let (x0, bw) = (bi * BLUR_BAND, segs.first().map_or(0, |s| s.len())); + let row = |k: i64| { + let o = (k.clamp(0, h as i64 - 1) as usize) * w + x0; + &src[o..o + bw] + }; + // The first window, summed in order from the same start as `Iterator::sum` uses. + acc.clear(); + acc.resize(bw, std::iter::empty::().sum()); + for k in -(r as i64)..=r as i64 { + for (t, &x) in acc.iter_mut().zip(row(k)) { + *t += x; + } + } + for (k, seg) in segs.iter_mut().enumerate() { + let (add, sub) = (row(k as i64 + r as i64 + 1), row(k as i64 - r as i64)); + for (((o, t), &p), &m) in seg.iter_mut().zip(acc.iter_mut()).zip(add).zip(sub) { + *o = *t / den; + *t += p - m; } } - dst }; - pass(&pass(v, true), false) + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + bands.into_par_iter().enumerate().for_each_init(Vec::new, |acc, (bi, mut segs)| run(bi, &mut segs, acc)); + } + #[cfg(target_arch = "wasm32")] + { + let mut acc = Vec::new(); + for (bi, mut segs) in bands.into_iter().enumerate() { + run(bi, &mut segs, &mut acc); + } + } + dst +} + +/// Runs `f(y, row)` over the `w`-wide rows of `buf`, in parallel on native targets. +fn par_rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + buf.par_chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row)); + } + #[cfg(target_arch = "wasm32")] + buf.chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row)); +} + +/// [`par_rows`] over three row-aligned buffers in lockstep. +fn par_rows3(a: &mut [f32], b: &mut [f32], c: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32], &mut [f32], &mut [f32]) + Sync + Send) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + a.par_chunks_mut(w).zip(b.par_chunks_mut(w)).zip(c.par_chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c)); + } + #[cfg(target_arch = "wasm32")] + a.chunks_mut(w).zip(b.chunks_mut(w)).zip(c.chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c)); } /// Harris corners: at most `max` corners at least `min_dist` pixels apart, strongest first, @@ -88,28 +161,40 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid: return Vec::new(); } let (mut xx, mut yy, mut xy) = (vec![0.0f32; w * h], vec![0.0f32; w * h], vec![0.0f32; w * h]); - for y in 1..h - 1 { + // Rows are independent (each output pixel reads only `img`), so they run in parallel. + par_rows3(&mut xx, &mut yy, &mut xy, w, |y, xxr, yyr, xyr| { + if y == 0 || y + 1 == h { + return; + } for x in 1..w - 1 { let p = |dx: i64, dy: i64| img[((y as i64 + dy) as usize) * w + (x as i64 + dx) as usize]; // Sobel. let gx = (p(1, -1) + 2.0 * p(1, 0) + p(1, 1)) - (p(-1, -1) + 2.0 * p(-1, 0) + p(-1, 1)); let gy = (p(-1, 1) + 2.0 * p(0, 1) + p(1, 1)) - (p(-1, -1) + 2.0 * p(0, -1) + p(1, -1)); - let i = y * w + x; - xx[i] = gx * gx; - yy[i] = gy * gy; - xy[i] = gx * gy; + xxr[x] = gx * gx; + yyr[x] = gy * gy; + xyr[x] = gx * gy; } - } + }); // Two box passes approximate the Gaussian window. let (xx, yy, xy) = (blur(w, h, &blur(w, h, &xx, 1), 1), blur(w, h, &blur(w, h, &yy, 1), 1), blur(w, h, &blur(w, h, &xy, 1), 1)); - let r: Vec = (0..w * h).map(|i| xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2)).collect(); + let mut r = vec![0.0f32; w * h]; + par_rows(&mut r, w, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * w + x; + *o = xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2); + } + }); let peak = r.iter().copied().fold(0.0f32, f32::max); if peak <= 0.0 { return Vec::new(); } let m = PATCH_RADIUS + 1; - let mut cand: Vec = Vec::new(); - for y in m..h - m { + // Rows are scanned in parallel and joined in row order; the sort below fully orders the + // candidates anyway (score, then y, then x), so the order they arrive in cannot matter. + let rows = par_map((h - m).saturating_sub(m), |i| { + let y = m + i; + let mut found: Vec = Vec::new(); for x in m..w - m { let v = r[y * w + x]; if v < peak * 0.01 { @@ -125,9 +210,11 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid: continue; } } - cand.push(Corner { x: x as f32, y: y as f32, score: v }); + found.push(Corner { x: x as f32, y: y as f32, score: v }); } - } + found + }); + let mut cand: Vec = rows.into_iter().flatten().collect(); cand.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.y.total_cmp(&b.y)).then(a.x.total_cmp(&b.x))); // Minimum spacing via a coarse occupancy grid. let cell = min_dist.max(1.0); @@ -176,37 +263,37 @@ pub fn describe(w: usize, h: usize, img: &[f32], corners: &[Corner], upright: bo let (xi, yi) = ((x.round() as i64).clamp(0, w as i64 - 1) as usize, (y.round() as i64).clamp(0, h as i64 - 1) as usize); smooth[yi * w + xi] }; - corners - .iter() - .map(|c| { - let (s, co) = if upright { - (0.0, 1.0) - } else { - let (mut m10, mut m01) = (0.0f32, 0.0f32); - let r = PATCH_RADIUS as i64; - for dy in -r..=r { - for dx in -r..=r { - if dx * dx + dy * dy <= r * r { - let v = at(c.x + dx as f32, c.y + dy as f32); - m10 += dx as f32 * v; - m01 += dy as f32 * v; - } + // Every descriptor depends only on its own corner and the smoothed image: computed in + // parallel, kept in corner order. + par_map(corners.len(), |ci| { + let c = &corners[ci]; + let (s, co) = if upright { + (0.0, 1.0) + } else { + let (mut m10, mut m01) = (0.0f32, 0.0f32); + let r = PATCH_RADIUS as i64; + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let v = at(c.x + dx as f32, c.y + dy as f32); + m10 += dx as f32 * v; + m01 += dy as f32 * v; } } - m01.atan2(m10).sin_cos() - }; - let mut d = [0u64; 4]; - for (i, p) in pairs().iter().enumerate() { - let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co); - let (ax, ay) = rot(p[0], p[1]); - let (bx, by) = rot(p[2], p[3]); - if at(ax, ay) < at(bx, by) { - d[i / 64] |= 1 << (i % 64); - } } - d - }) - .collect() + m01.atan2(m10).sin_cos() + }; + let mut d = [0u64; 4]; + for (i, p) in pairs().iter().enumerate() { + let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co); + let (ax, ay) = rot(p[0], p[1]); + let (bx, by) = rot(p[2], p[3]); + if at(ax, ay) < at(bx, by) { + d[i / 64] |= 1 << (i % 64); + } + } + d + }) } pub fn hamming(a: &Descriptor, b: &Descriptor) -> u32 { @@ -560,3 +647,121 @@ mod tests { assert_eq!(hamming(&[0, 0, 0, 0], &[1, 3, 0, u64::MAX]), 67); } } + +/// The row / column-band parallel `blur`, `harris` and `describe` against the serial code they +/// replaced. Every output must stay bit for bit the same. +#[cfg(test)] +mod parallel_tests { + use super::*; + + /// The serial blur exactly as it was before the parallelisation. + fn ref_blur(w: usize, h: usize, v: &[f32], r: usize) -> 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) }; + for line in 0..n { + let at = |k: i64| { + let k = k.clamp(0, m as i64 - 1) as usize; + if horizontal { src[line * w + k] } else { src[k * w + line] } + }; + let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum(); + for k in 0..m { + let i = if horizontal { line * w + k } else { k * w + line }; + dst[i] = acc / (2 * r + 1) as f32; + acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64); + } + } + dst + }; + pass(&pass(v, true), false) + } + + fn mix(mut z: u64) -> u64 { + z = z.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Integer-only data with negatives, signed zeros and large values, so a different order of + /// additions cannot hide. + fn noise(len: usize, seed: u64) -> Vec { + (0..len as u64) + .map(|i| { + let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i)); + let v = (z >> 40) as f32 / 16_777_216.0; + match z & 31 { + 0 => -v, + 1 => -0.0, + 2 => v * 1000.0, + _ => v, + } + }) + .collect() + } + + /// Blocks of random brightness: lots of strong corners, no libm in the making. + 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() + } + + fn fnv(parts: &[u64]) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325u64; + for p in parts { + h ^= p; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h + } + + #[test] + fn blur_matches_the_serial_code() { + // Widths around the 64-column band (1 band, band + 1, several bands, a ragged last one), + // 1-wide and 1-high images, and radii 0, 1, 2, larger than the image. + let widths = [1usize, 2, 3, 31, 63, 64, 65, 127, 128, 129, 200]; + let heights = [1usize, 2, 3, 5, 17, 40]; + for (a, &w) in widths.iter().enumerate() { + for (b, &h) in heights.iter().enumerate() { + let v = noise(w * h, (a * 10 + b) as u64); + for r in [0usize, 1, 2, 3, 7, 50] { + let (got, want) = (blur(w, h, &v, r), ref_blur(w, h, &v, r)); + let (g, e): (Vec, Vec) = (got.iter().map(|x| x.to_bits()).collect(), want.iter().map(|x| x.to_bits()).collect()); + assert_eq!(g, e, "blur {w}x{h} r={r}"); + } + } + } + assert!(blur(0, 0, &[], 1).is_empty()); + } + + // `harris` and `describe` go through the platform's hypot / ln / cos / atan2 / sin_cos, so the + // hashes recorded from the old code only hold where those are the same functions. + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + #[test] + fn harris_and_describe_hashes_recorded_from_the_serial_code() { + // Sizes: a whole number of 64-column bands and a ragged one; with and without a validity mask. + for (w, h, max, md, want_corners, want_desc) in + [(211usize, 157usize, 300usize, 6.0f32, 0xeccc6928d3ae3b8cu64, 0xd1e6caae4d68f253u64), (128, 97, 60, 3.0, 0xe3f6615117fa7da7, 0x818ae7e965ecf88f)] + { + let img = blocks(w, h, 7); + let valid: Vec = (0..w * h).map(|i| !((i % w) > w / 2 && (i / w) < h / 3)).collect(); + let mut parts = Vec::new(); + for v in [None, Some(valid.as_slice())] { + let c = harris(w, h, &img, max, md, v); + for k in &c { + parts.extend([k.x.to_bits() as u64, k.y.to_bits() as u64, k.score.to_bits() as u64]); + } + parts.push(c.len() as u64); + } + assert_eq!(fnv(&parts), want_corners, "harris {w}x{h}"); + let c = harris(w, h, &img, max, md, None); + assert!(c.len() > 10, "{} corners", c.len()); + let mut parts = Vec::new(); + for upright in [false, true] { + for d in describe(w, h, &img, &c, upright) { + parts.extend(d); + } + } + assert_eq!(fnv(&parts), want_desc, "describe {w}x{h}"); + } + } +} diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index fd7ccbc591..abe17db395 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -35,7 +35,7 @@ #![allow(clippy::needless_range_loop)] // index loops mirror the maths (pixels × channels) use crate::features::{self, Descriptor, Model}; -use crate::photo_util::{Normal, Rng, par_map, solve_dense}; +use crate::photo_util::{Normal, Rng, par_map, par_rows, solve_dense}; use crate::transform::Homography; /// Photomerge layouts. @@ -996,58 +996,99 @@ pub fn blend_levels(w: usize, h: usize) -> usize { n } +/// `a[i] ⊕ b[i]` for every `i` (the shorter length wins, like `zip`), in parallel on native +/// targets. Purely elementwise, so the result is the serial result bit for bit. +fn par_zip(a: &[f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Vec { + const CHUNK: usize = 1 << 14; + let mut out = vec![0.0f32; a.len().min(b.len())]; + let fill = |o: &mut [f32], a: &[f32], b: &[f32]| { + for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { + *o = f(a, b); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(CHUNK).zip(a.par_chunks(CHUNK)).zip(b.par_chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b)); + } + #[cfg(target_arch = "wasm32")] + out.chunks_mut(CHUNK).zip(a.chunks(CHUNK)).zip(b.chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b)); + out +} + +/// `f(a[i])` for every `i`, in parallel on native targets (elementwise, so bit for bit serial). +fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec { + par_zip(a, a, |x, _| f(x)) +} + +// `reduce`, `expand` and `push_pull` run one pyramid level for one image at a time, and the +// images themselves only fill part of a many-core machine, so each of them also runs its rows +// in parallel. Every output element is computed by the same expression, in the same order, as +// the one-thread loops they replace (rows never read each other's output), so the result is +// bit for bit unchanged. + fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec) { let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); let k = [1.0f32, 4.0, 6.0, 4.0, 1.0]; // Separable 5-tap binomial, then decimate. let mut tmp = vec![0.0f32; nw * h * ch]; - for y in 0..h { + par_rows(&mut tmp, nw, ch, |y, row| { + let line = &src[y * w * ch..(y + 1) * w * ch]; for x in 0..nw { + // The clamped source column of each tap is the same for every channel. + let xs: [usize; 5] = std::array::from_fn(|t| (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize * ch); for c in 0..ch { let mut s = 0.0; - for (t, kv) in k.iter().enumerate() { - let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize; - s += kv * src[(y * w + xx) * ch + c]; + for (kv, xx) in k.iter().zip(xs) { + s += kv * line[xx + c]; } - tmp[(y * nw + x) * ch + c] = s / 16.0; + row[x * ch + c] = s / 16.0; } } - } + }); let mut out = vec![0.0f32; nw * nh * ch]; - for y in 0..nh { - for x in 0..nw { - for c in 0..ch { - let mut s = 0.0; - for (t, kv) in k.iter().enumerate() { - let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize; - s += kv * tmp[(yy * nw + x) * ch + c]; - } - out[(y * nw + x) * ch + c] = s / 16.0; + par_rows(&mut out, nw, ch, |y, row| { + // Each output row is five source rows added in tap order: the same sum per element as + // before (`0 + k0·a + k1·b + …`), but over whole rows so it vectorises. + for (t, kv) in k.iter().enumerate() { + let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize; + for (o, &v) in row.iter_mut().zip(&tmp[yy * nw * ch..(yy + 1) * nw * ch]) { + *o += kv * v; } } - } + for o in row.iter_mut() { + *o /= 16.0; + } + }); (nw, nh, out) } fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec { // Bilinear upsampling at half-pixel-aligned positions. let mut out = vec![0.0f32; w * h * ch]; - for y in 0..h { - let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32); - let (y0, ty) = (fy.floor() as usize, fy - fy.floor()); - let y1 = (y0 + 1).min(sh - 1); - for x in 0..w { + // Source columns and weights depend on x only: worked out once instead of for every row. + // The clamped position is never negative, so truncating is `floor` (and spares the libm call). + let cols: Vec<(usize, usize, f32)> = (0..w) + .map(|x| { let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32); - let (x0, tx) = (fx.floor() as usize, fx - fx.floor()); - let x1 = (x0 + 1).min(sw - 1); - for c in 0..ch { - let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c]; - let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx; - let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx; - out[(y * w + x) * ch + c] = top + (bot - top) * ty; + let x0 = fx as usize; + (x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32) + }) + .collect(); + par_rows(&mut out, w, ch, |y, row| { + let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32); + let y0 = fy as usize; + let ty = fy - y0 as f32; + let y1 = (y0 + 1).min(sh - 1); + let (r0, r1) = (&src[y0 * sw * ch..(y0 + 1) * sw * ch], &src[y1 * sw * ch..(y1 + 1) * sw * ch]); + for (px, &(x0, x1, tx)) in row.chunks_exact_mut(ch.max(1)).zip(&cols) { + for (c, o) in px.iter_mut().enumerate() { + let top = r0[x0 + c] + (r0[x1 + c] - r0[x0 + c]) * tx; + let bot = r1[x0 + c] + (r1[x1 + c] - r1[x0 + c]) * tx; + *o = top + (bot - top) * ty; } } - } + }); out } @@ -1058,41 +1099,75 @@ fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) { return; } let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); + // Coarse sums. A coarse row is fed by two fine rows; walking them in raster order adds each + // coarse cell's (up to) four fine pixels in the order the serial scatter-add did, from zero. let mut cpx = vec![0.0f32; nw * nh * ch]; let mut cw = vec![0.0f32; nw * nh]; - for y in 0..h { - for x in 0..w { - let (i, j) = (y * w + x, (y / 2) * nw + x / 2); - let a = wt[i]; - cw[j] += a; - for c in 0..ch { - cpx[j * ch + c] += a * px[i * ch + c]; + par_rows(&mut cw, nw, 1, |cy, row| { + for y in 2 * cy..(2 * cy + 2).min(h) { + for (x, &a) in wt[y * w..(y + 1) * w].iter().enumerate() { + row[x / 2] += a; } } - } - for j in 0..nw * nh { - if cw[j] > 0.0 { - for c in 0..ch { - cpx[j * ch + c] /= cw[j]; + }); + let px_in: &[f32] = px; + par_rows(&mut cpx, nw, ch, |cy, row| { + for y in 2 * cy..(2 * cy + 2).min(h) { + for x in 0..w { + let (i, j) = (y * w + x, x / 2); + let a = wt[i]; + for c in 0..ch { + row[j * ch + c] += a * px_in[i * ch + c]; + } } - cw[j] = 1.0; + } + }); + // Normalise by the weight (this reads `cw` before it is reset to 0 / 1 below). + par_rows(&mut cpx, nw, ch, |cy, row| { + for cx in 0..nw { + let wj = cw[cy * nw + cx]; + if wj > 0.0 { + for c in 0..ch { + row[cx * ch + c] /= wj; + } + } + } + }); + for v in cw.iter_mut() { + if *v > 0.0 { + *v = 1.0; } } if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) { push_pull(nw, nh, ch, &mut cpx, &cw); } - for y in 0..h { + par_rows(px, w, ch, |y, row| { for x in 0..w { let i = y * w + x; if wt[i] < 1.0 { let j = (y / 2) * nw + x / 2; let a = wt[i]; for c in 0..ch { - px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c]; + row[x * ch + c] = a * row[x * ch + c] + (1.0 - a) * cpx[j * ch + c]; } } } - } + }); +} + +/// One image's pyramid in [`multiband`]: the grid origin `(gx0, gy0)` at level 0, the Laplacian +/// levels `(w, h, colours)` and the Gaussian weight levels. +type PyramidBand = (i32, i32, Vec<(usize, usize, Vec)>, Vec>); + +/// The part of `band` on panorama row `py` of level `l`: the panorama column its first pixel +/// lands on, its width, its weight row and its colour row. `None` when it misses the row. +fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize, usize, &[f32], &[f32])> { + let (ox, oy) = ((band.0 >> l) as usize, (band.1 >> l) as usize); + let (lw, lh, colours) = &band.2[l]; + (py >= oy && py - oy < *lh).then(|| { + let y = py - oy; + (ox, *lw, &band.3[l][y * lw..(y + 1) * lw], &colours[y * lw * ch..(y + 1) * lw * ch]) + }) } /// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image @@ -1119,22 +1194,37 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le 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); + // 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 { + let (x, y) = (gx as i64 + gx0 as i64 - im.x0 as i64, gy as i64 + gy0 as i64 - im.y0 as i64); + (x >= 0 && y >= 0 && (x as usize) < im.w && (y as usize) < im.h).then(|| y as usize * im.w + x as usize) + }; let mut px = vec![0.0f32; gw * gh * ch]; - let mut cov = vec![0.0f32; gw * gh]; - let mut wt = vec![0.0f32; gw * gh]; - for y in 0..im.h { - for x in 0..im.w { - let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0); - if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh { - continue; + par_rows(&mut px, gw, ch, |gy, row| { + for gx in 0..gw { + if let Some(i) = src_of(gx, gy) { + row[gx * ch..(gx + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]); } - let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize); - cov[g] = im.alpha[i]; - wt[g] = weights[k][i] * im.alpha[i].min(1.0); - px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]); } - } - let bin: Vec = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect(); + }); + let mut cov = vec![0.0f32; gw * gh]; + par_rows(&mut cov, gw, 1, |gy, row| { + for (gx, o) in row.iter_mut().enumerate() { + if let Some(i) = src_of(gx, gy) { + *o = im.alpha[i]; + } + } + }); + let mut wt = vec![0.0f32; gw * gh]; + par_rows(&mut wt, gw, 1, |gy, row| { + for (gx, o) in row.iter_mut().enumerate() { + if let Some(i) = src_of(gx, gy) { + *o = weights[k][i] * im.alpha[i].min(1.0); + } + } + }); + let bin: Vec = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 }); push_pull(gw, gh, ch, &mut px, &bin); // Laplacian of the colours, Gaussian of the weights. let mut lap: Vec<(usize, usize, Vec)> = Vec::new(); @@ -1148,7 +1238,7 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le } let (nw, nh, down) = reduce(cw, chh, ch, &cur); let up = expand(nw, nh, ch, &down, cw, chh); - lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect())); + lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b))); gau.push(cwt.clone()); let (_, _, wdown) = reduce(cw, chh, 1, &cwt); cw = nw; @@ -1158,41 +1248,57 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le } (gx0, gy0, lap, gau) }); - for (gx0, gy0, lap, gau) in &bands { - for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() { - let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize); - let (pw, ph) = sizes[l]; - for y in 0..*lh { - let py = oy + y; - if py >= ph { - break; - } - for x in 0..*lw { + // 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 (i, p) = (y * lw + x, py * pw + px); - let k = g[i]; + 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; } - wsum[l][p] += k; for c in 0..ch { - acc[l][p * ch + c] += k * band[i * ch + c]; + row[px * ch + c] += k * band[x * ch + c]; } } } - } + }); } for l in 0..levels { - let (pw, ph) = sizes[l]; - for p in 0..pw * ph { - let k = wsum[l][p]; - for c in 0..ch { - acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 }; + let pw = sizes[l].0; + let ws = &wsum[l]; + par_rows(&mut acc[l], pw, ch, |py, row| { + for x in 0..pw { + let k = ws[py * pw + x]; + for c in 0..ch { + row[x * ch + c] = if k > 1e-8 { row[x * ch + c] / k } else { 0.0 }; + } } - } + }); } // Collapse. // `levels ≥ 1`, so there is always a coarsest level. @@ -1201,22 +1307,27 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le let (pw, ph) = sizes[l]; let (sw, sh) = sizes[l + 1]; let up = expand(sw, sh, ch, &cur, pw, ph); - cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect(); + cur = par_zip(&acc[l], &up, |a, b| a + b); } - // Coverage: union of the images. + // Coverage: union of the images. Rows are independent; each walks the images in order, so a + // pixel sees the same sequence of `max` calls as in the serial loop. let mut cov = vec![0.0f32; w * h]; - for im in imgs { - for y in 0..im.h { + par_rows(&mut cov, w, 1, |py, row| { + for im in imgs { + let y = py as i64 - im.y0 as i64; + if y < 0 || y as usize >= im.h { + continue; + } for x in 0..im.w { - let (px, py) = (im.x0 + x as i32, im.y0 + y as i32); - if px < 0 || py < 0 || px as usize >= w || py as usize >= h { + let px = im.x0 + x as i32; + if px < 0 || px as usize >= w { continue; } - let p = py as usize * w + px as usize; - cov[p] = cov[p].max(im.alpha[y * im.w + x]); + let p = px as usize; + row[p] = row[p].max(im.alpha[y as usize * im.w + x]); } } - } + }); (cur, cov) } @@ -1431,3 +1542,389 @@ mod tests { assert!(cov.iter().all(|c| *c == 1.0)); } } + +/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they +/// replaced, kept below verbatim as the reference. Every output must stay bit for bit the same. +#[cfg(test)] +mod parallel_tests { + use super::*; + + fn mix(mut z: u64) -> u64 { + z = z.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Deterministic data from integer arithmetic only (no libm): mostly `0..1`, with negatives, + /// signed zeros and large values so a different order of operations cannot hide. + fn noise(len: usize, seed: u64) -> Vec { + (0..len as u64) + .map(|i| { + let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i)); + let v = (z >> 40) as f32 / 16_777_216.0; + match z & 31 { + 0 => -v, + 1 => -0.0, + 2 => v * 1000.0, + 3 => 0.0, + _ => v, + } + }) + .collect() + } + + /// Weights with exact zeros, exact ones and partial values, in blobs and noise. + fn weights(w: usize, h: usize, seed: u64) -> Vec { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let z = mix(seed + i as u64); + if (x / 5 + y / 3) % 3 == 0 { + 0.0 + } else if z.is_multiple_of(7) { + ((z >> 20) % 100) as f32 / 100.0 + } else { + 1.0 + } + }) + .collect() + } + + fn bits(v: &[f32]) -> Vec { + v.iter().map(|x| x.to_bits()).collect() + } + + fn fnv(parts: &[&[f32]]) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325u64; + for p in parts { + for x in *p { + h ^= x.to_bits() as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + } + h + } + + /// Sizes that cross the odd / even and tiny cases (1 wide, 1 high, 1×1, odd, power of two + 1). + const SIZES: [(usize, usize); 12] = [(1, 1), (1, 9), (9, 1), (2, 2), (3, 5), (4, 4), (17, 13), (64, 40), (65, 41), (97, 61), (130, 7), (7, 130)]; + + fn ref_reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec) { + let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); + let k = [1.0f32, 4.0, 6.0, 4.0, 1.0]; + // Separable 5-tap binomial, then decimate. + let mut tmp = vec![0.0f32; nw * h * ch]; + for y in 0..h { + for x in 0..nw { + for c in 0..ch { + let mut s = 0.0; + for (t, kv) in k.iter().enumerate() { + let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize; + s += kv * src[(y * w + xx) * ch + c]; + } + tmp[(y * nw + x) * ch + c] = s / 16.0; + } + } + } + let mut out = vec![0.0f32; nw * nh * ch]; + for y in 0..nh { + for x in 0..nw { + for c in 0..ch { + let mut s = 0.0; + for (t, kv) in k.iter().enumerate() { + let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize; + s += kv * tmp[(yy * nw + x) * ch + c]; + } + out[(y * nw + x) * ch + c] = s / 16.0; + } + } + } + (nw, nh, out) + } + + fn ref_expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec { + // Bilinear upsampling at half-pixel-aligned positions. + let mut out = vec![0.0f32; w * h * ch]; + for y in 0..h { + let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32); + let (y0, ty) = (fy.floor() as usize, fy - fy.floor()); + let y1 = (y0 + 1).min(sh - 1); + for x in 0..w { + let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32); + let (x0, tx) = (fx.floor() as usize, fx - fx.floor()); + let x1 = (x0 + 1).min(sw - 1); + for c in 0..ch { + let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c]; + let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx; + let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx; + out[(y * w + x) * ch + c] = top + (bot - top) * ty; + } + } + } + out + } + + fn ref_push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) { + if w <= 1 && h <= 1 { + return; + } + let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); + let mut cpx = vec![0.0f32; nw * nh * ch]; + let mut cw = vec![0.0f32; nw * nh]; + for y in 0..h { + for x in 0..w { + let (i, j) = (y * w + x, (y / 2) * nw + x / 2); + let a = wt[i]; + cw[j] += a; + for c in 0..ch { + cpx[j * ch + c] += a * px[i * ch + c]; + } + } + } + for j in 0..nw * nh { + if cw[j] > 0.0 { + for c in 0..ch { + cpx[j * ch + c] /= cw[j]; + } + cw[j] = 1.0; + } + } + if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) { + ref_push_pull(nw, nh, ch, &mut cpx, &cw); + } + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if wt[i] < 1.0 { + let j = (y / 2) * nw + x / 2; + let a = wt[i]; + for c in 0..ch { + px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c]; + } + } + } + } + } + + fn ref_multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> (Vec, Vec) { + let ch = imgs.first().map_or(1, |i| i.ch); + let levels = levels.max(1); + let align = 1i32 << (levels - 1); + // Level sizes of the panorama. + let mut sizes = vec![(w, h)]; + for _ in 1..levels { + let Some(&(pw, ph)) = sizes.last() else { break }; + sizes.push((pw.div_ceil(2), ph.div_ceil(2))); + } + let mut acc: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh * ch]).collect(); + 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 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); + let mut px = vec![0.0f32; gw * gh * ch]; + let mut cov = vec![0.0f32; gw * gh]; + let mut wt = vec![0.0f32; gw * gh]; + for y in 0..im.h { + for x in 0..im.w { + let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0); + if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh { + continue; + } + let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize); + cov[g] = im.alpha[i]; + wt[g] = weights[k][i] * im.alpha[i].min(1.0); + px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]); + } + } + let bin: Vec = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect(); + ref_push_pull(gw, gh, ch, &mut px, &bin); + // Laplacian of the colours, Gaussian of the weights. + 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()); + break; + } + let (nw, nh, down) = ref_reduce(cw, chh, ch, &cur); + let up = ref_expand(nw, nh, ch, &down, cw, chh); + lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect())); + gau.push(cwt.clone()); + let (_, _, wdown) = ref_reduce(cw, chh, 1, &cwt); + cw = nw; + chh = nh; + cur = down; + cwt = wdown; + } + (gx0, gy0, lap, gau) + }); + for (gx0, gy0, lap, gau) in &bands { + for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() { + let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize); + let (pw, ph) = sizes[l]; + for y in 0..*lh { + let py = oy + y; + if py >= ph { + break; + } + for x in 0..*lw { + let px = ox + x; + if px >= pw { + break; + } + let (i, p) = (y * lw + x, py * pw + px); + let k = g[i]; + if k <= 0.0 { + continue; + } + wsum[l][p] += k; + for c in 0..ch { + acc[l][p * ch + c] += k * band[i * ch + c]; + } + } + } + } + } + for l in 0..levels { + let (pw, ph) = sizes[l]; + for p in 0..pw * ph { + let k = wsum[l][p]; + for c in 0..ch { + acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 }; + } + } + } + // Collapse. + // `levels ≥ 1`, so there is always a coarsest level. + let Some(mut cur) = acc.pop() else { return (vec![0.0; w * h * ch], vec![0.0; w * h]) }; + for l in (0..levels - 1).rev() { + let (pw, ph) = sizes[l]; + let (sw, sh) = sizes[l + 1]; + let up = ref_expand(sw, sh, ch, &cur, pw, ph); + cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect(); + } + // Coverage: union of the images. + let mut cov = vec![0.0f32; w * h]; + for im in imgs { + for y in 0..im.h { + for x in 0..im.w { + let (px, py) = (im.x0 + x as i32, im.y0 + y as i32); + if px < 0 || py < 0 || px as usize >= w || py as usize >= h { + continue; + } + let p = py as usize * w + px as usize; + cov[p] = cov[p].max(im.alpha[y * im.w + x]); + } + } + } + (cur, cov) + } + + #[test] + fn reduce_and_expand_match_the_serial_code() { + for (n, &(w, h)) in SIZES.iter().enumerate() { + for ch in [1usize, 3, 4] { + let src = noise(w * h * ch, n as u64 * 7 + ch as u64); + let (nw, nh, got) = reduce(w, h, ch, &src); + let (rw, rh, want) = ref_reduce(w, h, ch, &src); + assert_eq!((nw, nh), (rw, rh)); + assert_eq!(bits(&got), bits(&want), "reduce {w}x{h}x{ch}"); + // Expand back to the original size and to sizes that are not twice the source's. + for (ew, eh) in [(w, h), (2 * nw, 2 * nh), (2 * nw - 1, 2 * nh + 1), (w + 3, h + 5)] { + let got = expand(nw, nh, ch, &want, ew, eh); + let want_up = ref_expand(nw, nh, ch, &want, ew, eh); + assert_eq!(bits(&got), bits(&want_up), "expand {nw}x{nh}x{ch} to {ew}x{eh}"); + } + } + } + } + + #[test] + fn push_pull_matches_the_serial_code() { + for (n, &(w, h)) in SIZES.iter().enumerate() { + for ch in [1usize, 3] { + for variant in 0..3u64 { + let wt = match variant { + 0 => weights(w, h, n as u64), + 1 => vec![1.0; w * h], + // A single covered pixel: every level of the recursion has to fill from it. + _ => (0..w * h).map(|i| if i == (w * h) / 3 { 1.0 } else { 0.0 }).collect(), + }; + let px = noise(w * h * ch, 100 + n as u64); + let (mut got, mut want) = (px.clone(), px); + push_pull(w, h, ch, &mut got, &wt); + ref_push_pull(w, h, ch, &mut want, &wt); + assert_eq!(bits(&got), bits(&want), "push_pull {w}x{h}x{ch} variant {variant}"); + } + } + } + } + + /// ROIs of assorted sizes and offsets (some sticking out of the canvas), with holes in the alpha. + fn rois(w: usize, h: usize, ch: usize, seed: u64) -> (Vec, Vec>) { + let specs = + [(0i32, 0i32, w * 2 / 3, h), (w as i32 / 3, -(h as i32) / 5, w * 2 / 3 + 1, h * 3 / 4 + 1), (-(w as i32) / 4, h as i32 / 2, w / 2 + 1, h / 2 + 3)]; + let imgs: Vec = specs + .iter() + .enumerate() + .map(|(k, &(x0, y0, iw, ih))| { + let (iw, ih) = (iw.max(1), ih.max(1)); + let mut alpha = vec![1.0f32; iw * ih]; + for (i, a) in alpha.iter_mut().enumerate() { + let z = mix(seed + k as u64 * 1000 + i as u64); + if (i % iw) / 4 % 5 == 2 && (i / iw) % 6 < 3 { + *a = 0.0; + } else if z.is_multiple_of(11) { + *a = 0.3; + } + } + RoiImage { x0, y0, w: iw, h: ih, ch, px: noise(iw * ih * ch, seed + k as u64), alpha } + }) + .collect(); + let wts = imgs.iter().enumerate().map(|(k, r)| weights(r.w, r.h, seed + 50 + k as u64)).collect(); + (imgs, wts) + } + + #[test] + fn multiband_matches_the_serial_code() { + for (n, &(w, h)) in [(1usize, 1usize), (5, 3), (40, 30), (97, 61), (130, 70), (64, 64), (201, 129)].iter().enumerate() { + for ch in [1usize, 3] { + let (imgs, wts) = rois(w, h, ch, n as u64 * 31 + ch as u64); + for levels in [1usize, 2, 3, 5, 7] { + let got = multiband(w, h, &imgs, &wts, levels); + let want = ref_multiband(w, h, &imgs, &wts, levels); + assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels}"); + assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels}"); + } + } + } + // No images at all, and zero levels. + assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len()); + } + + /// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so + /// they hold on every platform). + #[test] + fn multiband_hashes_recorded_from_the_serial_code() { + let (imgs, wts) = rois(97, 61, 3, 5); + let (px, cov) = multiband(97, 61, &imgs, &wts, 5); + assert_eq!(fnv(&[&px, &cov]), 0xf89a5726082ebb6c, "97x61 rgb, 5 levels"); + let (imgs, wts) = rois(130, 70, 1, 9); + let (px, cov) = multiband(130, 70, &imgs, &wts, 7); + assert_eq!(fnv(&[&px, &cov]), 0xb7d7ddc59d201730, "130x70 grey, 7 levels"); + let wt = weights(65, 41, 3); + let mut px = noise(65 * 41 * 3, 4); + push_pull(65, 41, 3, &mut px, &wt); + assert_eq!(fnv(&[&px]), 0x50ea36a84ca9a68f, "push_pull 65x41 rgb"); + let (nw, nh, r) = reduce(65, 41, 3, &px); + assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41)]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb"); + } +} 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 101/171] 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 102/171] 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 45afd2e9d7c8b6391a1a72e50b1e3bfb07e4e1ba Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 17 Oct 2026 18:37:12 +0300 Subject: [PATCH 103/171] 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 104/171] 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 074d4b08b6e4121e074d670d8e30fcd802c08805 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 08:53:44 -0700 Subject: [PATCH 105/171] 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 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 106/171] 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 107/171] 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 108/171] 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 109/171] 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 110/171] 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 111/171] 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