From bcaadccbfab3f31c103921d3c77c3c3996ee26f2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Thu, 15 Oct 2026 00:02:09 +0200 Subject: [PATCH 01/90] Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) (#5204) * Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) - flatten: a path's flattened points are capped at 1 << 20; past it every curve keeps a proportional share of its pieces (at least one), so hostile handles from files (vmsk, SVG, effect outlines) cannot exhaust memory. fill_rasterizer flattens the whole path under one budget. - stroke: past the dash step budget (or below coordinate precision), dash_polyline keeps the dashes made so far and draws the rest solid instead of dropping the stroke. Open polylines are no longer copied. - raster: coordinates are clamped to +-1e15 and edges with a non-finite slope (vanishingly short) are skipped, so huge finite coordinates no longer produce NaN coverage. fill_rasterizer sorts edges once instead of after every component (stable sort, identical order). - Cleanups: dead miter `half`, dead loop-length check in trace_mask, shared mask render body, map_knots helper, shared straight-segment test. Co-Authored-By: Claude Opus 5.5 * Vector: bound stroke outline points (#5211) - stroke_polygons dashes all lines first, then counts round caps and joins. When their full-resolution tessellation would exceed 1 << 20 outline points, every round cap and join gets the same smaller segment count (down to 4 per circle, 1 per join; area-exact radius kept). Strokes under the budget are unchanged; no piece is dropped. - The dash step budget (100000) is now shared by all lines of a stroke instead of per polyline, so many subpaths cannot multiply the dash (and cap) count. Past it the rest is drawn solid, as before. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/vector/src/edit.rs | 12 ++- crates/vector/src/flatten.rs | 62 ++++++++++- crates/vector/src/lib.rs | 32 +++--- crates/vector/src/map.rs | 15 ++- crates/vector/src/raster.rs | 69 ++++++++++++- crates/vector/src/stroke.rs | 193 ++++++++++++++++++++++++++++------- crates/vector/src/trace.rs | 4 +- 7 files changed, 318 insertions(+), 69 deletions(-) diff --git a/crates/vector/src/edit.rs b/crates/vector/src/edit.rs index d08d762121..7f1f4d5823 100644 --- a/crates/vector/src/edit.rs +++ b/crates/vector/src/edit.rs @@ -80,6 +80,12 @@ fn handle(k: &Knot, h: Handle) -> Point { } } +/// Whether the segment from knot `a` to knot `b` is straight: both of its handles retracted. +fn straight(a: &Knot, b: &Knot) -> bool { + let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; + retracted(a, Handle::Out) && retracted(b, Handle::In) +} + fn other(h: Handle) -> Handle { match h { Handle::In => Handle::Out, @@ -178,10 +184,9 @@ pub fn convert_point(path: &mut Path, knot: [usize; 2], out: Option) -> R /// the pointer exactly, with the nearer handle moving more. Smooth end knots stay smooth. pub fn bend_segment(path: &mut Path, knot: [usize; 2], t: f64, d: [f64; 2]) -> Result<(), String> { let end = segment_end(path, knot)?; - let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; - if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { + if straight(&a, &b) { return move_anchors(path, &[knot, end], d); } // B(t) moves by 3(1−t)²t·Δc1 + 3(1−t)t²·Δc2. With Δc1 = (1−t)·s·d and Δc2 = t·s·d that is @@ -213,10 +218,9 @@ pub fn add_anchor(path: &mut Path, knot: [usize; 2], t: f64) -> Result<[usize; 2 return Err("`t` must be strictly between 0 and 1".into()); } let end = segment_end(path, knot)?; - let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; - let new = if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { + let new = if straight(&a, &b) { // Hit testing and hover feedback use the cubic parameter, which is not linear even // when both handles are retracted. Keep the inserted corner at that same point. let p = eval(&[a.anchor, a.out_ctrl, b.in_ctrl, b.anchor], t); diff --git a/crates/vector/src/flatten.rs b/crates/vector/src/flatten.rs index b3fd0a90f6..a586dfa1bf 100644 --- a/crates/vector/src/flatten.rs +++ b/crates/vector/src/flatten.rs @@ -109,8 +109,38 @@ pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) Ok(()) } -/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. +/// Points one flattened path may hold. Paths that never pass [`validate_shape`] (vector masks +/// from files, imported SVG, effect outlines) are flattened more coarsely past it, so hostile +/// curves cannot exhaust memory. +pub(crate) const MAX_PATH_POINTS: usize = 1 << 20; + +/// How much to coarsen the curves of `subpaths` at `tol` so the whole path stays within +/// [`MAX_PATH_POINTS`]: the share of each curve's pieces to keep (1 = all of them). +fn piece_scale<'a>(subpaths: impl IntoIterator, tol: f64) -> f64 { + // Subpath starts and straight segments take one point each and cannot be coarsened. + let (mut fixed, mut curves) = (0usize, 0usize); + for s in subpaths { + fixed = fixed.saturating_add(1); + for seg in s.segments() { + if is_line(&seg) { + fixed = fixed.saturating_add(1); + } else { + curves = curves.saturating_add(pieces(&seg, tol)); + } + } + } + let total = fixed.saturating_add(curves); + if total <= MAX_PATH_POINTS { 1.0 } else { MAX_PATH_POINTS as f64 / total as f64 } +} + +/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. A hostile +/// subpath is flattened more coarsely so it stays within a bounded number of points. pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { + flatten_scaled(s, tol, piece_scale([s], tol)) +} + +/// [`flatten_subpath`] keeping the share `scale` of each curve's pieces (at least one). +fn flatten_scaled(s: &Subpath, tol: f64, scale: f64) -> Polyline { let mut out = Polyline { closed: s.closed, ..Default::default() }; if let Some(k) = s.knots.first() { out.push((k.anchor.x, k.anchor.y), true); @@ -121,6 +151,7 @@ pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { continue; } let n = pieces(seg, tol); + let n = if scale < 1.0 { ((n as f64 * scale) as usize).max(1) } else { n }; for j in 1..=n { out.push(eval(seg, j as f64 / n as f64), j == n); } @@ -136,15 +167,18 @@ pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { out } -/// Flattens every subpath (see [`flatten_subpath`]). +/// Flattens every subpath (see [`flatten_subpath`]); a hostile path is flattened more coarsely +/// so the whole path stays within a bounded number of points. pub fn flatten_path(p: &Path, tol: f64) -> Vec { - p.subpaths.iter().map(|s| flatten_subpath(s, tol)).collect() + let scale = piece_scale(&p.subpaths, tol); + p.subpaths.iter().map(|s| flatten_scaled(s, tol, scale)).collect() } #[cfg(test)] mod tests { use super::*; use crate::shapes; + use photocraft_doc::Knot; #[test] fn lines_are_not_subdivided() { @@ -168,4 +202,26 @@ mod tests { assert!(p.pts.len() < 4 * (1 + (0.75 * 100.0 / tol.min(0.15)).sqrt() as usize + 1)); } } + + #[test] + fn hostile_curves_flatten_within_the_point_budget() { + // Unvalidated paths (a vector mask from a file, an imported SVG) can carry handles far + // outside the canvas: each of these segments alone asks for the 65536-piece cap, so the + // path would flatten to ~1.3M points (#5195). + let segments = 20; + let knots = (0..=segments) + .map(|i| { + let a = Point::new(f64::from(i) * 50.0, f64::from(i % 2) * 1000.0); + Knot::smooth(a, Point::new(a.x - 3e7, a.y + 3e7), Point::new(a.x + 3e7, a.y - 3e7)) + }) + .collect(); + let path = Path::new(vec![Subpath { closed: false, knots, op: Default::default() }]); + let lines = flatten_path(&path, 0.01); + let points: usize = lines.iter().map(|l| l.pts.len()).sum(); + assert!(points > 1000 && points <= MAX_PATH_POINTS + segments as usize + 1, "{points} points"); + assert_eq!(flatten_subpath(&path.subpaths[0], 0.01).pts.len(), points); + // Ordinary paths are flattened exactly as before. + let circle = shapes::ellipse(0.0, 0.0, 200.0, 200.0); + assert_eq!(flatten_path(&circle, 0.01)[0], flatten_scaled(&circle.subpaths[0], 0.01, 1.0)); + } } diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index 44e70ce760..d336435492 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -43,10 +43,12 @@ pub const DEFAULT_TOLERANCE: f64 = 0.01; /// folded with the subpath operations; open subpaths are closed implicitly. pub fn fill_rasterizer(path: &Path, tol: f64) -> Rasterizer { let mut r = Rasterizer::new(path.inverted); + // One point budget for the whole path, then one edge sort for all components. + let polys: Vec> = flatten_path(path, tol).into_iter().map(|l| l.pts).collect(); for c in path.components() { - let polys: Vec> = path.subpaths[c.clone()].iter().map(|s| flatten_subpath(s, tol).pts).collect(); - r.add_component(&polys, path.effective_op(c.start), path.fill_rule); + r.push_component(polys.get(c.clone()).unwrap_or_default(), path.effective_op(c.start), path.fill_rule); } + r.sort_edges(); r } @@ -105,26 +107,26 @@ impl CompiledVectorMask { /// Effective mask values over `rect`, with invocation-local rasterization state. pub fn render(&self, rect: Rect) -> Vec { - let mut v = match &self.geometry { - MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], - MaskGeometry::Path(rasterizer) => rasterizer.render(rect), - }; - self.apply_density(&mut v); - v + self.render_with(rect, Rasterizer::render) } /// Effective mask values without entering a thread pool. Use this while /// initializing a shared result that other Rayon workers may wait for. pub fn render_sequential(&self, rect: Rect) -> Vec { + self.render_with(rect, |rasterizer, rect| { + let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; + if !v.is_empty() { + rasterizer.render_into(rect, &mut v); + } + v + }) + } + + /// Mask values over `rect`, with a path's coverage from `coverage`. + fn render_with(&self, rect: Rect, coverage: impl FnOnce(&Rasterizer, Rect) -> Vec) -> Vec { let mut v = match &self.geometry { MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], - MaskGeometry::Path(rasterizer) => { - let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; - if !v.is_empty() { - rasterizer.render_into(rect, &mut v); - } - v - } + MaskGeometry::Path(rasterizer) => coverage(rasterizer, rect), }; self.apply_density(&mut v); v diff --git a/crates/vector/src/map.rs b/crates/vector/src/map.rs index ab2e62e1e8..6078e604f8 100644 --- a/crates/vector/src/map.rs +++ b/crates/vector/src/map.rs @@ -15,13 +15,18 @@ use photocraft_geom::Point; pub fn map_points(path: &Path, f: impl Fn(Point) -> Point) -> Path { let mut out = path.clone(); for s in &mut out.subpaths { - for k in &mut s.knots { - *k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth }; - } + map_knots(s, &f); } out } +/// Sends every anchor and control point of `s` through `f`, in place. +fn map_knots(s: &mut Subpath, f: &impl Fn(Point) -> Point) { + for k in &mut s.knots { + *k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth }; + } +} + /// Pieces a path may be split into, in total: bounds the work and memory on hostile input. const MAX_PIECES: usize = 1 << 20; /// Deepest split of one segment (2^6 = 64 pieces). @@ -47,7 +52,9 @@ fn map_subpath(s: &Subpath, f: &impl Fn(Point) -> Point, tol: f64, depth: u32) - let n = s.knots.len(); let segs = s.segments(); if segs.is_empty() { - return map_points(&Path::new(vec![s.clone()]), f).subpaths.pop().unwrap_or_default(); + let mut out = s.clone(); + map_knots(&mut out, f); + return out; } // The mapped pieces of each segment (segment i runs from knot i to knot i + 1). let pieces: Vec> = segs.iter().map(|seg| map_segment(seg, f, tol, depth)).collect(); diff --git a/crates/vector/src/raster.rs b/crates/vector/src/raster.rs index ba2cdd21ff..eb6c4a943b 100644 --- a/crates/vector/src/raster.rs +++ b/crates/vector/src/raster.rs @@ -37,6 +37,10 @@ impl Edge { } } +/// Polygon coordinates are clamped to `±COORD_LIMIT`: far outside any `i32` pixel rectangle, +/// and small enough that edge slopes and positions never overflow to infinity or NaN. +const COORD_LIMIT: f64 = 1e15; + /// A combination step: a component (set of polygons filled with `rule`) and how it folds into /// the result so far. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -66,6 +70,19 @@ impl Rasterizer { /// Adds a component made of closed polygons (open input is closed implicitly). pub fn add_component(&mut self, polys: &[Vec<(f64, f64)>], op: PathOp, rule: FillRule) { + self.push_component(polys, op, rule); + self.sort_edges(); + } + + /// Restores the `y0` order of the edges after [`Self::push_component`]. The sort is stable, + /// so sorting once after many components orders the edges exactly like sorting after each. + pub(crate) fn sort_edges(&mut self) { + self.edges.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + } + + /// [`Self::add_component`] without restoring the edge order: callers adding many components + /// sort once at the end with [`Self::sort_edges`]. + pub(crate) fn push_component(&mut self, polys: &[Vec<(f64, f64)>], op: PathOp, rule: FillRule) { let comp = self.comps.len() as u32; self.comps.push(Component { op, rule }); if op != PathOp::Combine && comp != 0 { @@ -77,11 +94,13 @@ impl Rasterizer { continue; } for i in 0..n { - let a = poly[i]; - let b = poly[(i + 1) % n]; + let (a, b) = (poly[i], poly[(i + 1) % n]); if !(a.0.is_finite() && a.1.is_finite() && b.0.is_finite() && b.1.is_finite()) { continue; } + // Far beyond any pixel rectangle; keeps edge arithmetic from overflowing. + let clamp = |p: (f64, f64)| (p.0.clamp(-COORD_LIMIT, COORD_LIMIT), p.1.clamp(-COORD_LIMIT, COORD_LIMIT)); + let (a, b) = (clamp(a), clamp(b)); self.bounds = Some(match self.bounds { None => (a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)), Some(bb) => (bb.0.min(a.0.min(b.0)), bb.1.min(a.1.min(b.1)), bb.2.max(a.0.max(b.0)), bb.3.max(a.1.max(b.1))), @@ -90,10 +109,15 @@ impl Rasterizer { continue; } let (p, q, dir) = if a.1 < b.1 { (a, b, 1) } else { (b, a, -1) }; - self.edges.push(Edge { x0: p.0, y0: p.1, y1: q.1, dir, comp, dxdy: (q.0 - p.0) / (q.1 - p.1) }); + let dxdy = (q.0 - p.0) / (q.1 - p.1); + // Only a vanishingly short edge has an infinite slope once clamped: like a + // horizontal edge it covers no area, and it would turn coverage into NaN. + if !dxdy.is_finite() { + continue; + } + self.edges.push(Edge { x0: p.0, y0: p.1, y1: q.1, dir, comp, dxdy }); } } - self.edges.sort_by(|a, b| a.y0.total_cmp(&b.y0)); } /// Pixel bounds of everything that may be covered (`None` = nothing; the whole plane @@ -428,6 +452,43 @@ mod tests { assert!(r.render(Rect::new(0, 0, 4, 4)).iter().all(|v| *v == 1.0)); } + #[test] + fn huge_finite_coordinates_keep_edges_finite() { + // The diagonal runs from far left of row 3 to far right of row 4, crossing x = 0 at + // y = 4: row 3 is outside the triangle on the canvas, row 4 inside. Its slope used to + // overflow to infinity, and the NaN it gave at the edge's top dropped that boundary, + // filling row 3 (#5195). + let mut r = Rasterizer::new(false); + r.add_component(&[vec![(-1e308, 3.0), (1e308, 5.0), (-1e308, 5.0)]], PathOp::Combine, FillRule::NonZero); + let c = r.render(Rect::new(0, 0, 10, 8)); + assert!(c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + for (y, row) in c.chunks(10).enumerate() { + let expect = if y == 4 { 1.0 } else { 0.0 }; + assert!(row.iter().all(|v| (v - expect).abs() < 1e-3), "row {y}: {row:?}"); + } + } + + #[test] + fn sorting_edges_once_matches_sorting_per_component() { + // Components sharing top rows (tied `y0`s) and a subtraction: one stable sort at the end + // gives the exact edge order (and so the exact bits) of a sort after every component. + let comps = [ + (vec![square(1.0, 1.0, 6.0), vec![(2.5, 1.0), (9.0, 4.0), (3.0, 8.5)]], PathOp::Combine, FillRule::NonZero), + (vec![square(3.0, 1.0, 2.5)], PathOp::Subtract, FillRule::EvenOdd), + (vec![vec![(0.5, 1.0), (8.0, 1.0), (4.2, 7.7)]], PathOp::Exclude, FillRule::NonZero), + ]; + let (mut each, mut once) = (Rasterizer::new(false), Rasterizer::new(false)); + for (polys, op, rule) in &comps { + each.add_component(polys, *op, *rule); + once.push_component(polys, *op, *rule); + } + once.sort_edges(); + let rect = Rect::new(0, 0, 10, 10); + let (a, b) = (each.render(rect), once.render(rect)); + assert!(a.iter().zip(&b).all(|(p, q)| p.to_bits() == q.to_bits())); + assert!(sum(&a) > 1.0); + } + #[test] fn pixel_bounds_clip_upper_edge_at_i32_max() { let mut r = Rasterizer::new(false); diff --git a/crates/vector/src/stroke.rs b/crates/vector/src/stroke.rs index dba9473e8f..2e08ab68ba 100644 --- a/crates/vector/src/stroke.rs +++ b/crates/vector/src/stroke.rs @@ -3,6 +3,8 @@ //! together with the non-zero rule yields their exact union without computing offset curves. //! Dashing splits the flattened polylines by arc length first. +use std::borrow::Cow; + use photocraft_doc::{LineCap, LineJoin}; use crate::flatten::Polyline; @@ -86,8 +88,14 @@ fn area_radius(r: f64, step: f64) -> f64 { if step < 1e-9 { r } else { r * (step / step.sin()).sqrt() } } -fn circle(c: P, r: f64, tol: f64) -> Vec

{ - let n = ((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8); +/// Points a full circle needs at `tol` (at least 8). +fn circle_points(r: f64, tol: f64) -> usize { + ((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8) +} + +/// A circle of at most `arc_cap` points (`usize::MAX` = no limit, at least 4). +fn circle(c: P, r: f64, tol: f64, arc_cap: usize) -> Vec

{ + let n = circle_points(r, tol).min(arc_cap).max(4); let rr = area_radius(r, std::f64::consts::TAU / n as f64); (0..n) .map(|i| { @@ -97,28 +105,78 @@ fn circle(c: P, r: f64, tol: f64) -> Vec

{ .collect() } +/// Dash steps one stroke may spend, shared by all of its lines: it bounds the number of dashes +/// (and so of caps) for file-provided patterns. Past it, the rest is drawn solid. +const DASH_STEP_BUDGET: usize = 100_000; + +/// Outline points a stroke may spend before its round caps and joins get coarser (#5211). +const OUTLINE_POINT_BUDGET: usize = 1 << 20; + /// Stroke outline pieces for `lines`. Fill them with the non-zero rule. +/// +/// The outline is bounded: when full-resolution round caps and joins would exceed +/// [`OUTLINE_POINT_BUDGET`] points, every round cap and join gets the same, smaller number of +/// segments (down to 4 per circle and 1 per join; areas stay exact). Strokes under the budget +/// are unchanged and no piece is dropped, so the total is at most the larger of the budget and +/// that minimal tessellation, which is linear in the (budgeted) flattened points and dashes. pub fn stroke_polygons(lines: &[Polyline], style: &StrokeStyle, tol: f64) -> Vec> { let mut out = Vec::new(); if style.width <= 0.0 || !style.width.is_finite() || !style.miter_limit.is_finite() || style.miter_limit < 1.0 { return out; } + let mut steps_left = DASH_STEP_BUDGET; + let mut pieces: Vec> = Vec::new(); for pl in lines { if pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { continue; } if !style.dashes.is_empty() { - for dash in dash_polyline(pl, &style.dashes, style.dash_offset) { - stroke_one(&dash, style, tol, &mut out); - } + pieces.extend(dash_polyline_budgeted(pl, &style.dashes, style.dash_offset, &mut steps_left).into_iter().map(Cow::Owned)); } else { - stroke_one(pl, style, tol, &mut out); + pieces.push(Cow::Borrowed(pl)); } } + let arc_cap = round_arc_cap(&pieces, style, tol); + for pl in &pieces { + stroke_one(pl, style, tol, arc_cap, &mut out); + } out } -fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec>) { +/// Points per full circle allowed for round caps and joins so the outline of `pieces` stays +/// within [`OUTLINE_POINT_BUDGET`]; `usize::MAX` when full resolution fits. +fn round_arc_cap(pieces: &[Cow], style: &StrokeStyle, tol: f64) -> usize { + let (round_cap, round_join) = (style.cap == LineCap::Round, style.join == LineJoin::Round); + if !round_cap && !round_join { + return usize::MAX; + } + // Upper bounds: ribbons and miter/bevel pieces, full circles (caps and dots), round joins. + let (mut base, mut circles, mut joins) = (0usize, 0usize, 0usize); + for pl in pieces { + let m = pl.pts.len(); + if m == 0 { + continue; + } + base = base.saturating_add(m.saturating_mul(6)).saturating_add(8); + if round_cap && !(pl.closed && m > 2) { + circles = circles.saturating_add(if m == 1 { 1 } else { 2 }); + } + if round_join { + joins = joins.saturating_add(m); + } + } + // A join sweeps at most half a circle, plus its centre and two end points. + let cost = |n: usize| base.saturating_add(circles.saturating_mul(n)).saturating_add(joins.saturating_mul(n / 2 + 3)); + let full = circle_points(style.width * 0.5, tol); + if cost(full) <= OUTLINE_POINT_BUDGET { + return usize::MAX; + } + let left = OUTLINE_POINT_BUDGET.saturating_sub(base).saturating_sub(joins.saturating_mul(3)) as f64; + let per_circle = circles as f64 + joins as f64 * 0.5; + ((left / per_circle.max(1.0)) as usize).clamp(4, full) +} + +fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, arc_cap: usize, out: &mut Vec>) { let hw = style.width * 0.5; // Drop repeated points. let mut pts: Vec

= Vec::with_capacity(pl.pts.len()); @@ -145,7 +203,7 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec // A dot: only round and square caps draw something. let c = pts[0]; match style.cap { - LineCap::Round => emit(out, circle(c, hw, tol)), + LineCap::Round => emit(out, circle(c, hw, tol, arc_cap)), LineCap::Square => emit(out, vec![(c.0 - hw, c.1 - hw), (c.0 + hw, c.1 - hw), (c.0 + hw, c.1 + hw), (c.0 - hw, c.1 + hw)]), LineCap::Butt => {} } @@ -213,19 +271,19 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec (jn, true) => jn, (_, false) => LineJoin::Miter, }; - join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, out); + join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, arc_cap, out); } } if !closed { let d0 = norm(sub(pts[1], pts[0])); let d1 = norm(sub(pts[n - 1], pts[n - 2])); - cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, out); - cap_piece(pts[n - 1], d1, hw, style.cap, tol, out); + cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, arc_cap, out); + cap_piece(pts[n - 1], d1, hw, style.cap, tol, arc_cap, out); } } #[allow(clippy::too_many_arguments)] -fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, out: &mut Vec>) { +fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, arc_cap: usize, out: &mut Vec>) { let c = cross(d1, d2); let d = dot(d1, d2); if c.abs() < 1e-12 && d > 0.0 { @@ -239,15 +297,14 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol match join { LineJoin::Bevel => emit(out, vec![v, a, b]), LineJoin::Miter => { - // Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments. - let half = ((1.0 - d) * 0.5).max(0.0).sqrt(); // sin of half the turning angle + // Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments, + // i.e. 1 / cos of half the turning angle. let cos_half = ((1.0 + d) * 0.5).max(0.0).sqrt(); if cos_half < 1e-9 || 1.0 / cos_half > miter_limit { emit(out, vec![v, a, b]); } else { let bis = norm(add(n1, n2)); let m = add(v, mul(bis, hw / cos_half)); - let _ = half; emit(out, vec![v, a, m, b]); } } @@ -267,7 +324,12 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol delta = std::f64::consts::PI * if cross(n1, d1) > 0.0 { 1.0 } else { -1.0 }; } a1 = a0 + delta; - let steps = ((delta.abs() / arc_step(hw, tol)).ceil() as usize).max(1); + let mut steps = (delta.abs() / arc_step(hw, tol)).ceil() as usize; + if arc_cap != usize::MAX { + // Over the outline budget: the join's share of an `arc_cap`-point circle. + steps = steps.min((arc_cap as f64 * delta.abs() / std::f64::consts::TAU).ceil() as usize); + } + let steps = steps.max(1); let rr = area_radius(hw, delta.abs() / steps as f64); let mut poly = vec![v, a]; for k in 1..steps { @@ -280,10 +342,10 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol } } -fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec>) { +fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, arc_cap: usize, out: &mut Vec>) { match cap { LineCap::Butt => {} - LineCap::Round => emit(out, circle(p, hw, tol)), + LineCap::Round => emit(out, circle(p, hw, tol, arc_cap)), LineCap::Square => { let n = mul(perp(dir), hw); let e = mul(dir, hw); @@ -294,6 +356,12 @@ fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec /// Splits a polyline into dashes (open polylines). `pattern` alternates on/off lengths in px. pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec { + dash_polyline_budgeted(pl, pattern, offset, &mut DASH_STEP_BUDGET.clone()) +} + +/// [`dash_polyline`] spending at most `steps_left` dash steps (decremented); once they are +/// spent, the rest of the line is drawn solid. +fn dash_polyline_budgeted(pl: &Polyline, pattern: &[f64], offset: f64, steps_left: &mut usize) -> Vec { if pattern.is_empty() { return vec![pl.clone()]; } @@ -313,13 +381,16 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec = if pl.closed { + let mut pts = pl.pts.clone(); pts.push(pl.pts[0]); - knots.push(true); - } + pts.into() + } else { + pl.pts.as_slice().into() + }; + // Knot flags past the source's (and the repeated closing point) count as knots. + let knot_at = |i: usize| i >= pl.pts.len() || pl.knot.get(i).copied().unwrap_or(true); // Position in the pattern. let mut idx = 0usize; let mut left = pat[0]; @@ -336,23 +407,27 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec = if idx.is_multiple_of(2) { Some(Polyline { pts: vec![pts[0]], knot: vec![true], closed: false }) } else { None }; - let mut guard = 0usize; - for (wi, w) in pts.windows(2).enumerate() { + 'walk: for (wi, w) in pts.windows(2).enumerate() { let (a, b) = (w[0], w[1]); - let b_knot = knots[wi + 1]; + let b_knot = knot_at(wi + 1); let seg = len(sub(b, a)); let mut pos = 0.0; while seg - pos > 1e-12 { - guard += 1; - if guard > 100_000 { - // Bound allocations and time, including file-provided patterns. Commands - // reject patterns that exceed this budget before document mutation. - return Vec::new(); - } let step = left.min(seg - pos); - if step > 0.0 && pos + step == pos { - return Vec::new(); + if *steps_left == 0 || (step > 0.0 && pos + step == pos) { + // Past the step budget (which bounds allocations and time for file-provided + // patterns; commands reject such patterns before mutating the document) or + // dashes below the coordinates' precision: keep the dashes made so far and draw + // the rest of the line solid rather than dropping the whole stroke (#5195). + let here = if pos > 0.0 { add(a, mul(sub(b, a), pos / seg)) } else { a }; + let rest = cur.get_or_insert_with(|| Polyline { pts: vec![here], knot: vec![true], closed: false }); + for (j, p) in pts.iter().enumerate().skip(wi + 1) { + rest.pts.push(*p); + rest.knot.push(knot_at(j)); + } + break 'walk; } + *steps_left -= 1; pos += step; left -= step; let at_end = seg - pos <= 1e-12; @@ -451,11 +526,57 @@ mod tests { #[test] fn invalid_and_too_dense_dashes_finish_with_bounded_work() { let pl = line(&[(0.0, 0.0), (100.0, 0.0)], false); - for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33], vec![1e-8, 1e-8]] { + for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33]] { assert!(dash_polyline(&pl, &pattern, 0.0).is_empty()); } assert!(dash_polyline(&pl, &[2.0, 1.0], f64::INFINITY).is_empty()); assert!(dash_polyline(&line(&[(f64::NAN, 0.0), (1.0, 0.0)], false), &[2.0, 1.0], 0.0).is_empty()); + // Too dense for the step budget: bounded work, and the line is still drawn. + let d = dash_polyline(&pl, &[1e-8, 1e-8], 0.0); + assert!(!d.is_empty() && d.len() <= 50_001); + assert_eq!(d.last().and_then(|p| p.pts.last()), Some(&(100.0, 0.0))); + } + + #[test] + fn dashes_past_the_step_budget_keep_the_rest_of_the_line_solid() { + // An SVG `stroke-dasharray="0.5 0.5"` along 60000 px needs more steps than the budget: + // the stroke used to vanish entirely (#5195). The dashes made so far stay, the rest of + // the line (through its later vertices) is drawn solid. + let pl = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0)], false); + let d = dash_polyline(&pl, &[0.5, 0.5], 0.0); + assert!(d.len() > 40_000, "{} dashes", d.len()); + assert_eq!(d[0].pts, [(0.0, 0.0), (0.5, 0.0)]); + let tail = d.last().unwrap(); + assert_eq!(tail.pts.last(), Some(&(60_000.0, 1000.0))); + assert!(tail.pts.contains(&(60_000.0, 0.0)) && tail.pts.len() == tail.knot.len()); + // The solid tail starts where the dashes stopped (100000 steps of 0.5 px). + assert!((tail.pts[0].0 - 50_000.0).abs() < 1.0 && tail.pts[0].1 == 0.0, "{:?}", tail.pts[0]); + // Closed outlines finish the same way, back to their first point, where the solid tail + // joins the first dash (one piece with a join at the seam, no caps). + let outline = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0), (0.0, 1000.0)], true); + let d = dash_polyline(&outline, &[0.5, 0.5], 0.0); + let joined = &d[0]; + assert!(joined.pts.contains(&(60_000.0, 1000.0)) && joined.pts.contains(&(0.0, 1000.0)) && joined.pts.contains(&(0.0, 0.0))); + assert_eq!(joined.pts.last(), Some(&(0.5, 0.0))); + } + + #[test] + fn wide_round_capped_dashes_keep_the_outline_within_its_point_budget() { + let points = |polys: &[Vec

]| polys.iter().map(Vec::len).sum::(); + // 5000 dashes, each with two round caps of ~629 points at full resolution (#5211). + let st = StrokeStyle { width: 4000.0, cap: LineCap::Round, join: LineJoin::Round, dashes: vec![1.0, 9.0], ..Default::default() }; + let pl = line(&[(0.0, 0.0), (25_000.0, 0.0), (25_000.0, 25_000.0)], false); + let polys = stroke_polygons(std::slice::from_ref(&pl), &st, 0.01); + assert!(points(&polys) <= OUTLINE_POINT_BUDGET, "{} points", points(&polys)); + // No dash is dropped: a ribbon and two caps each. + assert!(polys.len() >= 3 * 5000, "{} pieces", polys.len()); + // Many tiny undashed subpaths: bounded too, every one still drawn. + let dots: Vec = (0..3000).map(|i| line(&[(f64::from(i), 0.0), (f64::from(i) + 0.5, 0.0)], false)).collect(); + let polys = stroke_polygons(&dots, &StrokeStyle { dashes: Vec::new(), ..st.clone() }, 0.01); + assert!(points(&polys) <= OUTLINE_POINT_BUDGET && polys.len() >= 3 * 3000, "{} points", points(&polys)); + // Under the budget, round caps keep their full resolution. + let one = stroke_polygons(&dots[..1], &StrokeStyle { dashes: Vec::new(), ..st }, 0.01); + assert!(one.iter().any(|p| p.len() == circle_points(2000.0, 0.01)) && circle_points(2000.0, 0.01) > 600); } #[test] diff --git a/crates/vector/src/trace.rs b/crates/vector/src/trace.rs index 5aa5c03722..1a547bfb0b 100644 --- a/crates/vector/src/trace.rs +++ b/crates/vector/src/trace.rs @@ -25,10 +25,8 @@ type P = (f64, f64); pub fn trace_mask(values: &[f32], rect: Rect, outside: f32, tolerance: f64) -> Path { let loops = contours(values, rect, outside); let mut subpaths = Vec::new(); + // `contours` only returns loops of three or more points. for (i, lp) in loops.iter().enumerate() { - if lp.len() < 3 { - continue; - } // Fit to half the tolerance: the worst point may deviate by the full amount only rarely. let mut s = fit_closed(lp, (tolerance * 0.5).max(0.05), 1.5); s.op = if i == 0 { PathOp::Combine } else { PathOp::Exclude }; From 5c78d0848ff0094929f7a22da25a5f749eb7727d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:02:17 +0700 Subject: [PATCH 02/90] fix: limit Render Video timeline and GIF staging allocations (#5670) --- crates/engine/src/timeline_cmds.rs | 2 +- crates/engine/src/video_cmds.rs | 18 ++++++++++++++++++ crates/engine/src/video_cmds/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index 197c8e9ccc..9baa077608 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -12,7 +12,7 @@ use crate::{EngineError, Result, Session}; /// Longest timeline, in frames: every frame becomes a surface in a new video layer and a file in /// Render Video, so `duration` is capped well before it can exhaust memory or overflow (#1009). -const MAX_DURATION: usize = 1_000_000; +pub(crate) const MAX_DURATION: usize = 1_000_000; /// Highest frame rate; larger values only come from mistakes and become `inf` as `f32` (#1009). const MAX_FPS: f32 = 1000.0; diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 2a9f8ae8b4..dcb184c675 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -11,6 +11,10 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +/// Maximum dense RGBA staging pixels in one animated GIF frame. A GIF also keeps the +/// quantised frame and palette while rendering, so the document canvas limit is not enough. +const MAX_GIF_FRAME_PIXELS: u64 = 64 << 20; + /// Set every video layer's displayed content to its frame at the playhead. pub fn sync(doc: &mut Document) { let cur = doc.timeline.as_ref().map_or(0, |t| t.current); @@ -416,6 +420,13 @@ pub fn render_video_with( let mut base = (*st.doc).clone(); store(&mut base); let frames = base.timeline.as_ref().map_or(1, |t| t.duration).max(1); + // Loaded timelines bypass the duration validation used by timeline.create/setProps. + if frames > crate::timeline_cmds::MAX_DURATION { + return Err(EngineError::Other(format!( + "the timeline is {frames} frames; Render Video is limited to {} frames", + crate::timeline_cmds::MAX_DURATION + ))); + } let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0); let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); let fmt = base.pixel_format(); @@ -429,6 +440,13 @@ pub fn render_video_with( // Animated GIF: one file, every frame quantised to its own palette. if format.eq_ignore_ascii_case("gif") { + // The whole canvas is staged as f32 RGBA for each frame; reject over-budget + // dimensions before an infallible allocation can abort the process. + if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS { + return Err(EngineError::Other(format!( + "{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)" + ))); + } let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16; let mut gframes = Vec::with_capacity(frames); for f in 0..frames { diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 22d639d49b..c16c7f8e39 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -498,3 +498,25 @@ fn grow_duration_never_shrinks_and_keeps_the_work_area_valid() { grow_duration(&mut t, 12); assert_eq!((t.work_start, t.work_end, t.duration), (0, 6, 12)); } + +#[test] +fn render_video_rejects_a_loaded_timeline_past_the_frame_cap() { + let mut s = session(); + let mut doc = (*s.active().unwrap().doc).clone(); + doc.timeline = Some(Timeline::new(usize::MAX, 30.0)); + { + let st = s.active_mut().unwrap(); + st.doc = std::sync::Arc::new(doc); + st.revision += 1; + } + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("overlong"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("limited to"), "{err}"); +} + +#[test] +fn render_video_rejects_gif_frames_too_large_to_stage() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 9000, "height": 8000})).unwrap(); + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("large-gif"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); +} From 13f893f9987a88b0048e2b43b3e44b1b955c0b6f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:02:27 +0700 Subject: [PATCH 03/90] fix(export): prompt for a Render Video output directory (#3365) (#3378) fix(export): prompt for a Render Video output directory (#3365) Also covers the same fix as #3390; thanks to its author. Co-authored-by: buivanvu94 <125793064+buivanvu94@users.noreply.github.com> --- crates/ui-egui/src/file_dialog/tests.rs | 38 +++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 17 +++++++++++ 2 files changed, 55 insertions(+) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index bd7852ac38..dc21c670a6 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -912,3 +912,41 @@ fn save_as_openraster_of_what_it_cannot_hold_is_a_copy() { assert!(err.contains("OpenRaster"), "{err}"); assert_eq!(written.borrow().len(), 1); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn render_video_menu_asks_for_directory_and_writes_frames_only_after_confirmation() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!( + "pc-render-video-ui-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let path = dir.to_string_lossy().into_owned(); + + let result = menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap(); + assert_eq!(result["fileDialog"], "folder"); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { .. }, _)])); + assert!(!dir.exists(), "a menu click must not create an output directory"); + + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert!(!dir.exists(), "cancelling the picker must not render video"); + + menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::FolderTo(path.clone()))); + app.poll_file_dialog(&ctx, None); + let rendered: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(rendered.len(), 1, "a document without a timeline exports one frame"); + assert_eq!(rendered[0].extension().and_then(|ext| ext.to_str()), Some("png")); + std::fs::remove_dir_all(&dir).unwrap(); + + // Commands that already supply a directory must not open a file picker. + let result = menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({"dir": path})).unwrap(); + assert!(result["frames"].as_u64().is_some()); + assert!(!app.file_dialog_open()); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 376467a50a..7f47e6ea64 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -423,6 +423,23 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } "file.export.exportAs" => Ok(json!({"dialog": crate::export_dialog::open(app)?})), "file.export.quickExportAsPng" => crate::export_dialog::quick_export_png(app), + // Render Video exports a sequence of frames into a folder, not a named file. + // Keep explicit directory arguments from automation and the control channel unchanged. + "file.export.renderVideo" if params.get("dir").is_none() => { + #[cfg(target_arch = "wasm32")] + { + Err("Render Video to a local directory is unavailable on the web".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let doc = app.session.active().ok_or("no document")?.doc.id; + let mut render_params = params.as_object().cloned().unwrap_or_default(); + app.ask_folder(move |app, dir| { + render_params.insert("dir".into(), json!(dir)); + app.with_document(doc, |app| app.run("file.export.renderVideo", Value::Object(render_params))) + }) + } + } // Layer › Export As… / Quick Export as PNG: the export pipeline on just the active layer. l if matches!(l, "layer.exportAs" | "layer.quickExportAsPng") && params.as_object().is_none_or(|o| o.is_empty()) => { let layer = app.session.active().and_then(|d| d.active_layer).ok_or("no active layer")?; From 4c8101b5763adde2e3a1dbdcd16994a877906d3e Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:02:35 +0700 Subject: [PATCH 04/90] fix(ui): avoid overflow when allocating layer style instance IDs (#4884) * fix(ui): avoid overflow when allocating layer style instance IDs * fix(ui): avoid panicking when layer style IDs overflow * fix(ui): format non-panicking layer style ID allocation --- crates/ui-egui/src/layer_style.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index c2781157a3..3a1807cf18 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -415,14 +415,15 @@ fn set_param(f: &mut Map, id: &str, key: &str, value: Value) { /// A fresh instance id: one past the highest numbered `fxN` in the list. fn next_id(f: &Map) -> String { - let max = effects_of(f) + let used: std::collections::HashSet = effects_of(f) .iter() .filter_map(|e| e.get("id").and_then(Value::as_str)) .filter_map(|id| id.strip_prefix("fx")) .filter_map(|n| n.parse::().ok()) - .max() - .unwrap_or(0); - format!("fx{}", max + 1) + .collect(); + let max = used.iter().copied().max().unwrap_or(0); + let next = max.checked_add(1).or_else(|| (1_u64..).find(|n| !used.contains(n))).unwrap_or(1); + format!("fx{next}") } /// Adds an instance of `kind`, right after the last one of its kind (the list, @@ -1438,6 +1439,25 @@ mod tests { } } + #[test] + fn adding_an_effect_after_maximum_instance_id_uses_an_unused_id() { + // Imported and scripted effects can provide arbitrary instance IDs. + let layer = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); + let mut fields = initial_fields(&layer, None, 120.0); + fields.insert( + "effects".into(), + json!([ + {"id": "fx1", "kind": "stroke", "on": true, "params": {}}, + {"id": format!("fx{}", u64::MAX), "kind": "stroke", "on": false, "params": {}} + ]), + ); + add_instance(&mut fields, "stroke"); + + let ids: Vec<&str> = effects_of(&fields).iter().filter_map(|e| e.get("id").and_then(Value::as_str)).collect(); + assert_eq!(ids, ["fx1", "fx18446744073709551615", "fx2"]); + assert_eq!(fields["selected"], json!("fx2")); + } + #[test] fn initial_fields_lists_every_instance_and_selects() { let l = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); From 57d488032ce5acc71bb816c6a31b948845626fc7 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:02:43 +0200 Subject: [PATCH 05/90] fix(doc): variable definitions follow layer id remapping on copy and load (#5023) * fix(doc): variable definitions follow layer id remapping on copy and load A duplicated document (image.duplicate, history.newDocument) or a .pcraft loaded with remapped ids assigned fresh layer ids but cloned VariableDef.layer unchanged, so datasets silently drove no layer (or, after remap, a colliding one). Remap variable targets through the same id map as layer comps and slices; a dangling target keeps a non-colliding placeholder. A merged-only duplicate drops the whole variable binding set, matching how it drops comps. Closes #4969 * style: cargo fmt duplicate_variables test --------- Co-authored-by: highperfocused --- crates/engine/src/image_cmds.rs | 17 ++- crates/engine/tests/duplicate_variables.rs | 146 +++++++++++++++++++++ crates/format/src/convert.rs | 11 +- crates/format/tests/roundtrip.rs | 24 +++- 4 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 crates/engine/tests/duplicate_variables.rs diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 697794f469..3e870aa686 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -71,11 +71,12 @@ pub(crate) fn for_each_layer(layers: &mut [Layer], f: &mut dyn FnMut(&mut Layer) } } -/// Assign new layer ids to a copied document and update every saved layer-comp reference. +/// Assign new layer ids to a copied document and update every saved layer reference. /// -/// A document copy needs fresh identities, but its comps must still target the copied -/// layers. Deliberately leave references to already-deleted layers untouched so their -/// missing-layer warnings remain meaningful. The same applies to Last Document State. +/// A document copy needs fresh identities, but its comps and variable definitions must +/// still target the copied layers. Deliberately leave references to already-deleted +/// layers untouched so their missing-layer warnings remain meaningful. The same applies +/// to Last Document State. pub(crate) fn reidentify_copied_layers(doc: &mut Document) { let mut ids = std::collections::HashMap::new(); for_each_layer(&mut doc.layers, &mut |layer| { @@ -97,6 +98,11 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { if let Some(last) = &mut doc.last_document_state { remap(last); } + for def in &mut doc.variables.defs { + if let Some(&new_id) = ids.get(&def.layer) { + def.layer = new_id; + } + } } /// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › @@ -735,6 +741,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { doc.layer_comps.clear(); doc.last_applied_comp = None; doc.last_document_state = None; + // Likewise, variables drove template layers that no longer exist: drop the whole + // binding set (defs, data sets and the active index) on the flattened copy. + doc.variables = photocraft_doc::Variables::default(); } reidentify_copied_layers(&mut doc); let name = doc.name.clone(); diff --git a/crates/engine/tests/duplicate_variables.rs b/crates/engine/tests/duplicate_variables.rs new file mode 100644 index 0000000000..48d197b51e --- /dev/null +++ b/crates/engine/tests/duplicate_variables.rs @@ -0,0 +1,146 @@ +//! Variable definitions must continue targeting their layers after a copied document gets +//! fresh ids (#4969), and a merged-only copy must not keep bindings to discarded layers. + +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size, TextLayer}; +use photocraft_engine::Session; +use serde_json::json; + +fn scene() -> (Session, LayerId, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ids = s + .edit("setup", |doc, _| { + let fmt = doc.pixel_format(); + let mut photo = Layer::raster("photo", fmt); + photo.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &photocraft_raster::from_rgba(&fmt, [0.5, 0.5, 0.5, 1.0])); + let badge = Layer::raster("badge", fmt); + let title = Layer::new("title", LayerContent::Text(TextLayer { text: "Old".into(), ..Default::default() })); + let (p, b, t) = (photo.id, badge.id, title.id); + doc.layers.push(photo); + doc.layers.push(badge); + doc.layers.push(title); + Ok((p, b, t)) + }) + .unwrap(); + (s, ids.0, ids.1, ids.2) +} + +fn red_png(dir: &str) -> String { + let doc = Document::with_background( + "red", + Size::new(4, 4), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::rgb(1.0, 0.0, 0.0), + ); + let bytes = photocraft_io::export(&doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes; + let path = format!("{dir}/replacement.png"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn copied_layer(s: &Session, name: &str) -> LayerId { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id +} + +fn define_all(s: &mut Session, photo: LayerId, badge: LayerId, title: LayerId, png: &str) { + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + {"name": "photo", "layer": photo.0, "type": "pixelReplacement", "method": "conform"}, + ]}), + ) + .unwrap(); + s.execute( + "image.variables.dataSets", + json!({"dataSets": [ + {"name": "A", "values": [ + {"variable": "showBadge", "kind": "visibility", "value": false}, + {"variable": "headline", "kind": "text", "value": "Hello"}, + {"variable": "photo", "kind": "pixels", "value": png}, + ]}, + ]}), + ) + .unwrap(); +} + +#[test] +fn duplicated_documents_keep_variables_targeting_the_copied_layers() { + let dir = std::env::temp_dir().join(format!("pc-dup-vars-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = red_png(&dir.to_string_lossy()); + + for command in ["image.duplicate", "history.newDocument"] { + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, &png); + s.execute(command, json!({})).unwrap(); + + let doc = &s.active().unwrap().doc; + let new_photo = copied_layer(&s, "photo"); + let new_badge = copied_layer(&s, "badge"); + let new_title = copied_layer(&s, "title"); + assert_ne!(new_photo, photo, "{command}: layers must get new identities"); + let targets: Vec = doc.variables.defs.iter().map(|d| d.layer).collect(); + assert_eq!(targets, vec![new_badge, new_title, new_photo], "{command}: variable defs follow their layers"); + + let r = s.execute("image.applyDataSet", json!({"name": "A"})).unwrap(); + assert_eq!(r["applied"], "A"); + let doc = &s.active().unwrap().doc; + assert!(!doc.layer(new_badge).unwrap().visible, "{command}: visibility drives the copy"); + match &doc.layer(new_title).unwrap().content { + LayerContent::Text(t) => assert_eq!(t.text, "Hello", "{command}: text drives the copy"), + _ => panic!("not a text layer"), + } + let px = doc.layer(new_photo).unwrap().surface().unwrap().pixel(0, 0); + assert!(px[0] > 0.9 && px[1] < 0.1, "{command}: pixel replacement drives the copy: {px:?}"); + + // The source document still targets its own layers and is untouched. + s.set_active(0); + let src = &s.active().unwrap().doc; + let src_targets: Vec = src.variables.defs.iter().map(|d| d.layer).collect(); + assert_eq!(src_targets, vec![badge, title, photo], "{command}: source defs untouched"); + assert!(src.layer(badge).unwrap().visible); + assert!(src.layer(photo).unwrap().surface().unwrap().pixel(0, 0)[0] < 0.9); + } +} + +#[test] +fn dangling_variable_targets_stay_dangling_after_duplication() { + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, "unused.png"); + // A def may already refer to a deleted layer before duplication. + let missing = LayerId(u64::MAX); + s.edit("Break one def", |doc, _| { + doc.variables.defs.iter_mut().find(|d| d.name == "showBadge").unwrap().layer = missing; + Ok(()) + }) + .unwrap(); + s.execute("image.duplicate", json!({})).unwrap(); + + let doc = &s.active().unwrap().doc; + let def = doc.variables.defs.iter().find(|d| d.name == "showBadge").unwrap(); + assert_eq!(def.layer, missing, "a deleted target must not be rewritten to an unrelated new id"); + let photo_def = doc.variables.defs.iter().find(|d| d.name == "photo").unwrap(); + assert_eq!(photo_def.layer, copied_layer(&s, "photo"), "live targets still remap"); +} + +#[test] +fn merged_only_duplicate_does_not_carry_variables_for_removed_layers() { + let dir = std::env::temp_dir().join(format!("pc-dup-vars-merged-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = red_png(&dir.to_string_lossy()); + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, &png); + + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 1, "merged-only copy is a single composite layer"); + assert!(d.variables.defs.is_empty(), "old variables cannot address discarded layers"); + assert!(d.variables.data_sets.is_empty()); + assert_eq!(d.variables.active, None); + + s.set_active(0); + assert_eq!(s.active().unwrap().doc.variables.defs.len(), 3, "original variables are preserved"); +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 51d6183baa..95d45fafb9 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -617,6 +617,15 @@ impl Loader<'_> { } } } + // Variable targets follow their layers through id remapping; a dangling one keeps a + // non-colliding placeholder so it stays a missing-layer skip rather than driving an + // unrelated layer (#4969). With preserved ids the map is the identity and defs stay as saved. + let mut variables = m.variables.clone(); + if !self.preserve_ids { + for vd in &mut variables.defs { + vd.layer = self.id_map.get(&vd.layer.0).copied().unwrap_or(LayerId(u64::MAX - vd.layer.0.min(u64::MAX / 2))); + } + } let id = if self.preserve_ids { DocId(m.id) } else { DocId::fresh() }; Ok(Document { id, @@ -641,7 +650,7 @@ impl Loader<'_> { patterns, color_table: m.color_table.clone(), duotone: m.duotone.clone(), - variables: m.variables.clone(), + variables, timeline: m.timeline.clone(), file_encoding: m.file_encoding, layer_comps, diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 8b74a00c4b..f78e93947e 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -5,7 +5,7 @@ use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun}; use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef}; -use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer, VarKind, VariableDef}; use photocraft_format::*; use photocraft_raster::Rgba8Image; @@ -729,3 +729,25 @@ fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); } + +#[test] +fn variables_follow_layer_id_remapping() { + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let id_of = |d: &Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id; + doc.variables.defs = vec![ + VariableDef { name: "head".into(), layer: id_of(&doc, "Paint"), kind: VarKind::TextReplacement }, + VariableDef { name: "badge".into(), layer: id_of(&doc, "Inner"), kind: VarKind::Visibility }, + VariableDef { name: "gone".into(), layer: LayerId(u64::MAX), kind: VarKind::Visibility }, + ]; + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + // Preserved ids keep every definition exactly as saved, dangling ones included. + assert_eq!(load_from_bytes(&bytes).unwrap(), doc); + // Remapped ids carry the definitions to the copied layers; a dangling target stays + // dangling and must not collide with a live layer (#4969). + let back = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids: false, ..Default::default() }).unwrap(); + let defs = &back.variables.defs; + assert_eq!(defs.iter().find(|d| d.name == "head").unwrap().layer, id_of(&back, "Paint")); + assert_eq!(defs.iter().find(|d| d.name == "badge").unwrap().layer, id_of(&back, "Inner")); + let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer; + assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); +} From 686f3ee9445225fef0ba3157e3bfae19fd0dabbf Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:02:52 +0200 Subject: [PATCH 06/90] fix(raw): reject truncated or oversized Deflate tiles in DNG import (#4977) (#5072) --- crates/raw/src/sensor.rs | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 585191b799..d71c4a905f 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -214,6 +214,19 @@ pub(crate) enum JpegLayout { /// DNG 1.7 JPEG XL compression. const JPEG_XL: u32 = 52546; +/// Inflates one zlib tile into at most `need` bytes. The stream must run to its end: a truncated +/// stream, or one that holds more than `need` bytes, is an error rather than a silent cut-off. +fn inflate_tile(src: &[u8], need: usize) -> Result> { + let mut out = vec![0u8; need]; + let mut d = flate2::Decompress::new(true); + let status = d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?; + if status != flate2::Status::StreamEnd { + return Err(RawError::malformed("deflate tile is truncated or larger than its tile")); + } + out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need)); + Ok(out) +} + /// Reads the (uncompressed, lossless-JPEG or JPEG XL) image of a raw IFD. pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayout) -> Result { let width = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("raw image has no width"))? as usize; @@ -261,10 +274,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou let too_big = || RawError::malformed("deflate tile is too large"); let need = s.w.checked_mul(rows).and_then(|n| n.checked_mul(samples)).and_then(|n| n.checked_mul((bits as usize).div_ceil(8))).ok_or_else(too_big)?; - let mut out = vec![0u8; need]; - let mut d = flate2::Decompress::new(true); - d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?; - out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need)); + let out = inflate_tile(src, need)?; let v = unpack(&out, s.w, rows, samples, bits, le)?; // The TIFF predictor runs after unpacking, per row, per sample plane. match t.tag_uint(ifd, tag::PREDICTOR).unwrap_or(1) { @@ -459,6 +469,26 @@ mod tests { assert_eq!(c.phase(0, 0), [1, 0, 2, 1]); // GRBG } + fn zlib(data: &[u8]) -> Vec { + use std::io::Write; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + + #[test] + fn inflate_tile_requires_stream_end() { + let data: Vec = (0..64u8).collect(); + let z = zlib(&data); + assert_eq!(inflate_tile(&z, 64).unwrap(), data); + // Shorter than the tile, but a complete stream: still accepted. + assert_eq!(inflate_tile(&z, 100).unwrap(), data); + // Truncated stream. + assert!(inflate_tile(&z[..z.len() - 6], 64).is_err()); + // More decoded output than the tile holds. + assert!(inflate_tile(&z, 32).is_err()); + } + #[test] fn unpack_12_bit() { // Two 12-bit samples 0xABC, 0x123 packed MSB first. From dbfe8ba412e9b9951cae1db84dd6d669c24cb992 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:03:00 +0200 Subject: [PATCH 07/90] fix(history): count fill caches and smart filter masks in undo memory (#4972) (#5075) --- crates/ops/src/lib.rs | 38 +++++++++++++++++++++++++++++++++++++- crates/ops/src/tiles.rs | 10 +++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index a20b841c17..a63fc559e7 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -248,7 +248,7 @@ impl History { } } -/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels, selection) not already in `seen`. +/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) not already in `seen`. fn tile_bytes(doc: &Document, seen: &mut HashSet) -> usize { let mut n = 0usize; tiles::surfaces(doc, |surface| { @@ -408,6 +408,42 @@ mod tests { assert_eq!(h.unique_bytes(&cur), 256 * 256 * 4); } + #[test] + fn fill_caches_and_smart_filter_masks_count_toward_history_bytes() { + use photocraft_doc::{Affine, Fill, FillCache, LayerContent, LayerMask, PixelFormat, SmartObject, SmartSource, Surface}; + let mut doc = Document::new("fills", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut fill = Layer::new("Fill", LayerContent::Fill(Fill::Solid(Color::WHITE))); + let mut cache = Surface::new(PixelFormat::GRAY8); + cache.write_pixel(1, 1, &[1.0]); + fill.fill_cache = Some(FillCache { fill: Fill::Solid(Color::WHITE), surface: cache }); + doc.insert_above(None, fill); + let mut smart_obj = SmartObject::new(SmartSource::Linked { path: "a.png".into() }, Affine::IDENTITY, None); + let mut mask = LayerMask::reveal_all(); + mask.surface.write_pixel(1, 1, &[0.5]); + smart_obj.filter_mask = Some(mask); + doc.insert_above(None, Layer::new("Smart", LayerContent::Smart(smart_obj))); + let tile = 256 * 256; + let mut cur = Arc::new(doc); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + assert_eq!(h.pixel_bytes(&cur), 2 * tile, "fill cache and smart filter mask are counted"); + edit(&mut h, &mut cur, "Rename", |doc| doc.name = "renamed".into()); + assert_eq!(h.pixel_bytes(&cur), 2 * tile, "shared tiles count once"); + edit(&mut h, &mut cur, "Fill cache", |doc| { + let id = doc.layers[0].id; + if let Some(cache) = doc.layer_mut(id).and_then(|l| l.fill_cache.as_mut()) { + cache.surface.write_pixel(1, 1, &[0.0]); + } + }); + assert_eq!(h.pixel_bytes(&cur), 3 * tile, "the copied fill cache tile is history-only"); + assert_eq!(h.unique_bytes(&cur), tile); + h.trim(&cur); + assert_counter(&h, &cur); + h.max_bytes = 2 * tile; + assert_eq!(h.trim(&cur), 1, "the copied fill cache tile pushes history over budget"); + assert_eq!(h.pixel_bytes(&cur), 3 * tile, "the most recent undo state is always kept"); + assert_counter(&h, &cur); + } + #[test] fn selection_tiles_count_current_and_history_without_double_counting() { use photocraft_doc::{PixelFormat, Surface}; diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index b8d27775f9..ec191f301a 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -1,7 +1,7 @@ //! Tile references owned by history snapshots. The snapshots keep these addresses alive; //! COW edits cannot change a retained tile in place. Count occurrences (including aliases //! within one document), so removing a state releases a tile only after its last reference. -use photocraft_doc::{Document, Surface}; +use photocraft_doc::{Document, LayerContent, Surface}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -19,6 +19,14 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { if let Some(mask) = &layer.mask { visit(&mask.surface); } + if let Some(cache) = &layer.fill_cache { + visit(&cache.surface); + } + if let LayerContent::Smart(smart) = &layer.content + && let Some(mask) = &smart.filter_mask + { + visit(&mask.surface); + } if let Some(video) = &layer.video { // Undo retains the whole clip, not only the displayed frame. Visit aliases too: // Tiles tracks every owner and counts their shared allocations only once. From f1d13ca9c7d4b35ce6d784ea81a9cf820907c8e6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:03:07 +0200 Subject: [PATCH 08/90] fix(ui): keep the close prompt up when a Save Document script edits the document after saving (#4953) (#5081) --- crates/ui-egui/src/discard_ui.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 23248cd9ae..58519899d0 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -192,7 +192,8 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { return false; } match saved { - Ok(_) => !app.save_options_open() && !app.saving(), + // A Save Document script may have edited the document after it was written: still dirty. + Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()), // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { @@ -395,6 +396,32 @@ mod tests { app.session.documents()[i].doc.id } + #[test] + fn a_save_document_script_that_edits_the_document_keeps_the_prompt_up() { + use photocraft_engine::automate_cmds::ScriptBinding; + let mut app = app_with_docs(1); + let ctx = egui::Context::default(); + make_dirty(&mut app, 0); + app.session.active_mut().unwrap().path = Some("/pics/kept.psd".into()); + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); + app.session.edit_prefs(|prefs| { + prefs.script_events.enabled = true; + prefs.script_events.bindings = vec![ScriptBinding { + event: "saveDocument".into(), + script: None, + steps: Some(json!([["layer.new.layer", {"name": "From the script"}]])), + name: "Binding".into(), + }]; + }); + let doc = doc_id(&app, 0); + assert!(!save(&mut app, &ctx, doc), "the script's layer was never written"); + assert!(app.session.documents()[0].is_dirty()); + // Without the script the same save releases the prompt. + app.session.edit_prefs(|prefs| prefs.script_events.enabled = false); + assert!(save(&mut app, &ctx, doc)); + } + #[test] fn clean_documents_close_without_asking() { let mut app = app_with_docs(2); From e1706507b5d98296333179bbbe2514e028c56bfe Mon Sep 17 00:00:00 2001 From: Saad Abdullah Date: Thu, 15 Oct 2026 03:03:16 +0500 Subject: [PATCH 09/90] fix(geom): clamp huge sizes in Rect::from_xywh instead of wrapping, and saturate TileCoord::rect (#5161) Co-authored-by: Saad Abdullah --- crates/geom/src/lib.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1a6b60fca3..0e76736013 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -46,7 +46,12 @@ impl Rect { Self { x0, y0, x1, y1 } } pub fn from_xywh(x: i32, y: i32, w: u32, h: u32) -> Self { - Self::new(x, y, x.saturating_add(w as i32), y.saturating_add(h as i32)) + // Widen before adding: `w as i32` wraps negative for widths above `i32::MAX`, which + // would turn the far edge backwards (an empty, inverted rect) instead of clamping it + // to `i32::MAX` the way `saturating_add` alone would suggest. + let x1 = (i64::from(x) + i64::from(w)).min(i64::from(i32::MAX)) as i32; + let y1 = (i64::from(y) + i64::from(h)).min(i64::from(i32::MAX)) as i32; + Self::new(x, y, x1, y1) } pub fn from_size(size: Size) -> Self { Self::from_xywh(0, 0, size.width, size.height) @@ -117,7 +122,9 @@ impl TileCoord { Self::new(x.div_euclid(TILE_SIZE), y.div_euclid(TILE_SIZE)) } pub fn rect(&self) -> Rect { - Rect::from_xywh(self.tx * TILE_SIZE, self.ty * TILE_SIZE, TILE_SIZE as u32, TILE_SIZE as u32) + // `tx`/`ty` can come from a file (this type is deserialized), so saturate instead of + // overflowing (a debug-build panic / release-build wrap) on extreme coordinates. + Rect::from_xywh(self.tx.saturating_mul(TILE_SIZE), self.ty.saturating_mul(TILE_SIZE), TILE_SIZE as u32, TILE_SIZE as u32) } } @@ -258,6 +265,25 @@ mod tests { assert_eq!(grown.y0, i32::MIN); } + #[test] + fn from_xywh_clamps_huge_sizes_instead_of_wrapping() { + // Widths above `i32::MAX` used to wrap negative through `w as i32`, producing an + // empty inverted rect instead of clamping the far edge to `i32::MAX`. + assert_eq!(Rect::from_xywh(0, 0, u32::MAX, 10), Rect::new(0, 0, i32::MAX, 10)); + assert_eq!(Rect::from_xywh(-100, 5, u32::MAX, u32::MAX), Rect::new(-100, 5, i32::MAX, i32::MAX)); + assert_eq!(Rect::from_xywh(10, 20, i32::MAX as u32 + 1, 40).width(), i32::MAX as u32 - 10); + // Ordinary sizes are unchanged. + assert_eq!(Rect::from_xywh(10, 20, 30, 40), Rect::new(10, 20, 40, 60)); + } + + #[test] + fn tile_rect_saturates_on_extreme_coords() { + // Tile coords are deserialized from files; `tx * TILE_SIZE` used to overflow. + let r = TileCoord::new(i32::MAX, i32::MIN).rect(); + assert_eq!((r.x0, r.y0), (i32::MAX, i32::MIN)); + assert_eq!(TileCoord::new(2, -3).rect(), Rect::new(512, -768, 768, -512)); + } + #[test] fn width_and_height_do_not_overflow_on_extreme_edges() { // Inverted extreme rect: empty, so 0 (x1 - x0 used to overflow and panic). From 9cc8be43929b70d9f352b14b293b2ba2089429b2 Mon Sep 17 00:00:00 2001 From: Skyfuller <58360545+Skyfuller1303@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:03:24 +0200 Subject: [PATCH 10/90] fix(algo): keep clipped warp bounds from overflowing (#5451) warp() computed the source-centre fallback as (x0 + x1) in i32 and the inverse-mapped tile footprint as `floor() as i32 - 3` / `ceil() as i32 + 3`. With a strongly skewed finite affine (for example Homography [1, 1e12, 0, 0, 1, 0, 0, 0, 1] on a 32x32 clip) the float-to-int cast saturates at i32::MIN/MAX and the following -3/+3 overflows, panicking in debug builds and wrapping in release. A source rect with coordinates near 1.5e9 would overflow the centre sum the same way. Widen the centre sum to f64 and use saturating_sub/saturating_add for the footprint, which is intersected with src_rect afterwards anyway. Add crates/algo/tests/warp_clipped.rs covering clipped-vs-full equivalence, huge scale allocation, the extreme-affine footprint, and the far-origin source centre. Refs #3446 --- crates/algo/src/transform.rs | 10 +++--- crates/algo/tests/warp_clipped.rs | 59 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 crates/algo/tests/warp_clipped.rs diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 3b157b56a9..050d08c322 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -281,7 +281,7 @@ fn warp( // that matters is the one at the source point landing in the clip's centre. let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) { Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0), - None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0), + None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0), }; let scale = h.local_scale(cx, cy); let reduced; @@ -366,10 +366,10 @@ fn warp( return Ok(None); } let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect(); - let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3; - let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3; + let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3); + let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3); + let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3); + let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3); // Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint. let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0); let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) }; diff --git a/crates/algo/tests/warp_clipped.rs b/crates/algo/tests/warp_clipped.rs new file mode 100644 index 0000000000..ac6497f13f --- /dev/null +++ b/crates/algo/tests/warp_clipped.rs @@ -0,0 +1,59 @@ +use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +#[test] +fn clipped_warp_matches_the_existing_kernel_at_every_depth() { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]); + source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]); + let clip = Rect::new(10, 10, 52, 52); + for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] { + let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap(); + let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap(); + assert_eq!(full.read_region(clip), bounded.read_region(clip)); + assert!(clip.contains_rect(&bounded.content_bounds())); + assert!(bounded.tiles().count() <= clip.tiles().count()); + } + } + } +} + +#[test] +fn huge_scaled_warp_allocates_only_the_destination_clip() { + let mut source = Surface::new(PixelFormat::RGBA8); + source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]); + let clip = Rect::new(0, 0, 64, 64); + let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]); + let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap(); + assert!(clip.contains_rect(&output.content_bounds())); + assert!(output.tiles().count() <= clip.tiles().count()); + assert_eq!(output.pixel(32, 32), source.pixel(10, 10)); + assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none()); +} + +#[test] +fn inverse_footprint_handles_extreme_finite_affines() { + let mut source = Surface::new(PixelFormat::RGBA8); + source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]); + // A strongly skewed inverse maps a small visible tile beyond i32 limits. + let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + let clip = Rect::new(0, 0, 32, 32); + let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap(); + assert!(clip.contains_rect(&output.content_bounds())); + assert!(output.tiles().count() <= clip.tiles().count()); +} + +#[test] +fn source_center_does_not_add_extreme_coordinates_in_i32() { + let mut source = Surface::new(PixelFormat::RGBA8); + let origin = 1_500_000_000; + let bounds = Rect::new(origin, origin, origin + 4, origin + 4); + source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]); + let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]); + let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap(); + assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2)); +} From 9a905959ed7b249bdc3352851ba9ba4a96554bff Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 01:03:32 +0300 Subject: [PATCH 11/90] fix(linux): install the AppImage menu entry when AppImageLauncher skips it (#5551) AppImageLauncher exports DESKTOPINTEGRATION even after X-AppImage-Integrate=false makes it skip the AppImage. AppRun treated that variable as "already integrated" and wrote no .desktop file, so the menu stayed empty. Skip our entry only when another .desktop file's Exec already points at this AppImage. Co-authored-by: Cursor Agent Co-authored-by: nrq6993-a11y --- packaging/linux/AppRun | 31 +++++++++++++++---------------- packaging/linux/apprun-test.sh | 20 ++++++++++++++++++-- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index f04c5f4eab..43d0eeef7d 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -35,25 +35,24 @@ integrate() { src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop" [ -f "$src_desktop" ] || return 0 - # If an external AppImage integrator (AppImageLauncher, appimaged, Gear Lever) is - # already managing this AppImage, do not write a second launcher (#3623); remove our - # own if left over from an earlier run, and still install the hicolor icons below. + # If another integrator already registered a .desktop whose Exec points at this + # AppImage, do not write a second launcher (#3623); remove our own if one is left + # over, and still install the hicolor icons below. $DESKTOPINTEGRATION is not that + # signal: PhotoCraft sets X-AppImage-Integrate=false, so AppImageLauncher skips the + # AppImage and still exports the variable on its binfmt bypass. Treating it as + # "already integrated" left the menu with no entry (#5534). apps="$data/applications" mkdir -p "$apps" 2>/dev/null || return 0 external=0 - if [ -n "${DESKTOPINTEGRATION:-}" ]; then - external=1 - else - for f in "$apps"/*.desktop; do - if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then - continue - fi - if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then - external=1 - break - fi - done - fi + for f in "$apps"/*.desktop; do + if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then + continue + fi + if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; 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 475fb5ee5e..8c9e33e6bd 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -83,11 +83,27 @@ printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon= [ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with external integrator" rm -f "$ext_desktop" -# 7. DESKTOPINTEGRATION set by an external integrator skips creating a second .desktop entry. +# 7. AppImageLauncher exports DESKTOPINTEGRATION even after X-AppImage-Integrate=false +# made it skip the AppImage. That is not an existing launcher, so AppRun still +# writes its own entry and a later launch must not delete it (#5534). rm -rf "$XDG_DATA_HOME" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "DESKTOPINTEGRATION launch exited non-zero" -[ ! -e "$DESKTOP" ] || fail "DESKTOPINTEGRATION still wrote duplicate desktop entry" +[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION suppressed the desktop entry (#5534)" +grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec missing under DESKTOPINTEGRATION" +grep -Fxq "X-AppImage-Integrate=false" "$DESKTOP" || fail "X-AppImage-Integrate=false missing under DESKTOPINTEGRATION" [ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons not installed under DESKTOPINTEGRATION" +before="$(stat -c %i "$DESKTOP")" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "second DESKTOPINTEGRATION launch exited non-zero" +[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION removed the desktop entry on a later launch" +[ "$before" = "$(stat -c %i "$DESKTOP")" ] || fail "desktop entry rewritten under DESKTOPINTEGRATION although unchanged" +# A real integrator entry still wins, including when DESKTOPINTEGRATION is also set (#3623). +ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" +printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "launch with integrator entry and DESKTOPINTEGRATION exited non-zero" +[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept when an integrator entry exists" +[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" +[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing when DESKTOPINTEGRATION and an integrator entry coexist" +rm -f "$ext_desktop" # 8. Opt-out installs nothing. rm -rf "$XDG_DATA_HOME" From d8d592b1bbab0eca6e1f988310833c2437721ba6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 00:03:42 +0200 Subject: [PATCH 12/90] FITS: clamp 32-bit saves to 0..1 and warn, so they reopen as saved (#5627) (#5628) FITS: clamp 32-bit saves to 0..1 and warn, so they reopen as saved (#5627) Also covers the same fix as #5642; thanks to its author. Co-authored-by: nrq6993-a11y <342501791+nrq6993-a11y@users.noreply.github.com> --- crates/codecs/src/codecs/fits.rs | 17 ++++++++++++++++- crates/codecs/src/fidelity.rs | 3 ++- crates/codecs/tests/fidelity.rs | 6 ++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 3aa769fb01..7e3f92ae67 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -530,7 +530,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Date: Thu, 15 Oct 2026 00:03:50 +0200 Subject: [PATCH 13/90] Properties / New Document: tiny sizes in physical units no longer resize the document (#5661) (#5662) The Width/Height fields in Properties > Document and in the New Document dialog clamped their shown value to at least 0.01 of the unit. A 1 x 1 px document at 300 ppi is 0.0033 in, so showing it in inches clamped it to 0.01 in, the field reported a change and the document became 3 x 3 px (two history steps, no user edit). The fields now accept down to 0; the pixel conversion afterwards already clamps to at least 1 px. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/doc_props_ui.rs | 26 +++++++++++++++++++++++++- crates/ui-egui/src/new_doc_ui.rs | 28 ++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 8c10b8c442..fbdbc9a955 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -100,7 +100,7 @@ fn committed_field(ui: &mut egui::Ui, id: &str, current: f32, u: crate::layer_pr // Shown in the ruler unit (cm when the rulers are in centimeters); 100% is the current size. let extent = f64::from(current); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(extent, extent)); - let r = widgets::value_field(ui, &mut v, 0.01..=300_000.0, u.unit.suffix(), 74.0); + let r = widgets::value_field(ui, &mut v, 0.0..=300_000.0, u.unit.suffix(), 74.0); if r.dragged() || r.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; @@ -318,6 +318,30 @@ mod tests { assert_eq!(canvas_resize_params((2400, 1500), Some(2400.2), None, false), None); } + /// #5661: a 1 x 1 px document at 300 ppi is 0.0033 in wide. With the rulers in inches the + /// Width/Height fields clamped that to 0.01 in (3 px) and resized the document unasked. + #[test] + fn tiny_document_in_inches_is_not_resized_by_showing_it() { + use egui_kittest::Harness; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1, "height": 1, "resolution": 300, "background": "white"})).unwrap(); + app.session.prefs.edit(|p| p.units_and_rulers.rulers = photocraft_engine::prefs::Unit::Inches); + let undo = app.session.active().unwrap().history.entries().len(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + properties(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!((st.doc.size.width, st.doc.size.height), (1, 1)); + assert_eq!(st.history.entries().len(), undo, "no history step"); + } + #[test] fn document_dropdowns_map_to_live_mode_commands() { for id in MODES.iter().map(|m| m.2).chain(DEPTHS.iter().map(|d| d.2)) { diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index c9bf902c67..e615620d4c 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -633,7 +633,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect(); @@ -645,7 +645,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map)| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + body(app, ui, f); + } + }, + (app, f), + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let p = command_params(&h.state().1); + assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1), Some(1))); + } + /// #1147: the Orientation buttons swap the typed values too: 841 x 1189 mm becomes 1189 x 841. #[test] fn orientation_swap_keeps_the_typed_values() { From 2807deb20971cf84f52bf7397fbafafa89ac2d26 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Wed, 14 Oct 2026 23:03:59 +0100 Subject: [PATCH 14/90] fix(psd): write the layer block after a kept merged-transparency marker (#5673) A 16/32-bit document saved as PSD has its layers in a global Lr16/Lr32 block, which the writer put first among the global blocks. A Mtrn/Mt16/Mt32 marker kept from the opened file then followed it, and Photoshop refuses such a file (verified in Photoshop 2026 with psd-tools' transparentbg.psd re-saved both ways: Mt32 first opens, Lr32 first doesn't). The layer block now goes right after the last kept marker, as Photoshop orders them. Fixes #5068. Co-authored-by: Claude Opus 5.5 --- crates/io/src/psd_export.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 47861dbcc3..4fb952ba25 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1746,9 +1746,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); + // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer + // block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right + // after the last kept marker, else first. + let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1); let placement = match (has_layers, sample) { - (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None }, - (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr32", padding: None }, + (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None }, + (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None }, _ => LayerInfoPlacement::Section, }; let records = std::mem::take(&mut ex.records); @@ -1865,6 +1869,31 @@ mod tests { assert_eq!(unique, reloaded_ids); } + /// #5068: Photoshop won't open a file whose `Lr32` comes before the `Mt32` marker it keeps for + /// a 32-bit document's merged transparency (verified in Photoshop 2026 on psd-tools' + /// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker. + #[test] + fn layer_block_follows_a_kept_merged_transparency_marker() { + for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] { + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())), (*b"8BIM", marker, std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + let LayerInfoPlacement::GlobalBlock { index, key: written, .. } = saved.layer_info_placement else { panic!("{depth:?}: a global layer block") }; + assert_eq!(written, key); + let at = saved.global_blocks.iter().position(|b| b.key == marker).expect("the marker is kept"); + assert!(at < index, "{depth:?}: {marker:?} at {at}, the layer block at {index}"); + } + // Without a marker the layer block stays first. + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, SampleType::F32); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. })); + } + #[test] fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { // #2700: a 16-bit file whose Lr16 block claims five layers but holds none. From 772dfb9e7ee9abe45c9142e2eb018c2e8d40ee17 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 00:04:09 +0200 Subject: [PATCH 15/90] Options bar: Brush and Eraser size go to 5000 px in every theme (#5708) (#5709) Options bar: Brush and Eraser size go to 5000 px in every theme (#5708) Also covers the same fix as #5724; thanks to its author. Co-authored-by: nrq6993-a11y <342501791+nrq6993-a11y@users.noreply.github.com> --- crates/ui-egui/src/brush_panel_tests.rs | 15 +++++++++++++++ crates/ui-egui/src/panels.rs | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index cffbb5b534..670f442be2 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -399,6 +399,21 @@ fn brush_mode_dropdown_shows_outside_the_pro_themes() { assert_eq!(j[n].0, "tools.setBrush"); } +#[test] +fn brush_and_eraser_size_fields_keep_sizes_above_2500_px() { + // #5708: outside the Pro themes the Size field capped at 2500 px and clamped a bigger brush + // (set from the picker or `]`) on the next frame; Photoshop and the engine allow 5000 px. + for tool in [crate::state::Tool::Brush, crate::state::Tool::Eraser] { + let mut h = options_bar_harness(tool); + let n = h.state().session.journal.len(); + h.state_mut().session.tools.brush.size = 4000.0; + h.run_steps(4); + assert_eq!(h.state().session.tools.brush.size, 4000.0, "{tool:?}"); + let j = &h.state().session.journal; + assert_eq!(j.len(), n, "showing the bar issues nothing: {:?}", &j[n..]); + } +} + #[test] fn airbrush_smoothing_options_and_symmetry_buttons_work() { // #1177: in the default (Photoshop) theme these options-bar buttons did nothing. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 82c2b667a1..156a0a3f23 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1044,7 +1044,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let block = crate::eraser_ui::block_mode(app, app.ui.tool); ui.add_enabled_ui(!block, |ui| { opt_label(ui, tl!("Size")); - widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + widgets::value_field(ui, &mut b.size, 1.0..=photocraft_engine::paint::MAX_BRUSH_SIZE, "px", 76.0); }); widgets::vline(ui, 22.0); if app.ui.tool == Tool::Eraser { From 7e94c507e9336454a81d740eab58c20e69c19a3e Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:04:19 -0400 Subject: [PATCH 16/90] Smart Objects: Curves preview no longer zooms in on a reduced proxy (#5766) (#5779) Adjustments and filters on a smart object add a smart filter, so their live preview re-renders the smart object from its source through its placement. The preview runs on a proxy document scaled down by k (large documents), and proxy_document scaled the smart object's cache but left its transform (and perspective and filter mask) in full-size document coordinates. The re-render then came out k times too large on the proxy canvas, so the object looked zoomed in until OK committed the full-size result. proxy_document now scales the placement and the filter mask with the proxy. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/proxy.rs | 52 +++++++++++++++++++++++++++-- crates/ui-egui/src/filter_dialog.rs | 32 ++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 84c9348029..f48f10d83f 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -4,8 +4,8 @@ //! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP //! document would cost seconds and gigabytes. -use photocraft_doc::{Document, Layer, LayerContent, Size}; -use photocraft_geom::Rect; +use photocraft_doc::{Document, Layer, LayerContent, Size, SmartObject}; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; /// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k). @@ -60,11 +60,30 @@ fn shrink_layer(l: &mut Layer, k: u32) { } LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)), LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)), - LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Smart(so) => shrink_smart(so, k), LayerContent::Adjustment(_) | LayerContent::Fill(_) => {} } } +/// Scales a smart object with its proxy: the cache and filter mask like any surface, and the +/// placement too. A preview that adds a smart filter re-renders the source through the placement; +/// left in full-size document coordinates, that render came out `k` times too large on the proxy +/// canvas, so the Curves preview on a smart object zoomed in (#5766). +fn shrink_smart(so: &mut SmartObject, k: u32) { + so.cache = so.cache.as_ref().map(|s| downsample(s, k)); + if let Some(m) = &mut so.filter_mask { + m.surface = downsample(&m.surface, k); + } + let s = 1.0 / f64::from(k); + so.transform = Affine::scale(s).mul(&so.transform); + // Row-major source → document map: scaling the document side scales its first two rows. + if let Some(h) = &mut so.perspective { + for v in h.iter_mut().take(6) { + *v *= s; + } + } +} + /// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). The /// selection scales with it, so a filter previewed on the proxy stays inside the (feathered) /// selection exactly as the full-size result does. @@ -166,6 +185,33 @@ mod tests { assert!(proxy_faithful(&doc)); } + /// #5766: a smart object re-rendered on the proxy (a smart filter preview) goes through its + /// placement, which must shrink with the canvas, cache and filter mask. + #[test] + fn proxy_scales_smart_object_placement() { + use photocraft_doc::SmartSource; + let mut doc = Document::new("smart", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut cache = Surface::new(doc.pixel_format()); + cache.fill_rect(Rect::new(16, 16, 32, 48), &[0.0, 0.0, 0.0, 1.0]); + let source = SmartSource::Embedded { file_name: "s.png".into(), bytes: std::sync::Arc::new(Vec::new()) }; + let mut sm = SmartObject::new(source, Affine::translate(16.0, 16.0).mul(&Affine::scale(2.0)), Some(cache)); + let mut mask = LayerMask::hide_all(); + mask.surface.fill_rect(Rect::new(16, 16, 32, 32), &[1.0]); + sm.filter_mask = Some(mask); + sm.perspective = Some([2.0, 0.0, 16.0, 0.0, 2.0, 16.0, 0.0, 0.0, 1.0]); + doc.layers.push(Layer::new("smart", LayerContent::Smart(sm))); + let p = proxy_document(&doc, 4); + let LayerContent::Smart(sm) = &p.layers[0].content else { panic!("a smart object") }; + assert_eq!(sm.transform, Affine { m: [0.5, 0.0, 0.0, 0.5, 4.0, 4.0] }); + assert_eq!(sm.perspective, Some([0.5, 0.0, 4.0, 0.0, 0.5, 4.0, 0.0, 0.0, 1.0])); + assert_eq!(sm.cache.as_ref().unwrap().content_bounds(), Rect::new(4, 4, 8, 12)); + let mask = &sm.filter_mask.as_ref().unwrap().surface; + assert_eq!((mask.pixel(4, 4)[0], mask.pixel(8, 8)[0]), (1.0, 0.0)); + // The document keeps its full-size placement. + let LayerContent::Smart(orig) = &doc.layers[0].content else { panic!("a smart object") }; + assert_eq!(orig.transform.m[4], 16.0); + } + #[test] fn proxy_scales_the_selection() { let mut doc = Document::with_background("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 53885a45a2..5d86cc16b5 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1830,6 +1830,38 @@ mod tests { assert_eq!(photocraft_compose::flatten(&shown).px, photocraft_compose::flatten(&full).px); } + /// #5766: Curves on a smart object adds a smart filter, which re-renders the source through + /// the object's placement. On a reduced proxy that placement stayed in full-size document + /// coordinates, so the preview showed the object `k` times too large until OK. + #[test] + fn smart_object_curves_preview_keeps_its_size_on_a_reduced_proxy() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 16, "y": 16, "width": 16, "height": 16})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let st = s.active().unwrap(); + let (doc, layer) = ((*st.doc).clone(), st.active_layer.unwrap()); + let alpha = |d: &Document, x: i32, y: i32| match &d.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.cache.as_ref().unwrap().pixel(x, y).last().copied().unwrap(), + _ => panic!("a smart object"), + }; + let k = 4; + let shown = preview_document(&doc, Some(layer), "image.adjustments.curves", &json!({"points": [[0, 20], [90, 150], [255, 230]]}), k).unwrap(); + let photocraft_doc::LayerContent::Smart(sm) = &shown.layer(layer).unwrap().content else { panic!("a smart object") }; + assert_eq!(sm.smart_filters.len(), 1, "the preview adds the Curves smart filter"); + assert_eq!(shown.size, crate::proxy::proxy_document(&doc, k).size); + // The 16 px square at (16, 16) is a 4 px square at (4, 4) on the proxy, not 16 px at (16, 16). + for (x, y) in [(4, 4), (5, 6), (7, 7)] { + assert!(alpha(&shown, x, y) > 0.99, "({x}, {y}) is inside the reduced square"); + } + for (x, y) in [(2, 2), (9, 9), (12, 12), (15, 15)] { + assert!(alpha(&shown, x, y) < 0.01, "({x}, {y}) is outside the reduced square"); + } + } + /// #2063: a pixel-sized parameter divided by the proxy factor was clamped to the command's /// minimum (Pointillize's 5 px cells became 3 px proxy cells = 12 px), so the preview factor /// drops until every pixel-sized value fits its range. From 0e15fe77f7de5a6d0214a8fc134a3568f0bde81e Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Wed, 14 Oct 2026 23:04:27 +0100 Subject: [PATCH 17/90] Fix Nikon NEF lossy levels, packed decoding and metadata crops (#3238) Co-authored-by: optimalbytee --- ATTRIBUTION.md | 2 +- crates/raw/README.md | 6 +++ crates/raw/src/nefc.rs | 93 +++++++++++++++++++++++++++++++++- crates/raw/src/tiffep.rs | 30 ++++++++++- crates/raw/tests/nef_packed.rs | 86 +++++++++++++++++++++++++++++++ crates/raw/tests/raw_corpus.rs | 35 +++++++++++++ xtask/pixls.sha256 | 4 ++ xtask/src/corpus_pins.rs | 6 +++ 8 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 crates/raw/tests/nef_packed.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c296ece27b..26eca62464 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -114,6 +114,6 @@ next to the files: | `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause | | `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI | | `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain | -| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | +| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. diff --git a/crates/raw/README.md b/crates/raw/README.md index b8c75ebff6..fda140d75b 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -83,6 +83,12 @@ camera colour tables were copied. | CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | | X-Trans and other periodic non-Bayer CFAs (up to 16×16) | Demosaiced by one pattern-agnostic edge-directed method, whatever `Demosaic` is chosen | +Nikon maker-note compression modes 6/10 select checked little-endian packed 12/14-bit +rows even when TIFF Compression is 34713. The modern lossy `0x44 0x40` curve uses +its smaller encoded domain while preserving sensor ADUs. Valid Nikon CropArea +coordinates take precedence over model fallbacks. HE/HE* and lossy-after-split +remain unsupported. Four pinned CC0 Z6 files cover both bit depths and both paths. + ## Development pipeline 1. Linearization table, per-position black level, scale to the white level. diff --git a/crates/raw/src/nefc.rs b/crates/raw/src/nefc.rs index 9ca5eff5f9..db1f8b1d74 100644 --- a/crates/raw/src/nefc.rs +++ b/crates/raw/src/nefc.rs @@ -33,7 +33,9 @@ //! * Lossless (`0x0096` version `0x46`): the decoded values are the samples. //! Lossy (`0x44`): they index a curve. Type 1 (`0x44 0x10`) stores the whole //! curve, type 2 (`0x44 0x20` / `0x44 0x40`) stores `n` points spaced -//! `2^bits / (n - 1)` codes apart, linearly interpolated. +//! `2^bits / (n - 1)` codes apart, linearly interpolated. Version `0x44 0x40` +//! uses a two-bit-smaller encoded domain (1025 / 4097 levels for 12 / 14-bit +//! samples), not the sensor bit depth; see Bill Claff's NEF Compression tables. //! * Values in the maker note are in the maker note's byte order. //! //! Not decoded (reported as unsupported; the embedded preview is used @@ -50,6 +52,59 @@ pub(crate) const NIKON_COMPRESSION: u32 = 34713; /// Maker note NEFLinearizationTable. pub(crate) const NIKON_LINEARIZATION_TABLE: u16 = 0x0096; +/// Packed uncompressed NEF modes 6 (12-bit) and 10 (14-bit). Measured on CC0 +/// Z6/Z7/D850 samples: little-endian bit packing, rows optionally padded to 16 +/// bytes. Selection comes from the maker note, never from the strip's length. +pub(crate) fn read_packed(t: &Tiff, ifd: &Ifd, bits: u32, limits: &Limits) -> Result { + let w = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("packed NEF has no width"))? as usize; + let h = t.tag_uint(ifd, tag::IMAGE_LENGTH).ok_or_else(|| RawError::malformed("packed NEF has no height"))? as usize; + if !t.le || t.tag_uint(ifd, tag::BITS_PER_SAMPLE) != Some(bits) || t.tag_uint(ifd, tag::SAMPLES_PER_PIXEL).unwrap_or(1) != 1 { + return Err(RawError::unsupported("packed NEF byte order, bit depth or sample layout")); + } + limits.check(w as u64, h as u64, 2)?; + let offsets = t.tag_uints(ifd, tag::STRIP_OFFSETS); + let counts = t.tag_uints(ifd, tag::STRIP_BYTE_COUNTS); + let ([off], [len]) = (offsets.as_slice(), counts.as_slice()) else { + return Err(RawError::unsupported("packed NEF with multiple strips")); + }; + let src = t.bytes(*off as usize, *len as usize).ok_or_else(|| RawError::malformed("packed NEF strip lies outside the file"))?; + let data = unpack_little_rows(src, w, h, bits)?; + Ok(Plane { width: w, height: h, samples: 1, bits, data }) +} + +fn unpack_little_rows(src: &[u8], w: usize, h: usize, bits: u32) -> Result> { + let bad = || RawError::malformed("packed NEF row layout is invalid or truncated"); + if w == 0 || h == 0 || !matches!(bits, 12 | 14) { + return Err(bad()); + } + let row = w.checked_mul(bits as usize).ok_or_else(bad)?.div_ceil(8); + let padded = row.checked_add(15).ok_or_else(bad)? / 16 * 16; + let stride = if row.checked_mul(h) == Some(src.len()) { + row + } else if padded.checked_mul(h) == Some(src.len()) { + padded + } else { + return Err(bad()); + }; + let count = w.checked_mul(h).ok_or_else(bad)?; + let mut out = Vec::with_capacity(count); + let mask = (1u32 << bits) - 1; + for row in src.chunks_exact(stride) { + let mut bytes = row.iter(); + let (mut acc, mut have) = (0u32, 0u32); + for _ in 0..w { + while have < bits { + acc |= u32::from(*bytes.next().ok_or_else(bad)?) << have; + have += 8; + } + out.push((acc & mask) as u16); + acc >>= bits; + have -= bits; + } + } + Ok(out) +} + /// A code word that never occurred in any analysed sample. const UNSEEN: u8 = u8::MAX; @@ -216,7 +271,10 @@ pub(crate) fn parse_table(t: &[u8], le: bool, bits: u32) -> Result if split != 0 { return Err(RawError::unsupported(format!("Nikon \"lossy after split\" compressed NEF (split at row {split})"))); } - let range = 1usize << bits; + // The modern lossy curve has a smaller *encoded* domain while + // its output remains in sensor ADUs. Using 2^bits stretches the + // curve fourfold and puts Z6 image values below its black level. + let range = 1usize << if v1 == 0x40 { bits - 2 } else { bits }; if n - 1 > range || !range.is_multiple_of(n - 1) { return Err(RawError::unsupported(format!("Nikon compressed NEF: {n}-point curve for {bits}-bit data"))); } @@ -503,6 +561,37 @@ mod tests { assert!(parse_table(&[0x44, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], false, 12).is_err()); } + #[test] + fn modern_lossy_curve_preserves_sensor_adus_at_both_depths() { + for bits in [12u32, 14] { + for le in [false, true] { + let put = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() }; + let mut t = vec![0x44, 0x40]; + for _ in 0..4 { + t.extend(put(256)); + } + t.extend(put(257)); + let white = (1u16 << bits) - 1; + for i in 0..=256u32 { + t.extend(put(((i * 64).min(u32::from(white))) as u16)); + } + t.resize(624, 0); + let parsed = parse_table(&t, le, bits).unwrap(); + let Encoding::Lossy(curve) = &parsed.encoding else { panic!() }; + let range = 1usize << (bits - 2); + assert_eq!(curve.len(), range + 1); + assert_eq!(curve[range], white); + // Stored point 16 is 1024 ADU. It belongs to encoded code 256 + // at 14 bits, code 64 at 12 bits, independent of end-point clipping. + assert_eq!(curve[range / 16], 1024.min(white)); + let indices = vec![0, (range / 16) as i32, (range / 8) as i32, (range / 32) as i32]; + let huffman = if bits == 12 { &LOSSY_12 } else { &LOSSY_14 }; + let src = encode(&indices, 2, huffman, parsed.seeds); + assert_eq!(decode(&src, 2, 2, bits, &parsed).unwrap(), vec![0, 1024, 2048, 512]); + } + } + } + /// A 14-bit lossless table (version 0x46) with the given seeds. fn lossless_table(seed: u16, le: bool) -> Vec { let mut t = vec![0x46, 0x30]; diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index 4a75491f5f..25befbddea 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -78,6 +78,18 @@ fn nikon_maker_note<'a>(t: &Tiff<'a>, ifds: &[Ifd]) -> Option<(Tiff<'a>, Ifd)> { Some((inner, ifd)) } +/// ExifTool's public Nikon tag documentation: legacy 0x93, or a u16 at offset +/// 10 of MakerNotes0x51. The latter is used by Z6/Z7, despite TIFF Compression +/// remaining 34713 even for uncompressed sensor data. +fn nikon_compression(nikon: Option<&(Tiff<'_>, Ifd)>) -> Option { + let (t, m) = nikon?; + t.tag_uint(m, 0x0093).or_else(|| { + let b = t.raw(m.get(0x0051)?)?; + let v: [u8; 2] = b.get(10..12)?.try_into().ok()?; + Some(u32::from(if t.le { u16::from_le_bytes(v) } else { u16::from_be_bytes(v) })) + }) +} + /// Four levels listed in R, G, G, B order, placed at their 2×2 CFA positions /// (data parity). pub(crate) fn rggb_by_position(cfa: &Cfa, v: &[f64]) -> Option<[f32; 4]> { @@ -130,7 +142,12 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result nefc::read_packed(t, &raw, 12, limits)?, + Some(10) => nefc::read_packed(t, &raw, 14, limits)?, + Some(13 | 14) => return Err(RawError::unsupported("Nikon High Efficiency / High Efficiency* NEF")), + _ => nefc::read_compressed(t, &raw, nikon.as_ref(), limits)?, + } } else { read_plane(t, &raw, limits, JpegLayout::Quads)? }; @@ -195,6 +212,17 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result= 0.0 { v.round() as usize } else { 0 }; let r = Rect::new(f(*ox), f(*oy), f(*cw), f(*ch)).intersect(&full); diff --git a/crates/raw/tests/nef_packed.rs b/crates/raw/tests/nef_packed.rs new file mode 100644 index 0000000000..f050681857 --- /dev/null +++ b/crates/raw/tests/nef_packed.rs @@ -0,0 +1,86 @@ +use photocraft_raw::testgen::{Val, nef_compressed}; +use photocraft_raw::{Limits, RawError}; + +fn packed(values: &[u16], w: usize, bits: u32, padded: bool) -> Vec { + let mut out = Vec::new(); + for row in values.chunks_exact(w) { + let start = out.len(); + let (mut acc, mut have) = (0u32, 0u32); + for v in row { + acc |= u32::from(*v) << have; + have += bits; + while have >= 8 { + out.push(acc as u8); + acc >>= 8; + have -= 8; + } + } + if have > 0 { + out.push(acc as u8); + } + if padded { + out.resize(start + (out.len() - start).div_ceil(16) * 16, 0xff); + } + } + out +} + +#[test] +fn packed_nef_uses_maker_mode_not_the_shared_tiff_compression() { + let (w, h) = (22, 5); + for (bits, mode) in [(12u32, 6u16), (14, 10)] { + let values: Vec = (0..w * h).map(|i| ((i * 83 + 11) % (1 << bits)) as u16).collect(); + for padded in [false, true] { + for big_note in [false, true] { + let strip = packed(&values, w, bits, padded); + for modern in [false, true] { + let tag = if modern { + let mut record = b"01000400\0\0".to_vec(); + record.extend(if big_note { mode.to_be_bytes() } else { mode.to_le_bytes() }); + (0x51, Val::Undefined(record)) + } else { + (0x93, Val::Short(vec![mode])) + }; + let bytes = nef_compressed(w, h, bits as u16, strip.clone(), [0, 1, 1, 2], big_note, vec![tag]); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.data, values); + } + } + } + } +} + +#[test] +fn packed_nef_rejects_truncation_wrong_depth_and_unknown_modes() { + let strip = packed(&[1000; 18 * 5], 18, 14, true); + for size in 0..strip.len() { + let bytes = nef_compressed(18, 5, 14, strip[..size].to_vec(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10]))]); + // The unpadded size is another valid layout; all other truncations fail. + if size != (18 * 14usize).div_ceil(8) * 5 { + assert!(photocraft_raw::decode(&bytes, &Limits::default()).is_err()); + } + } + let bytes = nef_compressed(18, 5, 12, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10]))]); + assert!(matches!(photocraft_raw::decode(&bytes, &Limits::default()), Err(RawError::Unsupported(_)))); + for mode in [3, 4, 9, 13, 14, 65535] { + let bytes = nef_compressed(18, 5, 14, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![mode]))]); + assert!(matches!(photocraft_raw::decode(&bytes, &Limits::default()), Err(RawError::Unsupported(_)))); + } +} + +#[test] +fn nikon_crop_area_uses_sensor_coordinates_and_ignores_invalid_rectangles() { + let strip = packed(&[1000; 22 * 10], 22, 14, true); + for (rect, expected) in [ + ([2, 2, 18, 6], photocraft_raw::Rect::new(2, 2, 18, 6)), + ([2, 2, 22, 10], photocraft_raw::Rect::new(0, 0, 22, 10)), + ([2, 2, 0, 6], photocraft_raw::Rect::new(0, 0, 22, 10)), + ([u32::MAX, 2, 18, 6], photocraft_raw::Rect::new(0, 0, 22, 10)), + ] { + let bytes = nef_compressed(22, 10, 14, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10])), (0x45, Val::Long(rect.to_vec()))]); + let sensor = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(sensor.crop, expected); + let developed = photocraft_raw::develop_sensor(&sensor, &Default::default()).unwrap(); + assert_eq!((developed.width as usize, developed.height as usize), (expected.width, expected.height)); + } +} diff --git a/crates/raw/tests/raw_corpus.rs b/crates/raw/tests/raw_corpus.rs index d90e06a6c1..afd168a38a 100644 --- a/crates/raw/tests/raw_corpus.rs +++ b/crates/raw/tests/raw_corpus.rs @@ -144,6 +144,41 @@ fn developing_produces_the_crop_in_16bit_rgb() { } } +/// These paired exposures used to decode below their recorded black levels because the +/// version 0x44 0x40 linearization curve was interpolated across the sensor's full bit range. +#[test] +fn modern_lossy_nef_preserves_brightness_at_both_depths() { + for (file, black, white) in [("DSC_0751.NEF", 1008.0, 16383.0), ("DSC_0754.NEF", 252.0, 4095.0)] { + let bytes = std::fs::read(pixls(file)).unwrap(); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.model.as_deref(), Some("NIKON Z 6")); + assert_eq!((s.width, s.height), (6064, 4040)); + assert_eq!(s.black.values, vec![black; 4]); + assert!(s.white.iter().all(|v| (*v - white).abs() < 100.0)); + // The same scene spans deep shadows and bright highlights at either sensor depth. + let max = s.data.iter().copied().max().unwrap(); + assert!(f32::from(max) > white * 0.9, "{file}: maximum {max}"); + let mean = s.data.iter().map(|v| f64::from(*v)).sum::() / s.data.len() as f64; + assert!(mean > f64::from(black) * 1.5, "{file}: mean {mean}"); + let d = photocraft_raw::develop_sensor(&s, &photocraft_raw::DevelopOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (6048, 4024)); + assert!(d.rgb.iter().copied().max().unwrap() > 32768, "{file}: black development"); + } +} + +#[test] +fn packed_nef_opens_full_sensor_and_metadata_crop_at_both_depths() { + for (file, first) in [("DSC_0750.NEF", [3539, 3761, 3640, 3904]), ("DSC_0753.NEF", [866, 932, 881, 948])] { + let bytes = std::fs::read(pixls(file)).unwrap(); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!((s.width, s.height), (6064, 4040)); + assert_eq!(&s.data[..4], &first); + assert_eq!(s.crop, photocraft_raw::Rect::new(8, 8, 6048, 4024)); + let d = photocraft_raw::develop_sensor(&s, &Default::default()).unwrap(); + assert_eq!((d.width, d.height), (6048, 4024)); + } +} + /// The E-5 file is the documented known-unsupported case: later Olympus bodies store packed /// (compressed) sensor data. The error must say so, never panic, and the file still previews. #[test] diff --git a/xtask/pixls.sha256 b/xtask/pixls.sha256 index 3878b45356..f76f983ba4 100644 --- a/xtask/pixls.sha256 +++ b/xtask/pixls.sha256 @@ -3,5 +3,9 @@ 6c2ddd232f5432fb7bd0e28efc12d66995cedcd847f801fd854bfdfb180db50c IMG_4059.CR2 7c24fefb1f73ad45c42a0be0078e15b7e91deab1d623efceebfc3c4d0d06135c P1000475.RW2 880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8 JD1_8203.NEF +8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02 DSC_0751.NEF +3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886 DSC_0754.NEF +b4cdb8ecf8a971ec023dd242251c6cbfb19966914e37c2311dc9813a7ab5e15f DSC_0750.NEF +13c1ec5c2d020b3038d9cb660bc0e0b5a52cb9fe12561dec9ba0e9919e9cbee2 DSC_0753.NEF ab0d54e3819c9372df0fa98c8686dba0ae87c9d43526a5dd6fd26bd2a664483d _7061961_copy.ORF f602f8ddeeb9b062f60bfdd3ba0d560c8e54f1f64dbc20e18df5222e2f37a428 DSC00009.ARW diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index 917412709a..908557feea 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -253,6 +253,12 @@ pub const PIXLS_FILES: &[(&str, &str)] = &[ ("Canon/PowerShot%20SX50%20HS/IMG_4059.CR2", "IMG_4059.CR2"), ("Canon/PowerShot%20SX50%20HS/CRW_4061.DNG", "CRW_4061.DNG"), ("Nikon/D3/JD1_8203.NEF", "JD1_8203.NEF"), + // Modern lossy NEF curve: the encoded range is two bits smaller than the sensor ADUs. + ("Nikon/Z%206/DSC_0751.NEF", "DSC_0751.NEF"), + ("Nikon/Z%206/DSC_0754.NEF", "DSC_0754.NEF"), + // Uncompressed packed NEF selected by maker-note mode, despite TIFF Compression 34713. + ("Nikon/Z%206/DSC_0750.NEF", "DSC_0750.NEF"), + ("Nikon/Z%206/DSC_0753.NEF", "DSC_0753.NEF"), ("Sony/DSC-RX0/DSC00009.ARW", "DSC00009.ARW"), ("Panasonic/DC-G9/P1000475.RW2", "P1000475.RW2"), ("Olympus/E-1/E_1__C106743_gredos.ORF", "E_1__C106743_gredos.ORF"), From 0f8c1a410635d34d4bd8e315f5e2cb49bfcfca7f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 09:04:37 +1100 Subject: [PATCH 18/90] Curves: apply to masks, alpha, and Quick Mask (#5335) (#5448) Curves: apply to masks, alpha, and Quick Mask (#5335) Also covers the same fix as #5578; thanks to its author. Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> --- crates/engine/src/adjust_cmds/tests.rs | 100 ++++++++++++++++ crates/engine/src/channel_cmds.rs | 81 +++++++++++++ crates/engine/src/channel_cmds/tests.rs | 48 ++++++++ crates/engine/src/commands.rs | 38 ++++++- crates/ui-egui/src/adjust_dialog.rs | 126 ++++++++++++++++++++- crates/ui-egui/src/adjust_editors.rs | 41 +++++-- crates/ui-egui/src/adjust_editors_tests.rs | 2 + crates/ui-egui/src/tone.rs | 99 ++++++++++++++-- crates/ui-egui/src/widgets.rs | 26 +++-- 9 files changed, 531 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 9f614732de..80e97b9f34 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -527,6 +527,106 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() { assert_eq!(rgba(&s, 23, 3), pixels); } +/// #5335: Curves (⌘M) edits the targeted layer mask, saved channel or Quick Mask at every depth. +#[test] +fn curves_apply_to_non_colour_targets_at_every_depth() { + let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32); + for depth in DEPTHS { + for target in ["mask", "alpha", "quickMask"] { + let mut s = session(depth, "rgb"); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let p = match target { + "mask" => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "mask"}) + } + "alpha" => { + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": {"channel": 0}}) + } + _ => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "quickMask"}) + } + }; + let edit_target = p.get("target").cloned().unwrap_or(json!(null)); + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": edit_target })).unwrap(); + surf.fill_rect(area, &[0.5]); + Ok(()) + }) + .unwrap(); + let read = |s: &Session| { + let d = s.active().unwrap(); + let layer = d.doc.layer(d.active_layer.unwrap()).unwrap(); + let t = match target { + "quickMask" => d.doc.quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + "alpha" => d.doc.channels[0].surface.sample_channel(8, 8, 0), + _ => layer.mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + }; + (rgba(s, 8, 8), t) + }; + let ((pixels, before), past) = (read(&s), s.active().unwrap().history.past_len()); + s.execute("image.adjustments.curves", p).unwrap(); + let (pixels_after, after) = read(&s); + let case = format!("{target} at {depth}"); + assert_eq!(pixels_after, pixels, "{case}: colour pixels stay put"); + assert!(after > before + 0.1, "{case}: the curve lifted the midtone ({before} → {after})"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}"); + s.undo(); + assert!((read(&s).1 - before).abs() < 1e-5, "{case}: undo restores the target"); + } + } +} + +/// #5335: a layer mask, saved channel or Quick Mask holds gray levels whatever the document's +/// colour mode: inverting 25% grey gives 75% grey in Lab and CMYK, not an untouched plane (Lab +/// ignores the composite) or an ink-separated result (CMYK curves the inks). +#[test] +fn curves_on_grey_targets_ignore_the_document_colour_mode() { + let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32); + for mode in ["lab", "cmyk"] { + for depth in [8u64, 16] { + for target in ["mask", "alpha", "quickMask"] { + let mut s = session(depth, mode); + let p = match target { + "mask" => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!("mask") + } + "alpha" => { + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + json!({"channel": 0}) + } + _ => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!("quickMask") + } + }; + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": p })).unwrap(); + surf.fill_rect(area, &[0.25]); + Ok(()) + }) + .unwrap(); + let read = |s: &Session| match target { + "quickMask" => doc(s).quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + "alpha" => doc(s).channels[0].surface.sample_channel(8, 8, 0), + _ => mask_at(s, 8, 8), + }; + let before = read(&s); + let pixels = rgba(&s, 8, 8); + s.execute("image.adjustments.curves", json!({"points": [[0, 255], [255, 0]], "target": p})).unwrap(); + let after = read(&s); + let case = format!("{mode} {depth}-bit {target}"); + assert!((before - 0.25).abs() < 0.01, "{case}: filled with 25% grey ({before})"); + assert!((after - 0.75).abs() < 0.02, "{case}: inverting 25% grey gives 75% grey ({after})"); + assert_eq!(rgba(&s, 8, 8), pixels, "{case}: colour pixels stay put"); + } + } + } +} + #[test] fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { // The same cream on the left (x < 8) and the right (x ≥ 40), grey between. diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 00f3ec4e31..6dac6f2a69 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -182,6 +182,87 @@ pub fn composite_name(mode: ColorMode) -> &'static str { } } +/// The Curves dialog's Channel popup lists only this channel when the Channels panel has a +/// single non-composite target (a colour component, layer mask, saved channel or Quick Mask). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IsolatedCurveChannel { + /// Channels-panel name, including a custom alpha name or "{layer} Mask". + pub name: String, + /// Curves param key: empty = composite `points`; otherwise `red`/`green`/… + pub key: &'static str, + /// Index into the tone-space histograms (composite = 0 for RGB/CMYK). + pub hist: usize, + /// Colour components keep their curve tint; masks and alphas do not. + pub tint: bool, + /// Built-in labels (colour channels, Quick Mask) follow the interface language; a custom + /// alpha name or a "{layer} Mask" keeps the layer name, so it stays exactly as entered (#5335). + pub translate: bool, + /// The gray plane this target edits, whatever the document's colour mode; its surface is + /// what the dialog's histogram must describe (#5335). None for colour components. + pub gray: Option, +} + +/// A grayscale plane Curves can target: the dialog parses and previews its curves in gray levels +/// even in Lab and CMYK documents (#5335). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraySurface { + /// The active layer's layer mask. + LayerMask, + /// A saved channel, as an index into [`Document::channels`]. + Alpha(usize), + /// The Quick Mask. + QuickMask, +} + +/// Isolated Curves channel for the active document, matching [`inject_target`]. +pub fn isolated_curve_channel(st: &DocState) -> Option { + isolated_curve_channel_for(st, false) +} + +/// [`isolated_curve_channel`] with the Layers panel's mask target added: clicking a mask +/// thumbnail targets it without touching the Channels panel, and both ways of selecting the mask +/// must give the same Curves dropdown (#5335). +pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Option { + let doc = &st.doc; + let mode = edit_mode(doc); + match st.channel_view.target { + ChannelTarget::Color(k) => { + let names = color_names(mode); + let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode)); + let name = names.get(k)?.to_string(); + let key = *keys.get(k)?; + let hist = if matches!(mode, ColorMode::Lab) { k } else { k.saturating_add(1) }; + Some(IsolatedCurveChannel { name, key, hist, tint: true, translate: true, gray: None }) + } + ChannelTarget::Alpha(i) => doc.channels.get(i).map(|c| IsolatedCurveChannel { + name: c.name.clone(), + key: "", + hist: 0, + tint: false, + translate: false, + gray: Some(GraySurface::Alpha(i)), + }), + ChannelTarget::Composite if doc.quick_mask.is_some() => Some(IsolatedCurveChannel { + name: "Quick Mask".into(), + key: "", + hist: 0, + tint: false, + translate: true, + gray: Some(GraySurface::QuickMask), + }), + ChannelTarget::LayerMask => layer_mask_curve_channel(st), + ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st), + _ => None, + } +} + +fn layer_mask_curve_channel(st: &DocState) -> Option { + let layer = st.active_layer.and_then(|id| st.doc.layer(id))?; + layer.mask.as_ref()?; + let name = format!("{} Mask", layer.name); + Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) }) +} + fn edit_mode(doc: &Document) -> ColorMode { doc.pixel_format().mode } diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 1d721ee5c5..d63dc5fd0d 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -867,3 +867,51 @@ fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() { assert_eq!((b.x0, b.y0, b.width(), b.height()), (20, 10, 40, 30)); } } + +/// #5335: a single targeted channel is the only Curves popup entry, named as in the Channels panel. +#[test] +fn isolated_curve_channel_matches_the_channels_panel() { + let mut s = session(); + assert!(isolated_curve_channel(s.active().unwrap()).is_none(), "composite lists every colour channel"); + s.execute("channel.target", json!({"channel": {"color": 1}})).unwrap(); + let green = isolated_curve_channel(s.active().unwrap()).expect("green"); + assert_eq!((green.name.as_str(), green.key, green.tint), ("Green", "green", true)); + assert!(green.translate && green.gray.is_none(), "built-in colour labels follow the language"); + + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask = isolated_curve_channel(s.active().unwrap()).expect("mask"); + assert_eq!(mask.key, ""); + assert!(!mask.tint); + assert_eq!(mask.name, format!("{} Mask", doc(&s).layer(s.active().unwrap().active_layer.unwrap()).unwrap().name)); + + s.execute("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + let alpha = isolated_curve_channel(s.active().unwrap()).expect("alpha"); + assert_eq!((alpha.name.as_str(), alpha.key, alpha.tint), ("Green", "", false)); + assert!(!alpha.translate, "a custom channel name stays exactly as entered"); + assert_eq!(alpha.gray, Some(GraySurface::Alpha(0))); + + let mut qm = session(); + qm.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let row = isolated_curve_channel(qm.active().unwrap()).expect("quick mask"); + assert_eq!((row.name.as_str(), row.tint), ("Quick Mask", false)); + assert_eq!(row.gray, Some(GraySurface::QuickMask)); +} + +/// #5335: selecting the mask in the Layers panel gives the same isolated Curves channel as +/// selecting it in the Channels panel; the Quick Mask keeps priority. +#[test] +fn layers_panel_mask_target_isolates_the_curve_channel() { + let mut s = session(); + assert!(isolated_curve_channel_for(s.active().unwrap(), true).is_none(), "a layer without a mask is not targeted"); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let by_panel = isolated_curve_channel(s.active().unwrap()).expect("mask targeted from the Channels panel"); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let by_layers = isolated_curve_channel_for(s.active().unwrap(), true).expect("mask targeted from the Layers panel"); + assert_eq!(by_panel, by_layers, "both ways of selecting the mask give the same dropdown"); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let row = isolated_curve_channel_for(s.active().unwrap(), true).expect("quick mask wins"); + assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 68d00a7da3..7ddad80737 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -4,7 +4,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size}; use photocraft_geom::Rect; use photocraft_raster::Surface; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use crate::{EngineError, Result, Session, inspect, pixels}; @@ -414,6 +414,31 @@ fn doc_mode(s: &Session) -> ColorMode { s.active().map_or(ColorMode::Rgb, |d| d.doc.mode) } +/// The colour mode a destructive adjustment parses its tone space in: a targeted layer mask, +/// saved channel or Quick Mask holds gray levels whatever the document's mode, so Curves and +/// Levels stay in the neutral space instead of Lab (where the composite is ignored) or CMYK +/// (where the curve runs on the inks) (#5335). +fn adjust_mode(s: &Session, p: &Value) -> ColorMode { + if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { ColorMode::Grayscale } else { doc_mode(s) } +} + +/// Tone params for a gray plane target: the document-space channel records are dropped, because +/// those keys select the Lab or CMYK tone space and a plane only holds levels (#5335). +fn gray_tone_params(p: &Value) -> Value { + match p { + Value::Object(m) => { + let mut o = Map::new(); + for (k, v) in m { + if !matches!(k.as_str(), "cyan" | "magenta" | "yellow" | "black" | "lightness" | "a" | "b") { + o.insert(k.clone(), v.clone()); + } + } + Value::Object(o) + } + other => other.clone(), + } +} + // ---------- the table ---------- #[allow(clippy::too_many_lines)] @@ -1269,7 +1294,16 @@ fn build() -> Vec { }); let adj = match eyedropped { Some(adj) => adj, - None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?, + None => { + // A gray plane target keeps the neutral tone space even over params + // carrying Lab or CMYK records (#5335). + let tone = if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { + if matches!(kind.as_str(), "levels" | "curves") { gray_tone_params(p) } else { p.clone() } + } else { + p.clone() + }; + crate::adjust_params::from_params(&kind, &tone, None, adjust_mode(s, p))? + } }; let label = adj.label().to_string(); // A smart object records the adjustment as a smart filter (Photoshop), which diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index eb8f89a685..cabf9fbded 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -146,7 +146,7 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; let spec = photocraft_engine::commands::find(command)?; - let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + let mode = curve_mode(app, kind); let defaults = photocraft_engine::adjust_params::default_for(kind, mode).ok()?; let mut fields = Map::new(); fields.insert("__command".into(), json!(command)); @@ -184,19 +184,49 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields)) } +/// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask +/// selection — both ways of selecting a mask must give the same dropdown (#5335). +fn isolated(app: &PhotocraftApp) -> Option { + let mask = app.layer_mask_targeted(); + app.session.active().and_then(|st| photocraft_engine::channel_cmds::isolated_curve_channel_for(st, mask)) +} + +/// The colour mode the Levels/Curves dialog seeds and parses in: a mask, saved channel or +/// Quick Mask holds gray levels whatever the document's mode, so its curve is a neutral one +/// and the fields carry no Lab or CMYK records (#5335). +fn curve_mode(app: &PhotocraftApp, kind: &str) -> photocraft_doc::ColorMode { + let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + if kind == "curves" && isolated(app).is_some_and(|c| c.gray.is_some()) { + photocraft_doc::ColorMode::Grayscale + } else { + mode + } +} + /// Dialog body: the kind's editor over the fields, then the Preview checkbox. pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string(); - let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + let mode = curve_mode(app, &kind); if matches!(kind.as_str(), "levels" | "curves") { picker_controls(ui, fields, mode); ui.add_space(4.0); } let mut values = crate::filter_dialog::params_of(fields); let active = app.session.active().and_then(|s| s.active_layer); - let gray = app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode)); + let isolated_curve = isolated(app); + // Only Curves gets an isolated single-channel target here; Levels keeps its usual fields. + let gray_plane = (kind == "curves").then(|| isolated_curve.as_ref().and_then(|c| c.gray)).flatten(); + let gray = gray_plane.is_some() || app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode)); let hist = match active { - Some(id) if adjust_editors::needs_histogram(&kind) => Some(tone::histograms(app, HistSource::Layer(id), adjust_editors::space_of(&values))), + Some(id) if adjust_editors::needs_histogram(&kind) => { + // A mask or saved channel's curve edits that plane's own levels: the histogram + // describes it, not the colour layers beneath (#5335). + let source = match gray_plane { + Some(surface) => HistSource::Channel { layer: id, surface }, + None => HistSource::Layer(id), + }; + Some(tone::histograms(app, source, adjust_editors::space_of(&values))) + } _ => None, }; let cx = EditorCx { @@ -207,6 +237,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(4); + h + } + + /// The Channel popup's selected value (its option labels are what it shows). + fn popup_channels(h: &mut Harness<'static, PhotocraftApp>) -> Vec { + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .filter_map(|n| n.value().map(|s| s.to_string())) + .collect() + } } diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 2678cc65f2..87a81b8c35 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -92,6 +92,8 @@ pub struct EditorCx { /// Preferences ▸ Interface ▸ Dynamic Color Sliders: colour tracks on the colour sliders /// (off: the plain track). pub dynamic_color_sliders: bool, + /// Image › Curves with a single Channels-panel target: that channel only (#5335). + pub isolated_curve: Option, } /// The Levels/Curves channel space the values address. @@ -291,14 +293,26 @@ pub fn tone_channels(space: ToneSpace, gray: bool) -> &'static [ToneChannel] { } fn channel_picker(ui: &mut egui::Ui, id: egui::Id, chans: &[ToneChannel], text: &str) -> usize { + let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); + channel_picker_opts(ui, id, &opts, text, true) +} + +/// The popup, with `translate` for whether the built-in labels follow the interface language; +/// a user-typed channel name is shown exactly as entered (#5335). +fn channel_picker_opts(ui: &mut egui::Ui, id: egui::Id, opts: &[(usize, &str)], text: &str, translate: bool) -> usize { let mut ch: usize = ui.data(|d| d.get_temp(id)).unwrap_or(0); - if ch >= chans.len() { + if ch >= opts.len() { ch = 0; } ui.horizontal(|ui| { label(ui, text); - let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); - widgets::dropdown(ui, &format!("{id:?}-ch"), &mut ch, &opts, 110.0); + let width = if opts.len() == 1 { 160.0 } else { 110.0 }; + let key = format!("{id:?}-ch"); + if translate { + widgets::dropdown(ui, &key, &mut ch, opts, width); + } else { + widgets::dropdown_literal(ui, &key, &mut ch, opts, width); + } }); ui.data_mut(|d| d.insert_temp(id, ch)); ch @@ -428,11 +442,20 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let chans = tone_channels(space, cx.gray); let state_id = cx.mem.with("curves"); let mut st: CurveUi = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); - let ch = channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")); + let isolated = cx.isolated_curve.as_ref(); + let ch = if let Some(iso) = isolated { + channel_picker_opts(ui, cx.mem.with("curves-ch"), &[(0, iso.name.as_str())], tl!("Channel:"), iso.translate) + } else { + channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")) + }; if ch != st.channel { st = CurveUi { channel: ch, ..Default::default() }; } - let chan = chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }); + let chan = + if let Some(iso) = isolated { ToneChannel { key: iso.key, label: "" } } else { chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }) }; + let overlay_others = isolated.is_none(); + let tinted = isolated.is_some_and(|c| c.tint) || (isolated.is_none() && ch != 0); + let hist_i = isolated.map_or(ch, |c| c.hist); let key = curve_key(&chan); let mut e = curves_mode_row(ui, v, key, cx.gray); let pencil = v.get("pencil").and_then(Value::as_bool).unwrap_or(false); @@ -477,7 +500,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let p = ui.painter_at(full.expand(2.0)); p.rect_filled(graph, 0.0, t.field); if let Some(h) = &cx.hist { - tone::draw_histogram(&p, graph, &h[ch], Color32::from_gray(if t.pro { 88 } else { 190 }).gamma_multiply(0.75)); + tone::draw_histogram(&p, graph, &h[hist_i], Color32::from_gray(if t.pro { 88 } else { 190 }).gamma_multiply(0.75)); } for i in 1..4 { let f = i as f32 / 4.0; @@ -510,7 +533,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let line: Vec = map.iter().enumerate().map(|(k, o)| to_scr([k as f32 / last * 255.0, *o])).collect(); p.add(egui::Shape::line(line, Stroke::new(width, color))); }; - if ch == 0 { + if overlay_others && ch == 0 { // Photoshop overlays the edited channel curves on the composite. for c in chans.iter().skip(1) { if pencil { @@ -526,7 +549,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } } } - let color = if ch == 0 { t.text } else { bar_color(&chan, &t) }; + let color = if tinted { bar_color(&chan, &t) } else { t.text }; if pencil { draw_map(&map, color, 1.5); } else { @@ -1623,6 +1646,7 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj gradients: gradient_presets(app, kind), dialog: false, dynamic_color_sliders: app.session.prefs().interface.dynamic_color_sliders, + isolated_curve: None, }; if matches!(kind, "levels" | "curves") { crate::layer_picker::controls(app, ui, id); @@ -1676,6 +1700,7 @@ mod tests { gradients: Vec::new(), dialog: false, dynamic_color_sliders: dynamic, + isolated_curve: None, }; let stops = [Color32::BLACK, Color32::RED]; assert_eq!(track_stops(&cx(true), &stops), Some(&stops[..])); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 9c39088bef..d8c6e7953a 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -566,6 +566,7 @@ fn gradient_map_stop_controls_fit_a_narrow_properties_panel_in_every_theme() { gradients: Vec::new(), dialog: false, dynamic_color_sliders: true, + isolated_curve: None, }; editor(ui, "gradientMap", values, &cx); }, @@ -774,6 +775,7 @@ fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'sta gradients: Vec::new(), dialog: true, dynamic_color_sliders: true, + isolated_curve: None, }; editor(ui, kind, values, &cx); }, diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index 0eacc731ae..5cd04d4f0b 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -40,12 +40,20 @@ pub enum HistSource { BelowLayer(LayerId), /// One layer on its own (the target of a destructive Image › Adjustments command). Layer(LayerId), + /// The gray plane a Curves target edits: a layer mask, a saved channel or the Quick Mask. + /// The dialog shows this plane's own levels, not the colour layers beneath (#5335). + Channel { layer: LayerId, surface: photocraft_engine::channel_cmds::GraySurface }, } fn tag(source: HistSource, space: ToneSpace) -> u64 { let (k, id) = match source { HistSource::BelowLayer(l) => (1u64, l.0), HistSource::Layer(l) => (2, l.0), + HistSource::Channel { layer, surface } => match surface { + photocraft_engine::channel_cmds::GraySurface::LayerMask => (3, layer.0), + photocraft_engine::channel_cmds::GraySurface::Alpha(i) => (4, i as u64), + photocraft_engine::channel_cmds::GraySurface::QuickMask => (5, layer.0), + }, }; let s = match space { ToneSpace::Rgb => 0u64, @@ -68,6 +76,35 @@ fn isolate(layers: &mut [Layer], id: LayerId) -> bool { /// Histograms of `doc` as seen by `source`, in the channels of `space`. pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) -> Histograms { + let mut h = vec![[0u32; 256]; match space { + ToneSpace::Rgb => 4, + ToneSpace::Cmyk => 5, + ToneSpace::Lab => 3, + }]; + match source { + // The gray plane itself: its levels are what the curve edits, whatever the document's + // colour mode (#5335). + HistSource::Channel { layer, surface } => { + let surf = match surface { + photocraft_engine::channel_cmds::GraySurface::LayerMask => doc.layer(layer).and_then(|l| l.mask.as_ref()).map(|m| &m.surface), + photocraft_engine::channel_cmds::GraySurface::Alpha(i) => doc.channels.get(i).map(|c| &c.surface), + photocraft_engine::channel_cmds::GraySurface::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface), + }; + if let Some(s) = surf + && let Some(bin) = h.first_mut() + { + *bin = gray_bins(s, doc.bounds()); + } + } + HistSource::BelowLayer(_) | HistSource::Layer(_) => { + composite_bins(doc, source, space, &mut h); + } + } + Histograms { tag: tag(source, space), ch: h } +} + +/// Bins of the composited image below (`BelowLayer`) or of one layer alone (`Layer`), in `space`. +fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut [[u32; 256]]) { let mut d = doc.clone(); match source { HistSource::BelowLayer(hide) => { @@ -78,14 +115,9 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) HistSource::Layer(id) => { isolate(&mut d.layers, id); } + HistSource::Channel { .. } => {} } let img = photocraft_compose::thumbnail(&d, 384); - let n = match space { - ToneSpace::Rgb => 4, - ToneSpace::Cmyk => 5, - ToneSpace::Lab => 3, - }; - let mut h = vec![[0u32; 256]; n]; let bin = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as usize; for p in img.pixels.as_chunks::<4>().0 { if p[3] == 0 { @@ -115,7 +147,26 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) } } } - Histograms { tag: tag(source, space), ch: h } +} + +/// 256-bin histogram of a gray plane: its levels in 0–255, sampled like the engine's auto-tone +/// histograms so a big canvas costs no more than about a megapixel here (#5335). +fn gray_bins(surf: &photocraft_raster::Surface, area: photocraft_geom::Rect) -> [u32; 256] { + let mut h = [0u32; 256]; + let step = ((u64::from(area.width()) * u64::from(area.height())) / 1_000_000).max(1).isqrt().max(1) as i32; + let mut y = area.y0; + while y < area.y1 { + let mut x = area.x0; + while x < area.x1 { + let v = (surf.sample_channel(x, y, 0).clamp(0.0, 1.0) * 255.0).round() as usize; + if let Some(bin) = h.get_mut(v) { + *bin += 1; + } + x += step; + } + y += step; + } + h } /// Cached histograms for a tone editor; recomputed only when something other than the editor's @@ -126,6 +177,7 @@ pub fn histograms(app: &mut PhotocraftApp, source: HistSource, space: ToneSpace) let want = tag(source, space); let layer = match source { HistSource::BelowLayer(l) | HistSource::Layer(l) => l, + HistSource::Channel { layer, .. } => layer, }; let pixels_kept = view_only(st); if let Some((d, l, r, h)) = app.tone_hist.as_mut() @@ -362,6 +414,39 @@ mod tests { assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Cmyk).ch.len(), 5); assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Lab).ch.len(), 3); } + + /// #5335: the Curves histogram of a targeted gray plane describes that plane, not the + /// colour layers beneath it: a channel filled with 25% grey over a black layer shows a + /// spike at 25% grey, not at black. + #[test] + fn channel_histogram_describes_the_gray_plane() { + use photocraft_engine::channel_cmds::GraySurface; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); // white background + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 32})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let b = photocraft_geom::Rect::from_xywh(0, 0, 32, 32); + s.edit("fill-planes", |doc, active| { + doc.channels[0].surface.fill_rect(b, &[0.25]); + doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface.fill_rect(b, &[0.0]); + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let layer = st.active_layer.unwrap(); + let h = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb); + assert!(h[0][64] > 0, "a spike at 25% grey, got {:?}", &h[0][0..80]); + assert_eq!(h[0][0], 0, "not the black layer beneath"); + let m = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb); + assert!(m[0][0] > 0, "the hidden mask is black"); + // Each plane keeps its own cache entry: switching targets recomputes. + assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb), + tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb)); + assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb), tag(HistSource::Layer(layer), ToneSpace::Rgb)); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 49bcf0978a..39c063baa6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1296,9 +1296,16 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &mut T, op dropdown_hovered(ui, id, current, options, width).0 } +/// [`dropdown`] whose option labels are shown exactly as given: names the user typed (a channel +/// or layer name) must not be translated, unlike the built-in labels [`dropdown`] translates (#5335). +pub fn dropdown_literal(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { + let (chosen, _) = dropdown_with(ui, id, current, options, width, false, false); + !chosen.is_empty() +} + /// [`dropdown`], also returning the option under the pointer in its open list (live previews). pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (bool, Option) { - let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false); + let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false, true); (!chosen.is_empty(), hovered) } @@ -1309,11 +1316,13 @@ pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &m /// in order (one per notch; the last is `current`) and the option under the pointer in the open /// list, none while the pointer rests where the wheel last stepped. pub fn dropdown_wheel_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (Vec, Option) { - dropdown_with(ui, id, current, options, width, true) + dropdown_with(ui, id, current, options, width, true, true) } -fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32, wheel: bool) -> (Vec, Option) { - let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| tl!(l)).unwrap_or("—"); +#[allow(clippy::too_many_arguments)] +fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32, wheel: bool, translate: bool) -> (Vec, Option) { + let shown = |l: &str| if translate { tl!(l).to_string() } else { l.to_string() }; + let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| shown(l)).unwrap_or_else(|| "—".into()); let (mut chosen, mut hovered) = (Vec::new(), None); // The wheel's notches add up over the button and the open list alike. let wheel_id = ui.make_persistent_id(id).with("wheel"); @@ -1325,11 +1334,12 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o let mut reveal = wheel && ui.data_mut(|d| d.remove_temp::(reveal_id)).unwrap_or(false); // The list opens on the press: press, drag onto an option and release to choose it (#2735). let (press, close) = crate::press_menu::PressCombo::before(ui, id); + let texts: Vec = options.iter().map(|(_, l)| shown(l)).collect(); let response = egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).close_behavior(close).show_ui(ui, |ui| { - fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); + fit_widest_option(ui, texts.iter().map(|l| l.as_str())); // A typed letter jumps to the next option starting with it, ↩ closes the list (#1481). - let labels: Vec<&str> = options.iter().map(|(_, l)| tl!(l)).collect(); + let labels: Vec<&str> = texts.iter().map(|l| l.as_str()).collect(); let shown = options.iter().position(|(v, _)| v == current); if let Some((v, _)) = type_ahead::keys(ui, ui.make_persistent_id(id), shown, &labels).and_then(|i| options.get(i)) { *current = v.clone(); @@ -1348,8 +1358,8 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o // The row nearest the pointer, by its distance to each row. let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); let mut nearest: Option<(f32, &T)> = None; - for (v, l) in options { - let item = ui.selectable_label(v == current, tl!(l)); + for ((v, _), text) in options.iter().zip(texts.iter()) { + let item = ui.selectable_label(v == current, text.as_str()); if reveal && v == current { item.scroll_to_me(None); } From 914d51f9bbd030bc49d692595d5e1026b6d0c9c0 Mon Sep 17 00:00:00 2001 From: "Sir Clangington III." Date: Thu, 15 Oct 2026 00:04:45 +0200 Subject: [PATCH 19/90] perf(ui): bound carried selection outlines and stabilize clipping (#5699) Limit whole-selection transform traces to a padded one-million-cell grid while preserving normal viewport caching and thin boundary samples. Use double-precision intermediates for clipping long perspective lines. Add regressions for large/disconnected selections and viewport accuracy near the projective horizon. Preserve the full-resolution selection and the transform behavior already landed in #5337. Related: #5297, #3402. Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> --- crates/ui-egui/src/canvas.rs | 39 ++++++++++++++++++++----- crates/ui-egui/src/outline.rs | 55 +++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a9592b692..7e2e66ff48 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3083,8 +3083,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: (px(y1.ceil()).div_euclid(q) + 2) * q, ) }; + let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 }; + let carried_bounds = carried.then(|| app.cached_bounds(bounds_key, sel).intersect(&vis)); + let step = carried_bounds.map_or(step, |b| crate::outline::bounded_step(b, step)); let key = crate::surface_fingerprint(sel) - ^ (step as u64) << 56 + ^ u64::from(step).rotate_left(56) ^ doc.id.0.rotate_left(17) ^ (vis.x0 as u64) << 8 ^ (vis.y0 as u64) << 24 @@ -3094,8 +3097,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key); if !fresh { let t0 = crate::gpu_canvas::now_ms(); - let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 }; - let b = app.cached_bounds(bounds_key, sel).intersect(&vis); + let b = carried_bounds.unwrap_or_else(|| app.cached_bounds(bounds_key, sel).intersect(&vis)); // Filters can occupy every worker in Rayon's global pool. A parallel scan here would // block the UI thread waiting for those workers, preventing job cancellation (#1554). let mut segs = @@ -4153,9 +4155,12 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli /// The part of the line `a`–`b` inside `r` (Liang–Barsky), if any. fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { - let d = b - a; - let (mut t0, mut t1) = (0.0f32, 1.0f32); - for (p, q) in [(-d.x, a.x - r.min.x), (d.x, r.max.x - a.x), (-d.y, a.y - r.min.y), (d.y, r.max.y - a.y)] { + // Perspective can put endpoints billions of pixels away. f32 subtraction/interpolation + // loses viewport-sized differences there, clipping visible ants to the wrong endpoints. + let (ax, ay) = (f64::from(a.x), f64::from(a.y)); + let (dx, dy) = (f64::from(b.x) - ax, f64::from(b.y) - ay); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, ax - f64::from(r.min.x)), (dx, f64::from(r.max.x) - ax), (-dy, ay - f64::from(r.min.y)), (dy, f64::from(r.max.y) - ay)] { if p == 0.0 { if q < 0.0 { return None; @@ -4166,7 +4171,27 @@ fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { t1 = t1.min(q / p); } } - (t0 <= t1).then(|| (a + d * t0, a + d * t1)) + let at = |t: f64| pos2((ax + dx * t) as f32, (ay + dy * t) as f32); + (t0 <= t1).then(|| (at(t0), at(t1))) +} + +#[cfg(test)] +mod transform_outline_clip_tests { + use super::*; + + #[test] + fn near_horizon_lines_keep_viewport_precision() { + let r = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + for (a, b, start, end) in [ + (pos2(-1e9, 100.0), pos2(1e9, 100.0), pos2(0.0, 100.0), pos2(800.0, 100.0)), + (pos2(100.0, -1e9), pos2(100.0, 1e9), pos2(100.0, 0.0), pos2(100.0, 600.0)), + (pos2(-1e9, -1e9), pos2(1e9, 1e9), pos2(0.0, 0.0), pos2(600.0, 600.0)), + ] { + let (x, y) = clip_line(r, a, b).unwrap(); + assert!(x.distance(start) < 0.01 && y.distance(end) < 0.01, "{x:?}–{y:?}, expected {start:?}–{end:?}"); + } + assert!(clip_line(r, pos2(-1e9, -100.0), pos2(1e9, -100.0)).is_none()); + } } /// Marching ants along lines already placed on screen (a warped outline is a run of short ones). diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index bdb5c41501..9e06a1ae68 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -10,6 +10,21 @@ use photocraft_raster::Surface; /// A boundary segment in document pixel coordinates (axis-aligned). pub type Segment = ([i32; 2], [i32; 2]); +/// Bound a whole-selection trace, including offscreen pixels carried into view by a transform. +/// Include grid alignment and the two padding cells in the budget; ordinary traces keep their step. +pub(crate) fn bounded_step(bounds: Rect, mut step: u32) -> u32 { + step = step.max(1); + let cells = |step: u32| { + let s = i64::from(step); + let axis = |lo: i32, hi: i32| ((i64::from(hi) + s - 1).div_euclid(s) - i64::from(lo).div_euclid(s) + 2).max(0) as u64; + axis(bounds.x0, bounds.x1).saturating_mul(axis(bounds.y0, bounds.y1)) + }; + while cells(step) > 1_048_576 { + step = step.saturating_mul(2); + } + step +} + /// Extract the boundary of `mask` (> 0.5 = selected) as merged horizontal and vertical segments. pub fn outline(mask: &Surface, bounds: Rect) -> Vec { outline_scaled(mask, bounds, 1) @@ -40,15 +55,22 @@ fn outline_scaled_impl(mask: &Surface, bounds: Rect, step: u32, parallel: bool) let mut grid = vec![false; w * h]; let half = step / 2; let sample_row = |gy: usize, row: &mut [bool]| { - let y = (gy0 + gy as i32) * step + half; - if y < bounds.y0 || y >= bounds.y1 { + let y0 = (gy0 + gy as i32) * step; + if y0 >= bounds.y1 || y0 + step <= bounds.y0 { return; } + // A coarse cell can overlap a one-pixel-wide selection even when its centre misses it. + // Sample the overlapping edge; padding cells still stay outside the mask. + let y = (y0 + half).clamp(bounds.y0, bounds.y1 - 1); let mut vals = vec![0.0f32; row.len()]; mask.sample_row_strided(y, gx0 * step + half, step, 0, &mut vals); for (gx, (cell, v)) in row.iter_mut().zip(vals).enumerate() { let x = (gx0 + gx as i32) * step + half; - *cell = x >= bounds.x0 && x < bounds.x1 && v > 0.5; + *cell = if x >= bounds.x0 && x < bounds.x1 { + v > 0.5 + } else { + x - half < bounds.x1 && x - half + step > bounds.x0 && mask.sample_channel(x.clamp(bounds.x0, bounds.x1 - 1), y, 0) > 0.5 + }; } }; #[cfg(not(target_arch = "wasm32"))] @@ -108,6 +130,33 @@ mod tests { use super::*; use photocraft_color::PixelFormat; + #[test] + fn whole_transform_trace_has_a_bounded_grid() { + assert_eq!(bounded_step(Rect::new(0, 0, 200, 150), 1), 1); + assert_eq!(bounded_step(Rect::new(0, 0, 6000, 6000), 1), 8); + for bounds in [Rect::new(-50_001, -50_003, 50_000, 50_000), Rect::new(-1_000_000, 0, 1_000_000, 1)] { + let step = i64::from(bounded_step(bounds, 1)); + let w = (i64::from(bounds.x1) + step - 1).div_euclid(step) - i64::from(bounds.x0).div_euclid(step) + 2; + let h = (i64::from(bounds.y1) + step - 1).div_euclid(step) - i64::from(bounds.y0).div_euclid(step) + 2; + assert!(w * h <= 1_048_576, "{bounds:?}: {w} × {h}"); + } + // Both distant pieces survive the coarse trace: it covers the whole selection, not the viewport. + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(-50_000, -50_000, -49_488, -49_488), &[1.0]); + mask.fill_rect(Rect::new(49_488, 49_488, 50_000, 50_000), &[1.0]); + let bounds = mask.content_bounds(); + let lines = outline_scaled_serial(&mask, bounds, bounded_step(bounds, 1)); + assert!(lines.iter().any(|(a, _)| a[0] < -49_000)); + assert!(lines.iter().any(|(a, _)| a[0] > 49_000)); + let mut thin = Surface::new(PixelFormat::GRAY8); + thin.fill_rect(Rect::new(-1_000_000, 0, -999_488, 1), &[1.0]); + thin.fill_rect(Rect::new(999_488, 0, 1_000_000, 1), &[1.0]); + let bounds = thin.content_bounds(); + let lines = outline_scaled_serial(&thin, bounds, bounded_step(bounds, 1)); + assert!(lines.iter().any(|(a, _)| a[0] < -999_000), "thin left piece remains visible"); + assert!(lines.iter().any(|(a, _)| a[0] > 999_000), "thin right piece remains visible"); + } + #[test] fn rectangle_has_four_edges() { let mut m = Surface::new(PixelFormat::GRAY8); From d334d32c12bb8775531bb8e955efac5ea75afd0d Mon Sep 17 00:00:00 2001 From: Sidney Markowitz Date: Thu, 15 Oct 2026 11:04:55 +1300 Subject: [PATCH 20/90] fix(heif): a grid's ICC profile is inherited from its tiles (#5553) fix(heif): a grid's ICC profile is inherited from its tiles Also covers the same fix as #5591; thanks to its author. Co-authored-by: nrq6993-a11y <342501791+nrq6993-a11y@users.noreply.github.com> --- contributors/people.toml | 4 + crates/heif/src/lib.rs | 223 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index 6f630a0c12..8694c72698 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -158,3 +158,7 @@ sync_github_name = false [people.yeniufuklar-com] real_name = "Ufuk Efe" sync_github_name = true + +[people.sidney] +real_name = "Sidney Markowitz" +sync_github_name = true diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index ae3aff5a74..7c88fc1b9c 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -5,6 +5,9 @@ //! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows //! nothing about the rest of PhotoCraft; `photocraft-codecs` adapts it behind its `heif` feature. //! +//! A grid's ICC profile is inherited from its tiles when the grid item itself carries no `colr`, +//! which is how phone cameras write it: see [`icc_for_item`]. +//! //! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF. //! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded. //! @@ -163,7 +166,7 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) return (None, None, None); }; let id = ctx.meta.primary; - let icc = ctx.props(id).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + let icc = icc_for_item(&ctx, id); let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), @@ -172,6 +175,28 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) (icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) } +/// The ICC profile describing `item`, inherited from the coded tiles when the item itself carries +/// none. +/// +/// `colr` is optional on a `grid` derivation, and phone cameras write it on the tiles instead: a +/// OnePlus 11 photograph carries one `prof` (Display P3) on each of its 48 `hvc1` tiles and +/// nothing on the grid. Asking the primary item alone therefore loses the profile and the photo +/// opens as if it were sRGB. The tiles are what the mosaic is composed from, so their profile is +/// the composed image's profile. +/// +/// heic-rs resolves `nclx` from the primary item in the same way and has the same gap +/// (tbraun96/heic-rs#6). The sample range cannot be recovered here as well: `DecodeOptions` has no +/// override for it, so that half has to be fixed upstream. +fn icc_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option> { + let own = ctx.props(item).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + if own.is_some() { + return own; + } + // `Ok(None)` is a coded picture rather than a derivation: no tiles to inherit from. + let (_, tiles) = ctx.grid(item).ok()??; + tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec))) +} + #[cfg(test)] mod tests { use super::*; @@ -215,4 +240,200 @@ mod tests { assert!(Error::Limit("x".into()).to_string().contains("limit")); assert!(Error::Malformed("x".into()).to_string().contains("malformed")); } + + // A grid whose colour description lives on its tiles, not on the grid item. + // + // The fixtures below are synthetic containers with no coded data at all: resolving a profile + // never decodes a pixel. Each test first asserts the fixture is the shape it claims to be, so + // a mistake in the builder cannot look like a passing fix. + + /// Stands in for a profile. `colr` hands its payload back without parsing it. + const TILE_ICC: &[u8] = b"tile-profile"; + const GRID_ICC: &[u8] = b"grid-profile"; + + const GRID_ID: u16 = 1; + const PICTURE_ID: u16 = 2; + + fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = u32::try_from(payload.len() + 8).unwrap().to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v + } + + /// A box whose first four payload bytes are a version and 24 bits of flags. + fn full(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec { + let mut p = vec![version, 0, 0, 0]; + p.extend_from_slice(payload); + bx(kind, &p) + } + + fn hdlr() -> Vec { + let mut p = vec![0; 4]; + p.extend_from_slice(b"pict"); + p.extend_from_slice(&[0; 12]); + p.push(0); + full(b"hdlr", 0, &p) + } + + fn infe(id: u16, item_type: &[u8; 4]) -> Vec { + let mut p = id.to_be_bytes().to_vec(); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(item_type); + p.push(0); + full(b"infe", 2, &p) + } + + fn iref_dimg(from: u16, to: u16) -> Vec { + let mut p = from.to_be_bytes().to_vec(); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&to.to_be_bytes()); + full(b"iref", 0, &bx(b"dimg", &p)) + } + + fn colr_prof(icc: &[u8]) -> Vec { + let mut p = b"prof".to_vec(); + p.extend_from_slice(icc); + bx(b"colr", &p) + } + + fn ispe(w: u32, h: u32) -> Vec { + let mut p = w.to_be_bytes().to_vec(); + p.extend_from_slice(&h.to_be_bytes()); + full(b"ispe", 0, &p) + } + + fn ipma(entries: &[(u16, &[u8])]) -> Vec { + let mut p = u32::try_from(entries.len()).unwrap().to_be_bytes().to_vec(); + for (item, assoc) in entries { + p.extend_from_slice(&item.to_be_bytes()); + p.push(u8::try_from(assoc.len()).unwrap()); + p.extend_from_slice(assoc); + } + full(b"ipma", 0, &p) + } + + /// `item`'s bytes live in `idat` (construction method 1) at offset 0. + fn iloc_in_idat(item: u16, length: u32) -> Vec { + let mut p = vec![0x44, 0x00]; + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&item.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u32.to_be_bytes()); + p.extend_from_slice(&length.to_be_bytes()); + full(b"iloc", 1, &p) + } + + fn file(meta: &[u8]) -> Vec { + let mut ftyp = b"heic".to_vec(); + ftyp.extend_from_slice(&0u32.to_be_bytes()); + ftyp.extend_from_slice(b"mif1heic"); + let mut out = bx(b"ftyp", &ftyp); + out.extend_from_slice(&full(b"meta", 0, meta)); + out + } + + /// A 1x1 64x64 grid, with `grid_icc` on the grid item and `tile_icc` on its single tile. + /// + /// `ipco` children are numbered from 1 in file order; an association byte's top bit marks the + /// property essential, as macOS writes it. + fn grid_file(grid_icc: Option<&[u8]>, tile_icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let (mut grid_assoc, mut tile_assoc, mut index) = (vec![0x81], vec![0x81], 1u8); + if let Some(icc) = grid_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + grid_assoc.push(0x80 | index); + } + if let Some(icc) = tile_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + tile_assoc.push(0x80 | index); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(GRID_ID, grid_assoc.as_slice()), (PICTURE_ID, tile_assoc.as_slice())])); + + let mut items = 2u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(GRID_ID, b"grid")); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + // Version 0, flags 0, one row, one column, 64x64 output. + const DESCRIPTOR: &[u8] = &[0, 0, 0, 0, 0, 64, 0, 64]; + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &GRID_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&iref_dimg(GRID_ID, PICTURE_ID)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + meta.extend_from_slice(&bx(b"idat", DESCRIPTOR)); + meta.extend_from_slice(&iloc_in_idat(GRID_ID, u32::try_from(DESCRIPTOR.len()).unwrap())); + file(&meta) + } + + /// One coded picture rather than a derivation, with `icc` on the item itself. + fn picture_file(icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let mut assoc = vec![0x81]; + if let Some(icc) = icc { + ipco.extend_from_slice(&colr_prof(icc)); + assoc.push(0x82); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(PICTURE_ID, assoc.as_slice())])); + + let mut items = 1u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &PICTURE_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + file(&meta) + } + + fn ctx_of(bytes: &[u8]) -> heic_rs::context::Context<'_> { + heic_rs::context::Context::open(bytes).expect("the synthetic container must parse") + } + + #[test] + fn a_grid_inherits_its_tiles_icc_profile() { + let bytes = grid_file(None, Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + let primary = ctx.meta.primary; + assert_eq!(primary, u32::from(GRID_ID)); + let (grid, tiles) = ctx.grid(primary).unwrap().expect("a grid derivation"); + assert_eq!((grid.rows, grid.columns, tiles.len()), (1, 1, 1)); + assert!(ctx.icc(&ctx.props(primary).unwrap()).is_none(), "the grid item carries no colr of its own"); + assert_eq!(ctx.icc(&ctx.props(tiles[0]).unwrap()), Some(TILE_ICC), "the tile carries the profile"); + + assert_eq!(icc_for_item(&ctx, primary).as_deref(), Some(TILE_ICC)); + } + + #[test] + fn a_grids_own_profile_wins_over_its_tiles() { + let bytes = grid_file(Some(GRID_ICC), Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + } + + #[test] + fn an_unlabelled_grid_has_no_profile() { + let bytes = grid_file(None, None); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } + + #[test] + fn a_coded_picture_resolves_its_own_profile() { + let labelled = picture_file(Some(GRID_ICC)); + let ctx = ctx_of(&labelled); + assert!(ctx.grid(ctx.meta.primary).unwrap().is_none(), "not a derivation"); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + + let bare = picture_file(None); + let ctx = ctx_of(&bare); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } } From 23dc83928c8b4aa0c2e22dfe044b5f876171553d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:05:06 +0700 Subject: [PATCH 21/90] fix(ui): expose toolbar column toggle in all themes (#3538) fix(ui): expose toolbar column toggle in all themes Also covers the same fix as #4680; thanks to its author. Co-authored-by: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> --- crates/ui-egui/src/panels.rs | 82 ++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 156a0a3f23..470a72bfe0 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -90,18 +90,16 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .exact_size(w) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))) .show(ui, |ui| { - if t.pro { - // Photoshop's toolbar header chevrons switch between one and two columns. - let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); - // The chevrons point the way the toolbar grows or shrinks: towards the canvas. - let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; - icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); - if resp.clicked() { - app.ui.panels.toolbar_double = !app.ui.panels.toolbar_double; - } - ui.add_space(4.0); + // Toolbar width is a user preference, not a Pro-only feature. + // Keep the same hit target in every theme (including RTL layouts). + let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); + let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; + icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); + if resp.clicked() { + app.ui.panels.toolbar_double = !double; } + ui.add_space(4.0); // Subtle violet wash at the bottom of the toolbar. let full = ui.max_rect(); if !t.bevel && !t.pro && t.dark() { @@ -5437,35 +5435,45 @@ mod toolbar_tests { use super::*; use egui_kittest::kittest::Queryable; - /// #1197: the header chevron switches the toolbar between one and two columns. + /// #1197 / #3469: every theme can toggle the toolbar without changing its selection. #[test] - fn header_chevron_toggles_two_columns() { - let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( - |ui, app: &mut PhotocraftApp| { - if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - toolbar(app, ui); - let left = ui.available_rect_before_wrap().left(); - ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); - } - }, - app, - ); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); - h.run_steps(2); - let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); - let single = left(&h); - assert!(!h.state().ui.panels.toolbar_double); + fn header_chevron_toggles_two_columns_in_every_layout() { + for theme in [ + crate::theme::ThemeKind::Pro, + crate::theme::ThemeKind::Studio, + crate::theme::ThemeKind::StudioLight, + crate::theme::ThemeKind::Classic, + crate::theme::ThemeKind::AdwaitaDark, + crate::theme::ThemeKind::SolarizedDark, + crate::theme::ThemeKind::BreezeDark, + ] { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + toolbar(app, ui); + let left = ui.available_rect_before_wrap().left(); + ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(2); + let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); + let single = left(&h); + assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); - h.get_by_label("Toolbar").click(); - h.run_steps(2); - assert!(h.state().ui.panels.toolbar_double); - assert!(left(&h) > single + 20.0, "the toolbar did not widen: {single} -> {}", left(&h)); + h.get_by_label("Toolbar").click(); + h.run_steps(2); + assert!(h.state().ui.panels.toolbar_double, "{theme:?}"); + assert!(left(&h) > single + 20.0, "{theme:?}: the toolbar did not widen: {single} -> {}", left(&h)); - h.get_by_label("Toolbar").click(); - h.run_steps(2); - assert!(!h.state().ui.panels.toolbar_double); - assert_eq!(left(&h), single); + h.get_by_label("Toolbar").click(); + h.run_steps(2); + assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); + assert_eq!(left(&h), single, "{theme:?}"); + } } fn click(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: egui::Pos2) { From 051959ea7b41e47b0adb98704c8a55d98645d824 Mon Sep 17 00:00:00 2001 From: Daktyl Date: Wed, 14 Oct 2026 18:05:16 -0400 Subject: [PATCH 22/90] =?UTF-8?q?Double-clicking=20the=20empty=20workspace?= =?UTF-8?q?=20shows=20File=20=E2=80=BA=20Open=20(#5367)=20(#5429)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-clicking the empty workspace shows File › Open (#5367) Also covers the same fix as #5461; thanks to its author. Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> --- crates/ui-egui/src/canvas.rs | 14 +++- crates/ui-egui/src/empty_workspace_tests.rs | 89 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/empty_workspace_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7e2e66ff48..09d7dcc8ec 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1993,7 +1993,9 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if n == 0 && !opening { // Auto show the Home Screen is off: an empty workspace, like Photoshop. - paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots); + let area = ui.available_rect_before_wrap(); + paint_dots(ui, area, app.session.prefs().interface.show_canvas_dots); + open_on_double_click(app, ui, area); return; } if !app.ui.view.hides_tabs() || opening { @@ -2628,10 +2630,20 @@ pub fn mode_label(doc: &Document) -> &'static str { } } +/// With no document open, double-clicking the empty workspace shows File › Open, as in Photoshop +/// (#5367), and so does the Home screen's blank background. Registered before anything drawn over +/// `area`, so the buttons and links there keep their own clicks; not a keyboard focus stop. +fn open_on_double_click(app: &mut PhotocraftApp, ui: &egui::Ui, area: Rect) { + if app.session.documents().is_empty() && ui.interact(area, ui.id().with("empty-workspace"), Sense::CLICK).double_clicked() { + let _ = app.open_dialog_file(); + } +} + fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let area = ui.available_rect_before_wrap(); paint_dots(ui, area, app.session.prefs().interface.show_canvas_dots); + open_on_double_click(app, ui, area); // File › Open Recent, newest first (on the web there are no paths to reopen). let show_recent = app.session.prefs().interface.show_home_recent_files; let recent: Vec = diff --git a/crates/ui-egui/src/empty_workspace_tests.rs b/crates/ui-egui/src/empty_workspace_tests.rs new file mode 100644 index 0000000000..f7473dbccc --- /dev/null +++ b/crates/ui-egui/src/empty_workspace_tests.rs @@ -0,0 +1,89 @@ +//! With no document open, double-clicking the empty workspace shows File › Open, as in Photoshop +//! (#5367): with Auto Show the Home Screen on (the Home screen's blank background) and off (the +//! plain workspace). A single click does nothing, and the Home screen's own buttons keep theirs. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::file_dialog::{FileDialogReply, FileDialogRequest}; +use crate::{PhotocraftApp, Services}; + +type Requests = Rc>>; + +fn harness(home: bool) -> (Harness<'static, PhotocraftApp>, Requests) { + let requests: Requests = Default::default(); + let held = requests.clone(); + let services = Services { file_dialog: Some(Box::new(move |request, _parent, reply| held.borrow_mut().push((request, reply)))), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.session.edit_prefs(|p| p.general.auto_show_home_screen = home); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // Fonts set up after the first frame only apply from the next one. + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + app.poll_file_dialog(&ctx, None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + (h, requests) +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, at: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +/// `count` clicks two frames apart at `at`, after letting the clock move past earlier clicks. +fn clicks(h: &mut Harness<'static, PhotocraftApp>, at: Pos2, count: usize) { + h.hover_at(at); + for _ in 0..40 { + h.step(); + } + for _ in 0..count { + button(h, at, true); + h.step(); + button(h, at, false); + h.step(); + } + h.run_steps(3); +} + +fn opens(requests: &Requests) -> usize { + requests.borrow().iter().filter(|(r, _)| matches!(r, FileDialogRequest::Open { multiple: true, .. })).count() +} + +#[test] +fn double_clicking_the_empty_workspace_shows_file_open() { + for home in [false, true] { + let (mut h, requests) = harness(home); + assert!(h.state().session.documents().is_empty()); + // Well away from the Home screen's centred card. + let blank = pos2(40.0, 760.0); + clicks(&mut h, blank, 1); + assert_eq!(opens(&requests), 0, "home {home}: a single click opens nothing"); + clicks(&mut h, blank, 2); + assert_eq!(opens(&requests), 1, "home {home}: a double-click shows File › Open"); + } +} + +#[test] +fn the_home_screens_own_widgets_keep_their_double_clicks() { + let (mut h, requests) = harness(true); + // The subtitle is selectable text: a double-click there selects a word, not File › Open. + let at = h.get_by_label_contains("Create a new document or open an existing file").rect().center(); + clicks(&mut h, at, 2); + assert_eq!(opens(&requests), 0, "the label took the double-click"); + // The New document button opens the New Document dialog, not File › Open. + let at = h.get_by_label_contains("New document").rect().center(); + clicks(&mut h, at, 1); + assert_eq!(opens(&requests), 0); + assert_eq!(h.state().ui.dialogs.len(), 1, "the New Document dialog"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d8d0913142..f8cc8f21ae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2601,6 +2601,9 @@ mod profile_prompt_tests; #[cfg(test)] mod input_tests; +#[cfg(test)] +mod empty_workspace_tests; + #[cfg(test)] mod pen_variants_tests; From 1f33e4f6d7676ec3a96cbe10c2d2dbe1bd55d981 Mon Sep 17 00:00:00 2001 From: Juan Antonio Redondo <35599027+JuanAntonioRC@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:05:26 +0200 Subject: [PATCH 23/90] fix(ui): a held arrow key keeps stepping the focused value field (#5474) (#5497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding Up/Down in a value field (Character panel tracking, size, …) stepped it a couple of times, then jumped focus to the field above. Each arrow step re-requested focus on the field that already had it. egui's request_focus resets the field's event filter, so on the next frame egui no longer knew the field takes vertical arrows. A key repeat landing on that frame was read as "move focus up". Single presses rarely hit that frame; held keys always do. Only request focus when the field lost it (after a typed sum gives it up to be worked out). Regression test feeds key repeats on consecutive frames with a field above and failed before the fix (focus moved after two steps). Fixes #5474 Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/widgets.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 39c063baa6..b9b0336709 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -589,7 +589,11 @@ fn value_field_box( let v = (v * 1e4).round() / 1e4; // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. *value = if lo <= hi { v.clamp(lo, hi) } else { v }; - ui.memory_mut(|m| m.request_focus(resp.id)); + // Only take focus back after a typed sum gave it up: re-requesting it while held resets + // the field's arrow-key filter next frame, and a held arrow then moves focus away (#5474). + if !ui.memory(|m| m.has_focus(resp.id)) { + ui.memory_mut(|m| m.request_focus(resp.id)); + } // The text stays selected, as in Photoshop: typing after a step replaces the value. crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); resp.mark_changed(); @@ -2006,6 +2010,30 @@ mod tests { assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowDown), 10.0, "the field keeps focus"); } + /// #5474: a held arrow repeats on consecutive frames; every repeat steps the focused field + /// and none of them moves focus to the field above. + #[test] + fn held_arrow_keeps_stepping_the_focused_field() { + let mut h = Harness::new_ui_state( + |ui, s: &mut (f32, f32)| { + super::value_field(ui, &mut s.0, 0.0..=1000.0, "", 80.0); + super::value_field(ui, &mut s.1, 0.0..=1000.0, "", 80.0); + }, + (10.0, 59.0), + ); + h.run(); + h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(1).expect("second field").click(); + h.run(); + // Held down: key-down repeats on consecutive frames, no key-up in between. + for repeat in [false, true, true, true, true, true] { + h.event(egui::Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }); + h.step(); + } + h.event(egui::Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run(); + assert_eq!(*h.state(), (10.0, 65.0)); + } + #[test] fn arrow_keys_leave_an_unfocused_field_alone() { let mut h = field(100.0, 0.0..=1000.0); From 2eabf45ba5d2a97f74f01ece00eac312a2befcb9 Mon Sep 17 00:00:00 2001 From: Sabbir Ahmed <13796403+sabbirshawon@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:05:36 +0600 Subject: [PATCH 24/90] =?UTF-8?q?fix(ui):=20=E2=87=A7=20marquee=20square?= =?UTF-8?q?=20keeps=20equal=20sides=20on=20the=20pixel=20grid=20(#5472)=20?= =?UTF-8?q?(#5506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ⇧ square (and a 1:1 fixed ratio) is built in continuous document coordinates, and `marquee_px` then snapped each axis to the pixel grid on its own: floor the near edge, ceil the far one. A press between pixels, dragged 100.5 px, snapped x to 101 and y to 102, so the readout and the committed selection were off by one. `marquee_preview_px` now grows the shorter side to the longer one, away from the press point, so the square still covers every pixel the drag touched. Ants, readout and commit share this path. Fixes #5472 --- crates/ui-egui/src/canvas.rs | 52 ++++++++++++++++++++++++++++- crates/ui-egui/src/marquee_tests.rs | 17 ++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 09d7dcc8ec..2e44b83b43 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -244,7 +244,10 @@ pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let (a, b) = marquee_corners(o, d, last); - let r = marquee_px(a, b); + let mut r = marquee_px(a, b); + if marquee_is_square(o, d) { + r = square_px(r, d.start, last); + } (r[2] > r[0] && r[3] > r[1]).then_some(r) } @@ -253,6 +256,37 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()] } +/// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with +/// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`. +fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool { + let (w, h) = (f64::from(o.marquee_width), f64::from(o.marquee_height)); + match o.marquee_style.as_str() { + "fixedSize" => false, + "fixedRatio" if w > 0.0 && h > 0.0 => w == h, + _ => d.marquee_mods().0, + } +} + +/// A constrained square `r` (`marquee_px` of its corners) with equal sides on whole pixels. +/// +/// The square is built in continuous coordinates, and snapping each axis to the pixel grid on +/// its own (floor the near edge, ceil the far one) made one side a pixel longer than the other +/// whenever the press point sat between pixels: a ⇧ drag read 101 × 102 (#5472). The shorter +/// side grows to the longer one, away from the press point (`start`, towards `end`), so the +/// square still covers every pixel the drag touched. +pub fn square_px(r: [f64; 4], start: [f64; 2], end: [f64; 2]) -> [f64; 4] { + let [x0, y0, x1, y1] = r; + let (w, h) = (x1 - x0, y1 - y0); + let n = w.max(h); + let grow = |lo: f64, hi: f64, extent: f64, from: f64, to: f64| { + let d = (n - extent).max(0.0); + if to < from { (lo - d, hi) } else { (lo, hi + d) } + }; + let (x0, x1) = grow(x0, x1, w, start[0], end[0]); + let (y0, y1) = grow(y0, y1, h, start[1], end[1]); + [x0, y0, x1, y1] +} + /// The one-pixel line `[x0, y0, x1, y1]` a Single Row (Single Column) Marquee at `p` selects: the /// pixel row (column) under `p`, kept on the canvas, across the whole `size`. `None` for other /// tools, an empty canvas or a point that is not a number. @@ -5935,6 +5969,22 @@ mod tabs_tests; #[cfg(test)] mod tests { + /// #5472: a ⇧ square pressed between pixels snapped to 101 × 102; the short side grows to + /// match, on the side the drag points to. + #[test] + fn square_px_equalises_the_sides_away_from_the_press_point() { + use super::{marquee_px, square_px}; + // Down-right drag: the continuous square (50.3, 50.7)..(150.8, 151.2) snaps unevenly. + let r = marquee_px([50.3, 50.7], [150.8, 151.2]); + assert_eq!(r, [50.0, 50.0, 151.0, 152.0], "uneven before the fix"); + assert_eq!(square_px(r, [50.3, 50.7], [150.8, 90.0]), [50.0, 50.0, 152.0, 152.0]); + // Up-left drag: the short side grows towards lower coordinates. + let r = marquee_px([150.8, 151.2], [50.3, 50.7]); + assert_eq!(square_px(r, [150.8, 151.2], [60.0, 50.7]), [49.0, 50.0, 151.0, 152.0]); + // Already square: unchanged. + assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]); + } + #[test] fn display_image_matches_egui_unmultiplied_conversion() { // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 51b16916e5..40e9bbd392 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -147,6 +147,23 @@ fn shift_during_the_drag_draws_a_square_and_the_readout_follows() { assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag"); } +/// #5472: a ⇧ square pressed between pixels read 101 × 102 and selected that, because each axis +/// snapped to the pixel grid on its own. The readout and the selection are square, and the +/// Elliptical Marquee's circle too. +#[test] +fn shift_square_pressed_between_pixels_has_equal_sides() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + let mut h = harness(tool); + press_at(&mut h, 50.3, 50.7, Modifiers::NONE); + mods(&mut h, Modifiers::SHIFT); + move_to(&mut h, 150.8, 90.0); + assert!(readout(&h, 102, 102), "{tool:?}: the square covers every pixel it touched, on both axes"); + release_at(&mut h, 150.8, 90.0, Modifiers::SHIFT); + mods(&mut h, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(50, 50, 152, 152), "{tool:?}"); + } +} + /// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee. #[test] fn show_transformation_values_off_hides_the_readout() { From 2a0bebb49a778d4115222efec02c11b33ebe8850 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:05:46 -0700 Subject: [PATCH 25/90] fix(engine): validate bounded extended filter parameters (#5586) --- crates/engine/src/filters/validation.rs | 144 ++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 127e92efe1..068d37e9da 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -1,4 +1,4 @@ -//! Validate new core-filter invocations, without changing how old smart-filter records decode. +//! Validate new filter invocations, without changing how old smart-filter records decode. use serde_json::Value; @@ -33,7 +33,7 @@ impl Rule { } } -// The bounds and choices are the core filters' public dialog-unit contract. Defaults remain in +// The bounds and choices are the filters' public dialog-unit contract. Defaults remain in // params_for; gallery filters are checked in gallery_cmds. fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { use Rule::{Bool, Choice, Number, Unsigned}; @@ -48,6 +48,20 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { ("centerX", Number(0.0, 1.0)), ("centerY", Number(0.0, 1.0)), ], + "filter.blur.smartBlur" => &[ + ("radius", Number(0.1, 100.0)), + ("threshold", Number(0.1, 100.0)), + ("quality", Choice("high|medium|low")), + ("mode", Choice("normal|edgeOnly|overlayEdge")), + ], + "filter.blur.shapeBlur" => &[("radius", Number(5.0, 1000.0)), ("shape", Choice("circle|ring|square|diamond|triangle|hexagon|star|heart|cross"))], + "filter.noise.reduceNoise" => &[ + ("strength", Number(0.0, 10.0)), + ("preserveDetails", Number(0.0, 100.0)), + ("reduceColorNoise", Number(0.0, 100.0)), + ("sharpenDetails", Number(0.0, 100.0)), + ("removeJpegArtifact", Bool), + ], "filter.blur.surfaceBlur" => &[("radius", Number(1.0, 100.0)), ("threshold", Number(2.0, 255.0))], "filter.sharpen.unsharpMask" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 1000.0)), ("threshold", Number(0.0, 255.0))], "filter.sharpen.smartSharpen" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 64.0)), ("reduceNoise", Number(0.0, 100.0))], @@ -92,8 +106,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { }) } -// Extended filters: only their documented choices are checked for now, since `prepare` injects -// extra keys (colours, map documents) and their numeric ranges clamp in params_for. +// Remaining extended filters: only their documented choices are checked for now, since some +// accept extra keys (colours, map documents) and their numeric ranges clamp in params_for. fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { Some(match id { "filter.pixelate.mezzotint" => { @@ -109,11 +123,9 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], - "filter.blur.smartBlur" => &[("quality", "high|medium|low"), ("mode", "normal|edgeOnly|overlayEdge")], "filter.blur.lensBlur" => { &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] } - "filter.blur.shapeBlur" => &[("shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross")], "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], _ => return None, @@ -324,9 +336,7 @@ mod tests { "filter.distort.zigZag", "filter.render.lensFlare", "filter.render.lightingEffects", - "filter.blur.smartBlur", "filter.blur.lensBlur", - "filter.blur.shapeBlur", "filter.other.hsbHsl", "filter.video.deInterlace", ]; @@ -348,6 +358,124 @@ mod tests { s.execute("filter.stylize.wind", json!({"direction":"fromLeft"})).unwrap(); } + const STRICT_EXTENDED: [&str; 3] = ["filter.blur.smartBlur", "filter.blur.shapeBlur", "filter.noise.reduceNoise"]; + + #[test] + fn strict_extended_filters_reject_bad_fields_without_editing() { + let numbers = [ + ("filter.blur.smartBlur", "radius", 0.1, 100.0), + ("filter.blur.smartBlur", "threshold", 0.1, 100.0), + ("filter.blur.shapeBlur", "radius", 5.0, 1000.0), + ("filter.noise.reduceNoise", "strength", 0.0, 10.0), + ("filter.noise.reduceNoise", "preserveDetails", 0.0, 100.0), + ("filter.noise.reduceNoise", "reduceColorNoise", 0.0, 100.0), + ("filter.noise.reduceNoise", "sharpenDetails", 0.0, 100.0), + ]; + let specs = crate::filters_ext::specs(); + for depth in [8, 16, 32] { + let mut s = session(depth); + for (id, key, min, max) in numbers { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + assert!(doc.contains(&format!("\"{key}\":{min}..{max}=")), "{id}.{key}"); + for value in [json!("3"), json!(true), Value::Null, json!([]), json!({}), json!(min - 0.01), json!(max + 0.01), json!(1e300)] { + rejected(&mut s, id, key, value); + } + // Validate large endpoints without allocating large blur kernels. + for value in [min, max] { + validate_params(id, &json!({key:value})).unwrap(); + } + } + for id in STRICT_EXTENDED { + for (key, value) in [("radus", json!(3)), ("layer", json!("2")), ("target", json!("typo")), ("coalesce", json!(false))] { + rejected(&mut s, id, key, value); + } + for value in [json!([]), json!(false), json!(3), json!("params")] { + assert!(matches!(validate_params(id, &value), Err(EngineError::BadParams { .. }))); + } + } + for value in [json!("true"), json!(1), Value::Null, json!([]), json!({})] { + rejected(&mut s, "filter.noise.reduceNoise", "removeJpegArtifact", value); + } + } + for value in [false, true] { + validate_params("filter.noise.reduceNoise", &json!({"removeJpegArtifact":value})).unwrap(); + } + for (id, key, allowed) in [ + ("filter.blur.smartBlur", "quality", "high|medium|low"), + ("filter.blur.smartBlur", "mode", "normal|edgeOnly|overlayEdge"), + ("filter.blur.shapeBlur", "shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross"), + ] { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + assert!(doc.contains(&format!("\"{key}\":\"{allowed}\"")), "{id}.{key}"); + let mut s = session(8); + for value in [json!("typo"), json!(false), json!(1), Value::Null] { + rejected(&mut s, id, key, value); + } + for value in allowed.split('|') { + validate_params(id, &json!({key:value})).unwrap(); + } + } + } + + #[test] + fn strict_extended_filters_validate_before_floating_selection_commit_and_jobs() { + let mut s = session(16); + s.execute("select.rect", json!({"x":0,"y":0,"width":4,"height":4})).unwrap(); + s.execute("select.float", json!({"dx":8})).unwrap(); + for id in STRICT_EXTENDED { + rejected(&mut s, id, "radus", json!(3)); + let doc = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + let journal = s.journal.clone(); + let params = json!({"radus":3}); + assert!(matches!(s.start(id, params.clone()), Err(EngineError::BadParams { .. }))); + let spec = crate::commands::find(id).unwrap(); + assert!(matches!((spec.run)(&mut s, ¶ms), Err(EngineError::BadParams { .. }))); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some()); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc)); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal, journal); + assert!(s.jobs().is_empty()); + } + } + + #[test] + fn strict_extended_filters_keep_defaults_targets_history_and_last_filter() { + for depth in [8, 16, 32] { + for id in STRICT_EXTENDED { + let mut s = session(depth); + let layer = s.active().unwrap().active_layer.unwrap(); + for params in [Value::Null, json!({}), json!({"layer":layer.0,"target":"pixels","coalesce":"extended"})] { + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + s.execute(id, params).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = s.active().unwrap().doc.clone(); + s.undo(); + assert_eq!(*s.active().unwrap().doc, *before); + s.redo(); + assert_eq!(*s.active().unwrap().doc, *after); + } + s.execute("filter.lastFilter", json!({})).unwrap(); + // The session injects this target when editing an alpha channel. + s.execute("channel.new", json!({"fill":"white"})).unwrap(); + s.execute(id, json!({})).unwrap(); + } + } + } + + #[test] + fn stored_extended_filter_records_still_decode_tolerantly() { + use photocraft_algo::{BlurQuality, BlurShape, FilterParams, SmartBlurMode}; + let smart = + crate::filters::params_for("filter.blur.smartBlur", &json!({"radius":-2,"threshold":900,"quality":"old","mode":false,"extra":true})).unwrap(); + assert!(matches!(smart, FilterParams::SmartBlur { radius: 0.1, threshold: 100.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal })); + let shape = crate::filters::params_for("filter.blur.shapeBlur", &json!({"radius":2000,"shape":"old"})).unwrap(); + assert!(matches!(shape, FilterParams::ShapeBlur { radius: 1000.0, shape: BlurShape::Circle })); + let noise = crate::filters::params_for("filter.noise.reduceNoise", &json!({"strength":-1,"preserveDetails":101,"removeJpegArtifact":"yes"})).unwrap(); + assert!(matches!(noise, FilterParams::ReduceNoise { strength: 0.0, preserve_details: 100.0, remove_jpeg_artifact: false, .. })); + } + #[test] fn invalid_filters_leave_floating_selection_and_background_jobs_alone() { let mut s = session(16); From 64efae47c85a9b40f967bb6514c45a52dac54375 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 00:05:55 +0200 Subject: [PATCH 26/90] Guide Layout: the disabled Rows field no longer turns Rows on (#5625) (#5626) The disabled Number field still drew a DragValue with range 1..=1000, which clamps the default rows 0 to 1 and reports a change; writing that back made the Rows checkbox's `positive` fallback turn on, so OK added guides at the top and bottom. Disabled number fields no longer write back. Kittest regression covers Rows and Columns. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/guide_layout_ui.rs | 3 +++ crates/ui-egui/src/guide_layout_ui/tests.rs | 26 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/ui-egui/src/guide_layout_ui.rs b/crates/ui-egui/src/guide_layout_ui.rs index 8d47e628c8..894535e687 100644 --- a/crates/ui-egui/src/guide_layout_ui.rs +++ b/crates/ui-egui/src/guide_layout_ui.rs @@ -194,6 +194,9 @@ fn number(ui: &mut egui::Ui, f: &mut Map, key: &str, count: bool) let mut value = f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; if widgets::value_field(ui, &mut value, (if count { 1.0 } else { 0.0 })..=if count { 1000.0 } else { 300_000.0 }, if count { "" } else { "px" }, 68.0) .changed() + // A disabled field still clamps its value into range (a zero count to 1); writing that + // back would switch the axis on through its `positive` fallback (#5625). + && ui.is_enabled() { f.insert(key.into(), if count { json!(value.round() as u64) } else { json!(value) }); } diff --git a/crates/ui-egui/src/guide_layout_ui/tests.rs b/crates/ui-egui/src/guide_layout_ui/tests.rs index 299b3fb83e..673f1663bf 100644 --- a/crates/ui-egui/src/guide_layout_ui/tests.rs +++ b/crates/ui-egui/src/guide_layout_ui/tests.rs @@ -104,6 +104,32 @@ fn tab_skips_disabled_rows_and_margins() { assert_eq!(f["gutter"], 4.0); } +/// The disabled Number field must not clamp a zero count to 1 and so switch its axis on (#5625). +#[test] +fn disabled_number_fields_do_not_turn_their_axis_on() { + let mut app = app(); + let id = open(&mut app); + let mut h = harness(app); + h.run_steps(3); + let f = &h.state().ui.dialogs.first().unwrap().fields; + assert_eq!(f["rows"], 0, "the default layout has no rows"); + assert!(!on(f, "__rowsEnabled", positive(f, "rows")), "Rows stays off"); + let mut app = h.into_state(); + crate::dialogs::confirm(&mut app, id).unwrap(); + let guides = &app.session.active().unwrap().doc.guides; + assert!(!guides.vertical.is_empty(), "the columns were created"); + assert!(guides.horizontal.is_empty(), "no row guides at the top and bottom"); + + let mut app = self::app(); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("columns".into(), json!(0)); + let mut h = harness(app); + h.run_steps(3); + let f = &h.state().ui.dialogs.first().unwrap().fields; + assert_eq!(f["columns"], 0); + assert!(!on(f, "__columnsEnabled", positive(f, "columns")), "Columns stays off"); +} + /// New Guide Layout offers New Guide's colours, by value, and keeps another one as Custom. #[test] fn color_list_is_new_guides() { From 602344bae0dcedff74e695de3bd722abfd766368 Mon Sep 17 00:00:00 2001 From: agent-346022 Date: Thu, 15 Oct 2026 01:06:04 +0300 Subject: [PATCH 27/90] Fix filter preview live during drag (#5549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Filter dialogs: paint the preview computed mid-drag instead of waiting for the pointer to stop A preview that finished while the slider was still moving was thrown away before it could reach the canvas, because the worker result had to match the request on every field including the parameters. Every completion of a drag lands after the value has moved on, so none of them matched, the worker was handed the newest value instead, and that one went stale in turn. Nothing was ever painted while the pointer moved: the effect appeared only once the slider had been still for about a frame of preview time, which is what "I have to stop the slider and wait" looks like. The display gate a few lines below already had the opposite rule. It compared the document, revision, dialog, layer, channel target, command and proxy factor and deliberately left the parameters out, so that a slightly old frame stays on screen mid-drag. The publish gate disagreed with it and won, so the frame the display gate was written to keep never existed. Publish a successful result when the finished request matches the current one on everything except the parameters, and let the existing display gate decide whether to keep it. The two gates now share one predicate, FilterPreviewKey::same_target, so they cannot drift apart again. Errors keep the exact match. A value the pointer has already dragged past should not raise a notice, and a failure is not something to show stale. What this deliberately does not change: - A reopened dialog has a new id, a committed document a new revision, a different command or channel target a different key, so a result from another preview is still never painted. Covered by a_reopened_dialog_discards_the_previous_dialogs_finished_preview. - The freshly computed request is still what decides whether a new computation starts, so the worker still converges on the value the dialog actually holds. - One preview runs at a time and a stale one is still cancelled only once the values settle, so this adds no work to the worker; it only stops discarding completed work. - OK still recomputes at full resolution, so a frame shown mid-drag is never what gets written to the document. Test: a_preview_computed_mid_drag_reaches_the_canvas starts a blur at one radius, moves the slider before that preview lands, and requires the completed frame to reach the canvas while the slider is still on the new value. It fails on the old gate, timing out after 10 s with no frame published. * Filter dialogs: three guards on a mid-drag preview, after review The first version of this let any completed frame through that matched the dialog on everything but the parameters. Review found three ways that is wrong, and all three are now closed and pinned by a test. A stale result with nothing to show. Storing it replaces a good frame with an empty one, which the display gate paints as "draw nothing": the canvas blinks back to the unfiltered layer mid-drag. The exact request may still store an empty result, which is how a filter that no longer applies clears the canvas, so the guard belongs on the stale arm only. Splitting the match arm is what makes that separable; the earlier single arm forced a false choice between the two. A late frame replacing an exact one. Drag the slider away and straight back: the frame on screen is already exact for the dialog, and the in-flight frame for the value on the way out would have overwritten it, showing a blur the user has already undone. A stale result is now dropped when the stored preview is already exact. Errors were already scoped to the exact request and stay there: a value the pointer has dragged past should not raise a notice, and the value it comes to rest on will raise its own when it is recomputed. Also pinned by test, because both are easy to undo by accident: - The reopen test was not reaching the gate. discard_closed reclaims a closed dialog's job a frame earlier in the real app, and the test called it too, so the stale result never arrived and the test passed even with the dialog id removed from the comparison. It now drives the gate directly. Verified by mutation. - The mid-drag test asserted nothing about what the display gate does with the published frame, so re-adding params there passed. It now asserts the canvas actually returns something to draw, and it keeps polling until a frame for the dialog's current value lands, which is what pins fresh to an exact match. Tests are deterministic: the worker job is gated on a channel and signals when its result is already in the worker channel, so nothing sleeps and nothing races the 150 ms settle timer that cancels a job whose values stopped changing. Verified by mutating each guard and confirming a test catches it: publish gate back to exact-match ................. killed drop the is_some guard ........................... killed drop the already-exact guard .................... killed same_target ignores the dialog id ............... killed display gate compares params again .............. killed fresh relaxed to same_target .................... killed same_target ignores k ........................... killed k stays in the comparison on purpose. A 36 MP document previews at k=4, and preview_factor lowers k as a pixel-sized parameter approaches its minimum, so k does change under the slider. Relaxing it would paint a frame at a scale the preview policy exists to avoid, and would loosen the display gate further than upstream wrote it. The cost is a one-computation pause when the slider crosses a threshold on a large document; that test keeps the choice visible. * Filter dialogs: fix a race in the new tests that let CI pass them The four new tests signalled each other through a channel the worker sends from inside `compute`. But the worker only puts the result into the channel after `compute` returns: let result = compute(&work); let _ = tx.send(result); So a test that woke on the signal and called `ensure_filter_preview` immediately could reach `poll()` before that send. On a loaded CI runner it did: ubuntu failed `a_mid_drag_empty_result_leaves_the_preview_alone` with no frame published at all. The comment in the test claimed the result was already in the channel, which was the one thing it was not. The tests now drive the canvas until the state they need appears, which does not depend on the order of that send. They also check their invariant on every pass rather than only at the end: a wrongly published frame can be replaced by the next, correct one before the loop stops, which had already left the reopen, late-frame and empty-result tests unable to see their own mutation. Unchanged: the assertions, the production code, and the four mutations that broke each guard — re-verified, 7 of 7 still caught. Runs: 30 back-to-back runs of the four tests on 12 cores, no failure; the full crate twice, 2800 pass. --------- Co-authored-by: agent-346022 <340881286+agent-346022@users.noreply.github.com> --- crates/ui-egui/src/canvas.rs | 294 ++++++++++++++++---- crates/ui-egui/src/filter_dialog.rs | 68 ++++- crates/ui-egui/src/filter_preview_worker.rs | 2 +- 3 files changed, 302 insertions(+), 62 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2e44b83b43..37415bc0c1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1733,15 +1733,29 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e #[cfg(not(target_arch = "wasm32"))] if app.background_jobs { app.filter_preview_worker.supersede(&request, crate::gpu_canvas::now_ms()); - if let Some((finished, computed)) = app.filter_preview_worker.poll() - && finished == request - { + if let Some((finished, computed)) = app.filter_preview_worker.poll() { match computed { - Ok(computed) => publish_filter_preview(app, idx, finished, computed.result, computed.ms), - Err(error) => { + // The dialog still holds these exact parameters. Publish as before, including an + // empty result: that is how a filter which no longer applies clears the canvas. + Ok(computed) if finished == request => publish_filter_preview(app, idx, finished, computed.result, computed.ms), + // Mid-drag, a frame for the value the pointer has just left is what makes the + // canvas follow the drag. Three things have to hold for it: it belongs to this + // preview, it has a frame to show (an empty one would blank a good preview), and + // it must not displace a frame that is already exact for the dialog, which is + // what a slider dragged out and back would otherwise make the canvas do. + Ok(computed) + if finished.same_target(&request) && computed.result.is_some() && !app.filter_preview.as_ref().is_some_and(|p| p.key == request) => + { + publish_filter_preview(app, idx, finished, computed.result, computed.ms); + } + Ok(_) => {} + // Errors stay on the exact request: a value the pointer has already dragged past + // must not raise a notice, and a still one will raise it when it is recomputed. + Err(error) if finished == request => { app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: finished, result: None, committing: false }); crate::notices::error(app, error); } + Err(_) => {} } } let fresh = app.filter_preview.as_ref().is_some_and(|p| p.key == request); @@ -1776,15 +1790,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } let preview = app.filter_preview.as_ref()?; // Keep the last preview during a drag, but never one from another source or dialog. - // Completed worker results above must match every parameter before being uploaded. - if preview.key.doc != request.doc - || preview.key.revision != request.revision - || preview.key.dialog != request.dialog - || preview.key.active != request.active - || preview.key.target != request.target - || preview.key.k != request.k - || preview.key.command != request.command - { + if !preview.key.same_target(&request) { return None; } let result = preview.result.as_ref()?; @@ -6078,57 +6084,225 @@ mod tests { assert!(app.filter_preview.as_ref().unwrap().result.is_some()); } + // Starts a worker job that computes a real preview for the dialog as it stands, but only + // finishes when the returned sender is signalled. Gating it here keeps these tests off the + // clock: `supersede` cancels a running job once the values have been still for SETTLE_MS, so a + // test that relies on a real blur beating a 150 ms timer is a test that fails on a loaded box. + #[cfg(not(target_arch = "wasm32"))] + fn gated_blur_preview(app: &mut PhotocraftApp, ctx: &egui::Context) -> (std::sync::mpsc::Sender<()>, f64) { + let d = app.ui.dialogs.first().expect("a dialog is open"); + let st = app.session.active().expect("a document is active"); + let command = "filter.blur.gaussianBlur"; + let params = app.with_mask_target(command, crate::filter_dialog::params_of(&d.fields)); + assert_eq!( + crate::filter_dialog::preview_factor(command, ¶ms, 1), + 1, + "a 64x48 document previews at full resolution, and these tests rely on k staying 1" + ); + let key = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: command.into(), + params, + k: 1, + }; + let doc = app.session.documents()[0].doc.clone(); + let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let radius = key.params["radius"].as_f64().expect("the dialog seeds a radius"); + let (release, wait) = std::sync::mpsc::channel::<()>(); + 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) + }, + ); + crate::filter_preview_worker::Computed { result, ms: 0.0 } + }) + .expect("the worker starts"); + (release, radius) + } + + // The worker hands its result to the channel only after `compute` returns, so a signal sent + // from inside `compute` would still race that send. These tests drive the canvas until the + // state they need appears instead of assuming a timing. + #[cfg(not(target_arch = "wasm32"))] + fn drive_until(app: &mut PhotocraftApp, ctx: &egui::Context, what: &str, done: impl Fn(&PhotocraftApp) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !done(app) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for {what}"); + ensure_filter_preview(app, 0, ctx); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] - fn indexed_preview_discards_a_finished_job_after_slider_change_or_reopening() { - for reopen in [false, true] { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); - app.background_jobs = true; - crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); - let d = app.ui.dialogs.first().unwrap(); - let st = app.session.active().unwrap(); - let old = crate::filter_dialog::FilterPreviewKey { - doc: st.doc.id, - revision: st.revision, - dialog: d.id, - active: st.active_layer, - target: st.channel_view.target, - command: "image.mode.indexedColor".into(), - params: app.with_mask_target("image.mode.indexedColor", crate::filter_dialog::params_of(&d.fields)), - k: 1, - }; - let (release, wait) = std::sync::mpsc::channel(); - let ctx = egui::Context::default(); - app.filter_preview_worker - .start(old.clone(), ctx.clone(), move |_| { - wait.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); - crate::filter_preview_worker::Computed { result: None, ms: 0.0 } - }) - .unwrap(); - if reopen { - app.ui.dialogs.clear(); - crate::filter_preview_worker::discard_closed(&mut app); - crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); - } else { - app.ui.dialogs.first_mut().unwrap().fields.insert("colors".into(), json!(16)); - } - assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none()); - assert!(app.filter_preview.is_none()); - release.send(()).unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while app.filter_preview.is_none() { - assert!(std::time::Instant::now() < deadline); - ensure_filter_preview(&mut app, 0, &ctx); - if let Some(preview) = &app.filter_preview { - assert_ne!(preview.key, old, "an outdated preview was published"); - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - assert_eq!(app.session.active().unwrap().doc.mode, photocraft_color::ColorMode::Rgb); + fn a_preview_computed_mid_drag_reaches_the_canvas_and_then_converges() { + // A drag used to paint nothing at all: every completion was dropped for carrying stale + // parameters, so the canvas only ever showed an effect once the pointer had come to rest. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + let (release, first) = gated_blur_preview(&mut app, &ctx); + + // The pointer moves on before the frame lands. + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the mid-drag frame", |a| a.filter_preview.is_some()); + + // The finished frame is painted even though the dialog has moved on. + let shown = app.filter_preview.as_ref().expect("the mid-drag frame was published"); + assert_eq!(shown.key.params["radius"].as_f64(), Some(first), "the frame is the one the worker finished"); + assert!(shown.result.is_some()); + assert_eq!(app.ui.dialogs[0].fields["radius"].as_f64(), Some(moved), "the slider kept moving"); + // The display gate lets it through, so the canvas has something to draw: this is what + // re-adding `params` to that gate would break. + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_some(), "the canvas draws the mid-drag frame"); + + // And the preview still converges on the value the dialog holds. `fresh` has to stay an + // exact match for that: relaxed, the canvas would believe it already had the current + // request after the first frame of a drag and never compute again. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while app.filter_preview.as_ref().is_none_or(|p| p.key.params["radius"].as_f64() != Some(moved)) { + assert!(std::time::Instant::now() < deadline, "the preview never converged on the current radius"); + ensure_filter_preview(&mut app, 0, &ctx); + std::thread::sleep(std::time::Duration::from_millis(1)); } } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_mid_drag_frame_does_not_replace_a_frame_that_is_already_exact() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + + // A frame for the radius the dialog holds right now reaches the canvas. This also proves + // the hand-built key is the one the canvas builds: it can only be published by the exact + // arm, which requires `finished == request`. + let (release, first) = gated_blur_preview(&mut app, &ctx); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.is_some()); + assert_eq!(app.filter_preview.as_ref().expect("published").key.params["radius"].as_f64(), Some(first)); + + // The slider moves away and a frame for the new radius is computed... + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + let (release_moved, _) = gated_blur_preview(&mut app, &ctx); + // ...and back to `first` before that frame lands. + app.ui.dialogs[0].fields.insert("radius".into(), json!(first)); + release_moved.send(()).expect("released"); + + // The frame for `moved` arrives late. It belongs to this preview, but the frame on screen + // is already exact for the dialog, so it must never be shown in place of it. Checked on + // every pass: a wrong frame published and then overwritten before the loop ends would + // otherwise never be seen. + drive_until(&mut app, &ctx, "the late frame to arrive", |a| { + if let Some(shown) = &a.filter_preview { + assert_ne!(shown.key.params["radius"].as_f64(), Some(moved), "a late frame replaced an exact one"); + } + !a.filter_preview_worker.busy() + }); + let shown = app.filter_preview.as_ref().expect("a frame is on screen"); + assert_eq!(shown.key.params["radius"].as_f64(), Some(first), "a late frame replaced an exact one"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_mid_drag_empty_result_leaves_the_preview_alone() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + + let (release, first) = gated_blur_preview(&mut app, &ctx); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.is_some()); + assert!(app.filter_preview.as_ref().expect("published").result.is_some()); + + // The slider moves and a job for the new radius is started, then the slider moves again + // before that job lands: its result is stale by the time it arrives. + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + let d = app.ui.dialogs.first().expect("a dialog is open"); + let st = app.session.active().expect("a document is active"); + let key = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: "filter.blur.gaussianBlur".into(), + params: json!({"radius": moved}), + k: 1, + }; + let (release_empty, wait) = std::sync::mpsc::channel::<()>(); + app.filter_preview_worker + .start(key, ctx.clone(), move |_| { + wait.recv_timeout(std::time::Duration::from_secs(60)).expect("released"); + crate::filter_preview_worker::Computed { result: None, ms: 0.0 } + }) + .expect("the worker starts"); + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved + 4.0)); + release_empty.send(()).expect("released"); + + // Storing an empty result here would drop the canvas back to the unfiltered layer for a + // frame. The exact request may still store one; a stale one may not. Checked on every + // pass, because the reopened preview that follows would otherwise repair the symptom. + drive_until(&mut app, &ctx, "the empty frame to arrive", |a| { + if let Some(shown) = &a.filter_preview { + assert!(shown.result.is_some(), "an empty mid-drag result blanked the canvas"); + } + !a.filter_preview_worker.busy() + }); + assert!(app.filter_preview.as_ref().expect("a preview is still shown").result.is_some(), "an empty mid-drag result blanked the canvas"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_reopened_dialog_never_paints_the_previous_dialogs_preview() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + let first_dialog = app.ui.dialogs.first().expect("a dialog is open").id; + let (release, _) = gated_blur_preview(&mut app, &ctx); + + // Close that dialog and open another one for the same layer and the same command. Only the + // dialog id differs, which is exactly what `same_target` has to notice. + app.ui.dialogs.clear(); + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let second_dialog = app.ui.dialogs.first().expect("a dialog is open").id; + assert_ne!(second_dialog, first_dialog, "a reopened dialog gets a new id"); + release.send(()).expect("released"); + + // The first dialog's frame arrives late and must not be painted under the second one. + // `discard_closed` usually reclaims it a frame earlier, which is why this drives the gate + // directly: the id check is the part that has to hold when that cleanup misses. The + // invariant is checked on every pass, not only at the end, because a wrongly published + // frame can still be replaced by the reopened dialog's own preview before we stop. + drive_until(&mut app, &ctx, "the first dialog's frame to arrive", |a| { + if let Some(preview) = &a.filter_preview { + assert_ne!(preview.key.dialog, first_dialog, "a reopened dialog painted the previous dialog's preview"); + } + !a.filter_preview_worker.busy() + }); + } + #[test] fn windows_uses_a_visible_crosshair() { // #737: Windows' inverting crosshair vanishes over mid-grey; use a black/white glyph. diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 5d86cc16b5..3da0735204 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -962,7 +962,13 @@ pub struct FilterPreview { pub committing: bool, } -/// Match the full request before accepting a worker result, including a reopened dialog. +/// Identity of one filter dialog's live preview. +/// +/// `PartialEq` is the whole request: it says a worker result is exactly what the dialog is asking +/// for now. [`Self::same_target`] is the looser question of whether a result belongs to this +/// preview at all; two keys that agree on it name the same dialog, layer, document and preview +/// resolution, and differ only in parameters — which is what a slider drag produces, since the +/// worker computed for the value the pointer passed a moment ago. #[derive(Clone, Debug, PartialEq)] pub struct FilterPreviewKey { pub doc: photocraft_doc::DocId, @@ -975,6 +981,26 @@ pub struct FilterPreviewKey { pub k: u32, } +impl FilterPreviewKey { + /// Everything except the parameters: same dialog, document, revision, layer, channel target, + /// command and proxy factor. + /// + /// The canvas shows whatever this preview was computed for, so a result whose parameters are + /// a few frames old still belongs on screen while a slider is moving; matching on everything + /// would mean no frame of a drag is ever shown, because every completion lands after the + /// parameters moved on. A reopened dialog has a new id, a different command or a different + /// document, and a committed document a new revision, so neither survives this test. + pub fn same_target(&self, other: &Self) -> bool { + self.doc == other.doc + && self.revision == other.revision + && self.dialog == other.dialog + && self.active == other.active + && self.target == other.target + && self.command == other.command + && self.k == other.k + } +} + #[cfg(test)] mod tests { use super::*; @@ -988,6 +1014,46 @@ mod tests { assert_eq!(source_label("centerX"), "Center X"); } + fn key() -> FilterPreviewKey { + FilterPreviewKey { + doc: photocraft_doc::DocId(7), + revision: 11, + dialog: 3, + active: Some(photocraft_doc::LayerId(2)), + target: Default::default(), + command: "filter.blur.gaussianBlur".into(), + params: json!({"radius": 4.0}), + k: 1, + } + } + + #[test] + fn same_target_ignores_parameters_but_nothing_else() { + // A drag only changes the parameters, and that must not hide the finished frame. + let base = key(); + let mut moved = base.clone(); + moved.params = json!({"radius": 40.0}); + assert!(base.same_target(&moved)); + assert!(moved.same_target(&base)); + + // Anything else names a different preview and must not be shown. + for break_it in [ + |k: &mut FilterPreviewKey| k.doc = photocraft_doc::DocId(8), + |k: &mut FilterPreviewKey| k.revision = 12, + |k: &mut FilterPreviewKey| k.dialog = 4, + |k: &mut FilterPreviewKey| k.active = Some(photocraft_doc::LayerId(5)), + |k: &mut FilterPreviewKey| k.active = None, + |k: &mut FilterPreviewKey| k.target = photocraft_engine::channel_cmds::ChannelTarget::LayerMask, + |k: &mut FilterPreviewKey| k.command = "filter.blur.motionBlur".into(), + |k: &mut FilterPreviewKey| k.k = 2, + ] { + let mut other = base.clone(); + break_it(&mut other); + assert!(!other.same_target(&base), "{other:?} must not pass as {base:?}"); + assert!(!base.same_target(&other)); + } + } + #[test] fn only_direction_angles_get_the_dial() { // Every listed command still has a degree `angle` range, so the list cannot go stale. diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 48f29400fb..3006323c20 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -160,7 +160,7 @@ mod tests { let (old, result) = finish(&mut worker); assert!(result.is_ok()); let latest = key(1, 0, 256); - assert_ne!(old, latest); // The canvas must discard this result before uploading. + assert_ne!(old, latest); // The canvas paints this as the drag's frame; the newest one is computed next. assert!(worker.start(latest.clone(), ctx, |_| Computed { result: None, ms: 1.0 }).unwrap()); assert_eq!(finish(&mut worker).0, latest); } From 1f1e8d734fb63980d8b1c0829c0ccd0b8803aba3 Mon Sep 17 00:00:00 2001 From: Ahmed0_0Mamdouh <143358441+Ahmeddouhm@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:06:13 +0300 Subject: [PATCH 28/90] Fix #5414: Keep created paths visible and editable (#5424) * Paths: keep work and shape paths visible with any tool (#5414) * Tests: improve path overlay regression coverage --- crates/ui-egui/src/path_selection_tests.rs | 84 ++++++++++++++++++++++ crates/ui-egui/src/vector_ui.rs | 21 +++--- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 8f82e2ad68..b4aaa9e503 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -247,3 +247,87 @@ fn clicking_shape_with_path_selection_selects_its_layer() { crate::vector_ui::path_selection_finish(h.state_mut(), [150.0, 90.0], [150.0, 90.0]); assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); } + +/// Work paths remain visible (outline) regardless of the active tool. Before the fix, +/// `draw_overlay` was gated on `vector_tool` so paths disappeared when switching to Brush, Move, +/// etc. This test renders the canvas with a Brush tool active and a work path present, then +/// confirms the path's outline pixels are painted in the accent colour, proving the path is drawn. +#[test] +fn path_overlay_drawn_for_non_vector_tools() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + // Place a work path whose top edge runs exactly along y=10 in document space. + app.run("path.set", json!({"name": "work", "path": rect_path(50.0, 10.0, 100.0, 80.0)})).unwrap(); + // Switch to a non-vector tool — before the fix this hid the path outline. + app.ui.tool = Tool::Brush; + + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(6); + // Fit the view so document coordinates map predictably to screen pixels. + { + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 2.0; // 2 px/doc-px → the 200×200 doc fills 400×400 screen pixels + v.center = [100.0, 100.0]; + v.fit_pending = false; + } + h.run_steps(3); + + // Get the accent colour so we know what to look for. + let accent = crate::theme::Tokens::get(&h.ctx).accent; + + let xf = crate::canvas::ViewXform::active(h.state()).expect("active document view"); + let ppp = h.ctx.pixels_per_point(); + let img = h.render().unwrap(); + + // Sample the rendered outline at points derived from the same transform the canvas used. The + // top edge is doc-y=10; x=70..130 stays away from the rectangle's anchor squares. A 1 px + // egui stroke is anti-aliased over the white canvas, so accept visible blends of the accent. + let accent_r = accent.r(); + let accent_g = accent.g(); + let accent_b = accent.b(); + let is_accent_stroke = |pixel: [u8; 4]| { + let denom = f32::from(255u8.saturating_sub(accent_r)).max(1.0); + let coverage = f32::from(255u8.saturating_sub(pixel[0])) / denom; + if !(0.2..=1.05).contains(&coverage) { + return false; + } + let blended = |channel: u8| 255.0 + coverage * (f32::from(channel) - 255.0); + (f32::from(pixel[1]) - blended(accent_g)).abs() <= 12.0 && (f32::from(pixel[2]) - blended(accent_b)).abs() <= 12.0 + }; + + let has_accent_near = |at: egui::Pos2| { + let cx = (at.x * ppp).round() as i32; + let cy = (at.y * ppp).round() as i32; + let radius = (2.0 * ppp).ceil() as i32; + for y in (cy - radius)..=(cy + radius) { + for x in (cx - radius)..=(cx + radius) { + if x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() { + let pixel = img.get_pixel(x as u32, y as u32); + if is_accent_stroke([pixel[0], pixel[1], pixel[2], pixel[3]]) { + return true; + } + } + } + } + false + }; + let hits = (70..=130).step_by(5).filter(|x| has_accent_near(xf.to_screen(*x as f32, 10.0))).count(); + assert!( + hits >= 10, + "expected the work path outline to be visible with Brush tool active, \ + but found accent-coloured pixels at only {hits} sample point(s) along the transformed top edge \ + (accent = #{accent_r:02x}{accent_g:02x}{accent_b:02x}); \ + the path must be drawn regardless of the active tool", + ); +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index fe6db74079..f68a4a6a4e 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -955,7 +955,6 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a /// Work path / active shape path outlines, anchors, and the pen path in progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; - let vector_tool = matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) || is_shape_tool(tool); let accent = Tokens::get(painter.ctx()).accent; let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let draw_path = |p: &Path, anchors: bool| { @@ -976,16 +975,16 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - if vector_tool { - if let Some(wp) = &doc.work_path { - draw_path(wp, tool == Tool::PathSelection); - } - let st = app.session.active(); - if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) - && let LayerContent::Shape(sh) = &l.content - { - draw_path(&sh.path, tool == Tool::PathSelection); - } + // Paths remain visible as outlines with any tool. Path Selection adds anchor squares; Direct + // Selection has its own overlay above. + if let Some(wp) = &doc.work_path { + draw_path(wp, tool == Tool::PathSelection); + } + let st = app.session.active(); + if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) + && let LayerContent::Shape(sh) = &l.content + { + draw_path(&sh.path, tool == Tool::PathSelection); } // A targeted vector mask shows its path with any tool (#196). if let Some((_, p)) = targeted_vector_mask(app) { From 42fb79e909cd083ab2363500aa9e6ecc434fa491 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Thu, 15 Oct 2026 07:06:22 +0900 Subject: [PATCH 29/90] fix(ui): widen tool-options drag targets (#5135) Co-authored-by: haranishi <223786623+haranishi@users.noreply.github.com> --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/options_hit_area_tests.rs | 199 +++++++++++++++++++ crates/ui-egui/src/panels.rs | 4 +- crates/ui-egui/src/widgets.rs | 52 ++++- 4 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/options_hit_area_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f8cc8f21ae..5929e93385 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2988,3 +2988,6 @@ mod channel_tint_tests { assert_eq!(channel_tint(Rgb, 0, 77, false), Color32::from_gray(77)); } } + +#[cfg(test)] +mod options_hit_area_tests; diff --git a/crates/ui-egui/src/options_hit_area_tests.rs b/crates/ui-egui/src/options_hit_area_tests.rs new file mode 100644 index 0000000000..d444f06473 --- /dev/null +++ b/crates/ui-egui/src/options_hit_area_tests.rs @@ -0,0 +1,199 @@ +//! #5052: the actual Brush options caption and field form one scrub target. +use crate::PhotocraftApp; +use egui::{Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = crate::state::Tool::Brush; + app.session.tools.brush.hardness = 0.5; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn geometry(h: &Harness<'_, PhotocraftApp>) -> (Rect, Rect) { + let label = h.get_by_label("Hardness").rect(); + let field = h + .get_all_by_role(egui::accesskit::Role::SpinButton) + .map(|n| n.rect()) + .filter(|r| r.left() >= label.right()) + .min_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap(); + (label, field) +} + +fn drag(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, dx: f32, mods: Modifiers) { + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + for i in 1..=4 { + h.event(egui::Event::ModifiersChanged(mods)); + h.hover_at(at + vec2(dx * i as f32 / 4.0, 0.0)); + h.run(); + } + h.drop_at(at + vec2(dx, 0.0)); + h.run_steps(2); +} + +#[test] +fn options_hardness_label_drag_cannot_copy_selected_caption() { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, pos2(l.left() + 2.0, l.center().y), 20.0, Modifiers::NONE); + assert!(!h.ctx.plugin::().lock().has_selection(), "scrubbing Hardness must not create label text selection"); + h.event(egui::Event::Copy); + h.step(); + assert!( + !h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(s) if s.contains("Hard"))), + "a scrub caption must not select text for copying" + ); + assert!(h.state().session.tools.brush.hardness > 0.5); +} + +#[test] +fn options_hardness_caption_upper_and_lower_margins_scrub() { + for dy in [-10.0, 10.0] { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, l.center() + vec2(0.0, dy), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "24pt caption margin dy={dy}: {}", h.state().session.tools.brush.hardness); + assert_eq!(h.state().session.journal.len(), 1, "one command for the whole gesture"); + } +} + +#[test] +fn options_hardness_gap_and_suffix_scrub() { + for suffix in [false, true] { + let mut h = harness(); + let (l, f) = geometry(&h); + let x = if suffix { f.right() + 8.0 } else { (l.right() + f.left() - 4.0) / 2.0 }; + drag(&mut h, pos2(x, l.center().y), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "new region suffix={suffix}: {}", h.state().session.tools.brush.hardness); + assert_eq!(h.state().session.journal.len(), 1); + } +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) { + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + h.drop_at(at); + h.run_steps(2); +} + +#[test] +fn options_numeric_editing_and_focused_text_selection_keep_priority() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + let id = h.ctx.memory(|m| m.focused()).expect("click focuses the numeric editor"); + drag(&mut h, pos2(f.left() + 2.0, f.center().y), f.width() - 4.0, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(id), "text drag keeps numeric focus"); + assert_eq!(h.state().session.tools.brush.hardness, 0.5, "selecting digits must not scrub"); + let state = egui::TextEdit::load_state(&h.ctx, id).expect("focused text state"); + assert!(!state.cursor.char_range().unwrap().is_empty(), "digit selection still works"); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("25+5".into())); + h.run(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!((h.state().session.tools.brush.hardness - 0.3).abs() < 0.0001); +} + +#[test] +fn options_expanded_hit_click_does_not_edit_or_request_focus() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + let before_focus = h.ctx.memory(|m| m.focused()).expect("numeric editor focus"); + let before_journal = h.state().session.journal.len(); + let l = h.get_by_label("Hardness").rect(); + click(&mut h, l.center() + vec2(0.0, 10.0)); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + println!( + "focus_before={before_focus:?} focus_after={:?} value={} journal_before={before_journal} journal_after={}", + h.ctx.memory(|m| m.focused()), + h.state().session.tools.brush.hardness, + h.state().session.journal.len() + ); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(before_focus), "matches actual main: background retains numeric focus without acquiring it"); + assert_eq!(h.state().session.journal.len(), before_journal); +} + +#[test] +fn options_added_regions_preserve_modifiers_and_numeric_drag_rate() { + for (mods, dx, want) in [(Modifiers::SHIFT, 3.0, 0.8), (Modifiers::ALT, 20.0, 0.52)] { + let mut h = harness(); + let (l, f) = geometry(&h); + drag(&mut h, pos2(f.right() + 8.0, l.center().y), dx, mods); + assert!((h.state().session.tools.brush.hardness - want).abs() < 0.0001); + } + let mut h = harness(); + let (_, f) = geometry(&h); + drag(&mut h, f.center(), 20.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.6).abs() < 0.0001, "numeric drag retains its own rate"); +} + +#[test] +fn options_disabled_eraser_caption_and_added_regions_do_nothing() { + let mut h = harness(); + h.state_mut().ui.tool = crate::state::Tool::Eraser; + h.state_mut().ui.tool_options.eraser_mode = "block".into(); + h.run_steps(3); + let (l, f) = geometry(&h); + for at in [l.center(), pos2(f.right() + 8.0, l.center().y), l.center() + vec2(0.0, 10.0)] { + drag(&mut h, at, 12.0, Modifiers::NONE); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + assert!(h.state().session.journal.is_empty()); + } +} + +#[test] +fn options_field_outer_top_and_bottom_margins_scrub() { + for top in [true, false] { + let mut h = harness(); + let (_, f) = geometry(&h); + let y = if top { f.top() - 0.5 } else { f.bottom() + 0.5 }; + drag(&mut h, pos2(f.center().x, y), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "field margin top={top}"); + assert_eq!(h.state().session.journal.len(), 1); + } +} + +#[test] +fn options_gesture_keeps_its_start_owner_when_crossing_regions() { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, pos2(l.right() - 2.0, l.center().y), 24.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.74).abs() < 0.0001); + assert_eq!(h.state().session.journal.len(), 1); + let mut h = harness(); + let (_, f) = geometry(&h); + drag(&mut h, pos2(f.left() + 2.0, f.center().y), -30.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.35).abs() < 0.0001); + assert_eq!(h.state().session.journal.len(), 1); +} + +#[test] +fn options_tab_moves_from_hardness_to_opacity_without_a_caption_stop() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + h.key_press(egui::Key::Tab); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + assert!((h.state().session.tools.brush.opacity - 0.99).abs() < 0.0001); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 470a72bfe0..769f5a8b9a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1561,8 +1561,8 @@ fn body_text_width(ui: &egui::Ui, s: &str) -> f32 { fn opt_label(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); let text = if t.pro && !s.ends_with(':') { format!("{s}:") } else { s.to_string() }; - let label = ui.label(RichText::new(tl!(&text)).color(t.text_dim)); - widgets::scrub_label(ui, &label); + let label = ui.add(egui::Label::new(RichText::new(tl!(&text)).color(t.text_dim)).selectable(false)); + widgets::scrub_option_label(ui, &label); } fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b9b0336709..c6e62ecaa1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -384,6 +384,8 @@ struct LabelScrub { rect: Rect, /// How far right of the label the field may start (the whole row for a slider row). max_gap: f32, + /// Options captions defer interaction until the adjacent field bounds are known. + option_area: bool, /// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast). steps: f32, started: bool, @@ -402,9 +404,35 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { scrub_label_within(ui, label, 24.0) } +/// A tool-options caption whose scrub target includes the adjacent field's outer box. +/// Keep the original label bounds for linking; register the hit area before the numeric editor. +pub fn scrub_option_label(ui: &Ui, label: &Response) { + let scrub = LabelScrub { + pass: ui.ctx().cumulative_pass_nr(), + label: label.id.with("pc-option-scrub"), + rect: label.rect, + max_gap: 24.0, + option_area: true, + steps: 0.0, + started: false, + dragging: false, + stopped: false, + }; + ui.data_mut(|d| d.insert_temp(label_scrub_id(), scrub)); +} + fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { // Drag only, not focusable: Tab goes to the value field, not to its label as well. let resp = label.interact(Sense::DRAG); + let scrub = label_scrub_response(ui, &resp, label.rect, max_gap); + ui.data_mut(|d| { + d.insert_temp(label_scrub_id(), scrub); + d.insert_temp(resp.id.with("pc-scrub-linked"), false); + }); + resp +} + +fn label_scrub_response(ui: &Ui, resp: &Response, original_rect: Rect, max_gap: f32) -> LabelScrub { // Whether a field took this label's drags last frame: only then is it scrubby. let linked = ui.data(|d| d.get_temp::(resp.id.with("pc-scrub-linked"))).unwrap_or(false); if linked && resp.enabled() && (resp.hovered() || resp.dragged()) { @@ -434,21 +462,17 @@ fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { steps = acc.trunc(); ui.data_mut(|d| d.insert_temp(acc_id, acc - steps)); } - let scrub = LabelScrub { + LabelScrub { pass: ui.ctx().cumulative_pass_nr(), label: resp.id, - rect: resp.rect, + rect: original_rect, max_gap, + option_area: false, steps, started: resp.drag_started(), dragging: resp.dragged(), stopped: resp.drag_stopped(), - }; - ui.data_mut(|d| { - d.insert_temp(label_scrub_id(), scrub); - d.insert_temp(resp.id.with("pc-scrub-linked"), false); - }); - resp + } } /// The label drag left for the field at `rect` this frame, if its label is just left of it on @@ -505,7 +529,17 @@ fn value_field_box( let FieldOpts { decimals, units, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); - let scrub = take_label_scrub(ui, rect); + let scrub = take_label_scrub(ui, rect).map(|s| { + if !s.option_area { + return s; + } + // A direct click+drag target avoids egui snapping the padding/suffix to the nearby + // numeric button. It is not focusable; the editor drawn next wins inside its bounds. + // Preserve a popup field's trailing arrow, and never extend outside the UI clip. + let hit = Rect::from_min_max(pos2(s.rect.left(), rect.top()), pos2(rect.right() - trailing, rect.bottom())).intersect(ui.clip_rect()); + let resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + label_scrub_response(ui, &resp, s.rect, s.max_gap) + }); let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0)); // Typed text loses its spaces before parsing, so a " pt" suffix reads as "pt". let typed_suffix = suffix.trim(); From 98fc6ab0722465ad6167946b389333e7fc01a4db Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:06:35 +0400 Subject: [PATCH 30/90] fix(cms): write the product name as PhotoCraft in built-in profile descriptions (#5614) --- crates/cms/README.md | 6 +++--- .../cms/profiles/photocraft-coated-cmyk.icc | Bin 236844 -> 236844 bytes crates/cms/src/builtin.rs | 20 +++++++++--------- crates/cms/src/synth.rs | 2 +- crates/ui-egui/src/chrome_ui.rs | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/cms/README.md b/crates/cms/README.md index 6ae6a76cfb..c48d5366fd 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -1,6 +1,6 @@ # photocraft-cms -Pure-Rust ICC colour management for Photocraft. No workspace dependencies (L0, publishable), +Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable), no C, no `unsafe`. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, CIE 15, and Adobe's published black point compensation paper. @@ -53,13 +53,13 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou | `rec2020` | Rec. 2020 primaries, Rec. 709 OETF | | `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) | | `lab-d50` | Lab identity (v4 encoding) | -| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK | +| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK | ### The CMYK profile Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry -redistribution conditions or unclear licences, so Photocraft generates its own +redistribution conditions or unclear licences, so PhotoCraft generates its own (`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY, 17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink diff --git a/crates/cms/profiles/photocraft-coated-cmyk.icc b/crates/cms/profiles/photocraft-coated-cmyk.icc index 0d2d1da16da0eb612c735a6f8d589a1907e7a16c..b553fe9cecae7da389930d865073f6e3d40c2331 100644 GIT binary patch delta 48 zcmV-00MGxdx(=+m4zL#i0Yifr0k;?d0RdwHthX9u0pVN$s<-^W0r3n0xQFh|0k`hW G0zS{DO%s0r delta 48 zcmZ3pi*L;?z767xjLFRsjN2s`fegmA+oe(%AI31Q-2UeP<3}FGo$c?=GH!o&mdWls E0IVPrpa1{> diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index cd82883a50..8f3dc88a15 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -83,15 +83,15 @@ impl Builtin { match self { Builtin::Srgb => "sRGB IEC61966-2.1", Builtin::DisplayP3 => "Display P3", - Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)", - Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)", - Builtin::LinearSrgb => "Linear sRGB (Photocraft)", - Builtin::AcesCg => "ACEScg (AP1, linear, Photocraft)", - Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)", - Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)", - Builtin::SGray => "sGray (sRGB tone curve, Photocraft)", - Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)", - Builtin::LabD50 => "Lab D50 identity (Photocraft)", + Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)", + Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)", + Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)", + Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)", + Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)", + Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)", + Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)", + Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)", + Builtin::LabD50 => "Lab D50 identity (PhotoCraft)", Builtin::CoatedCmyk => crate::synth::DESCRIPTION, } } @@ -146,7 +146,7 @@ pub fn srgb() -> &'static Profile { /// only assigned to such files, never offered as a working space. pub fn linear_gray() -> &'static Profile { static PROFILE: OnceLock = OnceLock::new(); - PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0))) + PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0))) } /// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290). diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index 657c829f31..96a23dbfce 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -29,7 +29,7 @@ use crate::math; use crate::pipeline::Stage; use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass}; -pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; +pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; /// Parameters of the synthetic profile. #[derive(Clone, Debug)] diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index b20e913a7d..bbdce8fa78 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -339,7 +339,7 @@ mod tests { let mut gray = doc(); gray.mode = photocraft_doc::ColorMode::Grayscale; gray.icc_profile = Some(photocraft_engine::color_cmds::working_profile(gray.mode).to_bytes()); - assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"); + assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)"); let mut custom = (*photocraft_engine::color_cmds::working_profile(photocraft_doc::ColorMode::Cmyk)).clone(); custom.description = "PhotoCraft Studio CMYK".into(); From 7bb10a77591c14bc426155594b904e2b45ab5159 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 16:11:18 -0700 Subject: [PATCH 31/90] style: rustfmt five files on main (fmt break from #5448, #5670, #5135) (#5873) Formatting only; cargo fmt --all -- --check passes again. --- crates/engine/src/channel_cmds.rs | 11 +++-------- crates/engine/src/video_cmds.rs | 9 ++------- crates/ui-egui/src/adjust_dialog.rs | 10 ++-------- crates/ui-egui/src/tone.rs | 19 ++++++++++++------- crates/ui-egui/src/widgets.rs | 10 +++++++++- 5 files changed, 28 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 6dac6f2a69..468db2698b 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -242,14 +242,9 @@ pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Opt translate: false, gray: Some(GraySurface::Alpha(i)), }), - ChannelTarget::Composite if doc.quick_mask.is_some() => Some(IsolatedCurveChannel { - name: "Quick Mask".into(), - key: "", - hist: 0, - tint: false, - translate: true, - gray: Some(GraySurface::QuickMask), - }), + ChannelTarget::Composite if doc.quick_mask.is_some() => { + Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) }) + } ChannelTarget::LayerMask => layer_mask_curve_channel(st), ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st), _ => None, diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index dcb184c675..6fb9ab4574 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -422,10 +422,7 @@ pub fn render_video_with( let frames = base.timeline.as_ref().map_or(1, |t| t.duration).max(1); // Loaded timelines bypass the duration validation used by timeline.create/setProps. if frames > crate::timeline_cmds::MAX_DURATION { - return Err(EngineError::Other(format!( - "the timeline is {frames} frames; Render Video is limited to {} frames", - crate::timeline_cmds::MAX_DURATION - ))); + return Err(EngineError::Other(format!("the timeline is {frames} frames; Render Video is limited to {} frames", crate::timeline_cmds::MAX_DURATION))); } let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0); let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); @@ -443,9 +440,7 @@ pub fn render_video_with( // The whole canvas is staged as f32 RGBA for each frame; reject over-budget // dimensions before an infallible allocation can abort the process. if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS { - return Err(EngineError::Other(format!( - "{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)" - ))); + return Err(EngineError::Other(format!("{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)"))); } let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16; let mut gframes = Vec::with_capacity(frames); diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index cabf9fbded..be9acde2e8 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -196,11 +196,7 @@ fn isolated(app: &PhotocraftApp) -> Option photocraft_doc::ColorMode { let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); - if kind == "curves" && isolated(app).is_some_and(|c| c.gray.is_some()) { - photocraft_doc::ColorMode::Grayscale - } else { - mode - } + if kind == "curves" && isolated(app).is_some_and(|c| c.gray.is_some()) { photocraft_doc::ColorMode::Grayscale } else { mode } } /// Dialog body: the kind's editor over the fields, then the Preview checkbox. @@ -874,8 +870,6 @@ mod tests { /// The Channel popup's selected value (its option labels are what it shows). fn popup_channels(h: &mut Harness<'static, PhotocraftApp>) -> Vec { - h.query_all_by_role(egui::accesskit::Role::ComboBox) - .filter_map(|n| n.value().map(|s| s.to_string())) - .collect() + h.query_all_by_role(egui::accesskit::Role::ComboBox).filter_map(|n| n.value().map(|s| s.to_string())).collect() } } diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index 5cd04d4f0b..ba17755bd9 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -76,11 +76,14 @@ fn isolate(layers: &mut [Layer], id: LayerId) -> bool { /// Histograms of `doc` as seen by `source`, in the channels of `space`. pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) -> Histograms { - let mut h = vec![[0u32; 256]; match space { - ToneSpace::Rgb => 4, - ToneSpace::Cmyk => 5, - ToneSpace::Lab => 3, - }]; + let mut h = vec![ + [0u32; 256]; + match space { + ToneSpace::Rgb => 4, + ToneSpace::Cmyk => 5, + ToneSpace::Lab => 3, + } + ]; match source { // The gray plane itself: its levels are what the curve edits, whatever the document's // colour mode (#5335). @@ -443,8 +446,10 @@ mod tests { let m = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb); assert!(m[0][0] > 0, "the hidden mask is black"); // Each plane keeps its own cache entry: switching targets recomputes. - assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb), - tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb)); + assert_ne!( + tag(HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb), + tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb) + ); assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb), tag(HistSource::Layer(layer), ToneSpace::Rgb)); } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c6e62ecaa1..93f9679702 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1358,7 +1358,15 @@ pub fn dropdown_wheel_hovered(ui: &mut Ui, id: &str, curre } #[allow(clippy::too_many_arguments)] -fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32, wheel: bool, translate: bool) -> (Vec, Option) { +fn dropdown_with( + ui: &mut Ui, + id: &str, + current: &mut T, + options: &[(T, &str)], + width: f32, + wheel: bool, + translate: bool, +) -> (Vec, Option) { let shown = |l: &str| if translate { tl!(l).to_string() } else { l.to_string() }; let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| shown(l)).unwrap_or_else(|| "—".into()); let (mut chosen, mut hovered) = (Vec::new(), None); From cb319fd742adb6fd33624e2c4aa938a0ffb1415f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:54:48 +0200 Subject: [PATCH 32/90] Document layerFilter in the ui_set MCP schema (#5776) Co-authored-by: FredApps --- crates/automation/src/server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c5a0c3b180..f223bc5109 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -325,7 +325,10 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). Other fields are an error. + /// active layer's storage tiles). layerFilter (an object patching 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. pub fields: Value, } 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 33/90] 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 414ae0b2c21efca8c42c8616a1b4db30057945fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bihacsy=20L=C3=A1szl=C3=B3?= <115165402+Skaywalker@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:45:29 +0200 Subject: [PATCH 34/90] i18n(hu): align six rows with the catalog conventions (#5434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three prompts used the formal verb form ("Adjon", "Válasszon", "kattintson"); the catalog addresses the user informally throughout. Two colour profile messages put an article before a placeholder ("A(z) „{name}”") and glued a suffix to one ("{mode}-profilja"). The correct form depends on the inserted value, so the placeholders now stand after a colon or in brackets. The Ask On Mismatch hint kept the English option name; it now names the Hungarian label of that option in Color Settings. --- crates/ui-egui/src/i18n/hu.tsv | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0404b429ce..54bde5d0c4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1194,7 +1194,7 @@ Capture Brush Size in Preset Ecsetméret rögzítése az előbeállításban Include Tool Settings Eszközbeállítások belefoglalása Include Color Szín belefoglalása - Enter a name for the brush preset Adjon nevet az ecset-előbeállításnak + Enter a name for the brush preset Adj nevet az ecset-előbeállításnak A brush preset with this name already exists Már van ilyen nevű ecset-előbeállítás New Crop Preset… Új körbevágási előbeállítás… New Tool Preset Új eszköz-előbeállítás @@ -2490,8 +2490,8 @@ navigator Zoom Nagyítás mértéke Camera Raw Filter ({layer}) Camera Raw szűrő ({layer}) Camera Raw isn't open A Camera Raw nincs megnyitva Cancelled opening {name} Megnyitás megszakítva: {name} - The document “{name}” does not have an embedded {mode} profile. A(z) „{name}” dokumentumnak nincs beágyazott {mode}-profilja. - The document “{name}” has an embedded color profile that does not match the current {mode} working space. A(z) „{name}” dokumentum beágyazott színprofilja nem egyezik az aktuális {mode}-munkaszíntérrel. + The document “{name}” does not have an embedded {mode} profile. Nincs beágyazott profil ({mode}) ebben a dokumentumban: „{name}” + The document “{name}” has an embedded color profile that does not match the current {mode} working space. A dokumentum beágyazott színprofilja nem egyezik az aktuális munkaszíntérrel ({mode}): „{name}” Working: {working} Munkaszíntér: {working} Cancelled {label} Megszakítva: {label} Clarity Tisztaság @@ -4018,7 +4018,7 @@ cameraRaw Yellow Sárga The layer or its mask changed. Drag the mask onto the Trash again. A réteg vagy a maszkja megváltozott. Húzd újra a maszkot a Kukába. 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 + Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra Response curve Válaszgörbe Show home recent files Legutóbbi fájlok megjelenítése a kezdőképernyőn Clear Brush Controls Ecsetvezérlők törlése @@ -4051,7 +4051,7 @@ cameraRaw Yellow Sárga Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás Gradient Preset Options Színátmenet-előbeállítás beállításai Import .GRD .GRD importálása - Choose a gradient file (.grd). Válasszon színátmenetfájlt (.grd). + Choose a gradient file (.grd). Válassz színátmenetfájlt (.grd). Save Settings… Beállítások mentése… Load Settings… Beállítások betöltése… Save these settings as a Camera Raw .xmp file A beállítások mentése Camera Raw .xmp-fájlként @@ -4078,7 +4078,7 @@ cameraRaw Yellow Sárga Use the embedded profile ({profile}) A beágyazott profil használata ({profile}) Convert to the working space ({profile}) Átalakítás a munkaszíntérbe ({profile}) Remember this choice for future images A választás megjegyzése a későbbi képekhez - Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. A kérdés a Szerkesztés › Színbeállítások Ask On Mismatch beállításával kapcsolható vissza. + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. A kérdést a Szerkesztés › Színbeállítások „Profileltérés: rákérdezés megnyitáskor” beállításával kapcsolhatod vissza. Button Mode Gomb mód Button Mode On Gomb mód be From 2b934aa53ae35935203c3475a826a32903e08ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stevan=20Marjanovi=C4=87?= Date: Thu, 15 Oct 2026 04:45:34 +0200 Subject: [PATCH 35/90] UI: centre text vertically in shared text fields (#5550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UI: centre text vertically in shared text fields TextEdit draws its line at the top of its rect, and the theme's 24 pt interact height made the edit taller than the field's inside, so text in widgets::text_field sat a few pixels high (Preferences search, hex fields, name fields) while dropdowns and value fields were centred. Fill the box and centre the line in it. * Fixed failing test --------- Co-authored-by: Stevan Marjanović <24486184+stevanmarjanovic@users.noreply.github.com> --- crates/ui-egui/src/widgets.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 93f9679702..9f2521a865 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -759,14 +759,20 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) }; - let mut edit = egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font); + let inner = rect.shrink2(TEXT_FIELD_PAD); + // TextEdit draws its text at the top of its rect; fill the box and centre the line in it, so + // the text sits on the field's middle like a dropdown's or value field's. + let mut edit = + egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font).min_size(inner.size()).vertical_align(egui::Align::Center); if let Some(limit) = char_limit { edit = edit.char_limit(limit); } // A child Ui keeps the field's selection colour local: `Visuals::selection` also fills selected // list rows and slider trails, which must keep the theme's. let layout = egui::Layout::centered_and_justified(egui::Direction::TopDown); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(TEXT_FIELD_PAD)).layout(layout)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(layout)); + // The theme's 24 pt interact height would make the edit taller than the box's inside. + child.spacing_mut().interact_size.y = inner.height(); child.style_mut().visuals.selection.bg_fill = t.field_selection(); child.add(edit) } From 88c7286d99847e8292c75e2471c75c72a01a90ac Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:45:39 +0300 Subject: [PATCH 36/90] fix(ui): the Move tool keeps Photoshop's arrow pointer (#5814) The canvas cursor match mapped Tool::Move to CursorIcon::Move, the 4-way move cursor, while Photoshop shows its standard arrow pointer and marks the movable layer with the hover outline. Use CursorIcon::Default for the tool (#5646). --- crates/ui-egui/src/canvas.rs | 4 +++- crates/ui-egui/src/marquee_tests.rs | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 37415bc0c1..d9414d857f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3991,7 +3991,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { egui::CursorIcon::Crosshair } - Tool::Move => egui::CursorIcon::Move, + // Photoshop's Move tool keeps the standard arrow pointer (#5646): the movable + // layer under it is marked by the hover outline, not by a different cursor. + Tool::Move => egui::CursorIcon::Default, // Dragging is handled with the pan itself, above. Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false), Tool::RotateView => crate::rotate_view::cursor(response.dragged()), diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 40e9bbd392..169d4588b9 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -546,6 +546,15 @@ fn mouse_drag_inside_the_selection_moves_it() { assert_eq!(selection(&h), Rect::new(120, 90, 220, 170), "moved by (20, 10)"); } +/// #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] +fn the_move_tool_keeps_the_arrow_pointer() { + let mut h = harness(Tool::Move); + move_to(&mut h, 200.0, 150.0); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); +} + /// #1428: hovering inside the ants with a marquee shows the move cursor (a press there drags the /// outline); outside it is the marquee's own cursor. #[test] From 9b59a8ca6e0b687450392b290f53b26b23fa878c Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:45:45 +0700 Subject: [PATCH 37/90] fix(pen): keep unfinished paths in their original document (#5460) * fix(pen): finish draft before creating a document * test(pen): keep open work path in its source document --- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/src/pen_draw_tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5929e93385..32a3962343 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1086,6 +1086,10 @@ impl PhotocraftApp { id, "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" ) && on_active_layer; + // A draft Pen path belongs to the current document, not the one about to be created. + if id == "file.new" && self.ui.pen.is_some() { + vector_ui::pen_commit(self, false); + } let r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 74689a1070..d8bb39d82f 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -122,6 +122,23 @@ fn alt_breaks_the_handle_being_dragged_and_grabs_the_last_outgoing_handle() { assert!(near([k.out_ctrl.x, k.out_ctrl.y], [140.0, 20.0])); } +#[test] +fn opening_a_new_document_finishes_the_previous_pen_path() { + let mut h = harness(); + click(&mut h, [20.0, 20.0]); + click(&mut h, [120.0, 20.0]); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + let original = h.state().session.active().unwrap().doc.id; + + h.state_mut().run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert!(h.state().ui.pen.is_none(), "the Pen draft must not overlay the new document"); + let docs = h.state().session.documents(); + assert_eq!(docs.len(), 2); + assert_eq!(docs[0].doc.id, original); + assert_eq!(docs[0].doc.work_path.as_ref().unwrap().subpaths[0].knots.len(), 2); + assert!(docs[1].doc.work_path.is_none(), "the new document starts with no path"); +} + #[test] fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { let mut h = harness(); From f4351cb37cfdb1fc0350d6550009934b2310308d Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:45:59 -0400 Subject: [PATCH 38/90] Drag and drop: PhotoCraft comes to the front after a drop (#5687) (#5894) Files dropped from another app's window (a file manager) were imported, but the source window stayed in front of PhotoCraft. The frame that takes a drop now asks for focus, so PhotoCraft comes forward as Photoshop does. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/file_open/tests.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +++ 2 files changed, 26 insertions(+) diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index bdefab6229..b1eb1e7b72 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -572,6 +572,29 @@ fn a_drop_slot_counts_documents_not_the_tabs_shown() { assert_eq!(crate::canvas::TabStrip { rect: strip.rect, tabs: vec![] }.slot(10.0), 0); } +/// #5687: files dropped from another app's window bring PhotoCraft to the front (a focus request +/// in the frame that takes the drop), so the source window ends up behind it. +#[test] +fn a_drop_brings_the_window_to_the_front() { + let dir = std::env::temp_dir().join(format!("photocraft-drop-focus-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("photo.png"); + std::fs::write(&file, png_bytes(8, 8)).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).with_max_steps(8).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()) + }); + h.run_steps(2); + let focus = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Focus)) + }; + assert!(!focus(&h), "no focus request without a drop"); + h.input_mut().dropped_files.push(dropped(file, Err("read from the path".into()))); + h.step(); + assert!(focus(&h), "the drop brings the window forward"); + let _ = std::fs::remove_dir_all(&dir); +} + /// The whole app (eframe harness, real layout): over the tab strip, the pointer read from the OS /// service picks a slot; files dropped there open (in the background) at it; dropped on the canvas they're placed in Free Transform, and Esc takes the place back. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 32a3962343..6f4346e06c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1839,6 +1839,9 @@ impl eframe::App for PhotocraftApp { if !dropped.is_empty() { let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); self.open_dropped(ctx, dropped, at); + // Files dragged in from another app's window: PhotoCraft comes to the front and takes + // the keyboard, as Photoshop does, instead of staying behind the source (#5687). + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } self.place_next_dropped(ctx); // The control transport wakes the UI on arrival (ctx.request_repaint); only poll while a From 2fbd171427b2fe3c1908552d239822e1d9f4172f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Thu, 15 Oct 2026 04:46:04 +0200 Subject: [PATCH 39/90] Options bar: Show Transform Controls in every theme (#5740) (#5802) The Studio, Adwaita and Breeze themes' Move tool options bar had only Auto-Select and a hint, so Show Transform Controls could not be turned on there and a click on a layer never showed its box and handles. The toggle is now in every theme, as Auto-Select is (#1275). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/move_auto_select_tests.rs | 26 ++++++++++++++++++++ crates/ui-egui/src/panels.rs | 3 +++ 2 files changed, 29 insertions(+) diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 0f01222a57..3d635922e3 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -152,6 +152,32 @@ fn every_theme_shows_the_auto_select_toggle() { } } +#[test] +fn every_theme_shows_the_transform_controls_toggle() { + // #5740: outside the Pro themes the Move tool's options bar had no Show Transform Controls, so + // a click on a layer never showed its box and handles there. + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + for theme in crate::theme::ThemeKind::ALL { + let (app, _, _) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let before = h.state().ui.tool_options.move_show_transform; + h.get_by_label("Show Transform Controls").click(); + h.run_steps(2); + assert_ne!(h.state().ui.tool_options.move_show_transform, before, "{theme:?}: the toggle flips Show Transform Controls"); + } +} + #[test] fn every_theme_shows_the_type_options() { // #1387: outside the Pro themes the Type tool's options bar was only the hint "Click to add diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 769f5a8b9a..d8b8e93cfb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1391,6 +1391,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Tool::Move => { // The Studio themes keep Photoshop's Auto-Select toggle too (#1275). widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:")); + // And Show Transform Controls (#5740): without it the box could not be + // turned on outside the Pro themes. + widgets::checkbox(ui, &mut app.ui.tool_options.move_show_transform, tl!("Show Transform Controls")); hint(ui, tl!("Drag to move the active layer")); } Tool::Eyedropper => crate::eyedropper_ui::options(app, ui), From 37f57f2e9c4edba7414eb0095330241999ef926a Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:46:09 +0200 Subject: [PATCH 40/90] Preserve Layers scroll per document (#5522) (#5540) * chore: reserve draft implementation for #5522 * fix(ui): retain each document's Layers scroll position (#5522) --- crates/ui-egui/src/layer_reveal_tests.rs | 29 ++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 3 ++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layer_reveal_tests.rs b/crates/ui-egui/src/layer_reveal_tests.rs index e2c65a3222..181e42dc17 100644 --- a/crates/ui-egui/src/layer_reveal_tests.rs +++ b/crates/ui-egui/src/layer_reveal_tests.rs @@ -132,3 +132,32 @@ fn reveal_survives_odd_documents() { h.state_mut().sync_views(); h.run_steps(3); } +#[test] +fn layers_scroll_position_survives_document_switches() { + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + let (app, inner, _, _, _) = tall_doc(); + let mut h = harness(app, theme); + h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap(); + h.run_steps(4); + let before = row(&h, "Deep inside").unwrap(); + assert!(in_view(&h, before)); + + // A short second document clamps its own scroll offset to zero. That must + // not erase the first document's position or trigger a selection change. + h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap(); + h.state_mut().sync_views(); + h.run_steps(4); + for _ in 0..3 { + h.state_mut().session.set_active(0); + h.state_mut().sync_views(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(inner)); + let restored = row(&h, "Deep inside").unwrap(); + assert!(in_view(&h, restored), "{theme:?}: restored bottom row: {restored:?}"); + assert!((restored.top() - before.top()).abs() < 0.5, "{theme:?}: restored exact scroll position"); + h.state_mut().session.set_active(1); + h.state_mut().sync_views(); + h.run_steps(4); + } + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d8b8e93cfb..fcdbe4bb2e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2312,7 +2312,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fill = ui.available_height() > footer + 60.0; let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY }; egui::ScrollArea::vertical() - .id_salt("layer-rows") + // Keep each document's position independent, even when tab indices change (#5522). + .id_salt(("layer-rows", doc.id.0)) .max_height(rows_h) .min_scrolled_height(if fill { rows_h } else { 0.0 }) .auto_shrink([false, !fill]) From be4cadd83ddb81a24a630c64ad4e8404c44c9186 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:46:15 -0400 Subject: [PATCH 41/90] Rulers: Alt-drag makes a guide of the other orientation (#5698) (#5885) As in Photoshop, dragging a guide out of the top ruler with Alt/Option held makes a vertical guide, and out of the left ruler a horizontal one; without Alt each ruler makes its own kind, as before. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/rulers.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 7f90860065..972d7c0346 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -431,6 +431,9 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); } if let Some(pos) = resp.interact_pointer_pos().filter(|_| salt != "ruler-corner" && (resp.dragged() || resp.drag_started())) { + // ⌥/Alt turns the guide the other way, as in Photoshop: a vertical guide out of the top + // ruler, a horizontal one out of the left (#5698), switching as Alt goes up and down. + let vertical = vertical != ui.input(|i| i.modifiers.alt); let d = xf.to_doc(pos); let along = if vertical { d[0] } else { d[1] }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; @@ -951,6 +954,35 @@ mod tests { } } + /// #5698: as in Photoshop, an Alt-drag out of the top ruler makes a vertical guide and one out + /// of the left ruler a horizontal guide; without Alt each ruler makes its own kind. + #[test] + fn alt_drags_a_guide_of_the_other_orientation_out_of_a_ruler() { + let full = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max); + let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let drag = |from: Pos2, modifiers: egui::Modifiers| { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), app); + let to = xf.to_screen(37.0, 90.0); + h.event_modifiers(egui::Event::PointerMoved(from), modifiers); + h.event_modifiers(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }, modifiers); + h.run_steps(1); + h.event_modifiers(egui::Event::PointerMoved(to), modifiers); + h.run_steps(1); + h.event_modifiers(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }, modifiers); + h.run_steps(2); + let g = &h.state().session.active().unwrap().doc.guides; + (g.vertical.clone(), g.horizontal.clone()) + }; + let (top, left) = (pos2(200.0, RULER / 2.0), pos2(RULER / 2.0, 150.0)); + assert_eq!(drag(top, egui::Modifiers::NONE), (vec![], vec![90.0]), "top ruler: horizontal"); + assert_eq!(drag(left, egui::Modifiers::NONE), (vec![37.0], vec![]), "left ruler: vertical"); + assert_eq!(drag(top, egui::Modifiers::ALT), (vec![37.0], vec![]), "Alt from the top ruler: vertical"); + assert_eq!(drag(left, egui::Modifiers::ALT), (vec![], vec![90.0]), "Alt from the left ruler: horizontal"); + } + /// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws /// (tenths of the labelled step, in the ruler unit); without Shift it lands where it's dropped. #[test] @@ -978,6 +1010,8 @@ mod tests { h.state().session.active().unwrap().doc.guides.vertical.clone() }; assert_eq!(drag_guide(egui::Modifiers::NONE), vec![37.0]); + // #5698: Alt turns it into a horizontal guide (at the drop's y, the document's centre). + assert_eq!(drag_guide(egui::Modifiers::ALT), Vec::::new(), "no vertical guide with Alt"); // Pixels at 100%: labels every 100 px, ticks every 10 px. assert_eq!(drag_guide(egui::Modifiers::SHIFT), vec![40.0]); From 3aaffdde681b17ed7653845c467e1e84b2fe4421 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:46:20 -0400 Subject: [PATCH 42/90] Adjustment layers: a live preview is never coarser than the screen (#5764) (#5778) Dragging a Curves point (or any control) in the Properties panel of an adjustment layer previews through `canvas::ensure_proxy_preview`, which picked its downscale factor from the document size alone (`proxy::preview_factor`): any document over 5 MP was drawn from a 2-5x reduced copy at every zoom, so at 100 % the canvas turned blocky until the mouse was released. Cap the factor by the view's zoom with the rule the Image > Adjustments dialogs already use (`adjust_preview::proxy_factor`: the proxy stays at least at screen resolution) when the wgpu compositor can draw the full-size document. At 50 % and closer the drag now shows the full-size composite; zoomed out, the proxy still saves the work. Documents the GPU compositor doesn't cover keep the old proxy (the full size would be a CPU composite per change). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 14 +++++++++++--- crates/ui-egui/src/proxy.rs | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d9414d857f..cb7227d6e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1924,14 +1924,22 @@ fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option /// If a live adjustment preview is active on a large document, composite it on the proxy and upload /// it under its own GPU key. Returns (factor, gpu key, dimensions) when the proxy should be drawn. -fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64, [u32; 2])> { +/// `zoom` is the view's (physical screen pixels per document pixel). +fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<(u32, u64, [u32; 2])> { let (layer, params) = app.live_adjust.clone()?; let (doc_id, revision, doc) = { let st = app.session.documents().get(idx)?; (st.doc.id, st.revision, st.doc.clone()) }; // Low-resolution previews off: k is 1 and the canvas shows `display_doc` (full size). - let k = crate::proxy::preview_factor(&doc, crate::proxy::reduced_previews(app)); + let mut k = crate::proxy::preview_factor(&doc, crate::proxy::reduced_previews(app)); + // Zoomed in, a proxy coarser than the screen showed the drag in k-pixel blocks (#5764): when + // the wgpu compositor draws the full-size document, use a proxy only as coarse as the screen + // (none at 50 % and closer). Otherwise the full size would be a CPU composite per change. + let fine = crate::proxy::view_factor(k, zoom); + if fine < k && app.gpu.as_ref().is_some_and(|g| g.supports(&doc)) { + k = fine; + } if k <= 1 { return None; } @@ -2962,7 +2970,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.gpu.is_some() && gpu_ok && let Some((k, key, preview_size)) = - ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx)) + ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx, view.zoom)) { on_gpu = true; app.navigator_gpu.remove(&doc.id); diff --git a/crates/ui-egui/src/proxy.rs b/crates/ui-egui/src/proxy.rs index dc30cc3148..a3a73c7542 100644 --- a/crates/ui-egui/src/proxy.rs +++ b/crates/ui-egui/src/proxy.rs @@ -27,6 +27,13 @@ pub fn preview_factor(doc: &Document, reduced: bool) -> u32 { if reduced { factor(doc) } else { 1 } } +/// A live preview's factor `k` capped for a view at `zoom` (physical screen pixels per document +/// pixel): never coarser than the screen ([`crate::adjust_preview::proxy_factor`]), so a view at +/// 100 % shows full resolution instead of k-pixel blocks while a control moves (#5764). +pub fn view_factor(k: u32, zoom: f32) -> u32 { + k.min(crate::adjust_preview::proxy_factor(zoom)).max(1) +} + pub use photocraft_compose::proxy::{downsample, proxy_document}; #[cfg(test)] @@ -48,6 +55,23 @@ mod tests { assert_eq!(preview_factor(&small, true), 1); } + /// #5764: dragging a Curves point on a large document showed the canvas in k-pixel blocks at + /// any zoom. The proxy may only be as coarse as the screen. + #[test] + fn view_factor_never_coarser_than_the_screen() { + let big = Document::new("b", Size::new(6016, 6016), ColorMode::Rgb, SampleType::U8); + let k = preview_factor(&big, true); + assert!(k > 2, "{k}"); + assert_eq!(view_factor(k, 1.0), 1, "100 %: full resolution"); + assert_eq!(view_factor(k, 2.0), 1, "zoomed in: full resolution"); + assert_eq!(view_factor(k, 0.6), 1); + assert_eq!(view_factor(k, 0.5), 2); + assert_eq!(view_factor(k, 0.1), k, "far out: the document's proxy"); + assert_eq!(view_factor(1, 0.1), 1, "small documents stay full size"); + assert_eq!(view_factor(k, f32::NAN), 1); + assert_eq!(view_factor(0, 0.1), 1); + } + #[test] fn proxy_keeps_structure_and_ids() { let doc = Document::with_background("d", Size::new(4000, 3000), ColorMode::Rgb, SampleType::U8, Color::WHITE); From b6cdb16caffa8095b8e9b61c4da97ac3f05a4aac Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 05:46:25 +0300 Subject: [PATCH 43/90] UI: unsaved-changes buttons show every key in one style (storytold/photocraft#5564) (#5603) German showed "Ja (Y)" next to "(N)ein". Keys now go inside the words only when every translated label starts with its key; otherwise all labels append it ("Ja (Y)", "Nein (N)", "Abbrechen (C)"). Co-authored-by: Cursor --- crates/ui-egui/src/discard_ui.rs | 50 ++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 58519899d0..30e5f47deb 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -265,7 +265,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { vec![(ButtonRole::Default, "Yes", Some(Key::Y), 84.0, Answer::Save), (ButtonRole::Alternate, "No", Some(Key::N), 84.0, Answer::Discard), cancel] }; 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: Vec = buttons.iter().map(|b| button_label(mac, b.1, b.2)).collect(); + 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 modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| { ui.set_max_width(420.0); @@ -361,17 +361,30 @@ enum Answer { } /// macOS keeps the plain button wording even though the keyboard shortcuts still work. +#[cfg(test)] fn button_label(mac: bool, label: &str, key: Option) -> String { - if mac { tl!(label).to_string() } else { key.map_or_else(|| tl!(label).to_string(), |k| mnemonic(label, k)) } + button_labels(mac, &[(label, key)]).into_iter().next().unwrap_or_default() } -/// "(S)ave": the key in parentheses, or appended ("Guardar (S)") when the translation doesn't start with it. -fn mnemonic(label: &str, key: Key) -> String { - let (label, k) = (tl!(label), key.name()); - match label.split_at_checked(1) { - Some((first, rest)) if first.eq_ignore_ascii_case(k) => format!("({first}){rest}"), - _ => format!("{label} ({k})"), - } +/// The labels of one dialog's buttons, each with its key in the same style: "(Y)es" when every +/// translation starts with its key, otherwise appended to all ("Ja (Y)", "Nein (N)") (#5564). +fn button_labels(mac: bool, buttons: &[(&str, Option)]) -> Vec { + let starts_with_key = |label: &str, k: Key| label.split_at_checked(1).is_some_and(|(first, _)| first.eq_ignore_ascii_case(k.name())); + let inline = buttons.iter().all(|&(label, key)| key.is_none_or(|k| starts_with_key(tl!(label), k))); + buttons + .iter() + .map(|&(label, key)| { + let text = tl!(label); + match key { + None => text.to_string(), + Some(_) if mac => text.to_string(), + Some(k) => match text.split_at_checked(1) { + Some((first, rest)) if inline => format!("({first}){rest}"), + _ => format!("{text} ({})", k.name()), + }, + } + }) + .collect() } #[cfg(test)] @@ -529,9 +542,9 @@ mod tests { #[test] fn mnemonic_labels_bracket_the_key() { - assert_eq!(mnemonic("Don't Save", Key::D), "(D)on't Save"); - assert_eq!(mnemonic("Guardar", Key::S), "Guardar (S)"); - assert_eq!(mnemonic("保存", Key::S), "保存 (S)"); + assert_eq!(button_label(false, "Don't Save", Some(Key::D)), "(D)on't Save"); + assert_eq!(button_label(false, "Guardar", Some(Key::S)), "Guardar (S)"); + assert_eq!(button_label(false, "保存", Some(Key::S)), "保存 (S)"); } type Prompted = egui_kittest::Harness<'static, PhotocraftApp>; @@ -668,6 +681,19 @@ mod tests { assert_eq!(button_label(false, "Cancel", Some(Key::C)), "(C)ancel"); } + /// German "Ja" doesn't start with Y, so every button appends its key rather than mixing + /// "Ja (Y)" with "(N)ein" (#5564). + #[test] + fn one_dialog_shows_every_key_in_the_same_style() { + let buttons = [("Yes", Some(Key::Y)), ("No", Some(Key::N)), ("Cancel", Some(Key::C))]; + assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]); + let german = crate::i18n::Lang::from_code("de").unwrap(); + crate::i18n::with_language(german, || { + assert_eq!(button_labels(false, &buttons), ["Ja (Y)", "Nein (N)", "Abbrechen (C)"]); + assert_eq!(button_labels(true, &buttons), ["Ja", "Nein", "Abbrechen"]); + }); + } + #[test] fn macos_asks_dont_save_cancel_save_with_the_default_last() { let mut h = prompt_on(egui::os::OperatingSystem::Mac); From 8e2c5ddb40ffe9780f55afbe75996a24d52eb33c Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:46:30 -0400 Subject: [PATCH 44/90] macOS: a bundled app keeps its own Dock icon (#5849) (#5879) The main window set a runtime PNG icon everywhere. On macOS that replaces the app bundle's PhotoCraft.icns in the Dock, so the system couldn't render it in its own style (Liquid Glass and its tinted and dark variants). The runtime icon is now skipped when running from an .app bundle; unbundled runs and the Windows and Linux builds still set it. Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/app_icon.rs | 35 +++++++++++++++++++++++++++++++++ apps/photocraft/src/main.rs | 6 ++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs index 90ce94ea04..54eea2d6b5 100644 --- a/apps/photocraft/src/app_icon.rs +++ b/apps/photocraft/src/app_icon.rs @@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData { } } +/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app +/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS +/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG +/// replaced it with a flat picture (#5849). `exe` is the running executable. +pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool { + !(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle)) +} + +/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`. +fn in_app_bundle(exe: &std::path::Path) -> bool { + let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); + let macos = exe.parent(); + let contents = macos.and_then(std::path::Path::parent); + let bundle = contents.and_then(std::path::Path::parent); + name(macos).as_deref() == Some("MacOS") + && name(contents).as_deref() == Some("Contents") + && name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app")) +} + #[cfg(target_os = "macos")] const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png"); #[cfg(not(target_os = "macos"))] @@ -45,6 +64,22 @@ mod tests { u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize } + /// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the + /// Linux and Windows builds) still set the runtime icon. + #[test] + fn only_a_macos_app_bundle_skips_the_runtime_icon() { + use std::path::Path; + assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))); + assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft"))); + assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft"))); + assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app"); + assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name"); + assert!(!in_app_bundle(Path::new("photocraft"))); + assert!(sets_runtime_icon(None)); + assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft")))); + assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos")); + } + #[test] fn window_icon_decodes() { let icon = window_icon(); diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 1739568159..de1155edbd 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -78,9 +78,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { /// different offset every launch (#419). A later launch restores the saved position instead /// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves. fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { + let viewport = egui::ViewportBuilder::default(); + // Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849). + let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport }; eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_icon(app_icon::window_icon()) + viewport: viewport .with_app_id(APP_ID) .with_title("PhotoCraft") .with_inner_size([1440.0, 900.0]) From 3887d8f738d21b6b4e431d50bcba2c9a59f9004f Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:46:36 -0400 Subject: [PATCH 45/90] Title bar: dialogs no longer stop the window dragging on macOS (#5826) (#5860) The overlay that keeps the title strip's free gap draggable under a dialog's modal layer (#4913) only ran with the custom title bar. macOS keeps the OS traffic lights over the app's own title strip (custom_titlebar is false), so with the Embedded Profile Mismatch prompt or any dialog open the window could not be moved. The overlay now runs whenever the strip is drawn. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 10 +++++++++- crates/ui-egui/src/titlebar.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f4346e06c..9c75ec3a36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1970,7 +1970,7 @@ impl eframe::App for PhotocraftApp { notices::show(self, &ctx); gpu_status::show_fallback(self, &ctx); kys_import::show_offer(self, &ctx); - if chrome && self.ui.panels.menu_bar && self.custom_titlebar && !self.ui.dialogs.is_empty() { + if self.titlebar_modal_drag(chrome) { titlebar::modal_drag_overlay(&ctx); } if self.custom_titlebar { @@ -2041,6 +2041,14 @@ fn read_dropped(_f: &dyn egui::DroppedFile) -> Result, String> { } impl PhotocraftApp { + /// Whether the title bar's free gap gets the drag overlay this frame: the app draws its title + /// strip (`chrome`: not hidden by the screen mode) and a dialog's modal layer covers it. Not + /// only with the custom title bar: on macOS the strip under the traffic lights is the window's + /// title bar too, and an open dialog (Embedded Profile Mismatch…) left it undraggable (#5826). + pub(crate) fn titlebar_modal_drag(&self, chrome: bool) -> bool { + chrome && self.ui.panels.menu_bar && !self.ui.dialogs.is_empty() + } + /// `params` aimed at the active layer's mask (`"target":"mask"`) when the Layers panel targets /// it and command `id` edits the target (adjustments, filters, fills) without naming one: /// ⌘I then inverts the mask, as in Photoshop (#780). A targeted alpha channel or Quick Mask diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index de10ca30f3..25b23ee0f6 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -538,6 +538,23 @@ mod tests { use egui::{Event, Pos2, RawInput, ViewportId}; use photocraft_engine::Session; + /// #5826: the drag overlay under a dialog doesn't depend on the custom title bar. On macOS + /// (OS traffic lights over the app's own title strip) a dialog left the window undraggable. + #[test] + fn a_dialog_keeps_the_title_strip_draggable_with_either_title_bar() { + for custom in [false, true] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.custom_titlebar = custom; + app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap(); + assert!(!app.titlebar_modal_drag(true), "no dialog, no overlay"); + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + assert!(app.titlebar_modal_drag(true), "custom title bar: {custom}"); + assert!(!app.titlebar_modal_drag(false), "chrome hidden (full screen): no title strip"); + app.ui.panels.menu_bar = false; + assert!(!app.titlebar_modal_drag(true), "no menu bar, no title strip"); + } + } + /// A headless window `width` pt wide showing the title bar and the resize zones. struct Win { ctx: egui::Context, From 7efebfa050ac87561755b44d0093c0fc540deab1 Mon Sep 17 00:00:00 2001 From: Juan Antonio Redondo <35599027+JuanAntonioRC@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:46:41 +0200 Subject: [PATCH 46/90] =?UTF-8?q?fix(path):=20Shift=20locks=20a=20Direct?= =?UTF-8?q?=20Selection=20anchor=20drag=20to=2045=C2=B0=20(#5475)=20(#5498?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging selected anchors with the Direct Selection tool ignored Shift, so lengthening a slanted bar also nudged it vertically. As in Photoshop, Shift now locks the anchor move to multiples of 45° from the press, in the live preview and in the committed path.moveAnchors. It works whether Shift is held from the press or added during the drag; Shift at the press still toggles the anchor's selection as before. Reuses stroke_constraint::snap45 (the Gradient tool's Shift lock). Fixes #5475 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/direct_select.rs | 10 ++++++++-- crates/ui-egui/src/direct_select_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index 26fba5dadc..b27d9ea466 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -94,6 +94,11 @@ pub enum Edit { } impl Edit { + /// The drag's end with ⇧ held: moving anchors locks to multiples of 45°, as in Photoshop (#5475). + fn constrain(&self, start: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { + if shift && matches!(self, Edit::Anchors(_)) { crate::stroke_constraint::snap45(start, p) } else { p } + } + /// The edit for a drag from `start` to `end`, as a command and its params (without target). fn command(&self, start: [f64; 2], end: [f64; 2]) -> (&'static str, Value) { let d = [end[0] - start[0], end[1] - start[1]]; @@ -238,7 +243,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let ds = &mut app.ui.direct_selection; match &mut ds.drag { Some(Drag::Edit { base, edit, start, end, preview, .. }) => { - *end = [x, y]; + *end = edit.constrain(*start, [x, y], mods.shift); *preview = edit.preview(base, *start, *end); } Some(Drag::Marquee { end, .. }) => *end = [x, y], @@ -248,7 +253,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Up { x, y } => match app.ui.direct_selection.drag.take() { Some(Drag::Edit { target, base, edit, start, .. }) => { - release(app, target, &base, &edit, start, [x, y]); + let end = edit.constrain(start, [x, y], mods.shift); + release(app, target, &base, &edit, start, end); app.ui.direct_selection.coalesce = None; true } diff --git a/crates/ui-egui/src/direct_select_tests.rs b/crates/ui-egui/src/direct_select_tests.rs index 0f3a5ba990..31a16bfcda 100644 --- a/crates/ui-egui/src/direct_select_tests.rs +++ b/crates/ui-egui/src/direct_select_tests.rs @@ -326,6 +326,28 @@ fn arrow_keys_nudge_the_selected_anchors() { assert_eq!(work(&app), before); } +/// #5475: ⇧ during an anchor drag locks it to 45°, whether it's held from the press or added later. +#[test] +fn shift_locks_an_anchor_drag_to_45_degrees() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 44.0, pressure: 1.0 }, Modifiers::SHIFT); + let Some(Drag::Edit { preview: Some(p), .. }) = &app.ui.direct_selection.drag else { panic!("dragging") }; + assert_eq!(p.subpaths[0].knots[0].anchor, Point::new(70.0, 40.0), "the preview is locked too"); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 47.0 }, Modifiers::SHIFT); + assert_eq!(anchor(&app, 0), Point::new(100.0, 40.0)); + + // Diagonal, and without ⇧ the drag stays free. + // (⇧ at the press would deselect the anchor, so it comes once the drag is under way.) + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 131.0, y: 69.0 }, Modifiers::SHIFT); + let a = anchor(&app, 0); + assert!((a.x - 130.0).abs() < 1e-9 && (a.y - 70.0).abs() < 1e-9, "{a:?}"); + drag(&mut app, [130.0, 70.0], [140.0, 73.0], Modifiers::NONE); + assert_eq!(anchor(&app, 0), Point::new(140.0, 73.0)); +} + #[test] fn the_curvature_pen_drags_an_anchor_of_the_active_path_without_a_modifier() { // As in Photoshop: with no path being drawn, pressing an anchor and dragging moves it. From fd28da56dae573df141edc25059e62788eef8304 Mon Sep 17 00:00:00 2001 From: safvanedge Date: Thu, 15 Oct 2026 08:16:46 +0530 Subject: [PATCH 47/90] Type: keep text selection highlight visible while Command/Ctrl is held (#5542) * Type: keep text selection highlight visible while Command/Ctrl is held * Type: match Photoshop 1px dark frame and handle styling for type transform * Merge main; keep the transform box and underline colours from the theme tokens The selection-highlight fix stands on its own; the hard-coded box and underline colours and the removed reference point were unrelated to it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: safvanedge <241307593+safvanedge@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/type_tool.rs | 20 ++++++++++---------- crates/ui-egui/src/type_tool_tests.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index bee49293b5..5641127489 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1014,20 +1014,20 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX } // Frame: paragraph text shows its box with handles; point text an underline per line. let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers)); - if crate::type_transform::draw(app, painter, xf, mods) { - return; - } + let has_transform = crate::type_transform::draw(app, painter, xf, mods); let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { - let c = [scr_t(x, y), scr_t(x + width, y), scr_t(x + width, y + height), scr_t(x, y + height)]; - painter.add(egui::Shape::closed_line(c.to_vec(), frame)); - let mids = [c[0].lerp(c[1], 0.5), c[1].lerp(c[2], 0.5), c[2].lerp(c[3], 0.5), c[3].lerp(c[0], 0.5)]; - for p in c.iter().chain(mids.iter()) { - let r = egui::Rect::from_center_size(*p, egui::vec2(7.0, 7.0)); - painter.rect_filled(r, 0.0, Color32::WHITE); - painter.rect_stroke(r, 0.0, frame, egui::StrokeKind::Inside); + if !has_transform { + let c = [scr_t(x, y), scr_t(x + width, y), scr_t(x + width, y + height), scr_t(x, y + height)]; + painter.add(egui::Shape::closed_line(c.to_vec(), frame)); + let mids = [c[0].lerp(c[1], 0.5), c[1].lerp(c[2], 0.5), c[2].lerp(c[3], 0.5), c[3].lerp(c[0], 0.5)]; + for p in c.iter().chain(mids.iter()) { + let r = egui::Rect::from_center_size(*p, egui::vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_stroke(r, 0.0, frame, egui::StrokeKind::Inside); + } } } _ => { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 0f5be9e2fa..725d2890fb 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -779,6 +779,23 @@ fn temporary_type_transform_visibility_and_cursors() { assert!(crate::type_transform::visible(h.state(), sticky)); } +#[test] +fn type_selection_highlight_visible_with_command_held() { + let (app, _id) = transform_app(false, false, Affine::IDENTITY); + let mut h = harness(1.0, app); + if let Some(ed) = h.state_mut().ui.text_edit.as_mut() { + ed.anchor = 0; + ed.caret = 5; + } + hold(&mut h, type_command()); + h.run(); + let accent = crate::theme::Tokens::get(&h.ctx).accent; + let expected_fill = egui::Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 110); + let has_selection_highlight = + h.output().shapes.iter().any(|clipped| if let egui::Shape::Path(path) = &clipped.shape { path.fill == expected_fill && path.closed } else { false }); + assert!(has_selection_highlight, "selection highlight must remain visible while Ctrl/Cmd is held"); +} + /// Real canvas input: layer-local scaling stays correct across orientation, zoom, HiDPI and flip. #[test] fn temporary_type_transform_scales_without_reflow_or_leaving_type() { From b8e48d6d4c23c7362d63318ca29a40b16f424e03 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:20 -0400 Subject: [PATCH 48/90] Filter dialogs: a Random button for seeds (#5710) (#5886) Clouds, Difference Clouds, Add Noise, Fibers and the other seeded filters exposed a seed number, but a new pattern meant typing a different one. A Random button beside every seed field now picks a fresh seed, which the live preview renders at once. "Random" gains its row in the zh-Hans, zh-Hant, ru, fr and id catalogs, which claim complete coverage. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/filter_dialog.rs | 36 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 6 files changed, 41 insertions(+) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 3da0735204..92b96f9141 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -822,6 +822,11 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.horizontal(|ui| { ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); crate::widgets::value_field(ui, &mut v, -30000.0..=30000.0, "", 80.0); + // A seed gets a fresh random value in one click (#5710): Clouds, Add Noise, + // Fibers… then render a new pattern, as each run of Photoshop's does. + if p.key == "seed" && ui.button(tl!("Random")).clicked() { + v = random_seed(ui.input(|i| i.time).to_bits(), v as i64) as f32; + } }); f.insert(p.key, json!(v.round() as i64)); } @@ -839,6 +844,15 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } +/// A new seed for a dialog's Random button: 0..=30000 (what the field takes), never `current`. +/// `entropy` varies per click (the input time); the process's random hasher keys mix it. +fn random_seed(entropy: u64, current: i64) -> i64 { + use std::hash::BuildHasher; + let h = std::collections::hash_map::RandomState::new().hash_one(entropy); + let seed = (h % 30_001) as i64; + if seed == current { (seed + 1) % 30_001 } else { seed } +} + /// User-facing params (strip the dialog's private `__` keys). pub fn params_of(f: &Map) -> Value { // A document picker left at "None" (-1) means "not given". @@ -1005,6 +1019,28 @@ impl FilterPreviewKey { mod tests { use super::*; + /// #5710: the seed field's Random button always gives a different seed the field can hold. + #[test] + fn random_seeds_stay_in_range_and_change() { + let mut seen = std::collections::HashSet::new(); + for i in 0..200u64 { + let s = random_seed(i, 7); + assert!((0..=30_000).contains(&s), "{s}"); + assert_ne!(s, 7, "a click always changes the seed"); + seen.insert(s); + } + assert!(seen.len() > 100, "seeds spread out: {}", seen.len()); + assert_ne!(random_seed(1, 30_000), 30_000); + // Every seed dialog shows the button: Clouds, Add Noise and the like carry a `seed` param. + let specs = photocraft_engine::command_specs(); + assert!( + specs + .iter() + .filter(|c| c.id.starts_with("filter.")) + .any(|c| parse_spec(c.params).iter().any(|p| p.key == "seed" && matches!(p.kind, Kind::Int { .. }))) + ); + } + /// Preset names such as "iPhone 14 Pro Max" (the artboard dialog's choices) are text, not /// camelCase keys: they used to read "I Phone 14 Pro Max". #[test] diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index db40d97abf..84a68be742 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3179,3 +3179,4 @@ curves Smooth Lisser # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés + Random Aléatoire diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 544ead05a4..eb5167c2e9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3166,3 +3166,4 @@ curves Smooth Haluskan # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih + Random Acak diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 862788630b..75c91a8836 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3144,3 +3144,4 @@ curves Smooth Сгладить # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие + Random Случайно diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2307739ed3..1ddd5fd848 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2790,3 +2790,4 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Random 随机 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 478489f8c7..b14d5ec8c6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2775,3 +2775,4 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Random 隨機 From f44d3b6f0601f79456d692e074e9f8462ba01a55 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:26 -0400 Subject: [PATCH 49/90] Brushes: dragging a preset to the list's edge scrolls the list (#5741) (#5862) A preset, group or folder dragged in the Brushes panel or the Brush Preset picker couldn't reach rows out of view: the list didn't scroll while dragging, so the drag stopped at the last visible row. Holding the drag at the list's top or bottom edge (or past it) now scrolls it, with the same ramp as the Layers panel's row drags. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/brushes_tab.rs | 9 ++++++ crates/ui-egui/src/brushes_tab_tests.rs | 37 +++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 709c91c151..3f45526e52 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -885,6 +885,15 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<& let show = (st.show_name, st.show_stroke, st.show_tip); let collapsed = &st.collapsed; egui::ScrollArea::vertical().id_salt(layout.id).max_height(layout.max_height).auto_shrink([false, true]).show(ui, |ui| { + // A preset, group or folder dragged to the list's top or bottom edge (or past it) scrolls + // the list, as the Layers panel's rows do, so it can be dropped on rows out of view (#5741). + let dragging = egui::DragAndDrop::has_payload_of_type::(ui.ctx()) && ui.input(|i| i.pointer.primary_down()); + let pointer = ui.ctx().pointer_interact_pos(); + let delta = crate::panels::layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ui.input(|i| i.stable_dt)); + if delta != 0.0 { + ui.scroll_with_delta(vec2(0.0, delta)); + ui.ctx().request_repaint(); + } ui.spacing_mut().item_spacing.y = 1.0; for (label, items) in grouped_presets(presets) { let items: Vec<&BrushPreset> = diff --git a/crates/ui-egui/src/brushes_tab_tests.rs b/crates/ui-egui/src/brushes_tab_tests.rs index 4672c062b4..0d0a8b790f 100644 --- a/crates/ui-egui/src/brushes_tab_tests.rs +++ b/crates/ui-egui/src/brushes_tab_tests.rs @@ -51,6 +51,43 @@ fn dragging_a_brush_shows_its_tip_preview() { } } +/// #5741: a brush dragged to the list's bottom edge (or below it) scrolls the list, so it can be +/// dropped on rows out of view; the list used to stay put, and the drag stopped at the last +/// visible row. +#[test] +fn dragging_a_brush_to_the_list_edge_scrolls_the_list() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.ui.panels.brush_settings = true; + app.ui.brush_tab = 1; + app.ui.brushes_panel.view = BrushesView::List; + app.ui.brushes_panel.collapsed.clear(); + let name = app.session.tools.presets[0].name.clone(); + let (w, h_px) = (1200.0, 520.0); + let mut h = Harness::builder().with_size(vec2(w, h_px)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + app + }); + h.run_steps(4); + let start = h.get_by_role_and_label(Role::Button, &name).rect(); + h.hover_at(start.center()); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(start.center() + vec2(0.0, 12.0))); + h.run_steps(2); + assert!(egui::DragAndDrop::has_payload_of_type::(&h.ctx), "the brush is being dragged"); + // Held at the bottom of the window, below the list's last visible row. + let bottom = egui::pos2(start.center().x, h_px - 2.0); + for _ in 0..12 { + h.event(egui::Event::PointerMoved(bottom)); + h.run_steps(1); + } + let moved = h.query_by_role_and_label(Role::Button, &name).map(|n| n.rect()); + assert!(moved.is_none_or(|r| r.top() < start.top() - 1.0), "the list scrolled down: {start:?} → {moved:?}"); + h.event(egui::Event::PointerButton { pos: bottom, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} + #[test] fn grid_columns_centre_whole_columns_past_the_indent() { // 6 × 44 + 5 × 3 = 279 of the 290 past a 4 pt indent: 5 spare on each side (rounded down). diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index fcdbe4bb2e..9fbb765a91 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2166,7 +2166,7 @@ fn blend_options(groups: bool, current: BlendMode) -> Vec<(BlendMode, &'static s /// list downward (reveals rows above) and negative upward (reveals rows below). /// The speed ramps with proximity to the edge and clamps to the maximum speed when /// dragged outside, using elapsed time instead of assuming a particular refresh rate. -fn layer_drag_edge_scroll(pointer: Option, viewport: Rect, dragging: bool, dt: f32) -> f32 { +pub(crate) fn layer_drag_edge_scroll(pointer: Option, viewport: Rect, dragging: bool, dt: f32) -> f32 { if !dragging || viewport.width() <= 0.0 || viewport.height() <= 0.0 || dt.is_nan() || dt <= 0.0 { return 0.0; } From f79f21931ec7e830060a2b1f5bd3906dd83cac85 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:48:32 +0300 Subject: [PATCH 50/90] fix(ui): smart filter adjustments open their own editor on double-click (#5812) An Image > Adjustments recorded on a smart object (Brightness/Contrast, Levels, Curves...) stores image.adjustments.. The Layers panel row called smart_ui::open_editor, which only knew filter_dialog and silently did nothing for those commands, so the filter could never be reopened. Route adjustments to adjust_dialog, seed it with the recorded params and let OK run layer.smartFilter.setParams (#5689). --- crates/ui-egui/src/smart_ui.rs | 49 +++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index deee9a7d17..7f49843c53 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -295,15 +295,29 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } return; } + // An adjustment recorded on the smart object (Brightness/Contrast, Levels…) re-opens its own + // Image › Adjustments editor, like the filters above. + if crate::adjust_dialog::has_dialog(&f.command) { + if let Some(id) = crate::adjust_dialog::open(app, &f.command) { + edit_filter(app, id, layer, index, &f.params); + } + return; + } if !crate::filter_dialog::has_dialog(&f.command) { return; } let Some(id) = crate::filter_dialog::open(app, &f.command) else { return }; + edit_filter(app, id, layer, index, &f.params); +} + +/// Hands the open dialog `id` the smart filter's recorded `params` and makes OK update that +/// filter (`layer.smartFilter.setParams`, see [`confirm_command`]) instead of adding another. +fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, params: &Value) { if let Some(d) = app.ui.dialog_mut(id) { // The live preview would stack a second copy of the filter on the smart object. d.fields.remove("__preview"); d.fields.insert("__smartFilter".into(), json!({"layer": layer, "index": index})); - if let Value::Object(p) = &f.params { + if let Value::Object(p) = params { for (k, v) in p { if d.fields.contains_key(k) { d.fields.insert(k.clone(), v.clone()); @@ -405,4 +419,37 @@ mod tests { assert!(drawn.contains(&(frame, t.field))); assert!(drawn.iter().all(|(_, c)| *c != t.icon)); } + + /// #5689: an Image › Adjustments recorded on a smart object (Brightness/Contrast…) opens its + /// own editor from the Smart Filters row, with the recorded settings, and OK updates that + /// filter instead of stacking another. + #[test] + fn an_adjustment_smart_filter_opens_its_editor_and_updates_it() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("image.adjustments.brightnessContrast", json!({"brightness": 40, "contrast": -10})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let sm = match &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("expected a smart object, got {other:?}"), + }; + assert_eq!(sm.smart_filters.len(), 1); + open_editor(&mut app, &egui::Context::default(), id, &sm, 0); + let d = app.ui.dialogs.last().expect("the adjustment editor opened"); + assert_eq!(d.fields.get("__adjust").and_then(Value::as_str), Some("brightnessContrast")); + assert_eq!(d.fields.get("brightness").and_then(Value::as_f64), Some(40.0), "the recorded settings"); + assert_eq!(d.fields.get("contrast").and_then(Value::as_f64), Some(-10.0)); + let dialog = d.id; + // OK updates the recorded filter (one history step), it does not add a second filter. + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let LayerContent::Smart(sm) = &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content else { + panic!("the layer stays a smart object"); + }; + assert_eq!(sm.smart_filters.len(), 1, "no filter was stacked"); + assert_eq!(sm.smart_filters[0].params.get("brightness").and_then(Value::as_f64), Some(40.0)); + assert_eq!(sm.smart_filters[0].params.get("contrast").and_then(Value::as_f64), Some(-10.0)); + } } From 3fd787a2b59417015fec3aa674406a3d21261774 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:37 -0400 Subject: [PATCH 51/90] Canvas: no scroll glide with Enable Animations off (#5721) (#5883) Interface > Enable Animations off already made scroll areas jump straight to their target, but the canvas still panned from egui's smoothed wheel delta, gliding for several frames after each notch. With the preference off a plain wheel scroll now moves the canvas by the whole notch in the frame it arrives and stops there. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 ++- crates/ui-egui/src/wheel_nav.rs | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cb7227d6e8..8e38d1d57e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3203,12 +3203,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx)); let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); let nav_hovered = response.hovered() || free_hover || over_bars; - let wheel = crate::wheel_nav::read( + // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). + let wheel = crate::wheel_nav::read_with( &ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, response.id.with("wheel"), nav_hovered, + app.session.prefs().interface.enable_animations, ); if nav_hovered { let pointer = ui.input(|i| i.pointer.hover_pos()); diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index 8917f9123c..bde5e71ae3 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -198,6 +198,13 @@ const SNAP_IDLE_TIMEOUT: f64 = 0.5; /// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving /// navigation may add raw input to the snap residual. pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option { + read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true) +} + +/// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's +/// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch +/// in the frame the wheel turns and stops there (#5721), as scroll areas already do. +pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool) -> Option { let notch = ctx.options(|o| o.input_options.line_scroll_speed); // --- Modifier tracking (always runs for all callers) --- @@ -316,6 +323,8 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, d.remove::(snap_time_id(owner)); }); } + // Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail). + let scroll = if smooth || alt || shift { scroll } else { raw }; classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, notch }) } } @@ -370,6 +379,42 @@ mod tests { (a - b).length() < 0.01 } + /// #5721: with smoothing off a wheel notch pans the whole notch in the frame it arrives and + /// nothing after; smoothed (the default) it glides, reaching the same total over later frames. + #[test] + fn unsmoothed_wheel_pans_at_once_without_a_glide() { + let pans = |smooth: bool| -> Vec { + let owner = Id::new("test-unsmoothed"); + let ctx = Context::default(); + configure(&ctx); + (0..30) + .map(|n| { + let events = if n == 0 { vec![wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)] } else { Vec::new() }; + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut pan = Vec2::ZERO; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth) { + pan = d; + } + }); + out.textures_delta.clear(); + pan + }) + .collect() + }; + let notch = { + let ctx = Context::default(); + configure(&ctx); + ctx.options(|o| o.input_options.line_scroll_speed) + }; + let direct = pans(false); + assert!(near(direct[0], Vec2::new(0.0, -notch)), "the whole notch at once: {:?}", direct[0]); + assert!(direct[1..].iter().all(|p| *p == Vec2::ZERO), "no glide after it"); + let smooth = pans(true); + assert!(smooth[0].y.abs() < notch - 0.5, "smoothed, the first frame moves only part of it: {:?}", smooth[0]); + assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows"); + } + #[test] fn plain_scroll_pans() { assert_eq!(classify(input(Vec2::new(3.0, -40.0), false)), Some(Wheel::Pan(Vec2::new(3.0, -40.0)))); From 49f878d91b51867fc506fd3e9c8ba5d53f416f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stevan=20Marjanovi=C4=87?= Date: Thu, 15 Oct 2026 05:48:43 +0200 Subject: [PATCH 52/90] Scaling around reference point no longer snaps center of image to reference point (#5510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stevan Marjanović --- crates/ui-egui/src/transform_tool.rs | 109 +++++++++++++-------------- 1 file changed, 51 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d35aac9040..13f92b1d5f 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1717,8 +1717,7 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods let Some(inv) = h.inverse() else { return }; let (u, v) = inv.apply(p[0], p[1]); let (pu, pv) = inv.apply(g.pivot0[0], g.pivot0[1]); - // Moving edges/corner in unit space: [u0, v0, u1, v1] starting as [0, 0, 1, 1]. - let mut r = [0.0f64, 0.0, 1.0, 1.0]; + // Which sides of the unit box move: (u = 0, u = 1) and (v = 0, v = 1). let (mu, mv) = match g.hit { Hit::Corner(0) => ((true, false), (true, false)), Hit::Corner(1) => ((false, true), (true, false)), @@ -1729,33 +1728,21 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods Hit::Edge(2) => ((false, false), (false, true)), _ => ((true, false), (false, false)), }; - if mu.0 { - r[0] = u; - } - if mu.1 { - r[2] = u; - } - if mv.0 { - r[1] = v; - } - if mv.1 { - r[3] = v; - } - // ⌥: symmetric about the reference point. - if mods.alt { - if mu.0 { - r[2] = 2.0 * pu - r[0]; + // Each axis scales by a factor `k` about an anchor: the opposite side, or with ⌥ the + // reference point, wherever it was set (#1329). The dragged handle follows the cursor. + let axis = |moving: (bool, bool), at: f64, pivot: f64| -> (f64, f64) { + if !moving.0 && !moving.1 { + return (0.5, 1.0); } - if mu.1 { - r[0] = 2.0 * pu - r[2]; - } - if mv.0 { - r[3] = 2.0 * pv - r[1]; - } - if mv.1 { - r[1] = 2.0 * pv - r[3]; - } - } + let handle = if moving.0 { 0.0 } else { 1.0 }; + let anchor = if mods.alt { pivot } else { 1.0 - handle }; + let d = handle - anchor; + // A reference point on the dragged side leaves nothing to scale against. + let k = if d.abs() > 1e-9 { (at - anchor) / d } else { 1.0 }; + (anchor, if k.is_finite() { k } else { 1.0 }) + }; + let (mut au, mut ku) = axis(mu, u, pu); + let (mut av, mut kv) = axis(mv, v, pv); // Corners and edges scale proportionally by default; ⇧ frees them (the legacy // preference swaps the two for corners and always stretches edges, `legacy_mods`). let corner = matches!(g.hit, Hit::Corner(_)); @@ -1765,41 +1752,16 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods // corners), so the opposite edge's handle stays put. Past the opposite edge only // the dragged axis flips. if mu.0 || mu.1 { - let k = (r[2] - r[0]).abs(); - let c = if mods.alt { pv } else { 0.5 }; - r[1] = c - k / 2.0; - r[3] = c + k / 2.0; + (av, kv) = (if mods.alt { pv } else { 0.5 }, ku.abs()); } else { - let k = (r[3] - r[1]).abs(); - let c = if mods.alt { pu } else { 0.5 }; - r[0] = c - k / 2.0; - r[2] = c + k / 2.0; + (au, ku) = (if mods.alt { pu } else { 0.5 }, kv.abs()); } } if corner && !mods.shift { - let (sx, sy) = (r[2] - r[0], r[3] - r[1]); - let k = if sx.abs() > sy.abs() { sx.abs() } else { sy.abs() }; - let (nx, ny) = (k * sx.signum(), k * sy.signum()); - let (ax, ay) = if mods.alt { (pu, pv) } else { (if mu.0 { r[2] } else { r[0] }, if mv.0 { r[3] } else { r[1] }) }; - let fx = if mods.alt { - 0.5 - } else if mu.0 { - 1.0 - } else { - 0.0 - }; - let fy = if mods.alt { - 0.5 - } else if mv.0 { - 1.0 - } else { - 0.0 - }; - r[0] = ax - nx * fx; - r[2] = r[0] + nx; - r[1] = ay - ny * fy; - r[3] = r[1] + ny; + let k = ku.abs().max(kv.abs()); + (ku, kv) = (k * ku.signum(), k * kv.signum()); } + let r = [au - au * ku, av - av * kv, au + (1.0 - au) * ku, av + (1.0 - av) * kv]; let map = |a: f64, b: f64| { let (x, y) = h.apply(a, b); [x, y] @@ -3755,6 +3717,37 @@ mod tests { assert!(close(s.quad, corners([-50.0, 0.0, 150.0, 50.0])), "{:?}", s.quad); } + /// A reference point moved off the centre (⌥-click) is the centre of ⌥-scaling too, not just + /// of rotation: it stays put and the box scales away from it. + #[test] + fn alt_scales_about_a_moved_reference_point() { + let alt = egui::Modifiers::ALT; + // Reference point on the top-left corner: the bottom-right corner to (200, 100) doubles + // the box, top-left fixed (not centred on the reference point). + let mut s = session(); + s.pivot = [0.0, 0.0]; + drag(&mut s, [100.0, 50.0], [200.0, 100.0], alt); + assert!(close(s.quad, corners([0.0, 0.0, 200.0, 100.0])), "{:?}", s.quad); + assert!(near(s.pivot, [0.0, 0.0]), "{:?}", s.pivot); + // Reference point at (25, 25): the right edge to x = 175 is 2× about x = 25, and + // proportionally 2× about y = 25 too. + let mut s = session(); + s.pivot = [25.0, 25.0]; + drag(&mut s, [100.0, 25.0], [175.0, 25.0], alt); + assert!(close(s.quad, corners([-25.0, -25.0, 175.0, 75.0])), "{:?}", s.quad); + // ⌥⇧: that edge's axis only. + let mut s = session(); + s.pivot = [25.0, 25.0]; + drag(&mut s, [100.0, 25.0], [175.0, 25.0], egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(close(s.quad, corners([-25.0, 0.0, 175.0, 50.0])), "{:?}", s.quad); + // Reference point in line with the dragged corner horizontally: that axis has nothing to + // scale against and stays, the other doubles about it. + let mut s = session(); + s.pivot = [100.0, 50.0]; + drag(&mut s, [100.0, 0.0], [150.0, -50.0], egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(close(s.quad, corners([0.0, -50.0, 100.0, 50.0])), "{:?}", s.quad); + } + fn near(a: [f64; 2], b: [f64; 2]) -> bool { (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6 } From 8d8db22c94f8f9058e62fb283c136049c91bfd4d Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 05:48:48 +0200 Subject: [PATCH 53/90] Type: a scaled type layer keeps its size when the Character fields show it (#5717) (#5718) egui's DragValue clamps an existing value to its range and reports a change, so showing a type layer scaled past 1296 pt (or below 0.1 pt) sent type.setStyle with the clamped size. Value fields now clamp only edits, never a value they merely show. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/type_tool_tests.rs | 20 ++++++++++++++++ crates/ui-egui/src/widgets.rs | 33 ++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 725d2890fb..f56829c6d1 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1545,6 +1545,26 @@ mod multi_layer_formatting { assert_eq!(text(h.state(), ids[1]).char_runs(), peer); } + /// #5717: a layer scaled so its shown size or leading is past a field's range (2400 pt, or + /// 0.08 pt) keeps it while the Character panel and the options bar only show it. + #[test] + fn showing_a_scaled_layer_past_the_size_range_does_not_change_it() { + for (layer, scale) in [(0, 200.0), (1, 0.005)] { + for properties in [true, false] { + let (mut app, ids) = fixture(); + let id = ids[layer]; + app.run("type.edit", json!({"layer": id.0, "transform": [scale, 0.0, 0.0, scale, 0.0, 0.0]})).unwrap(); + selected(&mut app, &[id]); + let before = text(&app, id).char_runs(); + let steps = app.session.active().unwrap().history.entries().len(); + let mut h = controls(app, properties); + h.run_steps(3); + assert_eq!(text(h.state(), id).char_runs(), before, "scale {scale}, properties {properties}"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps, "no history step"); + } + } + } + #[test] fn formatting_menus_isolate_the_edit_layer_and_range_but_honor_explicit_targets() { let (mut app, ids) = fixture(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9f2521a865..6e841292e7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -824,6 +824,9 @@ fn number_edit( let mut resp = ui.add( egui::DragValue::new(value) .range(range.clone()) + // Only edits are clamped (below): showing a value past the range, such as a type layer + // scaled to 2400 pt in a 1296 pt field, must not change it (#5717). + .clamp_existing_to_range(false) // A warp is an absolute-position jump. The scoped scrub below removes that jump. .speed(if wrapped_drag { 0.0 } else { speed }) .custom_formatter(move |v, _| match decimals { @@ -844,7 +847,12 @@ fn number_edit( }), ); if wrap_drag { - crate::scrub_wrap::update(ui, &resp, value, before, range, speed, wrapping); + crate::scrub_wrap::update(ui, &resp, value, before, range.clone(), speed, wrapping); + } + let (lo, hi) = (*range.start(), *range.end()); + // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. + if *value != before && lo <= hi { + *value = value.clamp(lo, hi); } resp.flags.set(egui::response::Flags::CHANGED, *value != before); resp @@ -2280,6 +2288,29 @@ mod tests { assert_eq!(*h.state(), (25.0, 3)); } + /// #5717: a value past the range shows as it is without reporting a change; an edit is clamped. + #[test] + fn value_field_keeps_a_shown_value_past_its_range_and_clamps_edits() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(300.0, 100.0)).build_ui_state( + |ui, (v, changes): &mut (f32, u32)| { + if super::value_field(ui, v, 0.1..=1296.0, "pt", 90.0).changed() { + *changes += 1; + } + }, + (2400.0, 0), + ); + h.run_steps(3); + assert_eq!(*h.state(), (2400.0, 0), "showing it changes nothing"); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("5000".into())); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().0, 1296.0, "a typed value is clamped"); + } + #[test] fn value_field_applies_typed_arithmetic() { use egui::Key::{Enter, Tab}; From cc9967654c0603adeabab61e082b7b2a63543049 Mon Sep 17 00:00:00 2001 From: safvanedge Date: Thu, 15 Oct 2026 09:18:55 +0530 Subject: [PATCH 54/90] fix(ui): restore previous tool when committing or canceling text edit (#5527) (#5528) * fix(ui): restore previous tool when committing or canceling text edit initiated via double-click * fix(ui): restore return tool only while on type and stay on type for layer thumbs - commit_with/cancel: restore return tool only while app.ui.tool.is_type() - edit_active: keep tool on Type when editing from layer thumbnail (matches Photoshop) - clear text_edit_return_tool when text_edit is dropped on lost text or stale transform - test tool switch mid-edit and edit_active remaining on Type --------- Co-authored-by: safvanedge <241307593+safvanedge@users.noreply.github.com> --- crates/ui-egui/src/state.rs | 4 ++ crates/ui-egui/src/type_tool.rs | 55 ++++++++++++++++++++++++++++ crates/ui-egui/src/type_transform.rs | 1 + 3 files changed, 60 insertions(+) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aeefb3ecbd..5cbda434e7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1073,6 +1073,9 @@ pub struct UiState { /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, + /// 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, /// Ctrl/Cmd Type-tool gesture, exposed to automation but never restored as a held pointer. #[serde(default, skip_deserializing)] pub type_transform: Option, @@ -1304,6 +1307,7 @@ impl Default for UiState { spring: None, recent_files: Vec::new(), text_edit: None, + text_edit_return_tool: None, type_transform: None, type_transform_pivot: None, cam_transform: None, diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 5641127489..d73bb2e2c4 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -121,6 +121,7 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { ed.caret = n; } let vertical = app.session.active().and_then(|st| text_layer(&st.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical); + app.ui.text_edit_return_tool = None; app.ui.tool = if vertical { crate::state::Tool::VerticalType } else { crate::state::Tool::Type }; app.ui.mask_target = false; app.ui.vector_mask_target = false; @@ -132,6 +133,7 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { /// type is there. pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { let Some(id) = hit_layer(app, x, y) else { return false }; + let previous_tool = app.ui.tool; commit(app); if app.session.select_layer(id).is_err() { return false; @@ -144,6 +146,9 @@ pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: false, resize: None, preedit: None }); let vertical = app.session.active().and_then(|st| text_layer(&st.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical); + if !previous_tool.is_type() { + app.ui.text_edit_return_tool = Some(previous_tool); + } app.ui.tool = if vertical { crate::state::Tool::VerticalType } else { crate::state::Tool::Type }; app.ui.mask_target = false; app.ui.vector_mask_target = false; @@ -271,6 +276,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie let off = hit_offset(app, id, x, y); app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: true, resize: None, preedit: None }); + app.ui.text_edit_return_tool = None; return true; } false @@ -335,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.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; } } @@ -773,6 +780,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { let id = LayerId(ed.layer); let Some(text) = current_text(app, id) else { app.ui.text_edit = None; + app.ui.text_edit_return_tool = None; return false; }; // Undo/redo can shorten the text under us. @@ -960,6 +968,11 @@ pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { } crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; + if let Some(tool) = app.ui.text_edit_return_tool.take() + && app.ui.tool.is_type() + { + app.ui.tool = tool; + } let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; if ed.mask { // Type Mask: the text's glyphs become (or combine with) the selection and the working @@ -2392,6 +2405,11 @@ fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { pub fn cancel(app: &mut PhotocraftApp) { crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; + if let Some(tool) = app.ui.text_edit_return_tool.take() + && app.ui.tool.is_type() + { + app.ui.tool = tool; + } undo_session(app, &ed); } @@ -2682,4 +2700,41 @@ mod tests { assert_eq!(word_boundary("hello big world", 7, false), 6); assert_eq!(word_boundary("hello big world", 15, false), 10); } + #[test] + fn double_click_from_move_tool_restores_move_tool_on_commit_and_cancel() { + let (mut app, _) = variants(); + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, Some(crate::state::Tool::Move)); + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Move, "commit restores the Move tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Also test cancel + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + cancel(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Move, "cancel restores the Move tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Switching tools mid-edit commits without overriding the newly chosen tool + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, Some(crate::state::Tool::Move)); + app.ui.tool = crate::state::Tool::Brush; + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Brush, "switching tools keeps the chosen tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Layers-thumbnail edit stays on Type tool (matches Photoshop) + app.ui.tool = crate::state::Tool::Move; + edit_active(&mut app).unwrap(); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, None); + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Type, "Layers-thumbnail edit stays on Type tool"); + } } diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index b724cfe321..0c1b4837ed 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -52,6 +52,7 @@ pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { cancel_drag(app); if lost_layer { app.ui.text_edit = None; + app.ui.text_edit_return_tool = None; } } } From f02c0cbef7048d859189f3a333b6bec95d8fca16 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 14:49:09 +1100 Subject: [PATCH 55/90] =?UTF-8?q?File=20=E2=80=BA=20Scripts=20=E2=80=BA=20?= =?UTF-8?q?Browse:=20reject=20Photoshop=20ExtendScript=20(#5406)=20(#5435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * File › Scripts › Browse rejects Photoshop ExtendScript. The picker listed .js/.jsx files that parse_script cannot run. Filter to JSON/text/droplet scripts and return a clear error for .js/.jsx paths. * File › Scripts › Browse keeps Photoshop extensions in the picker. Review feedback on #5435: filtering .js/.jsx out of the picker hid the rejection error from UI users. List Photoshop's script types (as #2452 asked) alongside PhotoCraft's so selecting one shows the ExtendScript error instead of hiding the file. --- crates/engine/src/automate_cmds.rs | 22 ++++++++++++++++++++++ crates/engine/src/automate_cmds/tests.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/file_dialog/tests.rs | 15 ++++++++++++++- crates/ui-egui/src/file_ui.rs | 8 +++++--- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 6a98ee3c4f..4c78f90b34 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -24,6 +24,22 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// Photoshop ExtendScript / UXP script suffixes — not PhotoCraft JSON or command lists. +fn extendscript_ext(path: &str) -> Option<&str> { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + let dot = name.rfind('.')?; + let ext = &name[dot + 1..]; + let lower = ext.to_ascii_lowercase(); + matches!(lower.as_str(), "js" | "jsx" | "jsxbin" | "psjs" | "ccjs").then_some(ext) +} + +fn reject_extendscript(cmd: &str, path: &str) -> Result<()> { + match extendscript_ext(path) { + Some(_) => Err(bad(cmd, "PhotoCraft scripts are JSON or command lists (one command.id {json} per line), not Photoshop ExtendScript (.js/.jsx)")), + None => Ok(()), + } +} + /// Session state of the File-menu commands (not saved, not history). #[derive(Clone, Debug, Default)] pub struct FileMenuState { @@ -127,6 +143,12 @@ fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec { /// File › Scripts › Browse…: run a script file on the session. fn browse(s: &mut Session, p: &Value) -> Result { let cmd = "file.scripts.browse"; + if let Some(path) = p.get("path").and_then(Value::as_str) { + reject_extendscript(cmd, path)?; + } + if let Some(name) = p.get("name").and_then(Value::as_str) { + reject_extendscript(cmd, name)?; + } let steps = match (p.get("path").and_then(Value::as_str), p.get("script").and_then(Value::as_str), p.get("steps")) { (Some(path), _, _) => parse_script(&String::from_utf8_lossy(&read_file(path)?))?, (_, Some(text), _) => parse_script(text)?, diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 67852534c3..06d0e88f36 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -54,6 +54,27 @@ fn browse_runs_a_script_file() { assert_eq!(r["results"].as_array().unwrap().len(), 1); } +#[test] +fn browse_rejects_photoshop_extendscript() { + let dir = tmp("jsx"); + let jsx = format!("{dir}/hello.jsx"); + std::fs::write(&jsx, "alert('hi');\n").unwrap(); + let mut s = Session::new(); + let err = s.execute("file.scripts.browse", json!({"path": jsx})).unwrap_err().to_string(); + assert!(err.contains("not Photoshop ExtendScript"), "{err}"); + assert!(err.contains("JSON"), "{err}"); + let js = format!("{dir}/hello.js"); + std::fs::write(&js, "alert('hi');\n").unwrap(); + assert!(s.execute("file.scripts.browse", json!({"path": js})).is_err()); + let err = s.execute("file.scripts.browse", json!({"name": "Resize.jsx", "script": "alert(1);"})).unwrap_err().to_string(); + assert!(err.contains(".js/.jsx"), "{err}"); + // A JSON script still runs even if the file name is not a Photoshop suffix. + let json = format!("{dir}/rotate.json"); + std::fs::write(&json, r#"[["file.new", {"width": 8, "height": 8}]]"#).unwrap(); + let r = s.execute("file.scripts.browse", json!({"path": json})).unwrap(); + assert_eq!(r["ok"], true); +} + #[test] fn recording_browse_script_captures_each_effect_once() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index dc21c670a6..2a325ea2ce 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -329,10 +329,23 @@ fn scripts_browse_lists_only_script_files() { assert!(matches!( open.borrow().first().map(|(r, _)| r), Some(FileDialogRequest::Open { multiple: false, extensions: Some(exts), .. }) - if exts.iter().map(String::as_str).collect::>() == ["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt"] + if exts.iter().map(String::as_str).collect::>() == ["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt", "pcdroplet"] )); } +#[test] +fn scripts_browse_extendscript_picks_report_the_error() { + // Photoshop scripts stay in the picker (matching its filter) and choosing one shows why it won't run. + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "file.scripts.browse", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("resize.jsx".into(), b"alert('hi');".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("not Photoshop ExtendScript"), "{}", app.ui.status); +} + #[test] fn last_used_dir_takes_the_most_recent_parent() { assert_eq!(last_used_dir(&[]), None); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index c3b8abc4d7..3f87f6339c 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -55,8 +55,10 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi json!({"dialog": app.ui.open_dialog(DialogKind::Command, f)}) } -/// File › Scripts › Browse… lists these script files (plus the JSON/text the engine parses). -const SCRIPT_EXTENSIONS: &[&str] = &["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt"]; +/// File › Scripts › Browse… lists Photoshop's script types (matching its filter, #2452) plus the +/// JSON/text/droplet files the engine parses; selecting a Photoshop script gets the engine's +/// "not Photoshop ExtendScript" error instead of hiding those files from the picker. +const SCRIPT_EXTENSIONS: &[&str] = &["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt", "pcdroplet"]; /// Menu items fronted here (dialogs before the engine command). `None` when not ours. pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { @@ -104,7 +106,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V "file.scripts.statistics" => json!({"dialog": crate::stack_dialog::open_statistics(app)}), "file.scripts.browse" => { return Some(app.pick_file_bytes_filtered(SCRIPT_EXTENSIONS, |app, name, bytes| { - let r = app.run("file.scripts.browse", json!({"script": String::from_utf8_lossy(&bytes)})); + let r = app.run("file.scripts.browse", json!({"name": name, "script": String::from_utf8_lossy(&bytes)})); if r.is_ok() { let template = "Ran script {name}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("name", &name)]); From e47086717a186b28ff5acd16dfb5761731d49044 Mon Sep 17 00:00:00 2001 From: envy-ai <177786885+envy-ai@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:49:15 -0400 Subject: [PATCH 56/90] Channels: load the targeted layer mask as selection (#5562) * Load the targeted layer mask from Channels footer * Fix Channels test setup after mask reveal --- crates/ui-egui/src/channels_panel.rs | 68 +++++++++++++++++++++++++++- docs/ui-design.md | 1 + 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index f232384139..7f175b6209 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -330,7 +330,11 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let load = icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")); if load.clicked() { - actions.push(("select.loadSelection".into(), json!({ "channel": target_ref }))); + // The selected mask row can coexist with a Composite engine target while mask + // painting is active. The footer follows the row the user sees as targeted. + let params = + if mask_targeted { json!({ "channel": "mask", "layer": masked.as_ref().map(|layer| layer.id.0) }) } else { json!({ "channel": target_ref }) }; + actions.push(("select.loadSelection".into(), params)); } actions.extend(drop(ui, &load, Footer::Load)); }); @@ -615,6 +619,68 @@ mod tests { assert!(h.state().session.active().unwrap().doc.selection.is_some(), "the white channel selects everything"); } + /// The footer follows the targeted row shown by the panel when mask painting is active, + /// even though the engine's channel-view target remains Composite. Other channel targets + /// continue to load their own values, and each load remains one undoable selection change. + #[test] + fn footer_loads_the_selected_layer_mask_and_preserves_color_alpha_targets() { + let (mut h, _) = channels_harness(); + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap(); + h.state_mut().run("layer.layerMask.hideAll", json!({})).unwrap(); + h.state_mut().run("layer.layerMask.revealSelection", json!({})).unwrap(); + h.state_mut().run("channel.target", json!({"channel": "composite"})).unwrap(); + h.state_mut().ui.mask_target = true; + h.run_steps(2); + + let click_load = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + use egui::{Modifiers, PointerButton}; + let p = at(h, "Load channel as selection"); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + let sample = + |h: &egui_kittest::Harness<'_, PhotocraftApp>, x, y| h.state().session.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(x, y, 0); + + assert_eq!(h.state().session.active().unwrap().channel_view.target, ChannelTarget::Composite); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0), "mask row selection wins over the Composite engine target"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + + h.state_mut().ui.mask_target = false; + h.state_mut().run("channel.target", json!({"channel": "composite"})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0), "composite luminosity remains available"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap(); + h.state_mut().run("channel.new", json!({"fill": "selection"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + h.state_mut().run("channel.target", json!({"channel": "red"})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0), "color channel still loads its own plane"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + + h.state_mut().run("channel.target", json!({"channel": 1})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0), "alpha channel still loads its own plane"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + } + /// A plain click on a row still targets it, and a drag released elsewhere does nothing. #[test] fn clicks_still_target_and_a_drop_elsewhere_does_nothing() { diff --git a/docs/ui-design.md b/docs/ui-design.md index c6135ff744..d4ac18d297 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -58,6 +58,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | +| Channels selection | `channels_panel.rs` | Load channel as selection in the footer follows the highlighted channel row. When the active layer mask is targeted for painting while the underlying channel view remains Composite, it loads that layer mask; otherwise it loads the selected composite, color, alpha or vector mask channel. | | Path thumbnails | `vector_ui.rs` | Ctrl/Cmd-click the thumbnail of a Work Path, saved path or layer path to load it as a selection; Shift adds, Alt subtracts, Shift+Alt intersects. Clicking the row name only selects the path. | | Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. | | Shape Stroke Options | `shape_stroke_ui.rs`, `vector_ui.rs` | Direct editing: valid changes apply immediately, with one numeric drag per undo step in Properties. Escape/outside click closes the popup and retains changes; a child dropdown keeps the parent open. The options bar sets new-shape defaults; Properties edits the selected shape. | From b404d505815792bed4259755c7891cb3f462a16b Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 05:49:20 +0200 Subject: [PATCH 57/90] Type: Swap and Default Colors recolour the selected text (#5690) (#5808) The toolbar's Switch Colors and Default Colors buttons ran their commands without type_tool::foreground_changed, so characters highlighted in the type layer being edited kept their colour. As in Photoshop, they now take the new foreground, like picking a colour in the Color or Swatches panel. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/panels.rs | 70 ++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9fbb765a91..f5b29d06d1 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -546,10 +546,18 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { } s.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Swap colours (X)"))); if s.on_hover_text(tl!("Swap colours (X)")).clicked() { - let _ = app.run("tools.swapColors", json!({})); + chip_command(app, "tools.swapColors"); } if d.is_some_and(|d| d.on_hover_text(tl!("Default colours (D)")).clicked()) { - let _ = app.run("tools.defaultColors", json!({})); + chip_command(app, "tools.defaultColors"); + } +} + +/// Run Switch Colors or Default Colors from the chips. As in Photoshop, characters highlighted +/// in the type layer being edited take the new foreground colour, like picking one (#5690). +fn chip_command(app: &mut PhotocraftApp, id: &str) { + if app.run(id, json!({})).is_ok() { + crate::type_tool::foreground_changed(app); } } @@ -4308,6 +4316,64 @@ mod color_tests { } } + /// While characters are highlighted in a type layer being edited, the Switch Colors and + /// Default Colors buttons recolour them with the new foreground (#5690); without a selection + /// they only change the tool colours, as before. + #[test] + fn swap_and_default_colors_recolour_selected_type() { + use egui_kittest::kittest::Queryable; + use photocraft_doc::LayerId; + let rgb = |app: &PhotocraftApp, id: LayerId, ci: usize| { + let doc = &app.session.active().unwrap().doc; + let photocraft_doc::LayerContent::Text(t) = &doc.layer(id).unwrap().content else { panic!("not a type layer") }; + let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b); + let mut at = 0; + for r in t.char_runs() { + if b < at + r.len { + return r.style.color.to_rgba8(); + } + at += r.len; + } + [0; 4] + }; + for selected in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let made = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 50, "y": 150, "color": "#000000"})).unwrap(); + let id = LayerId(made["layer"].as_u64().unwrap()); + app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + let anchor = if selected { 6 } else { 11 }; + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + caret: 11, + anchor, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let mut h = + egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, false), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(2); + h.get_by_label("Swap colours (X)").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0]); + assert_eq!(h.state().session.tools.background, [1.0, 0.0, 0.0, 1.0]); + let blue = if selected { [0, 0, 255, 255] } else { [0, 0, 0, 255] }; + assert_eq!(rgb(h.state(), id, 0), [0, 0, 0, 255], "unselected characters keep their colour"); + assert_eq!((rgb(h.state(), id, 6), rgb(h.state(), id, 10)), (blue, blue), "selected: {selected}"); + // Default Colors: the selection takes black, the new foreground. + h.state_mut().run("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + h.get_by_label("Default colours (D)").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(rgb(h.state(), id, 6), [0, 0, 0, 255], "selected: {selected}"); + } + } + /// The Studio layout (chips stacked, icons below) fits the height the toolbar reserves for /// the chips, so the two-column check does not change with the theme. #[test] From 82261398063d61d708ed8d2e67ab7bf0697aaffb Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 05:49:25 +0200 Subject: [PATCH 58/90] Image Size, Canvas Size: points and picas follow the Point/Pica Size preference (#5756) (#5757) The dialogs converted points and picas at a fixed 72 pt/in, while the rulers, Info, layer properties and the shape dialog use Preferences > Units & Rulers > Point/Pica Size. With Traditional (72.27) a document showed different point sizes in the rulers and in Image Size. The dialog now stores the preference's points per inch when it opens (__ptPerIn, 72 when missing) and uses it for points, picas (points / 12) and typed units in the Width/Height fields. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/sizing.rs | 89 +++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index 6cce7e0a5c..3c48e041f4 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -71,6 +71,7 @@ pub fn reset(f: &mut Map) { pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { let unit = pref_unit(app.session.prefs().units_and_rulers.rulers); let resample = pref_resample(app.session.prefs().general.image_interpolation); + let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); let d = &app.session.active()?.doc; let (w, h, res) = (d.size.width as f64, d.size.height as f64, d.resolution_dpi as f64); let label = photocraft_engine::commands::find(command).map(|c| c.label).unwrap_or("Image Size…"); @@ -82,6 +83,7 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { f.insert("__origH".into(), json!(h)); f.insert("__origRes".into(), json!(res)); f.insert("__unit".into(), json!(unit)); + f.insert("__ptPerIn".into(), json!(pt_per_in)); f.insert("__bytesPerPixel".into(), json!(d.layers.first().and_then(|l| l.surface()).map_or(4, |s| s.format().bytes_per_pixel()))); f.insert("width".into(), json!(w)); f.insert("height".into(), json!(h)); @@ -102,27 +104,33 @@ fn num(f: &Map, k: &str) -> f64 { f.get(k).and_then(Value::as_f64).unwrap_or(0.0) } -/// Pixels → display unit. -fn to_unit(px: f64, unit: &str, orig: f64, res: f64) -> f64 { +/// Points per inch from Preferences › Units & Rulers › Point/Pica Size, stored when the dialog +/// opens: 72 (PostScript) when missing or not a positive finite number. +fn pt_per_in(f: &Map) -> f64 { + f.get("__ptPerIn").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(72.0) +} + +/// Pixels → display unit; `ppi` is points per inch (a pica is 12 points). +fn to_unit(px: f64, unit: &str, orig: f64, res: f64, ppi: f64) -> f64 { match unit { "percent" => px / orig.max(1.0) * 100.0, "in" => px / res.max(1.0), "cm" => px / res.max(1.0) * 2.54, "mm" => px / res.max(1.0) * 25.4, - "pt" => px / res.max(1.0) * 72.0, - "pica" => px / res.max(1.0) * 6.0, + "pt" => px / res.max(1.0) * ppi, + "pica" => px / res.max(1.0) * ppi / 12.0, _ => px, } } -fn from_unit(v: f64, unit: &str, orig: f64, res: f64) -> f64 { +fn from_unit(v: f64, unit: &str, orig: f64, res: f64, ppi: f64) -> f64 { match unit { "percent" => v * orig / 100.0, "in" => v * res, "cm" => v / 2.54 * res, "mm" => v / 25.4 * res, - "pt" => v / 72.0 * res, - "pica" => v / 6.0 * res, + "pt" => v / ppi * res, + "pica" => v / ppi * 12.0 * res, _ => v, } } @@ -165,18 +173,19 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map, key: &str, orig_key: let res = f.get("resolution").and_then(Value::as_f64).unwrap_or(num(f, "__origRes")); let orig = num(f, orig_key); let px = num(f, key); - let mut v = to_unit(px, &unit, orig, res) as f32; + let ppi = pt_per_in(f); + let mut v = to_unit(px, &unit, orig, res, ppi) as f32; // The engine's 300000 px limit in this unit (never below 30000), so a large document's size // isn't clamped when the field is drawn: egui clamps the shown value to the range, and the // field reports that as an edit (#4162). - let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res).max(30000.0) as f32 }; + let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res, ppi).max(30000.0) as f32 }; let range = -lim..=lim; // Each unit keeps its own precision: whole pixels, 3 places for inches, 2 for cm/mm/picas (#2434). // A typed unit (`2in` while in pixels) converts at the dialog's resolution. - let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi: 72.0 }; + let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi }; let changed = crate::widgets::value_field_units(ui, &mut v, range, 90.0, crate::widgets::unit_decimals(&unit), units).changed(); if changed { - let px = from_unit(v as f64, &unit, orig, res); + let px = from_unit(v as f64, &unit, orig, res, ppi); f.insert(key.into(), json!(if unit == "px" { px.round() } else { px })); } let _ = id; @@ -442,15 +451,36 @@ mod tests { #[test] fn unit_conversions_round_trip() { - for unit in ["px", "percent", "in", "cm", "mm", "pt", "pica"] { - let v = to_unit(1500.0, unit, 3000.0, 300.0); - assert!((from_unit(v, unit, 3000.0, 300.0) - 1500.0).abs() < 1e-9, "{unit}"); + for ppi in [72.0, 72.27] { + for unit in ["px", "percent", "in", "cm", "mm", "pt", "pica"] { + let v = to_unit(1500.0, unit, 3000.0, 300.0, ppi); + assert!((from_unit(v, unit, 3000.0, 300.0, ppi) - 1500.0).abs() < 1e-9, "{unit} at {ppi} pt/in"); + } } - assert_eq!(to_unit(1500.0, "percent", 3000.0, 300.0), 50.0); - assert_eq!(to_unit(600.0, "in", 3000.0, 300.0), 2.0); + assert_eq!(to_unit(1500.0, "percent", 3000.0, 300.0, 72.0), 50.0); + assert_eq!(to_unit(600.0, "in", 3000.0, 300.0, 72.0), 2.0); assert_eq!(human_bytes(6016.0 * 6016.0 * 4.0), "138.1M"); } + /// #5756: points and picas use the Point/Pica Size preference (Traditional is 72.27 pt/in), + /// and a dialog without the field (or with a bad one) falls back to PostScript's 72. + #[test] + fn points_and_picas_use_the_points_per_inch() { + assert!((to_unit(72.0, "pt", 72.0, 72.0, 72.27) - 72.27).abs() < 1e-9); + assert!((to_unit(72.0, "pica", 72.0, 72.0, 72.27) - 72.27 / 12.0).abs() < 1e-9); + assert_eq!(to_unit(72.0, "pt", 72.0, 72.0, 72.0), 72.0); + assert_eq!(to_unit(72.0, "pica", 72.0, 72.0, 72.0), 6.0); + assert!((from_unit(72.27, "pt", 72.0, 72.0, 72.27) - 72.0).abs() < 1e-9); + let mut f = Map::new(); + assert_eq!(pt_per_in(&f), 72.0); + for bad in [json!(0.0), json!(-3.0), json!("x")] { + f.insert("__ptPerIn".into(), bad); + assert_eq!(pt_per_in(&f), 72.0); + } + f.insert("__ptPerIn".into(), json!(72.27)); + assert_eq!(pt_per_in(&f), 72.27); + } + #[test] fn dialog_maps_to_engine_command() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -547,6 +577,33 @@ mod tests { (h, id) } + /// #5756: points and picas follow Preferences › Units & Rulers › Point/Pica Size. With + /// Traditional (72.27 pt/in) and Resample off, 100 px typed as 72.27 pt wide is 100 ppi. + #[test] + fn traditional_point_size_drives_points_and_picas() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 100, "height": 50, "resolution": 72})).unwrap(); + app.session.execute("prefs.set", json!({"path": "unitsAndRulers.pointSize", "value": "traditional"})).unwrap(); + for command in ["image.imageSize", "image.canvasSize"] { + let id = open(&mut app, command).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields.get("__ptPerIn"), Some(&json!(72.27)), "{command}"); + app.ui.close_dialog(id); + } + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), "image.imageSize").unwrap(); + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.insert("resample".into(), json!("none")); + d.fields.insert("__unit".into(), json!("pt")); + h.run_steps(3); + type_width(&mut h, "72.27"); + let f = fields(&h); + assert_eq!((num(&f, "width"), num(&f, "height")), (100.0, 50.0), "{f:?}"); + assert!((num(&f, "resolution") - 100.0).abs() < 1e-2, "{f:?}"); + } + /// Types `text` into the Width field and leaves it with Tab. fn type_width(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) { use egui::accesskit::Role; From 56d3b7f983afa2d85a53b1f1e4fc8cc2436faefa Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:49:30 -0400 Subject: [PATCH 59/90] Pen and Path Selection: Delete deletes the finished work path (#5702) (#5732) A finished work path is drawn whenever the Pen, Freeform Pen or Path Selection tool is active, and it is saved with the document, as in Photoshop. But once the path was finished, Delete/Backspace fell through to Edit > Clear (the pixels under it), so the only ways to remove it were the right-click Delete Path and the Paths panel, and it looked impossible to delete. With those tools, no path being drawn and the work path the one they edit (no shape layer or targeted vector mask in front of it), an unmodified Delete/Backspace now runs path.delete on the work path: one undoable Delete Path step. Direct Selection and the Curvature Pen are left alone, since Photoshop deletes their selected anchors, not the path. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/pen_draw_tests.rs | 48 ++++++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 13 ++++++++ crates/ui-egui/src/vector_ui.rs | 13 ++++++++ 3 files changed, 74 insertions(+) diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index d8bb39d82f..155450eac6 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -171,3 +171,51 @@ fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { assert!(sp.closed && sp.knots.len() == 3); assert!(near([k.in_ctrl.x, k.in_ctrl.y], [0.0, 20.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [40.0, 20.0]), "{k:?}"); } + +/// #5702: a finished work path showed again whenever the Pen came back, and ⌫ / Delete reached +/// Edit › Clear (the pixels under it) instead, so the path looked impossible to delete. With the +/// Pen the key deletes the path, in one undoable step, and leaves the pixels; other tools keep +/// Edit › Clear. +#[test] +fn delete_with_the_pen_deletes_the_finished_work_path_not_the_pixels() { + let mut h = harness(); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut() + .session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 200, 150), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + for p in [[20.0, 20.0], [120.0, 20.0], [120.0, 100.0]] { + click(&mut h, p); + } + h.key_press(Key::Enter); + h.run_steps(2); + let work = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().doc.work_path.is_some(); + let red = |h: &Harness<'static, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(60, 60) == [1.0, 0.0, 0.0, 1.0] + }; + assert!(work(&h) && h.state().ui.pen.is_none(), "a finished work path"); + let steps = h.state().session.active().unwrap().history.past_len(); + + h.key_press(Key::Delete); + h.run_steps(2); + assert!(!work(&h), "Delete deletes the work path"); + assert!(red(&h), "and leaves the pixels under it"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one History step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(work(&h), "Undo brings it back"); + + h.state_mut().ui.tool = Tool::PathSelection; + h.key_press(Key::Backspace); + h.run_steps(2); + assert!(!work(&h), "Backspace with Path Selection deletes it too"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + + h.state_mut().ui.tool = Tool::Brush; + h.key_press(Key::Delete); + h.run_steps(2); + assert!(work(&h), "another tool's Delete is Edit › Clear, not Delete Path"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a2f3f17c1b..e8645a1511 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -477,6 +477,19 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::vector_ui::pen_commit(app, false); return; } + // A finished work path shown by the Pen or Path Selection: ⌫/Delete deletes it, as in + // Photoshop (#5702), never Edit › Clear on the pixels under it. + if focus != Focus::DeletingWidget && crate::vector_ui::delete_key_deletes_work_path(app) { + let mods = ctx.input(|i| i.modifiers); + if !mods.command && !mods.ctrl && !mods.shift && !mods.alt && ctx.input_mut(|i| i.consume_key(mods, Key::Backspace) || i.consume_key(mods, Key::Delete)) + { + if let Err(e) = app.run("path.delete", json!({"name": "work"})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } + } // Magnetic Lasso: ↩ closes, Esc cancels, ⌫ removes a fastening point (not Edit › Clear), // [ ] change the detection width (not the brush size). if crate::magnetic_lasso_ui::keys(app, ctx) { diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index f68a4a6a4e..9682ba2ed0 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -725,6 +725,19 @@ pub(crate) fn active_shape_path(app: &PhotocraftApp) -> Option<(u64, Path)> { } } +/// Does ⌫ / Delete delete the work path? With the Pen, Freeform Pen or Path Selection tool, no +/// path being drawn and the work path the one these tools show and edit (no shape layer or +/// targeted vector mask in front of it), as in Photoshop (#5702). It was drawn whenever one of +/// these tools was active, but the key reached Edit › Clear, so it looked impossible to delete. +/// Direct Selection and the Curvature Pen delete selected anchors in Photoshop, not the path. +pub(crate) fn delete_key_deletes_work_path(app: &PhotocraftApp) -> bool { + matches!(app.ui.tool, Tool::Pen | Tool::FreeformPen | Tool::PathSelection) + && app.ui.pen.is_none() + && app.ui.transform.is_none() + && app.ui.view.shows(app.ui.view.show.target_path) + && matches!(target_path(app), Some((PathTarget::Work, _))) +} + /// The path Path Selection edits: the targeted vector mask, the active shape layer's path, else /// the work path. fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> { From 6729fa1e50c5482685ba150d158bb86b32aa5bd8 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:49:36 -0400 Subject: [PATCH 60/90] Layer Style: drag on the image to move a drop or inner shadow (#5701) (#5893) As in Photoshop, with the Layer Style dialog on a Drop Shadow or Inner Shadow page, dragging on the image moves the shadow: its offset follows the pointer and is stored as the effect's angle and distance (whole degrees and pixels), moving the shared global light angle when the effect uses it. Space and the middle button still pan; colour pickers still sample. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 14 ++++++- crates/ui-egui/src/layer_style.rs | 65 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8e38d1d57e..b3693f7d8f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3312,7 +3312,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields)); // Field Blur's pins are placed and dragged on the image (`blur_pins`). let pinning = primary && app.ui.dialogs.last().is_some_and(|d| crate::blur_pins::owns(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning; + // Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701). + let shadow_drag = + !picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields)); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag; if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3320,6 +3323,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, true)); } else if free_hover && (space_pan || hand) { ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, false)); + } else if shadow_drag && !middle && crate::dialogs::free_pointer_over(&ctx, rect).is_some() { + ctx.set_cursor_icon(egui::CursorIcon::Move); + if let Some(p) = crate::dialogs::free_press(&ctx, rect) { + let (start, origin) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.press_origin().unwrap_or(p))); + let total = xf.unmap_vec(p - origin) / (view.zoom / ppp); + if let Some(d) = app.ui.dialogs.last_mut() { + crate::layer_style::drag_shadow(&mut d.fields, start, [f64::from(total.x), f64::from(total.y)]); + } + } } else if picking && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) { if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 3a1807cf18..d876c18278 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -461,6 +461,39 @@ fn remove_instance(f: &mut Map, id: &str) { /// the layer's order), the blending options and the selected page. `select` /// names a kind to open: its first instance is selected (and enabled), or one /// is created. `light` is the document's global light angle. +/// Whether the selected page is a Drop Shadow or Inner Shadow, which a drag on the image moves +/// while the dialog is open, as in Photoshop (#5701; [`drag_shadow`]). +pub fn drags_shadow(f: &Map) -> bool { + let selected = f.get("selected").and_then(Value::as_str).unwrap_or_default(); + entry(f, selected).and_then(|e| e.get("kind")).and_then(Value::as_str).is_some_and(|k| matches!(k, "dropShadow" | "innerShadow")) +} + +/// A drag on the image with a shadow page selected: `start` is true on the press (it records the +/// shadow's angle and distance then), `total` the pointer's movement since in document pixels. +/// The shadow's offset follows the pointer, kept as Photoshop's whole-degree angle and whole-pixel +/// distance (offset = (−cos angle, sin angle) × distance, the light opposite the shadow); with +/// Use Global Light the shared angle moves too ([`set_param`]). +pub fn drag_shadow(f: &mut Map, start: bool, total: [f64; 2]) { + const KEY: &str = "__shadowDrag"; + let Some(id) = f.get("selected").and_then(Value::as_str).map(str::to_string).filter(|_| drags_shadow(f)) else { return }; + let Some(p) = entry(f, &id).and_then(|e| e.get("params")) else { return }; + if start || f.get(KEY).is_none() { + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(120.0); + let distance = p.get("distance").and_then(Value::as_f64).unwrap_or(0.0); + f.insert(KEY.into(), json!([angle, distance])); + } + let Some([angle, distance]) = f.get(KEY).and_then(Value::as_array).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])) else { return }; + let a = angle.to_radians(); + let (ox, oy) = (-a.cos() * distance + total[0], a.sin() * distance + total[1]); + let d = ox.hypot(oy); + if !d.is_finite() { + return; + } + let new_angle = if d >= 0.5 { oy.atan2(-ox).to_degrees().round() } else { angle }; + set_param(f, &id, "angle", json!(new_angle)); + set_param(f, &id, "distance", json!(d.round().clamp(0.0, 30000.0))); +} + pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map { let mut f = Map::new(); f.insert("layer".into(), json!(layer.id.0)); @@ -1346,6 +1379,38 @@ mod tests { use super::*; use photocraft_doc::StrokeFx; + /// #5701: with a shadow page selected, a drag on the image moves the shadow: its offset + /// follows the pointer, as whole degrees and pixels, the global light angle with it. + #[test] + fn dragging_on_the_image_moves_the_selected_shadow() { + let shadow = |id: &str, kind: &str, global: bool| json!({"id": id, "kind": kind, "on": true, "params": {"angle": 120.0, "distance": 10.0, "useGlobalLight": global}}); + let mut f = Map::new(); + f.insert("selected".into(), json!("fx1")); + f.insert("globalLight".into(), json!(120.0)); + f.insert("effects".into(), json!([shadow("fx1", "dropShadow", true), shadow("fx2", "innerShadow", true), shadow("fx3", "dropShadow", false)])); + assert!(drags_shadow(&f)); + let params = |f: &Map, id: &str| { + let p = &entry(f, id).unwrap()["params"]; + (p["angle"].as_f64().unwrap(), p["distance"].as_f64().unwrap()) + }; + // The press keeps the shadow where it is: (5, 8.66) px. + drag_shadow(&mut f, true, [0.0, 0.0]); + assert_eq!(params(&f, "fx1"), (120.0, 10.0)); + // Dragged to (15, 0): straight right of the layer, the light from the left (180°). + drag_shadow(&mut f, false, [10.0, -8.660_254]); + assert_eq!(params(&f, "fx1"), (180.0, 15.0)); + assert_eq!(params(&f, "fx2").0, 180.0, "the global light angle is shared"); + assert_eq!(params(&f, "fx3"), (120.0, 10.0), "an instance with its own angle is left alone"); + // Back onto the layer: no offset, and the angle the drag started with. + drag_shadow(&mut f, false, [-5.0, -8.660_254]); + assert_eq!(params(&f, "fx1"), (120.0, 0.0)); + // Not on a shadow page: nothing moves. + f.insert("selected".into(), json!(BLENDING)); + assert!(!drags_shadow(&f)); + drag_shadow(&mut f, true, [50.0, 50.0]); + assert_eq!(params(&f, "fx1"), (120.0, 0.0)); + } + fn session() -> photocraft_engine::Session { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); From 0c5709e9bde02773d838032071201611af9c588f Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:49:42 -0400 Subject: [PATCH 61/90] Free Transform: shapes and paths stay on whole pixels (#5751) (#5841) * Free Transform: shapes and paths stay on whole pixels (#5751) With Snap Vector Tools and Transforms to Pixel Grid on, only the pointer was rounded, and only while a shape tool was active. Under the Move tool, or with a grab offset or a proportional scale, the box landed between pixels (a rectangle dragged to 540 px came out 539.6). An upright box on a shape layer or path now keeps whole-pixel edges whatever the tool. Co-Authored-By: Claude Opus 5.5 * Format (cargo fmt) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/snap_ui.rs | 46 ++++++++++++++++++++++++++++ crates/ui-egui/src/transform_tool.rs | 35 +++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index b41383c028..8ef7207fb6 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -1019,6 +1019,52 @@ mod tests { assert_eq!(mover_bounds(&app).x0, 300); } + /// #5751: a Rectangle drawn on a 1080 px canvas, then resized with Free Transform under the + /// Move tool. With Snap Vector Tools and Transforms to Pixel Grid on (the default) the box's + /// edges stay on whole pixels: the grab offset and a proportional scale used to leave them at + /// 20.6 and 1040.4. Off, the box follows the pointer exactly. + #[test] + fn free_transform_of_a_shape_keeps_whole_pixels() { + for grid in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1080, "height": 1080, "resolution": 72})).unwrap(); + app.run("prefs.set", json!({"path": "tools.snapVectorToolsAndTransformsToPixelGrid", "value": grid})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 0.6; + app.ui.views[0].fit_pending = false; + app.ui.tool = Tool::Rectangle; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 31.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 300.0, y: 700.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 481.0, y: 1000.0 }, m); + app.ui.tool = Tool::Move; + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let whole = |q: [[f64; 2]; 4]| q.iter().flatten().all(|v| v.fract() == 0.0); + // The left edge handle, grabbed 1.3 px inside, dropped away from any snap target. + let q = app.ui.transform.as_ref().unwrap().quad; + let mid_y = (q[0][1] + q[3][1]) / 2.0; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: q[0][0] + 1.3, y: mid_y, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 60.0, y: mid_y, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 80.6, y: mid_y }, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(whole(q), grid, "grid {grid}: left edge {q:?}"); + // The bottom-right corner scaled proportionally (the default), off any snap target. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: q[2][0], y: q[2][1], pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 450.4, y: 900.1 }, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(whole(q), grid, "grid {grid}: corner {q:?}"); + crate::transform_tool::commit(&mut app); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("a shape layer") }; + let (x0, y0, x1, y1) = sh.path.control_bounds().unwrap(); + if grid { + for v in [x0, y0, x1, y1] { + assert!((v - v.round()).abs() < 1e-6, "committed shape {:?}", (x0, y0, x1, y1)); + } + } + } + } + #[test] fn guides_snap_to_layer_edges() { let mut app = app_with_box(); diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 13f92b1d5f..330047cfd9 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1435,6 +1435,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } else { apply_mode_drag(s, g, [x, y], mode, type_layer, mods); } + round_to_pixel_grid(app); } } } @@ -1442,6 +1443,40 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> true } +/// Preferences › Tools › Snap Vector Tools and Transforms to Pixel Grid (#5751): while a shape +/// layer or a path is moved or scaled (the box still an upright rectangle), its edges stay on +/// whole pixels, whatever the tool. Rounding the pointer alone left the edges off by the grab +/// offset, and a proportional scale off by its ratio: a shape dragged to 540 px came out 539.6. +/// Rotated, skewed and distorted boxes are left as dragged. +fn round_to_pixel_grid(app: &mut PhotocraftApp) { + if !app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid { + return; + } + let Some(t) = app.ui.transform.as_ref() else { return }; + if t.selection || t.warp.is_some() || t.content_aware.is_some() || t.target.is_some() { + return; + } + let vector = + t.path.is_some() || app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + if !vector || !upright(&t.quad) { + return; + } + let q = t.quad.map(|p| p.map(f64::round)); + // A box narrower than a pixel keeps its size rather than collapsing. + if (q[1][0] - q[0][0]).abs() < 1.0 || (q[3][1] - q[0][1]).abs() < 1.0 || q.iter().flatten().any(|v| !v.is_finite()) { + return; + } + if let Some(s) = app.ui.transform.as_mut() { + s.quad = q; + } +} + +/// Whether corners clockwise from top-left form an axis-aligned rectangle (flips included). +fn upright(q: &[[f64; 2]; 4]) -> bool { + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + near(q[0][1], q[1][1]) && near(q[2][1], q[3][1]) && near(q[0][0], q[3][0]) && near(q[1][0], q[2][0]) +} + /// The same effective modifiers drive the geometry and decide whether Ctrl is a distortion /// gesture rather than the ordinary snap override. Keep type and Content-Aware Scale refusals. fn drag_mods(app: &PhotocraftApp, t: &TransformSession, hit: Hit, mods: egui::Modifiers) -> (TransformMode, egui::Modifiers) { From 0c7308adc529a2b28e6fb9dee835f6390937b4ab Mon Sep 17 00:00:00 2001 From: WendelVolm <26125511+WendelVolm@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:49:47 +0200 Subject: [PATCH 62/90] Snapping: snap a drawn marquee, Object Selection box or shape by its edges while Space repositions it (#5748) --- crates/ui-egui/src/snap_ui.rs | 72 ++++++++++++++++++++++++++++++++- crates/ui-egui/src/vector_ui.rs | 13 ++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 8ef7207fb6..e0b122c373 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -31,6 +31,9 @@ pub enum Gesture { /// A selection outline (or its floating piece) dragged with a selection tool: its bounds at /// the start of the drag. SelectionMove { rect: [f64; 4] }, + /// A marquee, Object Selection box or shape being drawn, repositioned with Space held + /// (`hold_keys`): its bounds when Space went down. + DrawMove { rect: [f64; 4] }, /// A guide being moved with the Move tool. Guide, } @@ -292,7 +295,7 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 } Gesture::TransformCorner { .. } => snap.targets.snap_point(p, tol), Gesture::Guide => snap.targets.snap_point(p, tol), - Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => { + Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } | Gesture::DrawMove { rect } => { let d = [p[0] - snap.start[0], p[1] - snap.start[1]]; let moved = [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]]; let set = if snap.targets.is_empty() { &snap.smart } else { &snap.targets }; @@ -388,6 +391,40 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines)) } +/// The bounds `[x0, y0, x1, y1]` of what drag `d` draws so far, for the tools whose drag Space +/// repositions and that snap (`None` for the rest, or a shape still too small to draw). +fn drawn_rect(app: &PhotocraftApp, d: &crate::canvas::Drag) -> Option<[f64; 4]> { + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let (a, b) = match d.tool { + Tool::RectMarquee | Tool::EllipseMarquee => crate::canvas::marquee_corners(&app.ui.tool_options, d, last), + Tool::ObjectSelection => (d.start, last), + t if crate::vector_ui::is_shape_tool(t) => return crate::vector_ui::shape_bounds(&app.ui.tool_options, t, d.start, last, d.live), + _ => return None, + }; + Some([a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]) +} + +/// Space held while a marquee, Object Selection box or shape is drawn moves the whole of it +/// (`hold_keys`): its edges and centre snap, as a moved selection's do, not just the corner under +/// the pointer. Releasing Space goes back to snapping that corner. +fn follow_reposition(app: &mut PhotocraftApp) { + let Some(d) = app.drag.as_ref() else { return }; + let Some(snap) = app.prefs_rt.snap.as_ref() else { return }; + let gesture = match (&snap.gesture, d.reposition) { + (Gesture::Point, true) => match drawn_rect(app, d) { + Some(rect) => Gesture::DrawMove { rect }, + None => return, + }, + (Gesture::DrawMove { .. }, false) => Gesture::Point, + _ => return, + }; + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + if let Some(snap) = app.prefs_rt.snap.as_mut() { + snap.gesture = gesture; + snap.start = last; + } +} + /// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it). pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent { match ev { @@ -415,6 +452,7 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + follow_reposition(app); let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { s.disabled = disabled; @@ -427,6 +465,7 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + follow_reposition(app); let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { s.disabled = disabled; @@ -661,6 +700,37 @@ mod tests { assert_eq!(sel.x1, 116); } + /// Space while drawing a marquee, Object Selection box or shape moves it: its edges snap, not + /// only the corner under the pointer; with Space released the corner snaps again. + #[test] + fn drawing_repositioned_with_space_snaps_its_edges() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee, Tool::ObjectSelection, Tool::Rectangle, Tool::Line] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 220})).unwrap(); + app.ui.tool = tool; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 150.0, y: 140.0, pressure: 1.0 }, m); + // Space down: the 50 × 40 box moves so its left edge lands 4 px short of the guide; + // the pointer corner (x = 266) is near nothing. + app.drag.as_mut().unwrap().reposition = true; + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 266.0, y: 140.0, pressure: 1.0 }, m); + assert_eq!(app.drag.as_ref().unwrap().start, [220.0, 100.0], "{tool:?}"); + if tool != Tool::RectMarquee { + continue; + } + // Space up: sizing resumes and the corner snaps to the document's right edge. + app.drag.as_mut().unwrap().reposition = false; + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 397.0, y: 140.0 }, m); + let sel = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert_eq!((sel.x0, sel.y0, sel.x1, sel.y1), (220, 100, 400, 140)); + } + } + /// Dragging inside a selection with a marquee or lasso moves the outline (⌘ cuts the pixels /// into a floating piece): its bounds snap, not the pointer. #[test] diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 9682ba2ed0..01bb3c133b 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -104,6 +104,19 @@ fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], m }) } +/// The box `[x0, y0, x1, y1]` a Shape-tool drag draws (a line's: its ends'), which snaps while +/// Space repositions it (`snap_ui`). None when too small to draw. +pub fn shape_bounds(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<[f64; 4]> { + let g = shape_geometry(o, tool, start, end, mods)?; + if tool == Tool::Line { + let a: [f64; 2] = serde_json::from_value(g["from"].clone()).ok()?; + let b: [f64; 2] = serde_json::from_value(g["to"].clone()).ok()?; + return Some([a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]); + } + let [x, y, w, h]: [f64; 4] = serde_json::from_value(g["rect"].clone()).ok()?; + Some([x, y, x + w, y + h]) +} + /// Finish a Shape-tool drag: ⇧ constrains proportions, ⌥ draws from the centre. pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let Some(mut p) = shape_geometry(&app.ui.tool_options, tool, start, end, mods) else { return }; From 38b96f273aa5a49ed1cafa13dbd65237ad408bf5 Mon Sep 17 00:00:00 2001 From: baz <57290335+bazmarth@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:49:52 +0000 Subject: [PATCH 63/90] Move: Show Transform Controls frames every selected layer (#5917) As in Photoshop, with several layers selected (Shift-click with Auto-Select, or the Move marquee) Show Transform Controls draws one frame around all of them (the Background excluded) and outlines every selected shape layer. Before, the frame showed only the active layer, so the other selected layers had no feedback on the canvas. The frame joins the boxes Free Transform would open on each layer (transform_frame), and it hides while a corner radius widget is dragged. Free Transform works on one layer, so the shared frame has no handles and a press on it doesn't start Free Transform; a drag inside still moves every selected layer. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 83 +++++++++++++++++--- crates/ui-egui/src/move_auto_select_tests.rs | 21 +++++ docs/ui-parity.md | 4 +- 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b3693f7d8f..9227c2a0fe 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3842,8 +3842,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // corner badge, as Photoshop's corner radius cursor. ui.ctx().set_cursor_icon(egui::CursorIcon::Default); crate::icons::cursor_badge(&ctx, "scan", p); - } else if let Some((r, p, c)) = - response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) + } else if let Some((r, p, c)) = response + .hover_pos() + .filter(|_| !transform_controls_several(app)) + .and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). @@ -4677,23 +4679,36 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option Option { - // A corner radius drag hides the box, as in Photoshop. +/// The layers Move › Show Transform Controls frames: every selected layer but the Background. +/// A corner radius drag hides the box, as in Photoshop. +fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_doc::Layer> { if app.ui.tool != Tool::Move || !app.ui.tool_options.move_show_transform || app.ui.transform.is_some() || app.drag.is_some() || app.ui.corner_radius.is_some() { - return None; + return Vec::new(); } + let Some(st) = app.session.active() else { return Vec::new() }; + st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect() +} + +/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but +/// Free Transform works on one layer, so the frame has no handles to scale or rotate them. +fn transform_controls_several(app: &PhotocraftApp) -> bool { + transform_controls_layers(app).len() > 1 +} + +/// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected +/// layers, joined into one. +fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { let st = app.session.active()?; - let l = st.active_layer.and_then(|id| st.doc.layer(id))?; - if crate::doc_props_ui::is_background(&st.doc, l) { - return None; - } - let b = photocraft_engine::transform_cmds::transform_frame(&st.doc, l); + let b = transform_controls_layers(app) + .into_iter() + .map(|l| photocraft_engine::transform_cmds::transform_frame(&st.doc, l)) + .filter(|b| !b.is_empty()) + .reduce(|a, b| a.union(&b))?; let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); let b = b.translate(dx, dy); if b.is_empty() { @@ -4750,6 +4765,9 @@ fn transform_controls_hit(r: Rect, p: Pos2) -> bool { /// Enter the existing Free Transform session when a Move-tool transform control is pressed. fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool { + if transform_controls_several(app) { + return false; + } let Some(r) = transform_controls_rect(app, xf) else { return false }; if !transform_controls_hit(r, p) { return false; @@ -4763,11 +4781,21 @@ fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: } } -/// Move tool › Show Transform Controls: the active layer's bounding box with its eight handles. +/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles +/// for one layer, and the outline of every selected shape layer, as in Photoshop. fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(r) = transform_controls_rect(app, xf) else { return }; let accent = crate::theme::Tokens::get(painter.ctx()).accent; + let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); + for l in transform_controls_layers(app) { + if let LayerContent::Shape(sh) = &l.content { + crate::vector_ui::draw_outline(painter, &sh.path, &to_scr, accent); + } + } painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle); + if transform_controls_several(app) { + return; + } for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] { let h = Rect::from_center_size(p, vec2(7.0, 7.0)); painter.rect_filled(h, 0.0, Color32::WHITE); @@ -8109,6 +8137,37 @@ mod transform_controls_tests { use super::*; use serde_json::json; + #[test] + fn several_selected_layers_share_one_frame_without_handles() { + // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform + // works on one layer, so the shared frame doesn't start it. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap(); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let one = transform_controls_rect(&app, &xf).unwrap(); + assert!(!transform_controls_several(&app)); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + assert!(transform_controls_several(&app)); + let both = transform_controls_rect(&app, &xf).unwrap(); + assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both"); + let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom())); + assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}"); + let ctx = egui::Context::default(); + assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); + assert!(app.ui.transform.is_none()); + // The Background never joins the frame. + let bg = app.session.active().unwrap().doc.layers[0].id; + app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + assert_eq!(transform_controls_layers(&app).len(), 2); + } + #[test] fn move_transform_controls_start_free_transform_for_vector_shapes() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 3d635922e3..403ab28cd1 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -236,3 +236,24 @@ fn every_theme_shows_the_gradient_options() { assert!(combos.iter().any(|c| c.contains("Normal")), "{theme:?}: the blend mode picker shows; combo boxes: {combos:?}"); } } + +#[test] +fn shift_click_adds_shape_layers_one_after_another() { + // Shapes, as in Photoshop: a click selects one, each ⇧-click adds the one under it. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + for rect in [[10, 10, 40, 40], [80, 10, 40, 40], [10, 100, 40, 40]] { + app.session.execute("shape.create", json!({"kind": "rect", "rect": rect, "fill": "#ff0000"})).unwrap(); + } + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let ids: Vec = app.session.active().unwrap().doc.layers.iter().skip(1).map(|l| l.id).collect(); + drag(&mut app, [30.0, 30.0], [0.0, 0.0], egui::Modifiers::NONE); + drag(&mut app, [100.0, 30.0], [0.0, 0.0], egui::Modifiers::SHIFT); + drag(&mut app, [30.0, 120.0], [0.0, 0.0], egui::Modifiers::SHIFT); + let mut selected = app.session.active().unwrap().selected_layers(); + selected.sort_by_key(|l| l.0); + assert_eq!(selected, ids); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7fbe8932cc..d8d29122c4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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, @@ -96,6 +96,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | +| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | | ⌘-drag temporary Move shows the box and snaps | partial | #2681 | | ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` | | Double-click type to edit it (caret at the pointer, Type tool) | done | #1132, `type_tool::edit_at` | @@ -253,3 +254,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Pen ⇧ 45° done: ⇧-click puts the next anchor on the nearest 45° ray, ⇧-drag turns its handles in 45° steps (TOOL-214-10 done, #3751) | | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | +| 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | From 94b1f0a4cb71e3f9b5a59db281bf19e9e0b67be1 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Thu, 15 Oct 2026 05:49:58 +0200 Subject: [PATCH 64/90] Warp: a thin layer stretched and bent keeps its curve on commit (#5793) (#5810) * Warp: a thin layer stretched and bent keeps its curve on commit (#5793) The mesh warp sized its triangle grid by the source rectangle only (one cell per 4 source px). A 3 px column scaled into a wide band and then warped got a single cell across, so the committed result was a straight, affine band while the preview showed the curve. The grid now also follows the destination length of the mapped rows and columns. Co-Authored-By: Claude Opus 5.5 * Warp: module doc describes the grid sized on both sides of the map (#5793) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/algo/src/warp.rs | 72 ++++++++++++++++++++++++++-- crates/ui-egui/src/transform_tool.rs | 26 ++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 01c54e7643..188de07d47 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -4,8 +4,10 @@ //! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each //! cell's two triangles are rasterized in destination space with the source position //! interpolated barycentrically, then the source is sampled there (premultiplied, nearest / -//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so -//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine +//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few pixels across in +//! both the source and the destination (a thin layer stretched far gets as many cells as its +//! warped size needs), so the piecewise-affine inverse is within a small fraction of a pixel of +//! the true one; affine //! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each //! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per //! source pixel), so thin lines don't alias. Where the warp folds over itself the @@ -77,8 +79,11 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true)); } let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height())); - let nx = ((sw / CELL).ceil() as usize).clamp(1, MAX_CELLS); - let ny = ((sh / CELL).ceil() as usize).clamp(1, MAX_CELLS); + // Cells are a few pixels across on both sides of the map: a thin source stretched far (a + // 3 px column scaled into a wide band, then bent) needs many cells, or it comes out straight. + let (lu, lv) = mapped_lengths(src_rect, f); + let cells = |s: f64, d: f64| ((s.max(d) / CELL).ceil() as usize).clamp(1, MAX_CELLS); + let (nx, ny) = (cells(sw, lu), cells(sh, lv)); // Forward-mapped vertices (and their source positions). let mut verts = Vec::with_capacity((nx + 1) * (ny + 1)); for j in 0..=ny { @@ -101,6 +106,43 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> warp_triangles(src, src_rect, &verts, &tris, interp) } +/// Probe lines per axis [`mapped_lengths`] follows through the map. +const PROBE: usize = 16; + +/// The longest destination length of a source row and of a source column under `f`, measured +/// along polylines through a [`PROBE`]-step grid. Non-finite steps count as zero. +fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -> (f64, f64) { + let (x0, y0) = (f64::from(src_rect.x0), f64::from(src_rect.y0)); + let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height())); + let n = PROBE + 1; + let mut pts = Vec::with_capacity(n * n); + for j in 0..n { + for i in 0..n { + let (dx, dy) = f(x0 + sw * i as f64 / PROBE as f64, y0 + sh * j as f64 / PROBE as f64); + pts.push([dx, dy]); + } + } + let step = |a: [f64; 2], b: [f64; 2]| { + let d = (b[0] - a[0]).hypot(b[1] - a[1]); + if d.is_finite() { d } else { 0.0 } + }; + let (mut lu, mut lv) = (0.0f64, 0.0f64); + for k in 0..n { + let (mut row, mut col) = (0.0, 0.0); + for m in 1..n { + if let (Some(&a), Some(&b)) = (pts.get(k * n + m - 1), pts.get(k * n + m)) { + row += step(a, b); + } + if let (Some(&a), Some(&b)) = (pts.get((m - 1) * n + k), pts.get(m * n + k)) { + col += step(a, b); + } + } + lu = lu.max(row); + lv = lv.max(col); + } + (lu, lv) +} + /// Rasterizes textured triangles: each vertex is `(destination, source)` in document pixels and /// each triangle samples `src` (restricted to `src_rect`) through the barycentric /// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the @@ -399,6 +441,28 @@ mod tests { surf } + #[test] + fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() { + // #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in + // source pixels (one cell across), so the arch came out as a straight band. + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + src.fill_rect(Rect::new(0, 0, 3, 40), &[1.0, 0.0, 0.0, 1.0]); + let arch = |x: f64, y: f64| { + let dx = x * 100.0; + let t = (dx - 150.0) / 150.0; + (dx, y + 40.0 * (1.0 - t * t)) + }; + let out = warp_mesh_surface(&src, Rect::new(0, 0, 3, 40), &arch, Interp::Bilinear); + let alpha = |x: i32, y: i32| { + let mut p = [0.0f32; 4]; + out.read_pixel(x, y, &mut p); + p[3] + }; + // The band follows the arch: 40 px lower in the middle, about 36 px a third of the way in. + assert!(alpha(150, 60) > 0.99 && alpha(150, 20) < 0.01, "mid {} {}", alpha(150, 60), alpha(150, 20)); + assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20)); + } + #[test] fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() { // #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 330047cfd9..d46ef189bc 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -4952,6 +4952,32 @@ mod tests { assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); } + #[test] + fn a_thin_layer_scaled_then_warped_commits_the_curve() { + // #5793: a 3 px column stretched wide, switched to Warp and pulled down in the middle. + // The commit came out straight: the warp's grid was one cell across the 3 source pixels. + let mut app = app_with_square(128, photocraft_geom::Rect::new(40, 20, 43, 60)); + let ctx = egui::Context::default(); + begin(&mut app, &ctx).unwrap(); + scale_about_pivot(&mut app, 30.0, 1.0); + enter_warp(&mut app, &ctx); + warp_pointer(&mut app, ToolEvent::Down { x: 41.5, y: 40.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Move { x: 41.5, y: 70.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Up { x: 41.5, y: 70.0 }, 2.0, egui::Modifiers::NONE); + commit(&mut app); + assert!(app.ui.transform.is_none(), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let alpha = |x: i32, y: i32| { + let mut p = [0.0f32; 4]; + s.read_pixel(x, y, &mut p); + p[3] + }; + // The middle sags below the band's old bottom edge (y 60); the ends stay put. + assert!(alpha(41, 70) > 0.99, "middle {}", alpha(41, 70)); + assert!(alpha(15, 70) < 0.01 && alpha(15, 40) > 0.99, "end {} {}", alpha(15, 70), alpha(15, 40)); + } + fn warping_square() -> PhotocraftApp { let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32)); begin_warp(&mut app, &egui::Context::default()).unwrap(); From 8987980d1614cfd4fc01f72066dcc6a9fd00e36e Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 05:50:03 +0200 Subject: [PATCH 65/90] Dialogs: a close box in the title bar cancels like Cancel (#5496) (#5505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialogs drawn by the shared renderer had no close box: the only ways out were Cancel/OK or Esc. As in Photoshop on Windows, the title band now ends in a × (the icon button the floating panels close with) on Windows, Linux and the web, none on macOS. A click on it joins Esc in the single cancel path of `dialogs::show` (outcome = Cancel -> `cancel()`), so the live preview goes and nothing reaches the document or history. The × is left out of the title band's drag area, so a drag starting on it neither moves nor closes the dialog; the rest of the band still drags. Check Spelling (an untitled window) gets the same close box; it is Done, as every change there is already made. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/dialogs.rs | 115 ++++++++++++++++++++++++++- crates/ui-egui/src/type_panels_ui.rs | 41 +++++++++- 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2930889bab..013c79e825 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -135,6 +135,26 @@ fn show_modal( }) } +/// Whether dialogs have a close box (×) in their title bar (#5496). Photoshop's modal dialogs have +/// one on Windows, where clicking it is Cancel, and none on macOS; Linux and the web follow Windows. +pub(crate) const CLOSE_BOX: bool = !cfg!(target_os = "macos"); + +/// The side of the close box's square, in points. +const CLOSE_BOX_SIZE: f32 = 20.0; + +/// The close box's square: flush with the title band's right end `right`, centred on the title +/// row at height `centre_y`. +fn close_box_rect(right: f32, centre_y: f32) -> egui::Rect { + egui::Rect::from_min_size(egui::pos2(right - CLOSE_BOX_SIZE, centre_y - CLOSE_BOX_SIZE / 2.0), egui::vec2(CLOSE_BOX_SIZE, CLOSE_BOX_SIZE)) +} + +/// Draws a title bar's close box in `rect`, the same × icon button the floating panels close with, +/// and says whether it was clicked. Its own child `Ui`, so the title row's layout is unchanged. +pub(crate) fn close_box(ui: &mut egui::Ui, rect: egui::Rect) -> bool { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::right_to_left(egui::Align::Center))); + crate::icons::button(&mut child, "x", CLOSE_BOX_SIZE, false, tl!("Close")).clicked() +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::layer_style::color_picker::prune(app); crate::type_panels_ui::color_picker::prune(app); @@ -163,6 +183,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let place = place_id(&d); let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::(place))).or_else(|| crate::blur_pins::first_place(&d.fields, ctx)); let mut drag = egui::Vec2::ZERO; + let mut close_clicked = false; let mut sizing = false; let area = match start { Some(offset) => egui::Modal::default_area(id).anchor(egui::Align2::LEFT_TOP, offset), @@ -242,8 +263,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = if crate::color_picker_ui::owns(&fields) { ui.add_sized(egui::vec2(ui.available_width(), 22.0), label).rect } else { ui.add(label).rect }; // The whole title band drags (#1921): the full width plus a little of the popup's // padding around it and the gap above the hairline, but none of the controls below. - let bar = egui::Rect::from_min_max(t.min - egui::vec2(7.0, 7.0), egui::pos2(ui.max_rect().right() + 7.0, t.bottom() + 4.0)); + // The close box (#5496) sits at the band's right end and is left out of the drag area: + // egui would let a drag that starts on a button move the dialog. + let close_rect = CLOSE_BOX.then(|| close_box_rect(ui.max_rect().right(), t.center().y)); + let bar_right = close_rect.map_or(ui.max_rect().right() + 7.0, |r| r.left()); + let bar = egui::Rect::from_min_max(t.min - egui::vec2(7.0, 7.0), egui::pos2(bar_right, t.bottom() + 4.0)); drag = ui.interact(bar, id.with("title"), egui::Sense::drag()).drag_delta(); + if let Some(r) = close_rect { + close_clicked = close_box(ui, r); + } ui.add_space(4.0); crate::widgets::hairline(ui); ui.add_space(8.0); @@ -458,11 +486,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.data_mut(|m| m.insert_persisted(place, offset)); } } - // Esc cancels (topmost dialog, no popup open). A click outside does nothing: Photoshop keeps - // the dialog, and the pointer may be panning or zooming the canvas under it. + // Esc and the title bar's close box cancel (topmost dialog, no popup open for Esc). A click + // outside does nothing: Photoshop keeps the dialog, and the pointer may be panning or + // zooming the canvas under it. if interactive && outcome.is_none() - && (modal.response.should_close() + && (close_clicked + || modal.response.should_close() || (modal.is_top_modal && !modal.any_popup_open && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))) { outcome = Some(false); @@ -735,6 +765,83 @@ mod tests { assert_eq!(crate::canvas::committed_filter_preview(&mut app, 0), None); } + /// #5496: a pointer click on the title bar's close box does what Cancel does: the dialog + /// closes, the live preview goes and nothing reaches the document or the journal. On macOS + /// dialogs have no close box. + #[test] + fn the_title_bar_close_box_cancels_like_cancel() { + use egui_kittest::kittest::Queryable; + let close = |button: &str| { + let (mut app, _) = filter_dialog_with_preview(false); + let revision = app.session.active().unwrap().revision; + let journal = app.session.journal.len(); + // The fonts are set up after the harness's first frame: the dialog appears after it. + let dialogs = std::mem::take(&mut app.ui.dialogs); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.state_mut().ui.dialogs = dialogs; + h.run_steps(4); + assert!(h.state().filter_preview.is_some(), "the preview is on screen"); + if !CLOSE_BOX { + assert_eq!(h.query_all_by_label("Close").count(), 0, "no close box on macOS"); + } + let target = h.query_by_label(button).map(|n| n.rect().center())?; + h.hover_at(target); + h.drag_at(target); + h.drop_at(target); + h.run_steps(3); + let app = h.state(); + Some(( + app.ui.dialogs.is_empty(), + app.filter_preview.is_none(), + app.session.active().unwrap().revision == revision, + app.session.journal.len() == journal, + )) + }; + let cancel = close("Cancel"); + assert_eq!(cancel, Some((true, true, true, true)), "Cancel closes, drops the preview and changes nothing"); + if CLOSE_BOX { + assert_eq!(close("Close"), cancel, "the × is Cancel"); + } + } + + /// #5496: the close box sits at the title band's right end, and the band stays a drag handle + /// around it: a drag that starts on the × neither moves nor closes the dialog, and the band + /// just left of the × still moves it. + #[test] + fn the_close_box_and_the_title_drag_keep_apart() { + use egui_kittest::kittest::Queryable; + if !CLOSE_BOX { + return; + } + let mut h = dialog_harness(egui::vec2(1280.0, 800.0), "filter.blur.gaussianBlur"); + let title = h.get_by_label("Gaussian Blur").rect(); + let x = h.get_by_label("Close").rect(); + let frame = rects(&h.ctx)[0]; + assert!((x.center().y - title.center().y).abs() < 2.0 && x.left() > title.right(), "the × is on the title row: {x:?}, {title:?}"); + assert!(frame.right() - x.right() < 16.0, "at its right end: {x:?} in {frame:?}"); + let drag = |h: &mut egui_kittest::Harness<'static, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2| { + h.hover_at(from); + h.drag_at(from); + h.run_steps(2); + for i in 1..=8 { + h.hover_at(from + (to - from) * (i as f32 / 8.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + }; + drag(&mut h, x.center(), x.center() + egui::vec2(-80.0, 60.0)); + assert_eq!(h.state().ui.dialogs.len(), 1, "a drag from the × doesn't close the dialog"); + assert!((h.get_by_label("Gaussian Blur").rect().min - title.min).length() < 1.0, "nor move it"); + let x = h.get_by_label("Close").rect(); + let beside = x.left_center() - egui::vec2(4.0, 0.0); + drag(&mut h, beside, beside + egui::vec2(-120.0, 40.0)); + assert_eq!(h.state().ui.dialogs.len(), 1, "a title drag doesn't close the dialog"); + let moved = h.get_by_label("Gaussian Blur").rect().min - title.min; + assert!((moved - egui::vec2(-120.0, 40.0)).length() < 2.0, "the band beside the × moved the dialog: {moved:?}"); + } + #[test] fn color_picker_has_inset_content_and_actions_at_the_right_edge() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index 499e8eac44..d97a649fba 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -768,7 +768,17 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { .anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)) .show(ctx, |ui| { ui.set_width(380.0); - ui.label(RichText::new(tl!("Check Spelling")).color(t.text).size(13.0).strong()); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Check Spelling")).color(t.text).size(13.0).strong()); + // The close box (#5496) is Done, as in Photoshop: every change is already made. + if crate::dialogs::CLOSE_BOX { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "x", 20.0, false, tl!("Close")).clicked() { + act = Some("done"); + } + }); + } + }); ui.add_space(6.0); ui.horizontal_top(|ui| { ui.vertical(|ui| { @@ -930,4 +940,33 @@ mod tests { // Headless calls with an action go to the engine. assert!(menu(&mut app, "edit.checkSpelling", &json!({"action": "list"})).is_none()); } + + /// #5496: Check Spelling's close box is Done, as in Photoshop on Windows: the window closes + /// and the text keeps what it had. None on macOS. + #[test] + fn check_spellings_close_box_is_done() { + use egui_kittest::kittest::Queryable; + let mut app = app_with_text("Teh cat"); + menu(&mut app, "edit.checkSpelling", &json!({})).unwrap().unwrap(); + let revision = app.session.active().unwrap().revision; + // The fonts are set up after the harness's first frame: the window appears after it. + let spell = app.ui.type_panels.spell.take(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(|ui, app| windows(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.state_mut().ui.type_panels.spell = spell; + h.run_steps(3); + if !crate::dialogs::CLOSE_BOX { + assert_eq!(h.query_all_by_label("Close").count(), 0, "no close box on macOS"); + return; + } + let x = h.get_by_label("Close").rect(); + let done = h.get_by_label("Done").rect(); + assert!(x.bottom() <= done.top() && x.right() >= done.right(), "the × ends the title row: {x:?} over {done:?}"); + h.hover_at(x.center()); + h.drag_at(x.center()); + h.drop_at(x.center()); + h.run_steps(2); + assert!(h.state().ui.type_panels.spell.is_none(), "the × closed Check Spelling"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "and changed nothing"); + } } From be32a7c651e4066c283284050b12ca8896f6f7ef Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:50:08 +0200 Subject: [PATCH 66/90] fix: commit type editing on Layers-panel selection (#5584) (#5612) --- crates/ui-egui/src/layer_row_ui/edit_tests.rs | 7 +- crates/ui-egui/src/panels.rs | 113 ++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/layer_row_ui/edit_tests.rs b/crates/ui-egui/src/layer_row_ui/edit_tests.rs index 829df351c7..0c217dd3d0 100644 --- a/crates/ui-egui/src/layer_row_ui/edit_tests.rs +++ b/crates/ui-egui/src/layer_row_ui/edit_tests.rs @@ -174,7 +174,7 @@ fn edit_type_command_rejects_missing_or_non_type_layers_without_changes() { } #[test] -fn reentering_type_edit_keeps_the_session_and_cancel_restores_cached_pixels() { +fn layer_reselection_commits_type_edit_and_cancel_preserves_committed_text() { let mut s = session(); let id = s.execute("type.create", json!({"text": "Hello", "size": 48, "x": 100, "y": 180})).unwrap()["layer"].as_u64().unwrap(); // Stand-in for a PSD's retained Photoshop rendering, displaced from our glyph layout. @@ -197,10 +197,13 @@ fn reentering_type_edit_keeps_the_session_and_cancel_restores_cached_pixels() { let key = h.state().ui.text_edit.as_ref().expect("inline text editor").session.clone(); h.event(Event::Text("Changed".into())); h.run_steps(2); + // The first row click commits the edit; the second opens a new session (#5584). double_click(&mut h, at); - assert_eq!(h.state().ui.text_edit.as_ref().unwrap().session, key); + assert_ne!(h.state().ui.text_edit.as_ref().unwrap().session, key); assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1); crate::type_tool::cancel(h.state_mut()); + assert_eq!(text(&h, id), "Changed"); + assert!(h.state_mut().session.undo()); assert_eq!(text(&h, id), "Hello"); let st = h.state().session.active().unwrap(); let LayerContent::Text(t) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("text") }; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f5b29d06d1..a7187c9141 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2551,6 +2551,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); for (id, p) in actions { + // A row click commits even when it selects the layer already being edited (#5584). + // Finish before selection changes so the existing type session keeps its own target. + if id == "layer.select" && app.ui.text_edit.is_some() { + crate::type_tool::commit(app); + } if id == "ui.maskTarget" { app.ui.mask_target = p.as_bool().unwrap_or(false); app.ui.vector_mask_target = false; @@ -6311,6 +6316,114 @@ mod properties_target_regressions { #[cfg(test)] #[path = "history_panel_select_tests.rs"] mod history_panel_select_tests; +#[cfg(test)] +mod layer_click_type_commit_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + fn harness(kind: theme::ThemeKind) -> (Harness<'static, PhotocraftApp>, LayerId, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let text = LayerId(app.run("type.create", json!({"text": "Before", "size": 24, "x": 20, "y": 80})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.setProps", json!({"layer": text.0, "name": "Edited type"})).unwrap(); + let other = LayerId(app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + crate::type_tool::edit_active(&mut app).unwrap(); + let session = app.ui.text_edit.as_ref().unwrap().session.clone(); + app.run("type.edit", json!({"layer": text.0, "text": "Preserved edit", "coalesce": session})).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(360.0, 650.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), kind); + ready = true; + return; + } + layers(app, ui); + }, + app, + ); + h.run_steps(4); + (h, text, other) + } + + fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, modifiers: egui::Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + } + + fn preserved(app: &PhotocraftApp, id: LayerId) { + let layer = app.session.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Text(text) = &layer.content else { panic!("type must remain editable") }; + assert_eq!(text.text, "Preserved edit"); + } + + #[test] + fn layer_name_and_thumbnail_clicks_commit_same_or_other_type_selection() { + for kind in theme::ThemeKind::ALL { + for same in [true, false] { + for thumbnail in [true, false] { + let (mut h, text, other) = harness(kind); + let label = if same { "Edited type" } else { "Other pixels" }; + let row = h.get_by_label(label).rect(); + let at = pos2(row.left() + if thumbnail { 43.0 } else { 120.0 }, row.center().y); + click(&mut h, at, egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none(), "{kind:?}, same={same}, thumbnail={thumbnail}"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(if same { text } else { other })); + preserved(h.state(), text); + } + } + } + } + + #[test] + fn layer_click_commits_without_breaking_modifier_selection_or_double_click_editing() { + for kind in theme::ThemeKind::ALL { + let (mut h, text, other) = harness(kind); + let row = h.get_by_label("Other pixels").rect(); + click(&mut h, pos2(row.left() + 120.0, row.center().y), egui::Modifiers { command: true, ..Default::default() }); + assert!(h.state().ui.text_edit.is_none()); + let selected = h.state().session.active().unwrap().selected_layers(); + assert!(selected.contains(&text) && selected.contains(&other)); + preserved(h.state(), text); + + let (mut h, text, _) = harness(kind); + let row = h.get_by_label("Edited type").rect(); + let thumb = pos2(row.left() + 43.0, row.center().y); + click(&mut h, thumb, egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none()); + click(&mut h, thumb, egui::Modifiers::NONE); + assert_eq!(h.state().ui.text_edit.as_ref().map(|ed| ed.layer), Some(text.0)); + preserved(h.state(), text); + } + } + + #[test] + #[ignore = "offscreen visual evidence for #5584"] + fn layer_click_synthetic_visual_evidence() { + let (h, text, _) = harness(theme::ThemeKind::ProMedium); + let mut app = h.into_state(); + app.ui.tool = Tool::Type; + let mut h = Harness::builder().with_size(vec2(1280.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme::ThemeKind::ProMedium); + app + }); + h.run_steps(4); + assert!(h.state().ui.text_edit.is_some()); + h.render().unwrap().save("5584-before.png").unwrap(); + let row = h.get_by_label("Other pixels").rect(); + click(&mut h, pos2(row.left() + 120.0, row.center().y), egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none()); + preserved(h.state(), text); + h.run_steps(4); + h.render().unwrap().save("5584-after.png").unwrap(); + } +} #[cfg(test)] mod sampler_readout_tests { From 96c683b25db7a1e52eac7e5046b3a2ff952c4cec Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 06:50:14 +0300 Subject: [PATCH 67/90] Info panel: show guide coordinates while dragging outside the canvas (#5728) (#5768) * Fix Info panel guide coordinates during ruler drags (storytold/photocraft#5728). Show the dragged guide position on one axis while the pointer is outside the canvas, matching Photoshop. Co-authored-by: Cursor * Merge main; fill GuideDrag's new press field in the test; cargo fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Cursor Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 11 +++++++++-- crates/ui-egui/src/rulers.rs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a7187c9141..522c74dc15 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1797,6 +1797,13 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .hover_doc .map(|p| (p[0].floor() as i32, p[1].floor() as i32)) .filter(|(x, y)| *x >= 0 && *y >= 0 && *x < size.width as i32 && *y < size.height as i32); + // While a guide is dragged (#5728), the Info panel shows its position in one axis only, even + // when the pointer is outside the canvas (ruler drags do not update `hover_doc` there). + let (guide_x, guide_y) = match app.guide_drag { + Some(d) if d.vertical => (Some(d.pos), None), + Some(d) => (None, Some(d.pos)), + None => (None, None), + }; // The readout averages the Eyedropper's Sample Size, as in Photoshop (#1649). let sample_size = app.ui.tool_options.eyedropper_size; let rgba = pos.and_then(|(x, y)| { @@ -1835,8 +1842,8 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.columns(2, |cols| { let dash = || "—".to_string(); // Measured from the rulers' zero point (#2955). - row(&mut cols[0], "X:", pos.map_or_else(dash, |p| fx(p.0 as f64 - origin[0]))); - row(&mut cols[0], "Y:", pos.map_or_else(dash, |p| fy(p.1 as f64 - origin[1]))); + row(&mut cols[0], "X:", guide_x.map(|p| fx(p - origin[0])).or_else(|| pos.map(|p| fx(p.0 as f64 - origin[0]))).unwrap_or_else(dash)); + row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash)); row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64))); row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64))); }); diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 972d7c0346..61ea25d96f 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -668,6 +668,24 @@ mod tests { assert!(h.query_by_label("50").is_none(), "not the raw image x"); } + /// #5728: dragging a guide shows its coordinate in the Info panel on one axis only. + #[test] + fn info_panel_shows_guide_drag_position_outside_canvas() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.hover_doc = None; + app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0 }); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(260.0, 240.0)) + .build_ui_state(|ui, app| crate::panels::dock_body(app, ui, crate::dock::Group::Navigator, 2), app); + h.run_steps(2); + assert!(h.query_by_label("42").is_some(), "vertical guide: X readout"); + h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0 }); + h.run_steps(2); + assert!(h.query_by_label("17").is_some(), "horizontal guide: Y readout"); + } + #[test] fn percentage_grid_spacing_uses_both_canvas_dimensions() { let mut g = GuidesGridAndSlices { grid_unit: Unit::Percent, gridline_every: 20.0, subdivisions: 2, ..Default::default() }; From 17c85e41751f63027345c323b19058c56640da83 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 15 Oct 2026 09:20:19 +0530 Subject: [PATCH 68/90] fix(ui): brush outline matches the stroke on Wayland at fractional scales (#5796) * fix(ui): brush outline matches the stroke on Wayland at fractional scales On Hyprland at 1.25 the brush outline was 1.25x too large and sat right of and below where the brush painted, by more the larger the brush. The engine places dabs exactly; the OS cursor bitmap was wrong. winit 0.30 sizes custom cursor bitmaps on Wayland with a wp_viewport destination whenever the scale isn't 1, and Hyprland (like Mutter before GNOME 48) ignores viewports on cursor surfaces: it shows the bitmap one pixel per logical pixel with the hotspot winit divided by the scale. A client can't tell which compositors do this, so on Wayland at any scale but 1 the brush outline and crosshairs are painted in-app, as on the web. Other platforms and Wayland at scale 1 keep the low-latency OS bitmap. Measured on Hyprland 0.56.2 at 1.25 (100 % zoom, 100 px brush): dab centre (288.5, 560.5), diameter 97 px; outline before (303, 574.5), ~130 px; after (288.5, 562), 107 px including its border. Co-Authored-By: Claude Opus 5.5 * Merge main; cargo fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/tool_cursor.rs | 20 +++++++++++++-- crates/ui-egui/src/tool_cursor/tests.rs | 34 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c75ec3a36..5d95f2d662 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1771,6 +1771,7 @@ impl eframe::App for PhotocraftApp { // 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`. self.caps_lock = self.services.caps_lock.as_mut().is_some_and(|f| f()); + tool_cursor::set_wayland(ctx, self.services.is_wayland); if !self.styled { Self::setup_context(ctx, self.ui.theme); self.styled = true; diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 32f6aed82e..8b67b96d60 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -93,9 +93,25 @@ fn close_ring() -> Vec { } /// Can this pass show an OS cursor bitmap? eframe's immediate viewports and its web integration -/// don't upload cursor images; every desktop root viewport does (winit custom cursors). +/// don't upload cursor images; every desktop root viewport does (winit custom cursors). Wayland at +/// any scale but 1 doesn't either: winit sizes the bitmap there with a `wp_viewport` destination +/// that some compositors ignore on cursor surfaces (Hyprland 0.56; Mutter before GNOME 48). They +/// show it one bitmap pixel per logical pixel, so at 1.25 a brush outline was 1.25× too large and +/// its centre sat 0.2 × its half-width right of and below the pointer, which is where the brush +/// painted. Nothing tells the app which compositor does what, so the outline is painted instead. fn native(ctx: &egui::Context) -> bool { - cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() == egui::ViewportId::ROOT + cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() == egui::ViewportId::ROOT && !scaled_wayland(ctx) +} + +/// Mark this window as a Wayland surface (or not), for [`native`]; the shell calls it every pass. +pub(crate) fn set_wayland(ctx: &egui::Context, wayland: bool) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("photocraft-cursor-wayland"), wayland)); +} + +/// A Wayland window whose scale is not 1, where winit's cursor bitmaps are unreliable. +fn scaled_wayland(ctx: &egui::Context) -> bool { + let wayland = cfg!(target_os = "linux") && ctx.data(|d| d.get_temp::(egui::Id::new("photocraft-cursor-wayland"))).unwrap_or(false); + wayland && ctx.native_pixels_per_point().unwrap_or_else(|| ctx.pixels_per_point() / ctx.zoom_factor()) != 1.0 } /// Bitmap pixels per egui point. winit takes an `NSCursor` image's dimensions as logical points on diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 409d528b95..d68be241f5 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -272,6 +272,40 @@ fn ordinary_brush_cursor_is_not_part_of_the_canvas_draw_list() { assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Circle(_))), "the full tip is not silently shrunk"); } +#[test] +#[cfg(target_os = "linux")] +fn scaled_wayland_paints_the_brush_outline_instead_of_an_os_bitmap() { + // Hyprland ignores the viewport winit sizes cursor bitmaps with: at 1.25 the outline was + // 1.25× too large and its centre 0.2 × its half-width right of and below where the brush + // painted. Off scale 1 on Wayland the outline is painted at the pointer, at its true size. + let run = |ctx: &egui::Context, native_ppp: f32| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native_ppp); + let mut output = ctx.run_ui(input, |ui| { + let icon = circle(ui.painter(), pos2(100.0, 100.0), 20.0, false); + ui.ctx().set_cursor_icon(icon); + }); + output.textures_delta.clear(); + let circles: Vec<_> = + output.shapes.iter().filter_map(|s| if let egui::Shape::Circle(c) = &s.shape { Some((c.center, c.radius)) } else { None }).collect(); + (output.platform_output.cursor_image.is_some(), output.platform_output.cursor_icon, circles) + }; + let ctx = egui::Context::default(); + ctx.add_plugin(CursorLifecycle); + set_wayland(&ctx, true); + for ppp in [1.25, 1.5, 2.0] { + let (bitmap, icon, circles) = run(&ctx, ppp); + assert!(!bitmap, "{ppp}: no OS bitmap"); + assert_eq!(icon, CursorIcon::None, "{ppp}: the painted outline is the pointer"); + assert!(!circles.is_empty() && circles.iter().all(|&(c, r)| c == pos2(100.0, 100.0) && r == 20.0), "{ppp}: {circles:?}"); + } + let (bitmap, _, circles) = run(&ctx, 1.0); + assert!(bitmap && circles.is_empty(), "Wayland at scale 1 keeps the low-latency bitmap"); + set_wayland(&ctx, false); + let (bitmap, _, circles) = run(&ctx, 1.25); + assert!(bitmap && circles.is_empty(), "X11 at 1.25 keeps the bitmap"); +} + /// Run explicitly for visual QA: native cursors are absent from GPU screenshots. #[test] #[ignore = "writes target/tool-cursor-preview.png for visual inspection"] From 7edc7aa03df5aef247a41c1bf71d5aabd7f67ebe Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 15:22:48 +1100 Subject: [PATCH 69/90] Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) (#4323) Aligning the decoded D90 sensor plane with the camera's own JPEG puts the image at (10, 10) in the 4352x2868 sensor plane, giving the camera's 4288x2848 frame (10-pixel margins; the right border reads the camera's flat 255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the camera's frame instead of the uncropped plane, and make a measured profile leave a file that carries its own colour calibration (a DNG) untouched. Co-authored-by: Brandon Thomas --- crates/raw/README.md | 3 ++- crates/raw/src/cameras.rs | 40 ++++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/gaps.md | 5 +++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/raw/README.md b/crates/raw/README.md index fda140d75b..b66b533616 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -123,7 +123,8 @@ smallest preview at least 512 pixels long, fits and develops in one go. Camera Raw's tuned import fits at the default exposure and as-shot white balance first, then applies the user's Exposure, Temperature and Tint to that same rendering. The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 -sensor plane; it removes the border without changing levels, WB or calibration. +sensor plane; the D90's is 4288 × 2848 at (10, 10) in a 4352 × 2868 plane; each +removes the border without changing levels, WB or calibration. Measured image areas preserve an explicit file crop. For a slightly tighter camera JPEG, fitting can align the reference proxy using a bounded diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index 7439433c3e..84204c6bce 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -127,6 +127,10 @@ const PROFILES: &[Profile] = &[Profile { /// 4992 x 3280 sensor data, giving the camera JPEG's 4928 x 3264 frame. /// * **ILCE-7** (2 files): 12 x 12 pixels in from the left and top of the /// 6048 x 4024 cRAW data (36 and 12 pixels of margin on the right and bottom). +/// * **D90** (1 file): 10 x 10 pixels in from the left and top of the +/// 4352 x 2868 sensor data, giving the camera JPEG's 4288 x 2848 frame +/// (10 pixels of margin on the right and bottom; the right border reads the +/// camera's flat 255 from column 4310 on). struct ImageArea { make: &'static str, model: &'static str, @@ -137,6 +141,7 @@ struct ImageArea { } const IMAGE_AREAS: &[ImageArea] = &[ + ImageArea { make: "NIKON CORPORATION", model: "NIKON D90", size: (4352, 2868), crop: (10, 10, 4288, 2848) }, ImageArea { make: "NIKON CORPORATION", model: "NIKON D5100", size: (4992, 3280), crop: (8, 8, 4928, 3264) }, ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) }, ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) }, @@ -152,6 +157,11 @@ pub(crate) fn apply(s: &mut Sensor) { s.crop = Rect::new(x, y, w, h); } let Some(p) = PROFILES.iter().find(|p| make == p.make && model == p.model && (s.width, s.height) == p.size) else { return }; + // A raw with its own colour calibration (a DNG) keeps it: only the measured image area + // above applies. The measured profile stands in for files that carry no matrix themselves. + if !s.color.calibrations.is_empty() { + return; + } let (x, y, w, h) = p.crop; s.crop = Rect::new(x, y, w, h); s.white = [p.white; 3]; @@ -265,6 +275,36 @@ mod tests { assert_eq!(s.crop, Rect::new(0, 0, 7424, 4924)); } + #[test] + fn d90_image_area_matches_the_camera_jpeg() { + let mut s = nef("NIKON D90", 4352, 2868); + apply(&mut s); + assert_eq!(s.crop, Rect::new(10, 10, 4288, 2848)); + // Only the image area is measured: levels, colour and tone stay untouched. + assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); + assert_eq!(s.white, [16383.0; 3]); + // An explicit crop in the file wins over the measured area. + s.crop = Rect::new(0, 0, 4288, 2848); + apply(&mut s); + assert_eq!(s.crop, Rect::new(0, 0, 4288, 2848)); + } + + #[test] + fn a_file_with_its_own_calibration_keeps_it() { + // A file that carries its own colour calibration (a DNG, or any calibrated raw) keeps + // it: the measured profile must not override it (only the image area would apply). + let mut s = nef("NIKON D4", 4992, 3292); + s.color = ColorInfo { + calibrations: vec![Calibration { temperature: 6504.0, color_matrix: IDENTITY, forward_matrix: None, camera_calibration: IDENTITY }], + ..ColorInfo::default() + }; + apply(&mut s); + assert_eq!(s.color.calibrations.len(), 1); + assert_eq!(s.color.calibrations[0].temperature, 6504.0, "the file's own calibration stands"); + assert!(s.tone_curve.is_empty() && s.black_point == 0.0); + assert_eq!(s.crop, Rect::new(0, 0, 4992, 3292), "the D4 has no measured image area"); + } + #[test] fn other_cameras_and_sizes_are_left_alone() { for (model, w, h) in [("NIKON D4S", 4992, 3292), ("NIKON D4", 2496, 1646), ("NIKON D800", 7424, 4924)] { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2651f4c4d1..ae0806374d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -66,7 +66,7 @@ supports many more, including CR3). | DNG (incl. LJPEG and JPEG XL tiles) | yes; lossy DNG no | FILE-215-11 | | Canon CR2 | yes | | | Canon CR3 | detected, unsupported | `crates/raw/src/lib.rs:219` | -| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 image area measured on three 14-bit lossy NEFs; [validation](nikon-nef-development.md) | +| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 and D90 image areas measured (14-bit NEFs); [validation](nikon-nef-development.md) | | Sony ARW/SR2/SRF (incl. compressed) | yes | | | Panasonic RW2, Pentax PEF | yes | | | Olympus ORF | uncompressed only | | diff --git a/docs/gaps.md b/docs/gaps.md index 52b467b2dd..d90ce1b74d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -129,6 +129,10 @@ what an agent can't finish alone. tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a measured D5100 colour calibration; [validation](nikon-nef-development.md). +- **Progress (2026-10-13):** the D90's image area (4288 × 2848 at (10, 10) in a + 4352 × 2868 plane) is measured by aligning the decoded plane with the camera's own + JPEG, so a D90 NEF opens on the camera's frame instead of the uncropped sensor plane; + `crates/raw/src/cameras.rs` (`IMAGE_AREAS`). - **Estimate:** 120–200 h. ### G15. AI and generative features @@ -263,6 +267,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | | 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | From 5dafbeeafcb7bc59042a8691bd3bce607969a121 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Thu, 15 Oct 2026 15:23:03 +1100 Subject: [PATCH 70/90] Codecs: open Cineon (.cin) film frames read-only (#4691) * Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) Aligning the decoded D90 sensor plane with the camera's own JPEG puts the image at (10, 10) in the 4352x2868 sensor plane, giving the camera's 4288x2848 frame (10-pixel margins; the right border reads the camera's flat 255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the camera's frame instead of the uncropped plane, and make a measured profile leave a file that carries its own colour calibration (a DNG) untouched. * Codecs: open Cineon (.cin) film frames read-only Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from the same Kodak header family: the generic header (big-endian, orientation at 192, channel count at 193, the channel-1 bits/dimensions), one or three channels, three 10-bit samples packed into each 32-bit word on a word boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the file's log black/white points are a display choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 is Unsupported; truncation errors rather than panicking. Read-only (no Cineon writer), so .cin joins the open filters and Cineon read closes part of #1049's G10 gap. * cargo fmt the extension lists Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 4 +- apps/photocraft/src/services.rs | 4 +- crates/codecs/README.md | 11 ++- crates/codecs/src/codecs/dpx.rs | 128 +++++++++++++++++++++++++++++ crates/codecs/src/format.rs | 17 +++- crates/codecs/src/lib.rs | 3 + crates/codecs/tests/detect_caps.rs | 11 +++ docs/file-format-parity.md | 2 +- docs/gaps.md | 7 +- 9 files changed, 178 insertions(+), 9 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index d5004334c1..eb350d17eb 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", + "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 36c7c2ccf5..ae2b3fae84 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", - "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", + "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..d4f7094894 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -47,6 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | +| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -93,7 +94,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. Other descriptors, signed or run-length-encoded data and other bit depths return - `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. + `CodecError::Unsupported` naming the field. + +* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), + read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray) + or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word + boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white + reference points are a log-conversion choice and are not applied. A depth other than 10 bits or + a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than + panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index 8b772707cc..091403d641 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -337,6 +337,78 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +// ---------------------------------------------------------------- Cineon (Kodak .cin) +// +// Cineon is the 10-bit film-scan format DPX grew from (same Kodak header family, big-endian). +// The generic header is 1024 bytes: the file information fields, then at offset 192 the image +// orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors +// (the first carries the bits per pixel and the line/image dimensions). Image data is three +// 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as +// DPX's 10-bit packing method A, so the unpacking is shared. + +/// The Cineon magic number (`802A5FD7` hex), big-endian. +pub(crate) fn is_cineon(b: &[u8]) -> bool { + b.get(..4) == Some(&[0x80, 0x2A, 0x5F, 0xD7]) +} + +/// The EXIF orientation (1–8) for a Cineon header's orientation code (the same 0–7 order as DPX). +pub(crate) fn cineon_orientation(b: &[u8]) -> u16 { + let Some(code) = b.get(192).copied() else { return 1 }; + match code { + 1 => 2, + 2 => 4, + 3 => 3, + 4 => 5, + 5 => 6, + 6 => 8, + 7 => 7, + _ => 1, + } +} + +/// Decode a Cineon (`.cin`) frame: 10-bit logarithmic film scans, big-endian, one (gray) or +/// three (RGB) channels. The 10-bit code values become U16 as `round(c × 65535 / 1023)`; the +/// file's black/white reference points are not applied (a log conversion is a display choice). +pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result { + if !is_cineon(bytes) { + return Err(err("missing the Cineon magic number (802A5FD7)")); + } + let o = Order::Big; + let missing = |what: &str| err(format!("the header ends before {what}")); + let image_offset = usize::try_from(o.u32(bytes, 4).ok_or_else(|| missing("the image data offset"))?).map_err(|_| err("image data offset out of range"))?; + let channels = *bytes.get(193).ok_or_else(|| missing("the channel count"))?; + let bits = *bytes.get(198).ok_or_else(|| missing("the bits per pixel"))?; + if bits != 10 { + return Err(unsupported(format!("{bits}-bit samples (Cineon frames are 10-bit)"))); + } + let layout = match channels { + 1 => ChannelLayout::Gray, + 3 => ChannelLayout::Rgb, + n => return Err(unsupported(format!("{n} channels (Cineon frames have 1 or 3)"))), + }; + let width = o.u32(bytes, 200).ok_or_else(|| missing("the pixels per line"))?; + let height = o.u32(bytes, 204).ok_or_else(|| missing("the lines per image"))?; + limits.check(width, height, layout, SampleType::U16)?; + + let (w, h) = (width as usize, height as usize); + let per_line = w.checked_mul(layout.channels()).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + // Three 10-bit samples per 32-bit word, lines on a word boundary (DPX packing 1). + let line_len = line_bytes(per_line, 10, 1).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + let data = bytes.get(image_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?; + let need = line_len.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?; + if data.len() < need { + return Err(err("truncated image data")); + } + let mut codes = vec![0u16; per_line.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?]; + for (y, row) in codes.chunks_exact_mut(per_line).enumerate() { + let start = y * line_len; + let line = data.get(start..start + line_len).ok_or_else(|| err("truncated image data"))?; + unpack_line(line, o, 10, 1, row)?; + } + let wide: Vec = codes.iter().map(|&c| widen(c, 1023)).collect(); + Image::from_u16(width, height, layout, &wide) +} + /// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { let mut text = Vec::new(); @@ -653,4 +725,60 @@ mod tests { let img = decode(&b, &Limits::default()).unwrap(); assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]); } + + /// A minimal Cineon: a 1024-byte generic header, then `w × h` 10-bit samples packed three + /// to a 32-bit word (RGB in word order, or gray three pixels per word). + fn cineon(w: u32, h: u32, channels: u8, px: &[[u16; 3]]) -> Vec { + let mut b = vec![0u8; 1024]; + b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); // magic (802A5FD7) + b[4..8].copy_from_slice(&1024u32.to_be_bytes()); // image data offset + b[192] = 0; // orientation: left-to-right, top-to-bottom + b[193] = channels; + b[198] = 10; // bits per pixel (channel 1) + b[200..204].copy_from_slice(&w.to_be_bytes()); // pixels per line + b[204..208].copy_from_slice(&h.to_be_bytes()); // lines per image + let word = |a: u16, c: u16, e: u16| ((u32::from(a) << 22) | (u32::from(c) << 12) | (u32::from(e) << 2)).to_be_bytes(); + if channels == 3 { + for p in px { + b.extend_from_slice(&word(p[0], p[1], p[2])); + } + } else { + let g = |i: usize| px.get(i).map_or(0, |p| p[0]); + for i in (0..px.len()).step_by(3) { + b.extend_from_slice(&word(g(i), g(i + 1), g(i + 2))); + } + } + b + } + + #[test] + fn cineon_reads_ten_bit_rgb() { + let px = [[0, 512, 1023], [1023, 0, 256]]; + let b = cineon(2, 1, 3, &px); + assert!(is_cineon(&b)); + assert_eq!(order(&b), None, "Cineon is not DPX"); + let img = decode_cineon(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb)); + assert_eq!( + img.to_u16_samples().unwrap(), + vec![widen(0, 1023), widen(512, 1023), widen(1023, 1023), widen(1023, 1023), widen(0, 1023), widen(256, 1023)] + ); + } + + #[test] + fn cineon_reads_grayscale_and_rejects_other_depths() { + let px = [[10, 0, 0], [20, 0, 0], [30, 0, 0], [40, 0, 0]]; + let b = cineon(4, 1, 1, &px); + let img = decode_cineon(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.to_u16_samples().unwrap(), vec![widen(10, 1023), widen(20, 1023), widen(30, 1023), widen(40, 1023)]); + // A depth other than 10 is unsupported, and truncation errors rather than panicking. + let mut bad = cineon(1, 1, 3, &[[0, 0, 0]]); + bad[198] = 8; + assert!(matches!(decode_cineon(&bad, &Limits::default()), Err(CodecError::Unsupported { .. }))); + let full = cineon(4, 4, 3, &[[0; 3]; 16]); + for cut in [0, 4, 200, 1024, full.len() - 1] { + assert!(decode_cineon(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}"); + } + } } diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index d3730c08fb..2d330cc976 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -33,6 +33,8 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, + /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only. + Cineon, } /// What a format can hold **and** what this crate reads/writes for it. @@ -78,6 +80,11 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.", ), + ( + Format::Cineon, + "Cineon frames are imported for film work; this crate has no Cineon writer, so save an \ + imported frame as DPX, TIFF or EXR instead.", + ), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -95,7 +102,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 19] = [ + pub const ALL: [Format; 20] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -115,6 +122,7 @@ impl Format { Format::Heif, Format::Fits, Format::Dpx, + Format::Cineon, ]; pub fn caps(self) -> FormatCaps { @@ -148,6 +156,7 @@ impl Format { Format::Heif => "HEIF", Format::Fits => "FITS", Format::Dpx => "DPX", + Format::Cineon => "Cineon", } } @@ -174,6 +183,7 @@ impl Format { Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], Format::Dpx => &["dpx"], + Format::Cineon => &["cin"], } } @@ -198,6 +208,7 @@ impl Format { Format::Heif => "image/heif", Format::Fits => "image/fits", Format::Dpx => "image/x-dpx", + Format::Cineon => "image/x-cin", } } @@ -253,6 +264,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, + Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, } } @@ -307,6 +319,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { return Some(Format::Dpx); } + if crate::codecs::dpx::is_cineon(b) { + return Some(Format::Cineon); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3831cf654f..eb010a758f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -79,6 +79,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), + Format::Cineon => dpx::decode_cineon(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -97,6 +98,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Tiff => tiff::orientation(bytes, None), Format::Heif => 1, Format::Dpx => dpx::orientation(bytes), + Format::Cineon => dpx::cineon_orientation(bytes), _ => img.meta.exif.as_deref().map_or(1, exif_orientation), }; finish_decode(img, o, opts) @@ -252,6 +254,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), + Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 22bf227eb8..f2191e05f1 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -187,6 +187,17 @@ fn detect_magic_hdr() { assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr)); } #[test] +fn detect_magic_cineon() { + let mut b = vec![0u8; 208]; + b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); + assert_eq!(detect(&b), Some(Format::Cineon)); + assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon)); + assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only"); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); + // A bare magic is an error, never a panic. + assert!(decode_as(Format::Cineon, &b).is_err()); +} +#[test] fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ae0806374d..382034e320 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -43,7 +43,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon no | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; no Cineon, no RLE | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB, read-only | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; Cineon read-only, no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | diff --git a/docs/gaps.md b/docs/gaps.md index d90ce1b74d..d964b01eb5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -224,10 +224,12 @@ what an agent can't finish alone. ### G10. Missing and partial file formats - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the + DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. - **Estimate:** 120–220 h. ## Hardware gaps @@ -292,6 +294,7 @@ what an agent can't finish alone. | 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | | 2026-10-12 | minor | G1: CMYK Invert, Posterize and Threshold work on the inks, 16-bit Lab Invert keeps greys neutral; Photoshop-oracle floor 133 → 145 of 258 (145 already passed on main) | | 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | +| 2026-10-13 | minor | G10: Cineon read landed (10-bit gray/RGB, shares the DPX path); Cineon write remains open | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | From 4446ec6d65049b92a1b73f02e79d97ef4153135a Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Thu, 15 Oct 2026 06:32:55 +0200 Subject: [PATCH 71/90] PSD: write tiff blocks without copies, inflate merged ZIP once (#5221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) Aligning the decoded D90 sensor plane with the camera's own JPEG puts the image at (10, 10) in the 4352x2868 sensor plane, giving the camera's 4288x2848 frame (10-pixel margins; the right border reads the camera's flat 255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the camera's frame instead of the uncropped plane, and make a measured profile leave a file that carries its own colour calibration (a DNG) untouched. * Codecs: open Cineon (.cin) film frames read-only Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from the same Kodak header family: the generic header (big-endian, orientation at 192, channel count at 193, the channel-1 bits/dimensions), one or three channels, three 10-bit samples packed into each 32-bit word on a word boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the file's log black/white points are a display choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 is Unsupported; truncation errors rather than panicking. Read-only (no Cineon writer), so .cin joins the open filters and Cineon read closes part of #1049's G10 gap. * PSD: write tiff blocks without copies, inflate merged ZIP once - ImageSourceData::to_bytes writes the global mask and global blocks straight from the model with 4-byte alignment instead of cloning each block (data included) to set its padding. Same bytes. - The tiff transcoder's nested regions and blocks write into the parent's buffer (taken and handed back, error or not) instead of building their own output and copying it up per nesting level. Bodies that fail or are not transcoded are truncated before being copied as stored or dropped. Same bytes. - PsdFile::from_bytes_and_merged decodes the merged image with the parse when asked: a ZIP stream is inflated once (the decode's stream check is the parse's); the parse-only check runs only when the decode fails, to keep from_bytes' errors. PSD import uses it with merged_needed. - PsdBuilder::composite documents that Gray/CMYK composites stay straight. Co-Authored-By: Claude Opus 5.5 * Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2) Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults), and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes Photoshop's HTML list of every bound command by menu. edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use and whether it is modified. Bad names, keys or counts are errors that change nothing. * .kys import: tool keys and sets saved on Windows (K3) Photoshop's rows now import: each matches our tool by name (our tool names are Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own becomes its override, while its own letter keeps the tool cycling with the others (#5036). A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that would also mean Control on a Mac. The import reports `windows`. * cargo fmt the extension lists Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Vikram Bhaskaran Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas Co-authored-by: Tanish Manku --- crates/engine/src/kys.rs | 260 ++++++++++++++++- crates/engine/src/lib.rs | 1 + crates/engine/src/prefs.rs | 34 ++- crates/engine/src/shortcut_sets.rs | 347 +++++++++++++++++++++++ crates/engine/tests/shortcut_sets.rs | 60 ++++ crates/io/src/lib.rs | 5 +- crates/io/src/psd_import.rs | 25 +- crates/psd/README.md | 2 + crates/psd/src/builder.rs | 7 +- crates/psd/src/compression.rs | 8 + crates/psd/src/file.rs | 81 +++++- crates/psd/src/tagged.rs | 26 +- crates/psd/src/tiff.rs | 140 +++++++-- crates/ui-egui/src/i18n/cs.tsv | 14 + crates/ui-egui/src/i18n/de.tsv | 14 + crates/ui-egui/src/i18n/el.tsv | 14 + crates/ui-egui/src/i18n/es.tsv | 14 + crates/ui-egui/src/i18n/fr.tsv | 14 + crates/ui-egui/src/i18n/hu.tsv | 14 + crates/ui-egui/src/i18n/id.tsv | 14 + crates/ui-egui/src/i18n/it.tsv | 14 + crates/ui-egui/src/i18n/ja.tsv | 14 + crates/ui-egui/src/i18n/ko.tsv | 14 + crates/ui-egui/src/i18n/nl.tsv | 14 + crates/ui-egui/src/i18n/pl.tsv | 14 + crates/ui-egui/src/i18n/pt-br.tsv | 14 + crates/ui-egui/src/i18n/ru.tsv | 14 + crates/ui-egui/src/i18n/sr.tsv | 14 + crates/ui-egui/src/i18n/tr.tsv | 14 + crates/ui-egui/src/i18n/uk.tsv | 14 + crates/ui-egui/src/i18n/vi.tsv | 14 + crates/ui-egui/src/i18n/zh-hans.tsv | 14 + crates/ui-egui/src/i18n/zh-hant.tsv | 14 + crates/ui-egui/src/kys_import.rs | 28 +- crates/ui-egui/src/kys_import_tests.rs | 41 ++- crates/ui-egui/src/prefs_ui.rs | 190 ++++++++++++- crates/ui-egui/src/shortcut_set_tests.rs | 128 +++++++++ docs/control-protocol.md | 2 +- docs/file-format-parity.md | 2 +- docs/scorecard.md | 4 +- docs/ui-parity.md | 1 + 41 files changed, 1601 insertions(+), 71 deletions(-) create mode 100644 crates/engine/src/shortcut_sets.rs create mode 100644 crates/engine/tests/shortcut_sets.rs create mode 100644 crates/ui-egui/src/shortcut_set_tests.rs diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index a274a9cc6c..f996e23475 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -12,8 +12,11 @@ //! candidate (the app lists them in Photoshop's menu order). A row's first shortcut becomes the //! command's main key, unless one of its keys is already the command's default (Zoom In lists //! `Cmd++` before `Cmd+=`); its other keys become the command's alternates (#3665: Copy is -//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys are not -//! imported: the tool letters are fixed here. +//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys match our +//! tools by name ([`plan_tools`]; the app knows the tools). A set saved on Windows spells the +//! primary modifier Ctrl where a Mac set says Cmd ([`KysSet::windows`], [`to_ours`]). +//! [`write`] saves a set the same way, with our command id on each row (`pc-id`, which +//! Photoshop ignores), so PhotoCraft reads its own sets back exactly. use std::collections::{BTreeMap, HashMap}; @@ -23,10 +26,12 @@ use crate::prefs::normalize_shortcut; pub const MAX_BYTES: usize = 4 << 20; /// One `` row: Photoshop's label and its shortcuts, first one primary. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct KysCommand { pub name: String, pub shortcuts: Vec, + /// Our command id, in sets PhotoCraft wrote ([`write`]'s `pc-id`): matched before the label. + pub id: Option, } /// The parsed set: its name, its command rows and how many tools carry a key. @@ -37,6 +42,11 @@ pub struct KysSet { pub name: Option, pub commands: Vec, pub tool_keys: usize, + /// The `` rows with a key: the tool's name, its key, our id in sets PhotoCraft wrote. + pub tools: Vec, + /// Saved by Photoshop on Windows: no key spells ⌘ or Opt, and Ctrl is the primary modifier, + /// so `Ctrl` here is our `Cmd` (a Mac set's `Control` stays the Control key). See [`to_ours`]. + pub windows: bool, } /// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows @@ -63,16 +73,62 @@ pub fn parse(text: &str) -> Result { .filter_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()).map(str::to_string)) .collect(); if !shortcuts.is_empty() { - set.commands.push(KysCommand { name: name.to_string(), shortcuts }); + let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string); + set.commands.push(KysCommand { name: name.to_string(), shortcuts, id }); + } + } + "tool" => { + let key = node.text().map(str::trim).filter(|t| !t.is_empty()); + let name = node.attribute("name").map(str::trim).filter(|n| !n.is_empty()); + if let Some(key) = key { + set.tool_keys += 1; + if let Some(name) = name { + let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string); + set.tools.push(KysCommand { name: name.to_string(), shortcuts: vec![key.to_string()], id }); + } } } - "tool" if node.text().is_some_and(|t| !t.trim().is_empty()) => set.tool_keys += 1, _ => {} } } + set.windows = windows_set(&set); Ok(set) } +/// The modifier words of `sc` (`Ctrl+Alt+K` → Ctrl, Alt), whatever the key is. +fn modifiers(sc: &str) -> impl Iterator + '_ { + let mods = sc.strip_suffix("++").unwrap_or_else(|| sc.rsplit_once('+').map_or("", |(m, _)| m)); + mods.split('+').filter(|m| !m.is_empty()).map(|m| m.trim().to_ascii_lowercase()) +} + +/// Photoshop on Windows writes Ctrl for the primary modifier, where a Mac set writes Cmd (and +/// Control for the Control key): a set with no ⌘ or Opt anywhere but some Ctrl is a Windows set. +fn windows_set(set: &KysSet) -> bool { + let keys = || set.commands.iter().flat_map(|c| c.shortcuts.iter()); + let mac = keys().flat_map(|k| modifiers(k).collect::>()).any(|m| matches!(m.as_str(), "cmd" | "command" | "opt" | "option")); + let ctrl = keys().flat_map(|k| modifiers(k).collect::>()).any(|m| matches!(m.as_str(), "ctrl" | "control")); + !mac && ctrl +} + +/// A key of `set` in our notation, normalised: on a Windows set, Ctrl (or Control) is our `Cmd` +/// (Ctrl on Windows and Linux, ⌘ on the Mac), so `Ctrl+L` is the `Cmd+L` default rather than a +/// literal Control key. +pub fn to_ours(set: &KysSet, sc: &str) -> Option { + if !set.windows { + return normalize_shortcut(sc); + } + let (mods, key) = match sc.strip_suffix("++") { + Some(m) => (m, "+"), + None => sc.rsplit_once('+').unwrap_or(("", sc)), + }; + let mods: Vec<&str> = mods + .split('+') + .filter(|m| !m.is_empty()) + .map(|m| if matches!(m.trim().to_ascii_lowercase().as_str(), "ctrl" | "control") { "Cmd" } else { m }) + .collect(); + normalize_shortcut(&mods.into_iter().chain(std::iter::once(key)).collect::>().join("+")) +} + /// What an import would change. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Plan { @@ -124,18 +180,21 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl entry.push((c.id, c.default)); } } + let by_id: HashMap<&str, &(String, Option)> = by_label.values().flatten().map(|c| (c.0.as_str(), c)).collect(); let mut plan = Plan::default(); for row in &set.commands { - let Some(candidates) = by_label.get(&key(&row.name)) else { + // A set PhotoCraft wrote names the command itself, whatever its label says. + let own = row.id.as_deref().and_then(|id| by_id.get(id)).map(|c| vec![(*c).clone()]); + let Some(candidates) = own.as_ref().or_else(|| by_label.get(&key(&row.name))) else { plan.unknown.push(row.name.clone()); continue; }; - let Some(sc) = row.shortcuts.first().and_then(|s| normalize_shortcut(s)) else { + let Some(sc) = row.shortcuts.first().and_then(|s| to_ours(set, s)) else { plan.unreadable.push(row.name.clone()); continue; }; plan.alternates += row.shortcuts.len().saturating_sub(1); - let imported: Vec = row.shortcuts.iter().filter_map(|s| normalize_shortcut(s)).collect(); + let imported: Vec = row.shortcuts.iter().filter_map(|s| to_ours(set, s)).collect(); let pick = candidates .iter() .find(|(_, d)| d.as_deref().and_then(normalize_shortcut).is_some_and(|d| imported.contains(&d))) @@ -159,6 +218,92 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl plan } +/// 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. +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(); + let mut unknown = Vec::new(); + for row in &set.tools { + let found = + row.id.as_deref().and_then(|id| tools.iter().find(|(t, _)| *t == id)).or_else(|| tools.iter().find(|(_, name)| key(name) == key(&row.name))); + let Some((id, _)) = found else { + 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()); + } + } + (keys, unknown) +} + +/// The attribute naming our command id on the rows PhotoCraft writes (Photoshop ignores it). +pub const PC_ID: &str = "pc-id"; + +/// One row to write: a command (or tool), its label and its keys, main key first. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub id: String, + pub label: String, + pub tool: bool, + pub keys: Vec, +} + +/// A key in the spelling Photoshop's macOS sets use (`Control+Opt+Shift+Cmd+G`): our `Ctrl` is +/// Control, `Alt` is Opt. `None` for a key that isn't one. +pub fn photoshop_key(sc: &str) -> Option { + let n = normalize_shortcut(sc)?; + // The key is the last part; `Cmd++` ends in an empty part, so take it from the string. + let (mods, key) = match n.strip_suffix("++") { + Some(m) => (m, "+"), + None => n.rsplit_once('+').unwrap_or(("", n.as_str())), + }; + let has = |m: &str| mods.split('+').any(|x| x == m); + let mut out: Vec<&str> = + [("Ctrl", "Control"), ("Alt", "Opt"), ("Shift", "Shift"), ("Cmd", "Cmd")].iter().filter(|(ours, _)| has(ours)).map(|(_, ps)| *ps).collect(); + out.push(key); + Some(out.join("+")) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'") +} + +/// A `.kys` file for the set `name` (Edit › Keyboard Shortcuts › Save Set): every entry with a +/// key, commands then tools, labels with Photoshop's `...`, keys in [`photoshop_key`] spelling, +/// and our id on each row so PhotoCraft reads its own sets back exactly. Rows without a key are +/// left out, and a key that isn't one is skipped. +pub fn write(name: &str, entries: &[Entry]) -> String { + let name = xml_escape(name.trim()); + let mut out = format!( + "\u{feff}\n\n" + ); + let row = |e: &Entry| -> Option { + let keys: Vec = e.keys.iter().filter_map(|k| photoshop_key(k)).collect(); + let first = keys.first()?; + let label = xml_escape(&e.label.replace('…', "...")); + let id = xml_escape(&e.id); + Some(if e.tool { + format!("\t{}\n", xml_escape(first)) + } else { + let shortcuts: String = keys.iter().map(|k| format!("{}", xml_escape(k))).collect(); + format!("\t{shortcuts}\n") + }) + }; + for e in entries.iter().filter(|e| !e.tool).chain(entries.iter().filter(|e| e.tool)) { + if let Some(r) = row(e) { + out.push_str(&r); + } + } + out.push_str("\n"); + out +} + #[cfg(test)] mod tests { use super::*; @@ -211,4 +356,103 @@ mod tests { assert!(parse(&big).unwrap_err().contains("4 MiB")); assert!(parse("").unwrap_err().contains("not a keyboard shortcut set")); } + + #[test] + fn written_sets_read_back_exactly_and_keys_use_photoshops_spelling() { + assert_eq!(photoshop_key("Cmd+Alt+Shift+Ctrl+g").as_deref(), Some("Control+Opt+Shift+Cmd+G")); + assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++")); + assert_eq!(photoshop_key("F3").as_deref(), Some("F3")); + assert_eq!(photoshop_key("Cmd+Shift+Ctrl"), None); + let entries = vec![ + Entry { id: "edit.copy".into(), label: "Copy".into(), tool: false, keys: vec!["Cmd+C".into(), "F3".into()] }, + Entry { id: "x.odd".into(), label: "Levels…".into(), tool: false, keys: vec!["Cmd+Alt+L".into()] }, + Entry { id: "tools.select.move".into(), label: "Move Tool".into(), tool: true, keys: vec!["V".into()] }, + Entry { id: "none".into(), label: "Unbound".into(), tool: false, keys: vec![] }, + Entry { id: "bad".into(), label: "A <\"weird\"> & 'name'".into(), tool: false, keys: vec!["Cmd+Shift+Ctrl".into(), "F9".into()] }, + ]; + let xml = write(" My & Set ", &entries); + assert!(xml.contains("KeyboardCustomization/My & Set=My & Set"), "{xml}"); + assert!(xml.contains("Opt+Cmd+L"), "{xml}"); + assert!(!xml.contains("Unbound"), "{xml}"); + assert!(xml.find("Move Tool").unwrap() > xml.find("Levels").unwrap(), "tools after commands"); + let set = parse(&xml).unwrap(); + assert_eq!(set.name.as_deref(), Some("My & Set")); + assert_eq!(set.tool_keys, 1); + assert_eq!(set.commands.len(), 3); + assert_eq!(set.commands[0], KysCommand { name: "Copy".into(), shortcuts: vec!["Cmd+C".into(), "F3".into()], id: Some("edit.copy".into()) }); + assert_eq!(set.commands[2].name, "A <\"weird\"> & 'name'"); + assert_eq!(set.commands[2].shortcuts, vec!["F9".to_string()]); + // The id wins over the label: a renamed or translated label still finds its command. + let cands = || { + vec![ + Candidate { id: "x.odd".into(), label: "Something Else".into(), default: None }, + Candidate { id: "y.levels".into(), label: "Levels".into(), default: Some("Cmd+L".into()) }, + ] + }; + let p = plan(cands(), &set); + assert_eq!(p.set.get("x.odd").map(String::as_str), Some("Cmd+Alt+L")); + assert!(!p.set.contains_key("y.levels")); + // Without the id, the label matches as before. + let mut plain = set.clone(); + plain.commands[1].id = None; + assert!(plan(cands(), &plain).set.contains_key("y.levels")); + } + + #[test] + fn a_windows_set_means_cmd_by_ctrl_and_a_mac_set_keeps_control() { + let win = " +\tCtrl+L +\tCtrl+Alt+Shift+G +\tCtrl++Ctrl+= +"; + let set = parse(win).unwrap(); + assert!(set.windows); + assert_eq!(to_ours(&set, "Ctrl+L").as_deref(), Some("Cmd+L")); + assert_eq!(to_ours(&set, "Ctrl+Alt+Shift+G").as_deref(), normalize_shortcut("Cmd+Alt+Shift+G").as_deref()); + assert_eq!(to_ours(&set, "Ctrl++").as_deref(), Some("Cmd++")); + assert_eq!(to_ours(&set, "F3").as_deref(), Some("F3")); + let cands = vec![Candidate { id: "image.levels".into(), label: "Levels…".into(), default: Some("Cmd+L".into()) }]; + assert_eq!(plan(cands, &set).set.get("image.levels").map(String::as_str), Some("Cmd+L"), "the default, not a Control override"); + // A Mac set spells ⌘ Cmd: its Control is the Control key. + let mac = parse( + "Cmd+LControl+Opt+Cmd+GControl+K", + ) + .unwrap(); + assert!(!mac.windows); + assert_eq!(to_ours(&mac, "Control+K"), normalize_shortcut("Ctrl+K")); + // No modifiers at all: nothing to tell, nothing to change. + let plain = parse("F3").unwrap(); + assert!(!plain.windows); + } + + #[test] + fn tool_keys_match_by_name_or_id_and_take_one_letter() { + let xml = " +\tV +\tn +\tB +\tQ +\tShift+L +\t +\tZ +"; + let set = parse(xml).unwrap(); + assert_eq!((set.tool_keys, set.tools.len()), (6, 5), "a key without a name counts but can't be matched"); + let tools = [ + ("tools.select.move", "Move Tool"), + ("tools.select.pencil", "Pencil Tool"), + ("tools.select.brush", "Brush Tool"), + ("tools.select.lasso", "Lasso Tool"), + ]; + let (keys, unknown) = plan_tools(tools, &set); + 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_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()] }]); + let (keys, _) = plan_tools(tools, &parse(&out).unwrap()); + assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("F")); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 75914dbdbb..6512a5047f 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 shortcut_sets; pub mod slice_cmds; pub mod smart_cmds; pub mod smartselect_cmds; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d6b40178d3..17117153c5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -938,6 +938,10 @@ pub struct Preferences { /// ([`ALTERNATE_SHORTCUTS`]); an empty list removes them. Settings files from before this /// map load with nothing stored, so every command keeps its defaults. pub alternate_shortcuts: BTreeMap>, + /// Edit › Keyboard Shortcuts › Set: saved shortcut sets by name (`crate::shortcut_sets`). + pub shortcut_sets: BTreeMap, + /// The saved set in use ("" for the defaults). + pub shortcut_set: String, pub menus: MenuCustomization, pub toolbar: ToolbarCustomization, /// Edit › Check Spelling: words added to the dictionary ("Add"). @@ -1837,7 +1841,8 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let cmd = "edit.keyboardShortcuts"; // Every change lands at once, after all of it validated: a bad key leaves the set as it was. let mut next = s.prefs().clone(); - let mut changed = false; + // A set loads first, so the call's other changes apply on top of it. + let mut changed = crate::shortcut_sets::load(&mut next, p).map_err(|e| bad(cmd, e))?; if let Some(r) = p.get("reset") { match r { Value::Bool(true) => { @@ -1887,6 +1892,7 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { "alternates": plan.alternates, "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, + "windows": set.windows, })); let mut with_import = p.clone(); with_import["set"] = Value::Object(merged); @@ -1951,6 +1957,8 @@ 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); } @@ -1990,6 +1998,28 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let conflicts: Vec = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().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). + 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) { + 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)); + } + if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) { + 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) { + 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)); + } if let Some(import) = import { out["import"] = import; } @@ -2121,7 +2151,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>"?}"##, + 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}?}"##, keyboard_shortcuts, true ), diff --git a/crates/engine/src/shortcut_sets.rs b/crates/engine/src/shortcut_sets.rs new file mode 100644 index 0000000000..c5fc4993fe --- /dev/null +++ b/crates/engine/src/shortcut_sets.rs @@ -0,0 +1,347 @@ +//! Edit › Keyboard Shortcuts › Set: named shortcut sets, as in Photoshop. The bindings in use +//! stay in [`Preferences::shortcuts`] / [`Preferences::alternate_shortcuts`] (what dispatch +//! reads); a set is a saved copy of them, `Preferences::shortcut_sets` keyed by name, and +//! `Preferences::shortcut_set` names the one in use ("" for the defaults). The bindings differ +//! from their set after an edit until it is saved ("modified", as Photoshop marks the set). +//! A set saves to and loads from a `.kys` file (`crate::kys::write` / the import), and +//! [`summarize`] writes the HTML table of Photoshop's Summarize button. +//! +//! `edit.keyboardShortcuts` takes the set parameters: `useSet` (load a set's bindings, before +//! the call's other changes), then `saveSet` (save the bindings as they end up under a name and +//! use it), `deleteSet`, `sets` (replace every saved set: the dialog's OK) and `activeSet`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::prefs::{Preferences, alternate_keys, normalize_shortcut}; + +/// The built-in set's name: the default bindings. +pub const DEFAULTS_NAME: &str = "PhotoCraft Defaults"; +/// Saved sets are small; the cap keeps a hostile settings file or call bounded. +pub const MAX_SETS: usize = 64; +/// Longest set name, in characters. +pub const MAX_NAME: usize = 64; + +/// A saved set: the overrides of the default bindings, in the shape the preferences keep them. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ShortcutSet { + /// Command id → main key; "" removes the default key. + pub shortcuts: BTreeMap, + /// Command id → further keys, replacing the defaults. + pub alternate_shortcuts: BTreeMap>, +} + +impl ShortcutSet { + /// The bindings `p` uses now. + pub fn of(p: &Preferences) -> Self { + Self { shortcuts: p.shortcuts.clone(), alternate_shortcuts: p.alternate_shortcuts.clone() } + } + + /// Use this set's bindings in `p`. + pub fn load_into(&self, p: &mut Preferences) { + p.shortcuts = self.shortcuts.clone(); + p.alternate_shortcuts = self.alternate_shortcuts.clone(); + } + + /// Check every key (a set from a call or a hand-edited file must not hold garbage). + fn validate(&self) -> Result<(), String> { + for (id, k) in &self.shortcuts { + if !k.is_empty() && normalize_shortcut(k).is_none() { + return Err(format!("`{k}` for `{id}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)")); + } + } + for (id, keys) in &self.alternate_shortcuts { + alternate_keys(&json!(keys)).map_err(|e| format!("`{id}`: {e}"))?; + } + Ok(()) + } +} + +/// A set name as given, trimmed; an error for an empty, overlong or reserved name. +pub fn set_name(name: &str) -> Result { + let n = name.trim(); + if n.is_empty() { + return Err("a set needs a name".into()); + } + if n.chars().count() > MAX_NAME { + return Err(format!("a set name is at most {MAX_NAME} characters")); + } + if n.chars().any(char::is_control) { + return Err("a set name can't contain control characters".into()); + } + if n.eq_ignore_ascii_case(DEFAULTS_NAME) { + return Err(format!("`{DEFAULTS_NAME}` is the built-in set")); + } + Ok(n.to_string()) +} + +/// The saved copy of the set in use: `None` for the defaults (or a set that no longer exists). +fn saved(p: &Preferences) -> Option<&ShortcutSet> { + p.shortcut_sets.get(&p.shortcut_set) +} + +/// Do the bindings differ from the set in use? +pub fn modified(p: &Preferences) -> bool { + let now = ShortcutSet::of(p); + match saved(p) { + Some(s) => *s != now, + None => now != ShortcutSet::default(), + } +} + +/// Before the call's binding changes: `useSet` loads a set ("" or [`DEFAULTS_NAME`]: the +/// defaults) and makes it the one in use. Returns whether it changed anything. +pub fn load(next: &mut Preferences, p: &Value) -> Result { + let Some(v) = p.get("useSet") else { return Ok(false) }; + let name = v.as_str().ok_or("`useSet` is a set name")?.trim(); + if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) { + ShortcutSet::default().load_into(next); + next.shortcut_set = String::new(); + } else { + let set = next.shortcut_sets.get(name).cloned().ok_or_else(|| format!("no shortcut set named `{name}`"))?; + set.load_into(next); + next.shortcut_set = name.to_string(); + } + Ok(true) +} + +/// After the call's binding changes: `sets` (replace every saved set), `deleteSet`, `saveSet` +/// (save the bindings under a name and use it) and `activeSet`. Returns whether it changed +/// anything. +pub fn store(next: &mut Preferences, p: &Value) -> Result { + let mut changed = false; + if let Some(v) = p.get("sets") { + let obj = v.as_object().ok_or("`sets` maps set names to {shortcuts, alternateShortcuts}")?; + if obj.len() > MAX_SETS { + return Err(format!("at most {MAX_SETS} shortcut sets")); + } + let mut sets = BTreeMap::new(); + for (name, s) in obj { + let name = set_name(name)?; + let set: ShortcutSet = serde_json::from_value(s.clone()).map_err(|e| format!("set `{name}`: {e}"))?; + set.validate().map_err(|e| format!("set `{name}`: {e}"))?; + sets.insert(name, set); + } + next.shortcut_sets = sets; + changed = true; + } + if let Some(v) = p.get("deleteSet") { + let name = v.as_str().ok_or("`deleteSet` is a set name")?.trim(); + if next.shortcut_sets.remove(name).is_none() { + return Err(format!("no shortcut set named `{name}`")); + } + if next.shortcut_set == name { + next.shortcut_set = String::new(); + } + changed = true; + } + if let Some(v) = p.get("saveSet") { + let name = set_name(v.as_str().ok_or("`saveSet` is a set name")?)?; + if !next.shortcut_sets.contains_key(&name) && next.shortcut_sets.len() >= MAX_SETS { + return Err(format!("at most {MAX_SETS} shortcut sets")); + } + next.shortcut_sets.insert(name.clone(), ShortcutSet::of(next)); + next.shortcut_set = name; + changed = true; + } + if let Some(v) = p.get("activeSet") { + let name = v.as_str().ok_or("`activeSet` is a set name")?.trim(); + if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) { + next.shortcut_set = String::new(); + } else if next.shortcut_sets.contains_key(name) { + next.shortcut_set = name.to_string(); + } else { + return Err(format!("no shortcut set named `{name}`")); + } + changed = true; + } + Ok(changed) +} + +/// The sets, the one in use and whether the bindings differ from it, for the command's reply. +pub fn describe(p: &Preferences) -> Value { + json!({ + "sets": p.shortcut_sets.keys().collect::>(), + "activeSet": if saved(p).is_some() { p.shortcut_set.as_str() } else { "" }, + "defaultsName": DEFAULTS_NAME, + "modified": modified(p), + }) +} + +/// A key as the Summarize table shows it on `mac` (⌘ spelled Cmd, ⌥ Option) or elsewhere +/// (Ctrl, Alt). +fn key_label(sc: &str, mac: bool) -> String { + let Some(n) = normalize_shortcut(sc) else { return sc.to_string() }; + // `Cmd++` splits into an empty part before the key, which joins back as `++`. + let mut parts: Vec<&str> = Vec::new(); + for part in n.split('+') { + let shown = match (part, mac) { + ("Ctrl", true) => "Control", + ("Cmd", false) | ("Ctrl", false) => "Ctrl", + ("Alt", true) => "Option", + (p, _) => p, + }; + // Off the Mac, ⌘ and Control are both Ctrl: say it once. + if shown == "Ctrl" && parts.contains(&"Ctrl") { + continue; + } + parts.push(shown); + } + parts.join("+") +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// A Summarize row: a command's label and its keys, the main one first. +pub type Row = (String, Vec); +/// A Summarize section: its title and rows. +pub type Section = (String, Vec); + +/// Photoshop's Summarize: an HTML page listing every command with a key, by section +/// (`sections` in display order: section title, then (command label, keys with the main one +/// first)). Commands without a key are left out. +pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String { + let mut out = String::new(); + out.push_str("\n"); + out.push_str(&html_escape(set)); + out.push_str("\n\n\n

"); + out.push_str(&html_escape(set)); + out.push_str("

\n"); + for (title, rows) in sections { + let rows: Vec<&Row> = rows.iter().filter(|(_, keys)| !keys.is_empty()).collect(); + if rows.is_empty() { + continue; + } + out.push_str("

"); + out.push_str(&html_escape(title)); + out.push_str("

\n\n"); + for (label, keys) in rows { + let keys: Vec = keys.iter().map(|k| html_escape(&key_label(k, mac))).collect(); + out.push_str("\n"); + } + out.push_str("
CommandShortcut
"); + out.push_str(&html_escape(label)); + out.push_str(""); + out.push_str(&keys.join(", ")); + out.push_str("
\n"); + } + out.push_str("\n"); + 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)| { + 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)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn prefs() -> Preferences { + Preferences::default() + } + + #[test] + fn save_use_modify_and_delete_a_set() { + let mut p = prefs(); + assert!(!modified(&p)); + p.shortcuts.insert("edit.copy".into(), "F9".into()); + assert!(modified(&p), "an edit to the defaults"); + assert!(store(&mut p, &json!({"saveSet": " Mine "})).unwrap()); + assert_eq!((p.shortcut_set.as_str(), modified(&p)), ("Mine", false)); + p.shortcuts.insert("edit.cut".into(), "F8".into()); + assert!(modified(&p)); + // Back to the defaults, then to the saved set as it was saved. + assert!(load(&mut p, &json!({"useSet": ""})).unwrap()); + assert!(p.shortcuts.is_empty() && p.shortcut_set.is_empty() && !modified(&p)); + load(&mut p, &json!({"useSet": "Mine"})).unwrap(); + assert_eq!(p.shortcuts.get("edit.copy").map(String::as_str), Some("F9")); + assert_eq!(p.shortcuts.get("edit.cut"), None, "the unsaved edit isn't in the set"); + assert_eq!(describe(&p)["sets"], json!(["Mine"])); + // Deleting the set in use falls back to the defaults' name; the bindings stay. + store(&mut p, &json!({"deleteSet": "Mine"})).unwrap(); + assert!(p.shortcut_sets.is_empty() && p.shortcut_set.is_empty()); + assert_eq!(describe(&p)["activeSet"], ""); + assert!(modified(&p), "F9 differs from the defaults"); + } + + #[test] + fn bad_names_sets_and_params_are_errors() { + let mut p = prefs(); + for name in ["", " ", "PhotoCraft defaults", "a\u{7}b", &"x".repeat(65)] { + assert!(store(&mut p, &json!({"saveSet": name})).is_err(), "{name:?}"); + } + assert!(load(&mut p, &json!({"useSet": "Nope"})).is_err()); + assert!(load(&mut p, &json!({"useSet": 3})).is_err()); + assert!(store(&mut p, &json!({"deleteSet": "Nope"})).is_err()); + assert!(store(&mut p, &json!({"activeSet": "Nope"})).is_err()); + assert!(store(&mut p, &json!({"sets": []})).is_err()); + assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}})).is_err(), "no key"); + let nine: Vec = (1..=9).map(|i| format!("F{i}")).collect(); + assert!(store(&mut p, &json!({"sets": {"A": {"alternateShortcuts": {"edit.copy": nine}}}})).is_err()); + assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": 5}}})).is_err()); + let many: serde_json::Map = (0..=MAX_SETS).map(|i| (format!("S{i}"), json!({}))).collect(); + assert!(store(&mut p, &json!({ "sets": many })).is_err()); + for i in 0..MAX_SETS { + store(&mut p, &json!({"saveSet": format!("S{i}")})).unwrap(); + } + assert!(store(&mut p, &json!({"saveSet": "One more"})).is_err()); + assert!(store(&mut p, &json!({"saveSet": "S3"})).is_ok(), "saving over a set is fine at the cap"); + } + + #[test] + fn sets_replace_all_and_active_set_follows() { + let mut p = prefs(); + store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "F9"}}, "B": {}}, "activeSet": "A"})).unwrap(); + assert_eq!(p.shortcut_set, "A"); + assert!(modified(&p), "activeSet names the set; it doesn't load it"); + load(&mut p, &json!({"useSet": "A"})).unwrap(); + assert!(!modified(&p)); + store(&mut p, &json!({"activeSet": "PhotoCraft Defaults"})).unwrap(); + assert_eq!(p.shortcut_set, ""); + } + + #[test] + fn summarize_lists_bound_commands_by_section_for_each_platform() { + let sections = vec![ + ("Edit".to_string(), vec![("Copy".to_string(), vec!["Cmd+C".to_string(), "F3".to_string()]), ("Unbound".to_string(), vec![])]), + ("Empty".to_string(), vec![("Nothing".to_string(), vec![])]), + ("Filter ".to_string(), vec![("Gaussian Blur & more".to_string(), vec!["Ctrl+Alt+Cmd+G".to_string()])]), + ]; + let mac = summarize("My ", §ions, true); + assert!(mac.contains("My <Set>") && mac.contains("CopyCmd+C, F3"), "{mac}"); + assert!(mac.contains("Cmd+Control+Option+G") && mac.contains("Gaussian Blur & more") && mac.contains("Filter <x>"), "{mac}"); + assert!(!mac.contains("Unbound") && !mac.contains("Empty"), "{mac}"); + let pc = summarize("S", §ions, false); + assert!(pc.contains("Ctrl+C, F3") && pc.contains("Ctrl+Alt+G"), "{pc}"); + assert_eq!(key_label("Cmd++", true), "Cmd++"); + } + + #[test] + 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")); + } +} diff --git a/crates/engine/tests/shortcut_sets.rs b/crates/engine/tests/shortcut_sets.rs new file mode 100644 index 0000000000..c00e6a069d --- /dev/null +++ b/crates/engine/tests/shortcut_sets.rs @@ -0,0 +1,60 @@ +//! Edit › Keyboard Shortcuts › Set through `edit.keyboardShortcuts`: save, switch, delete, +//! export to `.kys` and back, Summarize, and bad params as errors. + +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") +} + +#[test] +fn a_set_saves_switches_exports_and_imports_back() { + let mut s = Session::new(); + let r = ks(&mut s, json!({})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["defaultsName"].as_str()), (Some(""), Some(false), Some("PhotoCraft Defaults"))); + // Edit, then save the bindings as they end up after this call's edits. + let r = ks(&mut s, json!({"set": {"edit.copy": "F9"}, "alternates": {"edit.copy": []}, "saveSet": "Mine"})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["sets"].clone()), (Some("Mine"), Some(false), json!(["Mine"]))); + // Export the set, go back to the defaults, then import the file: the bindings return. + let xml = ks(&mut s, json!({"exportKys": true}))["kys"].as_str().expect("kys").to_string(); + assert!(xml.contains("KeyboardCustomization/Mine=Mine") && xml.contains("pc-id=\"edit.copy\">F9"), "{xml}"); + let r = ks(&mut s, json!({"useSet": ""})); + assert_eq!((r["overrides"].clone(), r["activeSet"].as_str()), (json!({}), Some(""))); + let r = ks(&mut s, json!({"reset": true, "importKys": xml})); + assert_eq!(r["overrides"]["edit.copy"], "F9"); + assert_eq!(r["alternateOverrides"]["edit.copy"], json!([]), "Copy's F3 stays removed"); + assert_eq!(r["modified"], true, "imported over the defaults"); + // Switch back to the saved set; a later unsaved edit marks it modified. + ks(&mut s, json!({"useSet": "Mine"})); + let r = ks(&mut s, json!({"set": {"edit.cut": "F8"}})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool()), (Some("Mine"), Some(true))); + // Summarize lists what's bound, per platform. + let html = ks(&mut s, json!({"summarize": {"mac": false}}))["html"].as_str().expect("html").to_string(); + assert!(html.contains("Mine") && html.contains("CopyF9") && html.contains("CutF8"), "{html}"); + // Delete the set in use: the defaults' name, the bindings kept. + let r = ks(&mut s, json!({"deleteSet": "Mine"})); + assert_eq!((r["sets"].clone(), r["activeSet"].as_str(), r["overrides"]["edit.copy"].as_str()), (json!([]), Some(""), Some("F9"))); +} + +#[test] +fn bad_set_params_change_nothing() { + let mut s = Session::new(); + ks(&mut s, json!({"saveSet": "Keep"})); + let before = s.prefs().clone(); + for p in [ + json!({"useSet": "Missing"}), + json!({"useSet": 7}), + json!({"saveSet": ""}), + json!({"saveSet": "PhotoCraft Defaults"}), + json!({"deleteSet": "Missing"}), + json!({"activeSet": "Missing"}), + json!({"sets": "nope"}), + json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}}), + // A bad key elsewhere in the call keeps the set from being saved too. + json!({"set": {"edit.copy": "Cmd+Shift+Ctrl"}, "saveSet": "New"}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + assert_eq!(s.prefs(), &before, "{p} changed the preferences"); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 49e4bb60e2..378122986e 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -273,13 +273,14 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); } if is_psd(bytes) { - let file = PsdFile::from_bytes(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))?; // Nesting past the document model's cap could never be saved; reject the file with the // actionable limit. psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?; + let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 94c4ea9932..c0144b66b7 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -737,6 +737,26 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { + psd_to_document_decoded(file, ctl, None) +} + +/// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* +/// the image), a file without layers, or extra alpha/spot channels behind the colour ones. +pub(crate) fn merged_needed(file: &PsdFile) -> bool { + let h = &file.header; + let mode = doc_mode(h.color_mode).unwrap_or(ColorMode::Rgb); + // A layered mode is its own pixel format, so these are the document's colour channels. + let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1; + !layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha()) +} + +/// [`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( + file: &PsdFile, + ctl: &photocraft_raster::Interrupt, + decoded: Option>>, +) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); let mode = doc_mode(h.color_mode).unwrap_or_else(|| { @@ -859,9 +879,8 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) // multichannel file (it *is* the image), or extra alpha/spot channels behind the colour // ones. A normal layered Photoshop save carries a merged composite nobody reads - roughly // half the file's bytes, decoded and thrown away on every open before this. - let extra = usize::from(h.channels) > cc + usize::from(file.merged_has_alpha()); - let merged = if !layered || file.layers().is_empty() || extra { - let m = file.decode_merged(); + let merged = if merged_needed(file) { + let m = decoded.unwrap_or_else(|| file.decode_merged()); if ctl.cancelled() { return None; } diff --git a/crates/psd/README.md b/crates/psd/README.md index 02b76e5a7f..fc0085d44a 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -20,6 +20,8 @@ A standalone reader and writer for Adobe Photoshop **PSD** (version 1) and **PSB ```rust // Top level PsdFile::from_bytes(&[u8]) -> Result +PsdFile::from_bytes_and_merged(&[u8], want) -> Result<(PsdFile, Option>>)> + // + decode_merged() when want(&file): ZIP inflated once PsdFile::to_bytes(&self) -> Result> PsdFile::open(path) / save(path) // native only PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, diff --git a/crates/psd/src/builder.rs b/crates/psd/src/builder.rs index 04737337ef..d419f8edac 100644 --- a/crates/psd/src/builder.rs +++ b/crates/psd/src/builder.rs @@ -84,7 +84,8 @@ impl PixelData { } fn composite_plane(&self, c: usize, n: usize) -> Vec { - // Photoshop stores transparent RGB composites against white; layer channels stay straight. + // Photoshop stores transparent RGB composites against white; layer channels stay straight, + // and so do Gray and CMYK composites here (see `PsdBuilder::composite`). match self { Self::Rgba8(v) if c < 3 => v .as_chunks::<4>() @@ -290,7 +291,9 @@ impl PsdBuilder { Ok(self) } /// Supplies the merged composite (straight samples, same format as layers, full canvas). - /// Transparent RGB is stored against white, quantized at the document depth. + /// Transparent RGB is stored against white, quantized at the document depth. Gray and CMYK + /// composites with alpha are stored straight (not matted against white as Photoshop would): + /// a test that reads them as a Photoshop merged image has to allow for that. pub fn composite(&mut self, pixels: PixelData) -> &mut Self { self.composite = Some(pixels); self diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index 7ad2296fbc..aab785dff4 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -279,10 +279,18 @@ pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &Pl } } +#[cfg(test)] +thread_local! { + /// ZIP streams inflated on this thread (tests count passes over the merged image). + pub(crate) static INFLATES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // Keep only the requested pixels, but consume the trailer to verify Adler-32. // A bounded surplus tolerates padding without unbounded decompression work. fn inflate_checked(data: &[u8], expected: u64, keep: bool) -> Result> { const OOM: PsdError = PsdError::LimitExceeded("not enough memory for the decoded channel data"); + #[cfg(test)] + INFLATES.with(|n| n.set(n.get() + 1)); let mut decoder = flate2::Decompress::new(true); let mut out = Vec::new(); if keep { diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index effaa1fa11..5a1ee15c1c 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -1,5 +1,6 @@ //! The top-level [`PsdFile`] model, parser and writer. +use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, validate_planes}; use crate::error::{PsdError, Result}; use crate::header::{ColorMode, Header, Version}; use crate::image_data::ImageData; @@ -90,6 +91,17 @@ pub struct PsdFile { impl PsdFile { /// Parses a PSD or PSB file. pub fn from_bytes(data: &[u8]) -> Result { + Self::from_bytes_and_merged(data, |_| false).map(|(file, _)| file) + } + + /// Parses like [`Self::from_bytes`] and, when `want` asks for it, decodes the merged image + /// (as [`Self::decode_merged`] would) in the same pass. `want` sees the file with everything + /// but the merged image parsed (its `image_data` is still empty). A ZIP merged image is then + /// inflated once for both the parse's check of the stream and the decode, not twice. + /// + /// 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>>)> { let mut r = Reader::new(data); let header = Header::read(&mut r)?; let psb = header.version.is_psb(); @@ -146,8 +158,39 @@ impl PsdFile { layer_mask_trailing = s.peek_rest().to_vec(); } } - let image_data = ImageData::read(&mut r, &header)?; - Ok(PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data }) + 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)?; + } + Ok((file, merged)) + } + + /// Reads the merged image into `self.image_data` and decodes it. A ZIP stream within the + /// decode limit is inflated once: by the decode, whose stream check is the one + /// [`ImageData::read`] makes. Only when the decode fails is that check run on its own, to + /// tell a file the parse rejects (the outer `Err`) from one it accepts (the inner `Err`). + fn read_merged_decoded(&mut self, r: &mut Reader<'_>) -> Result>> { + let layout = ImageData::layout(&self.header); + let compression = Compression::from_u16(r.clone().u16()?); + let zip = matches!(compression, Compression::Zip | Compression::ZipPrediction); + if !zip || layout.total_bytes()? > MAX_DECODED_BYTES { + // Nothing is inflated twice here: the parse's check reads sizes only (or skips an + // over-limit stream, which the decode then refuses). + self.image_data = ImageData::read(r, &self.header)?; + return Ok(self.decode_merged()); + } + r.u16()?; + let data = r.peek_rest(); + let decoded = decode_planes(compression, data, &layout); + if decoded.is_err() { + validate_planes(compression, data, &layout)?; + } + self.image_data = ImageData { compression, data: data.to_vec() }; + r.skip(data.len())?; + Ok(decoded) } /// Serializes the file. @@ -374,6 +417,40 @@ mod write_tests { let file = PsdFile::from_bytes(&bytes).unwrap(); assert!(!file.layers().is_empty()); assert!(matches!(file.decode_merged(), Err(PsdError::LimitExceeded(_)))); + let (same, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| true).unwrap(); + assert_eq!(same, file); + assert!(matches!(merged, Some(Err(PsdError::LimitExceeded(_))))); + } + + /// Parsing and decoding a ZIP merged image together inflates it once (the parse's stream + /// check is the decode's), with the same model, pixels and errors as the two steps apart. + #[test] + fn merged_zip_image_is_inflated_once_when_decoded_with_the_parse() { + use crate::compression::INFLATES; + let inflates = |f: &dyn Fn()| { + let before = INFLATES.with(std::cell::Cell::get); + f(); + INFLATES.with(std::cell::Cell::get) - before + }; + for compression in [Compression::Zip, Compression::ZipPrediction] { + let bytes = testgen::small(Version::Psd, compression).to_bytes().unwrap(); + let file = PsdFile::from_bytes(&bytes).unwrap(); + let pixels = file.decode_merged().unwrap(); + let apart = inflates(&|| drop(PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap())); + let together = inflates(&|| { + let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |f| f.image_data.data.is_empty()).unwrap(); + assert_eq!(parsed, file); + assert_eq!(merged.unwrap().unwrap(), pixels); + }); + assert_eq!(together + 1, apart, "{compression:?}"); + let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| false).unwrap(); + assert_eq!((parsed, merged), (file, None)); + // A broken stream (its checksum) is still the parse's error. + let mut broken = bytes.clone(); + *broken.last_mut().unwrap() ^= 0xff; + let alone = PsdFile::from_bytes(&broken).unwrap_err().to_string(); + assert_eq!(PsdFile::from_bytes_and_merged(&broken, |_| true).unwrap_err().to_string(), alone); + } } #[test] diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 7d66eae920..7728d53fd1 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -325,13 +325,27 @@ impl TaggedBlock { } pub(crate) fn write(&self, out: &mut Vec, version: Version, alignment: usize) -> Result<()> { - out.put(&self.signature); - out.put(&self.key); - out.put_len(self.data.len() as u64, uses_long_length(version, &self.key))?; - out.put(&self.data); - match &self.padding { + Self::write_parts(out, self.signature, self.key, &self.data, self.padding.as_deref(), version, alignment) + } + + /// Writes a block from its parts (no owned [`TaggedBlock`] needed). `padding: None` writes + /// zeros up to `alignment`. + pub(crate) fn write_parts( + out: &mut Vec, + signature: [u8; 4], + key: [u8; 4], + data: &[u8], + padding: Option<&[u8]>, + version: Version, + alignment: usize, + ) -> Result<()> { + out.put(&signature); + out.put(&key); + out.put_len(data.len() as u64, uses_long_length(version, &key))?; + out.put(data); + match padding { Some(p) => out.put(p), - None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(self.data.len(), alignment))), + None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(data.len(), alignment))), } Ok(()) } diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 7c8b1940d5..67142921ba 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -33,7 +33,7 @@ use crate::image_data::ImageData; use crate::io::{Reader, WriteExt}; use crate::layer::{LayerInfo, LayerMask, mask_parameter_bytes}; use crate::resources::ImageResource; -use crate::tagged::{TaggedBlock, read_blocks, uses_long_length, write_blocks}; +use crate::tagged::{TaggedBlock, read_blocks, uses_long_length}; /// The byte order of a TIFF container (and so of the Photoshop data inside it). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] @@ -153,7 +153,7 @@ impl ImageSourceData { /// dropped because they could not be converted to a little-endian layout. pub fn to_bytes(&self, order: ByteOrder) -> Result<(Vec, Vec)> { let version = self.version; - let mut blocks = Vec::with_capacity(self.global_blocks.len() + 2); + let mut body = Vec::new(); if let Some(info) = &self.layer_info { // Pad to 4 without cloning the layers (and all their pixel data) just to set `padding`. let pad = info.pad_len(version, 4)?; @@ -161,21 +161,17 @@ impl ImageSourceData { data.try_reserve(info.channel_data_len().saturating_add(pad)).map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?; info.write_unpadded(&mut data, version)?; data.resize(data.len() + pad, 0); - blocks.push(padded_block(self.layer_info_key, data)); + padded_block(self.layer_info_key, data).write(&mut body, version, 4)?; } + // Blocks without explicit padding are padded to 4 as they are written (alignment 4), so + // the global mask and blocks are written from the model, without a copy of their data. + // The signature stays (a foreign-order marker included). if let Some(m) = &self.global_layer_mask { - blocks.push(padded_block(*b"LMsk", m.data.clone())); + TaggedBlock::write_parts(&mut body, *b"8BIM", *b"LMsk", &m.data, None, version, 4)?; } for b in &self.global_blocks { - let mut b = b.clone(); - if b.padding.is_none() { - // Padded to 4 like the others; the signature stays (a foreign-order marker included). - b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]); - } - blocks.push(b); + b.write(&mut body, version, 4)?; } - let mut body = Vec::new(); - write_blocks(&mut body, &blocks, version, 2)?; body.put(&self.trailing); let (body, warnings) = if order.is_little() { transcode::transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little)? } else { (body, Vec::new()) }; @@ -344,6 +340,19 @@ pub mod transcode { (self.out, self.warnings) } + /// A walker over `data` (a nested structure) that writes into this walker's output: it + /// takes the buffer, so a nested structure is written once, not copied up per level. Hand + /// the buffer back with [`Self::adopt`] before returning, error or not. + fn nested<'b>(&mut self, data: &'b [u8], depth: usize) -> Tx<'b> { + let r = if self.from.is_little() { Reader::new_little(data) } else { Reader::new(data) }; + Tx { r, out: std::mem::take(&mut self.out), from: self.from, to: self.to, version: self.version, warnings: Vec::new(), depth } + } + + /// Takes the output buffer back from a [`Self::nested`] walker. + fn adopt(&mut self, child: &mut Tx<'_>) { + self.out = std::mem::take(&mut child.out); + } + fn same_order(&self) -> bool { self.from == self.to } @@ -469,15 +478,15 @@ pub mod transcode { /// Transcodes the next `len` bytes with `f`; whatever `f` leaves is copied verbatim. fn region(&mut self, len: u64, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> Result<()> { let data = self.r.bytes_u64(len)?; - let mut child = Tx::new(data, self.version, self.from, self.to); - child.depth = self.depth; - f(&mut child)?; - child.rest()?; - if child.out.len() != data.len() { + let start = self.out.len(); + let mut child = self.nested(data, self.depth); + let res = f(&mut child).and_then(|()| child.rest()); + self.adopt(&mut child); + res?; + if self.out.len() - start != data.len() { return Err(PsdError::invalid("byte-order conversion changed a structure's length")); } self.warnings.append(&mut child.warnings); - self.out.extend_from_slice(&child.out); Ok(()) } @@ -500,13 +509,13 @@ pub mod transcode { len = len.checked_sub(4).ok_or_else(|| PsdError::invalid("length shorter than its own field"))?; } let data = self.r.bytes_u64(len)?; - let mut child = Tx::new(data, self.version, self.from, self.to); - child.depth = self.depth; - f(&mut child)?; - child.rest()?; + let start = self.out.len(); + let mut child = self.nested(data, self.depth); + let res = f(&mut child).and_then(|()| child.rest()); + self.adopt(&mut child); + res?; self.warnings.append(&mut child.warnings); - self.patch_len(at, long, child.out.len() as u64 + if inclusive { 4 } else { 0 })?; - self.out.extend_from_slice(&child.out); + self.patch_len(at, long, (self.out.len() - start) as u64 + if inclusive { 4 } else { 0 })?; Ok(()) } @@ -704,8 +713,9 @@ pub mod transcode { let len_at = self.out.len(); self.out.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] }); let data = self.r.bytes_u64(len)?; - let mut child = Tx::new(data, self.version, self.from, self.to); - child.depth = self.depth + 1; + // The body is transcoded straight into this output, after the length placeholder. + let data_at = self.out.len(); + let mut child = self.nested(data, self.depth + 1); let handled = if child.depth > MAX_DEPTH { Err(PsdError::LimitExceeded("tagged block nesting too deep")) } else if data.is_empty() { @@ -713,16 +723,21 @@ pub mod transcode { } else { child.block_body(&key) }; + let rest = if matches!(handled, Ok(true)) { child.rest() } else { Ok(()) }; + self.adopt(&mut child); + rest?; + if !matches!(handled, Ok(true)) { + // A body that was not transcoded leaves nothing: it is copied as stored or dropped. + self.out.truncate(data_at); + } // Padding: the smallest k in 0..=3 after which the next block (or the end) starts. let rest = self.r.peek_rest(); let pad = (0..=3usize).find(|&k| k == rest.len() || (k < rest.len() && is_sig(&rest[k..], self.from.is_little()))).unwrap_or(0); let pad_bytes = self.r.bytes(pad)?; match handled { Ok(true) => { - child.rest()?; self.warnings.append(&mut child.warnings); - self.patch_len(len_at, long, child.out.len() as u64)?; - self.out.extend_from_slice(&child.out); + self.patch_len(len_at, long, (self.out.len() - data_at) as u64)?; self.out.extend_from_slice(pad_bytes); } Ok(false) if self.same_order() => { @@ -2330,4 +2345,71 @@ mod tests { assert_eq!(again.global_blocks, expected.global_blocks); assert_eq!(again.layer_tree().len(), file.layer_tree().len()); } + + /// Global blocks are padded as they are written, not on a copy: the bytes are those of the + /// former path (each block cloned with explicit padding to 4, written with `write_blocks`). + #[test] + fn global_blocks_written_without_copies_keep_their_bytes() { + for version in [Version::Psd, Version::Psb] { + let mut d = ImageSourceData { version, global_layer_mask: Some(GlobalLayerMask { data: vec![0, 1, 0, 2, 0, 3, 0, 4, 9] }), ..Default::default() }; + for n in 0..8u8 { + d.global_blocks.push(TaggedBlock::new(*b"FMsk", (0..n).collect())); + } + let mut explicit = TaggedBlock::new(*b"FMsk", vec![1, 2, 3]); + explicit.padding = Some(vec![]); + d.global_blocks.push(explicit); + let mut odd = TaggedBlock::new(*b"FMsk", vec![1, 2]); + odd.padding = Some(vec![0, 0, 0, 0, 0]); + d.global_blocks.push(odd); + let mut copies = vec![padded_block(*b"LMsk", d.global_layer_mask.as_ref().unwrap().data.clone())]; + for b in &d.global_blocks { + let mut b = b.clone(); + if b.padding.is_none() { + b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]); + } + copies.push(b); + } + let mut body = Vec::new(); + crate::tagged::write_blocks(&mut body, &copies, version, 2).unwrap(); + let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; + assert_eq!(d.to_bytes(ByteOrder::Big).unwrap().0, [signature, &body[..]].concat()); + let (little, _) = transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); + assert_eq!(d.to_bytes(ByteOrder::Little).unwrap().0, [signature, &little[..]].concat()); + } + } + + /// Nested structures are transcoded into one shared buffer. A block whose body fails + /// part-way leaves no partial bytes: dropped on the way out (with the enclosing lengths + /// patched as if it had never been there), or kept exactly as stored on the way in. + #[test] + fn a_nested_block_that_fails_part_way_leaves_nothing_behind() { + let clean = sample_layers(); + let mut fx = Vec::new(); + fx.put_u32(0); + fx.extend_from_slice(&VersionedDescriptor::new(Descriptor::new("null").with("Opct", Value::Double(1.0))).to_bytes()); + // A whole number of 4-byte units, so dropping it cannot change any padding. + fx.resize(fx.len().next_multiple_of(4), 0); + let mut d = clean.clone(); + d.layer_info.as_mut().unwrap().layers[0].blocks.insert(0, TaggedBlock::new(*b"lfx2", fx.clone())); + let (mut little, w) = d.to_bytes(ByteOrder::Little).unwrap(); + assert!(!w.iter().any(|w| w.starts_with("lfx2")), "{w:?}"); + // An unknown value type after the descriptor's header: parsing fails part-way. + let at = little.windows(4).position(|w| w == b"buod").unwrap(); + little[at..at + 4].copy_from_slice(b"????"); + let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); + assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}"); + let kept = &back.layer_info.as_ref().unwrap().layers[0].blocks[0]; + assert!(kept.is_foreign_order()); + let stored = little.windows(4).position(|w| w == b"2xfl").unwrap() + 8; + assert_eq!(kept.data, little[stored..stored + fx.len()]); + // Big-endian data that fails part-way cannot go into a little-endian file: dropped. + let mut big_bad = d.clone(); + let mut broken = fx.clone(); + let at = broken.windows(4).position(|w| w == b"doub").unwrap(); + broken[at..at + 4].copy_from_slice(b"????"); + big_bad.layer_info.as_mut().unwrap().layers[0].blocks[0].data = broken; + let (little, w) = big_bad.to_bytes(ByteOrder::Little).unwrap(); + assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}"); + assert_eq!(little, clean.to_bytes(ByteOrder::Little).unwrap().0); + } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5cfc0f8ff5..6c8f21418a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3783,3 +3783,17 @@ navigator Zoom Zvětšení # View › Guides › right-click a guide Edit Selected Guides Upravit vybraná vodítka Clear Selected Guides Vymazat vybraná vodítka + Delete this set Smazat tuto sadu + New Set… Nová sada… + PhotoCraft Defaults Výchozí PhotoCraft + Save a list of these shortcuts as a web page Uložit seznam těchto zkratek jako webovou stránku + Save the changes to this set Uložit změny do této sady + Save these shortcuts as a .kys file Uložit tyto zkratky jako soubor .kys + Save these shortcuts as a new set Uložit tyto zkratky jako novou sadu + Set deleted. Sada smazána. + Set saved. Sada uložena. + Set: Sada: + Summarize… Shrnout… + 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) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 05ac77803c..2bb1c57f19 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3177,3 +3177,17 @@ curves Smooth Glätten # View › Guides › right-click a guide Edit Selected Guides Ausgewählte Hilfslinien bearbeiten Clear Selected Guides Ausgewählte Hilfslinien löschen + Delete this set Diesen Satz löschen + New Set… Neuer Satz… + PhotoCraft Defaults PhotoCraft-Standard + Save a list of these shortcuts as a web page Liste dieser Kurzbefehle als Webseite speichern + Save the changes to this set Änderungen in diesem Satz speichern + Save these shortcuts as a .kys file Diese Kurzbefehle als .kys-Datei speichern + Save these shortcuts as a new set Diese Kurzbefehle als neuen Satz speichern + Set deleted. Satz gelöscht. + Set saved. Satz gespeichert. + Set: Satz: + Summarize… Zusammenfassen… + 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) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c6c5ff8f66..48d23d13b9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3875,3 +3875,17 @@ curves Smooth Εξομάλυνση # View › Guides › right-click a guide Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών + Delete this set Διαγραφή αυτού του συνόλου + New Set… Νέο σύνολο… + PhotoCraft Defaults Προεπιλογές PhotoCraft + Save a list of these shortcuts as a web page Αποθήκευση λίστας αυτών των συντομεύσεων ως ιστοσελίδας + Save the changes to this set Αποθήκευση των αλλαγών σε αυτό το σύνολο + Save these shortcuts as a .kys file Αποθήκευση αυτών των συντομεύσεων ως αρχείου .kys + Save these shortcuts as a new set Αποθήκευση αυτών των συντομεύσεων ως νέου συνόλου + Set deleted. Το σύνολο διαγράφηκε. + Set saved. Το σύνολο αποθηκεύτηκε. + Set: Σύνολο: + Summarize… Σύνοψη… + There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα. + set name όνομα συνόλου + {set} (modified) {set} (τροποποιημένο) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e24171195a..a7338795aa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4137,3 +4137,17 @@ navigator Zoom Zoom # View › Guides › right-click a guide Edit Selected Guides Editar guías seleccionadas Clear Selected Guides Borrar guías seleccionadas + Delete this set Eliminar este conjunto + New Set… Nuevo conjunto… + PhotoCraft Defaults Valores por defecto de PhotoCraft + Save a list of these shortcuts as a web page Guardar una lista de estos métodos abreviados como página web + Save the changes to this set Guardar los cambios en este conjunto + Save these shortcuts as a .kys file Guardar estos métodos abreviados como archivo .kys + Save these shortcuts as a new set Guardar estos métodos abreviados como un conjunto nuevo + Set deleted. Conjunto eliminado. + Set saved. Conjunto guardado. + Set: Conjunto: + Summarize… Resumir… + There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero. + set name nombre del conjunto + {set} (modified) {set} (modificado) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 84a68be742..ba1de7b706 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3179,4 +3179,18 @@ curves Smooth Lisser # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés + Delete this set Supprimer ce jeu + New Set… Nouveau jeu… + PhotoCraft Defaults Valeurs par défaut de PhotoCraft + Save a list of these shortcuts as a web page Enregistrer la liste de ces raccourcis en page web + Save the changes to this set Enregistrer les modifications de ce jeu + Save these shortcuts as a .kys file Enregistrer ces raccourcis dans un fichier .kys + Save these shortcuts as a new set Enregistrer ces raccourcis comme nouveau jeu + Set deleted. Jeu supprimé. + Set saved. Jeu enregistré. + Set: Jeu : + Summarize… Résumer… + There are too many sets: delete one first. Il y a trop de jeux : supprimez-en un d’abord. + set name nom du jeu + {set} (modified) {set} (modifié) Random Aléatoire diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 54bde5d0c4..e26f3ec3b8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4094,3 +4094,17 @@ cameraRaw Yellow Sárga # View › Guides › right-click a guide Edit Selected Guides Kijelölt segédvonalak szerkesztése Clear Selected Guides Kijelölt segédvonalak törlése + Delete this set Készlet törlése + New Set… Új készlet… + PhotoCraft Defaults PhotoCraft alapértelmezések + Save a list of these shortcuts as a web page A billentyűparancsok listájának mentése weboldalként + Save the changes to this set A készlet változásainak mentése + Save these shortcuts as a .kys file A billentyűparancsok mentése .kys fájlként + Save these shortcuts as a new set A billentyűparancsok mentése új készletként + Set deleted. Készlet törölve. + Set saved. Készlet mentve. + Set: Készlet: + Summarize… Összegzés… + 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) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index eb5167c2e9..d13db9f0cf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3166,4 +3166,18 @@ curves Smooth Haluskan # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih + Delete this set Hapus set ini + New Set… Set Baru… + PhotoCraft Defaults Bawaan PhotoCraft + Save a list of these shortcuts as a web page Simpan daftar pintasan ini sebagai halaman web + Save the changes to this set Simpan perubahan ke set ini + Save these shortcuts as a .kys file Simpan pintasan ini sebagai file .kys + Save these shortcuts as a new set Simpan pintasan ini sebagai set baru + Set deleted. Set dihapus. + Set saved. Set disimpan. + Set: Set: + Summarize… Ringkas… + There are too many sets: delete one first. Terlalu banyak set: hapus satu terlebih dahulu. + set name nama set + {set} (modified) {set} (diubah) Random Acak diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 40b468926e..1779c1259e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4296,3 +4296,17 @@ shape Appearance Aspetto # View › Guides › right-click a guide Edit Selected Guides Modifica guide selezionate Clear Selected Guides Cancella guide selezionate + Delete this set Elimina questo set + New Set… Nuovo set… + PhotoCraft Defaults Predefiniti di PhotoCraft + Save a list of these shortcuts as a web page Salva un elenco di queste scorciatoie come pagina web + Save the changes to this set Salva le modifiche in questo set + Save these shortcuts as a .kys file Salva queste scorciatoie come file .kys + Save these shortcuts as a new set Salva queste scorciatoie come nuovo set + Set deleted. Set eliminato. + Set saved. Set salvato. + Set: Set: + Summarize… Riepiloga… + There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno. + set name nome del set + {set} (modified) {set} (modificato) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1d64bf868c..dd0cca727f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3851,3 +3851,17 @@ curves Smooth スムーズ # View › Guides › right-click a guide Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 + Delete this set このセットを削除 + New Set… 新規セット… + PhotoCraft Defaults PhotoCraft 初期設定 + Save a list of these shortcuts as a web page これらのショートカットの一覧を Web ページとして保存 + Save the changes to this set このセットへの変更を保存 + Save these shortcuts as a .kys file これらのショートカットを .kys ファイルとして保存 + Save these shortcuts as a new set これらのショートカットを新規セットとして保存 + Set deleted. セットを削除しました。 + Set saved. セットを保存しました。 + Set: セット: + Summarize… 概要… + There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。 + set name セット名 + {set} (modified) {set}(変更あり) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e76a3c9826..f5c243d1e5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3353,3 +3353,17 @@ curves Smooth 매끄럽게 # View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 + Delete this set 이 세트 삭제 + New Set… 새 세트… + PhotoCraft Defaults PhotoCraft 기본값 + Save a list of these shortcuts as a web page 이 단축키 목록을 웹 페이지로 저장 + Save the changes to this set 이 세트에 변경 사항 저장 + Save these shortcuts as a .kys file 이 단축키를 .kys 파일로 저장 + Save these shortcuts as a new set 이 단축키를 새 세트로 저장 + Set deleted. 세트를 삭제했습니다. + Set saved. 세트를 저장했습니다. + Set: 세트: + Summarize… 요약… + There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요. + set name 세트 이름 + {set} (modified) {set}(수정됨) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2c9416c707..aea44757ca 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3935,3 +3935,17 @@ curves Smooth Vloeiend maken # View › Guides › right-click a guide Edit Selected Guides Geselecteerde hulplijnen bewerken Clear Selected Guides Geselecteerde hulplijnen wissen + Delete this set Deze set verwijderen + New Set… Nieuwe set… + PhotoCraft Defaults PhotoCraft-standaard + Save a list of these shortcuts as a web page Een lijst van deze sneltoetsen als webpagina opslaan + Save the changes to this set De wijzigingen in deze set opslaan + Save these shortcuts as a .kys file Deze sneltoetsen als .kys-bestand opslaan + Save these shortcuts as a new set Deze sneltoetsen als nieuwe set opslaan + Set deleted. Set verwijderd. + Set saved. Set opgeslagen. + Set: Set: + Summarize… Samenvatten… + 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) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eee1e61a90..17feb459aa 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3773,3 +3773,17 @@ curves Smooth Wygładź # View › Guides › right-click a guide Edit Selected Guides Edytuj zaznaczone prowadnice Clear Selected Guides Usuń zaznaczone prowadnice + Delete this set Usuń ten zestaw + New Set… Nowy zestaw… + PhotoCraft Defaults Domyślne PhotoCraft + Save a list of these shortcuts as a web page Zapisz listę tych skrótów jako stronę internetową + Save the changes to this set Zapisz zmiany w tym zestawie + Save these shortcuts as a .kys file Zapisz te skróty jako plik .kys + Save these shortcuts as a new set Zapisz te skróty jako nowy zestaw + Set deleted. Zestaw usunięty. + Set saved. Zestaw zapisany. + Set: Zestaw: + Summarize… Podsumuj… + 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) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3e10fe7dbb..0e9559c80e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3965,3 +3965,17 @@ layerLabel No Color Sem cor # View › Guides › right-click a guide Edit Selected Guides Editar guias selecionadas Clear Selected Guides Limpar guias selecionadas + Delete this set Excluir este conjunto + New Set… Novo conjunto… + PhotoCraft Defaults Padrões do PhotoCraft + Save a list of these shortcuts as a web page Salvar uma lista destes atalhos como página da web + Save the changes to this set Salvar as alterações neste conjunto + Save these shortcuts as a .kys file Salvar estes atalhos como arquivo .kys + Save these shortcuts as a new set Salvar estes atalhos como um novo conjunto + Set deleted. Conjunto excluído. + Set saved. Conjunto salvo. + Set: Conjunto: + Summarize… Resumir… + There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro. + set name nome do conjunto + {set} (modified) {set} (modificado) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 75c91a8836..78e10a10c4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3144,4 +3144,18 @@ curves Smooth Сгладить # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие + Delete this set Удалить этот набор + New Set… Новый набор… + PhotoCraft Defaults Стандартные PhotoCraft + Save a list of these shortcuts as a web page Сохранить список этих комбинаций как веб-страницу + Save the changes to this set Сохранить изменения в этом наборе + Save these shortcuts as a .kys file Сохранить эти комбинации как файл .kys + Save these shortcuts as a new set Сохранить эти комбинации как новый набор + Set deleted. Набор удалён. + Set saved. Набор сохранён. + Set: Набор: + Summarize… Сводка… + There are too many sets: delete one first. Слишком много наборов: сначала удалите один. + set name имя набора + {set} (modified) {set} (изменён) Random Случайно diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b91b67b3eb..f4a8552fb0 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4248,3 +4248,17 @@ shape Appearance Izgled # View › Guides › right-click a guide Edit Selected Guides Uredi izabrane vodilje Clear Selected Guides Ukloni izabrane vodilje + Delete this set Obriši ovaj skup + New Set… Novi skup… + PhotoCraft Defaults Podrazumevano za PhotoCraft + Save a list of these shortcuts as a web page Sačuvaj listu ovih prečica kao veb stranicu + Save the changes to this set Sačuvaj izmene u ovom skupu + Save these shortcuts as a .kys file Sačuvaj ove prečice kao .kys datoteku + Save these shortcuts as a new set Sačuvaj ove prečice kao novi skup + Set deleted. Skup je obrisan. + Set saved. Skup je sačuvan. + Set: Skup: + Summarize… Rezime… + There are too many sets: delete one first. Previše skupova: prvo obrišite jedan. + set name ime skupa + {set} (modified) {set} (izmenjen) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4c8f06ccc1..b11f51a807 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3905,3 +3905,17 @@ cameraData Lens Lens # View › Guides › right-click a guide Edit Selected Guides Seçili Kılavuzları Düzenle Clear Selected Guides Seçili Kılavuzları Temizle + Delete this set Bu Kümeyi Sil + New Set… Yeni Küme… + PhotoCraft Defaults PhotoCraft Varsayılanları + Save a list of these shortcuts as a web page Bu kısayolların listesini web sayfası olarak kaydet + Save the changes to this set Bu kümedeki değişiklikleri kaydet + Save these shortcuts as a .kys file Bu kısayolları .kys dosyası olarak kaydet + Save these shortcuts as a new set Bu kısayolları yeni bir küme olarak kaydet + Set deleted. Küme silindi. + Set saved. Küme kaydedildi. + Set: Küme: + Summarize… Özetle… + 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) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 39597c15cb..e563354fdf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3874,3 +3874,17 @@ curves Smooth Згладити # View › Guides › right-click a guide Edit Selected Guides Редагувати вибрані напрямні Clear Selected Guides Очистити вибрані напрямні + Delete this set Видалити цей набір + New Set… Новий набір… + PhotoCraft Defaults Стандартні PhotoCraft + Save a list of these shortcuts as a web page Зберегти список цих клавіатурних скорочень як вебсторінку + Save the changes to this set Зберегти зміни в цьому наборі + Save these shortcuts as a .kys file Зберегти ці клавіатурні скорочення як файл .kys + Save these shortcuts as a new set Зберегти ці клавіатурні скорочення як новий набір + Set deleted. Набір видалено. + Set saved. Набір збережено. + Set: Набір: + Summarize… Зведення… + There are too many sets: delete one first. Забагато наборів: спершу видаліть один. + set name назва набору + {set} (modified) {set} (змінено) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ac7123d850..6907be96aa 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3661,3 +3661,17 @@ curves Smooth Làm mượt # View › Guides › right-click a guide Edit Selected Guides Sửa đường căn đã chọn Clear Selected Guides Xóa đường căn đã chọn + Delete this set Xóa bộ này + New Set… Bộ mới… + PhotoCraft Defaults Mặc định của PhotoCraft + Save a list of these shortcuts as a web page Lưu danh sách các phím tắt này thành trang web + Save the changes to this set Lưu thay đổi vào bộ này + Save these shortcuts as a .kys file Lưu các phím tắt này thành tệp .kys + Save these shortcuts as a new set Lưu các phím tắt này thành bộ mới + Set deleted. Đã xóa bộ. + Set saved. Đã lưu bộ. + Set: Bộ: + Summarize… Tóm tắ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) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1ddd5fd848..3962f46d65 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2790,4 +2790,18 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Delete this set 删除此组 + New Set… 新建组… + PhotoCraft Defaults PhotoCraft 默认值 + Save a list of these shortcuts as a web page 将这些快捷键的列表存储为网页 + Save the changes to this set 将更改存储到此组 + Save these shortcuts as a .kys file 将这些快捷键存储为 .kys 文件 + Save these shortcuts as a new set 将这些快捷键存储为新组 + Set deleted. 已删除组。 + Set saved. 已存储组。 + Set: 组: + Summarize… 摘要… + There are too many sets: delete one first. 组太多:请先删除一个。 + set name 组名称 + {set} (modified) {set}(已修改) Random 随机 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b14d5ec8c6..005c992e23 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2775,4 +2775,18 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Delete this set 刪除此組合 + New Set… 新增組合… + PhotoCraft Defaults PhotoCraft 預設值 + Save a list of these shortcuts as a web page 將這些快速鍵的清單儲存為網頁 + Save the changes to this set 將變更儲存到此組合 + Save these shortcuts as a .kys file 將這些快速鍵儲存為 .kys 檔案 + Save these shortcuts as a new set 將這些快速鍵儲存為新組合 + Set deleted. 已刪除組合。 + Set saved. 已儲存組合。 + Set: 組合: + Summarize… 摘要… + There are too many sets: delete one first. 組合太多:請先刪除一個。 + set name 組合名稱 + {set} (modified) {set}(已修改) Random 隨機 diff --git a/crates/ui-egui/src/kys_import.rs b/crates/ui-egui/src/kys_import.rs index c0344ee8c7..0b9165abd5 100644 --- a/crates/ui-egui/src/kys_import.rs +++ b/crates/ui-egui/src/kys_import.rs @@ -32,12 +32,20 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan { photocraft_engine::kys::plan(menu.chain(specs), set) } +/// 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) +} + /// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as /// overrides the OK button applies: a key equal to the command's default clears its override. /// Returns the dialog id and the plan as JSON. pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Result { let set = parse(text).map_err(|e| format!("{file_name}: {e}"))?; let plan = plan(app, &set); + let (tool_keys, unknown_tools) = plan_tools(&set); let id = match app.ui.dialogs.iter().find(|d| d.fields.get("__prefsui").and_then(Value::as_str) == Some("shortcuts")) { Some(d) => d.id, None => crate::prefs_ui::open_shortcuts(app, 0), @@ -52,6 +60,17 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu overrides.insert(cmd.clone(), sc.clone()); } } + // Tool keys (#5036): a letter other than the tool's own becomes its override; its own + // letter clears one, so the tool keeps cycling with the others that share it. + for tool in crate::state::Tool::ALL { + let id = crate::tool_shortcuts::id(tool); + let Some(key) = tool_keys.get(&id) else { continue }; + if crate::tool_shortcuts::default(tool).is_some_and(|d| d.eq_ignore_ascii_case(key)) { + overrides.remove(&id); + } else { + overrides.insert(id, key.clone()); + } + } // A row's other keys are the command's further keys (#3665): Copy's F3 beside ⌘C. let mut alternates: BTreeMap> = f.get("alternates").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); for (cmd, keys) in &plan.alternate_keys { @@ -63,10 +82,10 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu } } let shown = std::path::Path::new(file_name).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file_name.to_string()); - let n = plan.set.len().to_string(); + let n = (plan.set.len() + tool_keys.len()).to_string(); let mut message = crate::i18n::fmt(tl!("Imported {n} shortcuts from {file}. Click OK to keep them."), &[("n", &n), ("file", &shown)]); - if !plan.unknown.is_empty() { - let u = plan.unknown.len().to_string(); + if !plan.unknown.is_empty() || !unknown_tools.is_empty() { + let u = (plan.unknown.len() + unknown_tools.len()).to_string(); message.push(' '); message.push_str(&crate::i18n::fmt(tl!("{n} commands are not in PhotoCraft."), &[("n", &u)])); } @@ -87,6 +106,9 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu "alternates": plan.alternates, "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, + "tools": tool_keys, + "unknownTools": unknown_tools, + "windows": set.windows, })) } diff --git a/crates/ui-egui/src/kys_import_tests.rs b/crates/ui-egui/src/kys_import_tests.rs index 08523da79b..b31ee98e68 100644 --- a/crates/ui-egui/src/kys_import_tests.rs +++ b/crates/ui-egui/src/kys_import_tests.rs @@ -61,7 +61,7 @@ fn app() -> PhotocraftApp { fn parses_rows_shortcuts_entities_and_tool_keys() { let set = parse(SAMPLE).unwrap(); assert_eq!(set.commands.len(), 11); - assert_eq!(set.commands[2], KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Z".into(), "F1".into()] }); + assert_eq!(set.commands[2], KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Z".into(), "F1".into()], id: None }); assert_eq!(set.commands[7].name, "Black & White..."); assert_eq!(set.tool_keys, 1, "the empty Default Colors row carries no key"); } @@ -104,7 +104,12 @@ fn rows_match_our_commands_by_label_with_photoshop_tie_breaks() { #[test] fn an_unreadable_shortcut_is_reported_not_imported() { let app = app(); - let set = super::KysSet { name: None, commands: vec![KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Shift+Ctrl".into()] }], tool_keys: 0 }; + let set = super::KysSet { + name: None, + commands: vec![KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Shift+Ctrl".into()], id: None }], + tool_keys: 0, + ..Default::default() + }; let p = plan(&app, &set); assert!(p.set.is_empty()); assert_eq!(p.unreadable, vec!["Undo".to_string()]); @@ -143,7 +148,9 @@ fn import_button_fills_the_dialog_and_ok_applies_the_set() { assert!(overrides.get("select.all").is_none(), "already the default: {overrides}"); assert!(overrides.get("view.zoomIn").is_none(), "Cmd+= is among the keys, so the default stays: {overrides}"); let message = field(&h, "message"); - assert_eq!(message, json!("Imported 9 shortcuts from texcuts.kys. Click OK to keep them. 2 commands are not in PhotoCraft.")); + // 9 commands and the Move Tool's V (its own letter: no override). + assert_eq!(message, json!("Imported 10 shortcuts from texcuts.kys. Click OK to keep them. 2 commands are not in PhotoCraft.")); + assert!(overrides.get("tools.select.move").is_none(), "{overrides}"); assert_eq!(field(&h, "capture"), json!(false)); assert_eq!(field(&h, "messageWarning"), json!(false), "an import clears the earlier shortcut conflict warning"); // Nothing is applied until OK. @@ -320,3 +327,31 @@ fn an_oversized_file_is_refused() { assert!(e.contains("larger than 4 MiB"), "{e}"); assert!(app.ui.dialogs.is_empty()); } + +#[test] +fn a_windows_set_with_tool_keys_imports_as_photoshop_meant_it() { + // Photoshop on Windows writes Ctrl for the primary modifier; the tool rows give letters. + let set = " +\tCtrl+CF3 +\tCtrl+Shift+Alt+L +\tV +\tN +\tQ +"; + let mut app = app(); + let r = super::import_text(&mut app, "win.kys", set).unwrap(); + assert_eq!((r["windows"].as_bool(), r["unknownTools"].clone()), (Some(true), json!(["Imaginary Tool"]))); + assert_eq!(r["tools"], json!({"tools.select.move": "V", "tools.select.pencil": "N"})); + let id = r["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert!(f["overrides"].get("edit.copy").is_none(), "Ctrl+C on Windows is the default ⌘C: {}", f["overrides"]); + assert!(f["overrides"].get("tools.select.move").is_none(), "Move's own V keeps it cycling"); + assert_eq!(f["overrides"]["tools.select.pencil"], json!("N")); + let levels = f["overrides"].as_object().unwrap().iter().find(|(id, _)| id.contains("levels")).map(|(_, v)| v.clone()); + assert_eq!( + levels.as_ref().and_then(Value::as_str).and_then(photocraft_engine::prefs::normalize_shortcut), + photocraft_engine::prefs::normalize_shortcut("Cmd+Alt+Shift+L") + ); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::tool_shortcuts::shortcut(&app, crate::state::Tool::Pencil).as_deref(), Some("N")); +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 808a181478..d194803020 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1006,6 +1006,8 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { "filter": "", "overrides": p.shortcuts, "alternates": p.alternate_shortcuts, + "sets": p.shortcut_sets, + "activeSet": p.shortcut_set, "hidden": p.menus.hidden, "colors": p.menus.colors, "toolbarHidden": p.toolbar.hidden, @@ -1675,12 +1677,6 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>(v.clone())); @@ -1698,6 +1694,17 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map (Vec, String) { let lang = crate::i18n::current(); @@ -1933,6 +1940,167 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, alternates: &BTreeMap>, id: &str, default: Option<&str>) -> Vec { + let main = match overrides.get(id) { + Some(s) if s.is_empty() => None, + Some(s) => Some(s.as_str()), + None => default, + }; + let mut p = prefs::Preferences::default(); + if let Some(list) = alternates.get(id) { + p.alternate_shortcuts.insert(id.to_string(), list.clone()); + } + let mut keys: Vec = main.and_then(prefs::normalize_shortcut).into_iter().collect(); + keys.extend(p.alternates(id, main)); + keys +} + +/// Edit › Keyboard Shortcuts › Set, as in Photoshop: choose a saved set (its bindings replace +/// the dialog's), save the bindings as a new set or over the one in use, delete it, export it as +/// a `.kys` file or summarize it as an HTML page. Everything but Export and Summarize waits for +/// OK, like the rest of the dialog. +#[allow(clippy::too_many_arguments)] +fn set_row( + app: &mut PhotocraftApp, + ui: &mut egui::Ui, + f: &mut Map, + overrides: &mut BTreeMap, + alternates: &mut BTreeMap>, + items: &[(String, String, Vec, Option)], + message: &mut String, + warning: &mut bool, +) { + use photocraft_engine::shortcut_sets::{self, ShortcutSet}; + let mut sets: BTreeMap = f.get("sets").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let mut active = f.get("activeSet").and_then(Value::as_str).unwrap_or("").to_string(); + if !active.is_empty() && !sets.contains_key(&active) { + active.clear(); + } + let now = ShortcutSet { shortcuts: overrides.clone(), alternate_shortcuts: alternates.clone() }; + let modified = match sets.get(&active) { + Some(s) => *s != now, + None => now != ShortcutSet::default(), + }; + let defaults = tl!("PhotoCraft Defaults").to_string(); + let shown = |name: &str| -> String { + let base = if name.is_empty() { defaults.clone() } else { name.to_string() }; + if name == active && modified { crate::i18n::fmt(tl!("{set} (modified)"), &[("set", &base)]) } else { base } + }; + let names: Vec<(String, String)> = std::iter::once(String::new()).chain(sets.keys().cloned()).map(|n| (n.clone(), shown(&n))).collect(); + let mut new_name: Option = f.get("newSetName").and_then(Value::as_str).map(str::to_string); + let set_label = if active.is_empty() { defaults.clone() } else { active.clone() }; + let mut export = false; + let mut summarize = false; + ui.horizontal(|ui| { + ui.label(tl!("Set:")); + let mut chosen = active.clone(); + let pairs: Vec<(String, &str)> = names.iter().map(|(n, l)| (n.clone(), l.as_str())).collect(); + if crate::widgets::dropdown(ui, "shortcut-set", &mut chosen, &pairs, 220.0) && chosen != active { + let set = sets.get(&chosen).cloned().unwrap_or_default(); + *overrides = set.shortcuts; + *alternates = set.alternate_shortcuts; + active = chosen; + message.clear(); + } + if ui.button(tl!("New Set…")).on_hover_text(tl!("Save these shortcuts as a new set")).clicked() { + new_name = Some(String::new()); + } + if ui.add_enabled(!active.is_empty() && modified, egui::Button::new(tl!("Save"))).on_hover_text(tl!("Save the changes to this set")).clicked() { + sets.insert(active.clone(), now.clone()); + *message = tl!("Set saved.").into(); + *warning = false; + } + if ui.add_enabled(!active.is_empty(), egui::Button::new(tl!("Delete"))).on_hover_text(tl!("Delete this set")).clicked() { + sets.remove(&active); + active.clear(); + *message = tl!("Set deleted.").into(); + *warning = false; + } + export = ui.button(tl!("Export…")).on_hover_text(tl!("Save these shortcuts as a .kys file")).clicked(); + summarize = ui.button(tl!("Summarize…")).on_hover_text(tl!("Save a list of these shortcuts as a web page")).clicked(); + }); + if let Some(name) = new_name.as_mut() { + let mut create = false; + let mut cancel = false; + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.add(egui::TextEdit::singleline(name).desired_width(220.0).hint_text(tl!("set name"))); + create = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + create |= ui.button(tl!("Create")).clicked(); + cancel = ui.button(tl!("Cancel")).clicked(); + }); + if create { + match shortcut_sets::set_name(name) { + Ok(n) if !sets.contains_key(&n) && sets.len() >= shortcut_sets::MAX_SETS => { + *message = tl!("There are too many sets: delete one first.").into(); + *warning = true; + } + Ok(n) => { + sets.insert(n.clone(), now.clone()); + active = n; + new_name = None; + *message = tl!("Set saved.").into(); + *warning = false; + } + Err(e) => { + *message = e; + *warning = true; + } + } + } else if cancel { + new_name = None; + } + } + if export || summarize { + let result = if export { + let entries: Vec = items + .iter() + .map(|(id, label, _, def)| photocraft_engine::kys::Entry { + id: id.clone(), + label: label.clone(), + tool: id.starts_with("tools.select."), + keys: dialog_keys(overrides, alternates, id, def.as_deref()), + }) + .collect(); + let text = photocraft_engine::kys::write(&set_label, &entries); + save_text(app, &format!("{set_label}.kys"), text) + } else { + let mut sections: Vec = Vec::new(); + for (id, label, path, def) in items { + let section = path.first().cloned().unwrap_or_else(|| tl!("Other").to_string()); + let name = path.iter().skip(1).chain(std::iter::once(label)).cloned().collect::>().join(" › "); + let row = (name, dialog_keys(overrides, alternates, id, def.as_deref())); + match sections.iter_mut().find(|(s, _)| *s == section) { + Some((_, rows)) => rows.push(row), + None => sections.push((section, vec![row])), + } + } + let html = shortcut_sets::summarize(&set_label, §ions, cfg!(target_os = "macos")); + save_text(app, &format!("{set_label}.html"), html) + }; + if let Err(e) = result { + *message = e; + *warning = true; + } + } + f.insert("sets".into(), json!(sets)); + f.insert("activeSet".into(), json!(active)); + f.insert("newSetName".into(), new_name.map_or(Value::Null, Value::String)); +} + +/// Save `text` through the save dialog under the suggested file name. +fn save_text(app: &mut PhotocraftApp, suggested: &str, text: String) -> Result { + // A set name can hold characters a file name can't. + let name: String = suggested.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + app.pick_save(&name, move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, text.as_bytes())?; + Ok(json!({"path": path})) + }) +} + fn toolbar_tab(ui: &mut egui::Ui, f: &mut Map) { let mut hidden: Vec = f.get("toolbarHidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); egui::ScrollArea::vertical().max_height(380.0).id_salt("toolbar-scroll").show(ui, |ui| { @@ -2131,7 +2299,12 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result