diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index ea20009d7e..a39fdfb4b4 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -17,10 +17,6 @@ struct State { files: BTreeMap>, bytes: u64, dirty: BTreeSet, - /// User edits still queued or in flight. Startup migrations are reproducible from the - /// loaded records and built-ins, so their persistence alone is not work at risk. - changed: BTreeSet, - initializing: bool, writing: bool, error: Option, rejected: BTreeMap, @@ -36,11 +32,7 @@ pub fn session(backend: Bridge) -> (Session, Vec) { // An unreadable group may reference any tip, including one shared with a good group. // Keep all tips until the damaged library can be recovered; unrelated edits still save. backend.state().preserve_tips = !opened.warnings.is_empty(); - // This is a fresh session: attaching can stage generated defaults, but no user edits. - // Keep those writes queued (including the seed marker) without a loss-of-work warning. - backend.state().initializing = true; let warnings = session.attach_preset_store(opened); - backend.state().initializing = false; (session, warnings) } @@ -107,13 +99,6 @@ impl Bridge { // Requeue names, never old bytes: edits/deletes made during the failed write win. s.dirty.extend(batch.into_keys()); s.error = Some(e); - } else { - for name in batch.into_keys() { - // A newer edit made during the transaction still needs its own write. - if !s.dirty.contains(&name) { - s.changed.remove(&name); - } - } } } @@ -132,7 +117,7 @@ impl Bridge { pub fn unsaved(&self) -> bool { let s = self.state(); - !s.changed.is_empty() || !s.rejected.is_empty() + s.writing || !s.dirty.is_empty() || !s.rejected.is_empty() } } @@ -166,10 +151,7 @@ impl PresetBackend for Bridge { return Err(e); } s.dirty.insert(name.to_string()); - if !s.initializing { - s.changed.insert(name.to_string()); - } - // This current replacement resolves only its own omitted edit. Pending user edits + // This current replacement resolves only its own omitted edit. Pending transactions // still keep unsaved() true; Retry or an older successful batch cannot clear it. s.rejected.remove(name); Ok(()) @@ -190,9 +172,6 @@ impl PresetBackend for Bridge { if let Some(bytes) = s.files.remove(name) { s.bytes = s.bytes.saturating_sub(bytes.len() as u64); s.dirty.insert(name.to_string()); - if !s.initializing { - s.changed.insert(name.to_string()); - } } s.rejected.remove(name); Ok(()) @@ -366,100 +345,6 @@ mod tests { assert!(b.error().unwrap().contains("SecurityError")); assert!(b.begin().is_none()); } - - #[test] - fn generated_defaults_are_persisted_without_unsaved_user_work() { - let b = Bridge::default(); - let (s, warnings) = session(b.clone()); - assert!(warnings.is_empty()); - assert_eq!(s.actions.list.len(), 5); - assert!(!b.unsaved()); - let batch = b.begin().unwrap(); - let actions: serde_json::Value = serde_json::from_slice(batch.get(ACTIONS_FILE).unwrap().as_deref().unwrap()).unwrap(); - assert_eq!(actions["defaults_version"], 1); - assert_eq!(actions["actions"].as_array().unwrap().len(), 5); - assert!(!b.unsaved(), "generated defaults in flight can be recreated on reload"); - let restored = Bridge::default(); - for (name, bytes) in &batch { - if let Some(bytes) = bytes { - restored.load(name, bytes.to_vec()).unwrap(); - } - } - b.finish(batch, Ok(())); - let (reloaded, warnings) = session(restored.clone()); - assert!(warnings.is_empty()); - assert_eq!(reloaded.actions.list, s.actions.list); - assert!(restored.begin().is_none(), "the persisted marker avoids reseeding on reload"); - assert!(!b.unsaved()); - } - - #[test] - fn denied_storage_marks_default_edits_unsaved_and_retry_saves_them() { - for command in ["actions.delete", "actions.rename"] { - let b = Bridge::default(); - b.unavailable("SecurityError".into()); - let (mut s, _) = session(b.clone()); - assert!(!b.unsaved()); - s.execute(command, json!({"action": "Auto Tone", "name": "My Tone"})).unwrap(); - assert!(b.unsaved(), "editing generated defaults is user work: {command}"); - assert!(b.begin().is_none()); - assert!(b.error().unwrap().contains("SecurityError")); - b.retry(); - let batch = b.begin().unwrap(); - assert!(b.unsaved(), "the edit remains unsaved while its transaction runs"); - let restored = Bridge::default(); - for (name, bytes) in &batch { - if let Some(bytes) = bytes { - restored.load(name, bytes.to_vec()).unwrap(); - } - } - b.finish(batch, Ok(())); - assert!(!b.unsaved()); - assert!(b.error().is_none()); - let (reloaded, warnings) = session(restored); - assert!(warnings.is_empty()); - assert_eq!(reloaded.actions.list, s.actions.list); - assert!(!reloaded.actions.list.iter().any(|a| a.name == "Auto Tone")); - } - } - - #[test] - fn finishing_default_transaction_keeps_newer_action_edits_unsaved() { - for result in [Ok(()), Err("QuotaExceededError".into())] { - let b = Bridge::default(); - let (mut s, _) = session(b.clone()); - let defaults = b.begin().unwrap(); - s.execute("actions.delete", json!({"action": "Auto Tone"})).unwrap(); - b.finish(defaults, result); - assert!(b.unsaved(), "the bootstrap transaction cannot save a later edit"); - b.retry(); - let edited = b.begin().unwrap(); - let actions: serde_json::Value = serde_json::from_slice(edited.get(ACTIONS_FILE).unwrap().as_deref().unwrap()).unwrap(); - assert!(!actions["actions"].as_array().unwrap().iter().any(|a| a["name"] == "Auto Tone")); - b.finish(edited, Ok(())); - assert!(!b.unsaved()); - } - } - - #[test] - fn failed_default_transaction_does_not_warn_until_a_user_edit() { - let b = Bridge::default(); - let (mut s, _) = session(b.clone()); - b.finish(b.begin().unwrap(), Err("QuotaExceededError".into())); - assert!(!b.unsaved(), "the defaults are still reproducible after a failed write"); - assert!(b.begin().is_none()); - import(&mut s); - assert!(b.unsaved()); - assert!(b.error().unwrap().contains("QuotaExceededError")); - let (reloaded, warnings) = session(b.clone()); - assert!(warnings.is_empty()); - assert_eq!(reloaded.tools.presets, s.tools.presets); - assert!(b.unsaved(), "reattaching must not hide already pending user edits"); - b.retry(); - b.finish(b.begin().unwrap(), Ok(())); - assert!(!b.unsaved()); - } - #[test] fn corrupt_group_and_shared_tip_survive_unrelated_save_delete_and_reload() { let b = Bridge::default(); diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index cc69681032..2334a2ff89 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -462,7 +462,6 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool { | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" - | "shape.presets.importCsh" | "plugin.install" | "swatches.import" | "style.presets.importAsl" @@ -471,7 +470,7 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool { params.get("data").is_none() && params.get("path").is_some() } // With a `path` the file is written there; without one the bytes come back as `data`. - "actions.save" | "actions.export" | "swatches.export" | "style.presets.exportAsl" | "layer.layerStyle.contours.save" => params.get("path").is_some(), + "actions.save" | "swatches.export" | "style.presets.exportAsl" | "layer.layerStyle.contours.save" => params.get("path").is_some(), // Shortcut sets: a .kys read or written by path, or a Summarize page written by path. "edit.keyboardShortcuts" => { params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some()) diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 827a78d557..4cafd976f9 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -449,7 +449,6 @@ fn automation_rejects_ambient_path_commands_and_preferences() { ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), ("actions.load", serde_json::json!({"path": "/outside/set.atn"})), ("actions.save", serde_json::json!({"path": "/outside/set.atn"})), - ("actions.export", serde_json::json!({"path": "/outside/actions.json"})), ("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})), ("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})), ("style.presets.exportAsl", serde_json::json!({"path": 5})), @@ -467,8 +466,6 @@ fn automation_rejects_ambient_path_commands_and_preferences() { ("swatches.export", serde_json::json!({"format": "ase"})), ("actions.load", serde_json::json!({"data": "QUJD"})), ("actions.save", serde_json::json!({"set": "S"})), - ("actions.import", serde_json::json!({"data": "{\"version\":1,\"actions\":[]}"})), - ("actions.export", serde_json::json!({})), ("style.presets.importAsl", serde_json::json!({"data": "QUJD"})), ("style.presets.exportAsl", serde_json::json!({"group": "Basics"})), ("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})), @@ -881,21 +878,3 @@ fn absolute_paths_are_checked_against_the_authority_s_own_root() { let none = AuthorizedWorkspace::default(); assert!(none.read(&target.to_string_lossy()).is_err()); } - -#[test] -fn csh_import_requires_inline_data_in_automation() { - let id = "shape.presets.importCsh"; - for path in ["/outside/set.csh", "set.csh", "../set.csh", ""] { - let params = serde_json::json!({"path": path}); - assert!(authorize_engine_command(id, ¶ms).is_err(), "{path}"); - assert!(authorize_desktop_engine_command(id, ¶ms).is_err(), "{path}"); - } - // The importer gives inline data precedence, even when a path is also supplied. - for params in [serde_json::json!({"data": "QUJD"}), serde_json::json!({"data": "QUJD", "path": "/outside/set.csh"})] { - assert!(authorize_engine_command(id, ¶ms).is_ok()); - assert!(authorize_desktop_engine_command(id, ¶ms).is_ok()); - } - let mut headless = crate::headless::Headless::new(); - let error = headless.command_run(id, serde_json::json!({"path": "/outside/set.csh"})).unwrap_err(); - assert!(error.to_string().contains("uses ambient filesystem paths and is disabled"), "{error}"); -} diff --git a/crates/compose/src/composite.rs b/crates/compose/src/composite.rs index c4c6686a1e..8436ff7428 100644 --- a/crates/compose/src/composite.rs +++ b/crates/compose/src/composite.rs @@ -557,7 +557,7 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option>)> { let LayerContent::Shape(sh) = &layer.content else { return None }; sh.stroke.as_ref()?; - let (fs, ss) = shape_split::split_with_patterns(sh, cx.canvas, cx.depth, cx.patterns.source(), cx.mode)?; + let (fs, ss) = shape_split::split(sh, cx.canvas, cx.depth)?; Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx))) } diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index d484d384be..908b8de1a7 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -41,9 +41,9 @@ mod paint; pub use bevel::{BEVEL_H_EPS, BevelGeom, BevelPaint, TextureCtx, bevel_chisel_h, bevel_geom, satin_distance}; #[cfg(test)] use bevel::{bevel_maps, satin_map}; +pub use blur::{tent_kernel, softer_glow_tail_width, softer_glow_tail_amplitude}; #[cfg(test)] use blur::{blur, box_line, dilate, tent, tent_direct}; -pub use blur::{softer_glow_tail_amplitude, softer_glow_tail_width, tent_kernel}; pub(crate) use composite::{VectorStroke, build_maps_prepared, composite_with_effects_prepared, map_count}; pub use composite::{build_maps, composite_with_effects, outline_share, premerges_layer, shadow_see_through, stroke_widths}; pub use distance::{FieldKind, STROKE_CHAMFER_MAX, bevel_distance_fields, distance_field, stroke_outside_exact}; diff --git a/crates/compose/src/effects/paint.rs b/crates/compose/src/effects/paint.rs index a528669685..2699e9a2f4 100644 --- a/crates/compose/src/effects/paint.rs +++ b/crates/compose/src/effects/paint.rs @@ -1,4 +1,4 @@ -use super::blur::{blur, dilate, softer_glow_tail_amplitude, softer_glow_tail_width}; +use super::blur::{blur, dilate, softer_glow_tail_width, softer_glow_tail_amplitude}; use super::distance::{dist_inside, dist_outside}; use super::{ BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect, diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index 1a05d62550..258ef5664a 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -4,13 +4,13 @@ //! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with //! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile //! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an -//! `n × n` grid of bilinear taps over its footprint in the tile (see [`Tile::sample_area`]), so a +//! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a //! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap. use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; -use photocraft_doc::{ColorMode, Pattern, Rect}; +use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20; const MAX_PREPARED_PATTERNS: usize = 64; @@ -96,7 +96,186 @@ impl<'a> PreparedPatterns<'a> { } } -pub use photocraft_vector::pattern::{Placement, Tile, pattern_rgba}; +/// A pattern's pixels (straight-alpha RGBA, row-major) as they look painted into a document of +/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the +/// library and in the document), but in a Grayscale or CMYK document it paints that mode's +/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the +/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't +/// [paintable](Pattern::is_paintable). +pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> { + let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else { + return Vec::new(); + }; + let mut px = vec![[0.0f32; 4]; n]; + p.surface.read_rgba_into(p.rect(), &mut px); + let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); + if paints_in && p.surface.format().mode != mode { + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let mut v = [0.0f32; 5]; + for q in &mut px { + let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v); + *q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default()); + } + } + px +} + +/// A pattern converted once to straight-alpha RGBA for sampling. +pub struct Tile { + w: usize, + h: usize, + /// Premultiplied RGBA. + px: Vec<[f32; 4]>, +} + +impl Tile { + pub fn new(p: &Pattern) -> Option { + Tile::new_in(p, ColorMode::Rgb) + } + + /// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]). + /// `None` when the pattern isn't [paintable](Pattern::is_paintable). + pub fn new_in(p: &Pattern, mode: ColorMode) -> Option { + if !p.is_paintable() { + return None; + } + let (w, h) = (p.width as usize, p.height as usize); + let mut px = pattern_rgba(p, mode); + for q in &mut px { + for c in 0..3 { + q[c] *= q[3]; + } + } + Some(Tile { w, h, px }) + } + + #[inline] + fn at(&self, x: i64, y: i64) -> [f32; 4] { + let xi = x.rem_euclid(self.w as i64) as usize; + let yi = y.rem_euclid(self.h as i64) as usize; + self.px[yi * self.w + xi] + } + + /// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`). + /// Returns straight alpha. + pub fn sample(&self, u: f64, v: f64) -> [f32; 4] { + unpremultiply(self.sample_premul(u, v)) + } + + /// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's + /// footprint in the tile when the placement shrinks it. Returns straight alpha. + pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] { + let n = pixel_taps(place.inv_scale); + if n == 1 { + let (u, v) = place.map(x, y); + return self.sample(u, v); + } + let mut acc = [0.0f32; 4]; + for j in 0..n { + for i in 0..n { + let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n)); + let q = self.sample_premul(u, v); + for c in 0..4 { + acc[c] += q[c]; + } + } + } + let k = 1.0 / (n * n) as f32; + unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k]) + } + + /// Bilinear, wrapping sample, premultiplied. + fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] { + let (u, v) = (u - 0.5, v - 0.5); + let (x0, y0) = (u.floor(), v.floor()); + let (fx, fy) = ((u - x0) as f32, (v - y0) as f32); + let (x0, y0) = (x0 as i64, y0 as i64); + let mut acc = [0.0f32; 4]; + for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { + if wy == 0.0 { + continue; + } + for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { + if wx == 0.0 { + continue; + } + let p = self.at(x0.saturating_add(dx), y0.saturating_add(dy)); + let k = wx * wy; + for c in 0..4 { + acc[c] += p[c] * k; + } + } + } + acc + } +} + +/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). +const MAX_TAPS: usize = 8; + +/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers +/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above. +fn pixel_taps(inv_scale: f64) -> usize { + let s = inv_scale as f32; + if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 } +} + +/// Offset in document pixels of tap `i` of `n`, centred on the pixel. +fn tap_offset(i: usize, n: usize) -> f64 { + (i as f64 + 0.5) / n as f64 - 0.5 +} + +fn unpremultiply(acc: [f32; 4]) -> [f32; 4] { + if acc[3] <= 0.0 { + return [0.0; 4]; + } + [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] +} + +/// Where a pattern sits: document point → tile coordinates. +#[derive(Clone, Copy, Debug)] +pub struct Placement { + origin: (f64, f64), + /// Inverse rotation (cos, sin) and inverse scale. + cs: (f64, f64), + inv_scale: f64, +} + +impl Placement { + /// `frame`: the layer's frame (used when `link`); `phase` in pixels; `scale` as a fraction; + /// `angle` in degrees counter-clockwise. + pub fn new(frame: Rect, link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement { + let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) }; + Self::anchored(anchor, link, phase, scale, angle) + } + + /// Like [`Placement::new`], with the linked origin given as a point. Layer effects anchor + /// linked patterns at the layer's effects reference point (PSD `fxrp`): Photoshop's + /// pattern overlays and pattern strokes match pixel-for-pixel tiled from there. + pub fn anchored(anchor: (f64, f64), link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement { + let base = if link { anchor } else { (0.0, 0.0) }; + let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1)); + let s = f64::from(scale); + let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 }; + let a = f64::from(angle).to_radians(); + Placement { origin, cs: (a.cos(), a.sin()), inv_scale } + } + + /// (origin, inverse rotation (cos, sin), inverse scale), for GPU shaders. + pub fn parts(&self) -> ((f64, f64), (f64, f64), f64) { + (self.origin, self.cs, self.inv_scale) + } + + /// Tile coordinates of the document point `(x, y)`. + #[inline] + pub fn map(&self, x: f64, y: f64) -> (f64, f64) { + let (dx, dy) = (x - self.origin.0, y - self.origin.1); + // Undo a counter-clockwise rotation (y points down on screen). + let (c, s) = self.cs; + let (rx, ry) = (dx * c - dy * s, dx * s + dy * c); + (rx * self.inv_scale, ry * self.inv_scale) + } +} /// Renders `tile` over `rect` (row-major straight RGBA). pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> { diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 01e9346f9a..c6e76fb0b2 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -6,15 +6,15 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::{Mutex, OnceLock}; -use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::vector::ShapeLayer; -use photocraft_doc::{Fill, Layer, LayerContent, Pattern}; +use photocraft_doc::{Layer, LayerContent}; use photocraft_geom::{Rect, TileCoord}; use photocraft_raster::Surface; /// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so /// their tile addresses, part of the key, can't be reused). -type Entry = ((Surface, Surface), u64, Option, Vec); +type Entry = ((Surface, Surface), u64, Option); fn cache() -> &'static Mutex<(HashMap, u64)> { static C: OnceLock, u64)>> = OnceLock::new(); @@ -22,26 +22,24 @@ fn cache() -> &'static Mutex<(HashMap, u64)> { } const CAPACITY: usize = 32; -/// Most pixel bytes held by split parts and their pinned patterns (#4530). +/// Most pixel bytes the split parts may hold (#4530): each entry is two canvas-sized surfaces, +/// so 32 of them at 24 MP could hold several GB outside every memory budget. #[cfg(not(test))] const BUDGET: usize = 256 << 20; /// Small under test, so a few 512² shapes exercise the eviction. #[cfg(test)] const BUDGET: usize = 8 << 20; -/// Pixel bytes held by one entry, including pinned source tiles. Shared pattern tiles count once -/// per entry, conservatively matching their retained lifetime without cross-entry bookkeeping. -fn entry_bytes(parts: &(Surface, Surface), patterns: &[Pattern]) -> usize { - let part_bytes = [&parts.0, &parts.1].iter().flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).fold(0usize, usize::saturating_add); - let pattern_bytes = patterns.iter().flat_map(|p| p.surface.tiles().map(|(_, t)| t.bytes().len())).fold(0usize, usize::saturating_add); - part_bytes.saturating_add(pattern_bytes) +/// Pixel bytes of one entry's (fill, stroke) parts (the pinned shape pixels are the document's). +fn entry_bytes(parts: &(Surface, Surface)) -> usize { + [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.bytes().len()).sum::()).sum() } -/// Drop every cached split (Edit › Purge › All), returning accounted pixel bytes. Callers may -/// still hold returned surfaces and patterns, so this is not a measure of freed process memory. +/// Drop every cached split (Edit › Purge › All), returning the parts' pixel bytes. Callers may +/// still hold returned surfaces, so this is not a measure of freed process memory. pub fn purge_cache() -> usize { let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); - let bytes = c.0.values().map(|e| entry_bytes(&e.0, &e.3)).sum(); + let bytes = c.0.values().map(|e| entry_bytes(&e.0)).sum(); c.0.clear(); bytes } @@ -51,29 +49,10 @@ pub fn purge_cache() -> usize { /// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is /// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects). pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> { - split_with_patterns(sh, canvas, depth, &[], ColorMode::Rgb) -} - -/// Splits patterned paints with the same embedded tiles and colour mode as the shape cache. -pub fn split_with_patterns(sh: &ShapeLayer, canvas: Rect, depth: SampleType, patterns: &[Pattern], mode: ColorMode) -> Option<(Surface, Surface)> { sh.stroke.as_ref()?; let mut h = std::collections::hash_map::DefaultHasher::new(); (canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h); depth.hash(&mut h); - mode.hash(&mut h); - let mut used_patterns = Vec::new(); - for fill in sh.fill.iter().chain(sh.stroke.iter().map(|s| &s.paint)) { - if let Fill::Pattern { id, name, .. } = fill - && let Some(p) = photocraft_doc::pattern::find(patterns, id, name) - { - (&p.id, &p.name, p.width, p.height, p.surface.format()).hash(&mut h); - format!("{:?}", p.surface.default_pixel()).hash(&mut h); - for (k, tile) in p.surface.tiles() { - (k.tx, k.ty, std::sync::Arc::as_ptr(tile) as usize).hash(&mut h); - } - used_patterns.push(p.clone()); - } - } format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h); (sh.density.to_bits(), sh.feather.to_bits()).hash(&mut h); if let Some(c) = &sh.cache { @@ -95,29 +74,27 @@ pub fn split_with_patterns(sh: &ShapeLayer, canvas: Rect, depth: SampleType, pat let fmt = PixelFormat::RGBA8.with_sample(depth); let fill_only = ShapeLayer { stroke: None, cache: None, ..sh.clone() }; let stroke_only = ShapeLayer { fill: None, cache: None, ..sh.clone() }; - let render = - |shape| photocraft_vector::CompiledShape::with_patterns(shape, photocraft_vector::DEFAULT_TOLERANCE, canvas, patterns, mode).render(fmt, canvas); - let mut parts = (render(&fill_only), render(&stroke_only)); + let mut parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas)); if let Some(cache) = &sh.cache { fit_stroke(&parts.0, &mut parts.1, cache, canvas); } let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); // Least recently used first, until the new entry fits both the count and the byte budget. - let new_bytes = entry_bytes(&parts, &used_patterns); - if new_bytes > BUDGET { - // Larger than the whole budget on its own: use it, don't keep it. - return Some(parts); - } + let new_bytes = entry_bytes(&parts); loop { - let held: usize = c.0.values().map(|e| entry_bytes(&e.0, &e.3)).sum(); + let held: usize = c.0.values().map(|e| entry_bytes(&e.0)).sum(); if c.0.len() < CAPACITY && held.saturating_add(new_bytes) <= BUDGET { break; } let Some(old) = c.0.iter().min_by_key(|e| (e.1).1).map(|e| *e.0) else { break }; c.0.remove(&old); } + if new_bytes > BUDGET { + // Larger than the whole budget on its own: use it, don't keep it. + return Some(parts); + } let tick = c.1; - c.0.insert(key, (parts.clone(), tick, sh.cache.clone(), used_patterns)); + c.0.insert(key, (parts.clone(), tick, sh.cache.clone())); Some(parts) } @@ -238,29 +215,9 @@ mod tests { } fn held() -> usize { - cache().lock().unwrap_or_else(|e| e.into_inner()).0.values().map(|e| entry_bytes(&e.0, &e.3)).sum() + cache().lock().unwrap_or_else(|e| e.into_inner()).0.values().map(|e| entry_bytes(&e.0)).sum() } - #[test] - fn split_cache_does_not_pin_pattern_tiles_over_its_budget() { - let fmt = PixelFormat::RGBA32F; - let mut pixels = Surface::new(fmt); - pixels.fill_rect(Rect::new(0, 0, 1024, 1024), &[0.2, 0.4, 0.6, 1.0]); - let pattern = Pattern::new("dense", pixels, 1024, 1024); - assert!(pattern.surface.tiles().map(|(_, tile)| tile.bytes().len()).sum::() > BUDGET); - let mut sh = shape(0.0); - sh.fill = Some(Fill::Pattern { id: pattern.id.clone(), name: pattern.name.clone(), scale: 1.0, angle: 0.0, link: false, phase: (0.0, 0.0) }); - split_with_patterns(&sh, Rect::new(0, 0, 32, 32), SampleType::U8, std::slice::from_ref(&pattern), ColorMode::Rgb).expect("stroked"); - let pattern_tiles: Vec<_> = pattern.surface.tiles().map(|(_, tile)| std::sync::Arc::as_ptr(tile) as usize).collect(); - let c = cache().lock().unwrap_or_else(|e| e.into_inner()); - assert!( - c.0.values().all(|entry| entry - .3 - .iter() - .all(|held| held.surface.tiles().all(|(_, tile)| !pattern_tiles.contains(&(std::sync::Arc::as_ptr(tile) as usize))))), - "oversized pattern was pinned by split cache" - ); - } /// The split cache stays within its byte budget, evicting the least recently used, and /// Edit › Purge › All empties it (#4530). #[test] @@ -268,7 +225,7 @@ mod tests { let canvas = Rect::new(0, 0, 1024, 512); for i in 0..6 { let parts = split(&shape(f64::from(i) * 10.0), canvas, SampleType::U8).expect("stroked"); - assert!(entry_bytes(&parts, &[]) > 0); + assert!(entry_bytes(&parts) > 0); assert!(held() <= BUDGET, "shape {i}: {} bytes held", held()); } // Other tests share the cache, so only what this test can rely on: a purge frees bytes. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0ee241f52b..055444c1e9 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -198,11 +198,6 @@ pub struct ParagraphStyle { pub auto_leading: f32, pub direction: TextDirection, pub hyphenate: bool, - /// Roman Hanging Punctuation (Photoshop's Paragraph panel): trailing punctuation - /// (`, . : ; ! ? …` and their fullwidth twins) may step past the box's right edge without - /// counting toward the line's width, so a centered line ending in an ellipsis stays - /// centered and a period the box can't hold hangs instead of wrapping. - pub hanging: bool, /// Applied paragraph style (id in [`crate::TextStyles`]); `None` = Basic Paragraph. #[serde(skip_serializing_if = "Option::is_none")] pub style_sheet: Option, @@ -220,7 +215,6 @@ impl Default for ParagraphStyle { auto_leading: 1.2, direction: TextDirection::Auto, hyphenate: false, - hanging: false, style_sheet: None, } } diff --git a/crates/engine/src/action_defaults.rs b/crates/engine/src/action_defaults.rs deleted file mode 100644 index f1e97fef7c..0000000000 --- a/crates/engine/src/action_defaults.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Independently authored starter actions, composed from PhotoCraft commands. The preset -//! store seeds these once; an explicit command restores missing names without replacing edits. - -use serde_json::{Value, json}; - -use crate::actions_cmds::{Action, ActionState}; -use crate::commands::CommandSpec; -use crate::{EngineError, Result, Session}; - -pub(crate) const VERSION: u32 = 1; -const SET: &str = "PhotoCraft Essentials"; - -fn defaults() -> Vec { - [ - ("Auto Tone", "image.autoTone", json!({})), - ("Auto Contrast", "image.autoContrast", json!({})), - ("Auto Color", "image.autoColor", json!({})), - ("Select Subject", "select.subject", json!({"sampleAllLayers": true, "mode": "replace"})), - ("Blur Background", "layer.blurBackground", json!({"radius": 8.0})), - ] - .into_iter() - .map(|(name, id, params)| Action { set: Some(SET.into()), ..Action::new(name, vec![(id.into(), params)]) }) - .collect() -} - -/// Preserve custom actions, including a user's edited default with the same name. -pub(crate) fn append_missing(state: &mut ActionState) -> Vec { - let mut added = Vec::new(); - for action in defaults() { - if !state.list.iter().any(|have| have.name == action.name) { - added.push(action.name.clone()); - state.list.push(action); - } - } - if !added.is_empty() { - state.touch(); - } - added -} - -fn enabled(s: &Session) -> std::result::Result<(), String> { - if s.actions.recording.is_some() || s.actions.playing > 0 { - return Err("Stop recording or playing an action before loading defaults".into()); - } - Ok(()) -} - -fn load(s: &mut Session, p: &Value) -> Result { - let bad = |msg: String| EngineError::BadParams { cmd: "actions.loadDefaults".into(), msg }; - enabled(s).map_err(bad)?; - if !p.is_object() { - return Err(bad("parameters must be an object".into())); - } - Ok(json!({"added": append_missing(&mut s.actions), "set": SET})) -} - -pub fn specs() -> Vec { - vec![CommandSpec { - id: "actions.loadDefaults", - label: "Load Default Actions", - menu: &[], - shortcut: None, - params: "{} → {added:[name…], set}. Append missing PhotoCraft Essentials actions; existing names and edits are preserved. No document needed. Unavailable during recording or playback.", - enabled, - run: load, - journal: false, - }] -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::actions_cmds::ActionsFile; - use crate::preset_store::{ACTIONS_FILE, MemBackend, PresetBackend, open}; - use photocraft_geom::Rect; - - fn attached(mem: &MemBackend) -> Session { - let mut s = Session::new(); - assert!(s.attach_preset_store(open(Box::new(mem.clone()))).is_empty()); - s - } - - fn saved(mem: &MemBackend) -> ActionsFile { - serde_json::from_slice(&mem.read(ACTIONS_FILE, 1 << 20).unwrap()).unwrap() - } - - #[test] - fn load_defaults_preserves_names_and_edits_and_is_idempotent() { - let mut s = Session::new(); - assert!(s.actions.list.is_empty(), "headless sessions remain hermetic"); - let custom = Action::new("Auto Tone", vec![("image.flipCanvasHorizontal".into(), json!({}))]); - s.actions.list.push(custom.clone()); - let loaded = s.execute("actions.loadDefaults", json!({})).unwrap(); - assert_eq!(loaded["added"], json!(["Auto Contrast", "Auto Color", "Select Subject", "Blur Background"])); - assert_eq!(s.actions.list[0], custom); - assert!(s.actions.list.iter().skip(1).all(|a| a.set.as_deref() == Some(SET))); - let rev = s.actions.rev; - assert_eq!(s.execute("actions.loadDefaults", json!({})).unwrap()["added"], json!([])); - assert_eq!(s.actions.rev, rev, "unchanged defaults do not cause a store write"); - assert!(s.journal.is_empty()); - assert!(s.active().is_none()); - - for bad in [Value::Null, json!([]), json!("defaults"), json!(true), json!(1)] { - assert!(s.execute("actions.loadDefaults", bad).is_err()); - } - s.execute("actions.record", json!({"name": "Recording"})).unwrap(); - assert!(!s.is_enabled("actions.loadDefaults")); - assert!(s.execute("actions.loadDefaults", json!({})).is_err()); - s.execute("actions.stop", json!({})).unwrap(); - s.actions.playing = 1; - assert!(load(&mut s, &json!({})).is_err()); - } - - #[test] - fn defaults_seed_once_and_restore_only_when_requested() { - let mem = MemBackend::default(); - let mut s = attached(&mem); - assert_eq!(s.actions.list, defaults()); - assert_eq!(saved(&mem).defaults_version, VERSION); - s.execute("actions.delete", json!({"action": "Auto Tone"})).unwrap(); - s.execute("actions.rename", json!({"action": "Auto Color", "name": "My Color"})).unwrap(); - let customized = s.actions.list.clone(); - let mut restarted = attached(&mem); - assert_eq!(restarted.actions.list, customized, "deleted and renamed defaults stay customized on restart"); - assert_eq!(restarted.execute("actions.loadDefaults", json!({})).unwrap()["added"], json!(["Auto Tone", "Auto Color"])); - assert_eq!(attached(&mem).actions.list, restarted.actions.list); - } - - #[test] - fn old_store_and_early_actions_win_over_defaults() { - let mem = MemBackend::default(); - let custom = Action::new("Auto Tone", vec![("select.all".into(), json!({}))]); - let disk = Action::new("My Workflow", vec![]); - // Old files have no defaults_version field. - mem.write(ACTIONS_FILE, &serde_json::to_vec(&json!({"version": 1, "actions": [custom, disk]})).unwrap()).unwrap(); - let mut s = Session::new(); - let early = Action::new("Auto Color", vec![("select.deselect".into(), json!({}))]); - s.actions.list.push(early.clone()); - s.attach_preset_store(open(Box::new(mem.clone()))); - assert_eq!(&s.actions.list[..3], &[early, custom, disk]); - assert_eq!( - s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), - ["Auto Color", "Auto Tone", "My Workflow", "Auto Contrast", "Select Subject", "Blur Background"] - ); - assert_eq!(saved(&mem).defaults_version, VERSION); - assert_eq!(attached(&mem).actions.list, s.actions.list); - } - - #[test] - fn unreadable_actions_are_not_seeded_or_overwritten() { - for bytes in [b"{".as_slice(), br#"{"version":999,"actions":[]}"#.as_slice()] { - let mem = MemBackend::default(); - mem.write(ACTIONS_FILE, bytes).unwrap(); - let mut s = Session::new(); - assert!(!s.attach_preset_store(open(Box::new(mem.clone()))).is_empty()); - assert!(s.actions.list.is_empty()); - s.execute("actions.loadDefaults", json!({})).unwrap(); - assert_eq!(s.actions.list, defaults(), "explicit recovery is available in memory"); - assert_eq!(mem.read(ACTIONS_FILE, 1 << 20).unwrap(), bytes, "the unreadable original remains recoverable"); - } - } - - #[test] - fn auto_defaults_play_on_pixels_and_undo_at_every_depth() { - for depth in [8, 16, 32] { - let mut s = Session::new(); - s.execute("actions.loadDefaults", json!({})).unwrap(); - s.execute("file.new", json!({"width": 64, "height": 64, "depth": depth})).unwrap(); - s.edit("ramp", |doc, _| { - let surf = doc.layers[0].surface_mut().unwrap(); - for x in 0..64 { - let v = 0.3 + 0.4 * x as f32 / 63.0; - surf.fill_rect(Rect::new(x, 0, x + 1, 64), &[v, v * 0.9, v * 0.8, 1.0]); - } - Ok(()) - }) - .unwrap(); - let pixel = |s: &Session, x| s.active().unwrap().doc.layers[0].surface().unwrap().pixel(x, 0); - let before = (pixel(&s, 0), pixel(&s, 63)); - let history = s.active().unwrap().history.past_len(); - for name in ["Auto Tone", "Auto Contrast", "Auto Color"] { - let played = s.execute("actions.play", json!({"action": name})).unwrap(); - assert_eq!(played["ran"], 1, "{name} at {depth}: {played}"); - assert!(played.get("failed").is_none()); - let (a, b) = (pixel(&s, 0), pixel(&s, 63)); - let (lo, hi) = if name == "Auto Contrast" { (a[2], b[0]) } else { (a[0], b[0]) }; - assert!(lo < 0.05 && hi > 0.95, "{name} at {depth}: {a:?} {b:?}"); - assert_eq!(s.active().unwrap().history.past_len(), history + 1); - assert!(s.undo()); - assert_eq!((pixel(&s, 0), pixel(&s, 63)), before); - } - } - } - - #[test] - fn subject_default_samples_visible_layers_and_undo_restores_selection() { - for depth in [8, 16, 32] { - let mut s = Session::new(); - s.execute("actions.loadDefaults", json!({})).unwrap(); - s.execute("file.new", json!({"width": 200, "height": 160, "depth": depth})).unwrap(); - s.edit("disc", |doc, _| { - let surf = doc.layers[0].surface_mut().unwrap(); - surf.fill_rect(Rect::new(0, 0, 200, 160), &[0.25, 0.45, 0.7, 1.0]); - for y in 0..160 { - for x in 0..200 { - if (x - 100) * (x - 100) + (y - 80) * (y - 80) <= 45 * 45 { - surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &[0.9, 0.3, 0.15, 1.0]); - } - } - } - Ok(()) - }) - .unwrap(); - s.execute("layer.new.layer", json!({"name": "Empty active layer"})).unwrap(); - s.execute("select.all", json!({})).unwrap(); - let played = s.execute("actions.play", json!({"action": "Select Subject"})).unwrap(); - assert_eq!(played["ran"], 1, "{depth}: {played}"); - assert!(played.get("failed").is_none()); - let coverage = |s: &Session, x, y| s.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(x, y, 0); - assert!(coverage(&s, 100, 80) > 0.9, "the subject on the underlying layer is selected"); - assert!(coverage(&s, 0, 0) < 0.1, "replace mode removes the old background selection"); - assert!(s.undo()); - assert!(coverage(&s, 0, 0) > 0.9); - } - } -} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index f2268bd8da..1598870479 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -58,7 +58,6 @@ pub mod analysis_cmds; pub mod artboard_cmds; pub mod atn_cmds; pub mod automate_cmds; -pub mod background_blur_cmds; pub mod background_cmds; pub mod brush_cmds; pub mod brush_key_cmds; @@ -85,7 +84,6 @@ pub mod fill_key_cmds; pub mod float_cmds; pub mod font_library_cmds; pub(super) mod frame_cmds; -pub mod frequency_separation_cmds; pub mod fx_view_cmds; pub mod fx_visibility_cmds; pub mod gallery_cmds; @@ -801,20 +799,17 @@ fn build() -> Vec { "Gradient…", ["Layer", "New Fill Layer"], None, - r##"{"from":"#rrggbb"=foreground,"to":"#rrggbb"? (none: Foreground to Transparent),"angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, + r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, has_doc, |s, p| { let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?; - // Without a `to`, Photoshop's default Foreground to Transparent (#8708): the - // `from` colour at both ends, fading out through the transparency stops. - let to_transparent = p.get("to").is_none_or(Value::is_null); - let b = if to_transparent { a } else { color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)? }; + let b = color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)?; let angle = crate::gradient_fill_cmds::angle_param("layer.newFillLayer.gradient", p, 90.0)?; let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Gradient Fill Layer", |doc, active| { - let mut fill = Fill::gradient( + let fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], angle, 1.0, @@ -822,9 +817,6 @@ fn build() -> Vec { reverse, ) .in_mode(doc.mode); - if to_transparent && let Fill::Gradient { opacity_stops, .. } = &mut fill { - *opacity_stops = vec![(0.0, 1.0), (1.0, 0.0)]; - } let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); @@ -1224,7 +1216,6 @@ fn build() -> Vec { v.extend(crate::filters::specs()); v.extend(crate::filters_ext::specs()); v.extend(crate::color_to_alpha_cmds::specs()); - v.extend(crate::frequency_separation_cmds::specs()); v.extend(crate::gallery_cmds::specs()); v.extend(crate::gradient_fill_cmds::specs()); v.extend(crate::solid_fill_cmds::specs()); @@ -1237,7 +1228,6 @@ fn build() -> Vec { v.extend(crate::path_edit_cmds::specs()); v.extend(crate::smartselect_cmds::specs()); v.extend(crate::cutout_cmds::specs()); - v.extend(crate::background_blur_cmds::specs()); v.extend(crate::symmetry_cmds::specs()); v.extend(crate::edit_cmds::specs()); v.extend(crate::color_cmds::specs()); @@ -1330,7 +1320,6 @@ fn build() -> Vec { v.extend(crate::mask_view_cmds::specs()); v.extend(crate::layer_mask_props_cmds::specs()); v.extend(crate::actions_cmds::specs()); - v.extend(crate::action_defaults::specs()); // The dispatch guard's unit test needs a command that panics on purpose (REL-3). #[cfg(test)] v.push(test_panic_command()); diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 4bc6dcf722..0fcdcd572f 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -22,8 +22,6 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; -mod json_file; - fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -80,9 +78,6 @@ impl Action { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ActionsFile { pub version: u32, - /// Starter set last seeded. Older stores default to zero; deletions after seeding persist. - #[serde(default)] - pub defaults_version: u32, pub actions: Vec, } @@ -118,84 +113,6 @@ pub struct ActionState { /// started in is still the one running when its result lands. #[serde(skip)] pub take: u64, - #[serde(skip)] - pub(crate) undo: Vec, - #[serde(skip)] - pub(crate) redo: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct ActionEdit { - list: Vec, - recording: Option<(usize, usize)>, - shortcuts: std::collections::BTreeMap, -} - -fn snapshot(s: &Session) -> ActionEdit { - let mut list = s.actions.list.clone(); - if let Some((_, idx)) = s.actions.recording { - let pending: Vec<_> = pending_steps(s, idx).cloned().collect(); - if let Some(action) = list.get_mut(idx) { - action.steps.extend(pending); - } - } - ActionEdit { - list, - recording: s.actions.recording.map(|(_, idx)| (s.journal.len(), idx)), - shortcuts: s.prefs().shortcuts.iter().filter(|(key, _)| key.starts_with(PLAY_SHORTCUT_PREFIX)).map(|(k, v)| (k.clone(), v.clone())).collect(), - } -} - -fn edit_action(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result, include_pending: bool) -> Result { - let mut before = snapshot(s); - if !include_pending { - before.list = s.actions.list.clone(); - } - let result = run(s, p)?; - if before.list != snapshot(s).list { - s.actions.undo.push(before); - if s.actions.undo.len() > 100 { - s.actions.undo.remove(0); - } - s.actions.redo.clear(); - } - Ok(result) -} - -pub fn can_undo(s: &Session) -> bool { - s.actions.playing == 0 && !s.actions.undo.is_empty() -} -pub fn can_redo(s: &Session) -> bool { - s.actions.playing == 0 && !s.actions.redo.is_empty() -} - -fn undo_edit(s: &mut Session, redo: bool) -> Result { - not_playing(s, if redo { "actions.redo" } else { "actions.undo" })?; - let current = snapshot(s); - let prior = if redo { s.actions.redo.pop() } else { s.actions.undo.pop() }; - let Some(prior) = prior else { return Ok(json!(false)) }; - if redo { - s.actions.undo.push(current); - } else { - s.actions.redo.push(current); - } - let old_names: std::collections::BTreeSet<_> = s.actions.list.iter().map(|a| a.name.as_str()).collect(); - let new_names: std::collections::BTreeSet<_> = prior.list.iter().map(|a| a.name.as_str()).collect(); - let changed_bindings: Vec<_> = old_names.symmetric_difference(&new_names).map(|name| format!("{PLAY_SHORTCUT_PREFIX}{name}")).collect(); - s.actions.list = prior.list; - s.actions.recording = s.actions.recording.and(prior.recording).map(|(_, idx)| (s.journal.len(), idx)); - if !changed_bindings.is_empty() { - s.edit_prefs(|prefs| { - for key in changed_bindings { - prefs.shortcuts.remove(&key); - if let Some(value) = prior.shortcuts.get(&key) { - prefs.shortcuts.insert(key, value.clone()); - } - } - }); - } - s.actions.touch(); - Ok(json!(true)) } /// Visibility gestures name a layer for replay, since document-local ids change on another image. @@ -805,46 +722,6 @@ fn set_color(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ - CommandSpec { - id: "actions.import", - label: "Load Actions…", - menu: &[], - shortcut: None, - params: r##"{"data":str} → {imported, skipped, actions:[names]}. Append version 1 Actions JSON, retarget nested calls, and rename conflicts; unchanged reimports do nothing."##, - enabled: always, - run: json_file::import, - journal: false, - }, - CommandSpec { - id: "actions.export", - label: "Save Actions…", - menu: &[], - shortcut: None, - params: r##"{"path":str?} → {count, bytes, data?:JSON string, path?}. Export version 1 Actions JSON, preserving disabled steps, dialog toggles and sets."##, - enabled: always, - run: json_file::export, - journal: false, - }, - CommandSpec { - id: "actions.undo", - label: "Undo Action Edit", - menu: &[], - shortcut: None, - params: "{} → bool. Undo an action definition edit without changing the document.", - enabled: |s| if can_undo(s) { Ok(()) } else { Err("nothing to undo".into()) }, - run: |s, _| undo_edit(s, false), - journal: false, - }, - CommandSpec { - id: "actions.redo", - label: "Redo Action Edit", - menu: &[], - shortcut: None, - params: "{} → bool. Redo an action definition edit without changing the document.", - enabled: |s| if can_redo(s) { Ok(()) } else { Err("nothing to redo".into()) }, - run: |s, _| undo_edit(s, true), - journal: false, - }, CommandSpec { id: "actions.move", label: "Move Action or Step", @@ -852,7 +729,7 @@ pub fn specs() -> Vec { shortcut: None, params: r##"{"action":name|index, "step":index?, "to":index}. Move a step within its action, or the action itself. to is the final zero-based index. Pending recorded steps can be moved."##, enabled: always, - run: |s, p| edit_action(s, p, move_item, true), + run: move_item, journal: false, }, CommandSpec { @@ -882,7 +759,7 @@ pub fn specs() -> Vec { shortcut: None, params: r##"{"name":str? (new action, default "Action N")} or {"action":name|index} (append) → {action, index}"##, enabled: always, - run: |s, p| edit_action(s, p, record, true), + run: record, journal: false, }, CommandSpec { @@ -892,7 +769,7 @@ pub fn specs() -> Vec { shortcut: None, params: "{} → {action, steps:count} (steps recorded since actions.record, queries and action-editing commands omitted)", enabled: always, - run: |s, p| edit_action(s, p, stop, false), + run: stop, journal: false, }, CommandSpec { @@ -912,7 +789,7 @@ pub fn specs() -> Vec { shortcut: None, params: r##"{"action":name|index, "step":index?} → {deleted:name} or {action, deletedStep, steps:count}. step is 0-based, including pending recorded steps. Whole-action deletion is refused while recording."##, enabled: always, - run: |s, p| edit_action(s, p, delete, true), + run: delete, journal: false, }, CommandSpec { @@ -922,7 +799,7 @@ pub fn specs() -> Vec { shortcut: None, params: r##"{"action":name|index, "step":index?, "enabled":bool?} → {action, disabled:[step…]}. Switch one 0-based step (including pending recorded steps) on or off for playback, Batch and droplets; omitted enabled flips it. Without step, switch every step: on unless all already are, or as enabled says."##, enabled: always, - run: |s, p| edit_action(s, p, toggle, true), + run: toggle, journal: false, }, CommandSpec { @@ -932,7 +809,7 @@ pub fn specs() -> Vec { shortcut: None, params: r##"{"action":name|index, "name":str} → {action, index}. The name must not be empty or whitespace-only and must be unique. Nested Play Action steps and the action's function key follow the rename."##, enabled: always, - run: |s, p| edit_action(s, p, rename, true), + run: rename, journal: false, }, CommandSpec { diff --git a/crates/engine/src/cmd/actions_cmds/json_file.rs b/crates/engine/src/cmd/actions_cmds/json_file.rs deleted file mode 100644 index 51e6a11a71..0000000000 --- a/crates/engine/src/cmd/actions_cmds/json_file.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Portable Actions JSON files share the preset store's versioned representation. - -use std::collections::{HashMap, HashSet}; - -use super::{Action, ActionsFile, bad}; -use crate::{Result, Session}; -use serde_json::{Value, json}; - -const MAX_ACTIONS: usize = 1024; -const MAX_STEPS: usize = 10_000; -const MAX_NAME_BYTES: usize = 256; - -fn idle(s: &Session, cmd: &str) -> Result<()> { - if s.actions.recording.is_some() || s.actions.playing > 0 { - return Err(bad(cmd, "stop recording or playing before transferring Actions")); - } - Ok(()) -} - -fn validate(file: &ActionsFile, cmd: &str) -> Result<()> { - if file.version != 1 { - return Err(bad(cmd, format!("unsupported Actions file version {}", file.version))); - } - if file.actions.len() > MAX_ACTIONS { - return Err(bad(cmd, "an Actions JSON file may contain at most 1024 actions")); - } - let mut names = HashSet::new(); - for action in &file.actions { - if action.name.trim().is_empty() || action.name.len() > MAX_NAME_BYTES || !names.insert(&action.name) { - return Err(bad(cmd, "action names must be unique, non-empty and at most 256 bytes")); - } - if action.steps.len() > MAX_STEPS { - return Err(bad(cmd, "an action may contain at most 10,000 steps")); - } - if action.disabled.iter().chain(&action.dialogs).any(|&i| i >= action.steps.len()) { - return Err(bad(cmd, "a disabled or dialog step index is out of range")); - } - if action.set.as_ref().is_some_and(|name| name.len() > MAX_NAME_BYTES) { - return Err(bad(cmd, "action set names may contain at most 256 bytes")); - } - } - Ok(()) -} - -fn same_contents(a: &Action, b: &Action) -> bool { - a.steps == b.steps && a.disabled == b.disabled && a.dialogs == b.dialogs && a.set == b.set && a.color == b.color -} - -fn same_name_family(source: &str, candidate: &str) -> bool { - if source == candidate { - return true; - } - let Some((stem, tail)) = candidate.rsplit_once(" (Imported ") else { return false }; - if tail.strip_suffix(')').and_then(|n| n.parse::().ok()).is_none_or(|n| n < 2) { - return false; - } - let suffix_len = candidate.len().saturating_sub(stem.len()); - let mut end = source.len().min(MAX_NAME_BYTES.saturating_sub(suffix_len)); - while !source.is_char_boundary(end) { - end = end.saturating_sub(1); - } - source.get(..end) == Some(stem) -} - -fn retarget(action: &mut Action, original: &[String], mapping: &HashMap, cmd: &str) -> Result<()> { - for (id, params) in &mut action.steps { - if id != "actions.play" { - continue; - } - let name = match params.get("action") { - Some(Value::String(name)) => name.clone(), - Some(Value::Number(n)) => { - let index = n.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "nested action index must be a non-negative integer"))?; - original.get(index).cloned().ok_or_else(|| bad(cmd, format!("invalid nested action index {index}")))? - } - _ => return Err(bad(cmd, "nested action calls need a name or index")), - }; - if let Some(target) = mapping.get(&name) { - let object = params.as_object_mut().ok_or_else(|| bad(cmd, "nested action parameters must be an object"))?; - object.insert("action".into(), json!(target)); - } - } - Ok(()) -} - -fn imported_name(name: &str, reserved: &mut HashSet, cmd: &str) -> Result { - if reserved.insert(name.to_string()) { - return Ok(name.to_string()); - } - // There can be no more occupied candidates than reserved names. The bound also avoids - // an unbounded counter on a hostile library. - for n in 2..=reserved.len().saturating_add(2) { - let suffix = format!(" (Imported {n})"); - let limit = MAX_NAME_BYTES.saturating_sub(suffix.len()); - let mut end = name.len().min(limit); - while !name.is_char_boundary(end) { - end = end.saturating_sub(1); - } - let base = name.get(..end).ok_or_else(|| bad(cmd, "invalid action name boundary"))?; - let candidate = format!("{base}{suffix}"); - if reserved.insert(candidate.clone()) { - return Ok(candidate); - } - } - Err(bad(cmd, "cannot allocate a unique imported action name")) -} - -pub(super) fn import(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "actions.import"; - idle(s, CMD)?; - let data = p.get("data").and_then(Value::as_str).ok_or_else(|| bad(CMD, "data must be an Actions JSON string"))?; - if data.len() as u64 > crate::preset_store::MAX_ACTIONS_BYTES { - return Err(bad(CMD, "Actions JSON exceeds the 16 MiB import limit")); - } - let file: ActionsFile = serde_json::from_str(data).map_err(|e| bad(CMD, format!("invalid Actions JSON: {e}")))?; - validate(&file, CMD)?; - let original: Vec = file.actions.iter().map(|a| a.name.clone()).collect(); - let existing = &s.actions.list; - let mut reserved: HashSet = existing.iter().map(|a| a.name.clone()).chain(original.iter().cloned()).collect(); - let mut mapping = HashMap::new(); - for action in &file.actions { - let target = if let Some(found) = existing.iter().find(|a| a.name == action.name && same_contents(a, action)) { - found.name.clone() - } else if !existing.iter().any(|a| a.name == action.name) { - action.name.clone() - } else { - imported_name(&action.name, &mut reserved, CMD)? - }; - mapping.insert(action.name.clone(), target); - } - // A parent can precede its child. Reusing an already imported child can make the - // parent reusable too, so resolve those dependencies before mutating the library. - let mut planned = Vec::new(); - for _ in 0..=file.actions.len() { - planned.clear(); - let mut corrections = Vec::new(); - for source in &file.actions { - let mut action = source.clone(); - retarget(&mut action, &original, &mapping, CMD)?; - let target = mapping.get(&source.name).ok_or_else(|| bad(CMD, "missing imported action name"))?; - action.name = target.clone(); - if let Some(found) = existing.iter().find(|a| same_contents(a, &action) && (a.name == *target || same_name_family(&source.name, &a.name))) { - if action.name != found.name { - corrections.push((source.name.clone(), found.name.clone())); - } - action.name = found.name.clone(); - } - planned.push(action); - } - if corrections.is_empty() { - break; - } - mapping.extend(corrections); - } - // Apply the final mapping once more so every retained nested call uses the destination. - for (source, action) in file.actions.iter().zip(&mut planned) { - *action = source.clone(); - retarget(action, &original, &mapping, CMD)?; - action.name = mapping.get(&source.name).cloned().ok_or_else(|| bad(CMD, "missing imported action name"))?; - } - let insert: Vec = planned.into_iter().filter(|a| !existing.iter().any(|b| a == b)).collect(); - let names: Vec = insert.iter().map(|a| a.name.clone()).collect(); - let imported = insert.len(); - if imported > 0 { - s.actions.list.extend(insert); - s.actions.touch(); - } - Ok(json!({"imported": imported, "skipped": original.len().saturating_sub(imported), "actions": names})) -} - -pub(super) fn export(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "actions.export"; - idle(s, CMD)?; - let path = match p.get("path") { - None => None, - Some(Value::String(path)) if !path.trim().is_empty() => Some(path.as_str()), - Some(_) => return Err(bad(CMD, "path must be a non-empty string")), - }; - let file = ActionsFile { version: 1, defaults_version: 0, actions: s.actions.list.clone() }; - validate(&file, CMD)?; - let data = serde_json::to_string_pretty(&file).map_err(|e| bad(CMD, e.to_string()))?; - if data.len() as u64 > crate::preset_store::MAX_ACTIONS_BYTES { - return Err(bad(CMD, "Actions JSON exceeds the 16 MiB export limit")); - } - let mut out = json!({"count": file.actions.len(), "bytes": data.len()}); - if let Some(path) = path { - crate::file_cmds::write_file(path, data.as_bytes())?; - out["path"] = json!(path); - } else { - out["data"] = json!(data); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn json_actions_round_trip_preserves_metadata() { - let mut s = Session::new(); - let mut action = Action::new("Draw", vec![("layer.new.layer".into(), json!({}))]); - action.disabled.insert(0); - action.dialogs.insert(0); - action.set = Some("Painting".into()); - s.actions.list.push(action.clone()); - let exported = s.execute("actions.export", json!({})).unwrap(); - s.actions.list.clear(); - let result = s.execute("actions.import", json!({"data": exported["data"]})).unwrap(); - assert_eq!(result["imported"], 1); - assert_eq!(s.actions.list, [action]); - let rev = s.actions.rev; - assert_eq!(s.execute("actions.import", json!({"data": exported["data"]})).unwrap()["imported"], 0); - assert_eq!(s.actions.rev, rev); - } - - #[test] - fn reimport_after_changing_button_color_keeps_names_unique() { - let mut s = Session::new(); - s.actions.list.push(Action::new("Draw", vec![])); - let data = s.execute("actions.export", json!({})).unwrap()["data"].clone(); - s.execute("actions.setColor", json!({"action": "Draw", "color": "red"})).unwrap(); - let params = json!({"data": data}); - assert_eq!(s.execute("actions.import", params.clone()).unwrap()["imported"], 1); - assert_eq!(s.actions.list[0].name, "Draw"); - assert_eq!(s.actions.list[0].color, "red"); - assert_eq!(s.actions.list[1].name, "Draw (Imported 2)"); - assert!(s.actions.list[1].color.is_empty()); - let before = s.actions.clone(); - assert_eq!(s.execute("actions.import", params).unwrap()["imported"], 0); - assert_eq!(s.actions, before); - } - - #[test] - fn parents_before_children_are_retargeted_and_reimport_is_idempotent() { - let mut s = Session::new(); - s.actions.list.push(Action::new("Child", vec![])); - let file = ActionsFile { - version: 1, - defaults_version: 0, - actions: vec![ - Action::new("Parent", vec![("actions.play".into(), json!({"action": 1}))]), - Action::new("Child", vec![("layer.new.layer".into(), json!({}))]), - Action::new("Named", vec![("actions.play".into(), json!({"action": "Child"}))]), - ], - }; - let params = json!({"data": serde_json::to_string(&file).unwrap()}); - assert_eq!(s.execute("actions.import", params.clone()).unwrap()["imported"], 3); - assert_eq!(s.actions.list[1].steps[0].1["action"], "Child (Imported 2)"); - assert_eq!(s.actions.list[2].name, "Child (Imported 2)"); - assert_eq!(s.actions.list[3].steps[0].1["action"], "Child (Imported 2)"); - let before = s.actions.clone(); - assert_eq!(s.execute("actions.import", params).unwrap()["imported"], 0); - assert_eq!(s.actions, before); - } - - #[test] - fn invalid_json_params_and_nested_indices_fail_atomically() { - let mut s = Session::new(); - s.actions.list.push(Action::new("Keep", vec![])); - let before = s.actions.clone(); - for p in [Value::Null, json!([]), json!({"data": true}), json!({"data": "invalid"}), json!({"data": "{\"version\":2,\"actions\":[]}"})] { - assert!(s.execute("actions.import", p).is_err()); - assert_eq!(s.actions, before); - } - for target in [json!(-1), json!(0.5), json!(1), Value::Null] { - let file = - ActionsFile { version: 1, defaults_version: 0, actions: vec![Action::new("A", vec![("actions.play".into(), json!({"action": target}))])] }; - assert!(s.execute("actions.import", json!({"data": serde_json::to_string(&file).unwrap()})).is_err()); - assert_eq!(s.actions, before); - } - assert!(s.execute("actions.export", json!({"path": 1})).is_err()); - s.actions.recording = Some((0, 0)); - assert!(s.execute("actions.import", json!({"data": "{}"})).is_err()); - assert!(s.execute("actions.export", json!({})).is_err()); - assert_eq!(s.actions.list, before.list); - } - - #[test] - fn long_unicode_collision_names_remain_portable_and_idempotent() { - let mut s = Session::new(); - let name = "界".repeat(85); - s.actions.list.push(Action::new(&name, vec![])); - let file = ActionsFile { version: 1, defaults_version: 0, actions: vec![Action::new(&name, vec![("select.all".into(), json!({}))])] }; - let params = json!({"data": serde_json::to_string(&file).unwrap()}); - assert_eq!(s.execute("actions.import", params.clone()).unwrap()["imported"], 1); - assert!(s.actions.list[1].name.len() <= MAX_NAME_BYTES); - assert_eq!(s.execute("actions.import", params).unwrap()["imported"], 0); - assert!(s.execute("actions.export", json!({})).is_ok()); - } -} diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index d8350b0f82..81511bf262 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -93,93 +93,6 @@ fn unchecked_steps_are_skipped_and_follow_move_and_delete() { assert_eq!(s.actions.list[0].disabled.iter().copied().collect::>(), [1], "rejected toggles change nothing"); } -#[test] -fn recording_and_appending_steps_can_be_undone_without_restarting_recording() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); - s.execute("actions.record", json!({"name": "A"})).unwrap(); - s.execute("layer.new.layer", json!({})).unwrap(); - s.execute("actions.stop", json!({})).unwrap(); - s.execute("actions.record", json!({"action": "A"})).unwrap(); - s.execute("select.all", json!({})).unwrap(); - s.execute("actions.stop", json!({})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - assert_eq!(s.actions.list[0].steps.len(), 2); - s.execute("actions.undo", json!({})).unwrap(); - assert_eq!(s.actions.list[0].steps.len(), 1); - assert!(s.actions.recording.is_none()); - s.execute("actions.redo", json!({})).unwrap(); - assert_eq!(s.actions.list[0].steps.len(), 2); - assert!(s.actions.recording.is_none()); - assert_eq!(s.active().unwrap().doc, doc); -} - -#[test] -fn definition_edits_undo_redo_without_touching_document_or_journal() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); - s.actions.list.push(Action { - name: "A".into(), - steps: vec![("layer.new.layer".into(), json!({})), ("select.all".into(), json!({}))], - disabled: Default::default(), - ..Default::default() - }); - s.actions.list.push(Action { name: "Caller".into(), steps: vec![("actions.play".into(), json!({"action": "A"}))], ..Default::default() }); - s.edit_prefs(|p| { - p.shortcuts.insert("actions.play:A".into(), "F6".into()); - }); - let before = s.actions.list.clone(); - let doc = s.active().unwrap().doc.clone(); - let journal = s.journal.clone(); - s.execute("actions.move", json!({"action": "A", "step": 0, "to": 1})).unwrap(); - s.execute("actions.delete", json!({"action": "A", "step": 0})).unwrap(); - s.execute("actions.rename", json!({"action": "A", "name": "B"})).unwrap(); - let after = s.actions.list.clone(); - for _ in 0..3 { - s.execute("actions.undo", json!({})).unwrap(); - } - assert_eq!(s.actions.list, before); - assert_eq!(s.prefs().shortcuts.get("actions.play:A").map(String::as_str), Some("F6")); - assert!(!s.prefs().shortcuts.contains_key("actions.play:B")); - for _ in 0..3 { - s.execute("actions.redo", json!({})).unwrap(); - } - assert_eq!(s.actions.list, after); - assert_eq!(s.active().unwrap().doc, doc); - assert_eq!(s.journal, journal); - s.execute("actions.undo", json!({})).unwrap(); - s.execute("actions.delete", json!({"action": "Caller"})).unwrap(); - assert!(!can_redo(&s)); -} - -#[test] -fn enabled_step_edits_undo_and_invalidate_redo() { - let mut s = Session::new(); - s.actions.list.push(Action::new("A", vec![("select.all".into(), json!({}))])); - s.execute("actions.toggle", json!({"action": "A", "step": 0})).unwrap(); - assert!(s.actions.list[0].disabled.contains(&0)); - s.execute("actions.undo", json!({})).unwrap(); - assert!(s.actions.list[0].disabled.is_empty()); - s.execute("actions.redo", json!({})).unwrap(); - assert!(s.actions.list[0].disabled.contains(&0)); - s.execute("actions.undo", json!({})).unwrap(); - s.execute("actions.toggle", json!({"action": "A"})).unwrap(); - assert!(!can_redo(&s)); -} - -#[test] -fn undo_pending_step_deletion_does_not_duplicate_recording() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); - s.execute("actions.record", json!({"name": "A"})).unwrap(); - s.execute("layer.new.layer", json!({})).unwrap(); - s.execute("actions.delete", json!({"action": "A", "step": 0})).unwrap(); - s.execute("actions.undo", json!({})).unwrap(); - s.execute("actions.stop", json!({})).unwrap(); - assert_eq!(s.actions.list[0].steps.len(), 1); - assert_eq!(s.active().unwrap().doc.layers.len(), 2); -} - #[test] fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() { let mut s = Session::new(); @@ -1092,8 +1005,8 @@ fn actions_persist_with_the_preset_store() { let mut t = Session::new(); t.attach_preset_store(crate::preset_store::open_dir(&dir)); let listed = t.execute("actions.list", json!({})).unwrap(); - let kept = listed["actions"].as_array().unwrap().iter().find(|a| a["name"] == "Kept").unwrap(); - assert_eq!(kept["steps"], 1); + assert_eq!(listed["actions"][0]["name"], "Kept"); + assert_eq!(listed["actions"][0]["steps"], 1); assert_eq!(t.execute("actions.get", json!({"action": "Kept"})).unwrap()["steps"][0][0], "layer.new.layer"); // An action created before the store finishes loading is kept, and a different one on diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 74b482bf62..78d65979bf 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -222,7 +222,7 @@ pub fn export( _ => format!("`{id}`"), }; return Err(format!( - "step {} of `{}` ({step_name}) has no Photoshop equivalent event or has a setting its event cannot hold; pass \"skipUnsupported\": true to leave it out", + "step {} of `{}` ({step_name}) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", i + 1, a.name )); diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index b45cca5389..011e9aa5e5 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -473,56 +473,6 @@ fn settings_at_the_value_the_event_plays_still_export() { } } -/// Photoshop's integer fields cannot hold a fractional setting. Rounding one into the file wrote -/// an edit the user never recorded -- `cellSize: 5.4` as `ClSz 5`, `amount: 81.4` as `Amnt 81` -- -/// and replay then changed the image with `skipped` empty and nothing in the file to say so -/// (#7669, #7670). Such a step is refused, as a step with no equivalent event already is. -/// -/// Only for the fields whose command uses the setting as written: `edit.stroke` ceils its width, -/// so 2.4 renders as 3 and the integer field holds it faithfully -- that one belongs in #7696, -/// not here (#7742 review). -#[test] -fn a_fractional_setting_an_integer_field_cannot_hold_is_refused_not_rounded() { - for (id, params) in [("filter.pixelate.crystallize", json!({"cellSize": 5.4})), ("filter.distort.ripple", json!({"amount": 81.4, "size": "small"}))] { - let mut s = Session::new(); - s.actions.list.push(Action::new("A", vec![(id.into(), params)])); - let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); - assert!(e.contains(id), "{id}: {e}"); - let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap(); - assert_eq!(r["skipped"], json!([{"action": "A", "step": 0, "id": id}]), "{id}"); - } -} - -/// The whole numbers those fields were made for are written and come back as themselves: the -/// refusal above is for the settings Photoshop's event cannot hold, not for the ones it can. -#[test] -fn whole_number_settings_for_integer_fields_still_round_trip() { - for (id, params) in [ - ("filter.pixelate.crystallize", json!({"cellSize": 5})), - ("filter.distort.ripple", json!({"amount": 81, "size": "small"})), - ("edit.stroke", json!({"width": 2, "color": "#d03060", "location": "inside"})), - ] { - let mut s = Session::new(); - s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())])); - let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); - assert_eq!(r["skipped"], json!([]), "{id}"); - let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); - let (back_id, back) = &imported.actions[0].steps[0]; - assert_eq!(back_id, id); - for (key, want) in params.as_object().unwrap() { - let got = back.get(key); - match want.as_f64() { - // A number can come back as an integer where it went out as a float, so these are - // compared by value. Anything else is compared as it is: `as_f64` is `None` for a - // string on both sides, so `size`, `color` and `location` went unchecked - // (#7742 review). - Some(n) => assert_eq!(got.and_then(Value::as_f64), Some(n), "{id}: {key} is {got:?}"), - None => assert_eq!(got, Some(want), "{id}: {key} is {got:?}"), - } - } - } -} - #[test] fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ diff --git a/crates/engine/src/cmd/background_blur_cmds.rs b/crates/engine/src/cmd/background_blur_cmds.rs deleted file mode 100644 index d49a6d3f3a..0000000000 --- a/crates/engine/src/cmd/background_blur_cmds.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Blur the background of one pixel layer while preserving the detected subject. Subject -//! coverage is temporary: the user's selection, layer masks and alpha are left intact. - -use photocraft_algo::{ - FilterParams, - segment::{Region, subject}, -}; -use photocraft_color::{PixelFormat, SampleType}; -use photocraft_doc::{Document, Layer, LayerContent}; -use photocraft_geom::Rect; -use photocraft_raster::Surface; -use serde_json::{Value, json}; - -use crate::commands::{CommandSpec, layer_param}; -use crate::jobs::JobCtx; -use crate::{EngineError, Result, Session}; - -const CMD: &str = "layer.blurBackground"; - -fn check(doc: &Document, layer: &Layer) -> std::result::Result<(), String> { - if !matches!(layer.content, LayerContent::Raster(_)) { - return Err("Blur Background needs a pixel layer".into()); - } - let locks = doc.effective_locks(layer.id); - if locks.pixels || locks.all { - return Err(format!("Could not complete your request because the layer \"{}\" is locked", layer.name)); - } - Ok(()) -} - -fn enabled(s: &Session) -> std::result::Result<(), String> { - let d = s.active().ok_or("no document open")?; - check(&d.doc, crate::active_layer_of(s)?) -} - -fn run(s: &mut Session, p: &Value) -> Result { - let bad = |msg: &str| EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }; - if !p.is_object() { - return Err(bad("parameters must be an object")); - } - let radius = match p.get("radius") { - None => 8.0, - Some(v) => v.as_f64().filter(|r| r.is_finite() && (0.1..=1000.0).contains(r)).ok_or_else(|| bad("radius must be a number from 0.1 to 1000"))? as f32, - }; - let id = layer_param(s, p)?; - let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - check(doc, doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?; - crate::jobs::edit_job( - s, - "Blur Background", - move |doc, _, ctx| { - let found = ctx - .stage(0.0, 0.45, "Finding subject", |ctl| { - ctl.check()?; - crate::smartselect_cmds::with_doc_sampler(doc, Some(id), false, |sampler, d| subject::select_subject_with(sampler, d.bounds(), ctl)) - }) - .map_err(|_| EngineError::Cancelled)?; - let subject = found.ok_or_else(|| EngineError::Other("no subject found; the layer was not changed".into()))?; - let source = doc.layer(id).and_then(Layer::surface).ok_or(EngineError::NoLayer(id))?; - let blurred = blur_background(source, &subject, doc.selection.as_ref(), doc.bounds(), radius, ctx)?; - ctx.check()?; - *crate::pixels_mut(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?)? = blurred; - Ok(()) - }, - move |()| json!({"layer": id.0, "radius": radius}), - ) -} - -fn blur_background(source: &Surface, subject: &Region, selection: Option<&Surface>, bounds: Rect, radius: f32, ctx: &JobCtx) -> Result { - let format = source.format(); - let colors = format.mode.color_channels(); - // Gaussian Blur premultiplies, blurs, then divides by alpha. Excluding subject coverage - // from this scratch alpha prevents fully selected foreground colours bleeding outward. - // Keep fractional coverage in float even for 8-bit documents. - let scratch_format = PixelFormat::new(format.mode, SampleType::F32, true); - let mut scratch = Surface::new(scratch_format); - ctx.progress(0.45, "Preparing background"); - for tile in bounds.tiles() { - ctx.check()?; - let area = tile.rect().intersect(&bounds); - let pixels = crate::allocation::read_region(source, area, "Blur Background")?; - let count = pixels.len() / source.channels(); - let mut data = crate::allocation::capacity(count * scratch_format.channels(), "Blur Background")?; - for (i, pixel) in pixels.chunks_exact(source.channels()).enumerate() { - let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32); - data.extend(pixel.iter().take(colors)); - let alpha = if format.alpha { pixel.last().copied().unwrap_or(0.0).clamp(0.0, 1.0) } else { 1.0 }; - data.push(alpha * (1.0 - subject.at(x, y))); - } - scratch.try_write_region(area, &data)?; - } - let filtered = ctx - .stage(0.5, 0.9, "Blurring background", |ctl| { - photocraft_algo::apply_in_with(&scratch, &FilterParams::GaussianBlur { radius }, bounds, bounds, None, bounds, ctl) - }) - .ok_or(EngineError::Cancelled)?; - // Sample freely across the background, then restrict only the result to the user's - // selection. The original layer remains the source for alpha and untouched pixels. - let mut output = source.clone(); - ctx.progress(0.9, "Applying background blur"); - for tile in bounds.tiles() { - ctx.check()?; - let area = tile.rect().intersect(&bounds); - let mut pixels = crate::allocation::read_region(source, area, "Blur Background")?; - let blurred = crate::allocation::read_region(&filtered, area, "Blur Background")?; - let mut changed = false; - for (i, (pixel, blurred)) in pixels.chunks_exact_mut(source.channels()).zip(blurred.chunks_exact(scratch_format.channels())).enumerate() { - let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32); - let weight = (1.0 - subject.at(x, y)) * selection.map_or(1.0, |mask| mask.sample_channel(x, y, 0).clamp(0.0, 1.0)); - if weight <= 0.0 || blurred.last().copied().unwrap_or(0.0) <= 1e-7 || (format.alpha && pixel.last().copied().unwrap_or(0.0) <= 0.0) { - continue; - } - for (original, blurred) in pixel.iter_mut().zip(blurred).take(colors) { - let value = *original * (1.0 - weight) + blurred * weight; - changed |= value != *original; - *original = value; - } - } - if changed { - output.try_write_region(area, &pixels)?; - } - } - ctx.check()?; - Ok(output) -} - -pub fn specs() -> Vec { - vec![CommandSpec { - id: CMD, - label: "Blur Background", - menu: &[], - shortcut: None, - params: r##"{"layer":id|name?,"radius":0.1..1000=8} → {layer,radius}. Detect a subject on the pixel layer and blur its background within the current selection; preserve selection, masks and alpha. One undo step. No detected subject leaves the document unchanged."##, - enabled, - run, - journal: true, - }] -} - -#[cfg(test)] -mod tests { - use super::*; - use photocraft_algo::segment::Rng; - use photocraft_doc::{LayerId, LayerMask}; - use std::sync::Arc; - - fn disc(width: u32, height: u32, depth: u32) -> Session { - let mut s = Session::new(); - s.execute("file.new", json!({"width": width, "height": height, "depth": depth})).unwrap(); - s.edit("test photo", |doc, _| { - let area = doc.bounds(); - let surface = doc.layers[0].surface_mut().unwrap(); - let mut rng = Rng::new(2); - for tile in area.tiles() { - let r = tile.rect().intersect(&area); - let mut pixels = Vec::new(); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let (dx, dy) = (x as f32 + 0.5 - width as f32 * 0.5, y as f32 + 0.5 - height as f32 * 0.5); - let noise = 0.05 * rng.normal(); - let inside = dx * dx + dy * dy <= (height as f32 * 0.28).powi(2); - let color = if inside { - let texture = ((x + y) as f32 * 0.35).sin() * 0.5 + 0.5; - [0.85 + 0.05 * texture + noise, 0.2 + 0.3 * texture + noise, 0.15 + noise] - } else { - [0.25 + noise, 0.45 + noise, 0.7 + noise] - }; - pixels.extend(color.map(|v| v.clamp(0.0, 1.0))); - if surface.format().alpha { - pixels.push(1.0); - } - } - } - surface.write_region(r, &pixels); - } - Ok(()) - }) - .unwrap(); - s - } - - fn id(s: &Session) -> LayerId { - s.active().unwrap().active_layer.unwrap() - } - - fn surface(s: &Session, layer: LayerId) -> &Surface { - s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap() - } - - fn background_energy(surface: &Surface) -> f32 { - (10..40).flat_map(|y| (10..40).map(move |x| (x, y))).map(|(x, y)| (surface.sample_channel(x, y, 0) - 0.25).powi(2)).sum::() / 900.0 - } - - #[test] - fn blur_background_preserves_subject_and_undoes_at_every_depth() { - for depth in [8, 16, 32] { - let mut s = disc(200, 160, depth); - let layer = id(&s); - let before = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - assert!(s.is_enabled(CMD)); - // Exercise the shipping action, including its command params and single-step undo. - s.execute("actions.loadDefaults", json!({})).unwrap(); - let result = s.execute("actions.play", json!({"action": "Blur Background"})).unwrap(); - assert_eq!(result["ran"], 1, "{depth}: {result}"); - assert!(result.get("failed").is_none(), "{depth}: {result}"); - assert_eq!(s.active().unwrap().history.past_len(), history + 1); - let old = before.layer(layer).unwrap().surface().unwrap(); - let new = surface(&s, layer); - assert_eq!( - new.read_region(Rect::new(85, 65, 115, 95)), - old.read_region(Rect::new(85, 65, 115, 95)), - "the subject interior remains sharp at {depth}" - ); - assert!(background_energy(new) < background_energy(old) * 0.1, "background noise is reduced at {depth}"); - let after = s.active().unwrap().doc.clone(); - assert!(s.undo()); - assert_eq!(s.active().unwrap().doc, before); - assert!(s.redo()); - assert_eq!(s.active().unwrap().doc, after); - } - } - - #[test] - fn blur_background_preserves_selection_alpha_masks_and_other_layers() { - let mut s = disc(200, 160, 16); - let layer = id(&s); - s.execute("layer.new.layer", json!({"name": "Unchanged overlay"})).unwrap(); - s.edit("existing state", |doc, _| { - let layer = doc.layer_mut(layer).unwrap(); - let surf = layer.surface_mut().unwrap(); - for y in 0..160 { - for x in 0..200 { - let mut pixel = surf.pixel(x, y); - *pixel.last_mut().unwrap() = if x < 50 { 0.5 } else { 1.0 }; - surf.write_pixel(x, y, &pixel); - } - } - // Off-canvas pixels must survive the canvas-bounded edit too. - surf.fill_rect(Rect::new(-8, -8, 0, 0), &[0.1, 0.2, 0.3, 0.4]); - layer.mask = Some(LayerMask { density: 0.7, feather: 2.0, ..LayerMask::reveal_all() }); - layer.opacity = 0.6; - let mut selection = Surface::new(PixelFormat::GRAY8); - selection.fill_rect(Rect::new(0, 0, 60, 160), &[0.5]); - doc.selection = Some(selection); - Ok(()) - }) - .unwrap(); - // A mask target must not turn this layer operation into a mask filter. - s.active_mut().unwrap().active_layer = Some(layer); - s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask; - let before = s.active().unwrap().doc.clone(); - s.execute(CMD, json!({"radius": 4})).unwrap(); - let mut expected = (*before).clone(); - expected.layer_mut(layer).unwrap().content = s.active().unwrap().doc.layer(layer).unwrap().content.clone(); - assert_eq!(*s.active().unwrap().doc, expected, "only target layer pixels may change"); - let (old, new) = (before.layer(layer).unwrap().surface().unwrap(), surface(&s, layer)); - assert_ne!(new.read_region(Rect::new(10, 10, 40, 40)), old.read_region(Rect::new(10, 10, 40, 40))); - assert_eq!(new.read_region(Rect::new(60, 0, 200, 160)), old.read_region(Rect::new(60, 0, 200, 160)), "outside the user's selection"); - assert_eq!(new.read_region(Rect::new(-8, -8, 0, 0)), old.read_region(Rect::new(-8, -8, 0, 0))); - for y in 0..160 { - for x in 0..200 { - assert_eq!(new.sample_channel(x, y, 3), old.sample_channel(x, y, 3)); - } - } - assert_eq!(s.active().unwrap().channel_view.target, crate::channel_cmds::ChannelTarget::LayerMask); - } - - #[test] - fn background_sampling_excludes_subject_colors_for_both_blur_paths() { - let bounds = Rect::new(0, 0, 80, 60); - let subject = Region { bbox: Rect::new(30, 10, 50, 50), mask: vec![255; 20 * 40] }; - use photocraft_color::ColorMode; - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { - for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { - let mut source = Surface::new(PixelFormat::new(mode, depth, true)); - let colors = mode.color_channels(); - let mut background = vec![0.2; colors]; - let mut foreground = vec![0.9; colors]; - if mode == ColorMode::Rgb { - background = vec![0.0, 0.3, 0.85]; - foreground = vec![1.0, 0.0, 0.0]; - } - background.push(0.5); - foreground.push(1.0); - source.fill_rect(bounds, &background); - source.fill_rect(subject.bbox, &foreground); - for radius in [3.0, 8.0] { - let output = blur_background(&source, &subject, None, bounds, radius, &JobCtx::new()).unwrap(); - assert_eq!(output.format(), source.format()); - let edge = output.pixel(29, 30); - for (c, value) in edge.iter().take(colors).enumerate() { - assert!( - (value - source.sample_channel(29, 30, c)).abs() < 0.005, - "foreground leaked into {mode:?} channel {c}, {depth:?}, radius {radius}: {edge:?}" - ); - } - if mode == ColorMode::Rgb { - assert!(edge[0] < 1e-6, "subject red leaked through at {depth:?}, radius {radius}: {edge:?}"); - } - assert_eq!(output.read_region(subject.bbox), source.read_region(subject.bbox)); - assert_eq!(edge[colors], source.sample_channel(29, 30, colors)); - } - } - } - let mut tiny_alpha = Surface::new(PixelFormat::RGBA32F); - tiny_alpha.fill_rect(bounds, &[0.2, 0.3, 0.4, 1e-9]); - let output = blur_background(&tiny_alpha, &subject, None, bounds, 8.0, &JobCtx::new()).unwrap(); - assert_eq!(output, tiny_alpha, "no usable background contribution must not turn into black"); - } - - #[test] - fn blur_background_without_subject_or_when_cancelled_changes_nothing() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); - s.execute("select.rect", json!({"x": 4, "y": 4, "width": 16, "height": 16})).unwrap(); - let before = s.active().unwrap().doc.clone(); - let steps = s.active().unwrap().history.past_len(); - assert!(s.execute(CMD, json!({})).is_err()); - assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); - assert_eq!(steps, s.active().unwrap().history.past_len()); - - let mut s = disc(200, 160, 8); - let before = s.active().unwrap().doc.clone(); - let steps = s.active().unwrap().history.past_len(); - let ctx = JobCtx::new(); - ctx.cancel(); - s.set_inline_job_ctx(Some(ctx)); - assert!(matches!(s.execute(CMD, json!({})), Err(EngineError::Cancelled))); - assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); - assert_eq!(steps, s.active().unwrap().history.past_len()); - } - - #[test] - fn blur_background_rejects_invalid_targets_params_and_effective_locks() { - let mut empty = Session::new(); - assert!(!empty.is_enabled(CMD)); - assert!(empty.execute(CMD, json!({})).is_err()); - let mut s = disc(200, 160, 8); - let before = s.active().unwrap().doc.clone(); - for params in [ - Value::Null, - json!([]), - json!(false), - json!({"radius": "8"}), - json!({"radius": null}), - json!({"radius": 0}), - json!({"radius": -1}), - json!({"radius": 1001}), - json!({"radius": 1e100}), - json!({"layer": false}), - json!({"layer": -1}), - json!({"layer": 1.5}), - json!({"layer": "missing"}), - json!({"layer": 999_999}), - ] { - assert!(s.execute(CMD, params.clone()).is_err(), "{params}"); - assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); - } - s.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap(); - assert!(!s.is_enabled(CMD)); - assert!(s.execute(CMD, json!({})).is_err()); - s.execute("layer.setProps", json!({"locks": {"pixels": false}})).unwrap(); - s.execute("layer.groupLayers", json!({})).unwrap(); - // The group is not a raster target; a locked parent also protects the member's pixels. - assert!(!s.is_enabled(CMD)); - assert!(s.execute(CMD, json!({})).is_err()); - let group = id(&s); - s.execute("layer.setProps", json!({"locks": {"all": true}})).unwrap(); - let child = s.active().unwrap().doc.layer(group).unwrap().children().unwrap().first().unwrap().id; - let before = s.active().unwrap().doc.clone(); - assert!(s.execute(CMD, json!({"layer": child.0})).is_err()); - assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); - } - - #[test] - #[ignore = "release-only 24 MP background blur timing; run explicitly"] - fn blur_background_24mp_timing() { - let mut s = disc(6000, 4000, 8); - let started = std::time::Instant::now(); - let result = s.execute(CMD, json!({})).unwrap(); - println!("Blur Background 24 MP: {:?}; {result}", started.elapsed()); - } -} diff --git a/crates/engine/src/cmd/frequency_separation_cmds.rs b/crates/engine/src/cmd/frequency_separation_cmds.rs deleted file mode 100644 index b1e9e3e2ea..0000000000 --- a/crates/engine/src/cmd/frequency_separation_cmds.rs +++ /dev/null @@ -1,475 +0,0 @@ -//! Filter › Other › Frequency Separation (`layer.frequencySeparation`, #1714): splits the active -//! pixel layer into a "Low Frequency" layer (a Gaussian-blurred copy: colour and tone) and a -//! "High Frequency" layer (the detail the blur took out) in a "Frequency Separation" group right -//! above it, so tone and texture can be retouched apart. One history step; the source layer is -//! left as it is, under the group. -//! -//! High = (Source − Low) / 2 + ½ in every colour channel, on Linear Light (Low + 2·High − 1), -//! gives the source back. That is Photoshop's recipe (Apply Image › Subtract, Scale 2, Offset 128 -//! in 8-bit; Add with Invert, Scale 2, Offset 0 in 16-bit: the same values) and it holds in 32-bit -//! too, since our Linear Light is the same formula at every depth. It clips at 0 and 1, so 32-bit -//! values above 1 come back as 1. 8-bit stores High to the nearest level, so the result is within -//! one level of the source, as in Photoshop. -//! -//! Photoshop has no such menu item (retouchers record it as an action), so the id is our own. It -//! is not a `filter.*` command: it adds layers instead of filtering the target, so it must not -//! follow a targeted mask or channel, nor be offered to Fade or Last Filter. The new layers carry -//! the source's pixels only (no mask, style or blending options), and the selection is ignored. - -use photocraft_color::{BlendMode, ColorMode}; -use photocraft_doc::{Layer, LayerContent}; -use photocraft_geom::Rect; -use photocraft_raster::Surface; -use rayon::prelude::*; -use serde_json::{Value, json}; - -use crate::commands::CommandSpec; -use crate::{EngineError, Result, Session}; - -/// The command id. -pub const ID: &str = "layer.frequencySeparation"; - -const LABEL: &str = "Frequency Separation"; -const GAUSSIAN: &str = "filter.blur.gaussianBlur"; -/// Gaussian Blur's own range and a portrait-sized default. -const RADIUS: (f64, f64, f64) = (0.1, 1000.0, 4.0); - -fn bad(msg: impl Into) -> EngineError { - EngineError::BadParams { cmd: ID.into(), msg: msg.into() } -} - -fn radius(p: &Value) -> Result { - let (min, max, default) = RADIUS; - match p.get("radius") { - None => Ok(default), - Some(v) => { - v.as_f64().filter(|r| r.is_finite() && (min..=max).contains(r)).ok_or_else(|| bad(format!("`radius` must be a number in {min}..{max}, not {v}"))) - } - } -} - -/// The pixels of `l`, when it is a pixel layer with any. Only the tile count is checked here, as -/// the menu asks often; [`run`] looks at the pixels themselves. -fn pixels_of(l: &Layer) -> std::result::Result<&Surface, String> { - match &l.content { - LayerContent::Raster(surf) if surf.tile_count() > 0 => Ok(surf), - LayerContent::Raster(_) => Err(format!("the layer \"{}\" has no pixels", l.name)), - other => Err(format!("Frequency Separation needs a pixel layer (\"{}\" is {} {} layer)", l.name, other.article(), other.kind_name())), - } -} - -/// Menu state: an RGB or Grayscale document whose active layer is a pixel layer with pixels, its -/// pixels targeted (not a channel, Quick Mask or the layer's mask). -fn enabled(s: &Session) -> std::result::Result<(), String> { - if crate::channel_cmds::edits_channel(s) { - return Err("Frequency Separation works on layer pixels, not on a channel or Quick Mask".into()); - } - let d = s.active().ok_or("no document open")?; - if d.channel_view.target == crate::channel_cmds::ChannelTarget::LayerMask || crate::mask_view_cmds::current(d).is_some() { - return Err("Frequency Separation works on layer pixels, not on a layer mask".into()); - } - match d.doc.mode { - ColorMode::Rgb | ColorMode::Grayscale => {} - m => return Err(format!("Frequency Separation needs an RGB or Grayscale document (this one is {m:?})")), - } - pixels_of(crate::active_layer_of(s)?).map(|_| ()) -} - -/// High = (Source − Low) / 2 + ½ in the colour channels, the source's transparency kept. Where a -/// layer with transparency is transparent High stays so; a layer without (the Background) is -/// computed over the whole canvas. -fn high_pass(src: &Surface, low: &Surface, canvas: Rect) -> Surface { - let fmt = src.format(); - let (n, colors) = (fmt.channels(), fmt.mode.color_channels()); - let coords: Vec<_> = if fmt.alpha { src.tiles().map(|(c, _)| *c).collect() } else { canvas.union(&src.tile_bounds()).tiles().collect() }; - let mut high = src.clone(); - // A bounded batch at a time: the f32 copies of a whole 36 MP layer would take gigabytes. - for batch in coords.chunks(rayon::current_num_threads().max(1) * 4) { - let done: Vec<_> = batch - .par_iter() - .map(|c| { - let r = c.rect(); - let mut px = src.read_region(r); - for (h, l) in px.chunks_exact_mut(n).zip(low.read_region(r).chunks_exact(n)) { - // Nothing to rebuild where the source is transparent: leaving it as it is keeps - // High's bounds the source's (mid-grey there would fill out whole tiles). - if fmt.alpha && h.get(colors).is_some_and(|a| *a <= 0.0) { - continue; - } - for (h, l) in h.iter_mut().zip(l).take(colors) { - *h = (*h - l) * 0.5 + 0.5; - } - } - (r, px) - }) - .collect(); - for (r, px) in done { - high.write_region(r, &px); - } - } - high.prune(); - high -} - -fn run(s: &mut Session, p: &Value) -> Result { - let radius = radius(p)?; - enabled(s).map_err(EngineError::Other)?; - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - let (group, low, high) = s.edit(LABEL, |doc, active| { - let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let canvas = doc.bounds(); - let source = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - let src = pixels_of(source).map_err(EngineError::Other)?; - if src.content_bounds().is_empty() { - return Err(EngineError::Other(format!("the layer \"{}\" has no pixels", source.name))); - } - let low = crate::filters::apply_filter_to_surface(GAUSSIAN, &json!({ "radius": radius }), src, canvas, None, canvas)? - .ok_or_else(|| EngineError::Other("Gaussian Blur is unavailable".into()))?; - // From the Low that is stored (quantised at 8 and 16 bits), so the two add up exactly. - let high = high_pass(src, &low, canvas); - let low = Layer::new("Low Frequency", LayerContent::Raster(low)); - let mut high = Layer::new("High Frequency", LayerContent::Raster(high)); - high.blend = BlendMode::LinearLight; - let ids = (low.id, high.id); - let group = doc.insert_above(Some(id), Layer::group(LABEL, vec![low, high])); - crate::layer_multi_cmds::check_group_depth(doc, LABEL)?; - *active = Some(ids.1); - Ok((group, ids.0, ids.1)) - })?; - crate::layer_multi_cmds::note_insert(s, &before); - Ok(json!({ "group": group.0, "low": low.0, "high": high.0, "radius": radius })) -} - -/// The command spec. -pub fn specs() -> Vec { - vec![CommandSpec { - id: ID, - label: "Frequency Separation…", - menu: &["Filter", "Other"], - shortcut: None, - params: r##"{"radius":0.1..1000=4} → {group,low,high,radius} (Low Frequency = Gaussian Blur of the active pixel layer, High Frequency = (layer − Low) / 2 + ½ on Linear Light, grouped above it)"##, - enabled, - run, - journal: true, - }] -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{EngineError, Session}; - use photocraft_color::BlendMode; - use photocraft_doc::{Document, LayerContent, LayerId}; - use photocraft_geom::Rect; - use serde_json::json; - - const W: i32 = 40; - const H: i32 = 28; - - /// Fine detail on a slow gradient, different in every channel, so a blur changes it a lot. - fn texture(x: i32, y: i32) -> [f32; 4] { - let hash = |k: i32| { - let v = ((x * 73 + y * 151 + k * 211) as f32 * 12.9898).sin() * 43758.547; - v - v.floor() - }; - let ramp = (x + y) as f32 / (W + H) as f32; - [0.1 + 0.5 * ramp + 0.35 * hash(1), 0.8 - 0.6 * ramp + 0.15 * hash(2), 0.5 + 0.45 * (hash(3) - 0.5), 1.0] - } - - /// A document with a white Background and an opaque textured pixel layer on top (active). - fn session(depth: u32, mode: &str) -> Session { - let mut s = Session::new(); - s.execute("file.new", json!({"width": W, "height": H, "depth": depth, "mode": mode})).unwrap(); - s.execute("layer.new.layer", json!({"name": "Skin"})).unwrap(); - paint(&mut s); - s - } - - fn paint(s: &mut Session) { - s.edit("texture", |doc, active| { - let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); - let fmt = surf.format(); - let px: Vec = (0..H).flat_map(|y| (0..W).flat_map(move |x| photocraft_raster::from_rgba(&fmt, texture(x, y)))).collect(); - surf.write_region(Rect::new(0, 0, W, H), &px); - Ok(()) - }) - .unwrap(); - } - - fn doc(s: &Session) -> &Document { - &s.active().unwrap().doc - } - - fn active(s: &Session) -> LayerId { - s.active().unwrap().active_layer.unwrap() - } - - fn composite(d: &Document) -> Vec<[f32; 4]> { - photocraft_compose::flatten(d).px - } - - fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { - assert_eq!(a.len(), b.len()); - a.iter().zip(b).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max) - } - - /// The document with layer `id` hidden. - fn without(d: &Document, id: LayerId) -> Document { - let mut d = d.clone(); - d.layer_mut(id).unwrap().visible = false; - d - } - - /// The new group: (group id, [Low, High] ids, bottom to top). - fn group_of(d: &Document, source: LayerId) -> (LayerId, [LayerId; 2]) { - let path = d.path_of(source).unwrap(); - let (&k, parent) = path.split_last().unwrap(); - let mut above = parent.to_vec(); - above.push(k + 1); - let g = d.layer_at(&above).expect("a layer right above the source"); - let ch = g.children().expect("a group right above the source"); - assert_eq!(ch.len(), 2, "two layers in the group"); - (g.id, [ch[0].id, ch[1].id]) - } - - fn pixels(d: &Document, id: LayerId) -> Vec { - d.layer(id).unwrap().surface().unwrap().read_region(Rect::new(0, 0, W, H)) - } - - /// The defining identity: High on Linear Light over Low gives the source back, through the - /// compositor, to within one step of the depth (8-bit stores High to the nearest level: Photoshop's - /// recipe is ±1 level too). Low and High each differ from it, so the identity isn't a no-op. - #[test] - fn low_and_high_composite_back_to_the_source_at_every_depth() { - for (depth, mode, tol) in [(8, "rgb", 1.0 / 255.0), (16, "rgb", 1.0 / 65535.0), (32, "rgb", 1e-5), (8, "gray", 1.0 / 255.0), (32, "gray", 1e-5)] { - let tol = tol + 1e-6; - let mut s = session(depth, mode); - let source = active(&s); - let before = composite(doc(&s)); - s.execute(ID, json!({"radius": 3})).unwrap_or_else(|e| panic!("{depth} {mode}: {e}")); - let d = doc(&s); - let (_, [low, high]) = group_of(d, source); - // The group alone rebuilds the source: hide the source so it can't show through. - let rebuilt = composite(&without(d, source)); - let err = max_diff(&before, &rebuilt); - assert!(err <= tol, "{depth} {mode}: Low + High is off by {err} (tolerance {tol})"); - assert!(max_diff(&composite(d), &rebuilt) <= 1e-6, "{depth} {mode}: the group covers the source"); - // Low alone is visibly smoother than the source; High carries what it lost. - let low_only = composite(&without(&without(d, source), high)); - assert!(max_diff(&before, &low_only) > 0.1, "{depth} {mode}: Low is blurred"); - let h = d.layer(high).unwrap().surface().unwrap(); - assert!((0..W).any(|x| (h.rgba(x, 5)[0] - 0.5).abs() > 0.05), "{depth} {mode}: High holds the detail"); - assert_ne!(pixels(d, low), pixels(d, source), "{depth} {mode}"); - } - } - - /// Breaking the recipe breaks the identity: a High layer left in Normal mode covers the image - /// with grey (guards the test above against passing for the wrong reason). - #[test] - fn high_must_be_on_linear_light() { - let mut s = session(16, "rgb"); - let source = active(&s); - let before = composite(doc(&s)); - s.execute(ID, json!({})).unwrap(); - let (_, [_, high]) = group_of(doc(&s), source); - let mut d = doc(&s).clone(); - d.layer_mut(high).unwrap().blend = BlendMode::Normal; - assert!(max_diff(&before, &composite(&d)) > 0.1); - } - - #[test] - fn builds_a_named_group_above_the_source_and_targets_high() { - let mut s = session(8, "rgb"); - let source = active(&s); - let source_px = pixels(doc(&s), source); - let r = s.execute(ID, json!({})).unwrap(); - let d = doc(&s); - let (group, [low, high]) = group_of(d, source); - let g = d.layer(group).unwrap(); - assert_eq!(g.name, "Frequency Separation"); - assert_eq!(g.blend, BlendMode::PassThrough); - let (l, h) = (d.layer(low).unwrap(), d.layer(high).unwrap()); - assert_eq!((l.name.as_str(), l.blend), ("Low Frequency", BlendMode::Normal)); - assert_eq!((h.name.as_str(), h.blend), ("High Frequency", BlendMode::LinearLight)); - assert!(matches!(l.content, LayerContent::Raster(_)) && matches!(h.content, LayerContent::Raster(_))); - assert!(l.visible && h.visible && g.visible); - // High draws where the source does, not grey over the transparent rest of its tiles. - let bounds = |id: LayerId| d.layer(id).unwrap().surface().unwrap().content_bounds(); - assert_eq!(bounds(source), Rect::new(0, 0, W, H)); - assert_eq!(bounds(high), bounds(source)); - // The source is left exactly as it was, visible, below the group. - let src = d.layer(source).unwrap(); - assert_eq!((src.name.as_str(), src.visible), ("Skin", true)); - assert_eq!(pixels(d, source), source_px); - assert_eq!(active(&s), high, "retouching starts on the High layer"); - assert_eq!(r, json!({"group": group.0, "low": low.0, "high": high.0, "radius": 4.0})); - } - - /// Low is exactly what Filter › Blur › Gaussian Blur makes of the source at that radius. - #[test] - fn low_is_the_gaussian_blur_of_the_source_at_the_radius() { - for radius in [2.0, 6.5] { - let mut s = session(16, "rgb"); - let source = active(&s); - s.execute(ID, json!({"radius": radius})).unwrap(); - let (_, [low, _]) = group_of(doc(&s), source); - let mut t = session(16, "rgb"); - t.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap(); - assert_eq!(pixels(doc(&s), low), pixels(doc(&t), active(&t)), "radius {radius}"); - } - } - - #[test] - fn one_undo_removes_everything_and_redo_brings_it_back() { - let mut s = session(8, "rgb"); - let source = active(&s); - let before = doc(&s).clone(); - let steps = s.active().unwrap().history.past_len(); - s.execute(ID, json!({})).unwrap(); - assert_eq!(s.active().unwrap().history.past_len(), steps + 1); - assert_eq!(s.active().unwrap().history.undo_label(), Some("Frequency Separation")); - let after = doc(&s).clone(); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(*doc(&s), before); - assert_eq!(active(&s), source); - s.execute("edit.redo", json!({})).unwrap(); - assert_eq!(*doc(&s), after); - } - - /// A Background source stays the Background; the copies are ordinary, unlocked layers. - #[test] - fn works_on_the_background() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": W, "height": H})).unwrap(); - paint(&mut s); - let source = active(&s); - assert!(doc(&s).is_background(source)); - let before = composite(doc(&s)); - s.execute(ID, json!({"radius": 2})).unwrap(); - let d = doc(&s); - assert!(d.is_background(source)); - let (_, [low, high]) = group_of(d, source); - for id in [low, high] { - assert_eq!(d.layer(id).unwrap().locks, Default::default()); - } - assert!(max_diff(&before, &composite(d)) <= 1.0 / 255.0 + 1e-6); - } - - fn refused(s: &mut Session, why: &str) { - assert!(!s.is_enabled(ID), "{why}: enabled"); - let before = s.active().map(|d| d.doc.clone()); - let e = s.execute(ID, json!({})).expect_err(why); - assert!(!e.to_string().is_empty()); - assert_eq!(s.active().map(|d| d.doc.clone()), before, "{why}: the document changed"); - } - - #[test] - fn needs_a_pixel_layer_with_pixels() { - refused(&mut Session::new(), "no document"); - - let mut s = session(8, "rgb"); - s.execute("layer.new.layer", json!({})).unwrap(); - refused(&mut s, "an empty layer"); - let e = s.execute(ID, json!({})).unwrap_err().to_string(); - assert!(e.contains("no pixels"), "{e}"); - - let mut s = session(8, "rgb"); - s.execute("type.create", json!({"x": 4, "y": 20, "text": "Hi", "size": 12})).unwrap(); - refused(&mut s, "a type layer"); - let e = s.execute(ID, json!({})).unwrap_err().to_string(); - assert!(e.contains("pixel layer"), "{e}"); - - let mut s = session(8, "rgb"); - s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); - refused(&mut s, "an adjustment layer"); - - let mut s = session(8, "rgb"); - s.execute("layer.new.group", json!({})).unwrap(); - refused(&mut s, "a group"); - - let mut s = session(8, "rgb"); - s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); - refused(&mut s, "a smart object"); - } - - #[test] - fn refuses_a_channel_or_mask_target() { - let mut s = session(8, "rgb"); - s.execute("select.all", json!({})).unwrap(); - s.execute("select.saveSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); - s.execute("channel.target", json!({"channel": "Alpha 1"})).unwrap(); - assert!(crate::channel_cmds::edits_channel(&s)); - refused(&mut s, "an alpha channel"); - - let mut s = session(8, "rgb"); - s.execute("layer.layerMask.revealAll", json!({})).unwrap(); - s.execute("channel.target", json!({"channel": "mask"})).unwrap(); - assert_eq!(s.active().unwrap().channel_view.target, crate::channel_cmds::ChannelTarget::LayerMask); - refused(&mut s, "a targeted layer mask"); - // The same layer, its pixels targeted again: allowed (the refusal was the target's). - s.execute("channel.target", json!({"channel": "composite"})).unwrap(); - assert!(s.is_enabled(ID)); - s.execute(ID, json!({})).unwrap(); - - // Showing the mask (⌥-click its thumbnail) targets it too. - let mut s = session(8, "rgb"); - s.execute("layer.layerMask.revealAll", json!({})).unwrap(); - s.execute("channel.target", json!({"channel": "composite"})).unwrap(); - assert!(s.is_enabled(ID)); - s.execute("view.layerMask", json!({"mode": "gray"})).unwrap(); - refused(&mut s, "a mask shown in mask view"); - } - - /// Linear Light over Low gives the source back in RGB and Grayscale only: CMYK and Lab blend - /// in other values (measured: off by 0.7 and 0.14), so they are refused rather than split. - #[test] - fn refuses_cmyk_and_lab() { - for mode in ["cmyk", "lab"] { - let mut s = session(8, mode); - refused(&mut s, mode); - let e = s.execute(ID, json!({})).unwrap_err().to_string(); - assert!(e.contains("RGB or Grayscale"), "{mode}: {e}"); - } - } - - /// A layer whose tiles hold only transparency (not yet pruned) passes the menu's quick check - /// but has nothing to split: the command says so and changes nothing. - #[test] - fn a_layer_of_transparent_tiles_is_refused_when_run() { - let mut s = session(8, "rgb"); - s.execute("layer.new.layer", json!({})).unwrap(); - s.edit("clear", |doc, active| { - let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); - surf.write_region(Rect::new(0, 0, 8, 8), &vec![0.0; 8 * 8 * surf.channels()]); - Ok(()) - }) - .unwrap(); - assert!(s.is_enabled(ID)); - let before = doc(&s).clone(); - let e = s.execute(ID, json!({})).unwrap_err().to_string(); - assert!(e.contains("no pixels"), "{e}"); - assert_eq!(*doc(&s), before); - } - - #[test] - fn bad_params_are_rejected_without_changes() { - let mut s = session(8, "rgb"); - let before = doc(&s).clone(); - for p in [ - json!(7), - json!("4"), - json!({"radius": 0}), - json!({"radius": 0.05}), - json!({"radius": -3}), - json!({"radius": 1000.5}), - json!({"radius": 1e308}), - json!({"radius": "4"}), - json!({"radius": null}), - json!({"radius": [4]}), - ] { - assert!(matches!(s.execute(ID, p.clone()), Err(EngineError::BadParams { .. })), "{p}"); - } - assert_eq!(*doc(&s), before); - } -} diff --git a/crates/engine/src/cmd/gradient_fill_cmds/tests.rs b/crates/engine/src/cmd/gradient_fill_cmds/tests.rs index eca8a7bc12..3c0c2f3c6a 100644 --- a/crates/engine/src/cmd/gradient_fill_cmds/tests.rs +++ b/crates/engine/src/cmd/gradient_fill_cmds/tests.rs @@ -386,22 +386,3 @@ fn stop_rejects_huge_indices_without_panicking() { assert_eq!(midpoints, vec![0.25]); assert_eq!(s.active().unwrap().history.past_len(), h + 1); } - -#[test] -fn new_gradient_fill_layer_defaults_to_foreground_to_transparent() { - // #8708: Photoshop's Layer › New Fill Layer › Gradient… starts from Foreground to - // Transparent: the foreground at both ends, its opacity 100% to 0%. A `to` colour keeps a - // two-colour gradient. - let mut s = session(8, "transparent"); - s.tools.foreground = [0.2, 0.4, 0.6, 1.0]; - s.tools.background = [1.0, 1.0, 1.0, 1.0]; - s.execute("layer.newFillLayer.gradient", json!({})).unwrap(); - let Fill::Gradient { stops, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") }; - let fg = Color::rgba(0.2, 0.4, 0.6, 1.0); - assert_eq!(stops, vec![(0.0, fg), (1.0, fg)]); - assert_eq!(opacity_stops, vec![(0.0, 1.0), (1.0, 0.0)]); - s.execute("layer.newFillLayer.gradient", json!({"to": "#ff0000"})).unwrap(); - let Fill::Gradient { stops, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") }; - assert_eq!(stops[1].1, Color::rgba(1.0, 0.0, 0.0, 1.0)); - assert!(opacity_stops.is_empty()); -} diff --git a/crates/engine/src/cmd/preset_import_cmds.rs b/crates/engine/src/cmd/preset_import_cmds.rs index da1feb539f..417f5623a1 100644 --- a/crates/engine/src/cmd/preset_import_cmds.rs +++ b/crates/engine/src/cmd/preset_import_cmds.rs @@ -34,39 +34,26 @@ pub const MAX_ABR_FILE_BYTES: u64 = photocraft_psd::abr::MAX_TOTAL_BYTES as u64; /// File bytes from `data` (base64) or `path`, plus a default group name. pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, String)> { - file_bytes_limited(p, cmd, what, MAX_FILE_BYTES) + file_bytes_within(p, cmd, what, MAX_FILE_BYTES) } -/// Load preset bytes with a format-specific limit; `data` takes precedence over `path`. -/// Inline base64 returns an empty group name; a file returns its filename stem. -/// The encoded-length check bounds base64 allocation, then decoded length is checked exactly. -/// File reads are capped at `max_bytes + 1` so growth after the metadata check is still rejected. -/// This helper does not authorize paths; automation callers must enforce their workspace policy. -pub(crate) fn file_bytes_limited(p: &Value, cmd: &str, what: &str, max_bytes: u64) -> Result<(Vec, String)> { +/// [`file_bytes`] with a size cap of `max` bytes. +fn file_bytes_within(p: &Value, cmd: &str, what: &str, max: u64) -> Result<(Vec, String)> { if let Some(d) = p.get("data") { let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?; - if s.len() as u64 > max_bytes / 3 * 4 + 4 { + if s.len() as u64 > max / 3 * 4 + 4 { return Err(bad(cmd, "`data` is too large")); } let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?; - if bytes.len() as u64 > max_bytes { - return Err(bad(cmd, "`data` is too large")); - } return Ok((bytes, String::new())); } let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?; let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len(); - if len > max_bytes { + if len > max { return Err(bad(cmd, format!("{path}: file too large ({len} bytes)"))); } - use std::io::Read; - let file = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let mut bytes = Vec::new(); - file.take(max_bytes.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if bytes.len() as u64 > max_bytes { - return Err(bad(cmd, format!("{path}: file too large"))); - } + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); Ok((bytes, stem)) } @@ -114,7 +101,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result { move |ctx| { ctx.progress(0.0, "Importing brushes"); let p = ¶ms; - let (bytes, stem) = file_bytes_limited(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?; + let (bytes, stem) = file_bytes_within(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?; let group = match p.get("group") { Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(), None if !stem.is_empty() => stem, @@ -385,14 +372,6 @@ fn import_grd(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { - #[test] - fn decoded_preset_data_cannot_exceed_its_format_budget() { - // Both payloads have four encoded characters: checking base64 length alone is insufficient. - let at_limit = serde_json::json!({"data": "AQI="}); - let over_limit = serde_json::json!({"data": "AQID"}); - assert_eq!(super::file_bytes_limited(&at_limit, "test", "preset", 2).unwrap().0, [1, 2]); - assert!(super::file_bytes_limited(&over_limit, "test", "preset", 2).is_err()); - } use super::*; use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v6, write_v12}; use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as DV}; @@ -638,16 +617,16 @@ mod tests { let path = dir.join("cap.abr"); std::fs::write(&path, [0u8; 10]).unwrap(); let p = json!({"path": path.to_string_lossy()}); - assert!(file_bytes_limited(&p, "t", "f", 10).is_ok()); - assert!(file_bytes_limited(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading"); + assert!(file_bytes_within(&p, "t", "f", 10).is_ok()); + assert!(file_bytes_within(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading"); // A sparse file between the two caps: refused as a .grd, which is checked before reading. let big = dir.join("big.grd"); std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap(); let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err(); assert!(err.to_string().contains("too large"), "{err}"); let data = json!({"data": "A".repeat(16)}); - assert!(file_bytes_limited(&data, "t", "f", 12).is_ok()); - assert!(file_bytes_limited(&data, "t", "f", 8).is_err()); + assert!(file_bytes_within(&data, "t", "f", 12).is_ok()); + assert!(file_bytes_within(&data, "t", "f", 8).is_err()); std::fs::remove_dir_all(&dir).ok(); } diff --git a/crates/engine/src/cmd/retouch_cmds/dab.rs b/crates/engine/src/cmd/retouch_cmds/dab.rs index 6e88e543fd..7ff5a600e6 100644 --- a/crates/engine/src/cmd/retouch_cmds/dab.rs +++ b/crates/engine/src/cmd/retouch_cmds/dab.rs @@ -190,9 +190,12 @@ impl DabTool { } "paint.blur" | "paint.sharpen" => { let sharpen = cmd == "paint.sharpen"; - let sigma = if sharpen { (stroke.brush.size * 0.03).clamp(1.0, 4.0) } else { (stroke.brush.size * 0.15).clamp(1.0, 64.0) }; - let tool = - Self::Focus { sharpen, strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0, protect: flag(p, "protectDetail", true), sigma }; + let tool = Self::Focus { + sharpen, + strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0, + protect: flag(p, "protectDetail", true), + sigma: (stroke.brush.size * 0.03).clamp(1.0, 4.0), + }; (tool, if sharpen { "Sharpen Tool" } else { "Blur Tool" }) } "paint.smudge" => { diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index fed3c36c28..a1329064d2 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -483,28 +483,6 @@ fn blur_reduces_local_variance_and_sharpen_increases_it() { } } -#[test] -fn blur_scales_with_brush_size_and_diffuses_pixels_visibly() { - for depth in [8u64, 16] { - let run = |size: u32| { - let mut s = session(120, 40, depth, "rgb"); - paint_layer(&mut s, |x, _| if x < 60 { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }); - s.execute("paint.blur", json!({"points": [[60, 20]], "size": size, "hardness": 100, "strength": 100})).unwrap(); - (0..120).map(|x| rgba(&s, x, 20)[0]).collect::>() - }; - let p20 = run(20); - let p60 = run(60); - // Size 20 (sigma = 3.0, radius 10, footprint [50, 70]) leaves x=45 and x=75 untouched. - assert_eq!(p20[45], 0.0, "size 20 footprint does not reach x=45"); - assert_eq!(p20[75], 1.0, "size 20 footprint does not reach x=75"); - // Size 60 (sigma = 9.0, radius 30, footprint [30, 90]) diffuses light and dark across a wider band. - assert!(p60[45] > 0.0, "size 60 diffuses light past x=45: {}", p60[45]); - assert!(p60[75] < 1.0, "size 60 diffuses dark past x=75: {}", p60[75]); - // Near the step edge (x=55), size 60 diffuses more than size 20 due to larger sigma. - assert!(p60[55] > p20[55], "larger sigma diffuses deeper near edge: p60={} p20={}", p60[55], p20[55]); - } -} - #[test] fn smudge_moves_colour_along_the_stroke() { for depth in DEPTHS { diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index cba5b5025a..58a2145409 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -479,10 +479,6 @@ pub fn apply_para_props(s: &mut ParagraphStyle, p: &Value) -> bool { s.hyphenate = v; any = true; } - if let Some(v) = p.get("hanging").and_then(Value::as_bool) { - s.hanging = v; - any = true; - } any } @@ -1073,7 +1069,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: Box::leak( - format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading/leadingBy include each layer's transform scale), {CHAR_PARAMS}, relative steps from each character's own value: "leadingBy":pt (auto leading starts from its laid-out value; result 0.01..=5000),"baselineShiftBy":pt (result -5000..=5000),"trackingBy":1/1000 em (result -1000..=10000), paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool,"hanging":bool}}"##) + format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading/leadingBy include each layer's transform scale), {CHAR_PARAMS}, relative steps from each character's own value: "leadingBy":pt (auto leading starts from its laid-out value; result 0.01..=5000),"baselineShiftBy":pt (result -5000..=5000),"trackingBy":1/1000 em (result -1000..=10000), paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) .into_boxed_str(), ), enabled: has_doc, diff --git a/crates/engine/src/cmd/type_cmds/tests.rs b/crates/engine/src/cmd/type_cmds/tests.rs index 6a06314331..b9851d27e1 100644 --- a/crates/engine/src/cmd/type_cmds/tests.rs +++ b/crates/engine/src/cmd/type_cmds/tests.rs @@ -404,22 +404,6 @@ fn rasterize_and_fonts() { assert!(!faces["faces"].as_array().unwrap().is_empty()); } -#[test] -fn set_style_paragraph_hanging_round_trips() { - let mut s = session(); - let id = s.execute("type.create", json!({"x": 10, "y": 10, "text": "Hello, world."})).unwrap()["layer"].as_u64().unwrap(); - let info = s.execute("type.setStyle", json!({"layer": id, "hanging": true})).unwrap(); - assert_eq!(info["paragraphs"][0]["style"]["hanging"], true, "the report exposes the flag"); - let t = text_layer(&s, id); - assert!(t.paragraphs[0].style.hanging); - // Off again. - let info = s.execute("type.setStyle", json!({"layer": id, "hanging": false})).unwrap(); - assert_eq!(info["paragraphs"][0]["style"]["hanging"], false); - // A junk value is ignored (like a junk `align`), not coerced or a crash. - let _ = s.execute("type.setStyle", json!({"layer": id, "hanging": "yes"})).unwrap(); - assert!(!text_layer(&s, id).paragraphs[0].style.hanging); -} - /// #124: a type edit damages only the layer's old and new pixels (the canvas recomposites /// that, not the document), and a drag's coalesced steps are one history step. #[test] diff --git a/crates/engine/src/cmd/type_styles_cmds.rs b/crates/engine/src/cmd/type_styles_cmds.rs index a6edc27fa6..7fc58a5ec4 100644 --- a/crates/engine/src/cmd/type_styles_cmds.rs +++ b/crates/engine/src/cmd/type_styles_cmds.rs @@ -128,7 +128,6 @@ fn perturbed_para() -> ParagraphStyle { auto_leading: -1.0, direction: TextDirection::Rtl, hyphenate: true, - hanging: true, style_sheet: None, } } diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index 695e80ace0..efbff863fd 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -70,7 +70,7 @@ mod shape; mod vector_mask; pub(crate) use params::{layer_id, nums, op_from, pt}; -pub use params::{parse_path, parse_stroke, path_json, shape_fill, shape_paint_params}; +pub use params::{parse_path, parse_stroke, path_json}; pub use paths::STROKE_PATH_TOOLS; use paths::{ clipping_path_clear, clipping_path_set, path_delete, path_fill, path_rename, path_set, path_stroke, path_to_selection, path_transform, paths_list, @@ -91,7 +91,7 @@ pub(crate) fn leak(s: String) -> &'static str { pub fn specs() -> Vec { let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##; - let fill = r##""fill":"#rrggbb[aa]"|[r,g,b,a]|{"color":{mode,c:[4 channels],alpha}}|{"gradient":{"stops":[[0..1,colour]],"angle":deg,"scale":%>0,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[0..1,0..1]],"midpoints":[0..1],"offset":[fractionX,fractionY],"dither":bool,"align":bool}}|{"pattern":name|id,"id":str?,"scale":%>0,"angle":deg,"link":bool,"phase":[px,px]}|null (no fill)"##; + let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]|stored Color]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[t,0..1]]?,"midpoints":[0..1]?,"offset":[x,y]?,"dither":bool?,"align":bool?}}|{"pattern":name}|null (no fill)"##; vec![ spec!( "shape.create", diff --git a/crates/engine/src/cmd/vector_cmds/params.rs b/crates/engine/src/cmd/vector_cmds/params.rs index da88abf536..00f28c5937 100644 --- a/crates/engine/src/cmd/vector_cmds/params.rs +++ b/crates/engine/src/cmd/vector_cmds/params.rs @@ -10,18 +10,6 @@ pub(crate) fn layer_id(s: &Session, p: &Value) -> Result { } } -pub(super) fn check_clipboard_pattern_collision(s: &Session, patterns: &[photocraft_doc::Pattern]) -> Result<()> { - let st = s.active().ok_or(EngineError::NoDocument)?; - for pattern in patterns { - if let Some(existing) = st.doc.patterns.iter().find(|p| p.id == pattern.id) - && (existing.width != pattern.width || existing.height != pattern.height || existing.surface != pattern.surface) - { - return Err(EngineError::Other("A different pattern with the copied pattern's id already exists in this document".into())); - } - } - Ok(()) -} - pub(super) fn f64p(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_f64) } @@ -157,44 +145,12 @@ pub fn path_json(p: &Path) -> Value { pub(super) fn fill_json(f: &Fill) -> Value { match f { - Fill::Solid(c) => paint_color_json(c), + Fill::Solid(c) => json!(hex(c)), Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": { - "stops": stops.iter().map(|(t, c)| json!([t, paint_color_json(c)])).collect::>(), - "angle": angle, "scale": f64::from(*scale) * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse, - "opacityStops": opacity_stops, "midpoints": midpoints, "offset": offset, "dither": dither, "align": align } }), - Fill::Pattern { name, scale, id, angle, link, phase } => json!({ "pattern": name, "scale": f64::from(*scale) * 100.0, - "id":id, "angle":angle, "link":link, "phase":phase }), - } -} - -fn fill_pair(v: &Value, key: &str) -> std::result::Result<(f32, f32), String> { - let Some(value) = v.get(key) else { return Ok((0.0, 0.0)) }; - let Some(values) = value.as_array().filter(|a| a.len() == 2) else { return Err(format!("{key} must contain two numbers")) }; - let mut values = values.iter(); - let a = values.next().ok_or_else(|| format!("{key} needs an x value"))?; - let b = values.next().ok_or_else(|| format!("{key} needs a y value"))?; - Ok((fill_number(a, key, -1_000_000.0, 1_000_000.0)?, fill_number(b, key, -1_000_000.0, 1_000_000.0)?)) -} - -fn fill_bool(v: &Value, key: &str, default: bool) -> std::result::Result { - v.get(key).map_or(Ok(default), |v| v.as_bool().ok_or_else(|| format!("{key} must be a boolean"))) -} - -fn fill_number(v: &Value, label: &str, lo: f64, hi: f64) -> std::result::Result { - v.as_f64() - .filter(|n| n.is_finite() && (lo..=hi).contains(n)) - .map(|n| n as f32) - .ok_or_else(|| format!("{label} must be a finite number between {lo} and {hi}")) -} - -fn paint_color_json(c: &Color) -> Value { - if c.mode != photocraft_color::ColorMode::Rgb || c.c[3] != 0.0 { - return json!({"color":c}); - } - if c.alpha == 1.0 && c.c.iter().take(3).all(|v| (0.0..=1.0).contains(v) && (v * 255.0).round() / 255.0 == *v) { - json!(hex(c)) - } else { - json!([c.c[0], c.c[1], c.c[2], c.alpha]) + "stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::>(), + "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse, + "opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align } }), + Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }), } } @@ -202,168 +158,85 @@ fn paint_color_json(c: &Color) -> Value { /// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them. const MAX_GRADIENT_STOPS: usize = 1024; -/// Legacy colour/gradient/pattern forms plus complete geometry and opacity metadata. -/// Gradient locations, opacity stops and midpoints are fractions; scale is percent. +/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse", +/// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`. +/// `null` → `Ok(None)` (no fill). pub(super) fn parse_fill(v: &Value) -> std::result::Result, String> { - if let Some(g) = v.get("gradient") { - for key in ["stops", "opacityStops", "midpoints"] { - if g.get(key).and_then(Value::as_array).is_some_and(|a| a.len() > MAX_GRADIENT_STOPS) { - return Err(format!("too many gradient {key} (max {MAX_GRADIENT_STOPS})")); - } - } - } - if v.is_null() { return Ok(None); } - if v.is_array() || v.is_string() || v.get("color").is_some() || (v.get("mode").is_some() && v.get("c").is_some()) { - return Ok(Some(Fill::Solid(paint_color(v)?))); + if let Some(c) = color(v) { + return Ok(Some(Fill::Solid(c))); } if let Some(g) = v.get("gradient") { - if !g.is_object() { - return Err("gradient must be an object".into()); - } - let stops = match g.get("stops") { - None => vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], - Some(Value::Array(stops)) if stops.len() <= 4096 => stops - .iter() - .map(|stop| { - let values = stop.as_array().filter(|a| a.len() == 2).ok_or("gradient stop must contain a location and colour")?; - let t = values.first().ok_or("missing stop location")?; - let c = values.get(1).ok_or("missing stop colour")?; - let location = fill_number(t, "stop location", 0.0, 1.0)?; - Ok(paint_color(c).ok().map(|color| (location, color))) - }) - .collect::, String>>()? - .into_iter() - .flatten() - .collect(), - _ => return Err("gradient stops must contain at most 4096 location/colour pairs".into()), + // Stop lists are capped as `gradient.fill.stop` caps them: more is an error, not a cut. + let list = |key: &str, what: &str| match g.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(a)) if a.len() > MAX_GRADIENT_STOPS => Err(format!("too many {what} (max {MAX_GRADIENT_STOPS})")), + Some(Value::Array(a)) => Ok(Some(a)), + Some(_) => Err(format!("gradient `{key}` must be an array")), }; - let style = match g.get("style").map_or(Some("linear"), Value::as_str).ok_or("gradient style must be a string")? { - "linear" => GradientStyle::Linear, + let stops: Vec<(f32, Color)> = list("stops", "colour stops")? + .map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect()) + .unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); + let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") { "radial" => GradientStyle::Radial, "angle" => GradientStyle::Angle, "reflected" => GradientStyle::Reflected, "diamond" => GradientStyle::Diamond, - _ => return Err("unknown gradient style".into()), + _ => GradientStyle::Linear, }; - let mut opacity_stops = match g.get("opacityStops") { - None => Vec::new(), - Some(Value::Array(stops)) if stops.len() <= 4096 => stops - .iter() - .map(|stop| { - let values = stop.as_array().filter(|a| a.len() == 2).ok_or("opacity stop must contain a location and opacity")?; - Ok(( - fill_number(values.first().ok_or("missing opacity location")?, "opacity location", 0.0, 1.0)?, - fill_number(values.get(1).ok_or("missing opacity value")?, "opacity value", 0.0, 1.0)?, - )) - }) - .collect::, String>>()?, - _ => return Err("opacityStops must contain at most 4096 location/opacity pairs".into()), - }; - opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - let midpoints = match g.get("midpoints") { - None => Vec::new(), - Some(Value::Array(values)) if values.len() <= 4096 => { - values.iter().map(|v| fill_number(v, "midpoint", 0.0, 1.0)).collect::, _>>()? - } - _ => return Err("midpoints must contain at most 4096 fractions".into()), - }; - return Ok(Some(Fill::Gradient { + let mut fill = Fill::gradient( stops, - angle: g.get("angle").map_or(Ok(90.0), |v| fill_number(v, "angle", -1_000_000.0, 1_000_000.0))?, - scale: fill_scale(g)?, + f64p(g, "angle").unwrap_or(90.0) as f32, + f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0, style, - reverse: fill_bool(g, "reverse", false)?, - opacity_stops, - midpoints, - offset: fill_pair(g, "offset")?, - dither: fill_bool(g, "dither", false)?, - align: fill_bool(g, "align", true)?, - })); - } - if let Some(pattern) = v.get("pattern") { - let name = pattern.as_str().filter(|s| s.len() <= 65536).ok_or("pattern must be a string of at most 65536 bytes")?; - let id = v.get("id").map_or(Some(""), Value::as_str).filter(|s| s.len() <= 65536).ok_or("pattern id must be a string of at most 65536 bytes")?; - return Ok(Some(Fill::Pattern { - name: name.into(), - scale: fill_scale(v)?, - id: id.into(), - angle: v.get("angle").map_or(Ok(0.0), |v| fill_number(v, "angle", -1_000_000.0, 1_000_000.0))?, - link: fill_bool(v, "link", true)?, - phase: fill_pair(v, "phase")?, - })); - } - Err("unrecognised fill".into()) -} - -fn paint_color(v: &Value) -> std::result::Result { - let c = if let Some(raw) = v.get("color").or_else(|| (v.get("mode").is_some() && v.get("c").is_some()).then_some(v)) { - serde_json::from_value::(raw.clone()).map_err(|e| format!("invalid model colour: {e}"))? - } else { - if let Some(values) = v.as_array() { - if !matches!(values.len(), 3 | 4) { - return Err("colour must contain three or four numbers".into()); + g.get("reverse").and_then(Value::as_bool).unwrap_or(false), + ); + // The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing. + if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill { + let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32); + if let Some(a) = list("opacityStops", "opacity stops")? { + *opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect(); + opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0)); } - for value in values.iter().take(3) { - fill_number(value, "colour component", -1_000_000.0, 1_000_000.0)?; + if let Some(a) = list("midpoints", "midpoints")? { + *midpoints = a.iter().filter_map(unit).collect(); } - if let Some(alpha) = values.get(3) { - fill_number(alpha, "colour alpha", 0.0, 1.0)?; + if let Some(o) = g.get("offset") { + let at = |i: usize| o.get(i).and_then(Value::as_f64).map(|n| n.clamp(-1000.0, 1000.0) as f32); + *offset = (at(0).ok_or("gradient offset must be [x, y]")?, at(1).ok_or("gradient offset must be [x, y]")?); + } + if let Some(d) = g.get("dither").and_then(Value::as_bool) { + *dither = d; + } + if let Some(a) = g.get("align").and_then(Value::as_bool) { + *align = a; } } - color(v).ok_or("colour must be hex, RGB(A) numbers or a model colour object")? - }; - if !c.c.iter().all(|n| n.is_finite() && n.abs() <= 1_000_000.0) || !c.alpha.is_finite() || !(0.0..=1.0).contains(&c.alpha) { - return Err("colour components must be finite and within ±1000000; alpha must be 0..1".into()); + return Ok(Some(fill)); } - Ok(c) -} - -fn fill_scale(v: &Value) -> std::result::Result { - let Some(value) = v.get("scale") else { return Ok(1.0) }; - let n = value.as_f64().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1_000_000.0).ok_or("scale must be greater than 0 and at most 1000000 percent")?; - let scale = (n / 100.0) as f32; - if scale <= 0.0 { - return Err("scale is too small".into()); + if let Some(name) = v.get("pattern").and_then(Value::as_str) { + return Ok(Some(Fill::Pattern { + name: name.into(), + scale: f64p(v, "scale").unwrap_or(100.0) as f32 / 100.0, + id: String::new(), + angle: 0.0, + link: true, + phase: (0.0, 0.0), + })); } - Ok(scale) + Err(format!("unrecognised fill {v}")) } pub(super) fn stroke_json(s: &ShapeStroke) -> Value { json!({ - "width": s.width, "fill": fill_json(&s.paint), "opacity": f64::from(s.opacity) * 100.0, + "width": s.width, "fill": fill_json(&s.paint), "opacity": s.opacity * 100.0, "align": format!("{:?}", s.align).to_ascii_lowercase(), "cap": format!("{:?}", s.cap).to_ascii_lowercase(), "join": format!("{:?}", s.join).to_ascii_lowercase(), "miterLimit": s.miter_limit, "dashes": s.dashes, "dashOffset": s.dash_offset, }) } -pub(super) fn pattern_for_fill(s: &Session, fill: &mut Fill) -> Result> { - let Fill::Pattern { name, id, .. } = fill else { return Ok(None) }; - let by_id = (!id.is_empty()).then(|| crate::pattern_cmds::resolve(s, id)).flatten(); - let by_name = (!name.is_empty()).then(|| crate::pattern_cmds::resolve(s, name)).flatten(); - if let (Some(a), Some(b)) = (&by_id, &by_name) - && a.id != b.id - { - return Err(EngineError::Other("pattern id and name refer to different tiles".into())); - } - let pattern = by_id.or(by_name).ok_or_else(|| EngineError::Other(format!("no pattern \"{name}\" (see pattern.list)")))?; - if pattern.is_empty() { - return Err(EngineError::Other("the pattern is empty".into())); - } - if id.is_empty() { - *name = pattern.name.clone(); - } - *id = pattern.id.clone(); - Ok(Some(pattern)) -} - -/// Shape-tool creation uses the paint displayed for the active shape, including gradients. -pub fn shape_paint_params(fill: Option<&Fill>, stroke: Option<&ShapeStroke>) -> Value { - json!({"fill":fill.map_or(Value::Null,fill_json),"stroke":stroke.map_or(Value::Null,stroke_json)}) -} - /// Applies stroke keys onto `base` (`null` → no stroke). pub fn parse_stroke(v: &Value, base: Option, fg: Color) -> std::result::Result, String> { if v.is_null() || v.as_bool() == Some(false) { @@ -443,15 +316,6 @@ pub fn parse_stroke(v: &Value, base: Option, fg: Color) -> std::res Ok(Some(s)) } -/// Validate a shape paint without changing the document, resolving catalog pattern references. -pub fn shape_fill(s: &Session, value: &Value) -> Result> { - let mut fill = parse_fill(value).map_err(EngineError::Other)?; - if let Some(fill) = &mut fill { - pattern_for_fill(s, fill)?; - } - Ok(fill) -} - pub(super) fn live_json(l: &LiveShape) -> Value { serde_json::to_value(l).unwrap_or(Value::Null) } diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index d14e3bb35d..961e767924 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -1,6 +1,5 @@ use super::params::{ - check_clipboard_pattern_collision, f64p, fill_json, layer_id, live_from_params, live_json, nums, op_from, parse_fill, parse_path, parse_stroke, path_json, - pattern_for_fill, radii, shape_fill, stroke_json, + f64p, fill_json, layer_id, live_from_params, live_json, nums, op_from, parse_fill, parse_path, parse_stroke, path_json, radii, stroke_json, }; use super::vector_mask::with; use super::{ @@ -37,31 +36,21 @@ pub(super) fn can_paste_shape_stroke(s: &Session) -> std::result::Result<(), Str } pub(super) fn copy_shape_fill(s: &mut Session, _p: &Value) -> Result { - let mut fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?; - let pattern = clipboard_pattern(s, &fill, None)?; - set_clipboard_pattern_id(&mut fill, pattern.as_ref()); + let fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?; s.path_fill_clipboard = Some(fill); - s.path_fill_pattern = pattern; Ok(Value::Null) } pub(super) fn copy_shape_stroke(s: &mut Session, _p: &Value) -> Result { - let mut stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?; - let pattern = clipboard_pattern(s, &stroke.paint, None)?; - set_clipboard_pattern_id(&mut stroke.paint, pattern.as_ref()); + let stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?; s.path_stroke_clipboard = Some(stroke); - s.path_stroke_pattern = pattern; Ok(Value::Null) } pub(super) fn paste_shape_fill(s: &mut Session, p: &Value) -> Result { - let mut fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?; - let pattern = clipboard_pattern(s, &fill, s.path_fill_pattern.as_ref())?; - set_clipboard_pattern_id(&mut fill, pattern.as_ref()); - let patterns: Vec<_> = pattern.into_iter().collect(); - check_clipboard_pattern_collision(s, &patterns)?; + let fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?; let id = layer_id(s, p)?; - with_shape_patterns(s, id, "Paste Fill", &patterns, |shape, _| { + with_shape(s, id, "Paste Fill", |shape, _| { shape.fill = Some(fill); Ok(()) })?; @@ -69,39 +58,15 @@ pub(super) fn paste_shape_fill(s: &mut Session, p: &Value) -> Result { } pub(super) fn paste_shape_stroke(s: &mut Session, p: &Value) -> Result { - let mut stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?; - let pattern = clipboard_pattern(s, &stroke.paint, s.path_stroke_pattern.as_ref())?; - set_clipboard_pattern_id(&mut stroke.paint, pattern.as_ref()); - let patterns: Vec<_> = pattern.into_iter().collect(); - check_clipboard_pattern_collision(s, &patterns)?; + let stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?; let id = layer_id(s, p)?; - with_shape_patterns(s, id, "Paste Complete Stroke", &patterns, |shape, _| { + with_shape(s, id, "Paste Complete Stroke", |shape, _| { shape.stroke = Some(stroke); Ok(()) })?; shape_info(s, id) } -fn clipboard_pattern(s: &Session, fill: &Fill, snapshot: Option<&photocraft_doc::Pattern>) -> Result> { - let Fill::Pattern { id, name, .. } = fill else { return Ok(None) }; - let find = |patterns| photocraft_doc::pattern::find(patterns, id, name).cloned(); - let pattern = snapshot - .and_then(|p| find(std::slice::from_ref(p))) - .or_else(|| s.active().and_then(|st| find(&st.doc.patterns))) - .or_else(|| crate::pattern_cmds::resolve(s, id).or_else(|| crate::pattern_cmds::resolve(s, name))) - .ok_or_else(|| EngineError::Other(format!("no copied pattern \"{name}\" (see pattern.list)")))?; - if pattern.is_empty() { - return Err(EngineError::Other("the copied pattern is empty".into())); - } - Ok(Some(pattern)) -} - -fn set_clipboard_pattern_id(fill: &mut Fill, pattern: Option<&photocraft_doc::Pattern>) { - if let (Fill::Pattern { id, .. }, Some(pattern)) = (fill, pattern) { - *id = pattern.id.clone(); - } -} - /// The path `shape.create` draws for live-shape params `p` (the Shape tools preview it while dragging). pub fn shape_path(p: &Value) -> Result { let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect"); @@ -121,7 +86,7 @@ fn live_kind(l: &LiveShape) -> &'static str { /// Re-renders a shape layer's cache for `doc` (canvas-clipped, document pixel format). pub fn refresh_shape(doc: &Document, sh: &mut ShapeLayer) { - sh.cache = Some(vector::render_shape_with_patterns(sh, doc.pixel_format(), doc.bounds(), &doc.patterns)); + sh.cache = Some(vector::render_shape(sh, doc.pixel_format(), doc.bounds())); } /// A staged stroke edit rendered with the same validation and cache refresh as `shape.edit`. @@ -298,16 +263,6 @@ pub(super) fn shape_info(s: &Session, id: LayerId) -> Result { } pub(crate) fn with_shape(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result) -> Result { - with_shape_patterns(s, id, label, &[], f) -} - -fn with_shape_patterns( - s: &mut Session, - id: LayerId, - label: &str, - patterns: &[photocraft_doc::Pattern], - f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result, -) -> Result { if let Some(st) = s.active() { let locks = st.doc.effective_locks(id); if locks.all || locks.pixels { @@ -315,9 +270,6 @@ fn with_shape_patterns( } } s.edit(label, |doc, _| { - for pattern in patterns { - crate::pattern_cmds::ensure_in_doc(doc, pattern); - } let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if !matches!(l.content, LayerContent::Shape(_)) { @@ -359,25 +311,14 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result { }; let fg = s.tools.foreground; let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); - let mut fill = match p.get("fill") { + let fill = match p.get("fill") { Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?, None => Some(Fill::Solid(fgc)), }; - let mut stroke = match p.get("stroke") { + let stroke = match p.get("stroke") { Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?, None => None, }; - let mut patterns = Vec::new(); - if let Some(fill) = &mut fill - && let Some(pattern) = pattern_for_fill(s, fill)? - { - patterns.push(pattern); - } - if let Some(stroke) = &mut stroke - && let Some(pattern) = pattern_for_fill(s, &mut stroke.paint)? - { - patterns.push(pattern); - } vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; // Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode). if let Some(target) = p.get("addTo").and_then(Value::as_u64) { @@ -398,9 +339,6 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); let mut sh = ShapeLayer { path, fill, stroke, live, ..Default::default() }; let id = s.edit("New Shape Layer", |doc, active| { - for pattern in &patterns { - crate::pattern_cmds::ensure_in_doc(doc, pattern); - } refresh_shape(doc, &mut sh); let name = name.unwrap_or_else(|| doc.next_layer_name(default_name(kind))); let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Shape(sh)), crate::layer_place::Place::AutoEditable); @@ -416,21 +354,7 @@ pub(super) fn shape_edit(s: &mut Session, p: &Value) -> Result { let fg = s.tools.foreground; let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let fill = p.get("fill").map(|value| shape_fill(s, value)).transpose()?; - let base = s.active().and_then(|st| st.doc.layer(id)).and_then(|l| if let LayerContent::Shape(sh) = &l.content { sh.stroke.clone() } else { None }); - let mut stroke = p.get("stroke").map(|value| parse_stroke(value, base, fgc).map_err(|e| bad(CMD, e))).transpose()?; - let mut patterns = Vec::new(); - if let Some(Some(mut fill)) = fill.clone() - && let Some(pattern) = pattern_for_fill(s, &mut fill)? - { - patterns.push(pattern); - } - if let Some(Some(stroke)) = &mut stroke - && let Some(pattern) = pattern_for_fill(s, &mut stroke.paint)? - { - patterns.push(pattern); - } - with_shape_patterns(s, id, "Edit Shape", &patterns, |sh, layer| { + with_shape(s, id, "Edit Shape", |sh, layer| { if let Some(v) = p.get("path") { sh.path = parse_path(v).map_err(|e| bad(CMD, e))?; sh.live = None; @@ -470,11 +394,11 @@ pub(super) fn shape_edit(s: &mut Session, p: &Value) -> Result { if let Some(r) = p.get("fillRule").and_then(Value::as_str) { sh.path.fill_rule = if r.eq_ignore_ascii_case("evenodd") { FillRule::EvenOdd } else { FillRule::NonZero }; } - if let Some(fill) = fill { - sh.fill = fill; + if let Some(v) = p.get("fill") { + sh.fill = parse_fill(v).map_err(|e| bad(CMD, e))?; } - if let Some(stroke) = stroke { - sh.stroke = stroke; + if let Some(v) = p.get("stroke") { + sh.stroke = parse_stroke(v, sh.stroke.clone(), fgc).map_err(|e| bad(CMD, e))?; } if let Some(n) = name { layer.name = n; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b4a53a7d14..fdcbe24120 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,7 +7,6 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -pub mod action_defaults; pub mod adjust_params; mod allocation; pub mod build_info; @@ -68,7 +67,6 @@ pub use cmd::analysis_cmds; pub use cmd::artboard_cmds; pub use cmd::atn_cmds; pub use cmd::automate_cmds; -pub use cmd::background_blur_cmds; pub use cmd::background_cmds; pub use cmd::brush_cmds; pub use cmd::brush_key_cmds; @@ -94,7 +92,6 @@ pub use cmd::fill_key_cmds; pub use cmd::float_cmds; pub use cmd::font_library_cmds; use cmd::frame_cmds; -pub use cmd::frequency_separation_cmds; pub use cmd::fx_view_cmds; pub use cmd::fx_visibility_cmds; pub use cmd::gallery_cmds; @@ -207,9 +204,6 @@ pub struct Session { /// Shape path context menu: copied vector fill and stroke styles. pub path_fill_clipboard: Option, pub path_stroke_clipboard: Option, - /// Pattern tiles referenced by copied paints; surfaces retain their COW snapshots. - pub(crate) path_fill_pattern: Option, - pub(crate) path_stroke_pattern: Option, /// Colour management: proofing state, monitor profile, display transforms. pub color: color_cmds::ColorState, /// Open Edit Contents documents and the smart objects they update. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 693e1a92ca..61ea3f4eae 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -84,7 +84,6 @@ choice!( LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto ); choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small); -choice!(ScrollbarVisibility { Default = "default", Always = "always", AutoHide = "autoHide", Hidden = "hidden" } default Default); choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata); choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise); choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always); @@ -280,10 +279,6 @@ pub struct Interface { /// Command ids and the control protocol stay English. pub language: String, pub ui_font_size: UiFontSize, - /// Use PhotoCraft glyphs instead of gradient thumbnails in style controls. - pub native_gradient_icons: bool, - /// Default follows the theme; other modes apply to panel and canvas scrollbars (#3262). - pub scrollbar_visibility: ScrollbarVisibility, pub show_channels_in_color: bool, pub dynamic_color_sliders: bool, /// Draw menu item colours set with Edit › Menus. @@ -329,8 +324,6 @@ impl Default for Interface { ui_scale: UiScale::Auto, language: "auto".into(), ui_font_size: UiFontSize::Small, - native_gradient_icons: false, - scrollbar_visibility: ScrollbarVisibility::Default, show_channels_in_color: false, dynamic_color_sliders: true, show_menu_colors: true, diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index ac4d098864..6f17a21970 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -6,28 +6,6 @@ fn session() -> Session { s } -#[test] -fn native_gradient_icons_persist_reset_and_preserve_legacy_defaults() { - let mut s = session(); - let history = s.active().unwrap().history.past_len(); - assert!(!s.prefs().interface.native_gradient_icons); - s.execute("prefs.set", json!({"path": "interface.nativeGradientIcons", "value": true})).unwrap(); - let mut restarted = Session::new(); - restarted.load_prefs_json(&s.prefs_to_json()).unwrap(); - assert!(restarted.prefs().interface.native_gradient_icons); - assert_eq!(s.active().unwrap().history.past_len(), history); - s.execute("prefs.reset", json!({"path": "interface.nativeGradientIcons"})).unwrap(); - assert!(!s.prefs().interface.native_gradient_icons); - let before = s.prefs().to_json(); - for invalid in [json!("true"), json!(1), Value::Null] { - assert!(s.execute("prefs.set", json!({"values": {"interface.showTooltips": false, "interface.nativeGradientIcons": invalid}})).is_err()); - assert_eq!(s.prefs().to_json(), before, "invalid updates are atomic"); - } - restarted.load_prefs_json(r#"{"interface":{"showTooltips":false}}"#).unwrap(); - assert!(!restarted.prefs().interface.native_gradient_icons); - assert!(!restarted.prefs().interface.show_tooltips); -} - #[test] fn defaults_match_photoshop() { let p = Preferences::default(); @@ -1055,23 +1033,3 @@ fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() { assert!(s.load_prefs_json(&deep).is_ok()); assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok()); } - -#[test] -fn scrollbar_visibility_round_trips_and_old_preferences_keep_the_theme_default() { - let old = serde_json::from_value::(json!({"interface": {"uiFontSize": "small"}})).unwrap(); - assert_eq!(old.interface.scrollbar_visibility, ScrollbarVisibility::Default); - assert_eq!(choices("interface.scrollbarVisibility"), Some(ScrollbarVisibility::NAMES)); - let mut s = Session::new(); - for mode in ScrollbarVisibility::NAMES { - s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": mode}})).unwrap(); - let saved = s.prefs().to_json(); - let loaded: Preferences = serde_json::from_value(saved.clone()).unwrap(); - assert_eq!(loaded.interface.scrollbar_visibility, s.prefs().interface.scrollbar_visibility); - assert_eq!(saved["interface"]["scrollbarVisibility"], json!(mode)); - } - let before = s.prefs().to_json(); - assert!(s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": "sometimes"}})).is_err()); - assert_eq!(s.prefs().to_json(), before); - s.execute("prefs.reset", json!({"path": "interface.scrollbarVisibility"})).unwrap(); - assert_eq!(s.prefs().interface.scrollbar_visibility, ScrollbarVisibility::Default); -} diff --git a/crates/engine/src/prefs/validate.rs b/crates/engine/src/prefs/validate.rs index 9b9851f551..37d0fdf022 100644 --- a/crates/engine/src/prefs/validate.rs +++ b/crates/engine/src/prefs/validate.rs @@ -1,4 +1,3 @@ -use super::ScrollbarVisibility; use super::shortcuts::normalize_shortcut; use super::{ AppearanceMode, Ask, CanvasBorder, CanvasColor, CheckerColors, CheckerSize, ColorPicker, DarkTheme, ExportLocation, ExportMetadata, FontPreview, @@ -81,7 +80,6 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "interface.canvasBorder" => CanvasBorder::NAMES, "interface.uiScale" => UiScale::NAMES, "interface.uiFontSize" => UiFontSize::NAMES, - "interface.scrollbarVisibility" => ScrollbarVisibility::NAMES, "historyLog.destination" => LogDestination::NAMES, "historyLog.detail" => LogDetail::NAMES, "fileHandling.imagePreviews" | "fileHandling.maximizePsdCompatibility" => Ask::NAMES, diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 3c1b5b195c..cc93556412 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -564,8 +564,6 @@ pub struct PresetStore { index: Option, /// [`crate::actions_cmds::ActionState::rev`] last written (or loaded). actions_rev: u64, - /// Starter set last offered to this store, independent of later user deletions or renames. - actions_defaults_version: u32, /// `actions.json` exists but failed to load: left on disk untouched, so it is never written. actions_skipped: bool, /// The pattern files and index (`patterns.json`). @@ -668,7 +666,7 @@ pub fn open(backend: Box) -> Opened { let loaded_actions = load_actions(backend.as_ref(), &mut warnings); let actions_skipped = loaded_actions.is_none(); - let (actions, actions_defaults_version) = loaded_actions.map(|f| (f.actions, f.defaults_version)).unwrap_or_default(); + let actions = loaded_actions.unwrap_or_default(); let (patterns, pattern_files) = patterns::load(backend.as_ref(), &mut warnings); let mut store = PresetStore { backend, @@ -678,7 +676,6 @@ pub fn open(backend: Box) -> Opened { tips: HashMap::new(), index: None, actions_rev: 0, - actions_defaults_version, actions_skipped, patterns: pattern_files, warnings: Vec::new(), @@ -744,17 +741,17 @@ pub fn open(backend: Box) -> Opened { /// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning. /// The saved actions; `None` when `actions.json` exists but could not be loaded. -fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec) -> Option { +fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec) -> Option> { let bytes = match backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES) { Ok(b) => b, - Err(e) if missing_file(&e) => return Some(crate::actions_cmds::ActionsFile { version: 1, defaults_version: 0, actions: Vec::new() }), + Err(e) if missing_file(&e) => return Some(Vec::new()), Err(e) => { warnings.push(format!("actions: {ACTIONS_FILE} skipped: {e}")); return None; } }; match serde_json::from_slice::(&bytes) { - Ok(file) if file.version == 1 => Some(file), + Ok(file) if file.version == 1 => Some(file.actions), Ok(file) => { warnings.push(format!("actions: {ACTIONS_FILE} skipped: unsupported version {}", file.version)); None @@ -1106,7 +1103,7 @@ impl PresetStore { self.actions_rev = rev; return; } - let file = crate::actions_cmds::ActionsFile { version: 1, defaults_version: self.actions_defaults_version, actions: list.to_vec() }; + let file = crate::actions_cmds::ActionsFile { version: 1, actions: list.to_vec() }; let bytes = match serde_json::to_vec_pretty(&file) { Ok(b) => b, Err(e) => { @@ -1183,8 +1180,7 @@ impl Session { } self.preset_store = Some(store); // In-memory actions (created before the store finished loading) win on name. Disk-only - // names are appended. An empty session takes the disk list without rewriting it, - // except when the one-time starter set migration below is needed. + // names are appended. An empty session takes the disk list and does not rewrite it. if self.actions.list.is_empty() { self.actions.list = disk; self.actions.rev = 1; @@ -1199,16 +1195,6 @@ impl Session { } self.actions.rev = self.actions.rev.saturating_add(1).max(1); } - if let Some(st) = self.preset_store.as_mut() - && !st.actions_skipped - && st.actions_defaults_version < crate::action_defaults::VERSION - { - // Merge after disk and early in-memory actions so neither is overwritten. Persist - // the marker even when all names collide, so a later deletion stays deleted. - crate::action_defaults::append_missing(&mut self.actions); - st.actions_defaults_version = crate::action_defaults::VERSION; - self.actions.touch(); - } self.brush_presets_changed(); self.sync_preset_store(); // A brush remembered from the previous run (or picked before the store loaded) may be diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index 51d554534d..89be8bc681 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -112,7 +112,7 @@ fn group_with_sampled_8_and_16_bit_tips_round_trips() { let dir = TempDir::new("roundtrip"); let (mut s, w) = session(&dir); assert!(w.is_empty(), "{w:?}"); - assert_eq!(dir.files(), [ACTIONS_FILE], "only starter actions are written when attaching an empty store"); + assert!(dir.files().is_empty(), "attaching an empty store writes nothing"); let mut a = sampled("Eight", "Inks", tip8(64, 48, 1)); a.brush.dual_brush.tip = TipShape::Sampled(tip8(12, 12, 5)); a.brush.texture.pattern = Pattern::Tile(tip16(32, 32, 7)); @@ -163,7 +163,7 @@ fn commands_persist_and_delete_removes_from_disk() { assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_some()); assert!(photocraft_paint::presets::find(&t.tools.presets, "Scribble").is_none()); s.execute("brush.presets.delete", json!({"name": "Doodle"})).unwrap(); - assert_eq!(dir.files(), [ACTIONS_FILE, INDEX_FILE], "the group file and its tip are gone"); + assert_eq!(dir.files(), [INDEX_FILE], "the group file and its tip are gone"); let (t, _) = session(&dir); assert!(photocraft_paint::presets::find(&t.tools.presets, "Doodle").is_none()); } diff --git a/crates/engine/src/presets/shapes.rs b/crates/engine/src/presets/shapes.rs index abc534c681..e8a966e40d 100644 --- a/crates/engine/src/presets/shapes.rs +++ b/crates/engine/src/presets/shapes.rs @@ -16,8 +16,6 @@ use crate::commands::CommandSpec; use crate::vector_cmds::path_json; use crate::{EngineError, Result, Session}; -mod csh; - /// Group listing Edit › Define Custom Shape results (`EditState::custom_shapes`). pub const CUSTOM_GROUP: &str = "Custom Shapes"; @@ -430,7 +428,6 @@ fn reset(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ - csh::spec(), CommandSpec { id: "shape.presets.list", label: "Shape Presets", diff --git a/crates/engine/src/presets/shapes/csh.rs b/crates/engine/src/presets/shapes/csh.rs deleted file mode 100644 index 119c548cc6..0000000000 --- a/crates/engine/src/presets/shapes/csh.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Import a complete custom-shape set before changing the persistent library. - -use std::collections::{HashMap, HashSet}; - -use serde_json::{Value, json}; - -use super::{CommandSpec, Group, Session, ShapePreset, always, bad, fit}; -use crate::Result; - -const CMD: &str = "shape.presets.importCsh"; - -pub(super) fn spec() -> CommandSpec { - CommandSpec { - id: CMD, - label: "Import Shapes", - menu: &[], - shortcut: None, - params: r#"{"path":str (.csh) | "data":str (base64),"group":str?} → {group,count,names}; appends a uniquely named group, never replaces existing presets"#, - enabled: always, - run: import, - journal: true, - } -} - -/// Parse and convert the complete set before applying any library changes. -/// Group and shape names are resolved against the live library on apply, not the worker's -/// snapshot, so edits made while the job runs do not introduce duplicate addressable names. -/// Conversion fits each path into a 100-by-100 reference box while preserving its aspect ratio. -fn import(s: &mut Session, p: &Value) -> Result { - let group = p - .get("group") - .map(|v| v.as_str().map(str::trim).filter(|n| !n.is_empty()).map(str::to_owned).ok_or_else(|| bad(CMD, "`group` must be a non-empty string"))) - .transpose()?; - let params = p.clone(); - crate::jobs::run( - s, - "Import Shapes", - false, - move |ctx| { - ctx.check()?; - let (bytes, stem) = crate::preset_import_cmds::file_bytes_limited(¶ms, CMD, ".csh file", photocraft_psd::csh::MAX_FILE_BYTES as u64)?; - let shapes = photocraft_psd::csh::parse(&bytes).map_err(|e| bad(CMD, e.to_string()))?; - if shapes.is_empty() { - return Err(bad(CMD, "the CSH set contains no shapes")); - } - let mut items = Vec::with_capacity(shapes.len()); - for shape in shapes { - ctx.check()?; - let [top, left, bottom, right] = shape.bounds; - // The parser validates ordering; widen before subtraction for signed bounds. - let width = u32::try_from(i64::from(right) - i64::from(left)).map_err(|_| bad(CMD, "shape width exceeds supported bounds"))?; - let height = u32::try_from(i64::from(bottom) - i64::from(top)).map_err(|_| bad(CMD, "shape height exceeds supported bounds"))?; - let path = photocraft_io::vector_map::path_from_records(&shape.path, width, height); - if path.is_empty() { - return Err(bad(CMD, format!("shape {:?} has no path", shape.name))); - } - let path = fit(&path, [0.0, 0.0, 100.0, 100.0], true); - // Photoshop stores some preset names as `$$$/…/Key=Shown`. The Shapes panel - // renders the stored name, so keep the text after `=`. - let shown = photocraft_doc::pattern::display_name(&shape.name); - let name = if shown.trim().is_empty() { "Shape".to_owned() } else { shown.to_owned() }; - items.push(ShapePreset { name, path }); - } - let group = group.unwrap_or_else(|| if stem.is_empty() { "Imported Shapes".into() } else { stem }); - Ok((group, items)) - }, - |s, (group, mut items)| { - let group = super::super::gradients::unique_group(&s.presets.shapes, &group); - let mut taken: HashSet = s.presets.shapes.iter().flat_map(|g| g.items.iter().map(|i| i.name.clone())).collect(); - taken.extend(s.edit_state.custom_shapes.iter().map(|c| c.name.clone())); - let mut suffixes: HashMap = HashMap::new(); - for item in &mut items { - let base = item.name.clone(); - let suffix = suffixes.entry(base.clone()).or_insert(2); - while !taken.insert(item.name.clone()) { - item.name = format!("{base} {suffix}"); - *suffix += 1; - } - } - let names: Vec = items.iter().map(|i| i.name.clone()).collect(); - let result = json!({"group": group, "count": items.len(), "names": names}); - s.presets.shapes.push(Group::new(&group, items)); - s.presets_changed(); - Ok(result) - }, - ) -} diff --git a/crates/engine/tests/csh_import.rs b/crates/engine/tests/csh_import.rs deleted file mode 100644 index 3c6e8779dd..0000000000 --- a/crates/engine/tests/csh_import.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Original geometric CSH fixture, assembled from the public container/path layout. -//! No Adobe or third-party shape assets are embedded. - -use photocraft_engine::Session; -use serde_json::json; - -#[path = "../../psd/tests/common/csh_fixture.rs"] -mod fixture; -use fixture::{polygon_csh, triangle_csh}; - -#[test] -fn csh_import_stores_the_display_form_of_a_localization_key() { - let bytes = fixture::named_polygon_csh("$$$/Presets/Shapes/Tiger=Tiger", &[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]]); - let blank = fixture::named_polygon_csh("$$$/Presets/Shapes/Blank= ", &[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]]); - let mut session = Session::new(); - let result = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Localized CSH"})).unwrap(); - assert_eq!(result["group"], "Localized CSH"); - assert_eq!(result["names"], json!(["Tiger"])); - assert_eq!(session.presets.shapes.iter().find(|g| g.name == "Localized CSH").unwrap().items[0].name, "Tiger"); - let again = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Localized CSH"})).unwrap(); - assert_eq!(again["names"], json!(["Tiger 2"])); - let blanked = session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&blank)})).unwrap(); - assert_eq!(blanked["names"], json!(["Shape"])); -} - -#[test] -fn csh_import_makes_an_original_shape_available_without_a_document() { - let bytes = triangle_csh(); - if let Some(path) = std::env::var_os("PHOTOCRAFT_CSH_FIXTURE") { - std::fs::write(path, &bytes).unwrap(); - } - let mut session = Session::new(); - session - .execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": "Original CSH"})) - .expect("CSH import must be available without an open document"); - let groups = photocraft_engine::presets::shapes::all_groups(&session); - let imported = groups.iter().find(|g| g.name == "Original CSH").expect("imported group"); - assert_eq!(imported.items.len(), 1); - assert_eq!(imported.items[0].name, "Triangle"); - let path = &imported.items[0].path; - assert_eq!(path.subpaths.len(), 1); - assert!(path.subpaths[0].closed); - assert_eq!(path.subpaths[0].knots.len(), 3); - // The reference bounds are twice as wide as tall; normalization must preserve that. - assert_eq!(path.control_bounds().unwrap(), (0.0, 25.0, 100.0, 75.0)); -} - -#[test] -fn csh_failed_import_preserves_existing_presets_and_revision() { - let mut session = Session::new(); - let before = session.presets.to_json(&session); - let revision = session.presets.rev; - let bytes = triangle_csh(); - for end in [0, 11, bytes.len() - 1] { - assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes[..end])})).is_err()); - assert_eq!(session.presets.to_json(&session), before); - assert_eq!(session.presets.rev, revision); - } - assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes), "group": 12})).is_err()); - assert_eq!(session.presets.to_json(&session), before); -} - -#[test] -fn csh_invalid_second_shape_does_not_partially_import_a_set() { - let triangle = triangle_csh(); - let mut set = triangle.clone(); - set[8..12].copy_from_slice(&2u32.to_be_bytes()); - set.extend_from_slice(&triangle[12..]); - // The first shape is complete; truncate the second shape's path/padding. - set.pop(); - let mut session = Session::new(); - session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&triangle)})).unwrap(); - let before = session.presets.to_json(&session); - let revision = session.presets.rev; - assert!(session.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&set)})).is_err()); - assert_eq!(session.presets.to_json(&session), before); - assert_eq!(session.presets.rev, revision); -} - -#[test] -fn csh_multiple_shapes_with_matching_names_remain_individually_available() { - let triangle = triangle_csh(); - let mut set = triangle.clone(); - set[8..12].copy_from_slice(&2u32.to_be_bytes()); - set.extend_from_slice(&triangle[12..]); - let mut s = Session::new(); - let result = s.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&set), "group": "Pair"})).unwrap(); - assert_eq!(result["count"], 2); - assert_eq!(result["names"], json!(["Triangle", "Triangle 2"])); - let group = s.presets.shapes.iter().find(|g| g.name == "Pair").unwrap(); - assert_eq!(group.items.len(), 2); - assert_eq!(group.items[0].path, group.items[1].path); - s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); - for name in ["Triangle", "Triangle 2"] { - s.execute("shape.presets.place", json!({"preset": name, "group": "Pair"})).unwrap(); - } -} - -#[test] -fn csh_repeat_import_names_are_unique_and_survive_preferences_reload() { - let mut session = Session::new(); - let params = json!({"data": photocraft_paint::tile::b64_encode(&triangle_csh()), "group": "Original CSH"}); - let first = session.execute("shape.presets.importCsh", params.clone()).unwrap(); - let second = session.execute("shape.presets.importCsh", params).unwrap(); - assert_eq!(first["count"], 1); - assert_eq!(second["count"], 1); - assert_ne!(first["group"], second["group"]); - assert_ne!(first["names"], second["names"]); - let persisted = session.presets.to_json(&session); - let mut restored = Session::new(); - restored.load_presets_json(persisted); - assert_eq!(restored.presets.shapes, session.presets.shapes); -} - -#[test] -fn csh_same_winding_inner_subpath_stays_a_hole_after_place_and_reload() { - // Same winding intentionally: a nonzero union would incorrectly fill the hole. - let bytes = - polygon_csh(&[&[(0.125, 0.125), (0.875, 0.125), (0.875, 0.875), (0.125, 0.875)], &[(0.375, 0.375), (0.625, 0.375), (0.625, 0.625), (0.375, 0.625)]]); - let mut imported = Session::new(); - let result = imported.execute("shape.presets.importCsh", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); - let mut s = Session::new(); - s.load_presets_json(imported.presets.to_json(&imported)); - s.execute("file.new", json!({"width": 96, "height": 96, "background": "transparent"})).unwrap(); - s.execute("shape.presets.place", json!({"preset": result["names"][0], "rect": [8, 8, 80, 80], "keepAspect": false, "fill": "#ff0000"})).unwrap(); - let image = photocraft_compose::flatten(&s.active().unwrap().doc); - assert!(image.px[48 * 96 + 16][0] > 0.99, "ring must remain red"); - assert!(image.px[48 * 96 + 16][3] > 0.99, "ring must remain opaque"); - assert!(image.px[48 * 96 + 48][3] < 0.01, "inner subpath must remain transparent"); - assert!(image.px[4 * 96 + 4][3] < 0.01, "outside shape must remain transparent"); - s.execute("edit.undo", json!({})).unwrap(); - assert!(photocraft_compose::flatten(&s.active().unwrap().doc).px.iter().all(|pixel| pixel[3] < 0.01)); -} diff --git a/crates/engine/tests/shape_paint_fidelity.rs b/crates/engine/tests/shape_paint_fidelity.rs deleted file mode 100644 index 449a91f06c..0000000000 --- a/crates/engine/tests/shape_paint_fidelity.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! Shape paint JSON preserves model colours and all gradient/pattern placement fields. -use photocraft_color::{Color, ColorMode}; -use photocraft_doc::{Fill, GradientStyle, LayerContent, LayerId, ShapeLayer, ShapeStroke}; -use photocraft_engine::{ - Session, - vector_cmds::{shape_fill, shape_paint_params}, -}; -use serde_json::json; - -fn session(depth: u32) -> Session { - let mut s = Session::new(); - s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap(); - s -} - -fn active_shape(s: &Session) -> &ShapeLayer { - let st = s.active().unwrap(); - let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape layer") }; - shape -} - -#[test] -fn exact_paints_roundtrip_through_create_edit_and_undo() { - for depth in [8, 16, 32] { - let mut s = session(depth); - let pattern = s.patterns.items[0].clone(); - let mut fills = vec![ - Fill::Solid(Color::rgba(0.1234567, 0.3737, 0.919191, 0.4737)), - Fill::Solid(Color { mode: ColorMode::Grayscale, c: [0.314159, 0.0, 0.0, 0.0], alpha: 0.3737 }), - Fill::Solid(Color { mode: ColorMode::Cmyk, c: [0.17, 0.28, 0.39, 0.41], alpha: 0.73 }), - Fill::Solid(Color { mode: ColorMode::Lab, c: [57.3, -14.8, 23.9, 0.0], alpha: 0.63 }), - Fill::Pattern { name: "Stored pattern alias".into(), id: pattern.id, scale: 0.3737, angle: 27.3, link: false, phase: (-7.3, 4.1) }, - ]; - for style in [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond] { - fills.push(Fill::Gradient { - stops: vec![(0.1737, Color::rgba(0.12345, 0.34567, 0.56789, 0.61)), (0.9137, Color::rgba(0.9191, 0.8181, 0.7171, 0.82))], - angle: 37.3, - scale: 0.3737, - style, - reverse: true, - opacity_stops: vec![(0.07, 0.23), (0.91, 0.79)], - midpoints: vec![0.27], - offset: (-0.13, 0.21), - dither: true, - align: false, - }); - } - for fill in fills { - let stroke = ShapeStroke { paint: fill.clone(), opacity: 0.3737, width: 2.3, dashes: vec![2.1, 1.3], ..Default::default() }; - let before_parse = s.active().unwrap().doc.clone(); - let paint = shape_paint_params(Some(&fill), Some(&stroke)); - assert_eq!(shape_fill(&s, &paint["fill"]).unwrap(), Some(fill.clone())); - assert_eq!(s.active().unwrap().doc, before_parse, "view-only paint validation edited the document"); - let mut create = paint.clone(); - create["rect"] = json!([8, 8, 40, 24]); - s.execute("shape.create", create).unwrap(); - assert_eq!(active_shape(&s).fill, Some(fill.clone())); - assert_eq!(active_shape(&s).stroke, Some(stroke.clone())); - s.execute("shape.edit", json!({"fill":"#0000ff","stroke":null})).unwrap(); - let before = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - s.execute("shape.edit", paint).unwrap(); - assert_eq!(active_shape(&s).fill, Some(fill)); - assert_eq!(active_shape(&s).stroke, Some(stroke)); - assert_eq!(s.active().unwrap().history.past_len(), history + 1); - let after = s.active().unwrap().doc.clone(); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, before); - s.execute("edit.redo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, after); - } - } -} - -#[test] -fn solid_alpha_and_library_patterns_render_and_embed_in_the_same_history_step() { - for depth in [8, 16, 32] { - let mut s = session(depth); - let library = s.patterns.items.clone(); - let before = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - let id = LayerId(s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":[0.17,0.31,0.43,0.3737]})).unwrap()["layer"].as_u64().unwrap()); - let alpha: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap(); - assert!((alpha[3] - 0.3737).abs() < 0.005, "depth={depth}: {alpha:?}"); - assert_eq!(s.active().unwrap().history.past_len(), history + 1); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, before); - s.execute("edit.redo", json!({})).unwrap(); - assert!(s.active().unwrap().doc.layer(id).is_some()); - let before = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - s.execute("shape.edit", json!({"fill":{"pattern":library[0].name},"stroke":{"width":4,"fill":{"pattern":library[1].id}}})).unwrap(); - assert_eq!(s.active().unwrap().doc.patterns.len(), 2); - assert_eq!(s.active().unwrap().history.past_len(), history + 1); - let pixel: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap(); - assert!(pixel[3] > 0.99 && pixel[0] > 0.1, "library pattern did not render: {pixel:?}"); - let stroke_pixel: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":7,"y":20})).unwrap()).unwrap(); - assert!(stroke_pixel[3] > 0.99, "library pattern stroke did not render: {stroke_pixel:?}"); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, before); - assert!(s.active().unwrap().doc.patterns.is_empty()); - s.execute("edit.redo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc.patterns.len(), 2); - // A freshly created patterned shape carries its own tile too. - let mut fresh = session(depth); - fresh.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":library[0].id}})).unwrap(); - assert_eq!(fresh.active().unwrap().doc.patterns.len(), 1); - let pixel: [f32; 4] = serde_json::from_value(fresh.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap(); - assert!(pixel[3] > 0.99); - fresh.execute("edit.undo", json!({})).unwrap(); - assert!(fresh.active().unwrap().doc.patterns.is_empty()); - } -} - -#[test] -fn imported_document_pattern_resolves_without_a_global_library_entry() { - let mut s = session(16); - let imported = s.patterns.items[0].clone(); - std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(imported.clone()); - s.patterns.items.clear(); - let fill = Fill::Pattern { name: "Imported name preserved".into(), id: imported.id, scale: 0.3737, angle: -17.3, link: false, phase: (3.1, -2.7) }; - let paint = shape_paint_params(Some(&fill), None); - let before = s.active().unwrap().doc.clone(); - assert_eq!(shape_fill(&s, &paint["fill"]).unwrap(), Some(fill.clone())); - assert_eq!(s.active().unwrap().doc, before); - let mut create = paint; - create["rect"] = json!([8, 8, 40, 24]); - s.execute("shape.create", create).unwrap(); - assert_eq!(active_shape(&s).fill, Some(fill)); - assert_eq!(s.active().unwrap().doc.patterns.len(), 1); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, before); -} - -#[test] -fn legacy_defaults_and_malformed_paint_inputs_are_explicit_and_atomic() { - let mut s = session(8); - assert_eq!( - shape_fill(&s, &json!({"gradient":{}})).unwrap(), - Some(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 90.0, 1.0, GradientStyle::Linear, false)) - ); - assert_eq!(shape_fill(&s, &json!("#ff000080")).unwrap(), Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 128.0 / 255.0)))); - s.execute("shape.create", json!({"rect":[8,8,40,24]})).unwrap(); - let before = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - for fill in [ - json!([0, "bad", 1, 0.5]), - json!([0, 0, 0, 2]), - json!([0, 0, 0, 1, 1]), - json!({"gradient":null}), - json!({"gradient":{"stops":[[0,"#000000"],["bad","#ffffff"]]}}), - json!({"gradient":{"stops":[[2,"#000000"]]}}), - json!({"gradient":{"opacityStops":[[0,2]]}}), - json!({"gradient":{"midpoints":[-0.1]}}), - json!({"gradient":{"offset":[0]}}), - json!({"gradient":{"scale":0}}), - json!({"gradient":{"scale":1e100}}), - json!({"gradient":{"align":"false"}}), - json!({"gradient":{"dither":null}}), - json!({"gradient":{"style":"unknown"}}), - json!({"pattern":1}), - json!({"pattern":"missing pattern"}), - json!({"pattern":"Checkerboard","link":0}), - json!({"pattern":"Checkerboard","phase":[0,1e100]}), - json!({"pattern":"Checkerboard","id":1}), - json!({"color":{"mode":"Rgb","c":[0,0,0,0],"alpha":-1}}), - json!({"gradient":{"stops":vec![json!([0,"#000000"]);4097]}}), - ] { - assert!(shape_fill(&s, &fill).is_err(), "{fill}"); - assert!(s.execute("shape.edit", json!({"fill":fill.clone()})).is_err(), "{fill}"); - assert!(s.execute("shape.create", json!({"rect":[0,0,5,5],"stroke":{"fill":fill}})).is_err()); - assert_eq!(s.active().unwrap().doc, before); - assert_eq!(s.active().unwrap().history.past_len(), history); - } -} diff --git a/crates/engine/tests/shape_pattern_clipboard.rs b/crates/engine/tests/shape_pattern_clipboard.rs deleted file mode 100644 index 311eee639a..0000000000 --- a/crates/engine/tests/shape_pattern_clipboard.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! Vector style clipboards retain the pattern tile after its source document closes. -use photocraft_doc::{Color, Fill, LayerContent, Pattern, PixelFormat, ShapeLayer, ShapeStroke}; -use photocraft_engine::{Session, vector_cmds::shape_fill}; -use photocraft_geom::Rect; -use photocraft_raster::Surface; -use serde_json::json; - -fn imported_pattern() -> Pattern { - let mut surface = Surface::new(PixelFormat::RGBA16); - surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.13, 0.27, 0.91, 1.0]); - Pattern::new("$$$/Patterns/Test/Blue=Imported Blue", surface, 4, 4) -} - -fn new_doc(s: &mut Session, depth: u32) { - s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap(); -} - -fn shape(s: &Session) -> &ShapeLayer { - let st = s.active().unwrap(); - let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape") }; - sh -} - -#[test] -fn patterned_fill_and_stroke_clipboards_keep_cow_tiles_after_source_closes() { - for depth in [8, 16, 32] { - for imported in [false, true] { - let mut s = Session::new(); - new_doc(&mut s, depth); - let pattern = if imported { - let p = imported_pattern(); - std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone()); - s.patterns.items.clear(); - p - } else { - s.patterns.items[0].clone() - }; - let paint = json!({"pattern":pattern.name,"id":pattern.id,"scale":100,"angle":0,"link":false,"phase":[0,0]}); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":paint,"stroke":{"width":4,"fill":paint}})).unwrap(); - let copied_fill = shape(&s).fill.clone(); - let copied_stroke = shape(&s).stroke.clone(); - let fill_pixel = shape(&s).cache.as_ref().unwrap().rgba(24, 20); - let stroke_pixel = shape(&s).cache.as_ref().unwrap().rgba(7, 20); - let original = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - s.execute("path.style.copyFill", json!({})).unwrap(); - s.execute("path.style.copyStroke", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, original); - assert_eq!(s.active().unwrap().history.past_len(), history); - // Editing the source tile after Copy must not change the copied COW snapshot. - std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns[0].surface.fill_rect(pattern.rect(), &[1.0, 0.0, 0.0, 1.0]); - s.patterns.items.clear(); - assert!(s.close(0).is_some()); - for (paste, sample, expected) in [("path.style.pasteFill", (24, 20), fill_pixel), ("path.style.pasteStroke", (7, 20), stroke_pixel)] { - new_doc(&mut s, depth); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap(); - assert!(s.active().unwrap().doc.patterns.is_empty()); - let before = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - s.execute(paste, json!({})).unwrap(); - let after = s.active().unwrap().doc.clone(); - assert_eq!(after.patterns, vec![pattern.clone()]); - assert_eq!(s.active().unwrap().history.past_len(), history + 1); - if paste == "path.style.pasteFill" { - assert_eq!(shape(&s).fill, copied_fill); - } else { - assert_eq!(shape(&s).stroke, copied_stroke); - } - let pixel = shape(&s).cache.as_ref().unwrap().rgba(sample.0, sample.1); - assert!(pixel[3] > 0.99, "{paste}: copied pattern remained transparent"); - assert!( - pixel.iter().zip(expected).all(|(a, b)| (*a - b).abs() < 0.005), - "{paste}, imported={imported}, depth={depth}: {pixel:?} != {expected:?}" - ); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, before); - assert!(s.active().unwrap().doc.patterns.is_empty()); - s.execute("edit.redo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, after); - } - } - } -} - -#[test] -fn replacing_public_clipboards_with_plain_paints_ignores_old_pattern_snapshots() { - let mut s = Session::new(); - new_doc(&mut s, 8); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":"Checkerboard"},"stroke":{"width":4,"fill":{"pattern":"Checkerboard"}}})).unwrap(); - s.execute("path.style.copyFill", json!({})).unwrap(); - s.execute("path.style.copyStroke", json!({})).unwrap(); - new_doc(&mut s, 8); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap(); - let fill = Fill::Solid(Color::rgba(0.13, 0.27, 0.91, 0.73)); - let stroke = ShapeStroke { paint: fill.clone(), width: 4.0, ..Default::default() }; - s.path_fill_clipboard = Some(fill.clone()); - s.path_stroke_clipboard = Some(stroke.clone()); - s.execute("path.style.pasteFill", json!({})).unwrap(); - s.execute("path.style.pasteStroke", json!({})).unwrap(); - assert_eq!(shape(&s).fill, Some(fill)); - assert_eq!(shape(&s).stroke, Some(stroke)); - assert!(s.active().unwrap().doc.patterns.is_empty()); -} - -#[test] -fn legacy_pattern_clipboard_without_a_snapshot_resolves_the_library() { - let mut s = Session::new(); - let p = s.patterns.items[0].clone(); - for old_id in ["", "obsolete-library-id"] { - new_doc(&mut s, 16); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap(); - s.path_fill_clipboard = Some(Fill::Pattern { name: p.name.clone(), id: old_id.into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }); - s.execute("path.style.pasteFill", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc.patterns, vec![p.clone()]); - assert_eq!(shape(&s).fill, Some(Fill::Pattern { name: p.name.clone(), id: p.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) })); - assert!(shape(&s).cache.as_ref().unwrap().rgba(24, 20)[3] > 0.99); - } -} - -#[test] -fn copied_name_only_and_obsolete_ids_bind_to_the_snapshot_despite_destination_conflicts() { - for old_id in ["", "obsolete-imported-id"] { - for stroke in [false, true] { - let mut s = Session::new(); - new_doc(&mut s, 16); - let pattern = imported_pattern(); - std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(pattern.clone()); - let paint = json!({"pattern":pattern.name,"id":pattern.id,"scale":37.37,"angle":17.3,"link":false,"phase":[-2.7,3.1]}); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":paint,"stroke":{"width":4,"fill":paint}})).unwrap(); - let id = s.active().unwrap().active_layer.unwrap(); - let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); - let LayerContent::Shape(source) = &mut doc.layer_mut(id).unwrap().content else { panic!("source shape") }; - if let Some(Fill::Pattern { id, .. }) = &mut source.fill { - *id = old_id.into(); - } - if let Some(ShapeStroke { paint: Fill::Pattern { id, .. }, .. }) = &mut source.stroke { - *id = old_id.into(); - } - let mut expected_fill = shape(&s).fill.clone().unwrap(); - let mut expected_stroke = shape(&s).stroke.clone().unwrap(); - if let Fill::Pattern { id, .. } = &mut expected_fill { - *id = pattern.id.clone(); - } - if let Fill::Pattern { id, .. } = &mut expected_stroke.paint { - *id = pattern.id.clone(); - } - let original = s.active().unwrap().doc.clone(); - s.execute(if stroke { "path.style.copyStroke" } else { "path.style.copyFill" }, json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, original, "normalization changes the clipboard, not imported source metadata"); - if stroke { - assert_eq!(s.path_stroke_clipboard, Some(expected_stroke.clone())); - } else { - assert_eq!(s.path_fill_clipboard, Some(expected_fill.clone())); - } - s.patterns.items.clear(); - new_doc(&mut s, 16); - let mut conflicting = pattern.clone(); - conflicting.id = if old_id.is_empty() { "destination-pattern-id".into() } else { old_id.into() }; - conflicting.surface.fill_rect(pattern.rect(), &[1.0, 0.0, 0.0, 1.0]); - std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(conflicting.clone()); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap(); - let before = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - s.execute(if stroke { "path.style.pasteStroke" } else { "path.style.pasteFill" }, json!({})).unwrap(); - let after = s.active().unwrap().doc.clone(); - assert_eq!(s.active().unwrap().history.past_len(), history + 1); - assert_eq!(after.patterns, vec![conflicting, pattern.clone()]); - if stroke { - assert_eq!(shape(&s).stroke, Some(expected_stroke)); - } else { - assert_eq!(shape(&s).fill, Some(expected_fill)); - } - let pixel = shape(&s).cache.as_ref().unwrap().rgba(if stroke { 7 } else { 24 }, 20); - assert!( - pixel[2] > 0.85 && pixel[0] < 0.2 && pixel[3] > 0.99, - "stroke={stroke}, old_id={old_id:?}: destination pattern replaced copied blue: {pixel:?}" - ); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, before); - s.execute("edit.redo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc, after); - } - } -} - -#[test] -fn unresolved_and_conflicting_pattern_clipboards_fail_atomically() { - let mut s = Session::new(); - new_doc(&mut s, 16); - let p = imported_pattern(); - std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone()); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":p.name,"id":p.id}})).unwrap(); - s.execute("path.style.copyFill", json!({})).unwrap(); - new_doc(&mut s, 16); - s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap(); - let mut conflicting = p.clone(); - conflicting.surface.fill_rect(p.rect(), &[1.0, 0.0, 0.0, 1.0]); - std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(conflicting); - let before = s.active().unwrap().doc.clone(); - let history = s.active().unwrap().history.past_len(); - let error = s.execute("path.style.pasteFill", json!({})).unwrap_err(); - assert!(error.to_string().contains("different pattern")); - assert_eq!(s.active().unwrap().doc, before); - assert_eq!(s.active().unwrap().history.past_len(), history); - s.path_fill_clipboard = Some(Fill::Pattern { name: "Missing".into(), id: "missing".into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }); - assert!(s.execute("path.style.pasteFill", json!({})).is_err()); - assert_eq!(s.active().unwrap().doc, before); - assert_eq!(s.active().unwrap().history.past_len(), history); -} - -#[test] -fn pattern_parser_falls_back_to_imported_display_names_and_rejects_conflicting_ids() { - let mut s = Session::new(); - new_doc(&mut s, 16); - let p = imported_pattern(); - std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone()); - let before = s.active().unwrap().doc.clone(); - let fill = shape_fill(&s, &json!({"pattern":"Imported Blue","id":"obsolete-id","angle":17,"phase":[3,4]})).unwrap().unwrap(); - assert_eq!(fill, Fill::Pattern { name: "Imported Blue".into(), id: p.id.clone(), scale: 1.0, angle: 17.0, link: true, phase: (3.0, 4.0) }); - assert!(shape_fill(&s, &json!({"pattern":s.patterns.items[0].name,"id":p.id})).is_err()); - assert_eq!(s.active().unwrap().doc, before); -} diff --git a/crates/engine/tests/shape_patterns.rs b/crates/engine/tests/shape_patterns.rs deleted file mode 100644 index abfd9de283..0000000000 --- a/crates/engine/tests/shape_patterns.rs +++ /dev/null @@ -1,74 +0,0 @@ -use photocraft_doc::{LayerContent, LayerId, Pattern, PixelFormat}; -use photocraft_engine::Session; -use photocraft_geom::Rect; -use photocraft_raster::Surface; -use serde_json::json; - -fn library_pattern() -> Pattern { - let mut surface = Surface::new(PixelFormat::RGBA16); - surface.fill_rect(Rect::new(0, 0, 2, 2), &[0.1, 0.2, 0.9, 1.0]); - surface.fill_rect(Rect::new(1, 0, 2, 2), &[0.9, 0.7, 0.1, 1.0]); - Pattern::new("Shape tile", surface, 2, 2) -} - -#[test] -fn patterned_shapes_embed_render_and_undo_in_one_command_at_every_depth() { - for depth in [8, 16, 32] { - let mut session = Session::new(); - session.execute("file.new", json!({"width":32,"height":32,"depth":depth})).unwrap(); - let p = library_pattern(); - session.patterns.items.push(p.clone()); - let history = session.active().unwrap().history.past_len(); - let f = json!({"pattern":p.name,"id":p.id,"scale":100,"angle":0,"link":true}); - let id = LayerId( - session.execute("shape.create", json!({"kind":"rect","rect":[8,8,16,16],"fill":f,"stroke":{"width":4,"fill":f}})).unwrap()["layer"] - .as_u64() - .unwrap(), - ); - let doc = session.active().unwrap().doc.clone(); - assert_eq!(session.active().unwrap().history.past_len(), history + 1); - assert_eq!(doc.patterns, vec![p.clone()]); - let surface = doc.layer(id).unwrap().surface().unwrap(); - let q = surface.rgba(10, 10); - assert!(q[2] > 0.85 && q[0] < 0.15 && q[3] > 0.99, "pattern fill is visible: {q:?}"); - let q = surface.rgba(6, 12); - assert!(q[2] > 0.85 && q[3] > 0.99, "pattern stroke is visible: {q:?}"); - assert!(session.undo()); - assert!(session.active().unwrap().doc.patterns.is_empty()); - assert!(session.active().unwrap().doc.layer(id).is_none()); - assert!(session.redo()); - assert_eq!(session.active().unwrap().doc, doc); - - let saved = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); - let native = photocraft_format::load_from_bytes(&saved).unwrap(); - assert_eq!(&native, doc.as_ref()); - let bytes = photocraft_io::export(&doc, "psd", &Default::default()).unwrap().bytes; - let mut imported = photocraft_io::import("shape-pattern.psd", &bytes).unwrap().document; - assert_eq!(imported.patterns.len(), 1); - let imported_id = imported.layers.iter().find(|l| matches!(l.content, LayerContent::Shape(_))).unwrap().id; - // Prove editable paint survives, rather than relying on the PSD's cached pixels. - let source = imported.clone(); - if let LayerContent::Shape(sh) = &mut imported.layer_mut(imported_id).unwrap().content { - photocraft_engine::vector_cmds::refresh_shape(&source, sh); - } - let q = imported.layer(imported_id).unwrap().surface().unwrap().rgba(10, 10); - assert!(q[2] > 0.8 && q[0] < 0.2 && q[3] > 0.99, "PSD pattern remains renderable after refresh: {q:?}"); - } -} - -#[test] -fn editing_a_solid_shape_to_pattern_embeds_and_undoes_the_tile() { - let mut session = Session::new(); - session.execute("file.new", json!({"width":32,"height":32})).unwrap(); - let p = library_pattern(); - session.patterns.items.push(p.clone()); - let id = session.execute("shape.create", json!({"kind":"rect","rect":[8,8,16,16],"fill":"#ff0000"})).unwrap()["layer"].as_u64().unwrap(); - let original = session.active().unwrap().doc.clone(); - session.execute("shape.edit", json!({"layer":id,"fill":{"pattern":p.name,"id":p.id}})).unwrap(); - assert_eq!(session.active().unwrap().doc.patterns, vec![p]); - let edited = session.active().unwrap().doc.clone(); - assert!(session.undo()); - assert_eq!(session.active().unwrap().doc, original); - assert!(session.redo()); - assert_eq!(session.active().unwrap().doc, edited); -} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 117eb0cb09..a093bd10e0 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -1,7 +1,6 @@ //! Document ⇄ manifest conversion. Binary data is routed through a //! [`Sink`] (save) or [`Fetch`] (load) keyed by BLAKE3 hash. -use std::collections::HashSet; use std::sync::Arc; use photocraft_color::{PixelFormat, SampleType}; @@ -562,32 +561,25 @@ impl Loader<'_> { } pub(crate) fn document(&mut self, m: &DocM) -> Result { - // Validate the whole layer tree before decoding payloads or allocating ids. Remapping - // cannot repair duplicates: layer comp and slice targets would still be ambiguous. - fn check_ids(layers: &[LayerM], depth: usize, seen: &mut HashSet, max: &mut u64) -> Result<()> { - if depth > MAX_GROUP_DEPTH && !layers.is_empty() { - return Err(too_deep()); - } - for l in layers { - if !seen.insert(l.id) { - return Err(FormatError::corrupt(format!("duplicate layer id {}", l.id))); - } - *max = (*max).max(l.id); - match &l.content { - ContentM::Group { children, .. } => check_ids(children, depth + 1, seen, max)?, - // Contents identities intentionally repeat for shared smart objects; embedded - // documents are opaque blobs with their own layer identity namespace. - ContentM::Smart { contents_id: Some(id), .. } => *max = (*max).max(*id), - _ => {} - } - } - Ok(()) - } - let mut max = m.id; - check_ids(&m.layers, 0, &mut HashSet::new(), &mut max)?; // Reserve every stored id before allocating identities missing from older bundles. If an // id is implausibly large, remap the whole document while retaining shared contents groups. if self.preserve_ids { + fn max_ids(layers: &[LayerM], depth: usize, max: &mut u64) -> Result<()> { + if depth > MAX_GROUP_DEPTH && !layers.is_empty() { + return Err(too_deep()); + } + for l in layers { + *max = (*max).max(l.id); + match &l.content { + ContentM::Group { children, .. } => max_ids(children, depth + 1, max)?, + ContentM::Smart { contents_id: Some(id), .. } => *max = (*max).max(*id), + _ => {} + } + } + Ok(()) + } + let mut max = m.id; + max_ids(&m.layers, 0, &mut max)?; self.preserve_ids = reserve_ids_through(max); } let layers = m.layers.iter().map(|l| self.layer(l, 0)).collect::>>()?; diff --git a/crates/format/tests/layer_identities.rs b/crates/format/tests/layer_identities.rs deleted file mode 100644 index fd764dd02c..0000000000 --- a/crates/format/tests/layer_identities.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Layer identities are unique within a document, not across embedded smart documents. - -use std::cell::Cell; -use std::collections::HashSet; -use std::sync::Arc; - -use photocraft_color::{ColorMode, SampleType}; -use photocraft_doc::{CompLayerState, DocId, Document, Layer, LayerComp, LayerContent, SmartContentsId, SmartObject, SmartSource}; -use photocraft_format::{ByteSource, FormatError, LoadOptions, SaveOptions, load_bytes_source, load_from_bytes_with, save_to_bytes, zip}; -use photocraft_geom::{Affine, Rect, Size}; - -fn sample() -> Document { - let mut doc = Document::new("layer identities", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); - let mut first = Layer::raster("same name", doc.pixel_format()); - first.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); - let leaf = || Layer::raster("same name", doc.pixel_format()); - doc.layers = vec![first, Layer::group("left", vec![leaf(), Layer::group("nested", vec![leaf()])]), Layer::group("right", vec![leaf()])]; - doc.layer_comps.push(LayerComp { - id: 1, - name: "every layer".into(), - comment: String::new(), - apply_visibility: true, - apply_position: false, - apply_appearance: false, - states: doc.walk().into_iter().map(|(_, _, l)| CompLayerState { layer: l.id, visible: Some(l.visible), position: None, appearance: None }).collect(), - }); - doc -} - -fn rewrite_manifest(bytes: &[u8], edit: impl FnOnce(&mut serde_json::Value)) -> Vec { - let reader = zip::ZipReader::new(bytes).unwrap(); - let mut manifest: serde_json::Value = serde_json::from_slice(&reader.read_by_name("manifest.json", usize::MAX).unwrap()).unwrap(); - edit(&mut manifest); - let mut writer = zip::ZipWriter::new(); - writer.add("manifest.json", &serde_json::to_vec(&manifest).unwrap()).unwrap(); - for entry in reader.entries.iter().filter(|e| e.name != "manifest.json") { - writer.add(&entry.name, &reader.read(entry, usize::MAX).unwrap()).unwrap(); - } - writer.finish().unwrap() -} - -fn assert_duplicate(error: FormatError, id: u64) { - assert!(matches!(&error, FormatError::Corrupt(message) if message == &format!("duplicate layer id {id}")), "{error}"); -} - -#[test] -fn duplicate_layer_ids_are_rejected_across_the_whole_tree() { - let doc = sample(); - let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); - // Top-level siblings, ancestor/descendant, nested siblings, and unrelated groups must all - // share one identity namespace. Reidentifying cannot recover an ambiguous comp target. - for (source, target) in [ - ("/document/layers/1/id", "/document/layers/2/id"), - ("/document/layers/1/id", "/document/layers/1/content/children/1/content/children/0/id"), - ("/document/layers/1/content/children/0/id", "/document/layers/1/content/children/1/id"), - ("/document/layers/1/content/children/0/id", "/document/layers/2/content/children/0/id"), - ("/document/layers/0/id", "/document/layers/1/content/children/1/content/children/0/id"), - ] { - let mut duplicate = 0; - let damaged = rewrite_manifest(&bytes, |m| { - duplicate = m.pointer(source).unwrap().as_u64().unwrap(); - *m.pointer_mut(target).unwrap() = duplicate.into(); - }); - for preserve_ids in [true, false] { - let error = load_from_bytes_with(&damaged, &LoadOptions { preserve_ids, ..Default::default() }).unwrap_err(); - assert_duplicate(error, duplicate); - } - } -} - -#[test] -fn duplicate_extreme_layer_ids_are_not_silently_remapped() { - let bytes = save_to_bytes(&sample(), &SaveOptions::default()).unwrap(); - let damaged = rewrite_manifest(&bytes, |m| { - m["document"]["layers"][0]["id"] = u64::MAX.into(); - m["document"]["layers"][2]["id"] = u64::MAX.into(); - }); - for preserve_ids in [true, false] { - let error = load_from_bytes_with(&damaged, &LoadOptions { preserve_ids, ..Default::default() }).unwrap_err(); - assert_duplicate(error, u64::MAX); - } -} - -#[test] -fn duplicate_layer_ids_are_rejected_before_fetching_payloads() { - struct ManifestOnly { - json: Vec, - payload_reads: Cell, - } - impl ByteSource for ManifestOnly { - fn get(&self, path: &str, _max: usize) -> photocraft_format::Result> { - if path == "manifest.json" { - return Ok(self.json.clone()); - } - self.payload_reads.set(self.payload_reads.get() + 1); - Err(FormatError::Corrupt("payload fetched before validating layer ids".into())) - } - } - let doc = sample(); - let duplicate = doc.layers[1].id.0; - let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); - let damaged = rewrite_manifest(&bytes, |m| m["document"]["layers"][2]["id"] = duplicate.into()); - let reader = zip::ZipReader::new(&damaged).unwrap(); - let src = ManifestOnly { json: reader.read_by_name("manifest.json", usize::MAX).unwrap(), payload_reads: Cell::new(0) }; - for preserve_ids in [true, false] { - assert_duplicate(load_bytes_source(&src, &LoadOptions { preserve_ids, ..Default::default() }).unwrap_err(), duplicate); - assert_eq!(src.payload_reads.get(), 0); - } -} - -#[test] -fn unique_nested_layer_ids_preserve_comp_targets_in_both_load_modes() { - let doc = sample(); - let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); - let original_ids: HashSet<_> = doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); - for preserve_ids in [true, false] { - let loaded = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids, ..Default::default() }).unwrap(); - let walk = loaded.walk(); - let ids: HashSet<_> = walk.iter().map(|(_, _, l)| l.id).collect(); - assert_eq!(ids.len(), walk.len()); - for ((_, _, layer), state) in walk.iter().zip(&loaded.layer_comps[0].states) { - assert_eq!(state.layer, layer.id); - assert_eq!(loaded.layer(state.layer), Some(*layer)); - } - if preserve_ids { - assert_eq!(loaded, doc); - } else { - assert!(ids.is_disjoint(&original_ids)); - } - } -} - -#[test] -fn layer_identity_validation_keeps_smart_document_and_contents_scopes_independent() { - let mut doc = sample(); - let mut inner = Document::new("inside", doc.size, doc.mode, doc.depth); - inner.layers.push(doc.layers[0].clone()); - let bytes = Arc::new(save_to_bytes(&inner, &SaveOptions::default()).unwrap()); - let mut smart = SmartObject::new(SmartSource::Embedded { file_name: "inside.pcraft".into(), bytes: bytes.clone() }, Affine::IDENTITY, None); - // These typed identities may have the same number without identifying the same thing. - smart.contents_id = SmartContentsId(doc.layers[0].id.0); - doc.id = DocId(doc.layers[0].id.0); - let first = Layer::new("instance", LayerContent::Smart(smart)); - let second = first.duplicate(); - doc.layers.extend([first, second]); - let outer = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); - for preserve_ids in [true, false] { - let opts = LoadOptions { preserve_ids, ..Default::default() }; - let loaded = load_from_bytes_with(&outer, &opts).unwrap(); - let instances: Vec<_> = loaded.layers.iter().filter_map(|l| if let LayerContent::Smart(s) = &l.content { Some(s) } else { None }).collect(); - assert_eq!(instances.len(), 2); - assert_eq!(instances[0].contents_id, instances[1].contents_id); - for instance in instances { - let SmartSource::Embedded { bytes: stored, .. } = &instance.source else { panic!("embedded source expected") }; - assert_eq!(stored, &bytes); - let restored = load_from_bytes_with(stored, &opts).unwrap(); - assert_eq!(restored.layers.len(), 1); - if preserve_ids { - assert_eq!(restored, inner); - assert_eq!(restored.layers[0].id, loaded.layers[0].id); - } - } - if preserve_ids { - assert_eq!(loaded, doc); - } - } -} - -#[test] -fn an_empty_layer_tree_loads_in_both_modes() { - let doc = Document::new("empty", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); - let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); - for preserve_ids in [true, false] { - let loaded = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids, ..Default::default() }).unwrap(); - assert!(loaded.layers.is_empty()); - } -} diff --git a/crates/gpu/src/fx/builders.rs b/crates/gpu/src/fx/builders.rs index 253ebe65ad..ea0d074075 100644 --- a/crates/gpu/src/fx/builders.rs +++ b/crates/gpu/src/fx/builders.rs @@ -55,8 +55,8 @@ impl B { GlowTechnique::Softer => { // Inner glows spread 1 - alpha. let src = if inner { self.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape }; - let m = self.spread_blur(src, (g.size, g.spread), inner, 0.0, 0.0); - if inner { m } else { self.softer_glow_tail(m, g.size, g.range) } + let m=self.spread_blur(src, (g.size,g.spread),inner,0.0,0.0); + if inner {m} else {self.softer_glow_tail(m,g.size,g.range)} } }; self.finish_glow(m, fx::glow_lut(g), inner, center); diff --git a/crates/gpu/src/fx/tests.rs b/crates/gpu/src/fx/tests.rs index 32bdbae8cc..eec6383050 100644 --- a/crates/gpu/src/fx/tests.rs +++ b/crates/gpu/src/fx/tests.rs @@ -1,6 +1,6 @@ use super::*; use photocraft_color::BlendMode; -use photocraft_doc::{FxCommon, GlowSource, GlowTechnique}; +use photocraft_doc::FxCommon; /// Filled-shape identity survives integer moves, including fractional anchors and handles. #[test] diff --git a/crates/gpu/src/plan/planner.rs b/crates/gpu/src/plan/planner.rs index 9e68a98bbd..ef0924640a 100644 --- a/crates/gpu/src/plan/planner.rs +++ b/crates/gpu/src/plan/planner.rs @@ -485,8 +485,7 @@ impl<'a> Planner<'a> { if let LayerContent::Shape(sh) = &layer.content && !visible_clipped.is_empty() - && let Some((fill, stroke)) = - photocraft_compose::shape_split::split_with_patterns(sh, self.cx.canvas, self.cx.depth, self.cx.patterns, self.cx.mode) + && let Some((fill, stroke)) = photocraft_compose::shape_split::split(sh, self.cx.canvas, self.cx.depth) { // The vector stroke goes above the clipped layers: the fill is the clipping base, // the stroke is laid over the clipped result, then the masks apply to both @@ -788,9 +787,7 @@ impl<'a> Planner<'a> { // A stroked shape's vector stroke goes above its clipped layers and interior effects // (compose::split_parts): the fill and the stroke unmasked, the masks applied after. let split = match &layer.content { - LayerContent::Shape(sh) if sh.stroke.is_some() => { - photocraft_compose::shape_split::split_with_patterns(sh, canvas, self.cx.depth, self.cx.patterns, self.cx.mode) - } + LayerContent::Shape(sh) if sh.stroke.is_some() => photocraft_compose::shape_split::split(sh, canvas, self.cx.depth), _ => None, }; let (mut content, vstroke) = match split { diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index fdfff2be61..6900f56710 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -1606,40 +1606,6 @@ fn checker_pattern() -> Pattern { Pattern::new("checker", s, 6, 5) } -#[test] -fn patterned_shape_fill_and_stroke_clip_and_effects_match_cpu() { - let Some(mut g) = gpu() else { return }; - for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { - for effects in [false, true] { - let mut d = Document::new("pattern shape", Size::new(90, 70), mode, depth); - let p = checker_pattern(); - let fill = Fill::Pattern { id: p.id.clone(), name: p.name.clone(), scale: 1.3, angle: 23.0, link: true, phase: (2.0, -3.0) }; - d.patterns.push(p); - let mut sh = photocraft_doc::ShapeLayer { - path: photocraft_vector::shapes::rounded_rect(12.0, 12.0, 60.0, 40.0, [4.0; 4]), - fill: Some(fill.clone()), - stroke: Some(photocraft_doc::ShapeStroke { width: 4.0, paint: fill, align: photocraft_doc::StrokeAlign::Outside, ..Default::default() }), - ..Default::default() - }; - sh.cache = Some(photocraft_vector::render_shape_with_patterns(&sh, d.pixel_format(), d.bounds(), &d.patterns)); - assert!(sh.cache.as_ref().unwrap().rgba(30, 30)[3] > 0.5, "pattern fill must be visible before parity"); - assert!(sh.cache.as_ref().unwrap().rgba(9, 30)[3] > 0.5, "pattern stroke must be visible before parity"); - let mut layer = Layer::new("pattern", LayerContent::Shape(sh)); - if effects { - layer.mask = Some(mask(d.bounds(), 7, 0.6)); - layer.effects.items.push(Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 120.0, 4.0, 3.0, 0.0))); - } - d.layers.push(layer); - let mut clipped = noise_layer("clipped", d.pixel_format(), Rect::new(28, 0, 50, 70), 9, 0.5); - clipped.clipped = true; - d.layers.push(clipped); - fx_check(&mut g, &d, &format!("pattern shape {depth:?}/{mode:?}, effects {effects}")); - } - } - } -} - /// Every effect kind with several option combinations, one effect stack per case. fn effect_cases() -> Vec<(&'static str, Vec)> { let pat = checker_pattern(); diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index c56ecadf78..c280747611 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -718,11 +718,6 @@ pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> { for (i, v) in m.iter_mut().enumerate() { *v = f(2 + i * 8)?; } - // This best-effort fallback cannot bypass the text parser's geometry safety gate. - // Unusable matrices use identity instead of corrupting canvas or panel rectangles. - if !photocraft_text::psd::usable_tysh_transform(&m) { - return None; - } // text version (2) at 50, descriptor version (4) at 52, descriptor at 56. let rest = data.get(52..)?; let text = parse_prefix_versioned(rest).and_then(|d| match d.get("Txt ") { diff --git a/crates/io/src/blocks/more_tests.rs b/crates/io/src/blocks/more_tests.rs index 7900585127..3c41296134 100644 --- a/crates/io/src/blocks/more_tests.rs +++ b/crates/io/src/blocks/more_tests.rs @@ -490,20 +490,6 @@ fn effects_switch() { assert!(effects_enabled(&[0, 0, 0, 0])); } -#[test] -fn tysh_fallback_cannot_reintroduce_unusable_finite_or_nonfinite_transform() { - let mut tysh = vec![0u8; 52]; - tysh[..2].copy_from_slice(&1u16.to_be_bytes()); - for (i, value) in [1.0f64, 0.0, 0.0, 1.0, 30.0, 40.0].iter().enumerate() { - tysh[2 + 8 * i..10 + 8 * i].copy_from_slice(&value.to_be_bytes()); - } - assert!(parse_tysh(&tysh).is_some(), "normal matrices still import"); - for value in [1e62f64, -1e62, f64::INFINITY, f64::NAN] { - tysh[2..10].copy_from_slice(&value.to_be_bytes()); - assert!(parse_tysh(&tysh).is_none(), "invalid matrix {value} cannot reach UI"); - } -} - #[test] fn tysh_garbage_is_none() { assert!(parse_tysh(&[0; 10]).is_none()); diff --git a/crates/io/tests/it/psd_style_sheets.rs b/crates/io/tests/it/psd_style_sheets.rs index 1b78b1d23b..0634dd3f9c 100644 --- a/crates/io/tests/it/psd_style_sheets.rs +++ b/crates/io/tests/it/psd_style_sheets.rs @@ -107,33 +107,6 @@ fn alignment_from_the_paragraph_style_the_run_names() { } } -/// The paragraph sheets' `Hanging` key (Photoshop wrote it after `LeadingType` in the corpus -/// files) reads into `ParagraphStyle.hanging`; missing means off, as Photoshop's own files -/// write `/Hanging false` for the default paragraph sheet. -#[test] -fn hanging_punctuation_comes_from_the_paragraph_sheet() { - let on = engine( - "Text", - d(vec![ - ("Name", E::String("T".into())), - ("DefaultStyleSheet", E::Int(0)), - ("Properties", d(vec![("AutoHyphenate", E::Bool(true)), ("Hanging", E::Bool(true)), ("LeadingType", E::Int(0))])), - ]), - d(vec![("StyleSheetData", d(vec![]))]), - vec![para_sheet("Normal RGB", 0, 0), para_sheet("T", 0, 0)], - vec![normal_char(48.0)], - ); - assert!(layer("Text", &on).paragraphs[0].style.hanging, "on by the sheet"); - let off = engine( - "Text", - d(vec![("Name", E::String("T".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![("LeadingType", E::Int(0))]))]), - d(vec![("StyleSheetData", d(vec![]))]), - vec![para_sheet("Normal RGB", 0, 0), para_sheet("T", 0, 0)], - vec![normal_char(48.0)], - ); - assert!(!layer("Text", &off).paragraphs[0].style.hanging, "off without the key"); -} - /// A run's own value still wins over its paragraph style, and an unknown style name or an /// out-of-range sheet index falls back to Normal. #[test] diff --git a/crates/psd/src/csh.rs b/crates/psd/src/csh.rs deleted file mode 100644 index 2b6d545c2c..0000000000 --- a/crates/psd/src/csh.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Photoshop custom shape sets (`.csh`, version 2). -//! -//! Container layout: Michel Mariani's public format research, -//! . -//! Geometry uses Adobe's path record format. No external implementation or shape assets copied. - -use crate::error::{PsdError, Result}; -use crate::io::Reader; -use crate::path::{PathData, RECORD_LEN}; - -/// Maximum encoded file size accepted by this reader. -pub const MAX_FILE_BYTES: usize = 64 << 20; -/// Maximum presets in a set. -pub const MAX_SHAPES: usize = 10_000; -/// Maximum path records across all presets, not per shape. -pub const MAX_RECORDS: usize = 1_000_000; -/// Maximum UTF-16 units in a preset name, including its optional trailing null. -pub const MAX_NAME_UNITS: usize = 32_768; - -/// A named shape with its reference bounds and normalized path records. -#[derive(Debug, Clone, PartialEq)] -pub struct CshShape { - /// Display name. - pub name: String, - /// Source shape identifier (usually a UUID). - pub id: String, - /// Reference rectangle: top, left, bottom, right. Coordinates may be negative. - pub bounds: [i32; 4], - /// Coordinates are fractions of the reference rectangle's width and height. - pub path: PathData, -} - -/// Read a complete set before callers modify their preset libraries. -/// Unsupported versions, truncated records and excessive aggregate sizes return errors. -pub fn parse(bytes: &[u8]) -> Result> { - if bytes.len() > MAX_FILE_BYTES { - return Err(PsdError::LimitExceeded("CSH file bytes")); - } - let mut r = Reader::new(bytes); - let signature = r.array()?; - if &signature != b"cush" { - return Err(PsdError::InvalidSignature { expected: "cush", found: signature }); - } - if r.u32()? != 2 { - return Err(PsdError::Unsupported("CSH file version (expected 2)".into())); - } - let count = r.u32()? as usize; - if count > MAX_SHAPES { - return Err(PsdError::LimitExceeded("CSH shape count")); - } - r.check_count(count as u64, 32)?; - let mut shapes = Vec::new(); - let mut remaining_records = MAX_RECORDS; - for _ in 0..count { - let units = r.u32()? as usize; - if units > MAX_NAME_UNITS { - return Err(PsdError::LimitExceeded("CSH name length")); - } - let raw = r.bytes(units * 2)?; - let mut name: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); - if name.last() == Some(&0) { - name.pop(); - } - let name = String::from_utf16(&name).map_err(|_| PsdError::invalid("CSH name is not valid UTF-16"))?; - let padding = (4 - r.pos() % 4) % 4; - zero_padding(r.bytes(padding)?)?; - if r.u32()? != 1 { - return Err(PsdError::Unsupported("CSH shape version (expected 1)".into())); - } - let length = r.u32()?; - if !length.is_multiple_of(4) { - return Err(PsdError::invalid("CSH shape length must be aligned to four bytes")); - } - let mut shape = r.sub(u64::from(length))?; - let id_len = usize::from(shape.u8()?); - let id = std::str::from_utf8(shape.bytes(id_len)?).map_err(|_| PsdError::invalid("CSH identifier is not UTF-8"))?.to_owned(); - let bounds = [shape.i32()?, shape.i32()?, shape.i32()?, shape.i32()?]; - if bounds[2] <= bounds[0] || bounds[3] <= bounds[1] { - return Err(PsdError::invalid("CSH reference bounds are empty or reversed")); - } - let records = shape.remaining() / RECORD_LEN; - remaining_records = remaining_records.checked_sub(records).ok_or(PsdError::LimitExceeded("CSH aggregate path records"))?; - let path = PathData::from_bytes(shape.bytes(records * RECORD_LEN)?)?; - if shape.remaining() > 3 { - return Err(PsdError::invalid("CSH incomplete path record")); - } - zero_padding(shape.peek_rest())?; - validate_path(&path)?; - shapes.push(CshShape { name, id, bounds, path }); - } - if !r.is_empty() { - return Err(PsdError::invalid("CSH trailing data")); - } - Ok(shapes) -} - -fn zero_padding(bytes: &[u8]) -> Result<()> { - if bytes.iter().any(|&b| b != 0) { - return Err(PsdError::invalid("CSH nonzero padding")); - } - Ok(()) -} - -/// Enforce subpath structure more strictly than the generic PSD path reader. -/// Each length record must be followed by exactly its declared number of knots, with matching -/// open/closed selectors. Fill and clipboard records are accepted only between subpaths. -/// Rejecting inconsistent records prevents a damaged preset from silently changing geometry. -fn validate_path(path: &PathData) -> Result<()> { - let mut pending = 0u16; - let mut closed = false; - for record in &path.records { - match record.selector { - 0 | 3 if pending == 0 => { - pending = u16::from_be_bytes([record.data[0], record.data[1]]); - closed = record.selector == 0; - } - 1 | 2 | 4 | 5 if pending > 0 && closed == (record.selector <= 2) => pending -= 1, - 6..=8 if pending == 0 => {} - _ => return Err(PsdError::invalid("CSH path selectors or knot counts are inconsistent")), - } - } - if pending != 0 { - return Err(PsdError::invalid("CSH subpath is missing knots")); - } - Ok(()) -} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 3057596ef4..f5ffbed8b7 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -49,7 +49,6 @@ pub mod atn; pub mod blend; pub mod builder; pub mod compression; -pub mod csh; pub mod descriptor; pub mod error; pub mod file; diff --git a/crates/psd/tests/common/csh_fixture.rs b/crates/psd/tests/common/csh_fixture.rs deleted file mode 100644 index aef1e31da4..0000000000 --- a/crates/psd/tests/common/csh_fixture.rs +++ /dev/null @@ -1,49 +0,0 @@ -pub fn triangle_csh() -> Vec { - polygon_csh(&[&[(0.25, 0.25), (0.75, 0.25), (0.5, 0.75)]]) -} - -pub fn polygon_csh(polygons: &[&[(f64, f64)]]) -> Vec { - named_polygon_csh("Triangle", polygons) -} - -pub fn named_polygon_csh(label: &str, polygons: &[&[(f64, f64)]]) -> Vec { - let mut out = b"cush".to_vec(); - out.extend_from_slice(&2u32.to_be_bytes()); - out.extend_from_slice(&1u32.to_be_bytes()); - let name: Vec = label.encode_utf16().chain(std::iter::once(0)).collect(); - out.extend_from_slice(&(name.len() as u32).to_be_bytes()); - for unit in &name { - out.extend_from_slice(&unit.to_be_bytes()); - } - if !name.len().is_multiple_of(2) { - out.extend_from_slice(&[0, 0]); - } - out.extend_from_slice(&1u32.to_be_bytes()); - let mut body = vec![36]; - body.extend_from_slice(b"00000000-0000-4000-8000-000000000001"); - for bound in [0i32, 0, 100, 200] { - body.extend_from_slice(&bound.to_be_bytes()); - } - for selector in [6u16, 8] { - body.extend_from_slice(&selector.to_be_bytes()); - body.extend_from_slice(&[0; 24]); - } - for polygon in polygons { - body.extend_from_slice(&0u16.to_be_bytes()); - body.extend_from_slice(&(polygon.len() as u16).to_be_bytes()); - body.extend_from_slice(&[0; 22]); - for &(x, y) in *polygon { - body.extend_from_slice(&2u16.to_be_bytes()); - for _ in 0..3 { - body.extend_from_slice(&((y * 16_777_216.0) as i32).to_be_bytes()); - body.extend_from_slice(&((x * 16_777_216.0) as i32).to_be_bytes()); - } - } - } - while !body.len().is_multiple_of(4) { - body.push(0); - } - out.extend_from_slice(&(body.len() as u32).to_be_bytes()); - out.extend_from_slice(&body); - out -} diff --git a/crates/psd/tests/csh.rs b/crates/psd/tests/csh.rs deleted file mode 100644 index d21cfb035e..0000000000 --- a/crates/psd/tests/csh.rs +++ /dev/null @@ -1,88 +0,0 @@ -use photocraft_psd::csh; -use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath}; - -fn fixture(names: &[&str]) -> Vec { - let mut bytes = b"cush".to_vec(); - bytes.extend_from_slice(&2u32.to_be_bytes()); - bytes.extend_from_slice(&(names.len() as u32).to_be_bytes()); - for name in names { - let units: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); - bytes.extend_from_slice(&(units.len() as u32).to_be_bytes()); - for unit in units { - bytes.extend_from_slice(&unit.to_be_bytes()); - } - while !bytes.len().is_multiple_of(4) { - bytes.push(0); - } - bytes.extend_from_slice(&1u32.to_be_bytes()); - let mut body = vec![2, b'i', b'd']; - for bound in [-10i32, -20, 90, 180] { - body.extend_from_slice(&bound.to_be_bytes()); - } - let knot = PsdKnot { linked: true, pre: (0.125, 0.25), anchor: (0.5, 0.5), post: (0.75, 0.875) }; - let path = PathData::from_subpaths(&[PsdSubpath { closed: false, operation: 0, knots: vec![knot] }], false); - body.extend_from_slice(&path.to_bytes()); - while !body.len().is_multiple_of(4) { - body.push(0); - } - bytes.extend_from_slice(&(body.len() as u32).to_be_bytes()); - bytes.extend_from_slice(&body); - } - bytes -} - -#[test] -fn reads_multiple_names_padding_signed_bounds_and_cubic_controls() { - let bytes = fixture(&["Even", "Odd", "形😀"]); - let shapes = csh::parse(&bytes).unwrap(); - assert_eq!(shapes.iter().map(|s| s.name.as_str()).collect::>(), ["Even", "Odd", "形😀"]); - for shape in shapes { - assert_eq!(shape.bounds, [-10, -20, 90, 180]); - assert_eq!(shape.id, "id"); - let subpaths = shape.path.subpaths(); - assert_eq!(subpaths.len(), 1); - assert!(!subpaths[0].closed); - let knot = subpaths[0].knots[0]; - assert!(knot.linked); - assert_eq!(knot.pre, (0.125, 0.25)); - assert_eq!(knot.anchor, (0.5, 0.5)); - assert_eq!(knot.post, (0.75, 0.875)); - } -} - -#[test] -fn rejects_every_truncated_prefix_without_panicking() { - let bytes = fixture(&["One", "Two"]); - for end in 0..bytes.len() { - assert!(csh::parse(&bytes[..end]).is_err(), "accepted truncated prefix {end}"); - } -} - -#[test] -fn rejects_unsupported_headers_excessive_counts_and_invalid_unicode() { - let bytes = fixture(&["A"]); - for (offset, value) in [(4, 3u32), (8, u32::MAX), (12, u32::MAX), (24, u32::MAX)] { - let mut broken = bytes.clone(); - broken[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); - assert!(csh::parse(&broken).is_err(), "offset {offset}"); - } - let mut broken = bytes.clone(); - broken[16..18].copy_from_slice(&0xd800u16.to_be_bytes()); - assert!(csh::parse(&broken).is_err()); - let mut trailing = bytes; - trailing.push(0); - assert!(csh::parse(&trailing).is_err()); -} - -#[test] -fn rejects_missing_knots_and_mismatched_open_closed_records() { - let bytes = fixture(&["A"]); - // Header/name/version/length = 28; id = 3; bounds = 16; two fill records = 52. - let length_record = 28 + 3 + 16 + 52; - let mut missing = bytes.clone(); - missing[length_record + 2..length_record + 4].copy_from_slice(&2u16.to_be_bytes()); - assert!(csh::parse(&missing).is_err()); - let mut mismatched = bytes; - mismatched[length_record..length_record + 2].copy_from_slice(&0u16.to_be_bytes()); - assert!(csh::parse(&mismatched).is_err()); -} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5521fc6351..ac05d72a65 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -336,9 +336,6 @@ impl Layouter { flow.stop = true; break; } - if para.merge_hanging_row(out, &line, li, nlines) { - continue; - } let Some(frame) = para.frame_line(&line, li, nlines, slot, flow) else { flow.stop = true; break; diff --git a/crates/text/src/layout/lines.rs b/crates/text/src/layout/lines.rs index 0367dc29cb..b8943ef609 100644 --- a/crates/text/src/layout/lines.rs +++ b/crates/text/src/layout/lines.rs @@ -15,13 +15,6 @@ type Line<'a> = parley::Line<'a, RunBrush>; type GlyphRun<'a> = parley::GlyphRun<'a, RunBrush>; /// A cluster of a line in visual order, for kerning. -#[derive(Default)] -struct HangTrim { - advance: f32, - kern: f32, - active: bool, -} - struct KernSlot { /// Index of the run in the paragraph layout. run: usize, @@ -34,7 +27,6 @@ struct KernSlot { size: f32, rtl: bool, blank: bool, - hang_w: f32, /// Advance along the line (px, horizontal scale included). advance: f32, /// What horizontal scale adds to the shaped advance (px; negative below 100%). @@ -151,7 +143,6 @@ fn collect_kern_slots(line: &Line<'_>, para: &Para<'_>) -> Vec { size: run.font_size(), rtl: c.is_rtl(), blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= para.prefix.len(), - hang_w: hang_weight(para.text_of(c.text_range())), upright: env.vertical && c.first_style().brush.1 != VClass::Rotate as u8, advance: c.advance() * hs, growth: c.advance() * (hs - 1.0), @@ -328,59 +319,34 @@ impl Para<'_> { (trailing_tabs, trailing_growth) } - fn hanging_trim(&self, slots: &[KernSlot], kern_px: &[f32], f: &LineFrame) -> HangTrim { - if !self.ps.hanging || self.env.vertical { - return HangTrim::default(); - } - let Some((start, tail)) = hang_runs(slots) else { - return HangTrim::default(); - }; - let advance = slots[start..tail].iter().map(|s| s.advance * s.hang_w).sum(); - let kern = (start..tail).map(|j| kern_px.get(j).copied().unwrap_or(0.0) * slots[j].hang_w).sum(); - let edge = slots[..start].iter().map(|s| s.advance).sum::() + kern_px[..start].iter().sum::(); - let active = advance > 0.0 - && f.slot.is_none() - && (!self.env.is_box || matches!(self.ps.align, TextAlign::Center | TextAlign::Right) || edge >= self.avail.unwrap_or(0.0) - 0.5); - if active { HangTrim { advance, kern, active } } else { HangTrim::default() } - } - /// Where the line starts along its extent: box and area lines at their box or span edge, /// point lines around the anchor. - fn line_start(&self, f: &LineFrame, offset: f32, adv: f32, line_kern: f32, trim: &HangTrim) -> f32 { + fn line_start(&self, f: &LineFrame, offset: f32, adv: f32, line_kern: f32) -> f32 { if !self.env.wraps { let target = match self.ps.align { - TextAlign::Center | TextAlign::JustifyCenter => -(adv + line_kern - trim.advance - trim.kern) / 2.0, - TextAlign::Right | TextAlign::JustifyRight => -(adv + line_kern - trim.advance - trim.kern), + TextAlign::Center | TextAlign::JustifyCenter => -(adv + line_kern) / 2.0, + TextAlign::Right | TextAlign::JustifyRight => -(adv + line_kern), _ => self.indent_start, }; return target - offset; } // Parley aligned box lines without the kerning. let kern_align = match self.ps.align { - TextAlign::Center => (line_kern - trim.kern) / 2.0, - TextAlign::Right => line_kern - trim.kern, + TextAlign::Center => line_kern / 2.0, + TextAlign::Right => line_kern, _ => 0.0, }; let origin = f.slot.map_or(self.line_origin, |s| s.x); let base = origin + self.indent_start - kern_align; // The line's own width takes the justify-last-line slack. - let slack = self.line_avail(f).unwrap_or(0.0) - (adv * self.break_scale - trim.advance) - (line_kern - trim.kern); + let slack = self.line_avail(f).unwrap_or(0.0) - adv * self.break_scale - line_kern; // Parley aligned the unscaled line in `avail / break_scale`: scale its // indent and alignment offset back to the box. let scaled_offset = offset * (self.break_scale - 1.0); - let hung = trim.active && matches!(self.ps.align, TextAlign::Center | TextAlign::Right); base + if f.last { match self.ps.align { TextAlign::JustifyCenter => slack * 0.5 - offset, TextAlign::JustifyRight => slack - offset, - TextAlign::Center if hung => slack * 0.5 + kern_align - offset, - TextAlign::Right if hung => slack + kern_align - offset, - _ => scaled_offset, - } - } else if hung { - match self.ps.align { - TextAlign::Center => slack * 0.5 + kern_align - offset, - TextAlign::Right => slack + kern_align - offset, _ => scaled_offset, } } else { @@ -393,165 +359,6 @@ impl Para<'_> { f.slot.map(|s| s.width).or(self.avail) } - pub(super) fn merge_hanging_row(&self, out: &mut TextLayout, line: &Line<'_>, li: usize, nlines: usize) -> bool { - let ps = &self.ps; - let vertical = self.env.vertical; - let area = &self.env.area; - let pi = self.index; - let prefix = self.prefix; - let ptext = &self.ptext; - let style_at = |byte| self.env.style_at(byte); - // Hang-row detection needs the byte mapping the rest of the body uses. - let map = |o: usize| self.map_offset(o); - // Hanging punctuation: a row of nothing but marks was pushed here because they - // didn't fit the line above; with the paragraph's hanging on, step them back - // onto that line so they hang past the box edge (Photoshop's Roman Hanging - // Punctuation): a centered line keeps its centering, a left line keeps filling - // the box. Horizontal lines only (vertical type has Burasagari instead) and no - // RTL marks; the text between the line above and the marks stays contiguous, so - // carets and selection keep their rows. The marks keep their style's manual - // kerning but drop optical pair kerning and underline decorations along the way. - let hang_row = ps.hanging - && !vertical - // Area type plans its rows and slots ahead of the layout; merging a mark - // row back would leave a slot for a row that no longer exists, so hanging - // applies to point and box lines only there. - && area.is_none() - // An empty row (two forced breaks in a row) has no items: `all` would read - // it as a hanging run and merge it into the line above, dropping the blank - // row. - && line.items().next().is_some() - && line.items().all(|it| match it { - PositionedLayoutItem::GlyphRun(gr) => gr.run().visual_clusters().all(|c| { - !c.is_rtl() - && !c.is_space_or_nbsp() - && c.text_range().end > prefix.len() - && hang_cluster(ptext.get(c.text_range().clone()).unwrap_or("")) - }), - _ => false, - }); - if hang_row - && let Some(prev) = out.lines.last() - && prev.paragraph == pi - { - // The author's own break owns the row start: a forced break becomes '\n' - // in `ptext` and stays in the paragraph, so a marks-only row after it is - // deliberate, not a run the line breaker pushed out. Without the guard - // that row would merge into the line above and the break would vanish; - // the breaks of a normal wrapped line are spaces, never this. - let after_forced = line - .items() - .find_map(|it| match it { - PositionedLayoutItem::GlyphRun(gr) => gr.run().visual_clusters().next().map(|c| c.text_range().start), - _ => None, - }) - .is_some_and(|s| ptext.as_bytes().get(s.wrapping_sub(1)) == Some(&b'\n')); - // Count the row's clusters first: the merge takes the edge tip only. - let merged_line = out.lines.len() - 1; - let total = line - .items() - .map(|it| match it { - PositionedLayoutItem::GlyphRun(gr) => gr.run().visual_clusters().count(), - _ => 0, - }) - .sum::(); - // Photoshop hangs the marks at the edge, not the run: a row of a couple of - // marks was pushed whole because the row above ran out and hangs as one - // tip, but a row split out of a long run carries most of that run and - // keeps its break instead, or a paragraph of dots or hyphens in a narrow - // box would fold into single lines running past the edge by almost the - // whole run's width. - let tip = total <= 2; - // Photoshop hangs once per edge: the row above already ends in a hangable - // mark (its own run's tip, or a mark row already stepped back up), so no - // mark row may chain onto it and grow one line without bound. - let ends_in_mark = out - .clusters - .iter() - .rev() - .find(|c| c.line == merged_line && self.env.text.get(c.range.clone()).is_some_and(|s| !s.chars().all(char::is_whitespace))) - .and_then(|c| self.env.text.get(c.range.clone())) - .is_some_and(hang_cluster); - // A marks-only row merged into the last row of a justified paragraph would - // keep the justified (spread) spacing the row above was set out with: the - // placed clusters already carry the spread, but Photoshop centers, rights - // or lefts that last line. The row keeps its own last-line alignment; the - // measure-side hang that follows will not need the merge at all. - let justified_last = ps.align.is_justified() && li + 1 == nlines; - if tip && !ends_in_mark && !justified_last && !after_forced { - // Start past the row above's trailing breaking space: that space's cluster - // stays on the row, and `prev.x1` excludes trailing whitespace, so starting - // there would put the marks on top of the space cluster and carets could - // not tell them apart. - let mut cx = out.clusters.iter().rev().find(|c| c.line == merged_line).map_or(prev.x1, |c| c.x + c.advance); - let merged_baseline = prev.baseline; - // The kern of a pair comes from the pair's left cluster's style, as the - // line's kern_px does; it moves the following cluster and lands in the left - // cluster's reported advance. The pair with the row above's last cluster - // crosses a row boundary there, so it contributes none and the first - // cluster starts un-kerned. - let mut left: Option<(usize, f32)> = None; // (byte, font size) of the pair's left cluster - let mut n = 0usize; - let mut merged_end = cx; - for item in line.items() { - let PositionedLayoutItem::GlyphRun(gr) = item else { - continue; - }; - let run = gr.run(); - let si = gr.style().brush.0; - let st = &self.env.styles[si as usize]; - let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }; - let synth = run.synthesis(); - let face = out.faces.len() as u32; - out.faces.push(GlyphFace { - font: run.font().clone(), - coords: run.normalized_coords().to_vec(), - size_px: run.font_size(), - embolden: synthetic_bold(synth.embolden(), st.weight, run.font_attrs().weight.value()), - skew_deg: synth.skew().unwrap_or(0.0), - }); - for c in run.visual_clusters() { - n += 1; - cx += left.take().map_or(0.0, |(byte, size)| { - let st_p = &self.env.styles[style_at(byte)]; - let units = if st_p.kern.is_finite() { st_p.kern } else { 0.0 }; - units / 1000.0 * size - }); - let r = c.text_range(); - let st_h = &self.env.styles[style_at(map(r.start))]; - let kern = if st_h.kern.is_finite() { st_h.kern } else { 0.0 } / 1000.0 * run.font_size(); - let drawn = c.advance() * hs; - // The glyph pen is local to the cluster: the growth is already in - // the drawn advance, and each cluster starts where the pen is. - let mut gp = 0.0f32; - for g in c.glyphs() { - out.glyphs.push(PlacedGlyph { - face, - id: g.id, - x: cx + gp + g.x, - y: merged_baseline + g.y, - style: si, - orient: GlyphOrient::Horizontal, - }); - gp += g.advance * hs; - } - let adv = drawn + if n == total { 0.0 } else { kern }; - out.clusters.push(ClusterInfo { range: map(r.start)..map(r.end), x: cx, advance: adv, line: merged_line, rtl: false }); - cx += drawn; - merged_end = cx; - left = Some((map(r.start), run.font_size())); - } - } - if let Some(p) = out.lines.last_mut() { - p.x1 = merged_end; - p.range.end = map(line.text_range().end); - } - return true; - } - } - false - } - /// Where line `index` of `nlines` goes, advancing `flow` past it; `None` if a box clips it. pub(super) fn frame_line(&self, line: &Line<'_>, index: usize, nlines: usize, slot: Option, flow: &mut Flow) -> Option { let env = self.env; @@ -593,8 +400,7 @@ impl Para<'_> { let mut kern_px = self.kerning(optical, &slots); let (trailing_tabs, trailing_growth) = self.apply_tab_stops(&slots, &mut kern_px, f.index); let line_kern: f32 = kern_px.iter().sum::() - trailing_tabs; - let trim = self.hanging_trim(&slots, &kern_px, f); - let dx = self.line_start(f, m.offset, adv, line_kern, &trim); + let dx = self.line_start(f, m.offset, adv, line_kern); let lc = LineCtx { dx, baseline: f.baseline, slots: &slots, kern_px: &kern_px, line_index: out.lines.len() }; let (g0, c0) = (out.glyphs.len(), out.clusters.len()); let mut state = LineState { cursors: kern_cursors(line, &slots), seen_runs: Vec::new(), vinfo: Vec::new(), extra: 0.0 }; @@ -607,7 +413,7 @@ impl Para<'_> { state.extra -= squeeze_punctuation(env.text, &mut out.clusters[c0..], &mut out.glyphs[g0..]); } if env.wraps && f.last && self.ps.align == TextAlign::JustifyAll { - let slack = self.line_avail(f).unwrap_or(0.0) - (adv + state.extra - trim.advance - trim.kern); + let slack = self.line_avail(f).unwrap_or(0.0) - (adv + state.extra); state.extra += justify_line(out, c0, g0, slack); } if env.vertical { @@ -717,45 +523,3 @@ impl Para<'_> { } } } - -/// A cluster consisting only of characters that may hang at the line's end with hanging -/// punctuation on. Photoshop's checkbox is Roman Hanging Punctuation; the left-margin marks -/// (an opening quote hangs at the left edge; apostrophes and hyphens) are in the list, but -/// the run here covers a line's trailing marks. -fn hang_cluster(text: &str) -> bool { - hang_weight(text) > 0.0 -} - -/// A cluster's hang weight: every character must be a hangable mark for it to hang at all, -/// and the weakest mark governs: 1.0 hangs fully outside the margin, 0.5 hangs half, 0.0 -/// doesn't hang. -fn hang_weight(text: &str) -> f32 { - match text.is_empty() { - true => 0.0, - false => text.chars().map(hang_mark).fold(1.0, f32::min), - } -} - -fn hang_mark(c: char) -> f32 { - match c { - // Photoshop's Help: quotes, apostrophes, hyphens, periods and commas hang 100%. - '.' | ',' | '-' | '\u{2010}' | '-' | '\'' | '"' | '‘' | '’' | '“' | '”' | '«' | '»' | ',' | '.' => 1.0, - // Colons, semicolons, ellipses and dashes hang 50%; `!` and `?` aren't in the list. - ':' | ';' | '…' | '–' | '—' | ':' | ';' => 0.5, - _ => 0.0, - } -} - -/// Where the hanging run at the line's tail starts: the first slot after the last non-hang, -/// non-blank cluster. Returns `(run start, tail)` — the tail sits after the last non-blank -/// slot, because a line parley wrapped ends with the breaking space, whose slot must not -/// count into the sums (nor stop the run walk). `None` when nothing hangs there or when the -/// whole line is marks (an all-mark line is the merge-back case, and its trim would fight it). -fn hang_runs(slots: &[KernSlot]) -> Option<(usize, usize)> { - let tail = slots.iter().rposition(|s| !s.blank).map_or(0, |i| i + 1); - let mut end = tail; - while end > 0 && slots[end - 1].hang_w > 0.0 { - end -= 1; - } - (end > 0 && end < tail).then_some((end, tail)) -} diff --git a/crates/text/src/layout/shaping.rs b/crates/text/src/layout/shaping.rs index 9c4cca4872..c7d701c5d7 100644 --- a/crates/text/src/layout/shaping.rs +++ b/crates/text/src/layout/shaping.rs @@ -189,7 +189,7 @@ pub(super) struct Para<'a> { pub(super) start: usize, pub(super) content_end: usize, pub(super) prefix: &'static str, - pub(super) ptext: String, + ptext: String, synthetic_small_caps: Vec<(Range, f32)>, /// Offsets in the paragraph text of the spaces standing in for tabs. pub(super) tabs: Vec, diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 9a1c142c17..16d63c68b8 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -52,7 +52,7 @@ pub struct TySh { /// Whether a type transform can reach rendering or egui. Pixel coordinates use i32 throughout /// the document, so non-finite and unrepresentable affine terms are rejected (#4758). pub fn transform_is_safe(t: &Affine) -> bool { - t.m.iter().all(|v| v.is_finite() && v.abs() <= 1.0e9) + t.m.iter().all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX)) } /// Parses a `TySh` block. @@ -292,8 +292,3 @@ mod nonfinite_tests { assert!(out.contains("/FontSize 12.0"), "{out}"); } } - -/// Bounded finite affine terms accepted before type layout. -pub fn usable_tysh_transform(matrix: &[f64; 6]) -> bool { - transform_is_safe(&Affine { m: *matrix }) -} diff --git a/crates/text/src/psd/build.rs b/crates/text/src/psd/build.rs index 387c8f6466..9e2604cfde 100644 --- a/crates/text/src/psd/build.rs +++ b/crates/text/src/psd/build.rs @@ -85,8 +85,6 @@ pub(crate) fn paragraph_properties(p: &ParagraphStyle, k: f32) -> E { ("SpaceBefore".into(), real(p.space_before_pt / k)), ("SpaceAfter".into(), real(p.space_after_pt / k)), ("AutoHyphenate".into(), E::Bool(p.hyphenate)), - // Roman Hanging Punctuation; right after the leading keys, as Photoshop writes it. - ("Hanging".into(), E::Bool(p.hanging)), ("AutoLeading".into(), real(p.auto_leading)), ("LeadingType".into(), E::Int(0)), ]) diff --git a/crates/text/src/psd/import.rs b/crates/text/src/psd/import.rs index e4040b2bfe..0d0927f27f 100644 --- a/crates/text/src/psd/import.rs +++ b/crates/text/src/psd/import.rs @@ -298,9 +298,6 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { layer.transform.m = corrected; } } - if !super::usable_tysh_transform(&layer.transform.m) { - return None; - } layer.sync_summary(); Some(layer) } @@ -361,9 +358,6 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> { return None; } let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f]; - if !shifted.iter().all(|v| v.is_finite()) { - return None; - } let origin_distance = e.abs().max(f.abs()); let shifted_distance = shifted[0].abs().max(shifted[1].abs()); // A local bounding-box origin in the thousands and a transform origin beyond a normal canvas @@ -635,9 +629,6 @@ pub(crate) fn para_style(base: &[Option<&E>], d: &E, k: f32) -> ParagraphStyle { space_after_pt: numk("SpaceAfter", k).unwrap_or(0.0), auto_leading: numk("AutoLeading", 1.0).unwrap_or(1.2), hyphenate: lookup(d, base, "AutoHyphenate").and_then(E::as_bool).unwrap_or(false), - // Roman Hanging Punctuation: Photoshop writes `/Hanging` after `/LeadingType` - // (corpus PSDs), default off when missing. - hanging: lookup(d, base, "Hanging").and_then(E::as_bool).unwrap_or(false), ..Default::default() } } @@ -795,42 +786,3 @@ pub const OPENTYPE_KEYS: [(&str, &str); 8] = [ ("Ordinals", "ordn"), ("Fractions", "frac"), ]; - -#[cfg(test)] -mod tysh_geometry_tests { - use super::*; - use crate::psd::{usable_tysh_transform, write_tysh}; - use photocraft_psd::descriptor::UnicodeString; - - #[test] - fn overflowing_legacy_origin_does_not_escape_as_a_nonfinite_transform() { - let bounds = Descriptor::new("boundingBox") - .with("Left", D::Double(1.0e308)) - .with("Top ", D::Double(1.0e308)) - .with("Rght", D::Double(1.5e308)) - .with("Btom", D::Double(1.5e308)); - let text = Descriptor::new("TxLr").with("Txt ", D::Text(UnicodeString::new_nul("x"))).with("boundingBox", D::Descriptor(bounds)).with( - "EngineData", - D::RawData( - b"<< /EngineDict << /Rendered << /Shapes << /Children [ << /Cookie << /Photoshop << /PointBase [0.0 0.0] >> >> >> ] >> >> >> >>".to_vec(), - ), - ); - let tysh = TySh { transform: Affine { m: [1.0e9, 0.0, -1.0e9, 0.0, 5000.0, 0.0] }, text, warp: None, bounds: [0.0; 4] }; - assert!(engine_data(&tysh.text).is_some()); - assert!(legacy_point_origin(&tysh).is_none()); - let layer = text_layer_from_tysh(&write_tysh(&tysh), 72.0).unwrap(); - assert!(usable_tysh_transform(&layer.transform.m)); - } - - #[test] - fn reject_unusable_matrices_before_type_layout() { - let original = TySh { transform: Affine { m: [1.0, 0.0, 0.0, 1.0, 30.0, 40.0] }, text: Descriptor::new("TxLr"), warp: None, bounds: [0.0; 4] }; - let clean = write_tysh(&original); - assert_eq!(parse_tysh(&clean).unwrap().transform, original.transform); - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1.315776645269349e62] { - let mut bytes = clean.clone(); - bytes[2..10].copy_from_slice(&bad.to_be_bytes()); - assert!(parse_tysh(&bytes).is_none(), "invalid matrix must not be rendered: {bad}"); - } - } -} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 696507c732..9dd58a55d4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -368,349 +368,6 @@ fn alignment_point_and_box() { assert!((l.lines[0].x1 - 200.0).abs() < 0.5, "{:?}", l.lines[0]); } -/// Hanging punctuation (Roman Hanging Punctuation): trailing marks don't steer the alignment. -/// A centered line keeps its text centered and lets the marks hang right instead of pulling -/// everything left — a centered text ending in an ellipsis no longer sits off-center. -#[test] -fn hanging_marks_do_not_steer_the_alignment() { - let mut e = TextEngine::new(); - // The comma hangs fully and the ellipsis half (Photoshop's list), so the kept composition - // is "Hello" plus half the ellipsis. - let mut centered = - |hanging: bool| e.layout(&with_para(point("Hello,…", 20.0), ParagraphStyle { align: TextAlign::Center, hanging, ..Default::default() }), 72.0); - let off = centered(false); - let on = centered(true); - let lo = &off.lines[0]; - let ln = &on.lines[0]; - assert!(((lo.x0 + lo.x1) * 0.5).abs() < 0.01, "without the flag the whole line centers: {lo:?}"); - // With hanging, the composition centers: the drawn extent keeps the marks' full width, - // and dropping the trimmed advance (full mark + half mark) from the drawn line leaves - // content + kept halves centered on the anchor. - let comma = on.clusters.iter().find(|c| c.range.start == 5).unwrap_or_else(|| panic!("the comma is its own cluster: {:?}", on.clusters)); - let ell = on.clusters.iter().find(|c| c.range.start == 6).unwrap_or_else(|| panic!("the ellipsis is its own cluster: {:?}", on.clusters)); - let trim = comma.advance + ell.advance * 0.5; - assert!(((ln.x0 + ln.x1 - trim) * 0.5).abs() < 0.05, "the drawn line less the trimmed advance centers: {ln:?}"); - assert!(ln.x1 > comma.x, "the marks hang past the anchor: {ln:?}"); - assert!(ln.x0 > lo.x0 + 0.5, "the content sits further right than with the marks counted: {ln:?} vs {lo:?}"); - - // Right-aligned point text: the content stays flush at the anchor, marks hang past it. - let mut right = - |hanging: bool| e.layout(&with_para(point("Hello,…", 20.0), ParagraphStyle { align: TextAlign::Right, hanging, ..Default::default() }), 72.0); - let roff = right(false); - let ron = right(true); - assert!(roff.lines[0].x1.abs() < 0.01, "everything ends at the anchor: {:?}", roff.lines[0]); - assert!(ron.lines[0].x1 > 0.5, "the marks hang past the anchor: {:?}", ron.lines[0]); - assert!((ron.lines[0].x0 - roff.lines[0].x0 - trim).abs() < 0.05, "the whole line shifts by the trimmed advance: {:?}", ron.lines[0]); -} - -/// A hung centered line keeps its center even with inner kerning: the hung arms add -/// `kern_align` back, or the kerning runs twice. -#[test] -fn hanging_centers_a_kerned_line_exactly() { - let mut e = TextEngine::new(); - let mut boxed = |hanging: bool| { - let mut t = point("Hello,…", 24.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 400.0, height: 100.0 }; - // Manual kerning after every character of the single run (a quarter em each): inner - // kerning is what the hung arms must not apply twice. - t.runs = vec![TextRun { len: t.text.len(), style: CharStyle { kern: 250.0, ..Default::default() } }]; - e.layout(&with_para(t, ParagraphStyle { align: TextAlign::Center, hanging, ..Default::default() }), 72.0) - }; - let off = boxed(false); - let on = boxed(true); - let lo = &off.lines[0]; - let ln = &on.lines[0]; - // Without hanging, the kerned centered line centers: `kern_align` compensates parley's - // kern-blind offset. - assert!(((lo.x0 + lo.x1) * 0.5 - 200.0).abs() < 0.01, "{lo:?}"); - // With hanging, the drawn extent less the trimmed advance centers exactly. The hung arm - // adds `kern_align` back: its `slack` already deducts the kerning, so `base`'s shift - // would apply it twice and park the line left of centre. - let comma = on.clusters.iter().find(|c| c.range.start == 5).unwrap_or_else(|| panic!("the comma is its own cluster: {:?}", on.clusters)); - let ell = on.clusters.iter().find(|c| c.range.start == 6).unwrap_or_else(|| panic!("the ellipsis is its own cluster: {:?}", on.clusters)); - let trim = comma.advance + ell.advance * 0.5; - assert!(((ln.x0 + ln.x1 - trim) * 0.5 - 200.0).abs() < 0.05, "kerned hung line centers: {ln:?} trim {trim}"); -} - -/// Justify-all's last line spreads to the box edge exactly, even at horizontal scale ≠ 100%: -/// the slack counts the drawn width once (`extra` already carries the scale growth and the -/// kerning, so scaling `adv` would count the growth twice). A justify-all last line is -/// left-aligned in Photoshop, so its trailing mark stays inside for now — its trim belongs to -/// the left/justified measure gap, not to this PR's focused paths. -#[test] -fn justify_all_spreads_to_the_edge_exactly() { - let mut e = TextEngine::new(); - let mut boxed = |hs: f32, hanging: bool| { - let mut t = point("ab cd,", 24.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 400.0, height: 60.0 }; - t.runs = vec![TextRun { len: t.text.len(), style: CharStyle { horizontal_scale: hs, ..Default::default() } }]; - e.layout(&with_para(t, ParagraphStyle { align: TextAlign::JustifyAll, hanging, ..Default::default() }), 72.0) - }; - let end = |l: &crate::TextLayout| l.clusters.iter().filter(|c| c.line == 0).map(|c| c.x + c.advance).fold(0.0f32, f32::max); - let base = boxed(1.0, false); - assert_eq!(base.lines.len(), 1, "{base:?}"); - assert!((end(&base) - 400.0).abs() < 0.5, "spread to the edge: {}", end(&base)); - // At 150% the old slack scaled `adv` on top of the growth `extra` already carries: the - // line then fell short of the edge by the whole grown amount. - let scaled = boxed(1.5, false); - assert_eq!(scaled.lines.len(), 1, "{scaled:?}"); - assert!((end(&scaled) - 400.0).abs() < 0.5, "150% still spreads to the edge: {}", end(&scaled)); - let hung = boxed(1.0, true); - assert!((end(&hung) - 400.0).abs() < 0.5, "the mark stays inside for now: {}", end(&hung)); -} - -/// Photoshop's Roman Hanging Punctuation list doesn't include `!` and `?`: they stay in the -/// measure, a comma hangs fully and a colon or semicolon hangs half ("dashes hang 50%"). -#[test] -fn hanging_only_the_mark_list_trims() { - let mut e = TextEngine::new(); - let mut right = - |text: &str, hanging: bool| e.layout(&with_para(point(text, 20.0), ParagraphStyle { align: TextAlign::Right, hanging, ..Default::default() }), 72.0); - // `!` isn't in the list: hanging changes nothing. - let off = right("Hi!", false); - let on = right("Hi!", true); - assert_eq!(off.lines.len(), on.lines.len()); - let (lo, ln) = (&off.lines[0], &on.lines[0]); - assert!((lo.x0 - ln.x0).abs() < 1e-3 && (lo.x1 - ln.x1).abs() < 1e-3, "an exclamation stays in the measure: {lo:?} vs {ln:?}"); - - // A comma hangs fully: the content ends flush at the anchor and the whole mark sits - // past it. - right("Hi,", false); - let on = right("Hi,", true); - let ln = &on.lines[0]; - let comma = on.clusters.iter().find(|c| c.range.start == 2).unwrap_or_else(|| panic!("the comma is its own cluster: {:?}", on.clusters)); - assert!(comma.x.abs() < 0.05, "the content ends at the anchor: {ln:?}"); - assert!(ln.x1 > 0.5, "the full mark hangs past the anchor: {ln:?}"); - - // A colon hangs half: half its width stays in the measure (the mark straddles the - // anchor), half hangs out. - right("Hi:", false); - let on = right("Hi:", true); - let ln = &on.lines[0]; - let colon = on.clusters.iter().find(|c| c.range.start == 2).unwrap_or_else(|| panic!("the colon is its own cluster: {:?}", on.clusters)); - assert!((colon.x + colon.advance * 0.5).abs() < 0.05, "half the colon stays, half hangs: {ln:?} mark width {}", colon.advance); -} - -/// A marks-only row after a forced break is the author's row: the merge-back must not pull -/// those marks onto the line above, or the break would vanish. -#[test] -fn hanging_keeps_marks_after_a_forced_break_on_their_row() { - let mut e = TextEngine::new(); - let mut boxed = |hanging: bool| { - let mut t = point("abc\u{3}…", 16.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 300.0 }; - e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0) - }; - let off = boxed(false); - let on = boxed(true); - assert_eq!(off.lines.len(), on.lines.len(), "the forced break keeps its row: {:?} vs {:?}", off.lines, on.lines); - assert_eq!(on.lines.len(), 2, "two rows: {on:?}"); - let ell = on.clusters.iter().find(|c| c.range.start == 4).unwrap_or_else(|| panic!("the ellipsis is its own cluster: {:?}", on.clusters)); - assert_eq!(ell.line, 1, "the marks stay on the author's row: {ell:?}"); -} - -/// Hanging punctuation steps a wrapped run of marks back onto the line above: a box holding -/// text plus a mark nobody could fit keeps one row, the mark hanging past the box edge, and -/// the mark belongs to that row for carets and selection. -#[test] -fn a_hanging_run_steps_back_onto_the_line_above() { - let mut e = TextEngine::new(); - // A box just wide enough for "xx" (probed left-aligned at the same size). - let probe = e.layout(&point("xx", 20.0), 72.0); - let w = probe.lines[0].x1 - probe.lines[0].x0; - let mut boxed = |hanging: bool| { - let mut t = point("xx …", 20.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: w + 0.5, height: 100.0 }; - e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0) - }; - let off = boxed(false); - assert_eq!(off.lines.len(), 2, "the ellipsis wraps onto its own row: {:?}", off.lines); - let on = boxed(true); - assert_eq!(on.lines.len(), 1, "the mark steps back up: {:?}", on.lines); - let ln = &on.lines[0]; - assert!(ln.x1 > w + 0.5, "the mark hangs past the box edge: {ln:?}"); - assert_eq!(ln.range.end, 6, "the row's range now covers the mark ({})", ln.range.end); - let mark = on.clusters.iter().find(|c| c.range.start == 3).unwrap_or_else(|| panic!("the ellipsis is its own cluster: {:?}", on.clusters)); - // The row above keeps its trailing breaking space (as a blank cluster); the mark starts - // past it, so hit-testing can tell the space from the mark. - let space = on.clusters.iter().find(|c| c.range.start == 2).unwrap_or_else(|| panic!("the space is its own cluster: {:?}", on.clusters)); - assert!((mark.x - (space.x + space.advance)).abs() < 1e-3, "mark follows the space: {mark:?} {space:?}"); - assert_eq!(mark.line, 0, "the mark's caret lives on the merged row: {mark:?}"); - assert!(on.glyphs.iter().any(|g| g.x >= w && (g.y - ln.baseline).abs() < 1e-3), "the mark's glyphs draw on that row past the edge: {ln:?}"); -} - -/// The edge tip that steps back keeps its clusters and glyphs in step: each cluster starts -/// where the previous one's advance ended, and a mark's glyphs sit relative to its own cluster -/// (a per-cluster pen), not wherever a shared pen would drop them. -#[test] -fn a_stepped_mark_row_keeps_its_clusters_and_glyphs_in_step() { - let mut e = TextEngine::new(); - let probe = e.layout(&point("xx", 20.0), 72.0); - let w = probe.lines[0].x1 - probe.lines[0].x0; - let mut t = point("xx ……", 20.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: w + 0.5, height: 100.0 }; - let on = e.layout(&with_para(t, ParagraphStyle { hanging: true, ..Default::default() }), 72.0); - // The edge tip is one mark: the first ellipsis steps back up, the second keeps its row - // (Photoshop hangs the marks at the edge, not the run — a row that merges must not - // chain another one onto it, or pushed mark rows would fold runs into one line). - assert_eq!(on.lines.len(), 2, "the tip steps up, the rest keeps its row: {:?}", on.lines); - // The row above keeps its trailing breaking space (as a blank cluster); the mark starts - // past it, so hit-testing can tell the space from the mark. - - let space = on.clusters.iter().find(|c| c.range.start == 2).unwrap_or_else(|| panic!("the space is its own cluster: {:?}", on.clusters)); - let first = on.clusters.iter().find(|c| c.range.start == 3).unwrap_or_else(|| panic!("the first mark is its own cluster: {:?}", on.clusters)); - let second = on.clusters.iter().find(|c| c.range.start == 6).unwrap_or_else(|| panic!("the second mark is its own cluster: {:?}", on.clusters)); - assert_eq!(second.line, 1, "the second mark keeps its own row: {second:?}"); - assert!((first.x - (space.x + space.advance)).abs() < 1e-3, "run starts past the space: {first:?} {space:?}"); - // The same glyph in either cluster carries the same offset from its own cluster: a shared - // (run-wide) pen would leave the second mark's glyph an advance too far right. - let glyph_shift = |c: &crate::ClusterInfo| on.glyphs.iter().filter(|g| g.x >= c.x - 0.5).map(|g| g.x - c.x).min_by(|a, b| a.partial_cmp(b).unwrap()); - let d1 = glyph_shift(first).unwrap_or(f32::NAN); - let d2 = glyph_shift(second).unwrap_or(f32::NAN); - assert!((d1 - d2).abs() < 1e-3, "glyph pens belong to their clusters: {d1:?} vs {d2:?}"); -} - -/// A long run of marks the box had to split over several rows keeps its breaks with hanging -/// on: Photoshop hangs the edge tip, not the run, so each row keeps its own row and no line -/// overflows the box by almost the whole run. A full row of marks (more than two clusters) -/// does not merge back into the line above; only tipped rows of a couple of marks do. -#[test] -fn hanging_marks_of_a_split_run_keep_their_breaks() { - let mut e = TextEngine::new(); - let mut boxed = |hanging: bool| { - let mut t = point("Loading..................................", 16.0); // 34 dots - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 400.0 }; - e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0) - }; - let off = boxed(false); - assert!(off.lines.len() >= 2, "the run wraps over several rows: {:?}", off.lines); - let on = boxed(true); - assert!(on.lines.len() >= 2, "the breaks stay: {:?}", on.lines); - // No line runs past the box edge by more than an edge tip (a couple of marks): the fold - // that merged every row into one line overflowed by almost the whole run. - let adv = on.clusters.iter().map(|c| c.advance).fold(0.0f32, f32::max); - for (i, l) in on.lines.iter().enumerate() { - assert!(l.x1 - l.x0 <= 200.0 + 2.0 * adv + 0.5, "line {i} overflows the box past the tip: {l:?} (tip {adv})"); - } -} - -/// The edge-tip cap pins a case the chain guard alone doesn't: a run of marks pushed whole -/// as one row after a line that does NOT end in a mark. The chain guard only refuses the -/// second mark row onwards, so without the at-most-two-cluster cap that full row would step -/// back up and a `word` followed by thirty hyphens would make a 200 px box overflow by a -/// whole row's worth of marks. With the cap the rows keep their breaks, as with hanging off. -#[test] -fn a_full_mark_row_does_not_step_back_onto_a_short_line() { - let mut e = TextEngine::new(); - let hyphens = "word ------------------------------"; // 30 marks, 200 px holds ~28 - let mut boxed = |hanging: bool| { - let mut t = point(hyphens, 16.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 400.0 }; - e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0) - }; - let off = boxed(false); - assert!(off.lines.len() >= 3, "the run wraps over rows: {:?}", off.lines); - let on = boxed(true); - assert_eq!(on.lines.len(), off.lines.len(), "the full mark row keeps its break: {:?} vs {:?}", on.lines, off.lines); - for (a, b) in off.lines.iter().zip(&on.lines) { - assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3, "same rows either way: {a:?} vs {b:?}"); - } - let adv = on.clusters.iter().map(|c| c.advance).fold(0.0f32, f32::max); - for (i, l) in on.lines.iter().enumerate() { - assert!(l.x1 - l.x0 <= 200.0 + 2.0 * adv + 0.5, "line {i} steps a full mark row back up: {l:?} (tip {adv})"); - } -} - -/// The last line of a justified paragraph keeps its own last-line alignment: a marks-only -/// row that the box pushed out must not merge into the justified row above, whose placed -/// clusters already carry the spread — that would spread the real last line edge to edge -/// instead of centering, righting or lefting it. -#[test] -fn hanging_marks_stay_off_a_justified_paragraphs_last_line() { - let mut e = TextEngine::new(); - let probe = e.layout(&point("aa bb cc", 20.0), 72.0); - let w = probe.lines[0].x1 - probe.lines[0].x0; - let mut boxed = |align: TextAlign, hanging: bool| { - let mut t = point("aa bb cc …", 20.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: w + 1.0, height: 100.0 }; - e.layout(&with_para(t, ParagraphStyle { align, hanging, ..Default::default() }), 72.0) - }; - for (align, placed) in [(TextAlign::JustifyCenter, "centered"), (TextAlign::JustifyRight, "right"), (TextAlign::JustifyAll, "left")] { - let off = boxed(align, false); - let on = boxed(align, true); - assert_eq!(off.lines.len(), 2, "{align:?}: the ellipsis wraps to its own row: {off:?}"); - assert_eq!(on.lines.len(), off.lines.len(), "{align:?}: the mark row keeps its row: {on:?}"); - for (a, b) in off.lines.iter().zip(&on.lines) { - assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3, "{align:?}: the {placed} last line keeps its place: {a:?} vs {b:?}"); - } - // The last line sits where its alignment puts it, in either setting. - let ell = on.clusters.iter().find(|c| c.range.start == 9).unwrap_or_else(|| panic!("{align:?}: the ellipsis is its own cluster: {:?}", on.clusters)); - match align { - TextAlign::JustifyCenter => { - assert!((ell.x + ell.advance * 0.5 - (w + 1.0) * 0.5).abs() < 0.5, "{align:?}: the {placed} last line centers: {ell:?}") - } - TextAlign::JustifyRight => assert!((ell.x + ell.advance - (w + 1.0)).abs() < 0.5, "{align:?}: the {placed} last line rights: {ell:?}"), - _ => assert!(ell.x.abs() < 0.5, "{align:?}: the {placed} last line lefts: {ell:?}"), - } - } -} - -/// Hanging punctuation trims out of centered box lines too, not only ones at the margin: a -/// centered `Hello…` line that doesn't come near the box edges still centers its text and -/// lets the mark hang. -#[test] -fn hanging_centers_the_box_content_behind_its_marks() { - let mut e = TextEngine::new(); - let mut boxed = |hanging: bool| { - let mut t = point("Hello…", 20.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 400.0, height: 60.0 }; - e.layout(&with_para(t, ParagraphStyle { align: TextAlign::Center, hanging, ..Default::default() }), 72.0) - }; - let off = boxed(false); - let on = boxed(true); - let lo = &off.lines[0]; - let ln = &on.lines[0]; - assert_eq!(off.lines.len(), 1); - assert_eq!(on.lines.len(), 1); - assert!(((lo.x0 + lo.x1) * 0.5 - 200.0).abs() < 0.01, "without the flag the whole line centers: {lo:?}"); - // With hanging, the composition centers: dropping the kept advance (half the ellipsis) - // from the drawn extent leaves content + kept half centered in the box. - let ell = on.clusters.iter().find(|c| c.range.start == 5).unwrap_or_else(|| panic!("the mark is its own cluster: {:?}", on.clusters)); - let trim = ell.advance * 0.5; - assert!(((ln.x0 + ln.x1 - trim) * 0.5 - 200.0).abs() < 0.05, "drawn less trim centers: {ln:?} mark at {}", ell.x); - assert!(ln.x1 > ell.x, "the mark hangs past the content: {ln:?}"); -} - -/// Two forced breaks in a row keep their blank paragraph row with hanging on: a row with no -/// glyphs must not be read as a hanging run and merged into the line above. -#[test] -fn hanging_keeps_empty_rows_between_forced_breaks() { - let mut e = TextEngine::new(); - let mut boxed = |hanging: bool| { - let mut t = point("abc\u{3}\u{3}def", 16.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 300.0 }; - e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0) - }; - assert_eq!(boxed(false).lines.len(), boxed(true).lines.len(), "same rows either way"); -} - -/// Hanging changes nothing when no line ends in a mark: the same breaks at the same place. -#[test] -fn hanging_leaves_plain_paragraphs_untouched() { - let mut e = TextEngine::new(); - let mut boxed = |hanging: bool| { - let mut t = point("The quick brown fox jumps over the lazy dog", 16.0); - t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 160.0, height: 300.0 }; - e.layout(&with_para(t, ParagraphStyle { hanging, ..Default::default() }), 72.0) - }; - let off = boxed(false); - let on = boxed(true); - assert_eq!(off.lines.len(), on.lines.len(), "same breaks"); - for (a, b) in off.lines.iter().zip(&on.lines) { - assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3, "same place: {a:?} vs {b:?}"); - } -} - #[test] fn rtl_text_is_reordered() { let mut e = TextEngine::new(); @@ -2145,23 +1802,6 @@ fn legacy_origin_uses_the_rendering_engines_registered_face() { assert!((placed.x - 100.0).abs() < 1e-6 && (placed.y - 200.0).abs() < 1e-6, "{placed:?}"); } -/// Hanging travels in the text engine data under the paragraph sheet's `Hanging` key, written -/// explicitly on and off so Photoshop keeps the flag; the corpus files show it right after -/// `/LeadingType` (false), as Photoshop's own writer places it. -#[test] -fn hanging_punctuation_writes_into_the_paragraph_sheet() { - let raw = |t: &TextLayer, what: &str| { - let data = crate::engine_data::write(&crate::psd::build_engine_data(t, None, 72.0)); - String::from_utf8_lossy(&data).lines().find(|l| l.contains("Hanging")).unwrap_or("").to_owned() + format!(" ({what})").as_str() - }; - let on = with_para(point("Hello, world.", 12.0), ParagraphStyle { hanging: true, ..Default::default() }); - let got = raw(&on, "on"); - assert!(got.contains("/Hanging true"), "flag on: {got}"); - let off = with_para(point("Hello, world.", 12.0), ParagraphStyle::default()); - let got = raw(&off, "off"); - assert!(got.contains("/Hanging false"), "flag off: {got}"); -} - /// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary) /// panicked with `expect("EngineDict")` when the layer was written back (PSD export). #[test] diff --git a/crates/ui-egui/src/bidi_label.rs b/crates/ui-egui/src/bidi_label.rs deleted file mode 100644 index 1657177c29..0000000000 --- a/crates/ui-egui/src/bidi_label.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! RTL label regression compatibility for native egui paragraph ordering. -//! Keep logical text to prevent a second bidi pre-pass from reversing words twice. - -use std::borrow::Cow; - -/// Does `text` contain a character from a right-to-left script? -pub fn has_rtl(text: &str) -> bool { - text.chars().any(rtl_script) -} - -/// Characters egui shapes right to left: those of the right-to-left scripts (HarfRust guesses a -/// run's direction from its script), including their marks and digits. The joiners count too, -/// so a Persian word with a ZWNJ stays one stretch. -fn rtl_script(c: char) -> bool { - matches!( - c as u32, - 0x0590..=0x05FF // Hebrew - | 0x0600..=0x06FF // Arabic (letters, marks, Arabic-Indic and Persian digits) - | 0x0700..=0x074F // Syriac - | 0x0750..=0x077F // Arabic Supplement - | 0x0780..=0x07BF // Thaana - | 0x07C0..=0x07FF // NKo - | 0x0860..=0x08FF // Syriac Supplement, Arabic Extended-A/B - | 0xFB1D..=0xFB4F // Hebrew presentation forms - | 0xFB50..=0xFDFF // Arabic Presentation Forms-A - | 0xFE70..=0xFEFF // Arabic Presentation Forms-B - | 0x10800..=0x10FFF // Historic RTL scripts - | 0x1E800..=0x1EFFF // Mende Kikakui, Adlam, Arabic mathematical symbols - ) || matches!(c, '\u{200C}' | '\u{200D}') -} - -/// `text` arranged so that egui draws it in visual order. Text without right-to-left characters -/// is returned unchanged. -/// Native egui 0.37 at the pinned revision owns UAX #9 ordering. A second pre-pass would -/// reverse multiword RTL labels twice. Keep the logical text for shaping and accessibility. -pub fn for_egui(text: &str) -> Cow<'_, str> { - Cow::Borrowed(text) -} - -#[cfg(test)] -#[path = "bidi_label_tests.rs"] -mod tests; diff --git a/crates/ui-egui/src/bidi_label_tests.rs b/crates/ui-egui/src/bidi_label_tests.rs deleted file mode 100644 index cea99eb348..0000000000 --- a/crates/ui-egui/src/bidi_label_tests.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! The bidi pre-pass: string rules everywhere, and what egui actually draws when an Arabic or -//! Hebrew system font is installed. - -use super::*; -use egui::epaint::text::{FontData, FontDefinitions, FontFamily, Fonts, LayoutJob, TextFormat, TextOptions}; -use std::sync::Arc; - -#[test] -fn text_without_right_to_left_characters_is_untouched() { - for s in ["Layer 1", "", "Background copy 2", "图层 1", "Ебать"] { - assert!(matches!(for_egui(s), Cow::Borrowed(t) if t == s), "{s}"); - } -} - -#[test] -fn right_to_left_words_swap_places_but_keep_their_letters() { - // egui reverses each word's letters itself; only the word order changes here. - assert_eq!(for_egui("سلام عليكم"), "سلام عليكم"); - assert_eq!(for_egui("שלום עולם"), "שלום עולם"); - assert_eq!(for_egui("ئەمە تێستە بۆ ئیش"), "ئەمە تێستە بۆ ئیش"); - // One word: nothing to reorder. - assert_eq!(for_egui("طبقة"), "طبقة"); -} - -#[test] -fn numbers_and_latin_keep_reading_left_to_right() { - // European digits and Latin are runs of their own, in the right visual place. - assert_eq!(for_egui("عدد 123 مرحبا"), "عدد 123 مرحبا"); - assert_eq!(for_egui("طبقة 1 Layer"), "طبقة 1 Layer"); - // Arabic-Indic digits are Arabic-script, which egui reverses: pre-reversed so they read ١٢٣. - assert_eq!(for_egui("شلام ١٢٣ x"), "شلام ١٢٣ x"); - // A left-to-right label keeps its order; a number right after an Arabic word belongs to it - // (UAX #9 W2), as in browsers. - assert_eq!(for_egui("Layer طبقة"), "Layer طبقة"); - assert_eq!(for_egui("Layer طبقة 2"), "Layer طبقة 2"); -} - -#[test] -fn persian_joiners_stay_inside_their_word() { - // "می‌خواهم" has a ZWNJ: still one word, letters untouched. - assert_eq!(for_egui("می\u{200C}خواهم سلام"), "می\u{200C}خواهم سلام"); -} - -/// An installed font covering `sample`, if any of the usual ones is present. -fn system_font(candidates: &[&str]) -> Option> { - candidates.iter().find_map(|p| { - let bytes = std::fs::read(p).ok()?; - eprintln!("rtl-test font: {p}"); - Some(bytes) - }) -} - -const ARABIC_FONTS: [&str; 6] = [ - "/System/Library/Fonts/GeezaPro.ttc", - "/System/Library/Fonts/SFArabic.ttf", - "/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", - "/usr/share/fonts/noto/NotoSansArabic-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "C:\\Windows\\Fonts\\arial.ttf", -]; - -fn fonts_with(bytes: Vec) -> Fonts { - let mut defs = FontDefinitions::default(); - defs.font_data.insert("rtl-test".into(), Arc::new(FontData::from_owned(bytes))); - if let Some(family) = defs.families.get_mut(&FontFamily::Proportional) { - family.push("rtl-test".into()); - } - Fonts::new(TextOptions::default(), defs) -} - -/// x of each drawn (non-continuation) glyph, with the character it was attributed to. -fn drawn(fonts: &mut Fonts, text: &str) -> Vec<(char, f32)> { - let mut job = LayoutJob::default(); - job.append(text, 0.0, TextFormat { font_id: egui::FontId::proportional(20.0), ..Default::default() }); - let galley = fonts.with_pixels_per_point(1.0).layout_job(job); - galley.rows.iter().flat_map(|r| r.glyphs.iter()).filter(|g| !g.uv_rect.is_nothing()).map(|g| (g.chr, g.pos.x)).collect() -} - -fn x_of(glyphs: &[(char, f32)], c: char) -> f32 { - glyphs.iter().find(|(g, _)| *g == c).map(|(_, x)| *x).unwrap_or_else(|| panic!("{c} drawn: {glyphs:?}")) -} - -#[test] -fn arabic_draws_first_word_rightmost_with_letters_joined() { - let Some(bytes) = system_font(&ARABIC_FONTS) else { - eprintln!("skipped: no Arabic system font"); - return; - }; - let mut fonts = fonts_with(bytes); - let shown = drawn(&mut fonts, &for_egui("سلام عليكم")); - // The first word (سلام) is on the right: its first letter right of the second word's. - assert!(x_of(&shown, 'س') > x_of(&shown, 'ع'), "{shown:?}"); - // سلام draws as three glyphs (س, the لا ligature, م): the letters kept their logical order. - // Reversed letters (مالس) can't form the ligature and draw four. - let word = drawn(&mut fonts, &for_egui("سلام")); - let mirrored = drawn(&mut fonts, "مالس"); - assert!(word.len() < mirrored.len(), "ligature: {word:?} vs {mirrored:?}"); -} - -#[test] -fn hebrew_draws_first_word_rightmost() { - let Some(bytes) = system_font(&[ - "/System/Library/Fonts/SFHebrew.ttf", - "/System/Library/Fonts/ArialHB.ttc", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "C:\\Windows\\Fonts\\arial.ttf", - ]) else { - eprintln!("skipped: no Hebrew system font"); - return; - }; - let mut fonts = fonts_with(bytes); - let shown = drawn(&mut fonts, &for_egui("שלום עולם")); - // ש starts the first word, ע the second: ש is further right. - assert!(x_of(&shown, 'ש') > x_of(&shown, 'ע'), "{shown:?}"); -} - -#[test] -fn a_long_right_to_left_layer_name_keeps_its_beginning() { - let Some(bytes) = system_font(&ARABIC_FONTS) else { - eprintln!("skipped: no Arabic system font"); - return; - }; - let name = "سلام عليكم يا أصدقاء الطبقات"; - let mut defs = FontDefinitions::default(); - defs.font_data.insert("rtl-test".into(), Arc::new(FontData::from_owned(bytes))); - if let Some(family) = defs.families.get_mut(&FontFamily::Proportional) { - family.push("rtl-test".into()); - } - let long_fonts = defs.clone(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 100.0)).with_context_setup(move |ctx| ctx.set_fonts(long_fonts)).build_ui_state( - |ui, out: &mut Option>| { - *out = crate::layer_row_ui::truncated(ui.painter(), name, egui::FontId::proportional(20.0), egui::Color32::WHITE, false, 90.0); - }, - None, - ); - h.run_steps(3); - let g = h.state().clone().expect("room for a cut name"); - let shown = g.job.text.clone(); - assert!(g.size().x <= 90.0, "{} wide", g.size().x); - // Cut in logical order: the first word survives, and "…" sits at the reading end (left). - assert!(shown.ends_with('…') && shown.starts_with("سلام") && !shown.contains("الطبقات"), "{shown}"); - let glyphs: Vec<_> = g.rows.iter().flat_map(|r| r.glyphs.iter()).filter(|g| !g.uv_rect.is_nothing()).map(|g| (g.chr, g.pos.x)).collect(); - assert!(x_of(&glyphs, '…') < x_of(&glyphs, 'س'), "ellipsis stays at the visual reading end: {glyphs:?}"); - // Short enough to fit: drawn whole. - let mut short = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 100.0)).with_context_setup(move |ctx| ctx.set_fonts(defs)).build_ui_state( - |ui, out: &mut Option>| { - *out = crate::layer_row_ui::truncated(ui.painter(), "سلام عليكم", egui::FontId::proportional(20.0), egui::Color32::WHITE, false, 300.0); - }, - None, - ); - short.run_steps(1); - assert_eq!(short.state().as_ref().map(|g| g.job.text.clone()).as_deref(), Some("سلام عليكم")); -} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad8cd4987e..bbb53fb367 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1656,14 +1656,7 @@ fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut Vie // Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there. let t0 = crate::gpu_canvas::now_ms(); let before = view.center; - let mode = app.session.prefs().interface.scrollbar_visibility; - let visible = match mode { - photocraft_engine::prefs::ScrollbarVisibility::Hidden => false, - photocraft_engine::prefs::ScrollbarVisibility::AutoHide => ui.rect_contains_pointer(rect) || ui.input(|i| i.pointer.any_down()), - _ => true, - }; - let over_bars = visible - && app.ui.view.shows_scrollbars() + let over_bars = app.ui.view.shows_scrollbars() && crate::scrollbars::show(ui, rect, view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::unique(("pc-canvas", idx))); ctx.data_mut(|d| d.insert_temp(bars_id, over_bars)); if view.center != before { @@ -1962,8 +1955,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // (Space) and a middle-button drag still pan, and a secondary window's Hand never stops, as the // box is only drawn and driven on the primary canvas. let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle; - let middle_rotate = middle && ui.input(|i| i.modifiers.shift); - let hand_pan = tool == Tool::Hand && !transform_owns_pointer && !middle_rotate; + let hand_pan = tool == Tool::Hand && !transform_owns_pointer; // Preferences › Tools › Enable Flick Panning: a Hand drag let go while moving glides on. let flick_id = egui::Id::unique(("pc-flick-pan", idx, primary, ctx.viewport_id())); let flick_on = app.session.prefs().tools.enable_flick_panning; @@ -1980,14 +1972,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.data_mut(|m| m.get_temp_mut_or_default::(flick_id).stop()); } } - if middle_rotate && response.dragged() && !stroke_dismissal { - if let Some(pointer) = response.interact_pointer_pos() - && crate::rotate_view::middle_drag(&mut view, &xf, pointer, response.drag_delta().x) - { - ctx.request_repaint(); - } - ctx.set_cursor_icon(crate::rotate_view::cursor(true)); - } else if hand_pan && response.dragged() && !stroke_dismissal { + if hand_pan && response.dragged() && !stroke_dismissal { let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp); view.center[0] -= d.x; view.center[1] -= d.y; @@ -1995,7 +1980,7 @@ 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 primary && !stroke_dismissal && temporary.is_none() && !middle && crate::layer_picker::armed(app).is_some() { layer_picker_eyedropper(app, ui, &ctx, &response, &xf); - } else if primary && !stroke_dismissal && !middle_rotate { + } else if primary && !stroke_dismissal { let input = CanvasToolInput { ctx: &ctx, response: &response, diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 884ba271d7..9d5b84651f 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -210,10 +210,6 @@ const CROP_SHOW: &str = "crop.overlayShow."; pub const CROP_CLEAR: &str = "crop.clear"; pub const CROP_NEW: &str = "crop.newPreset"; pub const CROP_DELETE: &str = "crop.deletePreset"; -pub const CROP_CLASSIC: &str = "crop.overlay.classicMode"; -pub const CROP_SHOW_CROPPED: &str = "crop.overlay.showCroppedArea"; -pub const CROP_AUTO_CENTER: &str = "crop.overlay.autoCenterPreview"; -pub const CROP_RESET: &str = "crop.reset"; pub const CROP_CANCEL: &str = "crop.cancel"; pub const CROP_COMMIT: &str = "crop.commit"; @@ -237,13 +233,6 @@ pub fn crop_rows(menu: &CanvasToolMenu) -> Vec> { rows.push(None); rows.extend(OverlayShow::ALL.iter().map(|k| Some((k.label().to_string(), format!("{CROP_SHOW}{}", k.id()))))); rows.push(None); - rows.extend([ - Some(("Use Classic Mode".into(), CROP_CLASSIC.into())), - Some(("Show Cropped Area".into(), CROP_SHOW_CROPPED.into())), - Some(("Auto Center Preview".into(), CROP_AUTO_CENTER.into())), - None, - Some(("Reset".into(), CROP_RESET.into())), - ]); rows.push(Some(("Cancel".into(), CROP_CANCEL.into()))); rows.push(Some(("Crop".into(), CROP_COMMIT.into()))); rows @@ -270,12 +259,6 @@ fn crop_preset<'a>(menu: &'a CanvasToolMenu, command: &str) -> Option<&'a String /// the overlay and when it shows. fn crop_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { let o = &app.ui.tool_options; - match command { - CROP_CLASSIC => return o.crop_shield.classic_mode, - CROP_SHOW_CROPPED => return o.crop_shield.shows_cropped_area(), - CROP_AUTO_CENTER => return o.crop_shield.auto_center_preview, - _ => {} - } let user = crate::crop_presets::current_user(app); if let Some(k) = command.strip_prefix(CROP_RATIO) { return user.is_none() && k == o.crop_ratio; @@ -291,9 +274,6 @@ fn crop_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bo fn crop_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { match command { - CROP_CLASSIC => true, - CROP_SHOW_CROPPED | CROP_AUTO_CENTER => !app.ui.tool_options.crop_shield.classic_mode, - CROP_RESET | CROP_CANCEL | CROP_COMMIT => crate::crop_ui::pending(app), CROP_NEW => crate::crop_presets::savable(&app.ui.tool_options).is_some(), CROP_DELETE => menu.crop.as_ref().is_some_and(|u| !u.is_empty()), c => is_crop(menu, c), @@ -333,21 +313,6 @@ fn choose_crop(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> use crate::crop_overlay::{CropOverlay, OverlayShow}; use crate::crop_presets::{Choice, apply_choice}; match command { - CROP_RESET => crate::crop_ui::reset(app), - CROP_CLASSIC => { - app.ui.tool_options.crop_shield.classic_mode = !app.ui.tool_options.crop_shield.classic_mode; - crate::crop_mode::sync(app, None); - } - CROP_SHOW_CROPPED => { - if !app.ui.tool_options.crop_shield.classic_mode { - app.ui.tool_options.crop_shield.show_cropped_area = !app.ui.tool_options.crop_shield.show_cropped_area; - } - } - CROP_AUTO_CENTER => { - if !app.ui.tool_options.crop_shield.classic_mode { - app.ui.tool_options.crop_shield.auto_center_preview = !app.ui.tool_options.crop_shield.auto_center_preview; - } - } CROP_CANCEL => crate::crop_ui::cancel(app), CROP_COMMIT => crate::canvas::commit_crop(app), CROP_NEW => apply_choice(app, Choice::New), diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index fa241668b4..6f8d0a3aa8 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -1218,130 +1218,3 @@ fn crop_menu_rows_act_like_the_options_bar() { choose(&mut app, &ctx, CROP_CANCEL); assert!(!crate::crop_ui::pending(&app)); } - -/// The Crop tool with a real pending frame on the 32 x 32 document. -fn integration_crop_app() -> PhotocraftApp { - let mut app = app(); - app.ui.tool = Tool::Crop; - app.ui.crop_rect = Some([10.0, 5.0, 30.0, 25.0]); - app.crop.default_frame = false; - app.crop.editing = true; - assert!(crate::crop_ui::pending(&app)); - app -} - -fn crop_open(app: &mut PhotocraftApp) { - assert!(open_crop(app, &Context::default(), Tool::Crop, [10.0, 10.0])); -} - -fn frame_ratio(app: &PhotocraftApp) -> f64 { - let r = app.ui.crop_rect.unwrap(); - (r[2] - r[0]) / (r[3] - r[1]) -} - -#[test] -fn crop_menu_ratio_rows_fit_the_frame_like_the_options_bar() { - let mut app = integration_crop_app(); - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert!(menu.crop.is_some() && available(&app, &menu, "crop.ratio.16:9")); - choose(&mut app, &Context::default(), "crop.ratio.16:9"); - assert_eq!(app.ui.tool_options.crop_ratio, "16:9"); - assert!((frame_ratio(&app) - 16.0 / 9.0).abs() < 1e-9, "the frame is fitted to the ratio"); - // The current ratio shows checked; the others do not. - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert!(mode_checked(&app, &menu, "crop.ratio.16:9") && !mode_checked(&app, &menu, "crop.ratio.1:1")); - choose(&mut app, &Context::default(), "crop.ratio.1:1"); - assert!((frame_ratio(&app) - 1.0).abs() < 1e-9); -} - -#[test] -fn crop_menu_gear_rows_toggle_the_shield_options() { - let mut app = integration_crop_app(); - crop_open(&mut app); - choose(&mut app, &Context::default(), CROP_SHOW_CROPPED); - assert!(!app.ui.tool_options.crop_shield.show_cropped_area); - crop_open(&mut app); - assert!(!mode_checked(&app, app.ui.canvas_tool_menu.as_ref().unwrap(), CROP_SHOW_CROPPED), "the row shows the option's state"); - choose(&mut app, &Context::default(), CROP_CLASSIC); - assert!(app.ui.tool_options.crop_shield.classic_mode); - // As in the gear menu, Classic Mode greys out the other two options. - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert!(!entry_enabled(&app, &menu, CROP_SHOW_CROPPED) && !entry_enabled(&app, &menu, CROP_AUTO_CENTER)); - assert!(entry_enabled(&app, &menu, CROP_CLASSIC)); - choose(&mut app, &Context::default(), CROP_CLASSIC); - assert!(!app.ui.tool_options.crop_shield.classic_mode); -} - -#[test] -fn crop_menu_reset_cancel_and_commit_act_on_the_frame() { - // Reset: the whole canvas, still a pending frame. - let mut app = integration_crop_app(); - crop_open(&mut app); - choose(&mut app, &Context::default(), CROP_RESET); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 32.0, 32.0])); - assert!(crate::crop_ui::pending(&app), "Reset keeps the crop active, as in Photoshop"); - // Cancel: the frame is dropped (the tool's next frame is the default one). - crop_open(&mut app); - choose(&mut app, &Context::default(), CROP_CANCEL); - assert!(app.ui.crop_rect.is_none() && !crate::crop_ui::pending(&app)); - // Commit: the document is cropped to the frame. - let mut app = integration_crop_app(); - crop_open(&mut app); - choose(&mut app, &Context::default(), CROP_COMMIT); - assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(20, 20)); - assert!(app.ui.crop_rect.is_none()); -} - -#[test] -fn crop_menu_lists_saved_presets_and_applies_one() { - let mut app = integration_crop_app(); - app.run("tool.presets.new", json!({"name": "Wide", "tool": "crop", "options": {"toolOptions": {"crop_ratio": "16:9"}}})).unwrap(); - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert_eq!(menu.crop.clone().unwrap_or_default(), ["Wide"]); - assert!(available(&app, &menu, "crop.preset.0")); - assert!(crop_rows(&menu).iter().flatten().any(|(label, _)| label == "Wide"), "the preset is a row: {:?}", crop_rows(&menu)); - choose(&mut app, &Context::default(), "crop.preset.0"); - // As in the options bar's dropdown: the preset fills the fields (the frame follows on the - // next resize or ratio apply). - assert_eq!(app.ui.tool_options.crop_ratio, "16:9"); -} - -#[test] -fn crop_menu_closes_when_the_crop_is_no_longer_pending() { - let mut app = integration_crop_app(); - crop_open(&mut app); - assert!(app.ui.canvas_tool_menu.is_some()); - crate::crop_ui::cancel(&mut app); - show(&mut app, &Context::default()); - assert!(app.ui.canvas_tool_menu.is_none(), "the menu does not outlive the pending frame"); -} - -#[test] -fn right_click_on_a_pending_crop_opens_the_crop_menu() { - let app = integration_crop_app(); - let mut h = harness(app); - let center = h.state().last_canvas_rect.center(); - right_click(&mut h, center, Modifiers::NONE); - let menu = h.state().ui.canvas_tool_menu.clone().expect("right-click on a pending crop opens a menu"); - assert!(menu.crop.is_some() && !menu.pasteboard && !menu.transform); - let ctx = h.ctx.clone(); - choose(h.state_mut(), &ctx, "crop.ratio.16:9"); - let r = h.state().ui.crop_rect.unwrap(); - assert!(((r[2] - r[0]) / (r[3] - r[1]) - 16.0 / 9.0).abs() < 1e-9, "choosing 16:9 refits the frame"); -} - -#[test] -fn right_click_without_a_pending_crop_opens_no_menu() { - let mut app = app(); - app.ui.tool = Tool::Crop; - crate::crop_ui::ensure_frame(&mut app); - assert!(app.ui.crop_rect.is_some() && !crate::crop_ui::pending(&app), "the untouched default frame is not pending"); - let mut h = harness(app); - let center = h.state().last_canvas_rect.center(); - right_click(&mut h, center, Modifiers::NONE); - assert!(h.state().ui.canvas_tool_menu.is_none(), "no menu on the untouched default frame"); -} diff --git a/crates/ui-egui/src/canvas/rotate_view.rs b/crates/ui-egui/src/canvas/rotate_view.rs index 00b69b8e3c..8b6706327f 100644 --- a/crates/ui-egui/src/canvas/rotate_view.rs +++ b/crates/ui-egui/src/canvas/rotate_view.rs @@ -163,31 +163,6 @@ pub fn trackpad_delta(ctx: &egui::Context, enabled: bool, touch_gestures: bool) apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0) } -/// Shift + middle-drag rotates the view instead of panning. Keep the document point -/// under the pointer in place, including when the view is flipped or stretched. -pub fn middle_drag(view: &mut View, xf: &ViewXform, pointer: Pos2, delta_x: f32) -> bool { - if !pointer.x.is_finite() || !pointer.y.is_finite() || !delta_x.is_finite() || delta_x == 0.0 { - return false; - } - let current = ViewXform { center: view.center, rotation: view.rotation, ..*xf }; - let before = current.to_doc(pointer); - let rotation = wrap_deg(view.rotation + delta_x * 0.35); - if rotation == view.rotation { - return false; - } - let after = ViewXform { rotation, ..current }.to_doc(pointer); - if !before.into_iter().chain(after).all(f64::is_finite) { - return false; - } - let center = [view.center[0] + (before[0] - after[0]) as f32, view.center[1] + (before[1] - after[1]) as f32]; - if !center.into_iter().all(f32::is_finite) { - return false; - } - view.center = center; - view.rotation = rotation; - true -} - /// `view.rotateView`: set or add degrees on the active document's camera. pub fn command(app: &mut PhotocraftApp, p: &Value) -> Result { let i = app.session.active_index().ok_or("no document open")?; diff --git a/crates/ui-egui/src/canvas/rotate_view/tests.rs b/crates/ui-egui/src/canvas/rotate_view/tests.rs index a9fb172bb4..2705c28ac9 100644 --- a/crates/ui-egui/src/canvas/rotate_view/tests.rs +++ b/crates/ui-egui/src/canvas/rotate_view/tests.rs @@ -3,32 +3,6 @@ use egui::{Event, PointerButton, pos2}; use egui_kittest::Harness; use serde_json::json; -#[test] -fn middle_drag_rotates_about_the_pointer_without_panning() { - let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); - let pointer = pos2(600.0, 180.0); - for flip in [false, true] { - let mut view = View { zoom: 2.0, center: [130.0, 80.0], ..Default::default() }; - let xf = ViewXform { rect, zoom: 2.0, center: view.center, flip, rotation: 0.0, aspect: 1.25 }; - let original = xf.to_doc(pointer); - assert!(middle_drag(&mut view, &xf, pointer, 90.0)); - assert!((view.rotation - 31.5).abs() < 0.001); - let next = ViewXform { center: view.center, rotation: view.rotation, ..xf }.to_doc(pointer); - assert!((next[0] - original[0]).abs() < 0.001 && (next[1] - original[1]).abs() < 0.001, "{original:?} {next:?}"); - } -} - -#[test] -fn middle_drag_ignores_invalid_or_stationary_input() { - let mut view = View::default(); - let xf = - ViewXform { rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)), zoom: 1.0, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 }; - assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), 0.0)); - assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), f32::NAN)); - assert!(!middle_drag(&mut view, &xf, pos2(f32::INFINITY, 120.0), 20.0)); - assert_eq!(view.rotation, 0.0); -} - #[test] fn wrap_deg_folds_huge_and_hostile_angles() { assert_eq!(wrap_deg(0.0), 0.0); diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs index ce1ae599b5..fa87ebbf92 100644 --- a/crates/ui-egui/src/canvas/rulers.rs +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -108,22 +108,17 @@ pub struct GuideDrag { /// Whether the pointer was over a ruler when the guide was let go (#6286): a guide released /// there is removed, whatever its own axis reads. pub over_ruler: bool, - pub flipped: bool, + pub flipped:bool, } impl GuideDrag { - pub fn shows_vertical(&self) -> bool { - self.vertical != self.flipped - } - pub fn track(&mut self, at: [f64; 2], alt: bool) { - self.flipped = alt; - self.pos = if self.shows_vertical() { at[0] } else { at[1] }; - } + pub fn shows_vertical(&self)->bool {self.vertical!=self.flipped} + pub fn track(&mut self,at:[f64;2],alt:bool) {self.flipped=alt;self.pos=if self.shows_vertical(){at[0]}else{at[1]};} /// An existing guide taken at document point `p`. pub fn existing(vertical: bool, index: usize, p: [f64; 2]) -> Self { let at = if vertical { p[0] } else { p[1] }; - Self { vertical, index: Some(index), pos: at, press: at, over_ruler: false, flipped: false } + Self { vertical, index: Some(index), pos: at, press: at, over_ruler: false, flipped:false } } } @@ -302,14 +297,13 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { // A drop on a ruler removes the guide even when the drag held its own axis, which otherwise // reads as a click: dragged sideways onto the left ruler, a horizontal guide never moves in y. if let Some(i) = d.index - && !d.over_ruler - && !d.flipped + && !d.over_ruler && !d.flipped && (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5 { crate::guide_select::select(app, d.vertical, i); return; } - let shows = d.shows_vertical(); + let shows=d.shows_vertical(); let extent = if shows { size.width } else { size.height } as f64; let orientation = if d.vertical { "vertical" } else { "horizontal" }; let inside = !d.over_ruler && d.pos >= 0.0 && d.pos <= extent; @@ -318,14 +312,7 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { (None, true) => app.run("view.newGuide", json!({"orientation": if shows {"vertical"}else{"horizontal"}, "position": pos})), (Some(i), true) => app .run("view.moveGuide", json!({"orientation": orientation,"toOrientation":if shows {"vertical"}else{"horizontal"}, "index": i, "position": pos})) - .inspect(|_| { - let index = if d.flipped { - app.session.active().map_or(0, |st| if shows { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }).saturating_sub(1) - } else { - i - }; - crate::guide_select::select(app, shows, index); - }), + .inspect(|_| {let index=if d.flipped {app.session.active().map_or(0,|st|if shows {st.doc.guides.vertical.len()}else{st.doc.guides.horizontal.len()}).saturating_sub(1)} else {i};crate::guide_select::select(app,shows,index);}), (Some(i), false) => app.run("view.deleteGuide", json!({"orientation": orientation, "index": i})), (None, false) => Ok(serde_json::Value::Null), }; @@ -537,7 +524,7 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let d = xf.to_doc(pos); let along = if vertical { d[0] } else { d[1] }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; - app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos, over_ruler: false, flipped: false }); + app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos, over_ruler: false, flipped:false }); } if resp.drag_stopped() && let Some(mut d) = app.guide_drag.take() diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index f3791f23eb..de5f2b8cab 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -192,9 +192,9 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui:: if let ToolEvent::Down { x, y, .. } = raw && let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods) { - let mut guide = crate::rulers::GuideDrag::existing(vertical, i, [x, y]); - guide.track([x, y], mods.alt); - app.guide_drag = Some(guide); + let mut guide=crate::rulers::GuideDrag::existing(vertical,i,[x,y]); + guide.track([x,y],mods.alt); + app.guide_drag=Some(guide); crate::snap_ui::begin_guide(app, [x, y]); return None; } @@ -217,9 +217,9 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods: crate::guide_select::flip(app, vertical, i, [x, y]); return true; } - let mut guide = crate::rulers::GuideDrag::existing(vertical, i, [x, y]); - guide.track([x, y], mods.alt); - app.guide_drag = Some(guide); + let mut guide=crate::rulers::GuideDrag::existing(vertical,i,[x,y]); + guide.track([x,y],mods.alt); + app.guide_drag=Some(guide); return true; } // Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first @@ -278,14 +278,14 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods: } ToolEvent::Move { x, y, .. } => { if let Some(d) = app.guide_drag.as_mut().filter(|d| d.index.is_some()) { - d.track([x, y], mods.alt); + d.track([x,y],mods.alt); return true; } } ToolEvent::Up { x, y } => { if let Some(mut d) = app.guide_drag.filter(|d| d.index.is_some()) { app.guide_drag = None; - d.track([x, y], mods.alt); + d.track([x,y],mods.alt); crate::rulers::finish_drag(app, d); return true; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 753a855ba5..755a8217ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1092,7 +1092,6 @@ pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) pub mod adjust_dialog; pub mod adjust_editors; -pub(crate) mod adjust_presets; pub mod adjust_preview; pub(crate) mod bitmap_dialog; pub mod blur_pins; diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index 8f0bcdb35c..d3bcf2b688 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -405,54 +405,6 @@ fn curves_pencil_drag_draws_the_map_and_confirm_applies_it() { assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); } -/// #9263, #9265, #9268, #9270: Levels, Exposure, Black & White and Channel Mixer open on the -/// Default preset; choosing another loads its settings (the preview follows) and an edit by -/// hand reads Custom. -#[test] -fn adjustment_preset_menus_load_presets_and_read_custom_after_an_edit() { - use egui_kittest::kittest::{NodeT, Queryable}; - for (kind, label, key) in [ - ("levels", "Increase Contrast 2", "inBlack"), - ("exposure", "Plus 1.0", "exposure"), - ("blackWhite", "Red Filter", "reds"), - ("channelMixer", "Black & White with Red Filter (RGB)", "monochrome"), - ] { - 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_with_image()); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); - let id = open(h.state_mut(), &format!("image.adjustments.{kind}")).unwrap(); - h.run_steps(4); - let preset = |h: &Harness<'_, PhotocraftApp>| { - h.query_all_by_role(egui::accesskit::Role::ComboBox).next().and_then(|n| n.accesskit_node().value().map(|s| s.to_string())) - }; - assert_eq!(preset(&h).as_deref(), Some("Default"), "{kind} opens on Default"); - h.query_all_by_role(egui::accesskit::Role::ComboBox).next().unwrap().click(); - h.run_steps(2); - h.get_by_label(label).click(); - h.run_steps(3); - assert_eq!(preset(&h).as_deref(), Some(label), "{kind}: the menu shows the preset"); - let want = crate::adjust_presets::presets(kind, false) - .iter() - .find(|p| p.label == label) - .and_then(|p| crate::adjust_presets::settings(kind, p, false)) - .unwrap(); - let fields = &h.state().ui.dialogs[0].fields; - for (k, v) in want.as_object().expect("preset settings") { - assert_eq!(fields[k], *v, "{kind}: the preset's `{k}` is loaded"); - } - assert_eq!(fields[key], want[key]); - let edit = match kind { - "levels" => ("gamma", json!(1.37)), - "exposure" => ("offset", json!(0.05)), - "blackWhite" => ("blues", json!(33)), - _ => ("gray", json!([90, 10, 0, 0])), - }; - h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(edit.0.into(), edit.1); - h.run_steps(3); - assert_eq!(preset(&h).as_deref(), Some("Custom"), "{kind}: an edit is Custom"); - crate::dialogs::confirm(h.state_mut(), id).unwrap(); - } -} - #[test] fn curves_pointer_drag_previews_until_confirm_and_cancel_preserves_document() { for confirm in [false, true] { diff --git a/crates/ui-egui/src/dialogs/adjust_editors.rs b/crates/ui-egui/src/dialogs/adjust_editors.rs index c148ae01f1..cdee0d6502 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors.rs @@ -139,43 +139,6 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Ed if !v.is_object() { *v = json!({}); } - let mut e = preset_row(ui, kind, v, cx); - e.add(kind_editor(ui, kind, v, cx)); - e -} - -/// The Preset menu above Levels, Exposure, Black & White and Channel Mixer (#9263, #9265, #9268, -/// #9270): PhotoCraft's own presets (`adjust_presets`), Custom once edited; a choice is one edit. -fn preset_row(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Edit { - let list = crate::adjust_presets::presets(kind, cx.gray); - if list.is_empty() || (kind == "levels" && space_of(v) != ToneSpace::Rgb) { - return Edit::default(); - } - let mut e = Edit::default(); - ui.horizontal(|ui| { - // The collection is PhotoCraft's own (adjust_presets): the caption says so, so a tutorial's - // preset of the same name isn't taken for this one. - let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(tl!("PhotoCraft Preset:")).color(t.text_dim).size(12.0)) - .on_hover_text(tl!("PhotoCraft's own settings for these looks, not Photoshop's presets of the same names.")); - let current = crate::adjust_presets::matching(kind, v, cx.gray).map_or("custom", |p| p.id); - let mut options: Vec<(&str, &str)> = list.iter().map(|p| (p.id, tl!(p.label))).collect(); - if current == "custom" { - options.push(("custom", tl!("Custom"))); - } - let mut picked = current; - if widgets::dropdown(ui, &format!("{kind}-preset"), tl!("Preset"), &mut picked, &options, ui.available_width().min(220.0)) - && picked != current - && let Some(settings) = list.iter().find(|p| p.id == picked).and_then(|p| crate::adjust_presets::settings(kind, p, cx.gray)) - { - *v = settings; - e.add(Edit::discrete(true)); - } - }); - e -} - -fn kind_editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Edit { match kind { "brightnessContrast" => brightness_contrast(ui, v), "levels" => levels(ui, v, cx), diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index a4569b827d..2bbff7d596 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -912,48 +912,3 @@ fn row_values_round_to_their_own_decimals() { assert!((row_value(1.234, 0.01, 9.99, None) - 1.23).abs() < 1e-6, "small ranges keep two decimals"); assert!(row_value(f32::NAN, -20.0, 20.0, Some(u32::MAX)).is_nan(), "no panic on odd input"); } - -/// CodeRabbit on #9282: the Properties panel's Preset menu loads every setting of each preset -/// into the adjustment layer, in one history step. -#[test] -fn properties_preset_menus_load_every_setting_in_one_step() { - use egui_kittest::kittest::NodeT; - for (kind, label) in - [("levels", "Increase Contrast 2"), ("exposure", "Plus 1.0"), ("blackWhite", "Red Filter"), ("channelMixer", "Black & White with Red Filter (RGB)")] - { - let mut h = app_harness(kind, "rgb", 8); - let steps = h.state().session.active().unwrap().history.past_len(); - let menu = h - .query_all_by_role(egui::accesskit::Role::ComboBox) - .find(|n| n.accesskit_node().value().as_deref() == Some("Default")) - .unwrap_or_else(|| panic!("{kind}: the Preset menu, at Default")); - menu.click(); - h.run_steps(2); - h.get_by_label(label).click(); - h.run_steps(4); - let want = crate::adjust_presets::presets(kind, false) - .iter() - .find(|p| p.label == label) - .and_then(|p| crate::adjust_presets::settings(kind, p, false)) - .unwrap(); - let got = photocraft_engine::adjust_params::to_params(&layer(&h).1); - for (k, v) in want.as_object().expect("preset settings") { - assert_eq!(got[k], *v, "{kind}: the layer's `{k}`"); - } - assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "{kind}: one step"); - } -} - -/// FredApps on #9282: the menu says these are PhotoCraft's own presets, not Photoshop's presets of -/// the same names (whose settings differ), so a tutorial's "Increase Contrast 1" isn't mistaken -/// for this one. -#[test] -fn preset_menus_say_they_are_photocrafts_own() { - for kind in ["levels", "exposure", "blackWhite", "channelMixer"] { - let mut h = app_harness(kind, "rgb", 8); - let caption = h.get_by_label("PhotoCraft Preset:"); - caption.hover(); - h.run_steps(30); - assert!(h.query_by_label_contains("not Photoshop's presets").is_some(), "{kind}: the caption explains the presets are PhotoCraft's own"); - } -} diff --git a/crates/ui-egui/src/dialogs/adjust_presets.rs b/crates/ui-egui/src/dialogs/adjust_presets.rs deleted file mode 100644 index 406fb406cd..0000000000 --- a/crates/ui-egui/src/dialogs/adjust_presets.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Preset menus for Levels, Exposure, Black & White and Channel Mixer (#9263, #9265, #9268, -//! #9270): starting settings named for the looks they give, as the Preset menu at the top of -//! these adjustments in Photoshop. -//! -//! The settings are PhotoCraft's own. Each was designed for its look: Levels presets lift, lower or -//! stretch the tones; Exposure presets are whole stops; the Black & White "filter" presets follow -//! what a coloured lens filter does on black-and-white film (it lightens its own colour and darkens -//! its complement); the Channel Mixer presets mix the channels a filter would let through. None is -//! taken or measured from another application's preset files. - -use photocraft_doc::ColorMode; -use photocraft_engine::adjust_params::{from_params, to_params}; -use serde_json::{Value, json}; - -/// A preset: its id (the menu value), label and the adjustment params it sets (the rest default). -pub struct AdjustPreset { - pub id: &'static str, - pub label: &'static str, - pub params: fn() -> Value, -} - -macro_rules! preset { - ($id:literal, $label:literal, $params:tt) => { - AdjustPreset { id: $id, label: $label, params: || json!($params) } - }; -} - -const LEVELS: &[AdjustPreset] = &[ - preset!("default", "Default", {}), - // The whole image darker: the white output comes down. - preset!("darker", "Darker", {"outWhite": 220}), - // Black and white input points pulled in, a step further each time. - preset!("increaseContrast1", "Increase Contrast 1", {"inBlack": 12, "inWhite": 243}), - preset!("increaseContrast2", "Increase Contrast 2", {"inBlack": 24, "inWhite": 231}), - preset!("increaseContrast3", "Increase Contrast 3", {"inBlack": 36, "inWhite": 219}), - // Shadows lifted off black and opened up a little; highlights left where they are. - preset!("lightenShadows", "Lighten Shadows", {"outBlack": 20, "gamma": 1.1}), - // The whole image lighter: the white input point comes in. - preset!("lighter", "Lighter", {"inWhite": 220}), - // The midtones alone, up or down; black and white stay put. - preset!("midtonesBrighter", "Midtones Brighter", {"gamma": 1.3}), - preset!("midtonesDarker", "Midtones Darker", {"gamma": 0.77}), -]; - -const EXPOSURE: &[AdjustPreset] = &[ - preset!("default", "Default", {}), - preset!("minus1", "Minus 1.0", {"exposure": -1.0}), - preset!("minus2", "Minus 2.0", {"exposure": -2.0}), - preset!("plus1", "Plus 1.0", {"exposure": 1.0}), - preset!("plus2", "Plus 2.0", {"exposure": 2.0}), -]; - -// Black & White weights per colour range: reds, yellows, greens, cyans, blues, magentas. -const BLACK_WHITE: &[AdjustPreset] = &[ - preset!("default", "Default", {}), - // A blue filter: blues and cyans light, reds and yellows dark. - preset!("blueFilter", "Blue Filter", {"reds": 0, "yellows": 20, "greens": 40, "cyans": 100, "blues": 110, "magentas": 80}), - // Every colour a little darker. - preset!("darker", "Darker", {"reds": 20, "yellows": 40, "greens": 20, "cyans": 40, "blues": 0, "magentas": 60}), - // A green filter: greens light (foliage), reds and magentas dark. - preset!("greenFilter", "Green Filter", {"reds": 10, "yellows": 80, "greens": 110, "cyans": 60, "blues": 20, "magentas": 0}), - // The blue filter pushed further: deep reds, glowing blues. - preset!("highContrastBlueFilter", "High Contrast Blue Filter", {"reds": -40, "yellows": 0, "greens": 40, "cyans": 140, "blues": 160, "magentas": 100}), - // The red filter pushed further: near-white reds, near-black blue skies. - preset!("highContrastRedFilter", "High Contrast Red Filter", {"reds": 140, "yellows": 120, "greens": 0, "cyans": -40, "blues": -60, "magentas": 80}), - // Infrared film: foliage greens and yellows glow, skies go dark. - preset!("infrared", "Infrared", {"reds": -20, "yellows": 190, "greens": 200, "cyans": -30, "blues": -60, "magentas": 40}), - // Every colour a little lighter. - preset!("lighter", "Lighter", {"reds": 60, "yellows": 80, "greens": 60, "cyans": 80, "blues": 40, "magentas": 100}), - // Colours as dark as they go. - preset!("maximumBlack", "Maximum Black", {"reds": 0, "yellows": 0, "greens": 0, "cyans": 0, "blues": 0, "magentas": 0}), - // Colours as light as they go. - preset!("maximumWhite", "Maximum White", {"reds": 150, "yellows": 150, "greens": 150, "cyans": 150, "blues": 150, "magentas": 150}), - // A neutral filter: every colour weighed the same. - preset!("neutralDensity", "Neutral Density", {"reds": 50, "yellows": 50, "greens": 50, "cyans": 50, "blues": 50, "magentas": 50}), - // A red filter: reds and yellows light, blues and cyans dark (dramatic skies). - preset!("redFilter", "Red Filter", {"reds": 110, "yellows": 100, "greens": 0, "cyans": -20, "blues": -30, "magentas": 70}), - // A yellow filter: a gentle darkening of blue skies. - preset!("yellowFilter", "Yellow Filter", {"reds": 70, "yellows": 100, "greens": 50, "cyans": 30, "blues": 0, "magentas": 50}), -]; - -// Channel Mixer monochrome mixes `[red %, green %, blue %, constant %]`: what a filter lets through. -const CHANNEL_MIXER: &[AdjustPreset] = &[ - preset!("default", "Default", {}), - preset!("bwInfrared", "Black & White Infrared (RGB)", {"monochrome": true, "gray": [-60, 190, -30, 0]}), - preset!("bwBlueFilter", "Black & White with Blue Filter (RGB)", {"monochrome": true, "gray": [0, 0, 100, 0]}), - preset!("bwGreenFilter", "Black & White with Green Filter (RGB)", {"monochrome": true, "gray": [0, 100, 0, 0]}), - preset!("bwOrangeFilter", "Black & White with Orange Filter (RGB)", {"monochrome": true, "gray": [50, 50, 0, 0]}), - preset!("bwRedFilter", "Black & White with Red Filter (RGB)", {"monochrome": true, "gray": [100, 0, 0, 0]}), - preset!("bwYellowFilter", "Black & White with Yellow Filter (RGB)", {"monochrome": true, "gray": [34, 66, 0, 0]}), -]; - -/// The presets of adjustment `kind` for a document (`gray`: Grayscale, where the colour presets -/// don't apply); empty for a kind without a Preset menu. -pub fn presets(kind: &str, gray: bool) -> &'static [AdjustPreset] { - match kind { - "levels" => LEVELS, - "exposure" => EXPOSURE, - "blackWhite" if !gray => BLACK_WHITE, - "channelMixer" if !gray => CHANNEL_MIXER, - _ => &[], - } -} - -fn mode(gray: bool) -> ColorMode { - if gray { ColorMode::Grayscale } else { ColorMode::Rgb } -} - -/// `params` as the engine reads them: every key, defaults filled in. -fn normal(kind: &str, params: &Value, gray: bool) -> Option { - from_params(kind, params, None, mode(gray)).ok().map(|a| to_params(&a)) -} - -/// The settings `preset` gives, with every other key at its default. -pub fn settings(kind: &str, preset: &AdjustPreset, gray: bool) -> Option { - normal(kind, &(preset.params)(), gray) -} - -/// The preset the values `v` are (Default for untouched values), or `None` (Custom). -pub fn matching(kind: &str, v: &Value, gray: bool) -> Option<&'static AdjustPreset> { - let now = normal(kind, v, gray)?; - presets(kind, gray).iter().find(|p| settings(kind, p, gray).as_ref() == Some(&now)) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Every preset is valid, distinct and recognised once applied; an edit makes it Custom. - #[test] - fn presets_apply_and_are_recognised() { - for kind in ["levels", "exposure", "blackWhite", "channelMixer"] { - let all = presets(kind, false); - assert!(all.len() >= 5 && all[0].id == "default", "{kind}"); - for p in all { - let s = settings(kind, p, false).unwrap_or_else(|| panic!("{kind} {}: valid params", p.id)); - assert_eq!(matching(kind, &s, false).map(|m| m.id), Some(p.id), "{kind} {}", p.id); - } - let distinct: std::collections::HashSet = all.iter().map(|p| settings(kind, p, false).unwrap().to_string()).collect(); - assert_eq!(distinct.len(), all.len(), "{kind}: no two presets are the same"); - assert_eq!(matching(kind, &json!({}), false).map(|p| p.id), Some("default"), "{kind}: untouched is Default"); - } - let mut v = settings("levels", &LEVELS[3], false).unwrap(); - v["gamma"] = json!(1.37); - assert!(matching("levels", &v, false).is_none(), "an edit is Custom"); - assert!(presets("blackWhite", true).is_empty() && presets("channelMixer", true).is_empty(), "colour presets need colour"); - assert_eq!(presets("levels", true).len(), LEVELS.len()); - } - - /// The looks hold: contrast steps widen, filters lighten their own colour and darken the - /// complement, exposure presets are whole stops. - #[test] - fn presets_give_their_looks() { - let lv = |id: &str| settings("levels", LEVELS.iter().find(|p| p.id == id).unwrap(), false).unwrap(); - let span = |v: &Value| v["inWhite"].as_f64().unwrap() - v["inBlack"].as_f64().unwrap(); - assert!(span(&lv("increaseContrast1")) > span(&lv("increaseContrast2")) && span(&lv("increaseContrast2")) > span(&lv("increaseContrast3"))); - assert!(lv("midtonesBrighter")["gamma"].as_f64().unwrap() > 1.0 && lv("midtonesDarker")["gamma"].as_f64().unwrap() < 1.0); - let bw = |id: &str| settings("blackWhite", BLACK_WHITE.iter().find(|p| p.id == id).unwrap(), false).unwrap(); - let w = |v: &Value, k: &str| v[k].as_f64().unwrap(); - assert!(w(&bw("redFilter"), "reds") > w(&bw("redFilter"), "cyans"), "a red filter lightens reds over cyans"); - assert!(w(&bw("blueFilter"), "blues") > w(&bw("blueFilter"), "yellows"), "a blue filter lightens blues over yellows"); - assert!( - w(&bw("highContrastRedFilter"), "reds") - w(&bw("highContrastRedFilter"), "blues") > w(&bw("redFilter"), "reds") - w(&bw("redFilter"), "blues") - ); - let ex = |id: &str| settings("exposure", EXPOSURE.iter().find(|p| p.id == id).unwrap(), false).unwrap()["exposure"].as_f64().unwrap(); - assert_eq!([ex("minus2"), ex("minus1"), ex("plus1"), ex("plus2")], [-2.0, -1.0, 1.0, 2.0]); - for p in &CHANNEL_MIXER[1..] { - let s = settings("channelMixer", p, false).unwrap(); - assert_eq!(s["monochrome"], json!(true), "{}", p.id); - } - } -} diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 687da907a2..d042a1fb6b 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -325,7 +325,6 @@ pub fn has_dialog(command: &str) -> bool { | "image.mode.colorTable" | "edit.definePattern" | "select.convertToShape" - | "layer.frequencySeparation" | "layer.new.artboardFromLayers" )) && photocraft_engine::commands::find(command).is_some_and(|c| !parse_spec(c.params).is_empty()) diff --git a/crates/ui-egui/src/dialogs/liquify_ui.rs b/crates/ui-egui/src/dialogs/liquify_ui.rs index 9c8cde708a..5ab1fb7043 100644 --- a/crates/ui-egui/src/dialogs/liquify_ui.rs +++ b/crates/ui-egui/src/dialogs/liquify_ui.rs @@ -194,9 +194,8 @@ pub struct LiquifyDialog { pub dab_ms: f64, /// CPU time spent refreshing the zoomed-in source-resolution preview. pub preview_ms: f64, - /// The smart filter being edited, with its `smart_filter_dialog::target`: OK checks the target - /// and updates that filter instead of adding a new Liquify filter. - smart_filter: Option<(usize, Value)>, + /// The smart filter being edited: OK updates it instead of adding a new Liquify filter. + smart_filter: Option, } fn union(a: Option<[usize; 4]>, b: [usize; 4]) -> Option<[usize; 4]> { @@ -234,7 +233,7 @@ impl LiquifyDialog { "dabMs": self.dab_ms, "fullResolutionPreview": self.view_bounds.is_some(), "previewMs": self.preview_ms, - "smartFilter": self.smart_filter.as_ref().map(|(index, _)| index), + "smartFilter": self.smart_filter, }) } @@ -499,7 +498,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> /// Re-opens Liquify smart filter `index` of smart object `layer` with its recorded strokes and /// mesh size, previewing on the pixels below it; OK updates that filter -/// (`layer.smartFilter.setParams`) unless the document or the filter changed in the meantime. +/// (`layer.smartFilter.setParams`). pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: LayerId, index: usize) -> Result<(), String> { use photocraft_engine::distort_cmds::{LIQUIFY, liquify_params}; let st = app.session.active().ok_or("no document")?; @@ -514,8 +513,7 @@ pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: La let surf = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index) .map_err(|e| e.to_string())? .ok_or("the smart object's contents are unavailable (missing linked file?)")?; - let target = crate::smart_filter_dialog::target(app, layer, index)?; - open_on(app, ctx, layer, surf, strokes, cell, Some((index, target))) + open_on(app, ctx, layer, surf, strokes, cell, Some(index)) } fn open_on( @@ -525,7 +523,7 @@ fn open_on( surf: Surface, strokes: Vec, cell: f64, - smart_filter: Option<(usize, Value)>, + smart_filter: Option, ) -> Result<(), String> { let st = app.session.active().ok_or("no document")?; let canvas = st.doc.bounds(); @@ -595,25 +593,16 @@ pub fn cancel(app: &mut PhotocraftApp) { } /// OK: runs `filter.liquify` with the recorded strokes (one history step). When the dialog edits -/// a smart filter, OK updates that filter instead (also when Restore All left no strokes), after -/// `smart_filter_dialog::validate_target` checks that the document and the filter did not change. -/// The dialog closes either way. The status bar shows the error of a failed check or command -/// (`app.run` shows the latter), and `commit` returns it. +/// a smart filter, OK updates that filter instead (also when Restore All left no strokes). The +/// dialog closes either way. `app.run` shows a failed command's error in the status bar, and +/// `commit` returns it. pub fn commit(app: &mut PhotocraftApp) -> Result<(), String> { let Some(mut d) = app.distort.liquify.take() else { return Ok(()) }; remember(app, &d.opts); d.end(); - if let Some((_, target)) = &d.smart_filter { - let (layer, index) = match crate::smart_filter_dialog::validate_target(app, target) { - Ok(edit) => edit, - Err(e) => { - app.ui.status = e.clone(); - app.ui.status_error = true; - return Err(e); - } - }; + if let Some(index) = d.smart_filter { let params = json!({"strokes": d.strokes, "meshSize": d.cell}); - return app.run("layer.smartFilter.setParams", json!({"layer": layer.0, "index": index, "params": params})).map(|_| ()); + return app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params})).map(|_| ()); } if d.strokes.is_empty() { return Ok(()); diff --git a/crates/ui-egui/src/dialogs/liquify_ui/tests.rs b/crates/ui-egui/src/dialogs/liquify_ui/tests.rs index 9a244bcc83..777e090f8a 100644 --- a/crates/ui-egui/src/dialogs/liquify_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/liquify_ui/tests.rs @@ -759,7 +759,7 @@ fn smart_filter_double_click_edits_the_recorded_liquify() { crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0); assert!(app.ui.dialogs.is_empty(), "no generic parameter form"); let d = app.distort.liquify.as_ref().expect("the Liquify workspace opens"); - assert_eq!((d.smart_filter.as_ref().map(|(index, _)| *index), d.cell), (Some(0), 3.0)); + assert_eq!((d.smart_filter, d.cell), (Some(0), 3.0)); assert_eq!(d.strokes, recorded); assert_eq!(d.field, LiquifyField::from_strokes(d.canvas, 3.0, &recorded)); // The preview starts from the pixels below the filter (no liquify, no blur). @@ -828,37 +828,12 @@ fn smart_filter_edit_that_cannot_be_applied_reports_the_error() { let r = control(&mut app, &json!({"commit": true})); assert!(app.distort.liquify.is_none()); assert!(app.ui.status_error); - assert!(app.ui.status.contains("no longer exists"), "{}", app.ui.status); + assert!(app.ui.status.contains("no smart filters"), "{}", app.ui.status); assert!(smart(&app, layer).smart_filters.is_empty()); assert_eq!(app.session.active().unwrap().history.past_len(), steps); assert_eq!(r, Err(app.ui.status.clone())); } -/// OK after the stack was reordered while the workspace was open: the filter now at the -/// edited index (the Gaussian Blur) is left alone and the status bar asks to reopen. -#[test] -fn smart_filter_edit_after_the_stack_changed_updates_nothing() { - let ctx = egui::Context::default(); - let (mut app, layer) = smart_liquify_app(); - open_smart_filter(&mut app, &ctx, layer, 0).unwrap(); - drag(&mut app, 70.0, 84.0); - app.session.execute("layer.smartFilter.move", json!({"layer": layer.0, "index": 0, "to": 1})).unwrap(); - let before = smart(&app, layer).smart_filters; - assert_eq!(before[0].command, "filter.blur.gaussianBlur"); - let steps = app.session.active().unwrap().history.past_len(); - let r = control(&mut app, &json!({"commit": true})); - assert!(app.distort.liquify.is_none()); - assert!(app.ui.status_error); - assert!(app.ui.status.contains("Reopen its dialog"), "{}", app.ui.status); - let after = smart(&app, layer).smart_filters; - assert_eq!(after.len(), before.len()); - for (a, b) in after.iter().zip(&before) { - assert_eq!((&a.command, &a.params), (&b.command, &b.params)); - } - assert_eq!(app.session.active().unwrap().history.past_len(), steps); - assert_eq!(r, Err(app.ui.status.clone())); -} - /// OK after the layer was deleted: the dialog closes, the status bar shows the error and the /// control channel returns it. #[test] diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d6d12bd10..bad7f725c3 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -61,9 +61,7 @@ impl Group { match self { Group::Color => 190.0, Group::Properties => 250.0, - // Roman Hanging Punctuation added a bottom row to the Paragraph tab, so the - // group grows by one row to keep its content unscrolled. - Group::Character => 306.0, + Group::Character => 270.0, Group::Navigator => 210.0, Group::History => 200.0, Group::Layers => 320.0, diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e5c4d0f237..b095d15596 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1358,7 +1358,6 @@ Italic Kurzíva Join us on Discord Přidejte se k nám na Discordu Justify all Do bloku vše - Roman Hanging Punctuation Římská převislá interpunkce Justify last centered Do bloku, poslední řádek na střed Justify last left Do bloku, poslední řádek vlevo Justify last right Do bloku, poslední řádek vpravo @@ -1960,9 +1959,6 @@ Slice Select Tool Nástroj výběr řezu Slice Tool Nástroj řez Smart Filters Chytré filtry - Move Up Přesunout nahoru - Move Down Přesunout dolů - Delete Smart Filter Odstranit chytrý filtr Smudge Tool Nástroj rozmazání Soft Měkké Soften Změkčení @@ -2101,7 +2097,6 @@ Image previews Náhledy obrázků Integrations Integrace Interface Rozhraní - Use PhotoCraft-native gradient icons Používat nativní ikony přechodů PhotoCraft Jpeg quality Kvalita JPEG Jpg JPG Language Jazyk @@ -2601,7 +2596,6 @@ fontWeight Italic Kurzíva Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu Color to Alpha… Barva na alfu… - Frequency Separation… Frekvenční separace… Transparency Threshold Práh průhlednosti Opacity Threshold Práh krytí PhotoCraft — Screen Color Picker PhotoCraft — Výběr barvy obrazovky @@ -3880,8 +3874,6 @@ navigator Zoom Zvětšení Reset All Locked Settings Obnovit všechna zamknutá nastavení Copy Texture to Other Tools Kopírovat texturu do jiných nástrojů Load Actions… Načíst akce… - Load Default Actions Načíst výchozí akce - Blur Background Rozostřit pozadí Save Actions… Uložit akce… New Action… Nová akce… Start Recording Zahájit nahrávání @@ -4250,10 +4242,6 @@ cameraRaw Dehaze Odstranění oparu Font size presets Předvolby velikosti písma Leading presets Předvolby prokladu Tracking presets Předvolby prostrkání - Selection / Layer Bounds Výběr / Hranice vrstvy - No selection or layer Žádný výběr ani vrstva - {label}: empty {label}: prázdné - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Podle barev Decontaminate Colors Dekontaminovat barvy Edge Detection Detekce okraje @@ -4270,9 +4258,4 @@ cameraRaw Dehaze Odstranění oparu Refine Hair needs hair detection, which isn't available yet Upřesnit vlasy vyžaduje detekci vlasů, která zatím není k dispozici Select and Mask was closed: its document is no longer the active one Vybrat a maskovat bylo zavřeno: jeho dokument již není aktivní Select and Mask was closed: another layer was selected Vybrat a maskovat bylo zavřeno: byla vybrána jiná vrstva - Import Shapes… Importovat tvary… - Choose a custom shape file (.csh). Vyberte soubor vlastních tvarů (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Počet tvarů importovaných z {file}: {n}|Počet tvarů importovaných z {file}: {n}|Počet tvarů importovaných z {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Počet tvarů importovaných z {group}: {n}|Počet tvarů importovaných z {group}: {n}|Počet tvarů importovaných z {group}: {n} - Scrollbar Visibility Viditelnost posuvníků Drag to move the selected path Tažením přesunete vybranou cestu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6d0ab2fb5e..ea900cc655 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1320,7 +1320,6 @@ Italic Kursiv Join us on Discord Tritt unserem Discord bei Justify all Alles im Blocksatz - Roman Hanging Punctuation Römisch überhängende Zeichensetzung Justify last centered Letzte Zeile zentriert Justify last left Letzte Zeile links Justify last right Letzte Zeile rechts @@ -1919,11 +1918,6 @@ Slice Select Tool Slice-Auswahl-Werkzeug Slice Tool Slice-Werkzeug Smart Filters Smart-Filter -# Smart Filter row actions (#1265) - Move Up Nach oben - Move Down Nach unten - Delete Smart Filter Smartfilter löschen - Smudge Tool Wischfinger-Werkzeug Soft Weich Soften Erweichen @@ -2062,7 +2056,6 @@ Image previews Bildvorschauen Integrations Integrationen Interface Benutzeroberfläche - Use PhotoCraft-native gradient icons PhotoCraft-native Symbole für Verläufe verwenden Jpeg quality JPEG-Qualität Jpg JPG Language Sprache @@ -2640,7 +2633,6 @@ fontWeight Italic Kursiv Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe Color to Alpha… Farbe zu Alpha… - Frequency Separation… Frequenztrennung… Transparency Threshold Transparenz-Schwellenwert Opacity Threshold Deckkraft-Schwellenwert PhotoCraft — Screen Color Picker PhotoCraft — Bildschirmfarbenauswahl @@ -3280,8 +3272,6 @@ curves Smooth Glätten Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen Copy Texture to Other Tools Struktur auf andere Werkzeuge kopieren Load Actions… Aktionen laden… - Load Default Actions Standardaktionen laden - Blur Background Hintergrund weichzeichnen Save Actions… Aktionen speichern… New Action… Neue Aktion… Start Recording Aufnahme starten @@ -3525,10 +3515,6 @@ curves Smooth Glätten Font size presets Schriftgrößen-Vorgaben Leading presets Zeilenabstand-Vorgaben Tracking presets Laufweiten-Vorgaben - Selection / Layer Bounds Auswahl / Ebenengrenzen - No selection or layer Keine Auswahl oder Ebene - {label}: empty {label}: leer - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection Auswahl löschen Color Aware Farbbezogen Decontaminate Colors Farben dekontaminieren @@ -3546,9 +3532,3 @@ curves Smooth Glätten Refine Hair needs hair detection, which isn't available yet Haare verbessern benötigt eine Haarerkennung, die noch nicht verfügbar ist Select and Mask was closed: its document is no longer the active one Auswählen und maskieren wurde geschlossen: Das zugehörige Dokument ist nicht mehr aktiv Select and Mask was closed: another layer was selected Auswählen und maskieren wurde geschlossen: Es wurde eine andere Ebene ausgewählt - Import Shapes… Formen importieren… - Choose a custom shape file (.csh). Wählen Sie eine Datei mit eigenen Formen (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Anzahl der aus {file} importierten Formen: {n}|Anzahl der aus {file} importierten Formen: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Anzahl der aus {group} importierten Formen: {n}|Anzahl der aus {group} importierten Formen: {n} - Scrollbar Visibility Sichtbarkeit der Scrollleisten - Hidden Ausgeblendet diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9e0e06790f..998989a19c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -233,7 +233,6 @@ Settings Ρυθμίσεις General Γενικά Interface Περιβάλλον - Use PhotoCraft-native gradient icons Χρήση εγγενών εικονιδίων διαβάθμισης PhotoCraft Language Γλώσσα Auto Αυτόματα Cancel Ακύρωση @@ -554,11 +553,6 @@ Smart Object Έξυπνο αντικείμενο Smart Objects Έξυπνα αντικείμενα Smart Filters Έξυπνα φίλτρα -# Smart Filter row actions (#1265) - Move Up Μετακίνηση επάνω - Move Down Μετακίνηση κάτω - Delete Smart Filter Διαγραφή έξυπνου φίλτρου - Adjustment Layer Επίπεδο προσαρμογής Adjustment Layers Επίπεδα προσαρμογής Pixel Layer Επίπεδο εικονοστοιχείων @@ -3695,7 +3689,6 @@ fontWeight Thin Πολύ λεπτά Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web Color to Alpha… Χρώμα σε άλφα… - Frequency Separation… Διαχωρισμός συχνοτήτων… Transparency Threshold Κατώφλι διαφάνειας Opacity Threshold Κατώφλι αδιαφάνειας PhotoCraft — Screen Color Picker PhotoCraft — Επιλογή χρώματος οθόνης @@ -3964,8 +3957,6 @@ curves Smooth Εξομάλυνση Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία Load Actions… Φόρτωση ενεργειών… - Load Default Actions Φόρτωση προεπιλεγμένων ενεργειών - Blur Background Θόλωση φόντου Save Actions… Αποθήκευση ενεργειών… New Action… Νέα ενέργεια… Start Recording Έναρξη εγγραφής @@ -4205,10 +4196,6 @@ curves Smooth Εξομάλυνση Font size presets Προεπιλογές μεγέθους γραμματοσειράς Leading presets Προεπιλογές διάστιχου Tracking presets Προεπιλογές διαστήματος χαρακτήρων - Selection / Layer Bounds Όρια επιλογής / επιπέδου - No selection or layer Δεν υπάρχει επιλογή ή επίπεδο - {label}: empty {label}: κενό - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Με βάση το χρώμα Decontaminate Colors Απολύμανση χρωμάτων Edge Detection Ανίχνευση άκρης @@ -4226,8 +4213,3 @@ curves Smooth Εξομάλυνση Select and Mask was closed: its document is no longer the active one Η Επιλογή και μάσκα έκλεισε: το έγγραφό της δεν είναι πλέον το ενεργό Select and Mask was closed: another layer was selected Η Επιλογή και μάσκα έκλεισε: επιλέχθηκε άλλο επίπεδο Select Layer Under Cursor Επιλογή επιπέδου κάτω από τον δρομέα - Import Shapes… Εισαγωγή σχημάτων… - Choose a custom shape file (.csh). Επιλέξτε ένα αρχείο προσαρμοσμένων σχημάτων (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Αριθμός σχημάτων που εισήχθησαν από {file}: {n}|Αριθμός σχημάτων που εισήχθησαν από {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Αριθμός σχημάτων που εισήχθησαν από {group}: {n}|Αριθμός σχημάτων που εισήχθησαν από {group}: {n} - Scrollbar Visibility Ορατότητα γραμμών κύλισης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 52bef89e47..a2e0b19522 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1891,9 +1891,6 @@ Slice Select Tool Herramienta Selección de sector Slice Tool Herramienta Sector Smart Filters Filtros inteligentes - Move Up Mover arriba - Move Down Mover abajo - Delete Smart Filter Eliminar filtro inteligente Smudge Tool Herramienta Dedo Soft Suave Soften Suavizar @@ -2032,7 +2029,6 @@ Image previews Vistas previas de imagen Integrations Integraciones Interface Interfaz - Use PhotoCraft-native gradient icons Usar iconos nativos de degradado de PhotoCraft Jpeg quality Calidad JPEG Jpg JPG Language Idioma @@ -2612,7 +2608,6 @@ fontWeight Italic Cursiva Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano Color to Alpha… Color a alfa… - Frequency Separation… Separación de frecuencias… Transparency Threshold Umbral de transparencia Opacity Threshold Umbral de opacidad PhotoCraft — Screen Color Picker PhotoCraft — Selector de color de pantalla @@ -4176,8 +4171,6 @@ navigator Zoom Zoom Reset All Locked Settings Restaurar todos los ajustes bloqueados Copy Texture to Other Tools Copiar textura en otras herramientas Load Actions… Cargar acciones… - Load Default Actions Cargar acciones predeterminadas - Blur Background Desenfocar fondo Save Actions… Guardar acciones… New Action… Nueva acción… Start Recording Iniciar grabación @@ -4414,10 +4407,6 @@ navigator Zoom Zoom Font size presets Valores predefinidos de tamaño de fuente Leading presets Valores predefinidos de interlineado Tracking presets Valores predefinidos de tracking - Selection / Layer Bounds Límites de selección / capa - No selection or layer Sin selección ni capa - {label}: empty {label}: vacío - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Según color Decontaminate Colors Descontaminar colores Edge Detection Detección de bordes @@ -4435,9 +4424,4 @@ navigator Zoom Zoom Select and Mask was closed: its document is no longer the active one Se cerró Seleccionar y aplicar máscara: su documento ya no es el activo Select and Mask was closed: another layer was selected Se cerró Seleccionar y aplicar máscara: se seleccionó otra capa Select Layer Under Cursor Seleccionar capa bajo el cursor - Import Shapes… Importar formas… - Choose a custom shape file (.csh). Selecciona un archivo de formas personalizadas (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Cantidad de formas importadas de {file}: {n}|Cantidad de formas importadas de {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Cantidad de formas importadas de {group}: {n}|Cantidad de formas importadas de {group}: {n} - Scrollbar Visibility Visibilidad de las barras de desplazamiento Drag to move the selected path Arrastre para mover el trazado seleccionado diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e2cf26879f..afec5c6bde 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1899,11 +1899,6 @@ Slice Select Tool Outil Sélection de tranche Slice Tool Outil Tranche Smart Filters Filtres intelligents -# Smart Filter row actions (#1265) - Move Up Déplacer vers le haut - Move Down Déplacer vers le bas - Delete Smart Filter Supprimer le filtre intelligent - Smudge Tool Outil Doigt Soft Doux Soften Adoucir @@ -2040,7 +2035,6 @@ Image previews Aperçus d'image Integrations Intégrations Interface Interface - Use PhotoCraft-native gradient icons Utiliser les icônes natives de dégradé PhotoCraft Jpeg quality Qualité JPEG Jpg JPG Language Langue @@ -3006,7 +3000,6 @@ fontWeight Italic Italique Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche Color to Alpha… Couleur vers alpha… - Frequency Separation… Séparation de fréquences… Transparency Threshold Seuil de transparence Opacity Threshold Seuil d'opacité PhotoCraft — Screen Color Picker PhotoCraft — Sélecteur de couleur d'écran @@ -3270,8 +3263,6 @@ curves Smooth Lisser Reset All Locked Settings Réinitialiser tous les paramètres verrouillés Copy Texture to Other Tools Copier la texture dans d'autres outils Load Actions… Charger des actions… - Load Default Actions Charger les actions par défaut - Blur Background Flouter l’arrière-plan Save Actions… Enregistrer les actions… New Action… Nouvelle action… Start Recording Démarrer l'enregistrement @@ -3302,8 +3293,6 @@ curves Smooth Lisser Remember to turn off the printer's own colour management in Print Settings… Pensez à désactiver la gestion des couleurs de l’imprimante dans les paramètres d’impression… Sent to {printer} Envoyé à {printer} set in Print Settings… défini dans les paramètres d’impression… - Scrollbar Visibility Visibilité des barres de défilement - Hidden Masqué ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels Show Favorite Fonts Afficher les polices favorites No favorite fonts yet: click the star beside a font to add it. Aucune police favorite pour l’instant : cliquez sur l’étoile à côté d’une police pour l’ajouter. @@ -3343,14 +3332,6 @@ curves Smooth Lisser Could not write {name}: {error} Impossible d’écrire {name} : {error} Kept {name}: written by another application {name} conservé : écrit par une autre application Shadow Tint Teinte des ombres - -# Custom shape set import - Import Shapes… Importer des formes… - Choose a custom shape file (.csh). Choisissez un fichier de formes personnalisées (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Nombre de formes importées depuis {file} : {n}|Nombre de formes importées depuis {file} : {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Nombre de formes importées depuis {group} : {n}|Nombre de formes importées depuis {group} : {n} - -# PhotoCraft release checks. Check for Updates… Rechercher des mises à jour… PhotoCraft Updates Mises à jour de PhotoCraft Checking for updates… Recherche de mises à jour… @@ -4552,10 +4533,6 @@ shape Appearance Apparence Font size presets Tailles de police prédéfinies Leading presets Interlignages prédéfinis Tracking presets Approches prédéfinies - Selection / Layer Bounds Limites de la sélection / du calque - No selection or layer Aucune sélection ni aucun calque - {label}: empty {label} : vide - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Selon la couleur Decontaminate Colors Décontaminer les couleurs Edge Detection Détection des contours diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0381d4ee2c..02e2dce446 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -539,9 +539,6 @@ cameraRaw Yellow पीला Delete {count} layers? {names} {count} लेयर हटाएँ? {names} Delete layer? लेयर हटाएँ? Don't show again दोबारा न दिखाएँ - -# Paragraph panel - Roman Hanging Punctuation रोमन हैंगिंग विराम चिह्न The document is closed दस्तावेज़ बंद है The document or selected layers changed. Please request deletion again. दस्तावेज़ या चुनी हुई लेयर बदल गई हैं। कृपया हटाने का अनुरोध फिर से करें। Missing delete command हटाने का कमांड नहीं मिला diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 92aefe96c8..e2e0496a2b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1466,7 +1466,6 @@ Use esc to commit Véglegesítés az Esc billentyűvel Use finite lengths and a positive pattern total Véges hosszakat adj meg, és a mintázat teljes hossza legyen pozitív Use legacy free transform Régi szabad alakítás használata - Use PhotoCraft-native gradient icons PhotoCraft saját színátmenet-ikonjainak használata Vary round brush hardness on hud Kerek ecset keménységének állítása a HUD-on Version Verzió Vertical Függőleges @@ -1734,9 +1733,6 @@ fontWeight Thin Hajszálvékony Show the layer's effects ({key}-click: all layers) Réteg effektusainak megjelenítése ({key}+kattintás: minden réteg) Skip Kihagy Smart Filters Intelligens szűrők - Move Up Mozgatás felfelé - Move Down Mozgatás lefelé - Delete Smart Filter Intelligens szűrő törlése Soft Light Lágy fény Soften Lágyítás Source Forrás @@ -4134,13 +4130,10 @@ cameraRaw Yellow Sárga 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 - Frequency Separation… Frekvenciaszétválasztás… Clear Brush Controls Ecsetvezérlők törlése Reset All Locked Settings Összes zárolt beállítás visszaállítása Copy Texture to Other Tools Textúra másolása más eszközökre Load Actions… Műveletek betöltése… - Load Default Actions Alapértelmezett műveletek betöltése - Blur Background Háttér elmosása Save Actions… Műveletek mentése… New Action… Új művelet… Start Recording Felvétel indítása @@ -4414,10 +4407,6 @@ cameraRaw Yellow Sárga Font size presets Betűméret-előbeállítások Leading presets Sorköz-előbeállítások Tracking presets Betűköz-előbeállítások - Selection / Layer Bounds Kijelölés / Réteghatárok - No selection or layer Nincs kijelölés vagy réteg - {label}: empty {label}: üres - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Színalapú Decontaminate Colors Színek fertőtlenítése Edge Detection Élészlelés @@ -4434,9 +4423,4 @@ cameraRaw Yellow Sárga Refine Hair needs hair detection, which isn't available yet A Haj finomításához hajfelismerés kell, amely még nem érhető el Select and Mask was closed: its document is no longer the active one A Kijelölés és maszk bezárult: a dokumentuma már nem az aktív Select and Mask was closed: another layer was selected A Kijelölés és maszk bezárult: másik réteg lett kijelölve - Import Shapes… Alakzatok importálása… - Choose a custom shape file (.csh). Válasszon egy egyéni alakzatfájlt (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} A(z) {file} forrásból importált alakzatok száma: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} A(z) {group} forrásból importált alakzatok száma: {n} - Scrollbar Visibility Görgetősávok láthatósága Drag to move the selected path Húzza a kijelölt útvonal mozgatásához diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0be337a488..b7c92b1802 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1907,11 +1907,6 @@ Slice Select Tool Alat Slice Select Slice Tool Alat Slice Smart Filters Smart Filter -# Smart Filter row actions (#1265) - Move Up Pindah ke atas - Move Down Pindah ke bawah - Delete Smart Filter Hapus Smart Filter - Smudge Tool Alat Smudge Soft Lembut Soften Lembutkan @@ -2048,7 +2043,6 @@ Image previews Pratinjau gambar Integrations Integrasi Interface Antarmuka - Use PhotoCraft-native gradient icons Gunakan ikon gradien bawaan PhotoCraft Jpeg quality Kualitas JPEG Jpg JPG Language Bahasa @@ -3011,7 +3005,6 @@ fontWeight Italic Miring Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat Color to Alpha… Warna ke Alpha… - Frequency Separation… Pemisahan Frekuensi… Transparency Threshold Ambang transparansi Opacity Threshold Ambang opasitas PhotoCraft — Screen Color Picker PhotoCraft — Pemilih Warna Layar @@ -3275,8 +3268,6 @@ curves Smooth Haluskan Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci Copy Texture to Other Tools Salin Tekstur ke Alat Lain Load Actions… Muat Tindakan… - Load Default Actions Muat Aksi Default - Blur Background Buramkan Latar Belakang Save Actions… Simpan Tindakan… New Action… Tindakan Baru… Start Recording Mulai Rekaman @@ -3524,10 +3515,6 @@ curves Smooth Haluskan Font size presets Preset ukuran font Leading presets Preset leading Tracking presets Preset tracking - Selection / Layer Bounds Batas Seleksi / Layer - No selection or layer Tidak ada seleksi atau layer - {label}: empty {label}: kosong - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection Hapus Seleksi Color Aware Berbasis Warna Decontaminate Colors Dekontaminasi Warna @@ -3546,9 +3533,3 @@ curves Smooth Haluskan Select and Mask was closed: its document is no longer the active one Seleksi dan Mask ditutup: dokumennya tidak lagi aktif Select and Mask was closed: another layer was selected Seleksi dan Mask ditutup: layer lain dipilih Select Layer Under Cursor Pilih Lapisan di Bawah Kursor - Import Shapes… Impor Bentuk… - Choose a custom shape file (.csh). Pilih berkas bentuk khusus (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Jumlah bentuk yang diimpor dari {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Jumlah bentuk yang diimpor dari {group}: {n} - Scrollbar Visibility Visibilitas bilah gulir - Hidden Tersembunyi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f84852cf32..98c1c0390a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1936,11 +1936,6 @@ Slice Select Tool Strumento Selezione sezioni Slice Tool Strumento Sezione Smart Filters Filtri avanzati -# Smart Filter row actions (#1265) - Move Up Sposta su - Move Down Sposta giù - Delete Smart Filter Elimina filtro avanzato - Smudge Tool Strumento Sfumino Soft Morbido Soften Ammorbidisci @@ -2079,7 +2074,6 @@ Image previews Anteprime immagine Integrations Integrazioni Interface Interfaccia - Use PhotoCraft-native gradient icons Usa le icone native delle sfumature di PhotoCraft Jpeg quality Qualità JPEG Jpg JPG Language Lingua @@ -2660,7 +2654,6 @@ fontWeight Italic Corsivo Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino Color to Alpha… Colore ad alfa… - Frequency Separation… Separazione delle frequenze… Transparency Threshold Soglia di trasparenza Opacity Threshold Soglia di opacità PhotoCraft — Screen Color Picker PhotoCraft — Selettore colore schermo @@ -4227,8 +4220,6 @@ navigator Zoom Zoom Reset All Locked Settings Ripristina tutte le impostazioni bloccate Copy Texture to Other Tools Copia texture su altri strumenti Load Actions… Carica azioni… - Load Default Actions Carica azioni predefinite - Blur Background Sfoca sfondo Save Actions… Salva azioni… New Action… Nuova azione… Start Recording Avvia registrazione @@ -4618,10 +4609,6 @@ shape Appearance Aspetto Font size presets Predefiniti dimensione font Leading presets Predefiniti interlinea Tracking presets Predefiniti spaziatura - Selection / Layer Bounds Limiti selezione / livello - No selection or layer Nessuna selezione o livello - {label}: empty {label}: vuoto - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware In base al colore Decontaminate Colors Decontamina colori Edge Detection Rilevamento bordi @@ -4638,8 +4625,3 @@ shape Appearance Aspetto Refine Hair needs hair detection, which isn't available yet Migliora capelli richiede il rilevamento dei capelli, non ancora disponibile Select and Mask was closed: its document is no longer the active one Seleziona e maschera è stato chiuso: il suo documento non è più quello attivo Select and Mask was closed: another layer was selected Seleziona e maschera è stato chiuso: è stato selezionato un altro livello - Import Shapes… Importa forme… - Choose a custom shape file (.csh). Scegli un file di forme personalizzate (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Numero di forme importate da {file}: {n}|Numero di forme importate da {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Numero di forme importate da {group}: {n}|Numero di forme importate da {group}: {n} - Scrollbar Visibility Visibilità delle barre di scorrimento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bc42441a3e..7937a483e7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1944,9 +1944,6 @@ Slice Select Tool スライス選択ツール Slice Tool スライスツール Smart Filters スマートフィルター - Move Up 上へ移動 - Move Down 下へ移動 - Delete Smart Filter スマートフィルターを削除 Smudge Tool 指先ツール Soft ソフト Soften ぼかし @@ -2085,7 +2082,6 @@ Image previews 画像のプレビュー Integrations 連携 Interface インターフェイス - Use PhotoCraft-native gradient icons PhotoCraft 標準のグラデーションアイコンを使用 Jpeg quality JPEG 品質 Jpg JPG Language 言語 @@ -3612,7 +3608,6 @@ fontWeight Italic 斜体 ↑↓ navigate ↵ run esc close ↑↓ 移動 ↵ 実行 esc 閉じる @id select.all すべてを選択 Color to Alpha… カラーを透明に… - Frequency Separation… 周波数分離… Transparency Threshold 透明のしきい値 Opacity Threshold 不透明度のしきい値 PhotoCraft — Screen Color Picker PhotoCraft — スクリーンカラーピッカー @@ -3880,8 +3875,6 @@ curves Smooth スムーズ Saved contours to {file} カーブを {file} に保存しました Show home recent files ホーム画面に最近使ったファイルを表示 Load Actions… アクションを読み込み… - Load Default Actions 既定のアクションを読み込み - Blur Background 背景をぼかす Save Actions… アクションを保存… New Action… 新規アクション… Start Recording 記録を開始 @@ -3968,8 +3961,6 @@ curves Smooth スムーズ Clear Brush Controls ブラシのコントロールを消去 Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー - Scrollbar Visibility スクロールバーの表示 - Hidden 非表示 ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです Show Favorite Fonts お気に入りのフォントを表示 No favorite fonts yet: click the star beside a font to add it. お気に入りのフォントはまだありません。フォントの横の星をクリックして追加してください。 @@ -4180,10 +4171,6 @@ curves Smooth スムーズ Font size presets フォントサイズのプリセット Leading presets 行送りのプリセット Tracking presets トラッキングのプリセット - Selection / Layer Bounds 選択範囲 / レイヤーの境界 - No selection or layer 選択範囲またはレイヤーがありません - {label}: empty {label}: 空 - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection 選択範囲をクリア Color Aware カラー認識 Decontaminate Colors 不要なカラーの除去 @@ -4201,8 +4188,4 @@ curves Smooth スムーズ Refine Hair needs hair detection, which isn't available yet 髪の毛を調整には髪の毛の検出が必要ですが、まだ利用できません Select and Mask was closed: its document is no longer the active one 「選択とマスク」を閉じました: 対象のドキュメントがアクティブではなくなりました Select and Mask was closed: another layer was selected 「選択とマスク」を閉じました: 別のレイヤーが選択されました - Import Shapes… シェイプを読み込み… - Choose a custom shape file (.csh). カスタムシェイプファイル (.csh) を選択してください。 -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file} から読み込んだシェイプ数: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group} から読み込んだシェイプ数: {n} Drag to move the selected path ドラッグして選択したパスを移動 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 848bf5daa8..b1ba69937f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1318,7 +1318,6 @@ Italic 기울임꼴 Join us on Discord Discord 참여 Justify all 모든 줄 양쪽 정렬 - Roman Hanging Punctuation 로마자 매달림 문장 부호 Justify last centered 마지막 줄 가운데 정렬 Justify last left 마지막 줄 왼쪽 정렬 Justify last right 마지막 줄 오른쪽 정렬 @@ -1920,9 +1919,6 @@ Slice Select Tool 분할 영역 선택 도구 Slice Tool 분할 영역 도구 Smart Filters 스마트 필터 - Move Up 위로 이동 - Move Down 아래로 이동 - Delete Smart Filter 스마트 필터 삭제 Smudge Tool 손가락 도구 Soft 부드럽게 Soften 부드럽게 하기 @@ -2059,7 +2055,6 @@ Image previews 이미지 미리 보기 Integrations 연동 Interface 인터페이스 - Use PhotoCraft-native gradient icons PhotoCraft 기본 그레이디언트 아이콘 사용 Jpeg quality JPEG 품질 Jpg JPG Language 언어 @@ -3191,7 +3186,6 @@ fontWeight Italic 기울임 Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다 Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다 Color to Alpha… 색상을 알파로… - Frequency Separation… 주파수 분리… Transparency Threshold 투명도 한계값 Opacity Threshold 불투명도 한계값 PhotoCraft — Screen Color Picker PhotoCraft — 화면 색상 선택기 @@ -3469,8 +3463,6 @@ curves Smooth 매끄럽게 Reset All Locked Settings 모든 잠긴 설정 재설정 Copy Texture to Other Tools 다른 도구로 텍스처 복사 Load Actions… 액션 불러오기… - Load Default Actions 기본 액션 불러오기 - Blur Background 배경 흐리게 Save Actions… 액션 저장… New Action… 새 액션… Start Recording 기록 시작 @@ -3721,10 +3713,6 @@ curves Smooth 매끄럽게 Font size presets 글꼴 크기 사전 설정 Leading presets 행간 사전 설정 Tracking presets 자간 사전 설정 - Selection / Layer Bounds 선택 영역 / 레이어 경계 - No selection or layer 선택 영역 또는 레이어 없음 - {label}: empty {label}: 비어 있음 - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection 선택 영역 지우기 Color Aware 색상 인식 Decontaminate Colors 색상 정화 @@ -3743,10 +3731,4 @@ curves Smooth 매끄럽게 Select and Mask was closed: its document is no longer the active one 선택 및 마스크가 닫혔습니다: 해당 문서가 더 이상 활성 문서가 아닙니다 Select and Mask was closed: another layer was selected 선택 및 마스크가 닫혔습니다: 다른 레이어가 선택되었습니다 Select Layer Under Cursor 커서 아래 레이어 선택 - Import Shapes… 모양 가져오기… - Choose a custom shape file (.csh). 사용자 정의 모양 파일 (.csh)을 선택하세요. -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file}에서 가져온 모양 수: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group}에서 가져온 모양 수: {n} - Scrollbar Visibility 스크롤 막대 표시 - Hidden 숨김 Drag to move the selected path 드래그하여 선택한 패스 이동 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ea6ba7910c..de9d3dfb39 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1866,11 +1866,6 @@ Slice Select Tool Segmentselectiegereedschap Slice Tool Segmentgereedschap Smart Filters Slimme filters -# Smart Filter row actions (#1265) - Move Up Omhoog - Move Down Omlaag - Delete Smart Filter Slim filter verwijderen - Smudge Tool Uitsmeergereedschap Soft Zacht Soften Verzachten @@ -2009,7 +2004,6 @@ Image previews Afbeeldingsvoorvertoningen Integrations Integraties Interface Interface - Use PhotoCraft-native gradient icons Eigen PhotoCraft-verlooppictogrammen gebruiken Jpeg quality JPEG-kwaliteit Jpg JPG Language Taal @@ -3755,7 +3749,6 @@ cameraRaw Whites Witten 101 by 101 Average Gemiddelde van 101 bij 101 All Layers No Adjustments Alle lagen zonder aanpassingen Color to Alpha… Kleur naar alfa… - Frequency Separation… Frequentiescheiding… Current & Below No Adjustments Huidige en onderliggende zonder aanpassingen Now drag the selection onto another area Sleep de selectie nu naar een ander gebied Point Sample Puntmonster @@ -4023,8 +4016,6 @@ curves Smooth Vloeiend maken Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen Copy Texture to Other Tools Structuur kopiëren naar andere gereedschappen Load Actions… Handelingen laden… - Load Default Actions Standaardacties laden - Blur Background Achtergrond vervagen Save Actions… Handelingen opslaan… New Action… Nieuwe handeling… Start Recording Opname starten @@ -4262,10 +4253,6 @@ curves Smooth Vloeiend maken Font size presets Voorinstellingen lettergrootte Leading presets Voorinstellingen interlinie Tracking presets Voorinstellingen spatiëring - Selection / Layer Bounds Selectie- / laaggrenzen - No selection or layer Geen selectie of laag - {label}: empty {label}: leeg - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Kleurbewust Decontaminate Colors Kleuren zuiveren Edge Detection Randdetectie @@ -4283,8 +4270,3 @@ curves Smooth Vloeiend maken Select and Mask was closed: its document is no longer the active one Selecteren en maskeren is gesloten: het document is niet meer het actieve document Select and Mask was closed: another layer was selected Selecteren en maskeren is gesloten: er is een andere laag geselecteerd Select Layer Under Cursor Laag onder cursor selecteren - Import Shapes… Vormen importeren… - Choose a custom shape file (.csh). Kies een bestand met aangepaste vormen (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Aantal uit {file} geïmporteerde vormen: {n}|Aantal uit {file} geïmporteerde vormen: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Aantal uit {group} geïmporteerde vormen: {n}|Aantal uit {group} geïmporteerde vormen: {n} - Scrollbar Visibility Zichtbaarheid van schuifbalken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 32f1b73b1a..ea729314f8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1930,11 +1930,6 @@ Slice Select Tool Narzędzie Zaznaczanie plasterków Slice Tool Narzędzie Plasterek Smart Filters Filtry inteligentne -# Smart Filter row actions (#1265) - Move Up Przesuń w górę - Move Down Przesuń w dół - Delete Smart Filter Usuń filtr inteligentny - Smudge Tool Narzędzie Palec Soft Miękkie Soften Zmiękcz @@ -2073,7 +2068,6 @@ Image previews Podglądy obrazów Integrations Integracje Interface Interfejs - Use PhotoCraft-native gradient icons Używaj natywnych ikon gradientów PhotoCraft Jpeg quality Jakość JPEG Jpg JPG Language Język @@ -3622,7 +3616,6 @@ cameraRaw Roughness Chropowatość Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy Color to Alpha… Kolor na alfę… - Frequency Separation… Separacja częstotliwości… Transparency Threshold Próg przezroczystości Opacity Threshold Próg krycia PhotoCraft — Screen Color Picker PhotoCraft — Próbnik kolorów ekranu @@ -3880,8 +3873,6 @@ curves Smooth Wygładź Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia Copy Texture to Other Tools Kopiuj teksturę do innych narzędzi Load Actions… Wczytaj operacje… - Load Default Actions Wczytaj domyślne operacje - Blur Background Rozmyj tło Save Actions… Zapisz operacje… New Action… Nowa operacja… Start Recording Rozpocznij nagrywanie @@ -4122,10 +4113,6 @@ curves Smooth Wygładź Font size presets Ustawienia wstępne rozmiaru czcionki Leading presets Ustawienia wstępne interlinii Tracking presets Ustawienia wstępne światła - Selection / Layer Bounds Granice zaznaczenia / warstwy - No selection or layer Brak zaznaczenia lub warstwy - {label}: empty {label}: puste - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection Wyczyść zaznaczenie Color Aware Według kolorów Decontaminate Colors Odkażanie kolorów @@ -4144,9 +4131,3 @@ curves Smooth Wygładź Select and Mask was closed: its document is no longer the active one Zamknięto Zaznacz i maskuj: jego dokument nie jest już aktywny Select and Mask was closed: another layer was selected Zamknięto Zaznacz i maskuj: wybrano inną warstwę Select Layer Under Cursor Zaznacz warstwę pod kursorem - Import Shapes… Importuj kształty… - Choose a custom shape file (.csh). Wybierz plik kształtów własnych (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Liczba kształtów zaimportowanych z {file}: {n}|Liczba kształtów zaimportowanych z {file}: {n}|Liczba kształtów zaimportowanych z {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Liczba kształtów zaimportowanych z {group}: {n}|Liczba kształtów zaimportowanych z {group}: {n}|Liczba kształtów zaimportowanych z {group}: {n} - Scrollbar Visibility Widoczność pasków przewijania - Hidden Ukryte diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6d85005610..55a124843f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1893,9 +1893,6 @@ Slice Select Tool Ferramenta Seleção de fatia Slice Tool Ferramenta Fatia Smart Filters Filtros inteligentes - Move Up Mover para cima - Move Down Mover para baixo - Delete Smart Filter Excluir filtro inteligente Smudge Tool Ferramenta Borrar Soft Suave Soften Suavizar @@ -2032,7 +2029,6 @@ Image previews Visualizações de imagem Integrations Integrações Interface Interface - Use PhotoCraft-native gradient icons Usar ícones nativos de gradiente do PhotoCraft Jpeg quality Qualidade JPEG Jpg JPG Language Idioma @@ -3017,7 +3013,6 @@ fontWeight Italic Itálico Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima Color to Alpha… Cor para alfa… - Frequency Separation… Separação de frequências… Transparency Threshold Limiar de transparência Opacity Threshold Limiar de opacidade PhotoCraft — Screen Color Picker PhotoCraft — Seletor de cor da tela @@ -4061,8 +4056,6 @@ layerLabel No Color Sem cor Reset All Locked Settings Redefinir todas as configurações bloqueadas Copy Texture to Other Tools Copiar textura para outras ferramentas Load Actions… Carregar ações… - Load Default Actions Carregar ações padrão - Blur Background Desfocar plano de fundo Save Actions… Salvar ações… New Action… Nova ação… Start Recording Iniciar gravação @@ -4279,6 +4272,7 @@ layerLabel No Color Sem cor Layer Mask Hides Effects A máscara de camada oculta os efeitos Vector Mask Hides Effects A máscara vetorial oculta os efeitos This Layer: Esta camada: + Underlying Layer: Camada subjacente: Dark Gray Cinza-escuro Medium Gray Cinza médio Select Custom Color… Selecionar cor personalizada… @@ -4430,10 +4424,6 @@ layerLabel No Color Sem cor Font size presets Predefinições de tamanho da fonte Leading presets Predefinições de entrelinha Tracking presets Predefinições de tracking - Selection / Layer Bounds Limites da seleção / camada - No selection or layer Nenhuma seleção ou camada - {label}: empty {label}: vazio - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Com reconhecimento de cor Decontaminate Colors Descontaminar cores Edge Detection Detecção de borda @@ -4450,9 +4440,4 @@ layerLabel No Color Sem cor Refine Hair needs hair detection, which isn't available yet Refinar cabelo requer detecção de cabelo, ainda não disponível Select and Mask was closed: its document is no longer the active one Selecionar e mascarar foi fechado: o documento dele não está mais ativo Select and Mask was closed: another layer was selected Selecionar e mascarar foi fechado: outra camada foi selecionada - Import Shapes… Importar formas… - Choose a custom shape file (.csh). Escolha um arquivo de formas personalizadas (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Quantidade de formas importadas de {file}: {n}|Quantidade de formas importadas de {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Quantidade de formas importadas de {group}: {n}|Quantidade de formas importadas de {group}: {n} - Scrollbar Visibility Visibilidade das barras de rolagem Drag to move the selected path Arraste para mover o demarcador selecionado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 97befd1435..3fc72b0833 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -420,11 +420,6 @@ New Video Layer from File… Новый видеослой из файла… New Blank Video Layer Новый пустой видеослой Smart Filters Смарт-фильтры -# Smart Filter row actions (#1265) - Move Up Переместить вверх - Move Down Переместить вниз - Delete Smart Filter Удалить смарт-фильтр - Delete Smart Filters Удалить смарт-фильтры Enable Smart Filters Включить смарт-фильтры Disable Smart Filters Отключить смарт-фильтры @@ -2498,7 +2493,6 @@ Image previews Предпросмотр изображений Integrations Интеграции Interface Интерфейс - Use PhotoCraft-native gradient icons Использовать собственные значки градиента PhotoCraft Jpeg quality Качество JPEG Jpg JPEG Large tabs Крупные вкладки @@ -2867,7 +2861,6 @@ fontWeight Italic Курсив Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет Color to Alpha… Цвет в альфа-канал… - Frequency Separation… Частотное разложение… Transparency Threshold Порог прозрачности Opacity Threshold Порог непрозрачности PhotoCraft — Screen Color Picker PhotoCraft — Пипетка цвета экрана @@ -3227,8 +3220,6 @@ curves Smooth Сгладить Reset All Locked Settings Сбросить все заблокированные параметры Copy Texture to Other Tools Скопировать текстуру в другие инструменты Load Actions… Загрузить операции… - Load Default Actions Загрузить действия по умолчанию - Blur Background Размыть фон Save Actions… Сохранить операции… New Action… Новая операция… Start Recording Начать запись @@ -3259,8 +3250,6 @@ curves Smooth Сгладить Remember to turn off the printer's own colour management in Print Settings… Не забудьте отключить управление цветом самого принтера в параметрах печати… Sent to {printer} Отправлено на {printer} set in Print Settings… задаётся в параметрах печати… - Scrollbar Visibility Видимость полос прокрутки - Hidden Скрыто ICO is limited to 256 × 256 pixels ICO ограничен размером 256 × 256 пикселей Calibration Калибровка Red Primary Красный основной @@ -3318,14 +3307,6 @@ curves Smooth Сгладить Slab Serif С брусковыми засечками TrueType font Шрифт TrueType Variable font Вариативный шрифт - -# Custom shape set import - Import Shapes… Импортировать фигуры… - Choose a custom shape file (.csh). Выберите файл произвольных фигур (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Количество фигур, импортированных из {file}: {n}|Количество фигур, импортированных из {file}: {n}|Количество фигур, импортированных из {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Количество фигур, импортированных из {group}: {n}|Количество фигур, импортированных из {group}: {n}|Количество фигур, импортированных из {group}: {n} - -# Bevel & Emboss shading controls. Highlight Mode Режим светлых тонов Highlight Color Цвет светлых тонов Highlight Opacity Непрозрачность светлых тонов @@ -4713,10 +4694,6 @@ shape Appearance Оформление Font size presets Наборы размеров шрифта Leading presets Наборы интерлиньяжа Tracking presets Наборы трекинга - Selection / Layer Bounds Границы выделения / слоя - No selection or layer Нет выделения или слоя - {label}: empty {label}: пусто - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware С учетом цвета Decontaminate Colors Очистить цвета Edge Detection Обнаружение краев diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 0cbb0a944a..e29bb41dca 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1908,9 +1908,6 @@ Slice Select Tool Alatka za izbor isečka Slice Tool Alatka za isecanje na isečke Smart Filters Pametni filteri - Move Up Pomeri nagore - Move Down Pomeri nadole - Delete Smart Filter Obriši pametni filter Smudge Tool Alatka za razmazivanje Soft Meko Soften Omekšaj @@ -3015,7 +3012,6 @@ fontWeight Italic Kurziv Out of gamut for printing: click for the nearest printable colour Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa Not a web colour: click for the nearest web colour Nije veb boja: kliknite za najbližu veb boju Color to Alpha… Boja u alfu… - Frequency Separation… Razdvajanje frekvencija… Transparency Threshold Prag providnosti Opacity Threshold Prag neprozirnosti PhotoCraft — Screen Color Picker PhotoCraft — birač boja sa ekrana @@ -4286,8 +4282,6 @@ shape Appearance Izgled Reset All Locked Settings Vrati sva zaključana podešavanja Copy Texture to Other Tools Kopiraj teksturu u druge alatke Load Actions… Učitaj radnje… - Load Default Actions Učitaj podrazumevane radnje - Blur Background Zamuti pozadinu Save Actions… Sačuvaj radnje… New Action… Nova radnja… Start Recording Započni snimanje @@ -4318,7 +4312,6 @@ shape Appearance Izgled set in Print Settings… podešava se u podešavanjima štampanja… Paper Width Širina papira Paper Height Visina papira - Scrollbar Visibility Vidljivost traka za pomeranje ICO is limited to 256 × 256 pixels ICO je ograničen na 256 × 256 piksela Mask Options… Opcije maske… Layer Mask Display Options… Opcije prikaza maske sloja… @@ -4572,10 +4565,6 @@ shape Appearance Izgled Font size presets Unapred podešene veličine fonta Leading presets Unapred podešeni proredi Tracking presets Unapred podešeni razmaci slova - Selection / Layer Bounds Границе избора / слоја - No selection or layer Нема избора или слоја - {label}: empty {label}: празно - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Prema boji Decontaminate Colors Dekontaminiraj boje Edge Detection Detekcija ivice @@ -4593,9 +4582,4 @@ shape Appearance Izgled Select and Mask was closed: its document is no longer the active one Izaberi i maskiraj je zatvoren: njegov dokument više nije aktivan Select and Mask was closed: another layer was selected Izaberi i maskiraj je zatvoren: izabran je drugi sloj Select Layer Under Cursor Izaberi sloj ispod kursora - Import Shapes… Uvezi oblike… - Choose a custom shape file (.csh). Izaberite datoteku prilagođenih oblika (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Broj oblika uvezenih iz {file}: {n}|Broj oblika uvezenih iz {file}: {n}|Broj oblika uvezenih iz {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Broj oblika uvezenih iz {group}: {n}|Broj oblika uvezenih iz {group}: {n}|Broj oblika uvezenih iz {group}: {n} Drag to move the selected path Prevucite da pomerite izabranu putanju - Use PhotoCraft-native gradient icons Koristi sopstvene PhotoCraft ikone gradijenta diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6438ea7955..f914649d2d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1578,7 +1578,6 @@ Update layer comp (visibility, position and appearance) Katman kompozisyonunu güncelleyin (görünürlük, konum ve görünüm) Use Default Varsayılanı Kullan Use Legacy Eskiyi Kullan - Use PhotoCraft-native gradient icons PhotoCraft'ın kendi gradyan simgelerini kullan Use Measurement Scale Ölçüm Ölçeğini Kullan Variables bind a layer's visibility, text or pixels to a named data slot. Değişkenler bir katmanın görünürlüğünü, metnini veya piksellerini adlandırılmış bir veri yuvasına bağlar. Vertical scale Dikey ölçek @@ -1820,11 +1819,6 @@ Slice Select Tool Dilim Seçme Aracı Slice Tool Dilim Aracı Smart Filters Akıllı Filtreler -# Smart Filter row actions. - Move Up Yukarı taşı - Move Down Aşağı taşı - Delete Smart Filter Akıllı Filtreyi Sil - Smudge Tool Leke Aracı Soft Yumuşak Soften Yumuşat @@ -3944,12 +3938,9 @@ cameraData Lens Lens Response curve Yanıt eğrisi Show home recent files Ana ekranda son dosyaları göster Clear Brush Controls Fırça Denetimlerini Temizle - Frequency Separation… Frekans Ayırma… Reset All Locked Settings Tüm Kilitli Ayarları Sıfırla Copy Texture to Other Tools Dokuyu Diğer Araçlara Kopyala Load Actions… Eylemleri Yükle… - Load Default Actions Varsayılan Eylemleri Yükle - Blur Background Arka Planı Bulanıklaştır Save Actions… Eylemleri Kaydet… New Action… Yeni Eylem… Start Recording Kaydı Başlat @@ -4225,10 +4216,6 @@ cameraData Lens Lens Font size presets Yazı tipi boyutu hazır ayarları Leading presets Satır aralığı hazır ayarları Tracking presets İzleme hazır ayarları - Selection / Layer Bounds Seçim / Katman Sınırları - No selection or layer Seçim veya katman yok - {label}: empty {label}: boş - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Color Aware Renge Duyarlı Decontaminate Colors Renkleri Arındır Edge Detection Kenar Algılama @@ -4250,9 +4237,4 @@ cameraData Lens Lens Delete effect Efekti sil Current Layer: Geçerli Katman: Underlying Layer: Alttaki Katman: - Import Shapes… Şekilleri İçe Aktar… - Choose a custom shape file (.csh). Özel şekil dosyası (.csh) seçin. -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file} kaynağından içe aktarılan şekil sayısı: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group} kaynağından içe aktarılan şekil sayısı: {n} - Scrollbar Visibility Kaydırma çubuklarının görünürlüğü Drag to move the selected path Seçili yolu taşımak için sürükleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4b1973238d..8507592c6c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -400,11 +400,6 @@ New Video Layer from File… Новий відеошар із файлу… New Blank Video Layer Новий порожній відеошар Smart Filters Смартфільтри -# Smart Filter row actions (#1265) - Move Up Перемістити вгору - Move Down Перемістити вниз - Delete Smart Filter Видалити смарт-фільтр - Delete Smart Filters Видалити смартфільтри Enable Smart Filters Увімкнути смартфільтри Disable Smart Filters Вимкнути смартфільтри @@ -2462,7 +2457,6 @@ Image previews Перегляд зображень Integrations Інтеграції Interface Інтерфейс - Use PhotoCraft-native gradient icons Використовувати власні піктограми градієнта PhotoCraft Jpeg quality Якість JPEG Jpg JPG Large tabs Великі вкладки @@ -3685,7 +3679,6 @@ cameraRaw Masking Маскування 101 by 101 Average Середнє 101 × 101 All Layers No Adjustments Усі шари без коригувань Color to Alpha… Колір у прозорість… - Frequency Separation… Частотне розкладання… Current & Below No Adjustments Поточний і нижчі без коригувань Not a web colour: click for the nearest web colour Колір поза вебпалітрою: клацніть, щоб вибрати найближчий колір із неї Now drag the selection onto another area Тепер перетягніть виділення на іншу ділянку @@ -3966,8 +3959,6 @@ curves Smooth Згладити Reset All Locked Settings Скинути всі заблоковані параметри Copy Texture to Other Tools Скопіювати текстуру в інші інструменти Load Actions… Завантажити операції… - Load Default Actions Завантажити типові дії - Blur Background Розмити тло Save Actions… Зберегти операції… New Action… Нова операція… Start Recording Почати запис @@ -4215,10 +4206,6 @@ curves Smooth Згладити Font size presets Набори розмірів шрифту Leading presets Набори інтерліньяжу Tracking presets Набори трекінгу - Selection / Layer Bounds Межі виділення / шару - No selection or layer Немає виділення або шару - {label}: empty {label}: порожньо - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection Очистити виділення Color Aware З урахуванням кольору Decontaminate Colors Очистити кольори @@ -4236,9 +4223,3 @@ curves Smooth Згладити Refine Hair needs hair detection, which isn't available yet Для уточнення волосся потрібне розпізнавання волосся, яке ще недоступне Select and Mask was closed: its document is no longer the active one Вікно «Виділити та маскувати» закрито: його документ більше не активний Select and Mask was closed: another layer was selected Вікно «Виділити та маскувати» закрито: вибрано інший шар - Import Shapes… Імпортувати фігури… - Choose a custom shape file (.csh). Виберіть файл довільних фігур (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Кількість фігур, імпортованих із {file}: {n}|Кількість фігур, імпортованих із {file}: {n}|Кількість фігур, імпортованих із {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Кількість фігур, імпортованих із {group}: {n}|Кількість фігур, імпортованих із {group}: {n}|Кількість фігур, імпортованих із {group}: {n} - Scrollbar Visibility Видимість смуг прокручування - Hidden Приховано diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 69f7d3ee92..15fe0fbcee 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -158,9 +158,6 @@ # Art History Brush (#213). # Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) - -# Custom shape set import - Mask Options… Tùy chọn mặt nạ… Layer Mask Display Options… Tùy chọn hiển thị mặt nạ lớp… Channel Color Màu kênh @@ -1202,7 +1199,6 @@ Justify last centered Căn đều, dòng cuối giữa Justify last right Căn đều, dòng cuối phải Justify all Căn đều tất cả - Roman Hanging Punctuation Dấu câu treo kiểu La Mã Indent left margin Thụt lề trái Indent right margin Thụt lề phải Indent first line Thụt dòng đầu @@ -1586,7 +1582,6 @@ cameraRaw Shadows Vùng tối Inches Inch Integrations Tích hợp Interface Giao diện - Use PhotoCraft-native gradient icons Dùng biểu tượng chuyển sắc riêng của PhotoCraft Jpeg quality Chất lượng JPEG Jpg JPG Language Ngôn ngữ @@ -2118,7 +2113,6 @@ layers Fill: Độ tô: Color Picker (Layer Style Color) Ống hút màu (Màu kiểu lớp) Color Picker (Text Color) Ống hút màu (Màu chữ) Color to Alpha… Màu sang alpha… - Frequency Separation… Tách tần số… Corner radius Bán kính góc Create new shape from the current path Tạo hình mới từ đường dẫn hiện tại Create new style from the selected layer Tạo kiểu mới từ lớp đã chọn @@ -2730,11 +2724,6 @@ shape Appearance Diện mạo SemiBold Nửa đậm Show Extras Options Tuỳ chọn hiện phụ Smart Filters Các bộ lọc thông minh -# Smart Filter row actions. - Move Up Di chuyển lên - Move Down Di chuyển xuống - Delete Smart Filter Xóa bộ lọc thông minh - Soft Mềm Source: set Nguồn: bộ Square, 12 x 12 Vuông, 12 x 12 @@ -3726,8 +3715,6 @@ curves Smooth Làm mượt Reset All Locked Settings Đặt lại tất cả cài đặt đã khóa Copy Texture to Other Tools Sao chép họa tiết sang các công cụ khác Load Actions… Nạp hành động… - Load Default Actions Tải thao tác mặc định - Blur Background Làm mờ nền Save Actions… Lưu hành động… New Action… Hành động mới… Start Recording Bắt đầu ghi @@ -3806,10 +3793,6 @@ curves Smooth Làm mượt Could not write {name}: {error} Không thể ghi {name}: {error} Kept {name}: written by another application Đã giữ {name}: do ứng dụng khác ghi Shadow Tint Sắc độ bóng tối - Import Shapes… Nhập hình dạng… - Choose a custom shape file (.csh). Chọn tệp hình dạng tùy chỉnh (.csh). -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Số hình dạng đã nhập từ {file}: {n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Số hình dạng đã nhập từ {group}: {n} Check for Updates… Kiểm tra bản cập nhật… PhotoCraft Updates Bản cập nhật PhotoCraft Checking for updates… Đang kiểm tra bản cập nhật… @@ -4009,10 +3992,6 @@ curves Smooth Làm mượt Font size presets Cỡ chữ đặt sẵn Leading presets Giãn dòng đặt sẵn Tracking presets Khoảng cách chữ đặt sẵn - Selection / Layer Bounds Giới hạn vùng chọn / lớp - No selection or layer Không có vùng chọn hoặc lớp - {label}: empty {label}: trống - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection Xóa vùng chọn Color Aware Nhận biết màu Decontaminate Colors Khử nhiễm màu @@ -4031,5 +4010,3 @@ curves Smooth Làm mượt Select and Mask was closed: its document is no longer the active one Đã đóng Chọn và Mặt nạ: tài liệu của nó không còn là tài liệu đang hoạt động Select and Mask was closed: another layer was selected Đã đóng Chọn và Mặt nạ: một lớp khác đã được chọn Select Layer Under Cursor Chọn lớp dưới con trỏ - Scrollbar Visibility Hiển thị thanh cuộn - Hidden Ẩn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 77e060b365..8f59bf7098 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1353,7 +1353,6 @@ Italic 斜体 Join us on Discord 加入我们的 Discord 社区 Justify all 全部两端对齐 - Roman Hanging Punctuation 罗马字悬挂标点 Justify last centered 两端对齐,末行居中 Justify last left 两端对齐,末行左对齐 Justify last right 两端对齐,末行右对齐 @@ -1969,9 +1968,6 @@ Slice Select Tool 切片选择工具 Slice Tool 切片工具 Smart Filters 智能滤镜 - Move Up 上移 - Move Down 下移 - Delete Smart Filter 删除智能滤镜 Smudge Tool 涂抹工具 Soft 软 Soften 柔化 @@ -2112,7 +2108,6 @@ Image previews 图像预览 Integrations 集成 Interface 界面 - Use PhotoCraft-native gradient icons 使用 PhotoCraft 原生渐变图标 Jpeg quality JPEG 品质 Jpg JPG Language 语言 @@ -2631,7 +2626,6 @@ fontWeight Italic 斜体 Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色 Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色 Color to Alpha… 颜色转为 Alpha… - Frequency Separation… 频率分离… Transparency Threshold 透明度阈值 Opacity Threshold 不透明度阈值 PhotoCraft — Screen Color Picker PhotoCraft — 屏幕取色器 @@ -2919,8 +2913,6 @@ curves Smooth 平滑 Reset All Locked Settings 复位所有锁定的设置 Copy Texture to Other Tools 将纹理复制到其他工具 Load Actions… 载入动作… - Load Default Actions 载入默认动作 - Blur Background 模糊背景 Save Actions… 存储动作… New Action… 新建动作… Start Recording 开始录制 @@ -3185,10 +3177,6 @@ curves Smooth 平滑 Font size presets 字号预设 Leading presets 行距预设 Tracking presets 字距调整预设 - Selection / Layer Bounds 选区 / 图层边界 - No selection or layer 无选区或图层 - {label}: empty {label}:空 - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection 清除选区 Color Aware 颜色识别 Decontaminate Colors 净化颜色 @@ -3206,10 +3194,4 @@ curves Smooth 平滑 Refine Hair needs hair detection, which isn't available yet 调整细线需要毛发检测,目前尚不可用 Select and Mask was closed: its document is no longer the active one 已关闭“选择与蒙版”:其文档不再是当前文档 Select and Mask was closed: another layer was selected 已关闭“选择与蒙版”:已选择另一个图层 - Import Shapes… 导入形状… - Choose a custom shape file (.csh). 请选择自定义形状文件 (.csh)。 -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} 从 {file} 导入的形状数量:{n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} 从 {group} 导入的形状数量:{n} - Scrollbar Visibility 滚动条可见性 - Hidden 隐藏 Drag to move the selected path 拖动以移动选中的路径 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 26040fd5ae..06a9a40d07 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1334,7 +1334,6 @@ Italic 斜體 Join us on Discord 加入我們的 Discord Justify all 全部齊行 - Roman Hanging Punctuation 羅馬字懸掛標點 Justify last centered 最後一行置中齊行 Justify last left 最後一行靠左齊行 Justify last right 最後一行靠右齊行 @@ -1939,9 +1938,6 @@ Slice Select Tool 切片選取工具 Slice Tool 切片工具 Smart Filters 智慧型濾鏡 - Move Up 上移 - Move Down 下移 - Delete Smart Filter 刪除智慧型濾鏡 Smudge Tool 指尖工具 Soft 柔邊 Soften 柔化 @@ -2080,7 +2076,6 @@ Image previews 影像預視 Integrations 整合 Interface 介面 - Use PhotoCraft-native gradient icons 使用 PhotoCraft 原生漸層圖示 Jpeg quality JPEG 品質 Jpg JPG Language 語言 @@ -2578,7 +2573,6 @@ fontWeight Italic 斜體 Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色 Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色 Color to Alpha… 顏色轉為 Alpha… - Frequency Separation… 頻率分離… Transparency Threshold 透明度臨界值 Opacity Threshold 不透明度臨界值 PhotoCraft — Screen Color Picker PhotoCraft — 螢幕取色器 @@ -2880,8 +2874,6 @@ curves Smooth 平滑 Reset All Locked Settings 重設所有鎖定的設定 Copy Texture to Other Tools 複製紋理至其他工具 Load Actions… 載入動作… - Load Default Actions 載入預設動作 - Blur Background 模糊背景 Save Actions… 儲存動作… New Action… 新增動作… Start Recording 開始錄製 @@ -3141,10 +3133,6 @@ curves Smooth 平滑 Font size presets 字體大小預設 Leading presets 行距預設 Tracking presets 字距調整預設 - Selection / Layer Bounds 選取範圍 / 圖層邊界 - No selection or layer 沒有選取範圍或圖層 - {label}: empty {label}:空 - {label}: X {x} Y {y} W {w} H {h} {label}: X {x} Y {y} W {w} H {h} Clear Selection 清除選取範圍 Color Aware 顏色感知 Decontaminate Colors 淨化顏色 @@ -3162,10 +3150,4 @@ curves Smooth 平滑 Refine Hair needs hair detection, which isn't available yet 調整毛髮需要毛髮偵測,目前尚無法使用 Select and Mask was closed: its document is no longer the active one 已關閉「選取並遮住」:其文件已不是使用中的文件 Select and Mask was closed: another layer was selected 已關閉「選取並遮住」:已選取另一個圖層 - Import Shapes… 匯入形狀… - Choose a custom shape file (.csh). 請選擇自訂形狀檔案 (.csh)。 -@plural Imported {n} shape from {file}|Imported {n} shapes from {file} 從 {file} 匯入的形狀數量:{n} -@plural Imported {n} shape from {group}|Imported {n} shapes from {group} 從 {group} 匯入的形狀數量:{n} - Scrollbar Visibility 捲軸可見性 - Hidden 隱藏 Drag to move the selected path 拖曳以移動選取的路徑 diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index e9731f912f..a4c30646e7 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -510,16 +510,6 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { return; } match e.outcome { - JobOutcome::Done(v) if e.command == "shape.presets.importCsh" => { - let n = v.get("count").and_then(Value::as_u64).unwrap_or(0); - let group = v.get("group").and_then(Value::as_str).unwrap_or_default(); - app.ui.status = crate::i18n::fmt( - &crate::i18n::trn(crate::i18n::current(), n, "Imported {n} shape from {group}", "Imported {n} shapes from {group}"), - &[("group", group)], - ); - app.ui.status_error = false; - app.ui.presets_ui.shapes = true; - } JobOutcome::Done(v) if e.command == "brush.presets.importAbr" => { let n = v.get("count").and_then(Value::as_u64).unwrap_or(0); let group = v.get("group").and_then(Value::as_str).unwrap_or_default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f851d534b1..be89cb3044 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,7 +102,6 @@ pub use canvas::zoom_levels; pub use canvas::zoom_window_fit; pub use dialogs::adjust_dialog; pub use dialogs::adjust_editors; -pub(crate) use dialogs::adjust_presets; pub use dialogs::adjust_preview; pub(crate) use dialogs::bitmap_dialog; pub use dialogs::blur_pins; @@ -864,5 +863,3 @@ mod saved_path_edit_tests; #[cfg(test)] mod pen_path_ownership_tests; - -pub mod bidi_label; diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 36457b47ae..feb4459db1 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -287,9 +287,6 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: if id == "layer.selectUnderCursor" { return select_layer_under_cursor(app, ctx); } - if let Some(command) = crate::actions::undo_command(app, id) { - return app.run(command, params); - } if let Some(result) = invoke_early_commands(app, ctx, id, ¶ms) { return result; } diff --git a/crates/ui-egui/src/menus/items.rs b/crates/ui-egui/src/menus/items.rs index c9f2eb5275..37944cca5f 100644 --- a/crates/ui-egui/src/menus/items.rs +++ b/crates/ui-egui/src/menus/items.rs @@ -43,7 +43,6 @@ const PLACE_AFTER: &[(&str, &str)] = &[ ("filter.render.relight", "filter.render.lightingEffects"), ("view.resetView", "view.flipHorizontal"), ("filter.other.colorToAlpha", "filter.other.offset"), - ("layer.frequencySeparation", "filter.other.colorToAlpha"), ]; pub fn menu_items(app: &PhotocraftApp) -> Vec { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 27dc4f7a4f..c932ca092c 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -9,9 +9,6 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { if crate::tool_shortcuts::tool(id).is_some() { return true; } - if let Some(command) = crate::actions::undo_command(app, id) { - return photocraft_engine::commands::find(command).is_some_and(|spec| (spec.enabled)(&app.session).is_ok()); - } if id == "ui.symmetryTransform" { return crate::symmetry_ui::can_transform(app); } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index dedc4e6854..2527b0fc3f 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -122,22 +122,6 @@ fn color_to_alpha_sits_in_filter_other_after_offset() { assert!(!others.iter().any(|p| p.as_str() == "Other"), "no duplicate Other submenu: {others:?}"); } -/// Frequency Separation (#1714) is no Photoshop menu item either (it is a recorded action -/// there): it follows Color to Alpha in Filter › Other and asks for its radius. -#[test] -fn frequency_separation_sits_in_filter_other_after_color_to_alpha() { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); - let items = menu_items(&app); - let at = |id: &str| items.iter().position(|i| i.id == id).unwrap_or_else(|| panic!("{id} missing")); - let (c2a, fs) = (at("filter.other.colorToAlpha"), at("layer.frequencySeparation")); - assert_eq!(fs, c2a + 1); - assert_eq!(items[fs].path, items[at("filter.other.highPass")].path); - assert_eq!(items[fs].label, "Frequency Separation…"); - assert!(items[fs].enabled, "the Background is a pixel layer with pixels"); - assert!(crate::filter_dialog::has_dialog("layer.frequencySeparation")); -} - #[test] fn menu_bar_labels_have_horizontal_padding_and_open_menus() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 0280e7710e..c1ba436d22 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -28,8 +28,6 @@ pub struct ActionsUi { #[serde(default)] pub button_mode: bool, #[serde(skip)] - pub(crate) focused: bool, - #[serde(skip)] reveal_recording: Option<(usize, usize)>, /// The action whose name is being edited in place (double-click on its name). #[serde(skip)] @@ -98,7 +96,6 @@ pub(crate) fn assign_shortcut(app: &mut PhotocraftApp, name: &str, shortcut: &st /// Replay edits synchronously, with view steps handled by the shell in the same order. /// Engine steps keep the headless action semantics (no dialogs or background jobs). pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result { - app.ui.actions.focused = false; play_owned(app, params, None) } @@ -375,13 +372,7 @@ fn source(name: &str, bytes: &[u8]) -> Value { /// Load a `.atn` file's bytes into the Actions panel (the panel menu's Load Actions…). pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result { - let is_json = std::path::Path::new(name).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("json")); - let r = if is_json { - let data = std::str::from_utf8(bytes).map_err(|e| e.to_string())?; - app.run("actions.import", json!({"data": data}))? - } else { - app.run("actions.load", source(name, bytes))? - }; + let r = app.run("actions.load", source(name, bytes))?; let n = r["actions"].as_array().map_or(0, Vec::len) as u64; let unsupported = r["unsupported"].as_array().map_or(0, Vec::len); let shown = crate::file_open::display_name(name); @@ -402,7 +393,7 @@ pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result Result Result { - app.pick_file_bytes_filtered(&["atn", "json"], |app, name, bytes| load_bytes(app, &name, &bytes)) -} - -fn save_json_dialog(app: &mut PhotocraftApp) -> Result { - let r = app.run("actions.export", json!({}))?; - let data = r["data"].as_str().ok_or("the export returned no JSON data")?.to_string(); - let n = r["count"].as_u64().unwrap_or(0); - app.pick_save("Actions.json", move |app, path| { - let write = app.services.write.as_mut().ok_or("no writer configured")?; - write(&path, data.as_bytes())?; - let shown = crate::file_open::display_name(&path); - app.ui.status = - crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, "Saved {n} action to {file}", "Saved {n} actions to {file}"), &[("file", &shown)]); - app.ui.status_error = false; - Ok(json!({"path": path, "actions": n})) - }) + app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes)) } /// Save Actions…: the selected action's set (every action of it), or every action when none @@ -657,9 +633,6 @@ pub fn stopped(app: &mut PhotocraftApp, fields: &serde_json::Map) /// The Actions entries of the panel group's menu ("…"). pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut act: Option<&str> = None; - if ui.add_enabled(app.session.is_enabled("actions.loadDefaults"), egui::Button::new(tl!("Load Default Actions"))).clicked() { - act = Some("defaults"); - } let recording = app.session.actions.recording.is_some(); let any = !app.session.actions.list.is_empty(); let selected = app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()); @@ -691,13 +664,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() { act = Some("save"); } - if ui.add_enabled(any, egui::Button::new(format!("{} (.json)", tl!("Save Actions…")))).clicked() { - act = Some("save-json"); - } let Some(a) = act else { return }; ui.close(); let r = match a { - "defaults" => load_defaults(app), "new" => { begin_recording(app, false); Ok(Value::Null) @@ -717,7 +686,6 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Ok(Value::Null) } "load" => load_dialog(app), - "save-json" => save_json_dialog(app), "stop" => insert_stop_dialog(app), _ => save_dialog(app, None), }; @@ -729,21 +697,6 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } -fn load_defaults(app: &mut PhotocraftApp) -> Result { - let result = app.run("actions.loadDefaults", json!({}))?; - if let Some(name) = result["added"].as_array().and_then(|names| names.first()).and_then(Value::as_str) - && let Some(index) = app.session.actions.list.iter().position(|action| action.name == name) - { - app.ui.actions.selected = Some(index); - app.ui.actions.selected_step = None; - app.ui.actions.renaming = None; - if let Some(set) = result["set"].as_str() { - app.ui.actions.collapsed_sets.retain(|collapsed| collapsed != set); - } - } - Ok(result) -} - #[derive(Clone, Copy)] struct DragRow { action: usize, @@ -960,22 +913,8 @@ fn action_context_menu(app: &mut PhotocraftApp, response: &egui::Response, index }); } -pub(crate) fn undo_command(app: &PhotocraftApp, id: &str) -> Option<&'static str> { - if !app.ui.actions.focused { - return None; - } - match id { - "edit.undo" => Some("actions.undo"), - "edit.redo" => Some("actions.redo"), - _ => None, - } -} - pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - if ui.input(|i| i.pointer.any_pressed()) { - app.ui.actions.focused = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| ui.max_rect().contains(p)); - } let recording = app.session.actions.recording; let rows: Vec = app .session @@ -1026,12 +965,6 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } egui::ScrollArea::vertical().id_salt("actions-rows").max_height(max_h).min_scrolled_height(max_h).auto_shrink([false, false]).show(ui, |ui| { if rows.is_empty() { - if ui.button(tl!("Load Default Actions")).clicked() - && let Err(error) = load_defaults(app) - { - app.ui.status = error; - app.ui.status_error = true; - } ui.label(egui::RichText::new(tl!("Record ● a sequence of edits, then play ▶ it on any document.")).color(t.text_faint).size(11.5)); } scroll_actions_during_drag(ui); diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index b5a608dea3..a2f7629513 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -29,73 +29,6 @@ fn panel_menu_lists_load_save_and_existing_action_commands() { assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1); } -#[test] -fn loading_json_actions_uses_engine_import_and_selects_the_new_action() { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let action = Action::new("Portable", vec![("select.all".into(), json!({}))]); - let file = actions_cmds::ActionsFile { version: 1, defaults_version: 0, actions: vec![action.clone()] }; - let bytes = serde_json::to_vec(&file).unwrap(); - assert_eq!(load_bytes(&mut app, "Actions.JSON", &bytes).unwrap()["imported"], 1); - assert_eq!(app.session.actions.list, [action]); - assert_eq!(app.ui.actions.selected, Some(0)); - assert_eq!(app.ui.actions.selected_step, None); - assert!(app.ui.actions.expanded[0]); - assert!(!app.ui.status_error); - let before = app.session.actions.clone(); - assert_eq!(load_bytes(&mut app, "Actions.json", &bytes).unwrap()["imported"], 0); - assert_eq!(app.session.actions, before); -} - -#[test] -fn default_actions_load_from_empty_panel_and_play() { - use egui_kittest::{Harness, kittest::Queryable}; - let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut h = Harness::builder() - .with_size(vec2(360.0, 400.0)) - .with_context_setup(|ctx| PhotocraftApp::setup_context(ctx, Default::default())) - .build_ui_state(|ui, app| panel(app, ui), app); - h.run_steps(3); - h.get_by_label("Load Default Actions").click(); - h.run_steps(3); - assert!(h.query_by_label("PhotoCraft Essentials").is_some()); - for name in ["Auto Tone", "Auto Contrast", "Auto Color", "Select Subject", "Blur Background"] { - assert!(h.query_by_label(name).is_some(), "missing preset {name}"); - } - assert_eq!(h.state().ui.actions.selected, Some(0)); - h.state_mut().run("file.new", json!({"width": 16, "height": 16})).unwrap(); - h.run_steps(3); - h.get_by_label("Play selection").click(); - h.run_steps(3); - assert!(!h.state().ui.status_error, "{}", h.state().ui.status); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "image.autoTone")); -} - -#[test] -fn default_actions_flyout_preserves_custom_actions_and_reveals_restored_set() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let custom = Action::new("Auto Tone", vec![("view.fitOnScreen".into(), json!({}))]); - app.session.actions.list.push(custom.clone()); - app.ui.actions.collapsed_sets.push("PhotoCraft Essentials".into()); - let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state( - |ui, app| { - ui.menu_button("Actions menu", |ui| panel_menu(app, ui)); - panel(app, ui); - }, - app, - ); - h.run_steps(3); - h.get_by_label("Actions menu").click(); - h.run_steps(3); - h.get_by_label("Load Default Actions").click(); - h.run_steps(3); - assert_eq!(h.state().session.actions.list[0], custom); - assert_eq!(h.state().session.actions.list.len(), 5); - assert_eq!(h.state().ui.actions.selected, Some(1)); - assert!(h.state().ui.actions.collapsed_sets.is_empty()); - assert!(h.query_by_label("Select Subject").is_some()); -} - #[test] fn selected_layer_name_is_visible_during_recording_and_after_stop() { use egui_kittest::{Harness, kittest::Queryable}; @@ -172,31 +105,6 @@ fn a_function_key_with_shift_and_command_plays_its_action() { assert_eq!(layers(&app), before + 1, "Cmd+Shift+F2 plays it"); } -#[test] -fn actions_shortcut_undo_and_redo_edit_script_without_undoing_pixels() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); - app.run("layer.new.layer", json!({})).unwrap(); - app.session.actions.list.push(Action::new("A", vec![("select.all".into(), json!({}))])); - let doc = app.session.active().unwrap().doc.clone(); - let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app); - h.run_steps(3); - h.get_by_label("A").click(); - h.run_steps(3); - assert!(h.state().ui.actions.focused); - h.state_mut().run("actions.delete", json!({"action": "A", "step": 0})).unwrap(); - let ctx = egui::Context::default(); - crate::shortcut_dispatch::dispatch(h.state_mut(), &ctx, "edit.undo"); - assert_eq!(h.state().session.actions.list[0].steps.len(), 1); - assert_eq!(h.state().session.active().unwrap().doc, doc); - crate::shortcut_dispatch::dispatch(h.state_mut(), &ctx, "edit.redo"); - assert!(h.state().session.actions.list[0].steps.is_empty()); - h.state_mut().ui.actions.focused = false; - crate::shortcut_dispatch::dispatch(h.state_mut(), &ctx, "edit.undo"); - assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1); -} - #[test] fn shell_playback_resolves_recorded_eye_target_on_new_document() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/chrome_ui.rs b/crates/ui-egui/src/panels/chrome_ui.rs index 38671808f0..f5e58a455d 100644 --- a/crates/ui-egui/src/panels/chrome_ui.rs +++ b/crates/ui-egui/src/panels/chrome_ui.rs @@ -51,7 +51,6 @@ pub const STATUS_INFO: &[(&str, &str)] = &[ ("efficiency", "Efficiency"), ("tool", "Current Tool"), ("layers", "Layer Count"), - ("coordinates", "Selection / Layer Bounds"), ]; /// Photoshop's compact byte format: "10.3M", "412.5K". @@ -121,31 +120,6 @@ fn status_info_text_with_units(doc: &Document, key: &str, tool: &str, profile: & } } -/// Report document-pixel geometry from the selection mask or active layer. -fn status_bounds_text(doc: &Document, active_layer: Option) -> String { - let selection = doc.selection.as_ref().map(|s| s.content_bounds()).filter(|r| !r.is_empty()); - let (label, bounds) = if let Some(r) = selection { - (tl!("Selection"), r) - } else if let Some(layer) = active_layer.and_then(|id| doc.layer(id)) { - (tl!("Layer"), photocraft_compose::layer_bounds(layer, doc.bounds())) - } else { - return tl!("No selection or layer").into(); - }; - if bounds.is_empty() { - return crate::i18n::fmt(tl!("{label}: empty"), &[("label", label)]); - } - crate::i18n::fmt( - tl!("{label}: X {x} Y {y} W {w} H {h}"), - &[ - ("label", label), - ("x", &bounds.x0.to_string()), - ("y", &bounds.y0.to_string()), - ("w", &bounds.width().to_string()), - ("h", &bounds.height().to_string()), - ], - ) -} - fn profile_name(doc: &Document) -> String { let mode = crate::canvas::mode_label(doc); let Some(bytes) = doc.icc_profile.as_ref() else { @@ -174,17 +148,13 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { status_message(app, ui, &t); return; }; - let text = if app.ui.chrome.status_info == "coordinates" { - status_bounds_text(&st.doc, st.active_layer) - } else { - status_info_text_with_units( - &st.doc, - &app.ui.chrome.status_info, - tl!(app.ui.tool.label()), - &profile_name(&st.doc), - &app.session.prefs().units_and_rulers, - ) - }; + let text = status_info_text_with_units( + &st.doc, + &app.ui.chrome.status_info, + tl!(app.ui.tool.label()), + &profile_name(&st.doc), + &app.session.prefs().units_and_rulers, + ); let mut pct = app.ui.views[i].zoom * 100.0; let control_h = crate::panels::status_bar_heights(&t).1; if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(64.0, control_h)) @@ -210,7 +180,7 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.set_min_width(200.0); for (key, label) in STATUS_INFO { let on = app.ui.chrome.status_info == *key; - if ui.add(egui::Button::selectable(on, tl!(*label))).clicked() { + if ui.add(egui::Button::selectable(on, *label)).clicked() { app.ui.chrome.status_info = (*key).to_string(); ui.close(); } @@ -335,20 +305,6 @@ mod tests { (*s.active().unwrap().doc).clone() } - #[test] - fn status_bounds_follow_the_actual_selection_and_active_layer() { - let d = doc(); - let id = d.layers[0].id; - assert_eq!(status_bounds_text(&d, Some(id)), "Layer: X 0 Y 0 W 2400 H 1500"); - assert_eq!(status_bounds_text(&d, None), "No selection or layer"); - - let mut s = photocraft_engine::Session::new(); - s.execute("file.new", json!({"width": 120, "height": 80})).unwrap(); - s.execute("select.all", json!({})).unwrap(); - let doc = &s.active().unwrap().doc; - assert_eq!(status_bounds_text(doc, None), "Selection: X 0 Y 0 W 120 H 80"); - } - #[test] fn default_status_shows_dimensions_like_photoshop() { let d = doc(); @@ -375,7 +331,7 @@ mod tests { ] { let units = UnitsAndRulers { rulers: unit, ..Default::default() }; assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), expected); - for (key, _) in STATUS_INFO.iter().filter(|(key, _)| *key != "dimensions" && *key != "coordinates") { + for (key, _) in STATUS_INFO.iter().filter(|(key, _)| *key != "dimensions") { assert_eq!(status_info_text_with_units(&d, key, "Brush Tool", "sRGB", &units), status_info_text(&d, key, "Brush Tool", "sRGB")); } } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 924440b656..d83c1cbbb4 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -105,7 +105,6 @@ fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) { } pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - crate::smart_ui::context_menu::close_stale(app, ui.ctx()); // A new active layer opens its parent groups and is scrolled into view (#152). let reveal = crate::layer_reveal::track(app, ui.ctx()); let Some(st) = app.session.active() else { @@ -549,12 +548,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } continue; } - let selection = crate::smart_ui::context_menu::selection_update(app, ui.ctx(), &id, &p); - if app.run(&id, p).is_ok() - && let Some(selection) = selection - { - selection.apply(ui.ctx()); - } + let _ = app.run(&id, p); } } diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 328552a806..e03fa24e7a 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -427,9 +427,16 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: widgets::vline(ui, 22.0); ui.spacing_mut().item_spacing.x = 2.0; let before = app.ui.tool_options.clone(); - if let Some(style) = crate::gradient_ui::style_buttons(ui, &app.ui.tool_options.gradient_style, app.session.prefs().interface.native_gradient_icons) - { - app.ui.tool_options.gradient_style = style.into(); + for (style, icon, tip) in [ + ("linear", "gradient", tl!("Linear Gradient")), + ("radial", "circle", tl!("Radial Gradient")), + ("angle", "rotate-cw", tl!("Angle Gradient")), + ("reflected", "arrow-left-right", tl!("Reflected Gradient")), + ("diamond", "diamond", tl!("Diamond Gradient")), + ] { + if icons::button(ui, icon, 24.0, app.ui.tool_options.gradient_style == style, tip).clicked() { + app.ui.tool_options.gradient_style = style.into(); + } } ui.spacing_mut().item_spacing.x = 8.0; widgets::vline(ui, 22.0); diff --git a/crates/ui-egui/src/panels/preset_panels.rs b/crates/ui-egui/src/panels/preset_panels.rs index 05dc95f868..573d3d2894 100644 --- a/crates/ui-egui/src/panels/preset_panels.rs +++ b/crates/ui-egui/src/panels/preset_panels.rs @@ -701,7 +701,6 @@ fn canvas_drop_rect(app: &PhotocraftApp) -> egui::Rect { crate::rulers::content_rect(app, app.last_canvas_rect) } -pub(crate) use panels::gradient_picker; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/panels/preset_panels/panels.rs b/crates/ui-egui/src/panels/preset_panels/panels.rs index e5860c68e4..4b0e05fbd6 100644 --- a/crates/ui-egui/src/panels/preset_panels/panels.rs +++ b/crates/ui-egui/src/panels/preset_panels/panels.rs @@ -278,9 +278,6 @@ fn export_styles(app: &mut PhotocraftApp, params: Value, suggested: &str) -> Res // ------------------------------------------------------------------ Shapes pub fn shapes_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - if ui.button(tl!("Import Shapes…")).clicked() { - crate::preset_files_ui::import_shapes_dialog(app); - } let src = photocraft_engine::presets::shapes::all_groups(&app.session); let groups: Vec = src .iter() @@ -693,42 +690,3 @@ pub fn clone_sample_point(app: &PhotocraftApp, at: Option<[f64; 2]>) -> Option<[ _ => Some(src), } } - -fn gradient_browser(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str, place: Place<'_>) -> Vec { - let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); - let groups_src = app.session.presets.gradients.clone(); - let groups: Vec = groups_src - .iter() - .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }) - .collect(); - let mut st = std::mem::take(&mut app.ui.presets_ui); - let events = browser(ui, &mut st, panel, &groups, place, &mut |ui, r, gi, ii| { - let stops = groups_src[gi].items[ii].resolve(fg, bg); - paint_gradient(ui, r, &stops); - }); - app.ui.presets_ui = st; - events -} - -/// Editor/popup selection recolours the current target, never creates another layer. -/// The sidebar retains its separate double-click and canvas-drop Apply behavior. -pub(crate) fn gradient_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str, apply_to_layer: bool, new_params: Value) -> bool { - let place = Place { canvas: Rect::NOTHING, max_h: 220.0, new_tip: tl!("Create new gradient"), flush: false }; - let events = gradient_browser(app, ui, panel, place); - let mut changed = false; - for e in events { - match &e { - Ev::Select(k) | Ev::Activate(k) => { - if run(app, "gradient.presets.select", json!({"preset": k, "applyToLayer": apply_to_layer})).is_some() { - app.ui.presets_ui.selected.insert(panel.into(), k.clone()); - app.ui.presets_ui.selected.insert("gradients".into(), k.clone()); - changed = true; - } - } - _ => { - generic(app, panel, "gradient.presets", &e, new_params.clone()); - } - } - } - changed -} diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index 008c681668..8e531b47b6 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -205,11 +205,6 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, doc: &Document, l { open_editor(app, ui.ctx(), l.id.0, sm, i); } - if let Some(i) = index { - // The name is a child interaction target and owns secondary clicks too. - let menu_response = name_resp.as_ref().map_or_else(|| resp.clone(), |name| resp.clone().union(name.clone())); - context_menu::show(app, &menu_response, l.id, i, actions); - } if let (Some(i), Some(r)) = (index, blend) { let blend_resp = ui.interact(r, ui.scope_id().with(("sf-blend", l.id.0, i)), Sense::click()); icons::paint(ui, r, "sliders-horizontal", 13.0, if blend_resp.hovered() { t.text } else { t.icon }); @@ -854,9 +849,3 @@ mod tests { } } } - -pub(crate) mod context_menu; -#[cfg(test)] -mod context_tests; -#[cfg(test)] -mod menu_guard_tests; diff --git a/crates/ui-egui/src/panels/smart_ui/context_menu.rs b/crates/ui-egui/src/panels/smart_ui/context_menu.rs deleted file mode 100644 index 7623699969..0000000000 --- a/crates/ui-egui/src/panels/smart_ui/context_menu.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Per-filter reorder/delete convenience actions. The engine owns edits and Undo/Redo; a -//! transient weak snapshot prevents an open menu from following a different filter or document. - -use std::sync::{Arc, Weak}; - -use photocraft_doc::{DocId, Document, LayerContent, LayerId}; -use serde_json::{Value, json}; - -use crate::PhotocraftApp; - -#[derive(Clone)] -struct Target { - snapshot: Weak, - document: DocId, - revision: u64, - layer: LayerId, - index: usize, - popup: egui::Id, -} - -fn state_id() -> egui::Id { - egui::Id::unique("smart-filter-row-menu-target") -} - -#[derive(Clone)] -struct PendingSelection { - target: Target, - command: &'static str, - params: Value, - to: Option, -} - -fn pending_id() -> egui::Id { - egui::Id::unique("smart-filter-row-menu-selection") -} - -/// A menu edit changes storage indices. Apply its row selection only after the engine succeeds. -pub(crate) struct SelectionUpdate { - document: DocId, - before: super::FilterDrag, - after: Option, -} - -impl SelectionUpdate { - pub(crate) fn apply(self, ctx: &egui::Context) { - ctx.data_mut(|data| { - let key = super::selected_key(self.document); - if data.get_temp::(key) == Some(self.before) { - if let Some(after) = self.after { - data.insert_temp(key, after); - } else { - data.remove::(key); - } - } - }); - } -} - -/// Only choices queued by this row menu remap selection; other command origins keep their policy. -pub(crate) fn selection_update(app: &PhotocraftApp, ctx: &egui::Context, command: &str, params: &Value) -> Option { - let pending = ctx.data(|d| d.get_temp::(pending_id()))?; - if pending.command != command || &pending.params != params { - return None; - } - ctx.data_mut(|d| d.remove::(pending_id())); - let count = pending.target.count(app)?; - let before = ctx.data(|d| d.get_temp::(super::selected_key(pending.target.document)))?; - let (layer, Some(picked)) = before else { return None }; - if layer != pending.target.layer.0 || picked >= count { - return None; - } - let from = pending.target.index; - let next = match pending.to { - Some(to) if picked == from => Some(to), - Some(to) if from < to && picked > from && picked <= to => picked.checked_sub(1), - Some(to) if from > to && picked >= to && picked < from => picked.checked_add(1), - None if picked == from => None, - None if picked > from => picked.checked_sub(1), - _ => Some(picked), - }; - Some(SelectionUpdate { document: pending.target.document, before, after: next.map(|index| (layer, Some(index))) }) -} - -fn queue(ctx: &egui::Context, target: &Target, command: &'static str, params: Value, to: Option, actions: &mut Vec<(String, Value)>) { - ctx.data_mut(|d| d.insert_temp(pending_id(), PendingSelection { target: target.clone(), command, params: params.clone(), to })); - actions.push((command.into(), params)); -} - -impl Target { - fn count(&self, app: &PhotocraftApp) -> Option { - let st = app.session.active()?; - if st.doc.id != self.document - || st.revision != self.revision - || !self.snapshot.ptr_eq(&Arc::downgrade(&st.doc)) - || app.session.job_on(self.document).is_some() - || !crate::menus::modal_allows(app, "layer.smartFilter.move") - { - return None; - } - let LayerContent::Smart(sm) = &st.doc.layer(self.layer)?.content else { return None }; - sm.smart_filters.get(self.index)?; - Some(sm.smart_filters.len()) - } - - fn params(&self) -> Value { - json!({"layer": self.layer.0, "index": self.index}) - } - - fn enabled(&self, app: &mut PhotocraftApp, command: &str) -> bool { - // The immutable menu query checks the active layer. Query the named layer, like the - // engine's explicit-layer dispatch, then restore it without changing selection/history. - let Some(st) = app.session.active_mut() else { return false }; - let active = st.active_layer.replace(self.layer); - let enabled = app.session.is_enabled_with(command, &self.params()); - if let Some(st) = app.session.active_mut() { - st.active_layer = active; - } - enabled - } -} - -/// Runs even when the new document has no filter rows, so a stale popup cannot remain open. -pub(crate) fn close_stale(app: &PhotocraftApp, ctx: &egui::Context) { - let target: Option = ctx.data(|d| d.get_temp(state_id())); - if let Some(target) = target - && (target.count(app).is_none() || !egui::Popup::is_id_open(ctx, target.popup)) - { - egui::Popup::close_id(ctx, target.popup); - ctx.data_mut(|d| d.remove::(state_id())); - ctx.data_mut(|d| d.remove::(pending_id())); - } -} - -pub(crate) fn show(app: &mut PhotocraftApp, response: &egui::Response, layer: LayerId, index: usize, actions: &mut Vec<(String, Value)>) { - let ctx = &response.ctx; - let popup = egui::Id::unique(("smart-filter-row-menu", layer.0, index)); - if response.secondary_clicked() - && let Some(st) = app.session.active() - { - let target = Target { snapshot: Arc::downgrade(&st.doc), document: st.doc.id, revision: st.revision, layer, index, popup }; - if target.count(app).is_some() { - ctx.data_mut(|d| d.insert_temp(state_id(), target)); - } - } - let Some(target) = ctx.data(|d| d.get_temp::(state_id())).filter(|t| t.popup == popup) else { return }; - let Some(count) = target.count(app) else { - egui::Popup::close_id(ctx, popup); - ctx.data_mut(|d| d.remove::(state_id())); - return; - }; - egui::Popup::context_menu(response).id(popup).show(|ui| { - ui.set_min_width(180.0); - let enabled = target.enabled(app, "layer.smartFilter.move"); - // Filters display in reverse storage order: index + 1 moves the row visually up. - let up = index.checked_add(1).filter(|to| *to < count); - let down = index.checked_sub(1); - for (label, to) in [(tl!("Move Up"), up), (tl!("Move Down"), down)] { - if ui.add_enabled(enabled && to.is_some(), egui::Button::new(label)).clicked() - && let Some(to) = to - { - queue(ctx, &target, "layer.smartFilter.move", json!({"layer": target.layer.0, "index": target.index, "to": to}), Some(to), actions); - ui.close(); - } - } - ui.separator(); - if ui.add_enabled(target.enabled(app, "layer.smartFilter.delete"), egui::Button::new(tl!("Delete Smart Filter"))).clicked() { - queue(ctx, &target, "layer.smartFilter.delete", target.params(), None, actions); - ui.close(); - } - }); -} diff --git a/crates/ui-egui/src/panels/smart_ui/context_tests.rs b/crates/ui-egui/src/panels/smart_ui/context_tests.rs deleted file mode 100644 index 1cd002e845..0000000000 --- a/crates/ui-egui/src/panels/smart_ui/context_tests.rs +++ /dev/null @@ -1,182 +0,0 @@ -use super::*; -use egui::{Event, Modifiers, PointerButton, Pos2}; -use egui_kittest::{Harness, kittest::Queryable}; -use photocraft_doc::{LayerId, SmartFilter}; - -pub(super) fn app(depth: u32) -> (PhotocraftApp, LayerId) { - let mut s = photocraft_engine::Session::new(); - s.execute("file.new", json!({"width": 32, "height": 24, "depth": depth})).unwrap(); - s.execute("layer.new.layer", json!({"name": "Filtered source"})).unwrap(); - s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 24})).unwrap(); - s.execute("edit.fill", json!({"contents": "color", "color": "#d06030"})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); - s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); - let layer = s.active().unwrap().active_layer.unwrap(); - s.execute("filter.other.offset", json!({"horizontal": 5, "undefinedAreas": "transparent"})).unwrap(); - s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); - s.execute("layer.new.layer", json!({"name": "Other active layer"})).unwrap(); - let mut app = PhotocraftApp::new(s, Default::default()); - app.background_jobs = false; - (app, layer) -} - -pub(super) fn smart(app: &PhotocraftApp, layer: LayerId) -> &SmartObject { - let LayerContent::Smart(sm) = &app.session.active().unwrap().doc.layer(layer).unwrap().content else { panic!("Smart Object") }; - sm -} - -pub(super) fn filters(app: &PhotocraftApp, layer: LayerId) -> Vec { - smart(app, layer).smart_filters.clone() -} - -pub(super) fn pixels(app: &PhotocraftApp, layer: LayerId) -> Vec { - let doc = &app.session.active().unwrap().doc; - smart(app, layer).cache.as_ref().unwrap().read_region(doc.bounds()) -} - -pub(super) fn text_position(h: &Harness<'_, PhotocraftApp>, name: &str) -> Pos2 { - fn text(shape: &egui::Shape, name: &str) -> Option { - match shape { - egui::Shape::Text(t) if t.galley.text() == name => Some(Rect::from_min_size(t.pos, t.galley.size()).center()), - egui::Shape::Vec(v) => v.iter().find_map(|s| text(s, name)), - _ => None, - } - } - h.output().shapes.iter().find_map(|s| text(&s.shape, name)).unwrap_or_else(|| panic!("row text {name} not drawn")) -} - -pub(super) fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, button: PointerButton) { - h.hover_at(at); - h.step(); - for pressed in [true, false] { - h.event(Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }); - h.step(); - } - h.run_steps(3); -} - -pub(super) fn harness() -> (Harness<'static, PhotocraftApp>, LayerId) { - harness_at(8, 1.0) -} - -pub(super) fn harness_at(depth: u32, ppp: f32) -> (Harness<'static, PhotocraftApp>, LayerId) { - let (app, layer) = app(depth); - let mut h = - Harness::builder().with_size(vec2(1440.0, 900.0)).with_pixels_per_point(ppp).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - app - }); - let ctx = h.ctx.clone(); - let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); - crate::control::handle(h.state_mut(), &ctx, &req); - h.run_steps(8); - (h, layer) -} - -#[test] -fn row_menu_moves_and_deletes_the_clicked_filter_with_one_undo_step() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for depth in [8, 16, 32] { - for ppp in [1.0, 2.0] { - let (mut h, layer) = harness_at(depth, ppp); - let before = filters(h.state(), layer); - let before_pixels = pixels(h.state(), layer); - let source = photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(); - let st = h.state().session.active().unwrap(); - let active = st.active_layer; - let selected = st.selected_layers.clone(); - let history = st.history.past_len(); - let other = st.doc.layer(active.unwrap()).unwrap().clone(); - let offset = text_position(&h, "Offset"); - click(&mut h, offset, PointerButton::Secondary); - assert!(h.query_by_label("Move Up").is_some(), "actual filter row has Move Up"); - h.get_by_label("Move Up").click(); - h.run_steps(4); - assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", "filter.other.offset"]); - assert!(text_position(&h, "Offset").y < text_position(&h, "Gaussian Blur").y); - assert_ne!(pixels(h.state(), layer), before_pixels); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); - assert_eq!(h.state().session.active().unwrap().active_layer, active); - assert_eq!(h.state().session.active().unwrap().selected_layers, selected); - assert_eq!(photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(), source); - h.state_mut().run("edit.undo", json!({})).unwrap(); - h.run_steps(3); - assert_eq!(serde_json::to_value(filters(h.state(), layer)).unwrap(), serde_json::to_value(&before).unwrap()); - assert_eq!(pixels(h.state(), layer), before_pixels); - h.state_mut().run("edit.redo", json!({})).unwrap(); - h.run_steps(3); - let offset = text_position(&h, "Offset"); - click(&mut h, offset, PointerButton::Secondary); - h.get_by_label("Move Down").click(); - h.run_steps(4); - assert_eq!(serde_json::to_value(filters(h.state(), layer)).unwrap(), serde_json::to_value(&before).unwrap()); - assert_eq!(pixels(h.state(), layer), before_pixels); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 2); - let offset = text_position(&h, "Offset"); - click(&mut h, offset, PointerButton::Secondary); - h.get_by_label("Delete Smart Filter").click(); - h.run_steps(4); - assert_eq!(filters(h.state(), layer).len(), 1); - assert_eq!(filters(h.state(), layer)[0].command, "filter.blur.gaussianBlur"); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 3); - assert_eq!(h.state().session.active().unwrap().active_layer, active); - assert_eq!(h.state().session.active().unwrap().selected_layers, selected); - assert_eq!(photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(), source); - let unchanged = h.state().session.active().unwrap().doc.layer(other.id).unwrap(); - assert_eq!(unchanged, &other); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(pixels(h.state(), layer), before_pixels); - h.state_mut().run("edit.redo", json!({})).unwrap(); - assert_eq!(filters(h.state(), layer).len(), 1); - } - } -} - -#[test] -fn two_filter_row_context_offers_reorder_and_delete() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - let (mut h, layer) = harness(); - let top = text_position(&h, "Gaussian Blur"); - let bottom = text_position(&h, "Offset"); - assert!(top.y < bottom.y, "index1 is visually above index0"); - let before = h.state().session.active().unwrap().doc.clone(); - let history = h.state().session.active().unwrap().history.past_len(); - click(&mut h, bottom, PointerButton::Secondary); - assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history); - let missing = ["Move Up", "Move Down", "Delete Smart Filter"].into_iter().filter(|label| h.query_by_label(label).is_none()).collect::>(); - assert!(missing.is_empty(), "filter row lacks context choices {missing:?}; stack has {} filters", smart(h.state(), layer).smart_filters.len()); -} - -#[test] -fn existing_filter_move_delete_commands_preserve_source_and_undo_at_every_depth() { - for depth in [8, 16, 32] { - let (mut app, layer) = app(depth); - let original = filters(&app, layer); - let original_pixels = pixels(&app, layer); - let source = photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(); - let active = app.session.active().unwrap().active_layer; - let history = app.session.active().unwrap().history.past_len(); - app.run("layer.smartFilter.move", json!({"layer": layer.0, "index": 0, "to": 1})).unwrap(); - assert_eq!(filters(&app, layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", "filter.other.offset"]); - assert_ne!(pixels(&app, layer), original_pixels, "noncommuting operations really rerender"); - assert_eq!(app.session.active().unwrap().history.past_len(), history + 1); - assert_eq!(app.session.active().unwrap().active_layer, active); - assert_eq!(photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(), source); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(serde_json::to_value(filters(&app, layer)).unwrap(), serde_json::to_value(&original).unwrap()); - assert_eq!(pixels(&app, layer), original_pixels); - app.run("edit.redo", json!({})).unwrap(); - let reordered_pixels = pixels(&app, layer); - app.run("layer.smartFilter.delete", json!({"layer": layer.0, "index": 1})).unwrap(); - assert_eq!(filters(&app, layer).len(), 1); - assert_eq!(filters(&app, layer)[0].command, "filter.blur.gaussianBlur"); - assert_eq!(app.session.active().unwrap().history.past_len(), history + 2); - assert_eq!(app.session.active().unwrap().active_layer, active); - assert_eq!(photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(), source); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(pixels(&app, layer), reordered_pixels); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(filters(&app, layer).len(), 1); - } -} diff --git a/crates/ui-egui/src/panels/smart_ui/menu_guard_tests.rs b/crates/ui-egui/src/panels/smart_ui/menu_guard_tests.rs deleted file mode 100644 index 0e8c00cbfa..0000000000 --- a/crates/ui-egui/src/panels/smart_ui/menu_guard_tests.rs +++ /dev/null @@ -1,304 +0,0 @@ -use super::context_tests::*; -use super::*; -use egui::PointerButton; -use egui_kittest::kittest::{NodeT, Queryable}; -use std::sync::Arc; - -#[test] -fn row_menu_disables_edges_and_deletes_the_only_remaining_filter() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for ppp in [1.0, 2.0] { - let (mut h, layer) = harness_at(8, ppp); - let top = text_position(&h, "Gaussian Blur"); - click(&mut h, top, PointerButton::Secondary); - assert!(h.get_by_label("Move Up").accesskit_node().is_disabled()); - assert!(!h.get_by_label("Move Down").accesskit_node().is_disabled()); - let before = h.state().session.active().unwrap().doc.clone(); - let history = h.state().session.active().unwrap().history.past_len(); - h.get_by_label("Move Up").click(); - h.run_steps(3); - assert!(Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history); - egui::Popup::close_all(&h.ctx); - h.state_mut().run("layer.smartFilter.delete", json!({"layer":layer.0,"index":0})).unwrap(); - h.run_steps(3); - let only = text_position(&h, "Gaussian Blur"); - click(&mut h, only, PointerButton::Secondary); - assert!(h.get_by_label("Move Up").accesskit_node().is_disabled()); - assert!(h.get_by_label("Move Down").accesskit_node().is_disabled()); - assert!(!h.get_by_label("Delete Smart Filter").accesskit_node().is_disabled()); - h.get_by_label("Delete Smart Filter").click(); - h.run_steps(4); - assert!(filters(h.state(), layer).is_empty()); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 2); - assert!(!egui::Popup::is_any_open(&h.ctx)); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(filters(h.state(), layer).len(), 1); - h.state_mut().run("edit.redo", json!({})).unwrap(); - assert!(filters(h.state(), layer).is_empty()); - } -} - -#[test] -fn stale_row_menu_closes_on_edits_undo_redo_document_switch_and_same_id_reopen() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for change in ["move", "delete", "undo", "redo", "switch", "reopen", "close"] { - let (mut h, layer) = harness(); - let offset = text_position(&h, "Offset"); - click(&mut h, offset, PointerButton::Secondary); - assert!(h.query_by_label("Delete Smart Filter").is_some()); - match change { - "move" => { - h.state_mut().run("layer.smartFilter.move", json!({"layer":layer.0,"index":0,"to":1})).unwrap(); - } - "delete" => { - h.state_mut().run("layer.smartFilter.delete", json!({"layer":layer.0,"index":0})).unwrap(); - } - "undo" => { - h.state_mut().run("edit.undo", json!({})).unwrap(); - } - "redo" => { - h.state_mut().run("edit.undo", json!({})).unwrap(); - h.state_mut().run("edit.redo", json!({})).unwrap(); - } - "switch" => { - h.state_mut().run("file.new", json!({"width":8,"height":8})).unwrap(); - } - "reopen" => { - let session = &mut h.state_mut().session; - let old = session.close(session.active_index().unwrap()).unwrap(); - let index = session.add_document((*old.doc).clone(), None); - assert_eq!(session.documents()[index].doc.id, old.doc.id); - session.active_mut().unwrap().revision = old.revision; - assert!(!Arc::ptr_eq(&session.active().unwrap().doc, &old.doc)); - } - "close" => { - let session = &mut h.state_mut().session; - session.close(session.active_index().unwrap()).unwrap(); - } - other => panic!("unrecognized test mutation {other}"), - } - let before = h.state().session.active().map(|st| (st.doc.clone(), st.revision, st.history.past_len())); - h.run_steps(4); - assert!(h.query_by_label("Delete Smart Filter").is_none(), "{change} closes stale choices"); - assert!(!egui::Popup::is_any_open(&h.ctx), "{change} closes popup memory too"); - if let Some((doc, revision, history)) = before { - let st = h.state().session.active().unwrap(); - assert!(Arc::ptr_eq(&doc, &st.doc), "{change} adds no accidental edit"); - assert_eq!((st.revision, st.history.past_len()), (revision, history)); - } - } -} - -#[test] -fn parameter_and_camera_raw_modals_close_the_row_menu_without_mutation() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for raw in [false, true] { - let (mut h, layer) = harness(); - let offset = text_position(&h, "Offset"); - click(&mut h, offset, PointerButton::Secondary); - let ctx = h.ctx.clone(); - if raw { - crate::camera_raw_ui::open(h.state_mut(), &ctx).unwrap(); - } else { - let sm = smart(h.state(), layer).clone(); - open_editor(h.state_mut(), &ctx, layer.0, &sm, 0); - assert!(h.state().ui.dialogs.last().unwrap().fields.contains_key("__smartFilter")); - } - let before = h.state().session.active().unwrap().doc.clone(); - let history = h.state().session.active().unwrap().history.past_len(); - h.run_steps(4); - assert!(h.query_by_label("Delete Smart Filter").is_none()); - assert!(!egui::Popup::is_any_open(&h.ctx)); - assert!(Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history); - } -} - -#[test] -fn pending_document_job_closes_row_menu_and_refuses_new_choices() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - let (mut h, _layer) = harness(); - let offset = text_position(&h, "Offset"); - click(&mut h, offset, PointerButton::Secondary); - let before = h.state().session.active().unwrap().doc.clone(); - let started = h - .state_mut() - .session - .start_job( - "test.wait", - json!({}), - "Waiting filter", - true, - |ctx| -> photocraft_engine::Result<()> { - loop { - ctx.check()?; - std::thread::sleep(std::time::Duration::from_millis(1)); - } - }, - |_, ()| Ok(Value::Null), - ) - .unwrap(); - let photocraft_engine::jobs::Started::Job(id) = started else { panic!("native worker") }; - h.run_steps(3); - assert!(h.query_by_label("Delete Smart Filter").is_none()); - assert!(!egui::Popup::is_any_open(&h.ctx)); - click(&mut h, offset, PointerButton::Secondary); - assert!(h.query_by_label("Delete Smart Filter").is_none()); - assert!(Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); - h.state_mut().session.cancel_job(id); - h.state_mut().session.join_cancelled_jobs(); -} - -#[test] -fn existing_eye_and_name_double_click_remain_distinct_from_secondary_actions() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for ppp in [1.0, 2.0] { - let (mut h, layer) = harness_at(8, ppp); - let offset = text_position(&h, "Offset"); - let eye = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Visibility Offset").rect().center(); - let history = h.state().session.active().unwrap().history.past_len(); - click(&mut h, eye, PointerButton::Primary); - assert!(!filters(h.state(), layer)[0].visible); - assert!(h.query_by_label("Delete Smart Filter").is_none()); - assert!(h.state().ui.dialogs.is_empty()); - h.state_mut().run("edit.undo", json!({})).unwrap(); - h.run_steps(40); - click(&mut h, offset, PointerButton::Primary); - click(&mut h, offset, PointerButton::Primary); - let dialog = h.state().ui.dialogs.last().unwrap(); - assert_eq!(dialog.fields["__command"], json!("filter.other.offset")); - let target = &dialog.fields["__smartFilter"]; - assert_eq!(target["layer"], json!(layer.0)); - assert_eq!(target["index"], json!(0)); - let state = h.state().session.active().unwrap(); - assert_eq!(target["doc"], json!(state.doc.id.0)); - assert_eq!(target["revision"], json!(state.revision)); - assert_eq!(target["filter"], serde_json::to_value(&filters(h.state(), layer)[0]).unwrap()); - assert!(target["guard"].as_u64().is_some_and(|guard| guard > 0)); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history); - } -} - -#[test] -#[ignore = "offscreen native popup captures; set PHOTOCRAFT_SMART_FILTER_CAPTURE_DIR"] -fn capture_native_popup_at_two_scales_and_in_french() { - let out = std::env::var("PHOTOCRAFT_SMART_FILTER_CAPTURE_DIR").expect("capture destination"); - for (language, ppp) in [("en", 1.0), ("fr", 2.0)] { - let (mut app, layer) = app(8); - app.run("prefs.set", json!({"path":"interface.language","value":language})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_pixels_per_point(ppp).with_max_steps(64).wgpu().build_eframe( - move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - app - }, - ); - let ctx = h.ctx.clone(); - let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"zoom":12.0,"dock":{"collapsed":["color","properties","history","navigator"]}})); - crate::control::handle(h.state_mut(), &ctx, &req); - h.run_steps(12); - let label = filter_label(&filters(h.state(), layer)[0]); - let offset = text_position(&h, &label); - click(&mut h, offset, PointerButton::Secondary); - for label in [tl!("Move Up"), tl!("Move Down"), tl!("Delete Smart Filter")] { - assert!(h.query_by_label(label).is_some(), "localized popup item {label}"); - } - h.run_steps(12); - let file = std::path::Path::new(&out).join(format!("smart-filter-menu-{language}-{}x.png", ppp as u32)); - h.render().unwrap().save(&file).unwrap(); - println!("capture {} at {offset:?}, doc {:?}, inactive target {:?}", file.display(), h.state().session.active().unwrap().doc.id, layer); - } -} -/// Selecting a row is a primary action; its subsequent context edit keeps that filter selected. -#[test] -fn row_menu_reorder_keeps_the_selected_filter_identity() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for ppp in [1.0, 2.0] { - for (target, choice) in [("Offset", "Move Up"), ("Gaussian Blur", "Move Down")] { - let (mut h, layer) = harness_at(8, ppp); - let at = text_position(&h, "Offset"); - click(&mut h, at, PointerButton::Primary); - assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); - assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); - let st = h.state().session.active().unwrap(); - let (active, selected, history) = (st.active_layer, st.selected_layers.clone(), st.history.past_len()); - let at = text_position(&h, target); - click(&mut h, at, PointerButton::Secondary); - h.get_by_label(choice).click(); - h.run_steps(4); - assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", "filter.other.offset"]); - assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{target}/{choice} keeps Offset selected"); - assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); - let st = h.state().session.active().unwrap(); - assert_eq!((st.active_layer, &st.selected_layers, st.history.past_len()), (active, &selected, history + 1)); - } - } -} - -#[test] -fn row_menu_delete_before_selection_keeps_the_remaining_filter_selected() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for ppp in [1.0, 2.0] { - let (mut h, layer) = harness_at(8, ppp); - let at = text_position(&h, "Gaussian Blur"); - click(&mut h, at, PointerButton::Primary); - assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); - let st = h.state().session.active().unwrap(); - let (active, selected, history) = (st.active_layer, st.selected_layers.clone(), st.history.past_len()); - let at = text_position(&h, "Offset"); - click(&mut h, at, PointerButton::Secondary); - h.get_by_label("Delete Smart Filter").click(); - h.run_steps(4); - assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur"]); - assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); - let st = h.state().session.active().unwrap(); - assert_eq!((st.active_layer, &st.selected_layers, st.history.past_len()), (active, &selected, history + 1)); - } -} - -#[test] -fn row_menu_delete_selected_filter_clears_its_row_selection() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for ppp in [1.0, 2.0] { - let (mut h, layer) = harness_at(8, ppp); - let at = text_position(&h, "Offset"); - click(&mut h, at, PointerButton::Primary); - assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); - let st = h.state().session.active().unwrap(); - let (active, selected, history) = (st.active_layer, st.selected_layers.clone(), st.history.past_len()); - let at = text_position(&h, "Offset"); - click(&mut h, at, PointerButton::Secondary); - h.get_by_label("Delete Smart Filter").click(); - h.run_steps(4); - assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur"]); - assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); - let doc = h.state().session.active().unwrap().doc.id; - assert!(h.ctx.data(|d| d.get_temp::(selected_key(doc))).is_none()); - let st = h.state().session.active().unwrap(); - assert_eq!((st.active_layer, &st.selected_layers, st.history.past_len()), (active, &selected, history + 1)); - } -} - -#[test] -fn row_menu_refused_edit_preserves_filter_order_and_selection() { - let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - let (mut h, layer) = harness(); - let at = text_position(&h, "Offset"); - click(&mut h, at, PointerButton::Primary); - assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); - let before = h.state().session.active().unwrap().doc.clone(); - let st = h.state().session.active().unwrap(); - let (active, selected, history) = (st.active_layer, st.selected_layers.clone(), st.history.past_len()); - h.state_mut().session.authorize = - Some(|command, _| if command == "layer.smartFilter.move" { Err(photocraft_engine::EngineError::Other("refused menu edit".into())) } else { Ok(()) }); - let at = text_position(&h, "Offset"); - click(&mut h, at, PointerButton::Secondary); - h.get_by_label("Move Up").click(); - h.run_steps(4); - let st = h.state().session.active().unwrap(); - assert!(Arc::ptr_eq(&before, &st.doc)); - assert_eq!((st.active_layer, &st.selected_layers, st.history.past_len()), (active, &selected, history)); - assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.other.offset", "filter.blur.gaussianBlur"]); - assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); - assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); -} diff --git a/crates/ui-egui/src/panels/type_panels_ui.rs b/crates/ui-egui/src/panels/type_panels_ui.rs index 9008ebce10..d95c22addb 100644 --- a/crates/ui-egui/src/panels/type_panels_ui.rs +++ b/crates/ui-egui/src/panels/type_panels_ui.rs @@ -450,7 +450,6 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s (tl!("Indent Right"), "end_indent_pt", "pt"), (tl!("Space Before"), "space_before_pt", "pt"), (tl!("Space After"), "space_after_pt", "pt"), - (tl!("Roman Hanging Punctuation"), "hanging", "bool"), // Hyphenate is stored and round-trips to PSD, but layout never hyphenates (#6710). ]; for &(label, key, kind) in rows { diff --git a/crates/ui-egui/src/prefs/prefs_ui/fields.rs b/crates/ui-egui/src/prefs/prefs_ui/fields.rs index 00f701ef12..2258a3f7be 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/fields.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/fields.rs @@ -34,8 +34,6 @@ pub(super) fn humanize(key: &str) -> String { "darkTheme" => return "Dark Theme".into(), "lightTheme" => return "Light Theme".into(), "automaticallyCheckUpdates" => return "Automatically Check for Updates".into(), - "scrollbarVisibility" => return "Scrollbar Visibility".into(), - "nativeGradientIcons" => return "Use PhotoCraft-native gradient icons".into(), _ => {} } // These controls appear only for WebP, so reuse the existing translated labels. @@ -98,7 +96,6 @@ pub(super) fn humanize(key: &str) -> String { pub(super) fn choice_label(v: &str) -> String { match v { - "autoHide" => "Auto Hide".into(), "exr" => "EXR".into(), "cm" => "Centimeters".into(), "mm" => "Millimeters".into(), diff --git a/crates/ui-egui/src/prefs/prefs_ui/save.rs b/crates/ui-egui/src/prefs/prefs_ui/save.rs index 120a0e3991..9dba6f78f2 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/save.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/save.rs @@ -11,7 +11,6 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { sync_rulers(app); sync_display_scale(app, ctx); sync_appearance(app, ctx); - crate::theme::sync_scrollbars(ctx, app.session.prefs().interface.scrollbar_visibility); crate::theme::set_ui_font_size(ctx, app.session.prefs().interface.ui_font_size); presets_store(app); sync_tooltips(app, ctx); diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index 040aff39ed..31af407a83 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -1705,39 +1705,6 @@ fn integrations_explain_next_launch() { assert_eq!(h.query_all_by_label("Applies at next launch.").count(), 2, "one notice under each row"); } -#[test] -fn native_gradient_icons_apply_cancel_and_restart() { - use egui_kittest::{Harness, kittest::Queryable}; - let (mut app, store) = app_with_store(); - app.run("prefs.set", json!({"path": "interface.language", "value": "en"})).unwrap(); - let id = open_preferences(&mut app, "interface"); - let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_eframe(move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - app - }); - h.run_steps(4); - h.get_by_label("Use PhotoCraft-native gradient icons").scroll_to_me(); - h.run_steps(4); - for desired in [true, false] { - h.get_by_label("Use PhotoCraft-native gradient icons").click(); - h.run_steps(4); - assert_eq!(h.state().session.prefs().interface.native_gradient_icons, !desired, "draft waits for Apply"); - h.get_by_label("Apply").click(); - h.run_steps(4); - assert_eq!(h.state().session.prefs().interface.native_gradient_icons, desired); - assert_eq!(stored(&store)["interface"]["nativeGradientIcons"], desired); - let (restarted, _) = app_with_saved(store.lock().unwrap().clone()); - assert_eq!(restarted.session.prefs().interface.native_gradient_icons, desired); - } - h.get_by_label("Use PhotoCraft-native gradient icons").click(); - h.run_steps(4); - h.get_by_label("Cancel").click(); - h.run_steps(4); - assert!(h.state().ui.dialogs.iter().all(|d| d.id != id)); - assert!(!h.state().session.prefs().interface.native_gradient_icons); - assert_eq!(stored(&store)["interface"]["nativeGradientIcons"], false); -} - #[test] fn next_launch_notice_does_not_mark_unrelated_interface_preferences() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index b147a6543d..74be08360c 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -16,7 +16,7 @@ use crate::PhotocraftApp; /// the Swatches panel through [`crate::swatches_ui::import_bytes`]). /// `.psp` is Photoshop's live `Keyboard Shortcuts.psp` (the same XML as a `.kys`); other `.psp` /// settings files are refused with a clear message. -pub const PRESET_EXTS: &[&str] = &["abr", "grd", "asl", "pat", "aco", "act", "ase", "kys", "psp", "csh"]; +pub const PRESET_EXTS: &[&str] = &["abr", "grd", "asl", "pat", "aco", "act", "ase", "kys", "psp"]; fn ext(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -84,23 +84,6 @@ fn source(name: &str, bytes: &[u8]) -> Value { json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)}) } -/// Import a custom-shape set from the Shapes panel using the normal preset route. -pub(crate) fn import_shapes_dialog(app: &mut PhotocraftApp) { - let result = app.pick_file_bytes_filtered(&["csh"], |app, name, bytes| { - if ext(&name) != "csh" { - return Err(tl!("Choose a custom shape file (.csh).").into()); - } - open(app, &name, &bytes).ok_or_else(|| tl!("Choose a custom shape file (.csh).").to_string())??; - Ok(Value::Null) - }); - if let Err(error) = result - && error != crate::file_dialog::CANCELLED - { - app.ui.status = error; - app.ui.status_error = true; - } -} - /// Import a preset file; `None` when `name` isn't one. pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option> { let (cmd, what) = match ext(name).as_str() { @@ -108,7 +91,6 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option ("gradient.presets.importGrd", "gradients"), "asl" => ("style.presets.importAsl", "styles"), "pat" => ("pattern.import", "patterns"), - "csh" => ("shape.presets.importCsh", "shapes"), "aco" | "act" | "ase" => return Some(crate::swatches_ui::import_bytes(app, name, bytes, false).map(|_| ())), "kys" | "psp" => return Some(crate::kys_import::open_bytes(app, name, bytes).map(|_| ())), _ => return None, @@ -130,21 +112,13 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option = r.get("warnings").and_then(|w| serde_json::from_value(w.clone()).ok()).unwrap_or_default(); app.ui.status_error = false; - // Shape import may suffix the group when that name is taken. Report the group that - // was stored, which the background job path already does. - let reported_group = r.get("group").and_then(Value::as_str).filter(|g| !g.is_empty()); - let (one, other, slot, shown) = if what == "shapes" { - ("Imported {n} shape from {group}", "Imported {n} shapes from {group}", "group", reported_group.unwrap_or(stem.as_str())) - } else { - let (one, other) = match what { - "brushes" => ("Imported {n} brush from {file}", "Imported {n} brushes from {file}"), - "styles" => ("Imported {n} style from {file}", "Imported {n} styles from {file}"), - "patterns" => ("Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), - _ => ("Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), - }; - (one, other, "file", stem.as_str()) + let (one, other) = match what { + "brushes" => ("Imported {n} brush from {file}", "Imported {n} brushes from {file}"), + "styles" => ("Imported {n} style from {file}", "Imported {n} styles from {file}"), + "patterns" => ("Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), + _ => ("Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), }; - let imported = crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, one, other), &[(slot, shown)]); + let imported = crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, one, other), &[("file", &stem)]); app.ui.status = match warnings.first() { Some(warning) => { let template = "{message} (notes: {n}; {warning})"; @@ -157,23 +131,15 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option(egui::Id::unique("pc-scrollbar-visibility"))); -} - -/// Apply the saved override once per change, including after a theme switch. -pub(crate) fn sync_scrollbars(ctx: &egui::Context, mode: photocraft_engine::prefs::ScrollbarVisibility) { - use photocraft_engine::prefs::ScrollbarVisibility; - let id = egui::Id::unique("pc-scrollbar-visibility"); - if ctx.data(|d| d.get_temp::(id)) == Some(mode) { - return; - } - let t = Tokens::get(ctx); - let mut scroll = match mode { - ScrollbarVisibility::Always => egui::style::ScrollStyle::solid(), - ScrollbarVisibility::AutoHide | ScrollbarVisibility::Hidden => egui::style::ScrollStyle::floating(), - ScrollbarVisibility::Default if t.bevel => egui::style::ScrollStyle::solid(), - ScrollbarVisibility::Default => egui::style::ScrollStyle::thin(), - }; - if scroll.floating { - scroll.interact_handle_opacity = 1.0; - scroll.interact_background_opacity = 0.4; - if mode == ScrollbarVisibility::Default { - scroll.floating_width = 4.0; - } - } - if mode == ScrollbarVisibility::Hidden { - scroll.bar_width = 0.0; - scroll.floating_width = 0.0; - scroll.floating_allocated_width = 0.0; - scroll.bar_inner_margin = 0.0; - scroll.bar_outer_margin = 0.0; - scroll.dormant_handle_opacity = 0.0; - scroll.active_handle_opacity = 0.0; - scroll.interact_handle_opacity = 0.0; - scroll.dormant_background_opacity = 0.0; - scroll.active_background_opacity = 0.0; - scroll.interact_background_opacity = 0.0; - } - ctx.global_style_mut(|s| s.spacing.scroll = scroll); - let style = ctx.global_style(); - ctx.set_style_of(egui::Theme::Light, Arc::clone(&style)); - ctx.set_style_of(egui::Theme::Dark, style); - ctx.data_mut(|d| d.insert_temp(id, mode)); } /// Seconds the pointer rests on a control before its tooltip shows. diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs index 41cc21279f..d869649853 100644 --- a/crates/ui-egui/src/theme/tests.rs +++ b/crates/ui-egui/src/theme/tests.rs @@ -1,58 +1,5 @@ use super::*; -#[test] -fn scrollbar_visibility_applies_in_every_theme_and_survives_theme_changes() { - use photocraft_engine::prefs::ScrollbarVisibility as Mode; - let ctx = egui::Context::default(); - for kind in ThemeKind::ALL { - apply(&ctx, kind); - let original = ctx.global_style().spacing.scroll; - sync_scrollbars(&ctx, Mode::Default); - assert_eq!(ctx.global_style().spacing.scroll, original); - sync_scrollbars(&ctx, Mode::Always); - assert!(!ctx.global_style().spacing.scroll.floating); - assert!(ctx.global_style().spacing.scroll.bar_width > 0.0); - sync_scrollbars(&ctx, Mode::AutoHide); - assert!(ctx.global_style().spacing.scroll.floating); - assert_eq!(ctx.global_style().spacing.scroll.dormant_handle_opacity, 0.0); - sync_scrollbars(&ctx, Mode::Hidden); - assert_eq!(ctx.global_style().spacing.scroll.bar_width, 0.0); - apply(&ctx, kind); - sync_scrollbars(&ctx, Mode::Hidden); - assert_eq!(ctx.global_style().spacing.scroll.bar_width, 0.0, "theme changes retain the saved override"); - sync_scrollbars(&ctx, Mode::Default); - assert_eq!(ctx.global_style().spacing.scroll, original); - } -} - -#[test] -fn hidden_scrollbars_keep_wheel_scrolling_available() { - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(300.0, 120.0)).build_ui_state( - |ui, offset: &mut f32| { - let area = egui::ScrollArea::vertical().max_height(100.0).show(ui, |ui| { - for i in 0..30 { - ui.label(format!("Row {i}")); - } - }); - *offset = area.state.clamped_offset().y; - }, - 0.0, - ); - sync_scrollbars(&h.ctx, photocraft_engine::prefs::ScrollbarVisibility::Hidden); - h.run(); - h.hover_at(egui::pos2(30.0, 40.0)); - h.event(egui::Event::MouseWheel { - source: egui::MouseWheelSource::Unknown, - unit: egui::MouseWheelUnit::Line, - delta: egui::vec2(0.0, -3.0), - phase: egui::TouchPhase::Move, - modifiers: egui::Modifiers::NONE, - }); - h.run(); - assert!(*h.state() > 0.0); - assert_eq!(h.ctx.global_style().spacing.scroll.allocated_width(), 0.0); -} - fn text_sizes(ctx: &egui::Context) -> Vec { let mut sizes = Vec::new(); // The plugin queues changed fonts at the end of a pass; egui applies them next pass. diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index b608e3ad7d..ee1b939e15 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -207,18 +207,6 @@ pub fn commit_params(app: &PhotocraftApp, p: &mut Value) { } } -/// Whether committing a crop would resample to a target size or set a resolution (W x H x -/// Resolution with those fields filled): a frame over the whole canvas is then not a no-op. -pub fn resamples(app: &PhotocraftApp) -> bool { - let o = &app.ui.tool_options; - if o.crop_ratio != WHR { - return false; - } - let dpi = app.session.active().map_or(72.0, |st| f64::from(st.doc.resolution_dpi)); - let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); - target(o, dpi, pt_per_in).is_some() || resolution_ppi(o).is_some() -} - /// Fills W, H (px) and the resolution from the active document, in W x H x Resolution mode. pub fn front_image(app: &mut PhotocraftApp) { let Some(d) = app.session.active().map(|st| (st.doc.size, f64::from(st.doc.resolution_dpi))) else { return }; diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 8c704216ca..3a05ba2cc7 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -49,9 +49,6 @@ pub struct CropState { /// `UiState::crop_rect` is the untouched default frame ([`ensure_frame`]): a drag inside it /// draws a new frame (as in Photoshop) instead of moving it, and committing it does nothing. pub default_frame: bool, - /// The frame is what the crop menu's Reset made it ([`reset`]): the whole canvas, still a - /// pending frame, but committing it crops nothing as long as it still matches the bounds. - pub reset_frame: bool, /// The document (index and size) the default frame was made for. frame_for: Option<(usize, u32, u32)>, /// The frame is being edited (pressed since it was made): the canvas shows what lies past it. @@ -74,7 +71,6 @@ struct CropStep { rect: Option<[f64; 4]>, angle: f64, default_frame: bool, - reset_frame: bool, } /// A crop gesture in progress. Rects are `[x0, y0, x1, y1]` in document coordinates. @@ -390,7 +386,6 @@ fn frame(app: &mut PhotocraftApp) { } app.ui.crop_rect = Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]); app.crop.default_frame = true; - app.crop.reset_frame = false; app.crop.frame_for = key; app.crop.editing = false; app.crop.undo.clear(); @@ -432,25 +427,12 @@ pub fn cancel(app: &mut PhotocraftApp) { app.ui.crop_angle = 0.0; app.crop.drag = None; app.crop.default_frame = false; - app.crop.reset_frame = false; app.crop.frame_for = None; app.crop.editing = false; clear_history(app); crate::crop_straighten::reset(app); } -/// Reset the pending frame to the whole canvas, upright (the crop menu's Reset, #6561). The frame -/// stays a real pending one, so ↵ on it crops nothing and the tool keeps behaving as during a crop. -pub fn reset(app: &mut PhotocraftApp) { - let Some(st) = app.session.active() else { return }; - let b = st.doc.bounds(); - app.ui.crop_angle = 0.0; - keyed(app, [f64::from(b.x0), f64::from(b.y0), f64::from(b.x1), f64::from(b.y1)]); - app.crop.reset_frame = true; - crate::crop_mode::sync(app, None); - crate::crop_mode::sync_frame(app); -} - /// Drop the box's own Undo/Redo (leaving the tool, cancelling or committing a crop). pub fn clear_history(app: &mut PhotocraftApp) { app.crop.undo.clear(); @@ -460,7 +442,7 @@ pub fn clear_history(app: &mut PhotocraftApp) { /// The frame's current state, as an undo step. fn step_of(app: &PhotocraftApp) -> CropStep { - CropStep { rect: app.ui.crop_rect, angle: app.ui.crop_angle, default_frame: app.crop.default_frame, reset_frame: app.crop.reset_frame } + CropStep { rect: app.ui.crop_rect, angle: app.ui.crop_angle, default_frame: app.crop.default_frame } } /// Put a frame state back. @@ -468,7 +450,6 @@ fn apply_step(app: &mut PhotocraftApp, s: CropStep) { app.ui.crop_rect = s.rect; app.ui.crop_angle = s.angle; app.crop.default_frame = s.default_frame; - app.crop.reset_frame = s.reset_frame; } /// The pending frame, its box Undo/Redo and the view's turn, kept while `image.crop` runs so a @@ -695,7 +676,6 @@ fn keyed(app: &mut PhotocraftApp, r: [f64; 4]) { if r.iter().all(|v| v.is_finite()) { app.ui.crop_rect = Some(r); app.crop.default_frame = false; - app.crop.reset_frame = false; app.crop.editing = true; } } @@ -886,9 +866,6 @@ fn release(app: &mut PhotocraftApp, drag: CropDrag) { && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)); if ok && changed { app.crop.default_frame = false; - if !matches!(drag,CropDrag::Move{rect,..}|CropDrag::Resize{rect,..} if app.ui.crop_rect==Some(rect)) { - app.crop.reset_frame = false; - } } if ok { finish_frame(app, drag); diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index ce8910a83b..6fba4e57d4 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -1144,103 +1144,3 @@ fn readout_shows_the_frame_size_while_drawing_or_resizing() { assert_eq!(frame_size([f64::NAN, 0.0, 1.0, 1.0]), None); assert_eq!(frame_size([-f64::MAX, 0.0, f64::MAX, 1.0]), None); } - -/// #6561: the crop menu's Reset leaves a pending full-canvas frame; committing it is a no-op: -/// no history step, and with Delete Cropped Pixels on, layer pixels past the canvas survive. -#[test] -fn reset_then_commit_crops_nothing() { - let mut app = app(SampleType::U8); - app.ui.tool_options.crop_delete = true; - // A layer whose pixels reach past the right edge: a full-canvas crop with delete would - // trim them. - app.run("layer.new.layer", json!({"name": "over"})).unwrap(); - app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); - app.run("layer.translate", json!({"dx": 40, "dy": 0})).unwrap(); - let over = app.session.active().unwrap().doc.layers.iter().rev().find(|l| l.name == "over").unwrap(); - let past = over.surface().unwrap().content_bounds().x1; - assert!(past > 200, "the layer reaches past the canvas: {past}"); - // A drawn frame, then Reset: the whole canvas again, still pending. - drag(&mut app, &[[10.0, 10.0], [50.0, 40.0]], NONE); - reset(&mut app); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0])); - assert!(pending(&app), "Reset keeps the crop pending"); - let steps = app.session.active().unwrap().history.past_len(); - crate::canvas::commit_crop(&mut app); - let st = app.session.active().unwrap(); - assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps), "a reset frame commits as a no-op"); - assert!(app.ui.crop_rect.is_none(), "the frame is gone"); - let over = st.doc.layers.iter().rev().find(|l| l.name == "over").unwrap(); - assert_eq!(over.surface().unwrap().content_bounds().x1, past, "pixels outside the canvas are kept"); -} - -/// #6561: Undo after Reset restores the reset frame *as* a reset frame: committing it is -/// still a no-op, not a full-canvas `image.crop` that trims pixels outside the canvas. -#[test] -fn reset_edit_undo_then_commit_crops_nothing() { - let mut app = app(SampleType::U8); - app.ui.tool_options.crop_delete = true; - app.run("layer.new.layer", json!({"name": "over"})).unwrap(); - app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); - app.run("layer.translate", json!({"dx": 40, "dy": 0})).unwrap(); - let over = app.session.active().unwrap().doc.layers.iter().rev().find(|l| l.name == "over").unwrap(); - let past = over.surface().unwrap().content_bounds().x1; - assert!(past > 200, "the layer reaches past the canvas: {past}"); - // Reset, then a gesture that shrinks the frame, then Undo back to the reset frame. - reset(&mut app); - drag(&mut app, &[[0.0, 0.0], [50.0, 40.0]], NONE); - assert_eq!(app.ui.crop_rect, Some([50.0, 40.0, 200.0, 100.0]), "the corner drag resizes the frame"); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0]), "Undo restores the reset frame"); - assert!(pending(&app)); - let steps = app.session.active().unwrap().history.past_len(); - crate::canvas::commit_crop(&mut app); - let st = app.session.active().unwrap(); - assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps), "the undone reset frame commits as a no-op"); - assert!(app.ui.crop_rect.is_none(), "the frame is gone"); - let over = st.doc.layers.iter().rev().find(|l| l.name == "over").unwrap(); - assert_eq!(over.surface().unwrap().content_bounds().x1, past, "pixels outside the canvas are kept"); -} - -/// A click inside the reset frame that doesn't move it is not an edit: Reset stays an -/// intentional no-op, so Commit crops nothing. -#[test] -fn reset_then_a_still_click_keeps_the_no_op() { - let mut app = app(SampleType::U8); - app.ui.tool_options.crop_delete = true; - app.run("layer.new.layer", json!({"name": "over"})).unwrap(); - app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); - app.run("layer.translate", json!({"dx": 40, "dy": 0})).unwrap(); - reset(&mut app); - drag(&mut app, &[[100.0, 50.0], [100.0, 50.0]], NONE); - assert!(app.crop.reset_frame, "a click without a frame change keeps the reset marker"); - let steps = app.session.active().unwrap().history.past_len(); - crate::canvas::commit_crop(&mut app); - let st = app.session.active().unwrap(); - assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps), "the click leaves the no-op"); -} - -/// After Reset, a gesture that really moves the frame ends the no-op — even when a later -/// gesture brings the frame back to the full bounds: Commit then crops for real, and with -/// Delete Cropped Pixels the off-canvas pixels go. -#[test] -fn reset_then_frame_edit_back_to_bounds_commits_a_real_crop() { - let mut app = app(SampleType::U8); - app.ui.tool_options.crop_delete = true; - app.run("layer.new.layer", json!({"name": "over"})).unwrap(); - app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); - app.run("layer.translate", json!({"dx": 40, "dy": 0})).unwrap(); - let over = app.session.active().unwrap().doc.layers.iter().rev().find(|l| l.name == "over").unwrap(); - let past = over.surface().unwrap().content_bounds().x1; - assert!(past > 200, "the layer reaches past the canvas: {past}"); - reset(&mut app); - drag(&mut app, &[[0.0, 0.0], [50.0, 40.0]], NONE); // shrink: a real edit - assert!(!app.crop.reset_frame, "moving the frame ends the reset no-op"); - drag(&mut app, &[[50.0, 40.0], [0.0, 0.0]], NONE); // back to the full bounds - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0])); - let steps = app.session.active().unwrap().history.past_len(); - crate::canvas::commit_crop(&mut app); - let st = app.session.active().unwrap(); - assert_eq!(st.history.past_len(), steps + 1, "the restored frame commits as a real crop"); - let over = st.doc.layers.iter().rev().find(|l| l.name == "over").unwrap(); - assert_eq!(over.surface().unwrap().content_bounds().x1, 200, "pixels outside the canvas are deleted"); -} diff --git a/crates/ui-egui/src/tools/gradient_ui.rs b/crates/ui-egui/src/tools/gradient_ui.rs index ff96c73298..5bc7542761 100644 --- a/crates/ui-egui/src/tools/gradient_ui.rs +++ b/crates/ui-egui/src/tools/gradient_ui.rs @@ -29,9 +29,6 @@ use crate::widgets; #[cfg(test)] mod editor_tests; -mod preset_browser; -#[cfg(test)] -mod preset_browser_tests; mod properties_panel; /// Id of the temporary layer previewing a new gradient while it is drawn. @@ -521,16 +518,12 @@ pub(crate) fn paint_ramp(p: &egui::Painter, r: Rect, sample: impl Fn(f32) -> [f3 // The tessellator snaps the checker's rects to pixels but not the mesh: snap both alike, or a // half-pixel offset leaves a row of checker showing under the ramp (#4577). let r = r.round_to_pixels(p.pixels_per_point()); + widgets::checker(p, r, 5.0); let n = 64; - let samples: Vec<_> = (0..=n).map(|k| sample(k as f32 / n as f32)).collect(); - // Opaque previews need no transparency background, including at subpixel edges. - let painter = p.with_clip_rect(r); - if samples.iter().any(|c| c[3] < 1.0) { - widgets::checker(&painter, r, 5.0); - } let mut mesh = egui::Mesh::default(); - for (k, c) in samples.into_iter().enumerate() { + for k in 0..=n { let u = k as f32 / n as f32; + let c = sample(u); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; let col = Color32::from_rgba_unmultiplied(q(c[0]), q(c[1]), q(c[2]), q(c[3])); let x = r.left() + u * r.width(); @@ -542,7 +535,7 @@ pub(crate) fn paint_ramp(p: &egui::Painter, r: Rect, sample: impl Fn(f32) -> [f3 mesh.add_triangle(i - 1, i + 1, i); } } - painter.add(mesh); + p.add(mesh); } /// Side, in pixels, of a gradient fill layer's thumbnail texture. @@ -595,10 +588,17 @@ pub fn paint_thumbnail(ui: &egui::Ui, layer: LayerId, f: &Fill, rect: Rect) -> b } /// Options-bar swatch of the current gradient. Both live and classic modes use the same -/// engine-owned preset (#1651). The Classic ribbon opens the editor; Live is a preview. -/// Both modes provide a separate preset dropdown. Returns whether to open the editor. +/// engine-owned preset (#1651). Clicking it opens the Gradient Editor window, as Photoshop's +/// swatch does. Returns whether it was clicked. pub fn preset_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { - preset_browser::swatch(app, ui) + let t = Tokens::get(ui.ctx()); + let stops = app.session.presets.gradient.resolve(app.session.tools.foreground, app.session.tools.background); + let (r, resp) = ui.allocate_exact_size(vec2(96.0, 20.0), Sense::click()); + paint_ramp(ui.painter(), r, |u| photocraft_algo::paint::sample_stops(&stops, u)); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + // Name the control for accessibility (and so tests and agents can find it). + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Gradient picker"))); + resp.on_hover_text(tl!("Click to edit the gradient")).clicked() } /// The selected layer when it is a gradient fill layer. Unlike [`active_gradient`] it does not @@ -621,40 +621,32 @@ pub fn editor_window(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut open = true; let mut show_panel = false; // Resolved before the closure so `app` is borrowed only once inside it. - let layer = (!app.ui.tool_options.gradient_classic).then(|| selected_gradient(app)).flatten(); + let layer = selected_gradient(app); egui::Window::new(tl!("Gradient Editor")) .id(egui::Id::unique("gradient-editor")) .open(&mut open) .collapsible(false) .resizable(true) .default_width(330.0) - .default_height(if layer.is_some() { 850.0 } else { 650.0 }) - .vscroll(true) - .show(ctx, |ui| { - egui::CollapsingHeader::new(tl!("Presets")).id_salt("gradient-editor-presets").default_open(true).show(ui, |ui| { - preset_browser::show(app, ui, "gradient-editor-presets"); - }); - ui.separator(); - match &layer { - Some(l) => properties_panel::editor(app, ui, l), - None => { - // Photoshop's editor edits the gradient the tool paints with, so with no gradient - // fill layer around the strip edits the current gradient in place. - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(tl!("The gradient the Gradient tool paints with")).color(t.text_dim)); - ui.add_space(6.0); - preset_stops_editor(app, ui); - ui.add_space(6.0); - ui.horizontal(|ui| { - if widgets::secondary_button(ui, tl!("Save As Preset"), 0.0) - .on_hover_text(tl!("Adds the current gradient to the Gradients panel")) - .clicked() - { - let _ = app.run("gradient.presets.new", json!({})); - } - show_panel = widgets::secondary_button(ui, tl!("Gradients"), 0.0).clicked(); - }); - } + .show(ctx, |ui| match &layer { + Some(l) => properties_panel::editor(app, ui, l), + None => { + // Photoshop's editor edits the gradient the tool paints with, so with no gradient + // fill layer around the strip edits the current gradient in place. + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("The gradient the Gradient tool paints with")).color(t.text_dim)); + ui.add_space(6.0); + preset_stops_editor(app, ui); + ui.add_space(6.0); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, tl!("Save As Preset"), 0.0) + .on_hover_text(tl!("Adds the current gradient to the Gradients panel")) + .clicked() + { + let _ = app.run("gradient.presets.new", json!({})); + } + show_panel = widgets::secondary_button(ui, tl!("Gradients"), 0.0).clicked(); + }); } }); if show_panel { @@ -665,11 +657,6 @@ pub fn editor_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } } -/// Shared style previews for the options bar and Gradient Fill controls. -pub fn style_buttons(ui: &mut egui::Ui, current: &str, native_icons: bool) -> Option<&'static str> { - properties_panel::style_buttons(ui, current, native_icons) -} - /// Live mode: style, reverse, dither and blend mode changes in the options bar also edit the selected /// gradient fill layer (one history step each), as in Photoshop. pub fn options_changed(app: &mut PhotocraftApp, before: &crate::state::ToolOptions) { @@ -1046,21 +1033,3 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi #[cfg(test)] mod tests; - -fn stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill, picker: bool) { - if picker { - ui.horizontal_top(|ui| { - let strip_width = (ui.available_width() - 20.0 - ui.spacing().item_spacing.x).max(120.0); - ui.vertical(|ui| { - ui.set_width(strip_width); - stops_editor_for(app, ui, sink, f); - }); - ui.vertical(|ui| { - ui.add_space(18.0); // Align the 20-point picker with the ramp center. - preset_browser::layer_picker(app, ui, sink); - }); - }); - } else { - stops_editor_for(app, ui, sink, f); - } -} diff --git a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs index 863388b1dd..8bc5019ddf 100644 --- a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs +++ b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs @@ -6,7 +6,7 @@ use egui_kittest::{ kittest::{NodeT, Queryable}, }; -pub(super) fn editor(layer: bool) -> (Harness<'static, PhotocraftApp>, Sink) { +fn editor(layer: bool) -> (Harness<'static, PhotocraftApp>, Sink) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); let mut params = json!({"stops": [[0, "#ff0000"], [0.5, "#00ff00"], [1, "#0000ff"]], "transparency": [[0, 25], [0.5, 60], [1, 100]]}); @@ -27,7 +27,7 @@ pub(super) fn editor(layer: bool) -> (Harness<'static, PhotocraftApp>, Sink) { (h, sink) } -pub(super) fn click(h: &mut Harness, pos: Pos2) { +fn click(h: &mut Harness, pos: Pos2) { h.event(egui::Event::PointerMoved(pos)); h.run_steps(1); h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); @@ -41,7 +41,7 @@ fn select(h: &mut Harness, location: f32, opacity: bool) { click(h, pos2(rect.left() + 8.0 + location * (rect.width() - 16.0), rect.top() + if opacity { 6.0 } else { 50.0 })); } -pub(super) fn type_number(h: &mut Harness, index: usize, value: &str, commit: bool) { +fn type_number(h: &mut Harness, index: usize, value: &str, commit: bool) { h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().click(); h.run_steps(2); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); @@ -85,15 +85,13 @@ fn selected_editor_opacity_commits_once_and_delete_preserves_minimum() { let after = sink.fill(h.state()).unwrap(); assert!(matches!(&after, Fill::Gradient { opacity_stops, .. } if (opacity_stops[1].1 - 0.35).abs() < 1e-5)); assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); - let ramp = h.get_by_label("Gradient stops").rect(); - h.get_all_by_label("Delete").find(|n| n.rect().top() > ramp.bottom()).unwrap().click(); + h.get_by_label("Delete").click(); h.run_steps(3); let Fill::Gradient { stops, opacity_stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; assert_eq!(stops.len(), 3); assert_eq!(opacity_stops.len(), 2); select(&mut h, 0.0, true); - let ramp = h.get_by_label("Gradient stops").rect(); - assert!(h.get_all_by_label("Delete").find(|n| n.rect().top() > ramp.bottom()).unwrap().accesskit_node().is_disabled()); + assert!(h.get_by_label("Delete").accesskit_node().is_disabled()); h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(sink.fill(h.state()).unwrap(), after); h.state_mut().run("edit.undo", json!({})).unwrap(); @@ -140,8 +138,7 @@ fn preset_editor_opacity_fields_do_not_edit_document_history() { select(&mut h, 0.5, true); type_number(&mut h, 0, "35", true); assert!(matches!(sink.fill(h.state()).unwrap(), Fill::Gradient { opacity_stops, .. } if (opacity_stops[1].1 - 0.35).abs() < 1e-5)); - let ramp = h.get_by_label("Gradient stops").rect(); - h.get_all_by_label("Delete").find(|n| n.rect().top() > ramp.bottom()).unwrap().click(); + h.get_by_label("Delete").click(); h.run_steps(3); assert!(matches!(sink.fill(h.state()).unwrap(), Fill::Gradient { stops, opacity_stops, .. } if stops.len() == 3 && opacity_stops.len() == 2)); assert_eq!(h.state().session.active().unwrap().doc, before); diff --git a/crates/ui-egui/src/tools/gradient_ui/preset_browser.rs b/crates/ui-egui/src/tools/gradient_ui/preset_browser.rs deleted file mode 100644 index 9bf766b264..0000000000 --- a/crates/ui-egui/src/tools/gradient_ui/preset_browser.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Embedded preset selection, sharing the sidebar's folders, grid and management commands. - -use super::{Fill, Marker, PhotocraftApp, Sense, Sink, Stroke, StrokeKind, Tokens, Value, json, paint_ramp, selected_gradient, strip_key, vec2}; -use photocraft_engine::presets::gradients::{GradientPreset, StopColor}; - -fn target(app: &PhotocraftApp) -> Sink { - if !app.ui.tool_options.gradient_classic - && let Some(layer) = selected_gradient(app) - { - Sink::Layer(layer.id) - } else { - Sink::Preset - } -} - -fn new_params(app: &PhotocraftApp, sink: Sink) -> Value { - if let Sink::Layer(_) = sink - && let Some(Fill::Gradient { stops, opacity_stops, .. }) = sink.fill(app) - { - let stops = stops - .into_iter() - .map(|(location, color)| { - let rgb = color.to_rgb(); - (location, StopColor::Rgb([rgb[0], rgb[1], rgb[2]])) - }) - .collect(); - GradientPreset { name: "Custom".into(), stops, opacity: opacity_stops }.to_json() - } else { - json!({"name": "Custom"}) - } -} - -pub(super) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) -> bool { - let sink = target(app); - show_for(app, ui, panel, sink) -} - -fn show_for(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str, sink: Sink) -> bool { - let params = new_params(app, sink); - if !crate::preset_panels::gradient_picker(app, ui, panel, matches!(sink, Sink::Layer(_)), params) { - return false; - } - let key = strip_key(app, sink).with("selection"); - ui.data_mut(|d| { - d.remove::(strip_key(app, sink)); - let generation = d.get_temp::(key.with("generation")).unwrap_or_default(); - d.insert_temp(key.with("generation"), generation.wrapping_add(1)); - d.insert_temp(key, Marker::Color(0)); - }); - match sink { - Sink::Layer(id) => { - if app.gradient.panel.as_ref().is_some_and(|(layer, ..)| *layer == id) { - app.gradient.panel = None; - } - } - Sink::Preset => app.gradient.preset_stop = None, - } - true -} - -pub(super) fn layer_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink) { - let arrow = crate::icons::button(ui, "chevron-down", 20.0, false, "Presets"); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets"))); - egui::Popup::from_toggle_button_response(&arrow).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.set_width(320.0); - if show_for(app, ui, "gradient-properties-presets", sink) { - ui.close(); - } - }); -} - -pub(super) fn swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - let t = Tokens::get(ui.ctx()); - let classic = app.ui.tool_options.gradient_classic; - let stops = app.session.presets.gradient.resolve(app.session.tools.foreground, app.session.tools.background); - let (r, resp) = ui.allocate_exact_size(vec2(96.0, 20.0), if classic { Sense::click() } else { Sense::hover() }); - paint_ramp(ui.painter(), r, |u| photocraft_algo::paint::sample_stops(&stops, u)); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); - resp.widget_info(|| { - egui::WidgetInfo::labeled( - if classic { egui::Role::Button } else { egui::Role::Label }, - true, - if classic { tl!("Gradient picker") } else { tl!("Gradient") }, - ) - }); - let open_editor = if classic { resp.on_hover_text(tl!("Click to edit the gradient")).clicked() } else { false }; - let arrow = crate::icons::button(ui, "chevron-down", 20.0, false, "Presets"); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets"))); - egui::Popup::from_toggle_button_response(&arrow).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.set_width(320.0); - if show(app, ui, "gradient-picker") { - ui.close(); - } - }); - open_editor - }) - .inner -} diff --git a/crates/ui-egui/src/tools/gradient_ui/preset_browser_tests.rs b/crates/ui-egui/src/tools/gradient_ui/preset_browser_tests.rs deleted file mode 100644 index a2c64003dc..0000000000 --- a/crates/ui-egui/src/tools/gradient_ui/preset_browser_tests.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Exercise the embedded browser through the editor and toolbar, not a replacement test UI. - -use super::editor_tests::{editor, type_number}; -use super::*; -use egui_kittest::{ - Harness, - kittest::{NodeT, Queryable}, -}; - -#[test] -fn editor_browser_expands_groups_and_selects_without_opening_the_sidebar() { - let (mut h, sink) = editor(false); - let tab = h.state().ui.dock_tabs.color; - h.get_by_label("Purples").click(); - h.run_steps(3); - h.get_by_label("Purple 03").click(); - h.run_steps(3); - assert_eq!(h.state().session.presets.gradient.name, "Purple 03"); - assert!(matches!(sink.fill(h.state()), Some(Fill::Gradient { stops, .. }) if stops.len() == 3)); - assert_eq!(h.state().ui.dock_tabs.color, tab); - assert!(h.state().ui.panels.gradient_editor); - assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1); -} - -#[test] -fn layer_browser_replaces_color_and_opacity_preserves_geometry_and_undoes_once() { - let (mut h, sink) = editor(true); - let before = sink.fill(h.state()).unwrap(); - let count = h.state().session.active().unwrap().history.past_len(); - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(3); - let after = sink.fill(h.state()).unwrap(); - let Fill::Gradient { angle, scale, style, reverse, offset, dither, align, .. } = before.clone() else { panic!() }; - let Fill::Gradient { angle: a, scale: s, style: st, reverse: r, offset: o, dither: d, align: al, stops, opacity_stops, midpoints } = &after else { - panic!() - }; - assert_eq!((*a, *s, *st, *r, *o, *d, *al), (angle, scale, style, reverse, offset, dither, align)); - assert_eq!(stops.len(), 2); - assert_eq!(opacity_stops, &vec![(0.0, 1.0), (1.0, 0.0)]); - assert!(midpoints.is_empty()); - assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2); - assert_eq!(h.state().session.active().unwrap().history.past_len(), count + 1); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(sink.fill(h.state()).unwrap(), before); - h.state_mut().run("edit.redo", json!({})).unwrap(); - assert_eq!(sink.fill(h.state()).unwrap(), after); -} - -#[test] -fn classic_browser_and_editor_edit_the_tool_even_with_a_gradient_layer_selected() { - let (mut h, layer) = editor(true); - h.state_mut().ui.tool_options.gradient_classic = true; - h.run_steps(3); - let before = layer.fill(h.state()); - let count = h.state().session.active().unwrap().history.past_len(); - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(3); - assert_eq!(layer.fill(h.state()), before); - assert_eq!(h.state().session.presets.gradient.name, "Foreground to Transparent"); - assert_eq!(h.state().session.active().unwrap().history.past_len(), count); - assert!(h.query_by_label("Gradient Options").is_none()); - h.query_all_by_label("Color").find(|n| n.accesskit_node().role() == egui::accesskit::Role::Button).unwrap().click(); - h.run_steps(2); - assert!(!h.state().ui.dialogs.is_empty()); -} - -#[test] -fn picking_a_preset_discards_an_uncommitted_location_draft() { - let (mut h, sink) = editor(true); - let count = h.state().session.active().unwrap().history.past_len(); - type_number(&mut h, 0, "70", false); - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(4); - assert!(matches!(sink.fill(h.state()), Some(Fill::Gradient { stops, .. }) if stops[0].0 == 0.0)); - assert_eq!(h.state().session.active().unwrap().history.past_len(), count + 1); -} - -#[test] -fn toolbar_popup_selects_in_both_modes_without_creating_a_layer() { - for classic in [false, true] { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); - app.ui.tool = Tool::Gradient; - app.ui.tool_options.gradient_classic = classic; - let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state( - |ui, app: &mut PhotocraftApp| { - if preset_swatch(app, ui) { - app.ui.panels.gradient_editor = true; - } - }, - app, - ); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); - h.run_steps(3); - h.get_by_label("Presets").click(); - h.run_steps(3); - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(3); - assert_eq!(h.state().session.presets.gradient.name, "Foreground to Transparent"); - assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1); - assert!(!h.state().ui.panels.gradient_editor); - assert!(h.query_by_label("Basics").is_none(), "popup closes after choosing a preset"); - } -} - -#[test] -fn editor_browser_can_choose_presets_without_a_document() { - let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut h = Harness::builder().with_size(vec2(1000.0, 900.0)).build_ui_state(|ui, app: &mut PhotocraftApp| editor_window(app, ui.ctx()), app); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); - h.state_mut().ui.panels.gradient_editor = true; - h.run_steps(3); - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(3); - assert!(h.state().session.active().is_none()); - assert_eq!(h.state().session.presets.gradient.name, "Foreground to Transparent"); -} - -#[test] -fn browser_new_saves_the_edited_layer_instead_of_the_tool_gradient() { - let (mut h, sink) = editor(true); - let before = sink.fill(h.state()); - let count = h.state().session.active().unwrap().history.past_len(); - h.get_by_label("Create new gradient").click(); - h.run_steps(3); - let saved = h.state().session.presets.gradients.iter().flat_map(|g| &g.items).find(|p| p.name == "Custom").unwrap(); - assert_eq!(saved.stops.len(), 3); - assert_eq!(saved.opacity, vec![(0.0, 0.25), (0.5, 0.6), (1.0, 1.0)]); - assert_eq!(sink.fill(h.state()), before); - assert_eq!(h.state().session.active().unwrap().history.past_len(), count); -} - -#[test] -fn double_clicking_an_editor_preset_does_not_create_another_layer() { - let (mut h, _) = editor(true); - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(2); - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(3); - assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2); - assert_eq!(h.state().session.presets.gradient.name, "Foreground to Transparent"); -} - -#[test] -fn presets_can_collapse_to_reach_controls_in_a_small_window_across_themes() { - for theme in crate::theme::ThemeKind::ALL { - let (mut h, _) = editor(true); - PhotocraftApp::setup_context(&h.ctx, theme); - h.set_size(vec2(440.0, 560.0)); - h.run_steps(4); - h.get_by_label("Presets").click(); - h.run_steps(8); - let reset = h.get_by_label("Reset Alignment").rect(); - assert!(reset.left() >= 0.0 && reset.right() <= 440.0 && reset.bottom() <= 560.0, "{theme:?}: {reset:?}"); - h.get_by_label("Reset Alignment").click(); - h.run_steps(3); - assert!(h.state().ui.panels.gradient_editor); - } -} diff --git a/crates/ui-egui/src/tools/gradient_ui/properties_panel.rs b/crates/ui-egui/src/tools/gradient_ui/properties_panel.rs index 18b41c5046..b5a16773f1 100644 --- a/crates/ui-egui/src/tools/gradient_ui/properties_panel.rs +++ b/crates/ui-egui/src/tools/gradient_ui/properties_panel.rs @@ -2,7 +2,7 @@ use super::{ Color32, Fill, GradientStyle, Layer, LayerId, Marker, PhotocraftApp, Rect, Sense, Sink, Stroke, StrokeKind, Tokens, c32, cmds, edit_stop_color, fill_of, - json, preset_stop_color, selection_after_edit, stops_editor, strip_key, vec2, widgets, + json, preset_stop_color, selection_after_edit, stops_editor_for, strip_key, vec2, widgets, }; use crate::{icons, props_layout, theme}; @@ -17,22 +17,22 @@ const STYLES: [(GradientStyle, &str); 5] = [ pub(super) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let document = app.session.active().map(|s| s.doc.id); ui.push_id(("gradient-properties", document, layer.id.0), |ui| { - layer_controls(app, ui, layer, true); + layer_controls(app, ui, layer); }); } pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let document = app.session.active().map(|s| s.doc.id); ui.push_id(("gradient-editor-layer", document, layer.id.0), |ui| { - layer_controls(app, ui, layer, false); + layer_controls(app, ui, layer); }); } -fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer, picker: bool) { +fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if props_layout::section(ui, "gradient-fill-stops", "Gradient") && let Some(fill) = current_fill(app, layer.id) { - stops_editor(app, ui, Sink::Layer(layer.id), fill, picker); + stops_editor_for(app, ui, Sink::Layer(layer.id), fill); } options(app, ui, layer); } @@ -43,8 +43,10 @@ fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { ui.horizontal(|ui| { row_label(ui, "Style"); ui.spacing_mut().item_spacing.x = 3.0; - if let Some(value) = style_buttons(ui, cmds::style_name(style), app.session.prefs().interface.native_gradient_icons) { - let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "style": value})); + for (value, name) in STYLES { + if style_button(ui, value, value == style, name).clicked() { + let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "style": cmds::style_name(value)})); + } } }); if let Some(value) = numeric_row(ui, "angle", "Angle", angle, -180.0..=180.0, "°") { @@ -103,30 +105,6 @@ fn numeric_row(ui: &mut egui::Ui, id: &str, name: &str, current: f32, range: std .inner } -pub(super) fn style_buttons(ui: &mut egui::Ui, current: &str, native_icons: bool) -> Option<&'static str> { - let mut selected = None; - for (style, name) in STYLES { - let value = cmds::style_name(style); - let response = if native_icons { - let icon = match style { - GradientStyle::Linear => "gradient", - GradientStyle::Radial => "circle", - GradientStyle::Angle => "rotate-cw", - GradientStyle::Reflected => "arrow-left-right", - GradientStyle::Diamond => "diamond", - }; - icons::button(ui, icon, 25.0, value == current, name) - } else { - style_button(ui, style, value == current, name) - }; - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), value == current, tl!(name))); - if response.clicked() { - selected = Some(value); - } - } - selected -} - /// Small procedural previews: no proprietary icon assets, colours follow the theme. fn style_button(ui: &mut egui::Ui, style: GradientStyle, selected: bool, name: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); @@ -281,72 +259,25 @@ mod tests { h.run_steps(3); } - #[test] - fn properties_dropdown_edits_the_layer_in_both_tool_modes_and_saves_it() { - for classic in [false, true] { - let mut app = app(); - app.ui.tool_options.gradient_classic = classic; - let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state( - |ui, app: &mut PhotocraftApp| { - ui.set_width(280.0); - panel(ui, app); - }, - app, - ); - PhotocraftApp::setup_context(&h.ctx, theme::ThemeKind::ProMedium); - h.run_steps(3); - let original = fill(h.state()); - let count = h.state().session.active().unwrap().history.past_len(); - let arrow = h.get_by_label("Presets").rect(); - let strip = h.get_by_label("Gradient stops").rect(); - assert!(arrow.left() >= strip.right() && arrow.right() - strip.left() <= 280.0, "{arrow:?} beside {strip:?}"); - assert!((arrow.center().y - (strip.top() + 28.0)).abs() < 1.0); - h.get_by_label("Presets").click(); - h.run_steps(3); - h.get_by_label("Create new gradient").click(); - h.run_steps(3); - let saved = h.state().session.presets.gradients.iter().flat_map(|g| &g.items).find(|p| p.name == "Custom").unwrap(); - assert_eq!(saved.opacity, vec![(0.0, 0.2), (1.0, 1.0)]); - assert_eq!(saved.resolve([0.0; 4], [0.0; 4])[0].1, [1.0, 0.0, 0.0, 0.2]); - assert_eq!(fill(h.state()), original); - h.key_press(egui::Key::Escape); - h.run_steps(3); - h.get_by_label("Presets").click(); - h.run_steps(3); - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(3); - assert!(matches!(fill(h.state()), Fill::Gradient { opacity_stops, .. } if opacity_stops == vec![(0.0, 1.0), (1.0, 0.0)])); - assert_eq!(h.state().session.active().unwrap().history.past_len(), count + 1); - assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2); - assert!(h.query_by_label("Basics").is_none()); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(fill(h.state()), original); - } - } - #[test] fn style_icons_edit_the_selected_layer_with_one_undo_step() { - for native_icons in [false, true] { - let mut app = app(); - app.run("prefs.set", json!({"path": "interface.nativeGradientIcons", "value": native_icons})).unwrap(); - let mut h = Harness::builder().with_size(vec2(280.0, 800.0)).build_ui_state(panel, app); - PhotocraftApp::setup_context(&h.ctx, theme::ThemeKind::ProMedium); + let mut h = Harness::builder().with_size(vec2(280.0, 800.0)).build_ui_state(panel, app()); + PhotocraftApp::setup_context(&h.ctx, theme::ThemeKind::ProMedium); + h.run_steps(3); + for (style, name) in STYLES.into_iter().skip(1) { + let before = fill(h.state()); + let count = h.state().session.active().unwrap().history.past_len(); + h.get_by_label(name).click(); h.run_steps(3); - for (style, name) in STYLES.into_iter().skip(1) { - let before = fill(h.state()); - let count = h.state().session.active().unwrap().history.past_len(); - h.get_by_label(name).click(); - h.run_steps(3); - let after = fill(h.state()); - assert!(matches!(&after, Fill::Gradient { style: actual, .. } if *actual == style)); - assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2); - assert_eq!(h.state().session.active().unwrap().history.past_len(), count + 1); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(fill(h.state()), before); - h.state_mut().run("edit.redo", json!({})).unwrap(); - assert_eq!(fill(h.state()), after); - h.run_steps(2); - } + let after = fill(h.state()); + assert!(matches!(&after, Fill::Gradient { style: actual, .. } if *actual == style)); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2); + assert_eq!(h.state().session.active().unwrap().history.past_len(), count + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(fill(h.state()), before); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(fill(h.state()), after); + h.run_steps(2); } } diff --git a/crates/ui-egui/src/tools/gradient_ui/tests.rs b/crates/ui-egui/src/tools/gradient_ui/tests.rs index fd0eb7dd75..22e0355b2c 100644 --- a/crates/ui-egui/src/tools/gradient_ui/tests.rs +++ b/crates/ui-egui/src/tools/gradient_ui/tests.rs @@ -8,53 +8,6 @@ fn app_with_gradient(style: &str) -> PhotocraftApp { app } -#[test] -fn opaque_ramp_has_no_checkerboard_fringe_at_fractional_pixel_edges() { - use egui_kittest::Harness; - for scale in [1.0, 2.0] { - let edge = 20.0 + 0.3 / scale; - let rect = Rect::from_min_size(pos2(edge, edge), vec2(96.0, 20.0)); - let Ok(mut h) = std::panic::catch_unwind(|| { - Harness::builder().with_size(vec2(160.0, 80.0)).with_pixels_per_point(scale).wgpu().build_ui(move |ui| { - paint_ramp(ui.painter(), rect, |_| [0.1, 0.25, 0.2, 1.0]); - }) - }) else { - eprintln!("skipping: no GPU adapter"); - return; - }; - h.run_steps(3); - let image = h.render().unwrap(); - let background = image.get_pixel((8.0 * scale) as u32, (25.0 * scale) as u32); - let right = (rect.right() * scale).floor() as u32; - for y in ((rect.top() + 2.0) * scale) as u32..((rect.bottom() - 2.0) * scale) as u32 { - let pixel = image.get_pixel(right, y); - for channel in 0..3 { - assert!(pixel[channel].abs_diff(background[channel]) <= 2, "scale={scale} at ({right}, {y}): {pixel:?} vs {background:?}"); - } - } - } -} - -#[test] -fn genuinely_transparent_ramp_still_shows_checkerboard_at_both_scales() { - use egui_kittest::Harness; - for scale in [1.0, 2.0] { - let Ok(mut h) = std::panic::catch_unwind(|| { - Harness::builder().with_size(vec2(160.0, 80.0)).with_pixels_per_point(scale).wgpu().build_ui(|ui| { - paint_ramp(ui.painter(), Rect::from_min_size(pos2(20.0, 20.0), vec2(96.0, 20.0)), |_| [0.1, 0.25, 0.2, 0.0]); - }) - }) else { - eprintln!("skipping: no GPU adapter"); - return; - }; - h.run_steps(3); - let image = h.render().unwrap(); - let a = image.get_pixel((32.0 * scale) as u32, (22.0 * scale) as u32); - let b = image.get_pixel((32.0 * scale) as u32, (27.0 * scale) as u32); - assert!(a[0].abs_diff(b[0]) >= 30, "scale={scale}: {a:?} vs {b:?}"); - } -} - fn mods() -> egui::Modifiers { egui::Modifiers::NONE } @@ -99,8 +52,7 @@ fn thumbnails_show_the_gradient() { #[test] fn options_bar_swatch_opens_the_editor_and_reaches_the_gradients_panel() { use egui_kittest::{Harness, kittest::Queryable}; - let mut app = app_with_gradient("linear"); - app.ui.tool_options.gradient_classic = true; + let app = app_with_gradient("linear"); let mut h = Harness::builder().with_size(vec2(440.0, 560.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { if preset_swatch(app, ui) { @@ -117,10 +69,7 @@ fn options_bar_swatch_opens_the_editor_and_reaches_the_gradients_panel() { h.get_by_label("Gradient picker").click(); h.run_steps(2); assert!(h.state().ui.panels.gradient_editor, "the swatch opened the editor window"); - // Collapse the embedded browser to reach the sidebar shortcut on a small screen. - h.get_all_by_label("Presets").last().unwrap().click(); - h.run_steps(8); - // The existing Gradients button still opens the sidebar. + // Quick preset picking lives in the editor: its Gradients button shows the Gradients panel. h.get_by_label("Gradients").click(); h.run_steps(2); assert!(h.state().ui.panels.color, "the Gradients panel opened"); @@ -328,9 +277,12 @@ fn preview_shows_the_drag_before_commit() { assert!(display_doc(&mut app, 0).is_none()); } -/// Issue #2199: the Live ribbon is a preview; only Classic opens the editor. +/// Regression: the options bar's gradient swatch could not open anything. In live mode it +/// sensed hover only, so `clicked()` was never true; in classic mode it sensed clicks and +/// promised "Click to edit the gradient" but never read the response. Both now open the +/// Gradient Editor window. #[test] -fn the_options_bar_ribbon_opens_the_editor_only_in_classic_mode() { +fn the_options_bar_swatch_opens_the_gradient_editor_in_both_modes() { use egui_kittest::kittest::Queryable; for classic in [false, true] { @@ -347,13 +299,9 @@ fn the_options_bar_ribbon_opens_the_editor_only_in_classic_mode() { PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); h.run_steps(4); assert!(!h.state().ui.panels.gradient_editor, "classic={classic}: starts closed"); - if classic { - h.get_by_label("Gradient picker").click(); - } else { - h.query_all_by_label("Gradient").find(|n| n.rect().width() == 96.0).unwrap().click(); - } + h.get_by_label("Gradient picker").click(); h.run_steps(3); - assert_eq!(h.state().ui.panels.gradient_editor, classic, "classic={classic}: ribbon behavior follows the mode"); + assert!(h.state().ui.panels.gradient_editor, "classic={classic}: the swatch opened the editor"); } } @@ -468,8 +416,7 @@ fn the_ramp_covers_its_checkerboard_at_half_pixel_offsets() { let mut painted_at = 0.0; let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { painted_at = ui.ctx().pixels_per_point(); - // Translucent, so the checkerboard is drawn under it. - paint_ramp(ui.painter(), r, |u| [u, u, u, 0.5]); + paint_ramp(ui.painter(), r, |u| [u, u, u, 1.0]); }); out.textures_delta.clear(); assert_eq!(painted_at, ppp); diff --git a/crates/ui-egui/src/tools/move_hover.rs b/crates/ui-egui/src/tools/move_hover.rs index aef1f315ef..c7041f9a0d 100644 --- a/crates/ui-egui/src/tools/move_hover.rs +++ b/crates/ui-egui/src/tools/move_hover.rs @@ -185,7 +185,7 @@ mod tests { assert_eq!(active_outline(&mut app, None, false, false), Some(a)); // When a drag or transform is in progress, no outline: - app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0, over_ruler: false, flipped: false }); + app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0, over_ruler: false, flipped:false }); assert_eq!(active_outline(&mut app, None, false, false), None); app.guide_drag = None; diff --git a/crates/ui-egui/src/tools/transform_tool/options_bar.rs b/crates/ui-egui/src/tools/transform_tool/options_bar.rs index 64778476b4..5a618ab5ad 100644 --- a/crates/ui-egui/src/tools/transform_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/transform_tool/options_bar.rs @@ -1,4 +1,4 @@ -use super::begin::{enter_warp, interpolation_preference, leave_warp, warps_type}; +use super::begin::{interpolation_preference, enter_warp, leave_warp, warps_type}; use super::commit::{cancel, commit}; use super::pointer::point_on_quad; use super::warp::edit_session_warp; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index 78fb3ab5b1..a6dd09e44a 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -2433,26 +2433,6 @@ mod multi_layer_formatting { assert_eq!(shown(&h, "Swash"), Some(Toggled::True), "shown at the selection start"); } - /// The Paragraph panel's Roman Hanging Punctuation checkbox: off by default, toggles the - /// paragraph style on the selected type layers (or the layer being typed in). - #[test] - fn character_panel_hanging_checkbox_toggles_the_paragraph_style() { - use egui::accesskit::Toggled; - use egui_kittest::kittest::NodeT; - let (app, ids) = fixture(); - let mut h = controls(app, true); - let hanging = |h: &Harness<'static, PhotocraftApp>, id: &LayerId| text(h.state(), *id).paragraphs[0].style.hanging; - assert_eq!(h.get_by_label("Roman Hanging Punctuation").accesskit_node().toggled(), Some(Toggled::False), "off by default"); - h.get_by_label("Roman Hanging Punctuation").click(); - h.run(); - for id in &ids[..2] { - assert!(hanging(&h, id), "the selected layers get the flag"); - } - assert!(!hanging(&h, &ids[2]), "an unselected layer is untouched"); - h.run(); - assert_eq!(h.get_by_label("Roman Hanging Punctuation").accesskit_node().toggled(), Some(Toggled::True), "the checkbox shows the flag on"); - } - /// Type › Anti-Alias and Type › Orientation check the edited (else selected) layer's value. #[test] fn type_menu_checks_the_current_anti_alias_and_orientation() { diff --git a/crates/ui-egui/src/tools/type_tool/panel.rs b/crates/ui-egui/src/tools/type_tool/panel.rs index 779c077af2..09ebeb70c9 100644 --- a/crates/ui-egui/src/tools/type_tool/panel.rs +++ b/crates/ui-egui/src/tools/type_tool/panel.rs @@ -467,14 +467,6 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa apply(app, ui.ctx(), json!({"spaceAfter": v / u})); } }); - // Roman Hanging Punctuation: trailing marks hang past the box edge and don't steer the - // alignment (Photoshop keeps this checkbox at the bottom of the Paragraph panel). - row(ui, &mut |ui| { - let mut hanging = para.hanging; - if ui.checkbox(&mut hanging, tl!("Roman Hanging Punctuation")).changed() { - apply(app, ui.ctx(), json!({"hanging": hanging})); - } - }); // Hyphenate is stored (⌥⇧⌘H / PSD AutoHyphenate) but layout never breaks words (#6710). ui.add_space(ROW_GAP); } diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index df5b15e493..d6c21e3ac3 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -24,14 +24,13 @@ pub mod edit; pub mod feather; pub mod flatten; pub mod map; -pub mod pattern; pub mod raster; pub mod shapes; pub mod stroke; pub mod trace; use photocraft_color::{ColorMode, PixelFormat}; -use photocraft_doc::{Fill, FillRule, GradientStyle, Path, PathOp, Pattern, ShapeLayer, ShapeStroke, StrokeAlign, VectorMask}; +use photocraft_doc::{Fill, FillRule, GradientStyle, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, VectorMask}; use photocraft_geom::{Rect, TILE_SIZE}; use photocraft_raster::Surface; @@ -245,10 +244,6 @@ pub enum Paint { None, /// Straight RGBA. Solid([f32; 4]), - Pattern { - tile: std::sync::Arc, - placement: pattern::Placement, - }, Gradient { /// Colour stops sorted by location (straight RGBA). stops: Vec<(f32, [f32; 4])>, @@ -273,13 +268,9 @@ impl Paint { /// gradient field is honoured (midpoints, opacity stops, centre offset, dither, unsorted /// stops), rendering the same pixels as the compositor's fill layers; the parity tests in /// `photocraft-compose` (which this crate cannot depend on) keep the two in step. Pattern - /// paints need [`Paint::from_fill_with_patterns`] and the document's pattern table. + /// fills are not rendered (transparent): they need the document's pattern table, which + /// lives above this crate. pub fn from_fill(f: &Fill, bounds: (f64, f64, f64, f64), canvas: (f64, f64, f64, f64)) -> Paint { - Self::from_fill_with_patterns(f, bounds, canvas, &[], ColorMode::Rgb) - } - - /// Resolves pattern ids/names and samples them in the document's colour mode. - pub fn from_fill_with_patterns(f: &Fill, bounds: (f64, f64, f64, f64), canvas: (f64, f64, f64, f64), patterns: &[Pattern], mode: ColorMode) -> Paint { let rgba = |c: &photocraft_color::Color| { let v = c.to_rgb(); [v[0], v[1], v[2], c.alpha] @@ -305,12 +296,7 @@ impl Paint { frame, } } - Fill::Pattern { id, name, scale, angle, phase, link } => { - let tile = photocraft_doc::pattern::find(patterns, id, name).and_then(|p| pattern::Tile::new_in(p, mode)).map(std::sync::Arc::new); - let Some(tile) = tile else { return Paint::None }; - let anchor = (bounds.0.floor(), bounds.1.floor()); - Paint::Pattern { tile, placement: pattern::Placement::anchored(anchor, *link, *phase, *scale, *angle) } - } + Fill::Pattern { .. } => Paint::None, } } @@ -319,7 +305,6 @@ impl Paint { match self { Paint::None => [0.0; 4], Paint::Solid(c) => *c, - Paint::Pattern { tile, placement } => tile.sample_area(placement, x, y), Paint::Gradient { stops, midpoints, opacity_stops, style, layout, reverse, dither: dith, frame } => { let t = gradient_t(*style, layout.0, layout.1, *reverse, layout.2, *frame, x, y); let mut c = sample_stops(stops, midpoints, opacity_stops, t); @@ -584,11 +569,6 @@ impl CompiledShape { /// Compiles the shape at flattening tolerance `tol`. `canvas` is the frame gradients /// without "Align with layer" are laid out in. pub fn new(shape: &ShapeLayer, tol: f64, canvas: Rect) -> Self { - Self::with_patterns(shape, tol, canvas, &[], ColorMode::Rgb) - } - - /// Compiles both paints against the document's embedded pattern table. - pub fn with_patterns(shape: &ShapeLayer, tol: f64, canvas: Rect, patterns: &[Pattern], mode: ColorMode) -> Self { let pb = shape.path.control_bounds().unwrap_or((0.0, 0.0, 0.0, 0.0)); let cb = (f64::from(canvas.x0), f64::from(canvas.y0), f64::from(canvas.x1), f64::from(canvas.y1)); let area = fill_rasterizer(&shape.path, tol); @@ -596,7 +576,7 @@ impl CompiledShape { let fill = shape.fill.as_ref().map(|f| { let coverage = FillCoverage::new(shape, &area, canvas); bounds = coverage.bounds(); - (coverage, Paint::from_fill_with_patterns(f, pb, cb, patterns, mode)) + (coverage, Paint::from_fill(f, pb, cb)) }); let all_closed = shape.path.subpaths.iter().all(|s| s.closed); let mut align = None; @@ -613,7 +593,7 @@ impl CompiledShape { (Some(a), Some(b)) => Some(a.union(&b)), (a, b) => a.or(b), }; - (r, Paint::from_fill_with_patterns(&s.paint, pb, cb, patterns, mode), s.opacity.clamp(0.0, 1.0)) + (r, Paint::from_fill(&s.paint, pb, cb), s.opacity.clamp(0.0, 1.0)) }); if shape.path.inverted && shape.fill.is_some() { bounds = Some(Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4)); @@ -698,10 +678,5 @@ pub fn render_shape(shape: &ShapeLayer, format: PixelFormat, clip: Rect) -> Surf CompiledShape::new(shape, DEFAULT_TOLERANCE, clip).render(format, clip) } -/// Renders shape fill and stroke using the document's embedded patterns. -pub fn render_shape_with_patterns(shape: &ShapeLayer, format: PixelFormat, clip: Rect, patterns: &[Pattern]) -> Surface { - CompiledShape::with_patterns(shape, DEFAULT_TOLERANCE, clip, patterns, format.mode).render(format, clip) -} - #[cfg(test)] mod tests; diff --git a/crates/vector/src/pattern.rs b/crates/vector/src/pattern.rs deleted file mode 100644 index 9d7024c208..0000000000 --- a/crates/vector/src/pattern.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Shared pattern pixels and placement for vector shapes and compositing. - -use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; -use photocraft_raster::Surface; - -/// Shapes prepare at most two paints; larger patterns sample their existing COW surface. -const PREPARED_TILE_BYTES: usize = 64 << 20; - -fn paint_in_mode(mut rgba: [f32; 4], source_mode: ColorMode, mode: ColorMode) -> [f32; 4] { - let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); - if paints_in && source_mode != mode { - let fmt = PixelFormat::new(mode, SampleType::F32, true); - let mut values = [0.0f32; 5]; - let n = photocraft_raster::from_rgba_into(&fmt, rgba, &mut values); - rgba = photocraft_raster::to_rgba(&fmt, values.get(..n).unwrap_or_default()); - } - rgba -} - -/// A pattern's pixels (straight-alpha RGBA, row-major) as they look painted into a document of -/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the -/// library and in the document), but in a Grayscale or CMYK document it paints that mode's -/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the -/// pattern in colour in its picker, filled in grey). Empty for a pattern that is not -/// [paintable](Pattern::is_paintable). -pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> { - let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else { - return Vec::new(); - }; - let mut px = vec![[0.0f32; 4]; n]; - p.surface.read_rgba_into(p.rect(), &mut px); - for q in &mut px { - *q = paint_in_mode(*q, p.surface.format().mode, mode); - } - px -} - -/// A pattern converted once to straight-alpha RGBA for sampling. -#[derive(Clone, Debug)] -pub struct Tile { - w: usize, - h: usize, - /// Premultiplied RGBA. - px: Vec<[f32; 4]>, - /// Oversized tiles retain their COW source instead of allocating a second full buffer. - source: Option<(Surface, ColorMode)>, -} - -impl Tile { - pub fn new(p: &Pattern) -> Option { - Tile::new_in(p, ColorMode::Rgb) - } - - /// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]). - pub fn new_in(p: &Pattern, mode: ColorMode) -> Option { - Self::new_in_with_budget(p, mode, PREPARED_TILE_BYTES) - } - - fn new_in_with_budget(p: &Pattern, mode: ColorMode, budget: usize) -> Option { - if !p.is_paintable() { - return None; - } - let (w, h) = (p.width as usize, p.height as usize); - let bytes = w.checked_mul(h)?.checked_mul(std::mem::size_of::<[f32; 4]>())?; - if bytes > budget { - return Some(Tile { w, h, px: Vec::new(), source: Some((p.surface.clone(), mode)) }); - } - let mut px = pattern_rgba(p, mode); - for q in &mut px { - for c in 0..3 { - q[c] *= q[3]; - } - } - Some(Tile { w, h, px, source: None }) - } - - #[inline] - fn at(&self, x: i64, y: i64) -> [f32; 4] { - let xi = x.rem_euclid(self.w as i64) as usize; - let yi = y.rem_euclid(self.h as i64) as usize; - if let Some((surface, mode)) = &self.source { - let mut rgba = paint_in_mode(surface.rgba(xi as i32, yi as i32), surface.format().mode, *mode); - let alpha = rgba[3]; - for c in rgba.iter_mut().take(3) { - *c *= alpha; - } - rgba - } else { - self.px.get(yi * self.w + xi).copied().unwrap_or([0.0; 4]) - } - } - - /// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`). - /// Returns straight alpha. - pub fn sample(&self, u: f64, v: f64) -> [f32; 4] { - unpremultiply(self.sample_premul(u, v)) - } - pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] { - let n = pixel_taps(place.inv_scale); - if n == 1 { - let (u, v) = place.map(x, y); - return self.sample(u, v); - } - let mut acc = [0.0f32; 4]; - for j in 0..n { - for i in 0..n { - let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n)); - let q = self.sample_premul(u, v); - for c in 0..4 { - acc[c] += q[c]; - } - } - } - let k = 1.0 / (n * n) as f32; - unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k]) - } - fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] { - if !u.is_finite() || !v.is_finite() { - return [0.0; 4]; - } - let (u, v) = (u.rem_euclid(self.w as f64) - 0.5, v.rem_euclid(self.h as f64) - 0.5); - let (x0, y0) = (u.floor(), v.floor()); - let (fx, fy) = ((u - x0) as f32, (v - y0) as f32); - let (x0, y0) = (x0 as i64, y0 as i64); - let mut acc = [0.0f32; 4]; - for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { - if wy == 0.0 { - continue; - } - for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { - if wx == 0.0 { - continue; - } - let p = self.at(x0 + dx, y0 + dy); - let k = wx * wy; - for c in 0..4 { - acc[c] += p[c] * k; - } - } - } - acc - } -} - -/// Where a pattern sits: document point → tile coordinates. -#[derive(Clone, Copy, Debug)] -pub struct Placement { - origin: (f64, f64), - /// Inverse rotation (cos, sin) and inverse scale. - cs: (f64, f64), - inv_scale: f64, -} - -impl Placement { - /// `frame`: the layer's frame (used when `link`); `phase` in pixels; `scale` as a fraction; - /// `angle` in degrees counter-clockwise. - pub fn new(frame: Rect, link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement { - let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) }; - Self::anchored(anchor, link, phase, scale, angle) - } - - /// Like [`Placement::new`], with the linked origin given as a point. Layer effects anchor - /// linked patterns at the layer's effects reference point (PSD `fxrp`): Photoshop's - /// pattern overlays and pattern strokes match pixel-for-pixel tiled from there. - pub fn anchored(anchor: (f64, f64), link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement { - let base = if link { anchor } else { (0.0, 0.0) }; - let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1)); - let s = f64::from(scale); - let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s } else { 1.0 }; - let a = f64::from(angle).to_radians(); - Placement { origin, cs: (a.cos(), a.sin()), inv_scale } - } - - /// (origin, inverse rotation (cos, sin), inverse scale), for GPU shaders. - pub fn parts(&self) -> ((f64, f64), (f64, f64), f64) { - (self.origin, self.cs, self.inv_scale) - } - - /// Tile coordinates of the document point `(x, y)`. - #[inline] - pub fn map(&self, x: f64, y: f64) -> (f64, f64) { - let (dx, dy) = (x - self.origin.0, y - self.origin.1); - // Undo a counter-clockwise rotation (y points down on screen). - let (c, s) = self.cs; - let (rx, ry) = (dx * c - dy * s, dx * s + dy * c); - (rx * self.inv_scale, ry * self.inv_scale) - } -} - -/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). -const MAX_TAPS: usize = 8; - -/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers -/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above. -fn pixel_taps(inv_scale: f64) -> usize { - let s = inv_scale as f32; - if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 } -} - -/// Offset in document pixels of tap `i` of `n`, centred on the pixel. -fn tap_offset(i: usize, n: usize) -> f64 { - (i as f64 + 0.5) / n as f64 - 0.5 -} - -fn unpremultiply(acc: [f32; 4]) -> [f32; 4] { - if acc[3] <= 0.0 { - return [0.0; 4]; - } - [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] -} diff --git a/crates/vector/tests/shape_patterns.rs b/crates/vector/tests/shape_patterns.rs deleted file mode 100644 index 402356149f..0000000000 --- a/crates/vector/tests/shape_patterns.rs +++ /dev/null @@ -1,118 +0,0 @@ -use photocraft_doc::{ColorMode, Fill, Pattern, PixelFormat, SampleType, ShapeLayer, ShapeStroke}; -use photocraft_geom::Rect; -use photocraft_raster::Surface; -use photocraft_vector::{Paint, pattern::Tile, render_shape_with_patterns}; - -fn pattern(sample: SampleType, mode: ColorMode) -> Pattern { - let format = PixelFormat::new(mode, sample, true); - let mut s = Surface::new(format); - for (x, rgba) in [(0, [0.9, 0.2, 0.1, 1.0]), (1, [0.1, 0.3, 0.9, 0.5])] { - let mut values = [0.0; 5]; - let n = photocraft_raster::from_rgba_into(&format, rgba, &mut values); - s.fill_rect(Rect::new(x, 0, x + 1, 2), &values[..n]); - } - Pattern::new("$$$/Patterns/Test=Tile", s, 2, 2) -} - -fn fill(p: &Pattern) -> Fill { - Fill::Pattern { id: p.id.clone(), name: p.name.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) } -} - -fn close(a: [f32; 4], b: [f32; 4], tolerance: f32) { - for c in 0..4 { - assert!((a[c] - b[c]).abs() < tolerance, "{a:?} != {b:?}"); - } -} - -#[test] -fn pattern_shape_fill_and_stroke_render_at_every_depth_and_model() { - let canvas = Rect::new(0, 0, 32, 32); - for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { - let p = pattern(sample, mode); - let fmt = PixelFormat::new(mode, sample, true); - let check = |actual: [f32; 4], expected: [f32; 4], where_: &str| { - for c in 0..4 { - assert!((actual[c] - expected[c]).abs() < 1e-4, "{sample:?}/{mode:?} {where_}: {actual:?} != {expected:?}"); - } - }; - // The cache stores the document's format. CMYK's decode/encode/decode - // roundtrip need not preserve RGB, even when the source tile is also CMYK. - let encoded = |x| { - let values = photocraft_raster::from_rgba(&fmt, p.surface.rgba(x, 0)); - Surface::with_default(fmt, &values).rgba(0, 0) - }; - let sh = ShapeLayer { path: photocraft_vector::shapes::rounded_rect(8.0, 8.0, 16.0, 16.0, [0.0; 4]), fill: Some(fill(&p)), ..Default::default() }; - let sampler = - Paint::from_fill_with_patterns(sh.fill.as_ref().unwrap(), (8.0, 8.0, 24.0, 24.0), (0.0, 0.0, 32.0, 32.0), std::slice::from_ref(&p), mode); - check(sampler.at(10.5, 10.5), p.surface.rgba(0, 0), "opaque tile sampler"); - check(sampler.at(11.5, 10.5), p.surface.rgba(1, 0), "half-alpha tile sampler"); - let surface = render_shape_with_patterns(&sh, fmt, canvas, std::slice::from_ref(&p)); - check(surface.rgba(10, 10), encoded(0), "opaque fill cache"); - check(surface.rgba(11, 10), encoded(1), "half-alpha fill cache"); - assert_eq!(surface.rgba(2, 2)[3], 0.0); - let stroke = ShapeLayer { fill: None, stroke: Some(ShapeStroke { width: 4.0, paint: fill(&p), ..Default::default() }), ..sh }; - let surface = render_shape_with_patterns(&stroke, fmt, canvas, std::slice::from_ref(&p)); - check(surface.rgba(8, 12), encoded(0), "stroke cache"); - assert_eq!(surface.rgba(16, 16)[3], 0.0); - } - } -} - -#[test] -fn pattern_lookup_placement_and_premultiplied_sampling() { - let p = pattern(SampleType::F32, ColorMode::Rgb); - let mut f = fill(&p); - if let Fill::Pattern { id, name, link, phase, scale, angle } = &mut f { - *id = "missing-id".into(); - *name = "tile".into(); - *link = false; - *phase = (4.0, 6.0); - *scale = 2.0; - *angle = 90.0; - } - let paint = Paint::from_fill_with_patterns(&f, (8.0, 8.0, 24.0, 24.0), (0.0, 0.0, 32.0, 32.0), std::slice::from_ref(&p), ColorMode::Rgb); - // Undo 90° and 2× scale: (5,5) relative to phase maps to tile centre (.5,.5). - close(paint.at(5.0, 5.0), p.surface.rgba(0, 0), 0.001); - assert!(matches!(Paint::from_fill(&f, (0.0, 0.0, 1.0, 1.0), (0.0, 0.0, 32.0, 32.0)), Paint::None)); - let tile = Tile::new(&p).unwrap(); - let a = p.surface.rgba(0, 0); - let b = p.surface.rgba(1, 0); - let alpha = (a[3] + b[3]) / 2.0; - close( - tile.sample(1.0, 0.5), - [(a[0] * a[3] + b[0] * b[3]) / (2.0 * alpha), (a[1] * a[3] + b[1] * b[3]) / (2.0 * alpha), (a[2] * a[3] + b[2] * b[3]) / (2.0 * alpha), alpha], - 0.001, - ); - close(tile.sample(-1.5, 0.5), a, 0.001); - assert_eq!(tile.sample(f64::NAN, 0.0), [0.0; 4]); - assert!(tile.sample(f64::MAX, -f64::MAX).iter().all(|v| v.is_finite())); -} - -#[test] -fn tiny_positive_pattern_scale_is_preserved_in_sampling_and_shape_rendering() { - let p = pattern(SampleType::F32, ColorMode::Rgb); - let scale = 0.0005; - let placement = photocraft_vector::pattern::Placement::anchored((8.0, 8.0), true, (0.0, 0.0), scale, 0.0); - let mapped = placement.map(10.5, 10.5); - assert!((mapped.0 - 5000.0).abs() < 0.001 && (mapped.1 - 5000.0).abs() < 0.001, "{mapped:?}"); - let expected = Tile::new(&p).unwrap().sample(mapped.0, mapped.1); - let paint = Fill::Pattern { id: p.id.clone(), name: p.name.clone(), scale, angle: 0.0, link: true, phase: (0.0, 0.0) }; - let shape = ShapeLayer { path: photocraft_vector::shapes::rounded_rect(8.0, 8.0, 16.0, 16.0, [0.0; 4]), fill: Some(paint), ..Default::default() }; - let rendered = render_shape_with_patterns(&shape, PixelFormat::RGBA32F, Rect::new(0, 0, 32, 32), std::slice::from_ref(&p)); - close(rendered.rgba(10, 10), expected, 0.001); -} -#[test] -fn oversized_sparse_pattern_samples_without_a_second_full_surface() { - let p = Pattern { - id: "large".into(), - name: "large".into(), - width: 4096, - height: 4096, - surface: Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.6, 0.9, 1.0]), - }; - let tile = Tile::new(&p).unwrap(); - close(tile.sample(4000.5, 2000.5), p.surface.rgba(4000, 2000), 0.001); - assert!(Tile::new(&Pattern { width: u32::MAX, ..p.clone() }).is_none()); - assert!(Tile::new(&Pattern { height: 0, ..p }).is_none()); -} diff --git a/docs/architecture.md b/docs/architecture.md index 8d7c8a6f84..0125f26693 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -424,7 +424,7 @@ psd/src/ | Area | Contents (first pass) | GPU kernel? | |---|---|---| | Adjustments | levels, curves, brightness/contrast, exposure, vibrance, hue/sat, color balance, B&W, photo filter, channel mixer, gradient map, selective color, invert/posterize/threshold, 3D LUT | yes (LUT + small kernels) | -| Filters | gaussian/box/motion/radial/surface/lens blur, unsharp/smart sharpen, high pass, noise add/reduce/median/dust, distort family (twirl, wave, polar, spherize, displace), stylize, render (clouds, lens flare), pixelate, color to alpha, frequency separation | many | +| Filters | gaussian/box/motion/radial/surface/lens blur, unsharp/smart sharpen, high pass, noise add/reduce/median/dust, distort family (twirl, wave, polar, spherize, displace), stylize, render (clouds, lens flare), pixelate, color to alpha | many | | Selection | marquee/lasso geometry → mask, magic wand (flood fill tolerance), color range, quick select (graph cut / superpixels), feather, grow/shrink, refine edge | some | | Inpaint/heal | spot heal, healing brush (Poisson), content-aware fill (PatchMatch + multiscale EM) | later | | Warp | free transform (affine/perspective/warp mesh), liquify, puppet (ARAP), lens correction, resampling (Lanczos/bicubic) | yes | diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 49072bae9c..1ef971ef33 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -37,10 +37,6 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic The layer-list menu on an empty pixel or all-hidden hit should be empty/closed; it must not select a transparent or hidden layer. The right-erase preference is an alternate PhotoCraft behavior, and its selected state takes precedence over the Brush picker only for Brush/Eraser. -### Crop canvas menu (#6561, 2026-10-25) - -With the Crop tool and a pending frame, a canvas right-click opens the crop menu: the options-bar ratios (Original Ratio, 1 : 1, 4 : 5, 5 : 7, 2 : 3, 16 : 9; the current one checked), the saved crop presets, the gear options Use Classic Mode / Show Cropped Area / Auto Center Preview (checked; the last two greyed in Classic Mode), and Reset / Cancel / Commit. The rows route through the same functions as the options bar, so the menu and the bar cannot disagree. No menu on the untouched default frame or outside the image (the pasteboard menu wins), and the menu closes when the crop stops being pending. - ## Pen canvas reference capture (2026-10-07) The user supplied a Photoshop screenshot of a closed path in Path mode. The table preserves every row in its order; separator boundaries appear between the groups. Gray rows in that capture remain visible but disabled for a plain work path. Enablement changes with the active shape layer, saved path, copied style, and symmetry state. Ellipsis rows open a parameter dialog before changing the document. @@ -82,7 +78,6 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho |---|---|---|---| | Layers panel row | Blending Options, Duplicate/Delete, Group, export, convert/rasterize, mask, clipping, link, style copy/paste/clear, merge/flatten, Rename; filter by layer kind and selection | Implemented in layer_menu_ui.rs; item enablement uses menu commands | PhotoCraft inventory. Adobe documents layer rename/delete, color tagging, rasterization, and flattening [here](https://helpx.adobe.com/photoshop/using/layers.html); exact combined ordering **inferred** without a live capture | | Layers panel color label / thumbnail / mask thumbnail / effect row | Color label; context actions specific to hit icon | Layer-mask thumbnail: Disable/Delete/Apply Layer Mask, Add/Subtract/Intersect Mask with Selection, Select and Mask… (layer_menu_ui.rs `show_mask`, #4575; Mask Options… missing). Generic row on the other targets; inspect before extending | Adobe explicitly documents right-click on layer/group to assign a color ([layers](https://helpx.adobe.com/photoshop/using/layers.html)); other icon menus need capture | -| Smart Filter row | Reorder or remove the clicked filter | Move Up, Move Down and Delete Smart Filter invoke the existing engine commands, preserve inactive-layer targeting and Undo/Redo, and refuse a stale popup or modal/job edits; edge moves are disabled | **Partial gesture parity.** Adobe [documents dragging filters to reorder and to the Delete icon](https://helpx.adobe.com/uk/photoshop/using/applying-smart-filters.html), with bottom-up evaluation. These context actions are PhotoCraft convenience controls. Existing selection, transfer between Smart Objects and drag-to-Trash behavior is retained; drag-to-reorder remains open. | | Channels panel row | Duplicate/Delete/Rename, channel display and overlay color, spot conversion; new/split/merge | Implemented in channels_panel.rs | PhotoCraft inventory; exact Photoshop order **inferred** | | Brushes and preset panels | Rename/Delete brush or group, export selected brushes, view/reset preset choices | Rename/Delete, and preset group/item menus exist | Adobe explicitly documents Export Selected Brushes from brush/group right-click ([brushes](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html)); Photoshop pop-up-panel rename/delete/reset/view options are [documented](https://helpx.adobe.com/photoshop/using/panels-menus.html), though some are panel-button menus | | Paths, History, Actions, Swatches, Gradients, Patterns, Styles, Libraries, Properties | Target-specific duplicate/delete/rename and panel operations where a panel exists | Paths row menu added in this PR; other panels need audit | **Inferred** catalog; record a Photoshop capture before claiming parity | @@ -108,11 +103,8 @@ Each slice needs pure model tests (including every item dispatching a known comm | 2026-10-21 | minor | Crop tool menu on a pending crop (#6561) | | 2026-10-20 | trivial | Zoom tool menu order (#6005) | | 2026-10-18 | minor | Type tool menu over committed type (#7324) | -| 2026-10-16 | minor | Crop canvas menu on a pending frame (#6561) | | 2026-10-15 | trivial | Pasteboard row: the colour menu exists since #2462 | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | -| 2026-10-13 | minor | Retain main's Smart Filter selection, transfer and drag-to-Trash behavior alongside the context actions; drag-to-reorder remains a separate gap | -| 2026-10-12 | minor | Smart Filter rows expose bounded move/delete actions through existing commands, with captured document/revision guards; Adobe drag/trash gesture parity remains partial (#1265) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | | 2026-10-07 | minor | Pen canvas reference capture | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c820b76bbd..473c6fe4ff 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -130,7 +130,6 @@ store expose and persist the same setting; scripts keep using canonical command | `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel | | `channel.options` | `{"channel":index\|name\|"quickMask"\|"mask",…}`: Channel Options (name, `indicates`, overlay `color`, `opacity`/`solidity`). On `"mask"` it is Layer Mask Display Options: the `color` and `opacity` of every layer mask's rubylith (`\`, ⇧⌥-click), kept in the preferences as `layerMaskOverlay` (not an undo step) and returned under that key | | `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` | -| `layer.blurBackground` | `{"layer":id\|name?,"radius":0.1..1000=8}`. Detect the subject on a pixel layer and blur its background within the current selection in one undo step. Preserves transparency, selection, masks and other layers. No subject or cancellation leaves the document unchanged. | | `layer.fill.solidColor.set` | `{"layer":id?,"document":id?,"color":"#rrggbb"}`: edit a Solid Color Fill in one undo step; RGB without alpha preserves the fill's alpha. Float RGB arrays and a stored `Color` are also accepted. `document` rejects stale dialog targets. | | `layer.fill.pattern.set` | `{"layer":id?,"document":id?,"pattern":id\|name?,"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]?}`: edit a Pattern Fill (the Pattern Fill dialog's OK) in one undo step; omitted values keep the layer's, `phase` `[0,0]` is Snap to Origin. `layer.newFillLayer.pattern` from a menu (no params) and `layer.layerContentOptions` on a Pattern Fill layer open the Pattern Fill dialog. | | `layer.layerMask.edit` | `{"layer":id?,"document":id?,"density":0..100?,"feather":0..1000?}`: edit pixel-mask Density (%) and Feather (px) without changing mask pixels, including adjustment masks. At least one setting is required; `document` rejects stale targets. A shared `coalesce` key groups slider updates into one undo step. | @@ -151,7 +150,6 @@ store expose and persist the same setting; scripts keep using canonical command | `type.caret` | `{"layer":id?,"index":char}`: caret segment in document pixels, `{"index","line","segment":[[x,y],[x,y]]}` (rotated and vertical type included) | | `type.navigate` | `{"layer":id?,"index":char,"move":"wordPrev\|wordNext\|linePrev\|lineNext\|lineStart\|lineEnd\|start\|end","x":px?}`: neighbouring caret. `x` keeps the column across line moves. Result `{"index"}` | | `actions.list` | `{}` → `{actions:[{name, steps}], recording}` (the name being recorded, or null) | -| `actions.loadDefaults` | `{}` → `{added:[names], set}`. Append missing PhotoCraft Essentials actions without replacing same-name actions. Available without a document; refused while recording or playing an action. | | `actions.get` | `{"action": name or index}` → `{name, steps:[[id, params], …], disabled:[step, …]}`. `steps` is the shape `file.automate.batch` and droplets take; `disabled` lists the 0-based steps playback skips | | `actions.record` | `{"name":"Red"}` starts a new action (default name `Action N`). `{"action": name or index}` appends to one that exists | | `actions.stop` | `{}` → `{action, steps}`. Copies replayable journal entries since `actions.record` (queries and action-editing commands omitted) | @@ -161,30 +159,7 @@ store expose and persist the same setting; scripts keep using canonical command | `actions.toggle` | `{"action": name or index, "step": index?, "enabled": bool?}` → `{action, disabled}`. Switches one 0-based step (pending recorded steps included) on or off; omitting `enabled` flips it. Without `step`, switches every step: on unless all already are. Playback, Batch and droplets skip steps that are off. In the desktop panel, click a step's check box | | `actions.rename` | `{"action": name or index, "name": str}` → `{action, index}`. Empty, whitespace-only or duplicate names are rejected and keep the old name. Nested `actions.play` steps that call the action by name and its function key follow the new name. In the desktop panel, double-click an action's name to rename it in place (Enter commits, Esc cancels) | -The Actions list fills the resizable dock group. **PhotoCraft Essentials** supplies Auto Tone, -Auto Contrast, Auto Color, Select Subject, and Blur Background. These are original, editable -single-step actions. The three auto adjustments work on the active pixel layer -and respect the selection; Select Subject samples the visible image using PhotoCraft's current -subject-detection heuristic. Blur Background uses that detector on the active pixel layer and -blurs only its background within the current selection, with an 8-pixel radius. Fully detected -subject pixels are excluded from blur sampling; partial edge coverage can still contribute. -It preserves the layer's transparency and masks, other layers and the existing selection, and -fails without an edit when no subject is found. This is a Gaussian blur using the current -heuristic detector, not depth-aware lens blur. Each successful edit can be undone normally. -Actions playback currently runs synchronously; job progress and cancellation are available -when `layer.blurBackground` is invoked directly through the UI command dispatcher. - -The set is added once when the desktop or web preset store opens, including for existing users. -Saved actions keep their order and definitions; a same-name action is preserved. Deleting or -renaming a default stays that way after restart. **Load Default Actions** in the Actions panel's -menu (also offered by an empty panel) adds missing defaults again. CLI/MCP sessions can use -`actions.loadDefaults` explicitly. Blur Background calls a dedicated atomic command; it does -not compose selection-changing steps that could act on an old selection after detection fails. - -`actions.undo` and `actions.redo` undo/redo edits to action definitions (reordering, -deleting, renaming, creating and recording), independently of document History. Ctrl/Cmd+Z and -Ctrl/Cmd+Shift+Z target this history while the Actions panel has focus. Playing an -action returns Undo to document edits. +The Actions list fills the resizable dock group. Newly recorded Layers eye gestures resolve their targets by layer name and duplicate-name ordinal during playback rather than retaining a document-local layer id. A missing target @@ -261,20 +236,6 @@ Filtering narrows the same array without changing its elements: `serve`'s `engin desktop control channel's `engine.commands` takes no filter and always returns every command. The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). -### Smart Filter row actions (#1265) - -Smart Filter rows offer Move Up, Move Down and Delete Smart Filter from their context menu. -They use the existing `layer.smartFilter.move {layer,index,to}` and -`layer.smartFilter.delete {layer,index}` commands. The displayed stack is reversed: moving -visually up uses `to:index+1`, down uses `to:index-1`. Explicit `layer` targets the clicked -Smart Object while preserving the active layer and selection. - -Popup actions close when the captured document snapshot or revision changes, and cannot -edit while a modal dialog or a job locks the document. CLI/MCP commands retain their existing -validation and Undo/Redo behavior. These context actions provide PhotoCraft convenience -controls. Existing Smart Filter selection, transfer between Smart Objects and drag-to-Trash -behavior is retained, including current drag-to-reorder. - ### Background jobs (#210) Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. @@ -359,13 +320,6 @@ desktop startup's single check (enabled by default). Manual checks still work wh `ui.inspect` includes `updates` and `updatesOpen`; `ui.set {"updatesOpen": false}` closes the non-modal update window. - -## Shape tools - -Paint JSON retains solid alpha/model colours; gradient `opacityStops`, `midpoints`, `offset`, -`dither`, `align`; pattern `id`, `angle`, `link`, `phase` alongside legacy stops/name/scale. -Shape create/edit resolves document or preset pattern tiles and embeds them in the same undo step. - ## Snapping With View › Snap on, tool gestures snap to the View › Snap To targets (guides and grid while they diff --git a/docs/decisions.md b/docs/decisions.md index 479fa76430..ac44d14500 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,11 +1,5 @@ # Implementation decisions -## 2026-10-13: Editable starter actions (#4779) - -PhotoCraft Essentials uses ordinary editable actions. Add the set once after saved actions load, preserving names and order; persist a starter-set version so deleted or renamed actions stay that way. Explicit Load Default Actions adds missing names without replacing edits. Keep headless sessions empty until that command is called. Blur Background uses a single atomic edit on the target pixel layer: detect its subject, exclude subject coverage from Gaussian sampling, and blend the background inside the existing selection while preserving alpha. Detection failure and cancellation do not commit. A recorded Select Subject → Inverse → Blur sequence can act on an old selection when detection finds nothing, so the preset calls this dedicated command instead. - -The browser queues generated defaults and their marker for persistence, but only user edits count as unsaved work. Startup writes can be reproduced from the loaded records and built-ins; denied storage alone should not display a loss-of-work banner or block closing a fresh session. Imports and edits to defaults remain unsaved through failed or in-flight transactions until their current contents are committed. - ## 2026-10-10: Smart Object contents identity (#2163) Smart Object instances carry a document-scoped contents ID separately from their layer ID. Normal duplicates preserve it; New Smart Object via Copy creates a new one. Contents edits update the matching instances together, retaining their individual placement and filters. Copies into another document remap contents IDs as a batch, preserving sharing within the copied layers without joining existing destination objects. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 10315bf793..2744c085a8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -136,8 +136,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | -| Custom shapes `.csh` | R/W | R | Version 2 sets through Shapes → Import Shapes… or File → Open; persistent groups via `shape.presets.importCsh`. Original generated geometry cross-read with ag-psd; not yet checked against a Photoshop-written file. No export | -| Tool presets `.tpl` | R/W | no | | +| Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | | C2PA Content Credentials | R/W | no | #1050 | @@ -178,7 +177,6 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 2026-10-14 | minor | PSD smart objects warped with Transform Warp's Cylinder (`warpCylinder`, seven `warpValues`) are read and written: Replace Contents, Relink and Edit Contents re-render them wrapped instead of flat, and a regenerated `SoLd` no longer drops the warp (#4112). Vertical cylinders and ones with perspective are still unread | | 2026-10-15 | minor | Bevel & Emboss Gloss Contour › Anti-aliased (`antialiasGloss`) is read and written, set by `glossAntiAlias` and shown in the Layer Style dialog; a PSD whose bevel is edited no longer loses it. Rendering still ignores contour anti-aliasing, as for the other effects' contours | | 2026-10-15 | minor | Stroke › Overprint (`overprint`) is read and written, set by the stroke command's `overprint` and shown in the Layer Style dialog; an edited stroke no longer drops it. Rendering ignores it for now | -| 2026-10-14 | minor | CSH custom-shape set import (#5359, FILE-215-15); export and Photoshop-written fixture verification remain open; no estimate changed | | 2026-10-14 | minor | Nikon NEF: the D4 profile's saturation level follows the sample depth (15520 at 14 bits, 3880 at 12), so 12-bit D4 files open as bright as their 14-bit twins instead of 2.6 stops darker (#5978) | | 2026-10-14 | minor | Animated WebP: New Video Layer from File, Replace Footage, Reload Frame and Video Frames to Layers take every frame, composited, with the timeline rate from the durations (#1198) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | diff --git a/docs/gaps.md b/docs/gaps.md index e4ff545128..bd2a5e6d93 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -283,8 +283,6 @@ what an agent can't finish alone. - **Progress (2026-10-14, #5556):** Character and Paragraph controls stay available without a type target, editing persisted Type tool defaults inherited by new text. Formatting existing selected text keeps its current command and undo scope. Other panel gaps remain open. -- **Progress (2026-10-13):** scrollbar visibility can follow the theme, stay expanded, auto-hide - or be hidden; panel and canvas scrolling remain available (#3262). - **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). - **Progress (2026-10-15):** the Color panel has Photoshop's panel-menu modes (Hue Cube, Brightness @@ -343,9 +341,7 @@ what an agent can't finish alone. DICOM, EPS/DCS, Pixar, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; spot plates beyond PSD and TIFF: spot ink preview in RGB/CMYK documents and PDF Separation (FILE-215-17); .atn, - .shc presets and .csh export (.asl landed 2026-10-20) (FILE-215-15); C2PA (#1050). -- **CSH import:** Version 2 custom-shape sets load into persistent Shapes groups (#5359). - Original synthetic geometry is independently cross-read; Photoshop-written fixture verification remains open. + .shc, .csh presets (.asl landed 2026-10-20) (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). - **Progress (2026-10-23, #7415 split):** PSD stores and reads spot channels as Photoshop does (ink 0 = solid) and writes their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.16.0, which stored @@ -438,7 +434,6 @@ what an agent can't finish alone. | 2026-10-15 | minor | G9: Animated Zoom preference takes effect (hold the Zoom tool to zoom continuously, #6210); prefs that do nothing 25 of 166; no estimate changed | | 2026-10-16 | minor | G7: Content-Aware Move's live preview covers every selection the command takes (over 256 × 256: a coarse result solved scaled down, then the exact one); TOOL-213-4 done (#6680) | | 2026-10-15 | minor | G9: Save in Background off saves in the foreground under a modal progress dialog (#6207); preferences that do nothing 25 → 24 of 169 | -| 2026-10-14 | minor | CSH custom-shape set import (#5359, FILE-215-15); export and Photoshop-written fixture verification remain open; no estimate changed | | 2026-10-14 | minor | G1: PSD smart objects keep Transform Warp's Cylinder through Replace Contents, Relink, Edit Contents and re-saves (#4112) | | 2026-10-15 | minor | G8: area type: Type tool click inside a closed path or shape wraps text inside it (TYPE-218-10); Type tool click on a path's outline (TYPE-218-1); Type scorecard 5 done, 4 partial, 1 missing | | 2026-10-14 | minor | G14: the Nikon D4 profile's white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark (#5978); gap remains open | @@ -471,7 +466,6 @@ what an agent can't finish alone. | 2026-10-13 | minor | G14: lossy DNG (#5112), `.xmp` sidecars, Camera Raw Defaults wired, Calibration panel and Auto; CR3 sensor decode ruled out under the clean-room rule | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G2: Content-Aware Scale (P28) measured: carve 2013 → 556 ms, row 3.28 → 1.64 s on a 4-core VM, output unchanged; baseline, budgets and estimate unchanged | -| 2026-10-13 | minor | Scrollbar visibility preference for panels and the canvas (#3262), with live application, theme retention and wheel-scrolling coverage | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | diff --git a/docs/integration-testing/approved-bugfix-parity-2026-10-29.md b/docs/integration-testing/approved-bugfix-parity-2026-10-29.md index 4b361c0887..b6643f7ca8 100644 --- a/docs/integration-testing/approved-bugfix-parity-2026-10-29.md +++ b/docs/integration-testing/approved-bugfix-parity-2026-10-29.md @@ -1,3 +1,5 @@ +> HISTORICAL 108-PR BUILD: this record describes the earlier source and artifact. The current branch has 21 requested PRs removed. See [trimmed integration](trimmed-integration-2026-10-29.md) for current scope and validation. Do not use the old portable artifact to validate these removals. + # PhotoCraft integration test plan Status: TEST BUILD PREPARED; automated validation passed. Every manual case is NOT RUN. diff --git a/docs/integration-testing/integration-checkpoint.md b/docs/integration-testing/integration-checkpoint.md index 145e55ddb6..faceb14dc7 100644 --- a/docs/integration-testing/integration-checkpoint.md +++ b/docs/integration-testing/integration-checkpoint.md @@ -1,3 +1,5 @@ +> HISTORICAL 108-PR BUILD: this record describes the earlier source and artifact. The current branch has 21 requested PRs removed. See [trimmed integration](trimmed-integration-2026-10-29.md) for current scope and validation. Do not use the old portable artifact to validate these removals. + # Final testing checkpoint Validated and packaged source: `f5b3f1238bb414b43851ebe86f3a58ce83bb9b48`. Base: `5f9b4391874aabc9deb2fcb905267613e4d93ec8`. diff --git a/docs/integration-testing/integration-status.json b/docs/integration-testing/integration-status.json index 7514b87a26..ef09e58a4e 100644 --- a/docs/integration-testing/integration-status.json +++ b/docs/integration-testing/integration-status.json @@ -1,7 +1,7 @@ { - "selected_count": 108, - "integrated_count": 108, - "pending_count": 0, + "selected_count": 87, + "integrated_count": 87, + "removed_count": 21, "integrated_prs": [ 9703, 9651, @@ -32,8 +32,6 @@ 9354, 9342, 9339, - 9282, - 9172, 9110, 9019, 9015, @@ -46,7 +44,6 @@ 8897, 8896, 8892, - 8347, 8193, 8192, 8191, @@ -55,11 +52,8 @@ 7903, 7848, 7743, - 7742, 7648, 7255, - 7153, - 6621, 6510, 6492, 6449, @@ -68,35 +62,24 @@ 6236, 6139, 6026, - 5939, - 5870, 5744, 5650, 5515, - 5493, - 5453, 5270, 5176, 5165, - 5090, 5082, 5010, 4993, 4992, 4985, 4938, - 4890, - 4883, - 4783, 4763, 4742, 4711, 4547, - 4305, - 4042, 4035, 4023, - 3967, 3874, 3748, 3554, @@ -104,1293 +87,34 @@ 3519, 3464, 3461, - 3455, 3387, - 3345, 3142, + 2300 + ], + "removed_prs": [ + 1991, 3139, - 2300, - 1991 + 3345, + 3455, + 3967, + 4042, + 4305, + 4783, + 4883, + 4890, + 5090, + 5453, + 5493, + 5870, + 5939, + 6621, + 7153, + 7742, + 8347, + 9172, + 9282 ], - "pending_prs": [], - "state": "108 SELECTED HEADS INTEGRATED; VALIDATED PORTABLE TEST BUILD PREPARED; MANUAL ACCEPTANCE NOT RUN", - "manual_tests": "NOT RUN", - "integrated_validation": "PASSED at frozen source", - "branch": "integration/approved-bugfix-parity-2026-10-29", - "prs": [ - { - "number": 9703, - "title": "Fix adjustment layer name clicks targeting masks", - "head": "3605d30c38294c4d970e52f4a02f44f97e8bc1a9", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9703#pullrequestreview-5655990760", - "conflicts": [ - "crates/ui-egui/src/panels.rs" - ] - }, - { - "number": 9651, - "title": "perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426)", - "head": "67666bdf72a58066df868868cef82e5b9cc838de", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9651#pullrequestreview-5651366614", - "conflicts": [] - }, - { - "number": 9623, - "title": "Pencil: keep a 1 px freehand line connected where it turns", - "head": "652075f4da7fe905ad651eebc3a6c276fe1d7a5d", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9623#pullrequestreview-5649212328", - "conflicts": [] - }, - { - "number": 9606, - "title": "fix(ui): the Color panel remembers its options across restarts", - "head": "6cc516a3d20120b678040050d95ccadc8d1dfd3f", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9606#pullrequestreview-5647898187", - "conflicts": [ - "crates/ui-egui/src/panels.rs", - "crates/ui-egui/src/prefs_ui.rs", - "docs/scorecard.md" - ] - }, - { - "number": 9598, - "title": "fix(ui): Liquify's Twirl Counterclockwise gets a mirrored twirl icon", - "head": "47aea89e9220b996f0d5a0d2bb8b5938100b4ad1", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9598#pullrequestreview-5647858443", - "conflicts": [ - "ATTRIBUTION.md" - ] - }, - { - "number": 9597, - "title": "fix(ui): the layer list doesn't move with no layer selected", - "head": "9429df26b371c04c2afa65228a60ded739b5fbc2", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9597#pullrequestreview-5648898287", - "conflicts": [ - "crates/ui-egui/src/panels.rs" - ] - }, - { - "number": 9596, - "title": "fix(io): drop a kept Txt2 once no type layers are left", - "head": "e62013cfd652fa60470a0f7a185fbc8f3d2a2c92", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9596#pullrequestreview-5647870259", - "conflicts": [] - }, - { - "number": 9595, - "title": "fix(compose, gpu): a Pass Through group with Blend If renders isolated", - "head": "75fa71a4f55a098061b12d91508b514565ab8dcf", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9595#pullrequestreview-5647882992", - "conflicts": [] - }, - { - "number": 9594, - "title": "fix(engine): copied type keeps its size across resolutions after an edit", - "head": "46512521fec2b4b462c29d9fefd16398cb81212c", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9594#pullrequestreview-5647890395", - "conflicts": [] - }, - { - "number": 9590, - "title": "fix(io): PSD layer-effect contours keep at most 16 points", - "head": "a9744ed44dc782613b19a97b8c159a091f73e669", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9590#pullrequestreview-5647923229", - "conflicts": [ - "crates/io/src/effects_map.rs" - ] - }, - { - "number": 9589, - "title": "fix(compose, gpu): an adjustment layer's This Layer range judges its result before Opacity", - "head": "da157945077190e550b53a5e14b0536f62761292", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9589#pullrequestreview-5647036365", - "conflicts": [ - "crates/compose/src/lib.rs", - "crates/gpu/src/plan.rs" - ] - }, - { - "number": 9584, - "title": "fix(compose, gpu): Blend If hard points match Photoshop at 16 bits and for Gray", - "head": "fc0f7b1132f799b5bba7be642c9047499d95a887", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9584#pullrequestreview-5647082363", - "conflicts": [] - }, - { - "number": 9523, - "title": "Fix Type on a Path wrapping and caret positioning across closed-path seams", - "head": "728782c595e768bdb6febc7f30e4d1456d13fd36", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9523#pullrequestreview-5645920841", - "conflicts": [ - "crates/ui-egui/src/type_tool.rs" - ] - }, - { - "number": 9517, - "title": "fix(ui): Layer Style scrolls on a short window so OK stays on screen", - "head": "81b434c6149732eadc94e0465fea81bd075facd4", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9517#pullrequestreview-5644911791", - "conflicts": [ - "crates/ui-egui/src/dialogs.rs" - ] - }, - { - "number": 9512, - "title": "fix(compose, gpu): adjustment layers apply Fill inside the special eight blend modes", - "head": "6c55561128caf5ac917eb3d174e8e7bb4bd48e23", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9512#pullrequestreview-5644863033", - "conflicts": [ - "crates/compose/src/lib.rs", - "crates/gpu/src/plan.rs" - ] - }, - { - "number": 9509, - "title": "fix: an edit at full coverage keeps its result over huge 32-bit values", - "head": "d78d7493d3f0b6e760c88de969ac810e9bc5d910", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9509#pullrequestreview-5644707748", - "conflicts": [] - }, - { - "number": 9504, - "title": "fix(ui): Keyboard Shortcuts records \u00e2\u0152\u02dcZ instead of undoing the capture", - "head": "993c9ed167c6acd9bc8f87c6cff6e2c7abba17a8", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9504#pullrequestreview-5644657832", - "conflicts": [] - }, - { - "number": 9493, - "title": "fix(vector): a mask feather continues the mask past the canvas edge", - "head": "61a413d8df9e90ee545eb08e57c5615aa2b29cea", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9493#pullrequestreview-5644672718", - "conflicts": [] - }, - { - "number": 9481, - "title": "fix(ui): new layer effects start from the Make Default values", - "head": "7937adff961027154d795b8905e821244ae4f929", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9481#pullrequestreview-5644538284", - "conflicts": [ - "crates/ui-egui/src/dialogs/layer_style.rs" - ] - }, - { - "number": 9478, - "title": "fix(text): vertical type sets roman letters upright, one per em, as Photoshop does (#9470)", - "head": "cc208ee0440bbdf1858565dca49667272a490c56", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9478#pullrequestreview-5644503679", - "conflicts": [ - "crates/text/src/layout.rs", - "crates/text/src/psd.rs" - ] - }, - { - "number": 9472, - "title": "fix(io): import a Displace smart filter that names its map file without embedding it (#9162)", - "head": "4bab657cd4fb4a91ced44d3721e63a002ca5068c", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9472#pullrequestreview-5644841088", - "conflicts": [] - }, - { - "number": 9457, - "title": "raw: faster tone stage (Shadows, Highlights, Clarity, Texture, Dehaze), same bytes", - "head": "2863c3281c2a22dade0ce92d42bf8900074f76c6", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9457#pullrequestreview-5643852329", - "conflicts": [] - }, - { - "number": 9436, - "title": "Startup: a failed launch says why in a dialog instead of closing silently (#7643)", - "head": "819f2692db4302019638c57b6ea34cd1e8f4c24b", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9436#pullrequestreview-5645623601", - "conflicts": [] - }, - { - "number": 9418, - "title": "Color Lookup: honour the .3dl Mesh header's cube edge and output depth (#9078, #9073)", - "head": "da4b838e54f6073cda4798fa7b63b3b7214aaf93", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9418#pullrequestreview-5644614398", - "conflicts": [] - }, - { - "number": 9416, - "title": "Affinity reader: contiguous curve records and a cheaper object tag index", - "head": "742b06805284d8d76d5a29ec1821193f4835fedf", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9416#pullrequestreview-5642992245", - "conflicts": [] - }, - { - "number": 9401, - "title": "fix(format): a save on macOS keeps the file's creation time (#9388)", - "head": "f241bba1adfb9c7ec5dd5ed9d86f95b63c7bc2c3", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9401#pullrequestreview-5642877921", - "conflicts": [ - "crates/automation/src/workspace.rs" - ] - }, - { - "number": 9354, - "title": "fix(ui): Full Screen Mode needs a document (#9349)", - "head": "41eb1de5cff17f5f5b9c22913e48b71565b56a2a", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9354#pullrequestreview-5641946837", - "conflicts": [ - "crates/ui-egui/src/lib.rs", - "crates/ui-egui/src/panels.rs", - "crates/ui-egui/src/view_cmds.rs" - ] - }, - { - "number": 9342, - "title": "fix(io, engine): Embed Color Profile writes sRGB for an untagged document (#9325)", - "head": "c46a74621d36adae9707ab8f3eb39cf6b4c302b9", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9342#pullrequestreview-5642054720", - "conflicts": [ - "crates/engine/src/web_cmds.rs" - ] - }, - { - "number": 9339, - "title": "fix(export): embed requested sRGB profile for untagged sources (#9325)", - "head": "393645ab91460af2e37cc3ba8f6921e7551f676e", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9339#pullrequestreview-5642090549", - "conflicts": [ - "crates/ui-egui/src/dialogs/export_dialog.rs" - ] - }, - { - "number": 9282, - "title": "feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer", - "head": "20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9282#pullrequestreview-5647322262", - "conflicts": [ - "crates/ui-egui/src/adjust_dialog.rs", - "crates/ui-egui/src/adjust_editors.rs", - "crates/ui-egui/src/lib.rs", - "docs/ui-parity.md" - ] - }, - { - "number": 9172, - "title": "Gradient Fill layer: default to Foreground to Transparent (#8708)", - "head": "8ebb04999d25790f71f9bd88f7a268a6a2754aec", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9172#pullrequestreview-5639390920", - "conflicts": [] - }, - { - "number": 9110, - "title": "test: GPU tests honour WGPU_BACKEND, so they run on Windows with an Intel GPU (#4)", - "head": "2e716f93b2e2135944e07d821be4eb38fce13ec9", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9110#pullrequestreview-5639002103", - "conflicts": [] - }, - { - "number": 9019, - "title": "fix(xtask): reject unknown performance benchmark selections", - "head": "364c4be1c19728aefa601ee924bc7a0cd6a09789", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9019#pullrequestreview-5638973719", - "conflicts": [ - "xtask/src/perf.rs" - ] - }, - { - "number": 9015, - "title": "fix(xtask): reject leading zeroes in numeric prerelease identifiers", - "head": "afb4364a8795c42e6353762891b4890d878f9a68", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9015#pullrequestreview-5638974295", - "conflicts": [] - }, - { - "number": 9004, - "title": "fix(ui): enlarge Move tool alignment icons for legibility", - "head": "39cf823cdfe486bb967cf81df725d229f7eed903", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/9004#pullrequestreview-5638974843", - "conflicts": [] - }, - { - "number": 8986, - "title": "Path tools: edit the saved path selected in the Paths panel (#8478)", - "head": "390dfbbcb026b33d46c52c62360b65deca00f258", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8986#pullrequestreview-5638981860", - "conflicts": [ - "crates/ui-egui/src/tools/direct_select.rs", - "crates/ui-egui/src/vector_ui.rs" - ] - }, - { - "number": 8985, - "title": "Color panel and Color Picker: clip the round cursor to the colour field", - "head": "8e695caf885b529f01f482b3f0c90cdb34df1915", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8985#pullrequestreview-5638975214", - "conflicts": [ - "crates/ui-egui/src/dialogs/color_picker_ui.rs", - "crates/ui-egui/src/panels.rs" - ] - }, - { - "number": 8931, - "title": "feat(algo): describe the Gaussian blur as data for other devices", - "head": "d42dd4c6302c77c1f9dc46b7b53ffa5eb0047a13", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8931#pullrequestreview-5639059166", - "conflicts": [] - }, - { - "number": 8905, - "title": "fix(plugins): bound a run's total plug-in memory, not just each instance", - "head": "0fdc6259193aab32b8feab7347ddc36196f40a19", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8905#pullrequestreview-5638989196", - "conflicts": [ - "crates/plugins/tests/abi.rs" - ] - }, - { - "number": 8903, - "title": "Box-filter minified patterns in the CPU and GPU samplers (#8831)", - "head": "d387f52f1f2f5681b2c7d1c517aa5b5992c7bc88", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8903#pullrequestreview-5638989541", - "conflicts": [] - }, - { - "number": 8897, - "title": "fix(io): read linked smart objects for PSD export as regular files only", - "head": "eb06798f15516eb7e4fac24c98346fd9052e7f8e", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8897#pullrequestreview-5638990476", - "conflicts": [ - "crates/io/src/psd_export.rs" - ] - }, - { - "number": 8896, - "title": "Select and Mask: Photoshop's full-window workspace (#7069, #8148)", - "head": "19fa473039ef11deda7c3200c8fac1de41309086", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8896#pullrequestreview-5683297464", - "conflicts": [] - }, - { - "number": 8892, - "title": "ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte", - "head": "9267c4fcf83a260d4bae0e441dc1af9bc48aa23b", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8892#pullrequestreview-5638996509", - "conflicts": [ - "crates/ui-egui/src/file_ui.rs", - "crates/ui-egui/src/tools/align_bar.rs", - "crates/ui-egui/src/widgets/tab_strip.rs" - ] - }, - { - "number": 8347, - "title": "Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment)", - "head": "2b1364393cfcd09380cf18a221e7cdd325fc4deb", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8347#pullrequestreview-5644437741", - "conflicts": [ - "crates/engine/src/cmd/type_cmds.rs", - "crates/text/src/layout.rs", - "crates/text/src/psd.rs", - "crates/ui-egui/src/type_tool.rs", - "docs/scorecard.md", - "docs/ui-parity.md" - ] - }, - { - "number": 8193, - "title": "Filter Gallery \u00e2\u20ac\u00ba Cutout 3/4: edges and curves stage", - "head": "864024609b872de3d869879f1632b951c896c6cd", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8193#pullrequestreview-5643244034", - "conflicts": [] - }, - { - "number": 8192, - "title": "Filter Gallery \u00e2\u20ac\u00ba Cutout 2/4: regions stage", - "head": "412e3b7cf1f4836a5772d93391d21728dbe1f74f", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8192#pullrequestreview-5642599218", - "conflicts": [] - }, - { - "number": 8191, - "title": "Filter Gallery \u00e2\u20ac\u00ba Cutout 1/4: colour and palette stage", - "head": "91e4cec43525ca739c05cff614701fbd3576e389", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8191#pullrequestreview-5642584382", - "conflicts": [] - }, - { - "number": 8074, - "title": "Transform: remember selected interpolation (#8067)", - "head": "290f924d0fecc3bf6bea159c0d9e86255d326a96", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8074#pullrequestreview-5612886202", - "conflicts": [ - "crates/ui-egui/src/prefs_ui.rs", - "crates/ui-egui/src/state.rs", - "crates/ui-egui/src/transform_tool.rs" - ] - }, - { - "number": 8039, - "title": "Compose: Extend Softer outer glows past Size (#7899)", - "head": "5579cf5439856fed93f41a669a3fc9b56d5813cf", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/8039#pullrequestreview-5592809677", - "conflicts": [ - "crates/compose/src/effects.rs", - "crates/gpu/src/fx.rs" - ] - }, - { - "number": 7903, - "title": "Filter Gallery \u00e2\u20ac\u00ba Cutout 4/4: trace the image into flat paper shapes", - "head": "5c30699087b02edcaa23397a0f7076bf503be048", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/7903#pullrequestreview-5643279271", - "conflicts": [] - }, - { - "number": 7848, - "title": "fix(retouch): preserve Smudge pickup with live-pixel stroke commits", - "head": "389a1d5bd67072d5581c27989080222a7065b585", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/7848#pullrequestreview-5586186420", - "conflicts": [ - "crates/engine/src/retouch_cmds.rs" - ] - }, - { - "number": 7743, - "title": "packaging: the portable zip holds the files, not a folder with the files in it", - "head": "fd6fc92015fd08560362b4b32844edf97722f292", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/7743#pullrequestreview-5574964336", - "conflicts": [] - }, - { - "number": 7742, - "title": "Actions: a fractional setting an integer field cannot hold is refused, not rounded", - "head": "ab844f9cbcd1f1839ed7f286776331cb72e399aa", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/7742#pullrequestreview-5586281249", - "conflicts": [ - "crates/engine/src/atn_cmds.rs", - "crates/engine/src/atn_cmds/tests.rs" - ] - }, - { - "number": 7648, - "title": "Pen: Path Operations menu, as in Photoshop (#1397)", - "head": "d24669d412f64ab079a668b0b8520ddbc607410c", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/7648#pullrequestreview-5686770506", - "conflicts": [] - }, - { - "number": 7255, - "title": "fix(engine): guard trim and duplicate merged against documents exceeding render limits (#7223)", - "head": "1b810052bc3a876c1eaf1a8766870ffdba9f8a80", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/7255#pullrequestreview-5579038009", - "conflicts": [ - "crates/engine/src/image_cmds.rs" - ] - }, - { - "number": 7153, - "title": "Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace", - "head": "6f5d929d6172cf211a070f12cde9507aca9eb30a", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/7153#pullrequestreview-5675752399", - "conflicts": [] - }, - { - "number": 6621, - "title": "Crop: right-click on the pending crop opens the crop menu (#6561)", - "head": "ead45134d320cb1002724d5ef44f5c56588bbe1d", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6621#pullrequestreview-5643879866", - "conflicts": [ - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/canvas_tool_menu.rs", - "crates/ui-egui/src/control.rs", - "crates/ui-egui/src/crop_ui.rs", - "crates/ui-egui/src/transform_type_tests.rs", - "docs/context-menu-parity.md", - "docs/scorecard.md" - ] - }, - { - "number": 6510, - "title": "Automation: resolve configured root aliases for absolute paths (#6426)", - "head": "8bb0a5e67474d36ae08d565251edb1ae780dd044", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6510#pullrequestreview-5664421033", - "conflicts": [] - }, - { - "number": 6492, - "title": "Fail control startup promptly when its port is occupied", - "head": "657cfd503d4ce276df6b5e9f763cb3a5cf2deaeb", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6492#pullrequestreview-5583441089", - "conflicts": [ - "apps/photocraft/src/control_server.rs", - "apps/photocraft/src/main.rs", - "apps/photocraft/tests/it/control_exit_code.rs" - ] - }, - { - "number": 6449, - "title": "Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop", - "head": "25c38a0b69b7bff31badcdbe2ef9f2ff6aba1c63", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6449#pullrequestreview-5548906956", - "conflicts": [ - "crates/ui-egui/src/input/hold_keys.rs", - "crates/ui-egui/src/input/hold_keys/tests.rs", - "docs/ui-parity.md" - ] - }, - { - "number": 6387, - "title": "Video Frames to Layers: every page of a multi-page TIFF (part of #6379)", - "head": "8bfbbf816025942b74130adf16fb07265817b691", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6387#pullrequestreview-5555146754", - "conflicts": [] - }, - { - "number": 6383, - "title": "Windows releases ship a symbols zip: the release build's PDBs for profilers (#688)", - "head": "74aadae69e3b3f5cd257bca1926acc1b1297b1a2", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6383#pullrequestreview-5548207780", - "conflicts": [] - }, - { - "number": 6236, - "title": "Shift+Alt+right-click opens Photoshop's HUD Color Picker (#6098)", - "head": "ce546dd4b5451a17b843ef1e129bdb40fbfe3160", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6236#pullrequestreview-5644088784", - "conflicts": [ - "crates/ui-egui/src/brush_resize.rs", - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/lib.rs", - "crates/ui-egui/src/paint_mouse.rs" - ] - }, - { - "number": 6139, - "title": "Fix painting workflows, File Info and layer selection", - "head": "e2347f8ead85d929193044f139788b850e31c06f", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6139#pullrequestreview-5542220545", - "conflicts": [ - "crates/engine/src/cmd/brush_cmds.rs", - "crates/paint/src/render.rs", - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/dialogs.rs", - "crates/ui-egui/src/i18n/cs.tsv", - "crates/ui-egui/src/i18n/de.tsv", - "crates/ui-egui/src/i18n/hu.tsv", - "crates/ui-egui/src/i18n/it.tsv", - "crates/ui-egui/src/i18n/ja.tsv", - "crates/ui-egui/src/i18n/pt-br.tsv", - "crates/ui-egui/src/i18n/tr.tsv", - "crates/ui-egui/src/i18n/uk.tsv", - "crates/ui-egui/src/i18n/zh-hans.tsv", - "crates/ui-egui/src/i18n/zh-hant.tsv", - "crates/ui-egui/src/input_tests.rs", - "crates/ui-egui/src/menus.rs", - "crates/ui-egui/src/move_mods.rs", - "crates/ui-egui/src/panels.rs", - "crates/ui-egui/src/vector_ui.rs", - "crates/ui-egui/src/view_cmds.rs" - ] - }, - { - "number": 6026, - "title": "Marquee: Fixed Size selects exactly the entered width and height (fixes #6015)", - "head": "248ba660576f4896f69cbb6639353e2f1d295271", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/6026#pullrequestreview-5545897025", - "conflicts": [ - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/marquee_tests.rs" - ] - }, - { - "number": 5939, - "title": "fix(paint): scale blur sigma with brush size (#5892)", - "head": "7348e060b4be7204fb69b59079c3b9d808e9aad3", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5939#pullrequestreview-5560602826", - "conflicts": [] - }, - { - "number": 5870, - "title": "fix(format): reject duplicate native layer identities", - "head": "f9de0a6abc78aa405b1ef46f5da6cba424106204", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5870#pullrequestreview-5551781697", - "conflicts": [] - }, - { - "number": 5744, - "title": "fix(ui): keep detached documents in one interactive window", - "head": "50b1f6734799cf7b5d45dd84868976afd31aa711", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5744#pullrequestreview-5593443602", - "conflicts": [ - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/lib.rs", - "crates/ui-egui/src/menus.rs", - "crates/ui-egui/src/view_cmds.rs" - ] - }, - { - "number": 5650, - "title": "Guides: \u00e2\u0152\u00a5 turns a dragged guide; dropping one on either ruler deletes it (#5034)", - "head": "4b1bd6fb6d264f885cb9caa3b8a6578ee527bd7b", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5650#pullrequestreview-5593380251", - "conflicts": [ - "crates/engine/src/edit_cmds.rs", - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/rulers.rs", - "crates/ui-egui/src/tools/move_hover.rs", - "docs/ui-parity.md" - ] - }, - { - "number": 5515, - "title": "Selecting subject now automatically applies to selected layer, and us\u00e2\u20ac\u00a6", - "head": "375d4894a119b6cd5933d53e8fe7f506d2783392", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5515#pullrequestreview-5593377843", - "conflicts": [ - "crates/engine/src/cmd/smartselect_cmds.rs" - ] - }, - { - "number": 5493, - "title": "Import Photoshop custom shape sets into the Shapes panel", - "head": "f97eb2849a68e1ed1c08c5b378770cf35cbcb72d", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5493#pullrequestreview-5666969724", - "conflicts": [ - "docs/file-format-parity.md", - "docs/gaps.md" - ] - }, - { - "number": 5453, - "title": "Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1", - "head": "e22a9baedceefd97daffe84403b23c99c56d6919", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5453#pullrequestreview-5560030568", - "conflicts": [ - "crates/compose/src/lib.rs", - "crates/compose/src/shape_split.rs", - "crates/engine/src/vector_cmds.rs", - "crates/gpu/src/plan.rs", - "crates/vector/src/lib.rs", - "docs/control-protocol.md" - ] - }, - { - "number": 5270, - "title": "Layer Style: Gradient Overlay takes any number of colour stops (#5177)", - "head": "9db1c7ae1d48ce4f31622b5f9029c70c590842f8", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5270#pullrequestreview-5585703891", - "conflicts": [ - "crates/engine/src/layer_style.rs", - "crates/ui-egui/src/control.rs", - "crates/ui-egui/src/dialogs/layer_style.rs", - "crates/ui-egui/src/dialogs/layer_style/gradient_field.rs", - "docs/control-protocol.md" - ] - }, - { - "number": 5176, - "title": "Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale", - "head": "c96891c298151c5cb11a55928b9e1c27911f38bd", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5176#pullrequestreview-5585830576", - "conflicts": [ - "crates/engine/src/actions_cmds.rs", - "crates/engine/src/atn_cmds.rs", - "crates/engine/src/atn_cmds/tests.rs", - "crates/engine/src/cmd.rs", - "crates/engine/src/cmd/history_cmds.rs", - "crates/engine/src/cmd/pattern_cmds.rs", - "crates/engine/src/lib.rs", - "crates/engine/src/preset_store.rs", - "crates/engine/src/preset_store/tests.rs", - "crates/engine/src/smart_cmds.rs", - "crates/ui-egui/src/actions.rs", - "crates/ui-egui/src/panels/preset_panels.rs", - "docs/file-format-parity.md", - "docs/gaps.md", - "docs/target-app-parity.md" - ] - }, - { - "number": 5165, - "title": "Fix: brush section selection", - "head": "27bc6e4b37b701480efa69559ab13eafc52bd94c", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5165#pullrequestreview-5549755629", - "conflicts": [] - }, - { - "number": 5090, - "title": "Actions: add editable PhotoCraft Essentials starter presets", - "head": "61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5090#pullrequestreview-5585928498", - "conflicts": [ - "crates/engine/src/actions_cmds.rs", - "crates/engine/src/lib.rs", - "crates/engine/src/preset_store.rs", - "crates/ui-egui/src/actions.rs" - ] - }, - { - "number": 5082, - "title": "fix(mixer-brush): Alt-click samples the foreground colour like the Brush (#4873)", - "head": "23bab3cc9204568aaeb012b9e1da70a2caacb879", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5082#pullrequestreview-5562323692", - "conflicts": [ - "crates/engine/src/cmd/brush_cmds.rs", - "crates/paint/src/mixer.rs", - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/input/alt_grab.rs" - ] - }, - { - "number": 5010, - "title": "Layer Style: Photoshop's layout (three columns, OK/Cancel/New Style/Preview on the right, one-line rows) [depends on #4938]", - "head": "cc865221894e21f846d92b721cce7421ea338363", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/5010#pullrequestreview-5585991159", - "conflicts": [ - "crates/ui-egui/src/dialogs.rs", - "crates/ui-egui/src/dialogs/layer_style.rs", - "crates/ui-egui/src/panels.rs", - "crates/ui-egui/src/widgets.rs" - ] - }, - { - "number": 4993, - "title": "Prevent control-channel engine commands from editing behind modal dialogs", - "head": "81964dc5ef77a71a2ae2a938958cf3ed77d90719", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4993#pullrequestreview-5549725641", - "conflicts": [ - "crates/ui-egui/src/control.rs" - ] - }, - { - "number": 4992, - "title": "Preserve Dissolve coverage when compositing clipped layers", - "head": "194a8976cd6c14441222b287b9b4f4841a0eb2f9", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4992#pullrequestreview-5549725271", - "conflicts": [ - "crates/compose/src/lib.rs", - "crates/compose/src/tests.rs", - "crates/gpu/src/compose.wgsl", - "crates/gpu/tests/it/parity.rs", - "crates/ui-egui/src/rotate_view.rs", - "crates/ui-egui/src/wheel_nav.rs" - ] - }, - { - "number": 4985, - "title": "feat(ui): add Shift+right-click brush mode picker", - "head": "1dc98f80f60e993f3edf20af815391b6a7e7add7", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4985#pullrequestreview-5679506261", - "conflicts": [] - }, - { - "number": 4938, - "title": "Layer Style: one dialog size on every page, and it fits short screens", - "head": "63b7c921634835259139e4d3e7b9d3c396998b4d", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4938#pullrequestreview-5586050980", - "conflicts": [ - "crates/ui-egui/src/dialogs.rs", - "crates/ui-egui/src/dialogs/layer_style.rs" - ] - }, - { - "number": 4890, - "title": "Add scrollbar visibility preferences (#3262)", - "head": "ab84cab7355ac999fb1be226950978bafec39605", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4890#pullrequestreview-5593534344", - "conflicts": [ - "crates/engine/src/prefs.rs", - "crates/engine/src/prefs/tests.rs", - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/prefs_ui.rs", - "crates/ui-egui/src/theme.rs", - "docs/gaps.md", - "docs/scorecard.md", - "docs/ui-parity.md" - ] - }, - { - "number": 4883, - "title": "feat(ui): show selection and active-layer bounds in the status bar", - "head": "ea500486fb40511d3b4782dfacbb89fbff846d8a", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4883#pullrequestreview-5583810948", - "conflicts": [] - }, - { - "number": 4783, - "title": "Fix/4758 type extreme finite geometry 20261013s1215", - "head": "a9d62726bed607bc540db9d09c8dc1be4f9895aa", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4783#pullrequestreview-5583807081", - "conflicts": [ - "crates/io/src/blocks.rs", - "crates/io/tests/it/psd_type_bounds.rs", - "crates/text/src/psd.rs" - ] - }, - { - "number": 4763, - "title": "refactor(liquify): reuse native brush resizing and anchored HUD feedback", - "head": "f2804a7cf013519d9ba8bac1da3d96458c4f656b", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4763#pullrequestreview-5583718187", - "conflicts": [ - "crates/ui-egui/src/brush_resize.rs", - "crates/ui-egui/src/dialogs/liquify_ui.rs" - ] - }, - { - "number": 4742, - "title": "fix(macos): bundle photocraft-cli and resolve it from droplet scripts", - "head": "b7145b5a8afb85d0eef3922931d6749436966943", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4742#pullrequestreview-5549743037", - "conflicts": [] - }, - { - "number": 4711, - "title": "Treat Dissolve as a per-pixel choice", - "head": "db546ff4822a011a7915b66af3834f94c05767db", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4711#pullrequestreview-5594717250", - "conflicts": [ - "crates/compose/src/effects.rs", - "crates/compose/src/lib.rs", - "crates/compose/src/tests.rs", - "crates/gpu/src/compose.wgsl", - "crates/gpu/tests/it/parity.rs" - ] - }, - { - "number": 4547, - "title": "fix(icons): PhotoCraft icon corner radius matches other Craft apps (#4478)", - "head": "a08f7a2295488c2692f68902835a693322017b64", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4547#pullrequestreview-5583727527", - "conflicts": [] - }, - { - "number": 4305, - "title": "Filter > Other > Frequency Separation (#1714)", - "head": "1b74873991dbb2e9803d228a969291f09c6f1032", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4305#pullrequestreview-5594762253", - "conflicts": [ - "crates/engine/src/lib.rs", - "crates/ui-egui/src/filter_dialog.rs", - "crates/ui-egui/src/menus.rs" - ] - }, - { - "number": 4042, - "title": "feat(canvas): rotate view with Shift + middle-mouse drag", - "head": "f05098562220a663d052ee3918fd36c8c3e17a51", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4042#pullrequestreview-5583745473", - "conflicts": [ - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/rotate_view.rs" - ] - }, - { - "number": 4035, - "title": "fix(cmyk): Flatten Image and merges keep the inks of a tagged CMYK document (#3778)", - "head": "15ebf8fc940031abdf772e5fdd687c8b55938639", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4035#pullrequestreview-5595033656", - "conflicts": [ - "crates/compose/src/lib.rs", - "crates/engine/src/pixels.rs", - "crates/io/src/lib.rs", - "crates/io/tests/it/color.rs" - ] - }, - { - "number": 4023, - "title": "i18n(es): add 548 missing rows, fix five filter and video terms", - "head": "303a5346fcb5961cf3a776024db58507b401e4ea", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/4023#pullrequestreview-5595056228", - "conflicts": [ - "crates/ui-egui/src/i18n/es.tsv" - ] - }, - { - "number": 3967, - "title": "Add move and delete actions to Smart Filter rows", - "head": "3a77519ce2cef0fb60cebd4ea796d599f68868cb", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3967#pullrequestreview-5595072511", - "conflicts": [ - "crates/ui-egui/src/panels.rs", - "crates/ui-egui/src/smart_ui.rs", - "docs/control-protocol.md" - ] - }, - { - "number": 3874, - "title": "fix(layer-style): support Blend If Alt-split handle feathering and throttle live preview", - "head": "92488307722c3b840507827cac7ee9a88470fc8a", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3874#pullrequestreview-5595098959", - "conflicts": [] - }, - { - "number": 3748, - "title": "control: suggest near matches for unknown command ids and methods (#3674)", - "head": "c45d01eafe84e600bb28033a6853cc19c827b7bf", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3748#pullrequestreview-5583803054", - "conflicts": [ - "crates/automation/src/rpc.rs", - "crates/engine/src/lib.rs", - "crates/ui-egui/src/control.rs" - ] - }, - { - "number": 3554, - "title": "fix(packaging): launch bundled CLI directly from AppImage", - "head": "dd21e325b515cbf67fd4a461ae5b3e1b195aa53d", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3554#pullrequestreview-5583771056", - "conflicts": [] - }, - { - "number": 3520, - "title": "fix(ui): restore square color chips in Studio Dark theme", - "head": "d36ec9a21e9c1ed70de2e0d185efd7e084644157", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3520#pullrequestreview-5583735883", - "conflicts": [] - }, - { - "number": 3519, - "title": "feat(export): optionally trim transparent margins from layer files", - "head": "807de6c043a512cb947d86b944c758b3db1277c7", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3519#pullrequestreview-5583738841", - "conflicts": [ - "crates/engine/src/file_cmds.rs", - "crates/engine/src/image_cmds.rs", - "crates/ui-egui/src/view_cmds.rs" - ] - }, - { - "number": 3464, - "title": "fix(vector): target selected saved paths and own Pen gestures", - "head": "1eecd00b2dff4ae4c642269cec211cd06ce1c3aa", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3464#pullrequestreview-5597090618", - "conflicts": [ - "crates/engine/src/vector_cmds.rs", - "crates/ui-egui/src/canvas.rs", - "crates/ui-egui/src/lib.rs", - "crates/ui-egui/src/path_selection_tests.rs", - "crates/ui-egui/src/tools/anchor_tools/tests.rs", - "crates/ui-egui/src/tools/direct_select.rs", - "crates/ui-egui/src/tools/direct_select/tests.rs", - "crates/ui-egui/src/vector_paint_tests.rs", - "crates/ui-egui/src/vector_ui.rs", - "crates/vector/src/flatten.rs" - ] - }, - { - "number": 3461, - "title": "Replay recorded layer visibility on the intended named layer", - "head": "5d8cb2a39ad70e31260f1f3fc2010ca132daff65", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3461#pullrequestreview-5597122170", - "conflicts": [ - "crates/engine/src/actions_cmds.rs", - "crates/engine/src/jobs.rs", - "crates/engine/src/lib.rs", - "crates/ui-egui/src/actions.rs", - "docs/control-protocol.md" - ] - }, - { - "number": 3455, - "title": "Undo and redo action definition edits from the Actions panel", - "head": "d84fa7955d260505378a1a596ec80d602036319d", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3455#pullrequestreview-5597131410", - "conflicts": [ - "crates/engine/src/actions_cmds.rs", - "crates/ui-egui/src/actions.rs", - "crates/ui-egui/src/menus.rs", - "docs/control-protocol.md" - ] - }, - { - "number": 3387, - "title": "feat(codecs): add lossless JPEG XL export for 8/16-bit images", - "head": "58b83f42a4b39cf56751be53ec8d227efee70f68", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3387#pullrequestreview-5597200428", - "conflicts": [ - "apps/photocraft/src/services.rs", - "crates/codecs/Cargo.toml", - "crates/codecs/src/format.rs", - "crates/codecs/src/lib.rs" - ] - }, - { - "number": 3345, - "title": "Add grouped gradient presets and selectable style icons", - "head": "bfb691f84896fe68543980426632153dc4b4ea69", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3345#pullrequestreview-5597265645", - "conflicts": [ - "crates/ui-egui/src/panels.rs", - "crates/ui-egui/src/panels/preset_panels.rs", - "crates/ui-egui/src/prefs_ui.rs", - "crates/ui-egui/src/tools/gradient_ui.rs", - "crates/ui-egui/src/tools/gradient_ui/preset_browser.rs", - "crates/ui-egui/src/tools/gradient_ui/preset_browser_tests.rs", - "crates/ui-egui/src/tools/gradient_ui/properties_panel.rs", - "docs/scorecard.md" - ] - }, - { - "number": 3142, - "title": "Read applied Sony RAW metadata and crop ILCE-6000 images", - "head": "e2ed234e654e574109ffcdb9756f8e8d1b8a00b2", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3142#pullrequestreview-5597503674", - "conflicts": [ - "crates/raw/src/cameras.rs", - "docs/file-format-parity.md", - "docs/gaps.md" - ] - }, - { - "number": 3139, - "title": "Draw right-to-left layer names in visual word order", - "head": "4da79e8d16ded8b51f06c1ac64e50ccba288274e", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/3139#pullrequestreview-5597496876", - "conflicts": [ - "Cargo.lock", - "crates/ui-egui/Cargo.toml", - "crates/ui-egui/src/lib.rs", - "crates/ui-egui/src/panels/layer_row_ui.rs" - ] - }, - { - "number": 2300, - "title": "Scroll the viewport while outlining with Polygonal Lasso", - "head": "ebf3777aec961d1815aa141d9987eaa645c2273f", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/2300#pullrequestreview-5564229610", - "conflicts": [ - "crates/ui-egui/src/canvas.rs" - ] - }, - { - "number": 1991, - "title": "feat(actions): import and export reusable Actions JSON files", - "head": "202266b90613280e16ae285b53dd4e10d5f0c7ac", - "status": "MERGED", - "receipt": "https://github.com/storytold/photocraft/pull/1991#pullrequestreview-5597895651", - "conflicts": [ - "crates/automation/src/workspace.rs", - "crates/engine/src/actions_cmds.rs", - "crates/ui-egui/src/actions.rs" - ] - } - ], - "current_checkpoint_sha": "f5b3f1238bb414b43851ebe86f3a58ce83bb9b48", - "updated_at": "2026-10-29T12:06:29.604333+00:00", - "active_subsystem": "Portable test delivery", - "parent_decision_required": null, - "validation": { - "source_sha": "f5b3f1238bb414b43851ebe86f3a58ce83bb9b48", - "ci": "https://github.com/FredApps/photocraft/actions/runs/40858867520", - "conclusion": "success", - "linux_tests": { - "passed": 12904, - "skipped": 97 - }, - "windows_unit": { - "passed": 10326, - "skipped": 79, - "leaky_warnings": 1 - }, - "windows_integration": { - "passed": 2501, - "skipped": 19, - "leaky_warnings": 2 - }, - "manual": "NOT RUN", - "portable_archive": "VERIFIED", - "gui_acceptance": "NOT RUN" - }, - "currently_matching_approved_heads": 107, - "selection_drift": [ - { - "pr": 5493, - "integrated_approved_head": "f97eb2849a68e1ed1c08c5b378770cf35cbcb72d", - "new_unapproved_head": "67f57c3cf1e7de045aee03a305663d9560daa3e3", - "decision": "New head excluded; historical approved head retained and not counted among 107 current matching approvals." - } - ], - "historical_validation": { - "compositor_at_31ce2208": "PASSED: 402 passed, 0 failed, 2 ignored", - "current_full_workspace": "Pending hosted successor validation", - "current_build_check": "PENDING successor check at 964a47b51; 30a2b002b workspace/all-target passed in 1m20s; UI helper extraction compile passed", - "pattern_store_at_d98e4195": "PASSED: 35 tests, 0 failed, 1 benchmark ignored; final tree not yet tested", - "manual": "NOT RUN", - "portable_final_build": "NOT RUN; requires applicable hosted validation", - "current_ci": "40833090784 at2334a460f FAILED aggregate; Windows integration/corpus/noHEIF/web/morphology/quickperf passed", - "jxl_export": "PASSED 5/5 at 30a2b002b; no codec changes since", - "ownership": "PASSED at e40cdadb6: 17 passed, 0 failed, 0 ignored", - "engine": "PASSED at e40cdadb6: 2420 passed, 0 failed, 16 ignored, full sweep enabled", - "local_full_ui": "ABORTED at c3d1e816c:3788 completed success lines, three assertion failures, native Windows exit0xc0000005; no aggregate pass. Three targeted corrections passed afterward. No new broad local sweep.", - "superseded_ci": "40831890924 and 40832695382 cancelled intentionally; cancelled stages are not passes", - "corrected_text": "283 runner passes, zero failures, five ignored; two Cairo-only early-return cases BLOCKED; hanging-row regressions executed", - "corrected_focused_ui": "PASSED on reconciliation working tree:244 passed, zero failed, one intentionally ignored capture; full corrected UI sweep running" - }, - "portable_build": { - "source_sha": "f5b3f1238bb414b43851ebe86f3a58ce83bb9b48", - "base_sha": "5f9b4391874aabc9deb2fcb905267613e4d93ec8", - "version": "0.17.0-integration.20261029.f5b3f123", - "architecture": "Windows x64", - "ci_run": 40858867520, - "ci_url": "https://github.com/FredApps/photocraft/actions/runs/40858867520", - "packaging_lint_run": 40858747395, - "build_run": 40867283787, - "build_url": "https://github.com/FredApps/photocraft/actions/runs/40867283787", - "workflow_id": 390299892, - "workflow_restored_state": "disabled_manually", - "fonts_pin": "b076417b5880bd4154bd22f00695b1d28c6bea83", - "artifact_verified": true, - "artifact": { - "id": 12895845995, - "name": "release-windows-x64", - "job_id": 123425396034, - "url": "https://github.com/FredApps/photocraft/actions/runs/40867283787/artifacts/12895845995", - "source_sha": "f5b3f1238bb414b43851ebe86f3a58ce83bb9b48", - "filename": "photocraft-0.17.0-integration.20261029.f5b3f123-windows-x64-portable.zip", - "sha256": "4125eccd5b009f30dc2d72f64bfafcf32220dc5574f765d0b3664ac4d9f992ba", - "size_bytes": 127391123, - "github_artifact_digest": "sha256:d642eb3969b9b3afbf3898c71d08a979b3bef01b51d26bae32e384ed8a28bf4f", - "expires_at": "2027-01-27T11:41:10Z", - "zip_crc": "PASSED", - "portable_marker": "PRESENT", - "pe_headers": { - "photocraft.exe": { - "signature": "PE", - "machine": "0x8664 (x64)", - "size": 149296640 - }, - "photocraft-cli.exe": { - "signature": "PE", - "machine": "0x8664 (x64)", - "size": 107922432 - } - }, - "manual_gui": "NOT RUN" - }, - "manual_tests": "NOT RUN", - "packaging_platform_blocker": "macOS universal job could not start: GitHub reports recent account payments failed or spending limit needs increase. No billing/settings changes attempted. Windows jobs continue independently.", - "other_platform_failures": [ - { - "platform": "macOS universal", - "job_id": 123425395799, - "reason": "GitHub runner did not start: account payment/spending restriction; no settings changed." - }, - { - "platform": "FreeBSD aarch64", - "job_id": 123425395796, - "reason": "VM provisioning failed because pkg could not find cmake in configured repositories; no PhotoCraft build executed." - } - ], - "build_run_status": "in_progress", - "build_run_conclusion": null, - "windows_job_conclusion": "success", - "signing": "UNSIGNED: fork has no Windows signing secrets; no credentials/settings changed", - "packaging_cli_version_check": { - "status": "PASSED in GitHub packaging job", - "output": "photocraft-cli 0.17.0-integration.20261029.f5b3f123 (f5b3f1238, 2026-10-29)" - } - } + "historical_head": "8f9f1a9481f274ae15f952fd946eab13007f592d", + "validation": "PENDING combined CI for trimmed source; manual tests NOT RUN; old portable artifact not applicable", + "manifest": "trimmed-selection-2026-10-29.json" } diff --git a/docs/integration-testing/per-pr-regressions.md b/docs/integration-testing/per-pr-regressions.md index f04bc86b58..555c80b3d3 100644 --- a/docs/integration-testing/per-pr-regressions.md +++ b/docs/integration-testing/per-pr-regressions.md @@ -1,3 +1,5 @@ +> CURRENT SCOPE: 87 retained PRs. All 21 requested exclusions have been removed from this appendix. Rerun relevant cases against the new frozen SHA; older results are historical. + # Per-PR regression execution appendix Each automated case uses the synthetic fixture/setup and assertions in the named checked-in regression, @@ -32,94 +34,6 @@ Reviewed-head links below retain historical provenance. Use these current paths | #8896 Select and Mask | `crates/ui-egui/src/dialogs/select_mask_ui/tests.rs` | -## PR-3455: Undo and redo action definition edits from the Actions panel - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-engine --locked recording_and_appending_steps_can_be_undone_without_restarting_recording -- --nocapture` — [crates/engine/src/actions_cmds.rs / recording_and_appending_steps_can_be_undone_without_restarting_recording](https://github.com/storytold/photocraft/blob/d84fa7955d260505378a1a596ec80d602036319d/crates/engine/src/actions_cmds.rs) - - `cargo test -p photocraft-engine --locked definition_edits_undo_redo_without_touching_document_or_journal -- --nocapture` — [crates/engine/src/actions_cmds.rs / definition_edits_undo_redo_without_touching_document_or_journal](https://github.com/storytold/photocraft/blob/d84fa7955d260505378a1a596ec80d602036319d/crates/engine/src/actions_cmds.rs) - - `cargo test -p photocraft-engine --locked enabled_step_edits_undo_and_invalidate_redo -- --nocapture` — [crates/engine/src/actions_cmds.rs / enabled_step_edits_undo_and_invalidate_redo](https://github.com/storytold/photocraft/blob/d84fa7955d260505378a1a596ec80d602036319d/crates/engine/src/actions_cmds.rs) - - `cargo test -p photocraft-engine --locked undo_pending_step_deletion_does_not_duplicate_recording -- --nocapture` — [crates/engine/src/actions_cmds.rs / undo_pending_step_deletion_does_not_duplicate_recording](https://github.com/storytold/photocraft/blob/d84fa7955d260505378a1a596ec80d602036319d/crates/engine/src/actions_cmds.rs) - - `cargo test -p photocraft-ui-egui --locked actions_shortcut_undo_and_redo_edit_script_without_undoing_pixels -- --nocapture` — [crates/ui-egui/src/actions.rs / actions_shortcut_undo_and_redo_edit_script_without_undoing_pixels](https://github.com/storytold/photocraft/blob/d84fa7955d260505378a1a596ec80d602036319d/crates/ui-egui/src/actions.rs) - -- Expected behavior: recording and appending steps can be undone without restarting recording; definition edits undo redo without touching document or journal; enabled step edits undo and invalidate redo; undo pending step deletion does not duplicate recording; actions shortcut undo and redo edit script without undoing pixels. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`recording_and_appending_steps_can_be_undone_without_restarting_recording` setup/action excerpt: - -```rust -let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); - s.execute("actions.record", json!({"name": "A"})).unwrap(); - s.execute("layer.new.layer", json!({})).unwrap(); - s.execute("actions.stop", json!({})).unwrap(); - s.execute("actions.record", json!({"action": "A"})).unwrap(); - s.execute("select.all", json!({})).unwrap(); - s.execute("actions.stop", json!({})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - assert_eq!(s.actions.list[0].steps.len(), 2); - s.execute("actions.undo", json!({})).unwrap(); - assert_eq!(s.actions.list[0].steps.len(), 1); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(s.actions.list[0].steps.len(), 2); - s.execute("actions.undo", json!({})).unwrap(); - assert_eq!(s.actions.list[0].steps.len(), 1); - assert!(s.actions.recording.is_none()); - s.execute("actions.redo", json!({})).unwrap(); - assert_eq!(s.actions.list[0].steps.len(), 2); - assert!(s.actions.recording.is_none()); - assert_eq!(s.active().unwrap().doc, doc); - } -``` - -`definition_edits_undo_redo_without_touching_document_or_journal` setup/action excerpt: - -```rust -let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); - s.actions.list.push(Action { - name: "A".into(), - steps: vec![("layer.new.layer".into(), json!({})), ("select.all".into(), json!({}))], - disabled: Default::default(), - }); - s.actions.list.push(Action { name: "Caller".into(), steps: vec![("actions.play".into(), json!({"action": "A"}))], disabled: Default::default() }); - s.edit_prefs(|p| { - p.shortcuts.insert("actions.play:A".into(), "F6".into()); - }); - let before = s.actions.list.clone(); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(s.actions.list, before); - assert_eq!(s.prefs().shortcuts.get("actions.play:A").map(String::as_str), Some("F6")); - assert!(!s.prefs().shortcuts.contains_key("actions.play:B")); - for _ in 0..3 { - s.execute("actions.redo", json!({})).unwrap(); - } - assert_eq!(s.actions.list, after); - assert_eq!(s.active().unwrap().doc, doc); - assert_eq!(s.journal, journal); - s.execute("actions.undo", json!({})).unwrap(); - s.execute("actions.delete", json!({"action": "Caller"})).unwrap(); - assert!(!can_redo(&s)); - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-3748: control: suggest near matches for unknown command ids and methods (#3674) - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -272,123 +186,6 @@ assert_eq!(opened["ok"], true, "{opened}"); - Tester/date: - Tested SHA: -## PR-5090: Actions: add editable PhotoCraft Essentials starter presets - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-web --locked generated_defaults_are_persisted_without_unsaved_user_work -- --nocapture` — [apps/photocraft-web/src/preset_bridge.rs / generated_defaults_are_persisted_without_unsaved_user_work](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/apps/photocraft-web/src/preset_bridge.rs) - - `cargo test -p photocraft-web --locked denied_storage_marks_default_edits_unsaved_and_retry_saves_them -- --nocapture` — [apps/photocraft-web/src/preset_bridge.rs / denied_storage_marks_default_edits_unsaved_and_retry_saves_them](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/apps/photocraft-web/src/preset_bridge.rs) - - `cargo test -p photocraft-web --locked finishing_default_transaction_keeps_newer_action_edits_unsaved -- --nocapture` — [apps/photocraft-web/src/preset_bridge.rs / finishing_default_transaction_keeps_newer_action_edits_unsaved](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/apps/photocraft-web/src/preset_bridge.rs) - - `cargo test -p photocraft-web --locked failed_default_transaction_does_not_warn_until_a_user_edit -- --nocapture` — [apps/photocraft-web/src/preset_bridge.rs / failed_default_transaction_does_not_warn_until_a_user_edit](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/apps/photocraft-web/src/preset_bridge.rs) - - `cargo test -p photocraft-engine --locked load_defaults_preserves_names_and_edits_and_is_idempotent -- --nocapture` — [crates/engine/src/action_defaults.rs / load_defaults_preserves_names_and_edits_and_is_idempotent](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/action_defaults.rs) - - `cargo test -p photocraft-engine --locked defaults_seed_once_and_restore_only_when_requested -- --nocapture` — [crates/engine/src/action_defaults.rs / defaults_seed_once_and_restore_only_when_requested](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/action_defaults.rs) - - `cargo test -p photocraft-engine --locked old_store_and_early_actions_win_over_defaults -- --nocapture` — [crates/engine/src/action_defaults.rs / old_store_and_early_actions_win_over_defaults](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/action_defaults.rs) - - `cargo test -p photocraft-engine --locked unreadable_actions_are_not_seeded_or_overwritten -- --nocapture` — [crates/engine/src/action_defaults.rs / unreadable_actions_are_not_seeded_or_overwritten](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/action_defaults.rs) - - `cargo test -p photocraft-engine --locked auto_defaults_play_on_pixels_and_undo_at_every_depth -- --nocapture` — [crates/engine/src/action_defaults.rs / auto_defaults_play_on_pixels_and_undo_at_every_depth](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/action_defaults.rs) - - `cargo test -p photocraft-engine --locked subject_default_samples_visible_layers_and_undo_restores_selection -- --nocapture` — [crates/engine/src/action_defaults.rs / subject_default_samples_visible_layers_and_undo_restores_selection](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/action_defaults.rs) - - `cargo test -p photocraft-engine --locked actions_persist_with_the_preset_store -- --nocapture` — [crates/engine/src/actions_cmds.rs / actions_persist_with_the_preset_store](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/actions_cmds.rs) - - `cargo test -p photocraft-engine --locked blur_background_preserves_subject_and_undoes_at_every_depth -- --nocapture` — [crates/engine/src/background_blur_cmds.rs / blur_background_preserves_subject_and_undoes_at_every_depth](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/background_blur_cmds.rs) - - `cargo test -p photocraft-engine --locked blur_background_preserves_selection_alpha_masks_and_other_layers -- --nocapture` — [crates/engine/src/background_blur_cmds.rs / blur_background_preserves_selection_alpha_masks_and_other_layers](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/background_blur_cmds.rs) - - `cargo test -p photocraft-engine --locked background_sampling_excludes_subject_colors_for_both_blur_paths -- --nocapture` — [crates/engine/src/background_blur_cmds.rs / background_sampling_excludes_subject_colors_for_both_blur_paths](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/background_blur_cmds.rs) - - `cargo test -p photocraft-engine --locked blur_background_without_subject_or_when_cancelled_changes_nothing -- --nocapture` — [crates/engine/src/background_blur_cmds.rs / blur_background_without_subject_or_when_cancelled_changes_nothing](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/background_blur_cmds.rs) - - `cargo test -p photocraft-engine --locked blur_background_rejects_invalid_targets_params_and_effective_locks -- --nocapture` — [crates/engine/src/background_blur_cmds.rs / blur_background_rejects_invalid_targets_params_and_effective_locks](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/background_blur_cmds.rs) - - `cargo test -p photocraft-engine --locked blur_background_24mp_timing -- --nocapture --ignored` — [crates/engine/src/background_blur_cmds.rs / blur_background_24mp_timing](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/background_blur_cmds.rs) - - `cargo test -p photocraft-engine --locked group_with_sampled_8_and_16_bit_tips_round_trips -- --nocapture` — [crates/engine/src/preset_store/tests.rs / group_with_sampled_8_and_16_bit_tips_round_trips](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/preset_store/tests.rs) - - `cargo test -p photocraft-engine --locked commands_persist_and_delete_removes_from_disk -- --nocapture` — [crates/engine/src/preset_store/tests.rs / commands_persist_and_delete_removes_from_disk](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/engine/src/preset_store/tests.rs) - - `cargo test -p photocraft-ui-egui --locked default_actions_load_from_empty_panel_and_play -- --nocapture` — [crates/ui-egui/src/actions.rs / default_actions_load_from_empty_panel_and_play](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/ui-egui/src/actions.rs) - - `cargo test -p photocraft-ui-egui --locked default_actions_flyout_preserves_custom_actions_and_reveals_restored_set -- --nocapture` — [crates/ui-egui/src/actions.rs / default_actions_flyout_preserves_custom_actions_and_reveals_restored_set](https://github.com/storytold/photocraft/blob/61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206/crates/ui-egui/src/actions.rs) - -- Expected behavior: generated defaults are persisted without unsaved user work; denied storage marks default edits unsaved and retry saves them; finishing default transaction keeps newer action edits unsaved; failed default transaction does not warn until a user edit; load defaults preserves names and edits and is idempotent; defaults seed once and restore only when requested; old store and early actions win over defaults; unreadable actions are not seeded or overwritten; auto defaults play on pixels and undo at every depth; subject default samples visible layers and undo restores selection; actions persist with the preset store; blur background preserves subject and undoes at every depth; blur background preserves selection alpha masks and other layers; background sampling excludes subject colors for both blur paths; blur background without subject or when cancelled changes nothing; blur background rejects invalid targets params and effective locks; blur background 24mp timing; group with sampled 8 and 16 bit tips round trips; commands persist and delete removes from disk; default actions load from empty panel and play; default actions flyout preserves custom actions and reveals restored set. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`generated_defaults_are_persisted_without_unsaved_user_work` setup/action excerpt: - -```rust -let b = Bridge::default(); - let (s, warnings) = session(b.clone()); - assert!(warnings.is_empty()); - assert_eq!(s.actions.list.len(), 5); - assert!(!b.unsaved()); - let batch = b.begin().unwrap(); - let actions: serde_json::Value = serde_json::from_slice(batch.get(ACTIONS_FILE).unwrap().as_deref().unwrap()).unwrap(); - assert_eq!(actions["defaults_version"], 1); - assert_eq!(actions["actions"].as_array().unwrap().len(), 5); - assert!(!b.unsaved(), "generated defaults in flight can be recreated on reload"); - let restored = Bridge::default(); - for (name, bytes) in &batch { -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(warnings.is_empty()); - assert_eq!(s.actions.list.len(), 5); - assert!(!b.unsaved()); - let batch = b.begin().unwrap(); - let actions: serde_json::Value = serde_json::from_slice(batch.get(ACTIONS_FILE).unwrap().as_deref().unwrap()).unwrap(); - assert_eq!(actions["defaults_version"], 1); - assert_eq!(actions["actions"].as_array().unwrap().len(), 5); - assert!(!b.unsaved(), "generated defaults in flight can be recreated on reload"); - let restored = Bridge::default(); - for (name, bytes) in &batch { - if let Some(bytes) = bytes { - assert!(warnings.is_empty()); - assert_eq!(reloaded.actions.list, s.actions.list); - assert!(restored.begin().is_none(), "the persisted marker avoids reseeding on reload"); - assert!(!b.unsaved()); - } -``` - -`denied_storage_marks_default_edits_unsaved_and_retry_saves_them` setup/action excerpt: - -```rust -for command in ["actions.delete", "actions.rename"] { - let b = Bridge::default(); - b.unavailable("SecurityError".into()); - let (mut s, _) = session(b.clone()); - assert!(!b.unsaved()); - s.execute(command, json!({"action": "Auto Tone", "name": "My Tone"})).unwrap(); - assert!(b.unsaved(), "editing generated defaults is user work: {command}"); - assert!(b.begin().is_none()); - assert!(b.error().unwrap().contains("SecurityError")); - b.retry(); - let batch = b.begin().unwrap(); - assert!(b.unsaved(), "the edit remains unsaved while its transaction runs"); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(!b.unsaved()); - s.execute(command, json!({"action": "Auto Tone", "name": "My Tone"})).unwrap(); - assert!(b.unsaved(), "editing generated defaults is user work: {command}"); - assert!(b.begin().is_none()); - assert!(b.error().unwrap().contains("SecurityError")); - b.retry(); - let batch = b.begin().unwrap(); - assert!(b.unsaved(), "the edit remains unsaved while its transaction runs"); - let restored = Bridge::default(); - for (name, bytes) in &batch { - if let Some(bytes) = bytes { - assert!(!b.unsaved()); - assert!(b.error().is_none()); - let (reloaded, warnings) = session(restored); - assert!(warnings.is_empty()); - assert_eq!(reloaded.actions.list, s.actions.list); - assert!(!reloaded.actions.list.iter().any(|a| a.name == "Auto Tone")); - } - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-5176: Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale - Fixture/setup: Synthetic ATN mappings, smart-object Edit Contents action, temporary pattern stores and matting hard/soft edges. @@ -425,83 +222,6 @@ assert!(!b.unsaved()); - Tester/date: - Tested SHA: -## PR-7742: Actions: a fractional setting an integer field cannot hold is refused, not rounded - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-engine --locked a_fractional_setting_an_integer_field_cannot_hold_is_refused_not_rounded -- --nocapture` — [crates/engine/src/atn_cmds/tests.rs / a_fractional_setting_an_integer_field_cannot_hold_is_refused_not_rounded](https://github.com/storytold/photocraft/blob/ab844f9cbcd1f1839ed7f286776331cb72e399aa/crates/engine/src/atn_cmds/tests.rs) - - `cargo test -p photocraft-engine --locked whole_number_settings_for_integer_fields_still_round_trip -- --nocapture` — [crates/engine/src/atn_cmds/tests.rs / whole_number_settings_for_integer_fields_still_round_trip](https://github.com/storytold/photocraft/blob/ab844f9cbcd1f1839ed7f286776331cb72e399aa/crates/engine/src/atn_cmds/tests.rs) - -- Expected behavior: a fractional setting an integer field cannot hold is refused not rounded; whole number settings for integer fields still round trip. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`a_fractional_setting_an_integer_field_cannot_hold_is_refused_not_rounded` setup/action excerpt: - -```rust -for (id, params) in [("filter.pixelate.crystallize", json!({"cellSize": 5.4})), ("filter.distort.ripple", json!({"amount": 81.4, "size": "small"}))] { - let mut s = Session::new(); - s.actions.list.push(Action::new("A", vec![(id.into(), params)])); - let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); - assert!(e.contains(id), "{id}: {e}"); - let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap(); - assert_eq!(r["skipped"], json!([{"action": "A", "step": 0, "id": id}]), "{id}"); - } -} -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(e.contains(id), "{id}: {e}"); - let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap(); - assert_eq!(r["skipped"], json!([{"action": "A", "step": 0, "id": id}]), "{id}"); - } -} -``` - -`whole_number_settings_for_integer_fields_still_round_trip` setup/action excerpt: - -```rust -for (id, params) in [ - ("filter.pixelate.crystallize", json!({"cellSize": 5})), - ("filter.distort.ripple", json!({"amount": 81, "size": "small"})), - ("edit.stroke", json!({"width": 2, "color": "#d03060", "location": "inside"})), - ] { - let mut s = Session::new(); - s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())])); - let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); - assert_eq!(r["skipped"], json!([]), "{id}"); - let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); - let (back_id, back) = &imported.actions[0].steps[0]; - assert_eq!(back_id, id); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(r["skipped"], json!([]), "{id}"); - let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); - let (back_id, back) = &imported.actions[0].steps[0]; - assert_eq!(back_id, id); - for (key, want) in params.as_object().unwrap() { - let got = back.get(key); - match want.as_f64() { - Some(n) => assert_eq!(got.and_then(Value::as_f64), Some(n), "{id}: {key} is {got:?}"), - None => assert_eq!(got, Some(want), "{id}: {key} is {got:?}"), - } - } - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-8905: fix(plugins): bound a run's total plug-in memory, not just each instance - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -637,127 +357,6 @@ assert_eq!(q1, [0, 45, 255, 255], "Alt-drag right must split black triangle into - Tester/date: - Tested SHA: -## PR-3967: Add move and delete actions to Smart Filter rows - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-ui-egui --locked row_menu_moves_and_deletes_the_clicked_filter_with_one_undo_step -- --nocapture` — [crates/ui-egui/src/smart_ui/context_tests.rs / row_menu_moves_and_deletes_the_clicked_filter_with_one_undo_step](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/context_tests.rs) - - `cargo test -p photocraft-ui-egui --locked two_filter_row_context_offers_reorder_and_delete -- --nocapture` — [crates/ui-egui/src/smart_ui/context_tests.rs / two_filter_row_context_offers_reorder_and_delete](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/context_tests.rs) - - `cargo test -p photocraft-ui-egui --locked existing_filter_move_delete_commands_preserve_source_and_undo_at_every_depth -- --nocapture` — [crates/ui-egui/src/smart_ui/context_tests.rs / existing_filter_move_delete_commands_preserve_source_and_undo_at_every_depth](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/context_tests.rs) - - `cargo test -p photocraft-ui-egui --locked row_menu_disables_edges_and_deletes_the_only_remaining_filter -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / row_menu_disables_edges_and_deletes_the_only_remaining_filter](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked stale_row_menu_closes_on_edits_undo_redo_document_switch_and_same_id_reopen -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / stale_row_menu_closes_on_edits_undo_redo_document_switch_and_same_id_reopen](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked parameter_and_camera_raw_modals_close_the_row_menu_without_mutation -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / parameter_and_camera_raw_modals_close_the_row_menu_without_mutation](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked pending_document_job_closes_row_menu_and_refuses_new_choices -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / pending_document_job_closes_row_menu_and_refuses_new_choices](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked existing_eye_and_name_double_click_remain_distinct_from_secondary_actions -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / existing_eye_and_name_double_click_remain_distinct_from_secondary_actions](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked capture_native_popup_at_two_scales_and_in_french -- --nocapture --ignored` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / capture_native_popup_at_two_scales_and_in_french](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked row_menu_reorder_keeps_the_selected_filter_identity -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / row_menu_reorder_keeps_the_selected_filter_identity](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked row_menu_delete_before_selection_keeps_the_remaining_filter_selected -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / row_menu_delete_before_selection_keeps_the_remaining_filter_selected](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked row_menu_delete_selected_filter_clears_its_row_selection -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / row_menu_delete_selected_filter_clears_its_row_selection](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - - `cargo test -p photocraft-ui-egui --locked row_menu_refused_edit_preserves_filter_order_and_selection -- --nocapture` — [crates/ui-egui/src/smart_ui/menu_guard_tests.rs / row_menu_refused_edit_preserves_filter_order_and_selection](https://github.com/storytold/photocraft/blob/3a77519ce2cef0fb60cebd4ea796d599f68868cb/crates/ui-egui/src/smart_ui/menu_guard_tests.rs) - -- Expected behavior: row menu moves and deletes the clicked filter with one undo step; two filter row context offers reorder and delete; existing filter move delete commands preserve source and undo at every depth; row menu disables edges and deletes the only remaining filter; stale row menu closes on edits undo redo document switch and same id reopen; parameter and camera raw modals close the row menu without mutation; pending document job closes row menu and refuses new choices; existing eye and name double click remain distinct from secondary actions; capture native popup at two scales and in french; row menu reorder keeps the selected filter identity; row menu delete before selection keeps the remaining filter selected; row menu delete selected filter clears its row selection; row menu refused edit preserves filter order and selection. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`row_menu_moves_and_deletes_the_clicked_filter_with_one_undo_step` setup/action excerpt: - -```rust -let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - for depth in [8, 16, 32] { - for ppp in [1.0, 2.0] { - let (mut h, layer) = harness_at(depth, ppp); - let before = filters(h.state(), layer); - let before_pixels = pixels(h.state(), layer); - let source = photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(); - let st = h.state().session.active().unwrap(); - let active = st.active_layer; - let selected = st.selected_layers.clone(); - let history = st.history.past_len(); - let other = st.doc.layer(active.unwrap()).unwrap().clone(); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(h.query_by_label("Move Up").is_some(), "actual filter row has Move Up"); - h.get_by_label("Move Up").click(); - h.run_steps(4); - assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", "filter.other.offset"]); - assert!(text_position(&h, "Offset").y < text_position(&h, "Gaussian Blur").y); - assert_ne!(pixels(h.state(), layer), before_pixels); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); - assert_eq!(h.state().session.active().unwrap().active_layer, active); - assert_eq!(h.state().session.active().unwrap().selected_layers, selected); - assert_eq!(photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(), source); - h.state_mut().run("edit.undo", json!({})).unwrap(); - h.run_steps(3); - assert_eq!(serde_json::to_value(filters(h.state(), layer)).unwrap(), serde_json::to_value(&before).unwrap()); - assert_eq!(pixels(h.state(), layer), before_pixels); - h.state_mut().run("edit.redo", json!({})).unwrap(); - h.run_steps(3); - let offset = text_position(&h, "Offset"); - assert_eq!(serde_json::to_value(filters(h.state(), layer)).unwrap(), serde_json::to_value(&before).unwrap()); - assert_eq!(pixels(h.state(), layer), before_pixels); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 2); - let offset = text_position(&h, "Offset"); - click(&mut h, offset, PointerButton::Secondary); - h.get_by_label("Delete Smart Filter").click(); - assert_eq!(filters(h.state(), layer).len(), 1); - assert_eq!(filters(h.state(), layer)[0].command, "filter.blur.gaussianBlur"); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 3); - assert_eq!(h.state().session.active().unwrap().active_layer, active); - assert_eq!(h.state().session.active().unwrap().selected_layers, selected); - assert_eq!(photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(), source); - let unchanged = h.state().session.active().unwrap().doc.layer(other.id).unwrap(); - assert_eq!(unchanged, &other); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(pixels(h.state(), layer), before_pixels); - h.state_mut().run("edit.redo", json!({})).unwrap(); - assert_eq!(filters(h.state(), layer).len(), 1); - } - } -} -``` - -`two_filter_row_context_offers_reorder_and_delete` setup/action excerpt: - -```rust -let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); - let (mut h, layer) = harness(); - let top = text_position(&h, "Gaussian Blur"); - let bottom = text_position(&h, "Offset"); - assert!(top.y < bottom.y, "index1 is visually above index0"); - let before = h.state().session.active().unwrap().doc.clone(); - let history = h.state().session.active().unwrap().history.past_len(); - click(&mut h, bottom, PointerButton::Secondary); - assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history); - let missing = ["Move Up", "Move Down", "Delete Smart Filter"].into_iter().filter(|label| h.query_by_label(label).is_none()).collect::>(); - assert!(missing.is_empty(), "filter row lacks context choices {missing:?}; stack has {} filters", smart(h.state(), layer).smart_filters.len()); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(top.y < bottom.y, "index1 is visually above index0"); - let before = h.state().session.active().unwrap().doc.clone(); - let history = h.state().session.active().unwrap().history.past_len(); - click(&mut h, bottom, PointerButton::Secondary); - assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history); - let missing = ["Move Up", "Move Down", "Delete Smart Filter"].into_iter().filter(|label| h.query_by_label(label).is_none()).collect::>(); - assert!(missing.is_empty(), "filter row lacks context choices {missing:?}; stack has {} filters", smart(h.state(), layer).smart_filters.len()); -} -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-4023: i18n(es): add 548 missing rows, fix five filter and video terms - Fixture/setup: Select Spanish UI, synthetic document, and menus/dialogs for video footage, Blur Gallery, Pixelate and Sharpen. Record Spanish language selection and screenshots. @@ -768,88 +367,6 @@ assert!(top.y < bottom.y, "index1 is visually above index0"); - Evidence: - Tester: -## PR-4305: Filter > Other > Frequency Separation (#1714) - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-engine --locked low_and_high_composite_back_to_the_source_at_every_depth -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / low_and_high_composite_back_to_the_source_at_every_depth](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked high_must_be_on_linear_light -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / high_must_be_on_linear_light](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked builds_a_named_group_above_the_source_and_targets_high -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / builds_a_named_group_above_the_source_and_targets_high](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked low_is_the_gaussian_blur_of_the_source_at_the_radius -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / low_is_the_gaussian_blur_of_the_source_at_the_radius](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked one_undo_removes_everything_and_redo_brings_it_back -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / one_undo_removes_everything_and_redo_brings_it_back](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked works_on_the_background -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / works_on_the_background](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked needs_a_pixel_layer_with_pixels -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / needs_a_pixel_layer_with_pixels](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked refuses_a_channel_or_mask_target -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / refuses_a_channel_or_mask_target](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked refuses_cmyk_and_lab -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / refuses_cmyk_and_lab](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked a_layer_of_transparent_tiles_is_refused_when_run -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / a_layer_of_transparent_tiles_is_refused_when_run](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-engine --locked bad_params_are_rejected_without_changes -- --nocapture` — [crates/engine/src/frequency_separation_cmds.rs / bad_params_are_rejected_without_changes](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/engine/src/frequency_separation_cmds.rs) - - `cargo test -p photocraft-ui-egui --locked frequency_separation_sits_in_filter_other_after_color_to_alpha -- --nocapture` — [crates/ui-egui/src/menus.rs / frequency_separation_sits_in_filter_other_after_color_to_alpha](https://github.com/storytold/photocraft/blob/1b74873991dbb2e9803d228a969291f09c6f1032/crates/ui-egui/src/menus.rs) - -- Expected behavior: low and high composite back to the source at every depth; high must be on linear light; builds a named group above the source and targets high; low is the gaussian blur of the source at the radius; one undo removes everything and redo brings it back; works on the background; needs a pixel layer with pixels; refuses a channel or mask target; refuses cmyk and lab; a layer of transparent tiles is refused when run; bad params are rejected without changes; frequency separation sits in filter other after color to alpha. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`low_and_high_composite_back_to_the_source_at_every_depth` setup/action excerpt: - -```rust -for (depth, mode, tol) in [(8, "rgb", 1.0 / 255.0), (16, "rgb", 1.0 / 65535.0), (32, "rgb", 1e-5), (8, "gray", 1.0 / 255.0), (32, "gray", 1e-5)] { - let tol = tol + 1e-6; - let mut s = session(depth, mode); - let source = active(&s); - let before = composite(doc(&s)); - s.execute(ID, json!({"radius": 3})).unwrap_or_else(|e| panic!("{depth} {mode}: {e}")); - let d = doc(&s); - let (_, [low, high]) = group_of(d, source); - // The group alone rebuilds the source: hide the source so it can't show through. - let rebuilt = composite(&without(d, source)); - let err = max_diff(&before, &rebuilt); - assert!(err <= tol, "{depth} {mode}: Low + High is off by {err} (tolerance {tol})"); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(err <= tol, "{depth} {mode}: Low + High is off by {err} (tolerance {tol})"); - assert!(max_diff(&composite(d), &rebuilt) <= 1e-6, "{depth} {mode}: the group covers the source"); - // Low alone is visibly smoother than the source; High carries what it lost. - let low_only = composite(&without(&without(d, source), high)); - assert!(max_diff(&before, &low_only) > 0.1, "{depth} {mode}: Low is blurred"); - let h = d.layer(high).unwrap().surface().unwrap(); - assert!((0..W).any(|x| (h.rgba(x, 5)[0] - 0.5).abs() > 0.05), "{depth} {mode}: High holds the detail"); - assert_ne!(pixels(d, low), pixels(d, source), "{depth} {mode}"); - } - } -``` - -`high_must_be_on_linear_light` setup/action excerpt: - -```rust -let mut s = session(16, "rgb"); - let source = active(&s); - let before = composite(doc(&s)); - s.execute(ID, json!({})).unwrap(); - let (_, [_, high]) = group_of(doc(&s), source); - let mut d = doc(&s).clone(); - d.layer_mut(high).unwrap().blend = BlendMode::Normal; - assert!(max_diff(&before, &composite(&d)) > 0.1); - } -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(max_diff(&before, &composite(&d)) > 0.1); - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-4711: Treat Dissolve as a per-pixel choice - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -1010,21 +527,6 @@ assert!(close4(part.get(x, y), whole.get(x, y)), "{fmt:?}: region mismatch at ({ - Tester/date: - Tested SHA: -## PR-7153: Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace - -- Fixture/setup: Synthetic smart object with Liquify and Gaussian Blur filters; record original parameters, pixels and history. -- Action: run each command separately: - - - `cargo test -p photocraft-ui-egui --locked smart_filter_edit_after_the_stack_changed_updates_nothing -- --nocapture` - -- Expected: Double-click opens existing Liquify in its workspace with stored settings; commit updates intended filter rather than appending; Undo/Redo restores it; Cancel preserves parameters/pixels/history. Reordered stale stack refuses without history and says Reopen its dialog. -- Separate coverage / prerequisites: Automated stale-stack guard and native happy path/Cancel/undo are separate required subcases. Use supported controls for stale-target reproduction. -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-7903: Filter Gallery › Cutout 4/4: trace the image into flat paper shapes - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -2015,95 +1517,6 @@ assert_eq!(h.state().ui.mask_target, kind == MaskKind::Pixel); - Tester/date: - Tested SHA: -## PR-1991: feat(actions): import and export reusable Actions JSON files - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-automation --locked automation_rejects_ambient_path_commands_and_preferences -- --nocapture` — [crates/automation/src/workspace.rs / automation_rejects_ambient_path_commands_and_preferences](https://github.com/storytold/photocraft/blob/202266b90613280e16ae285b53dd4e10d5f0c7ac/crates/automation/src/workspace.rs) - - `cargo test -p photocraft-engine --locked json_actions_round_trip_preserves_metadata -- --nocapture` — [crates/engine/src/actions_cmds/json_file.rs / json_actions_round_trip_preserves_metadata](https://github.com/storytold/photocraft/blob/202266b90613280e16ae285b53dd4e10d5f0c7ac/crates/engine/src/actions_cmds/json_file.rs) - - `cargo test -p photocraft-engine --locked reimport_after_changing_button_color_keeps_names_unique -- --nocapture` — [crates/engine/src/actions_cmds/json_file.rs / reimport_after_changing_button_color_keeps_names_unique](https://github.com/storytold/photocraft/blob/202266b90613280e16ae285b53dd4e10d5f0c7ac/crates/engine/src/actions_cmds/json_file.rs) - - `cargo test -p photocraft-engine --locked parents_before_children_are_retargeted_and_reimport_is_idempotent -- --nocapture` — [crates/engine/src/actions_cmds/json_file.rs / parents_before_children_are_retargeted_and_reimport_is_idempotent](https://github.com/storytold/photocraft/blob/202266b90613280e16ae285b53dd4e10d5f0c7ac/crates/engine/src/actions_cmds/json_file.rs) - - `cargo test -p photocraft-engine --locked invalid_json_params_and_nested_indices_fail_atomically -- --nocapture` — [crates/engine/src/actions_cmds/json_file.rs / invalid_json_params_and_nested_indices_fail_atomically](https://github.com/storytold/photocraft/blob/202266b90613280e16ae285b53dd4e10d5f0c7ac/crates/engine/src/actions_cmds/json_file.rs) - - `cargo test -p photocraft-engine --locked long_unicode_collision_names_remain_portable_and_idempotent -- --nocapture` — [crates/engine/src/actions_cmds/json_file.rs / long_unicode_collision_names_remain_portable_and_idempotent](https://github.com/storytold/photocraft/blob/202266b90613280e16ae285b53dd4e10d5f0c7ac/crates/engine/src/actions_cmds/json_file.rs) - - `cargo test -p photocraft-ui-egui --locked loading_json_actions_uses_engine_import_and_selects_the_new_action -- --nocapture` — [crates/ui-egui/src/actions.rs / loading_json_actions_uses_engine_import_and_selects_the_new_action](https://github.com/storytold/photocraft/blob/202266b90613280e16ae285b53dd4e10d5f0c7ac/crates/ui-egui/src/actions.rs) - -- Expected behavior: automation rejects ambient path commands and preferences; json actions round trip preserves metadata; reimport after changing button color keeps names unique; parents before children are retargeted and reimport is idempotent; invalid json params and nested indices fail atomically; long unicode collision names remain portable and idempotent; loading json actions uses engine import and selects the new action. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`automation_rejects_ambient_path_commands_and_preferences` setup/action excerpt: - -```rust -for (id, params) in [ - ("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})), - ("brush.presets.importImage", serde_json::json!({"path": "/outside/tip.png"})), - ("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})), - ("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})), - ("plugin.reload", serde_json::json!({"path": "/outside/plugins"})), - ("plugin.reload", serde_json::json!({})), - ("plugin.install", serde_json::json!({"path": " "})), - ("swatches.import", serde_json::json!({"path": "/outside/set.aco"})), - ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), - ("actions.load", serde_json::json!({"path": "/outside/set.atn"})), - ("actions.save", serde_json::json!({"path": "/outside/set.atn"})), -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}"); - } - for (id, params) in [ - ("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})), - assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}"); - } - for path in [ - "", - assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": path, "value": {}})).is_err(), "{path}"); - } - assert!( - authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err() - ); - assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); - } -``` - -`json_actions_round_trip_preserves_metadata` setup/action excerpt: - -```rust -let mut s = Session::new(); - let mut action = Action::new("Draw", vec![("layer.new.layer".into(), json!({}))]); - action.disabled.insert(0); - action.dialogs.insert(0); - action.set = Some("Painting".into()); - s.actions.list.push(action.clone()); - let exported = s.execute("actions.export", json!({})).unwrap(); - s.actions.list.clear(); - let result = s.execute("actions.import", json!({"data": exported["data"]})).unwrap(); - assert_eq!(result["imported"], 1); - assert_eq!(s.actions.list, [action]); - let rev = s.actions.rev; -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(result["imported"], 1); - assert_eq!(s.actions.list, [action]); - let rev = s.actions.rev; - assert_eq!(s.execute("actions.import", json!({"data": exported["data"]})).unwrap()["imported"], 0); - assert_eq!(s.actions.rev, rev); - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-3142: Read applied Sony RAW metadata and crop ILCE-6000 images - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -2437,73 +1850,6 @@ assert_eq!(names.len(), encoded.len()); - Tester/date: - Tested SHA: -## PR-5870: fix(format): reject duplicate native layer identities - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-format --locked duplicate_layer_ids_are_rejected_across_the_whole_tree -- --nocapture` — [crates/format/tests/layer_identities.rs / duplicate_layer_ids_are_rejected_across_the_whole_tree](https://github.com/storytold/photocraft/blob/f9de0a6abc78aa405b1ef46f5da6cba424106204/crates/format/tests/layer_identities.rs) - - `cargo test -p photocraft-format --locked duplicate_extreme_layer_ids_are_not_silently_remapped -- --nocapture` — [crates/format/tests/layer_identities.rs / duplicate_extreme_layer_ids_are_not_silently_remapped](https://github.com/storytold/photocraft/blob/f9de0a6abc78aa405b1ef46f5da6cba424106204/crates/format/tests/layer_identities.rs) - - `cargo test -p photocraft-format --locked duplicate_layer_ids_are_rejected_before_fetching_payloads -- --nocapture` — [crates/format/tests/layer_identities.rs / duplicate_layer_ids_are_rejected_before_fetching_payloads](https://github.com/storytold/photocraft/blob/f9de0a6abc78aa405b1ef46f5da6cba424106204/crates/format/tests/layer_identities.rs) - - `cargo test -p photocraft-format --locked unique_nested_layer_ids_preserve_comp_targets_in_both_load_modes -- --nocapture` — [crates/format/tests/layer_identities.rs / unique_nested_layer_ids_preserve_comp_targets_in_both_load_modes](https://github.com/storytold/photocraft/blob/f9de0a6abc78aa405b1ef46f5da6cba424106204/crates/format/tests/layer_identities.rs) - - `cargo test -p photocraft-format --locked layer_identity_validation_keeps_smart_document_and_contents_scopes_independent -- --nocapture` — [crates/format/tests/layer_identities.rs / layer_identity_validation_keeps_smart_document_and_contents_scopes_independent](https://github.com/storytold/photocraft/blob/f9de0a6abc78aa405b1ef46f5da6cba424106204/crates/format/tests/layer_identities.rs) - - `cargo test -p photocraft-format --locked an_empty_layer_tree_loads_in_both_modes -- --nocapture` — [crates/format/tests/layer_identities.rs / an_empty_layer_tree_loads_in_both_modes](https://github.com/storytold/photocraft/blob/f9de0a6abc78aa405b1ef46f5da6cba424106204/crates/format/tests/layer_identities.rs) - -- Expected behavior: duplicate layer ids are rejected across the whole tree; duplicate extreme layer ids are not silently remapped; duplicate layer ids are rejected before fetching payloads; unique nested layer ids preserve comp targets in both load modes; layer identity validation keeps smart document and contents scopes independent; an empty layer tree loads in both modes. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`duplicate_layer_ids_are_rejected_across_the_whole_tree` setup/action excerpt: - -```rust -let doc = sample(); - let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); - // Top-level siblings, ancestor/descendant, nested siblings, and unrelated groups must all - // share one identity namespace. Reidentifying cannot recover an ambiguous comp target. - for (source, target) in [ - ("/document/layers/1/id", "/document/layers/2/id"), - ("/document/layers/1/id", "/document/layers/1/content/children/1/content/children/0/id"), - ("/document/layers/1/content/children/0/id", "/document/layers/1/content/children/1/id"), - ("/document/layers/1/content/children/0/id", "/document/layers/2/content/children/0/id"), - ("/document/layers/0/id", "/document/layers/1/content/children/1/content/children/0/id"), - ] { - let mut duplicate = 0; -``` - -Expected assertion context (overlapping windows merged): - -```rust - -``` - -`duplicate_extreme_layer_ids_are_not_silently_remapped` setup/action excerpt: - -```rust -let bytes = save_to_bytes(&sample(), &SaveOptions::default()).unwrap(); - let damaged = rewrite_manifest(&bytes, |m| { - m["document"]["layers"][0]["id"] = u64::MAX.into(); - m["document"]["layers"][2]["id"] = u64::MAX.into(); - }); - for preserve_ids in [true, false] { - let error = load_from_bytes_with(&damaged, &LoadOptions { preserve_ids, ..Default::default() }).unwrap_err(); - assert_duplicate(error, u64::MAX); - } -} -``` - -Expected assertion context (overlapping windows merged): - -```rust - -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-6383: Windows releases ship a symbols zip: the release build's PDBs for profilers (#688) - Fixture/setup: Portable/executable artifact and symbols ZIP from the SAME immutable final-SHA build run, per claimed architecture. @@ -3174,76 +2520,6 @@ assert_eq!(h.state().session.tools.brush.scattering.enabled, enabled); - Tester/date: - Tested SHA: -## PR-5453: Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1 - -- Fixture/setup: Synthetic distinct paints and imported/clipboard patterns in shape_paint_fidelity.rs, shape_patterns.rs and shape_pattern_clipboard.rs across their depth/model matrix. -- Action: run each command separately: - - - `cargo test -p photocraft-engine --locked exact_paints_roundtrip_through_create_edit_and_undo -- --nocapture` - - `cargo test -p photocraft-engine --locked solid_alpha_and_library_patterns_render_and_embed_in_the_same_history_step -- --nocapture` - - `cargo test -p photocraft-engine --locked patterned_shapes_embed_render_and_undo_in_one_command_at_every_depth -- --nocapture` - - `cargo test -p photocraft-engine --locked patterned_fill_and_stroke_clipboards_keep_cow_tiles_after_source_closes -- --nocapture` - - `cargo test -p photocraft-engine --locked unresolved_and_conflicting_pattern_clipboards_fail_atomically -- --nocapture` - - `cargo test -p photocraft-vector --locked pattern_shape_fill_and_stroke_render_at_every_depth_and_model -- --nocapture` - - `cargo test -p photocraft-gpu --locked patterned_shape_fill_and_stroke_clip_and_effects_match_cpu -- --nocapture` - -- Expected: Distinct solid/gradient/pattern fill and stroke survive create/edit/undo. Pattern embedding shares the history transaction; copied paints retain pixels after source closes; unresolved/conflicting clipboard patterns refuse atomically. GPU matches CPU at the existing tolerance. -- Separate coverage / prerequisites: Verified adapter required for GPU comparison; cache-budget checks are additional reliability coverage, not a replacement. -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - -## PR-5939: fix(paint): scale blur sigma with brush size (#5892) - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-engine --locked blur_scales_with_brush_size_and_diffuses_pixels_visibly -- --nocapture` — [crates/engine/src/retouch_cmds/tests.rs / blur_scales_with_brush_size_and_diffuses_pixels_visibly](https://github.com/storytold/photocraft/blob/7348e060b4be7204fb69b59079c3b9d808e9aad3/crates/engine/src/retouch_cmds/tests.rs) - -- Expected behavior: blur scales with brush size and diffuses pixels visibly. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`blur_scales_with_brush_size_and_diffuses_pixels_visibly` setup/action excerpt: - -```rust -for depth in [8u64, 16] { - let run = |size: u32| { - let mut s = session(120, 40, depth, "rgb"); - paint_layer(&mut s, |x, _| if x < 60 { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }); - s.execute("paint.blur", json!({"points": [[60, 20]], "size": size, "hardness": 100, "strength": 100})).unwrap(); - (0..120).map(|x| rgba(&s, x, 20)[0]).collect::>() - }; - let p20 = run(20); - let p60 = run(60); - // Size 20 (sigma = 3.0, radius 10, footprint [50, 70]) leaves x=45 and x=75 untouched. - assert_eq!(p20[45], 0.0, "size 20 footprint does not reach x=45"); - assert_eq!(p20[75], 1.0, "size 20 footprint does not reach x=75"); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(p20[45], 0.0, "size 20 footprint does not reach x=45"); - assert_eq!(p20[75], 1.0, "size 20 footprint does not reach x=75"); - // Size 60 (sigma = 9.0, radius 30, footprint [30, 90]) diffuses light and dark across a wider band. - assert!(p60[45] > 0.0, "size 60 diffuses light past x=45: {}", p60[45]); - assert!(p60[75] < 1.0, "size 60 diffuses dark past x=75: {}", p60[75]); - // Near the step edge (x=55), size 60 diffuses more than size 20 due to larger sigma. - assert!(p60[55] > p20[55], "larger sigma diffuses deeper near edge: p60={} p20={}", p60[55], p20[55]); - } -} -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-6139: Fix painting workflows, File Info and layer selection - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -3828,27 +3104,6 @@ assert_eq!(frame(&h).size(), size, "{page}"); - Tester/date: - Tested SHA: -## PR-5493: Import Photoshop custom shape sets into the Shapes panel - -- Fixture/setup: Synthetic multi-shape CSH, duplicate names, inner holes and malformed second shape; empty temporary library/preferences. -- Action: run each command separately: - - - `cargo test -p photocraft-engine --locked csh_import_makes_an_original_shape_available_without_a_document -- --nocapture` - - `cargo test -p photocraft-engine --locked csh_invalid_second_shape_does_not_partially_import_a_set -- --nocapture` - - `cargo test -p photocraft-engine --locked csh_multiple_shapes_with_matching_names_remain_individually_available -- --nocapture` - - `cargo test -p photocraft-engine --locked csh_repeat_import_names_are_unique_and_survive_preferences_reload -- --nocapture` - - `cargo test -p photocraft-engine --locked csh_same_winding_inner_subpath_stays_a_hole_after_place_and_reload -- --nocapture` - - `cargo test -p photocraft-ui-egui --locked csh_open_imports_presets_and_shows_shapes_without_opening_a_document -- --nocapture` - - `cargo test -p photocraft-ui-egui --locked malformed_csh_open_preserves_the_library -- --nocapture` - -- Expected: Valid sets populate Shapes without a document; duplicate names stay individually addressable after reload; placed holes remain holes. Invalid later shape does not partially change library/revision. Malformed UI open preserves the library. -- Separate coverage / prerequisites: Retain inline-data authorization test separately; capture native Shapes panel as additional visual evidence. -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-5515: Selecting subject now automatically applies to selected layer, and us… - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -4276,103 +3531,6 @@ assert_eq!(workspace.read(&path.to_string_lossy()).unwrap(), b"inside"); - Tester/date: - Tested SHA: -## PR-6621: Crop: right-click on the pending crop opens the crop menu (#6561) - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-ui-egui --locked crop_menu_ratio_rows_fit_the_frame_like_the_options_bar -- --nocapture` — [crates/ui-egui/src/canvas_tool_menu.rs / crop_menu_ratio_rows_fit_the_frame_like_the_options_bar](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/canvas_tool_menu.rs) - - `cargo test -p photocraft-ui-egui --locked crop_menu_gear_rows_toggle_the_shield_options -- --nocapture` — [crates/ui-egui/src/canvas_tool_menu.rs / crop_menu_gear_rows_toggle_the_shield_options](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/canvas_tool_menu.rs) - - `cargo test -p photocraft-ui-egui --locked crop_menu_reset_cancel_and_commit_act_on_the_frame -- --nocapture` — [crates/ui-egui/src/canvas_tool_menu.rs / crop_menu_reset_cancel_and_commit_act_on_the_frame](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/canvas_tool_menu.rs) - - `cargo test -p photocraft-ui-egui --locked crop_menu_lists_saved_presets_and_applies_one -- --nocapture` — [crates/ui-egui/src/canvas_tool_menu.rs / crop_menu_lists_saved_presets_and_applies_one](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/canvas_tool_menu.rs) - - `cargo test -p photocraft-ui-egui --locked crop_menu_closes_when_the_crop_is_no_longer_pending -- --nocapture` — [crates/ui-egui/src/canvas_tool_menu.rs / crop_menu_closes_when_the_crop_is_no_longer_pending](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/canvas_tool_menu.rs) - - `cargo test -p photocraft-ui-egui --locked right_click_on_a_pending_crop_opens_the_crop_menu -- --nocapture` — [crates/ui-egui/src/canvas_tool_menu.rs / right_click_on_a_pending_crop_opens_the_crop_menu](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/canvas_tool_menu.rs) - - `cargo test -p photocraft-ui-egui --locked right_click_without_a_pending_crop_opens_no_menu -- --nocapture` — [crates/ui-egui/src/canvas_tool_menu.rs / right_click_without_a_pending_crop_opens_no_menu](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/canvas_tool_menu.rs) - - `cargo test -p photocraft-ui-egui --locked reset_then_commit_crops_nothing -- --nocapture` — [crates/ui-egui/src/crop_ui.rs / reset_then_commit_crops_nothing](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/crop_ui.rs) - - `cargo test -p photocraft-ui-egui --locked reset_edit_undo_then_commit_crops_nothing -- --nocapture` — [crates/ui-egui/src/crop_ui.rs / reset_edit_undo_then_commit_crops_nothing](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/crop_ui.rs) - - `cargo test -p photocraft-ui-egui --locked reset_then_a_still_click_keeps_the_no_op -- --nocapture` — [crates/ui-egui/src/crop_ui.rs / reset_then_a_still_click_keeps_the_no_op](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/crop_ui.rs) - - `cargo test -p photocraft-ui-egui --locked reset_then_frame_edit_back_to_bounds_commits_a_real_crop -- --nocapture` — [crates/ui-egui/src/crop_ui.rs / reset_then_frame_edit_back_to_bounds_commits_a_real_crop](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/crop_ui.rs) - - `cargo test -p photocraft-ui-egui --locked type_layers_offer_free_transform_but_not_distort_or_perspective -- --nocapture` — [crates/ui-egui/src/transform_type_tests.rs / type_layers_offer_free_transform_but_not_distort_or_perspective](https://github.com/storytold/photocraft/blob/ead45134d320cb1002724d5ef44f5c56588bbe1d/crates/ui-egui/src/transform_type_tests.rs) - -- Expected behavior: crop menu ratio rows fit the frame like the options bar; crop menu gear rows toggle the shield options; crop menu reset cancel and commit act on the frame; crop menu lists saved presets and applies one; crop menu closes when the crop is no longer pending; right click on a pending crop opens the crop menu; right click without a pending crop opens no menu; reset then commit crops nothing; reset edit undo then commit crops nothing; reset then a still click keeps the no op; reset then frame edit back to bounds commits a real crop; type layers offer free transform but not distort or perspective. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`crop_menu_ratio_rows_fit_the_frame_like_the_options_bar` setup/action excerpt: - -```rust -let mut app = crop_app(); - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert!(menu.crop && available(&app, &menu, "crop.ratio.16:9")); - choose(&mut app, &Context::default(), "crop.ratio.16:9"); - assert_eq!(app.ui.tool_options.crop_ratio, "16:9"); - assert!((frame_ratio(&app) - 16.0 / 9.0).abs() < 1e-9, "the frame is fitted to the ratio"); - // The current ratio shows checked; the others do not. - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert!(mode_checked(&app, &menu, "crop.ratio.16:9") && !mode_checked(&app, &menu, "crop.ratio.1:1")); - choose(&mut app, &Context::default(), "crop.ratio.1:1"); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(menu.crop && available(&app, &menu, "crop.ratio.16:9")); - choose(&mut app, &Context::default(), "crop.ratio.16:9"); - assert_eq!(app.ui.tool_options.crop_ratio, "16:9"); - assert!((frame_ratio(&app) - 16.0 / 9.0).abs() < 1e-9, "the frame is fitted to the ratio"); - // The current ratio shows checked; the others do not. - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert!(mode_checked(&app, &menu, "crop.ratio.16:9") && !mode_checked(&app, &menu, "crop.ratio.1:1")); - choose(&mut app, &Context::default(), "crop.ratio.1:1"); - assert!((frame_ratio(&app) - 1.0).abs() < 1e-9); - } -``` - -`crop_menu_gear_rows_toggle_the_shield_options` setup/action excerpt: - -```rust -let mut app = crop_app(); - crop_open(&mut app); - choose(&mut app, &Context::default(), CROP_SHOW_CROPPED); - assert!(!app.ui.tool_options.crop_shield.show_cropped_area); - crop_open(&mut app); - assert!(!mode_checked(&app, app.ui.canvas_tool_menu.as_ref().unwrap(), CROP_SHOW_CROPPED), "the row shows the option's state"); - choose(&mut app, &Context::default(), CROP_CLASSIC); - assert!(app.ui.tool_options.crop_shield.classic_mode); - // As in the gear menu, Classic Mode greys out the other two options. - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert!(!entry_enabled(&app, &menu, CROP_SHOW_CROPPED) && !entry_enabled(&app, &menu, CROP_AUTO_CENTER)); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(!app.ui.tool_options.crop_shield.show_cropped_area); - crop_open(&mut app); - assert!(!mode_checked(&app, app.ui.canvas_tool_menu.as_ref().unwrap(), CROP_SHOW_CROPPED), "the row shows the option's state"); - choose(&mut app, &Context::default(), CROP_CLASSIC); - assert!(app.ui.tool_options.crop_shield.classic_mode); - // As in the gear menu, Classic Mode greys out the other two options. - crop_open(&mut app); - let menu = app.ui.canvas_tool_menu.clone().unwrap(); - assert!(!entry_enabled(&app, &menu, CROP_SHOW_CROPPED) && !entry_enabled(&app, &menu, CROP_AUTO_CENTER)); - assert!(entry_enabled(&app, &menu, CROP_CLASSIC)); - choose(&mut app, &Context::default(), CROP_CLASSIC); - assert!(!app.ui.tool_options.crop_shield.classic_mode); - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-7648: Pen: Path Operations menu, as in Photoshop (#1397) - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -4688,129 +3846,6 @@ assert!(d8 <= i32::from(depth == SampleType::U8), "{depth:?} density {density} f - Tester/date: - Tested SHA: -## PR-3139: Draw right-to-left layer names in visual word order - -- Fixture/setup: Arabic, Hebrew, mixed Latin/digits and Persian joiner samples in bidi_label_tests.rs; record rendered font files. -- Action: run each command separately: - - - `cargo test -p photocraft-ui-egui --locked right_to_left_words_swap_places_but_keep_their_letters -- --nocapture` - - `cargo test -p photocraft-ui-egui --locked numbers_and_latin_keep_reading_left_to_right -- --nocapture` - - `cargo test -p photocraft-ui-egui --locked persian_joiners_stay_inside_their_word -- --nocapture` - - `cargo test -p photocraft-ui-egui --locked arabic_draws_first_word_rightmost_with_letters_joined -- --nocapture` - - `cargo test -p photocraft-ui-egui --locked hebrew_draws_first_word_rightmost -- --nocapture` - - `cargo test -p photocraft-ui-egui --locked a_long_right_to_left_layer_name_keeps_its_beginning -- --nocapture` - -- Expected: First RTL word is rightmost; Arabic joining and Persian joiners remain intact; Latin/digits retain direction; truncation keeps the beginning. Retain LTR-unchanged negative coverage. -- Separate coverage / prerequisites: Inspect Layers at 1×/2×. Missing fonts block rendered coverage; an early return is not a pass. -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - -## PR-4783: Fix/4758 type extreme finite geometry 20261013s1215 - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-io --locked tysh_fallback_cannot_reintroduce_unusable_finite_or_nonfinite_transform -- --nocapture` — [crates/io/src/blocks.rs / tysh_fallback_cannot_reintroduce_unusable_finite_or_nonfinite_transform](https://github.com/storytold/photocraft/blob/a9d62726bed607bc540db9d09c8dc1be4f9895aa/crates/io/src/blocks.rs) - - `cargo test -p photocraft-io --locked hostile_type_sizes_without_pixels_import_without_a_huge_render -- --nocapture` — [crates/io/tests/psd_type_bounds.rs / hostile_type_sizes_without_pixels_import_without_a_huge_render](https://github.com/storytold/photocraft/blob/a9d62726bed607bc540db9d09c8dc1be4f9895aa/crates/io/tests/psd_type_bounds.rs) - - `cargo test -p photocraft-text --locked overflowing_legacy_origin_does_not_escape_as_a_nonfinite_transform -- --nocapture` — [crates/text/src/psd.rs / overflowing_legacy_origin_does_not_escape_as_a_nonfinite_transform](https://github.com/storytold/photocraft/blob/a9d62726bed607bc540db9d09c8dc1be4f9895aa/crates/text/src/psd.rs) - - `cargo test -p photocraft-text --locked reject_unusable_matrices_before_type_layout -- --nocapture` — [crates/text/src/psd.rs / reject_unusable_matrices_before_type_layout](https://github.com/storytold/photocraft/blob/a9d62726bed607bc540db9d09c8dc1be4f9895aa/crates/text/src/psd.rs) - -- Expected behavior: tysh fallback cannot reintroduce unusable finite or nonfinite transform; hostile type sizes without pixels import without a huge render; overflowing legacy origin does not escape as a nonfinite transform; reject unusable matrices before type layout. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`tysh_fallback_cannot_reintroduce_unusable_finite_or_nonfinite_transform` setup/action excerpt: - -```rust -let mut tysh = vec![0u8; 52]; - tysh[..2].copy_from_slice(&1u16.to_be_bytes()); - for (i, value) in [1.0f64, 0.0, 0.0, 1.0, 30.0, 40.0].iter().enumerate() { - tysh[2 + 8 * i..10 + 8 * i].copy_from_slice(&value.to_be_bytes()); - } - assert!(parse_tysh(&tysh).is_some(), "normal matrices still import"); - for value in [1e62f64, -1e62, f64::INFINITY, f64::NAN] { - tysh[2..10].copy_from_slice(&value.to_be_bytes()); - assert!(parse_tysh(&tysh).is_none(), "invalid matrix {value} cannot reach UI"); - } - } -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(parse_tysh(&tysh).is_some(), "normal matrices still import"); - for value in [1e62f64, -1e62, f64::INFINITY, f64::NAN] { - tysh[2..10].copy_from_slice(&value.to_be_bytes()); - assert!(parse_tysh(&tysh).is_none(), "invalid matrix {value} cannot reach UI"); - } - } -``` - -`hostile_type_sizes_without_pixels_import_without_a_huge_render` setup/action excerpt: - -```rust -// The import renders type layers that come without pixels; the model is untrusted, so an - // absurd size or scale must not make opening the file allocate gigabytes (or fail). - let scaled = |s: f64| Affine { m: [s, 0.0, 0.0, s, 10.0, 10.0] }; - for (size, transform, big) in - [(12.0, scaled(1100.0), true), (1.0e30, scaled(1.0), false), (12.0, scaled(1.0e12), false), (f64::MAX, scaled(f64::MAX), false)] - { - let runs = vec![Run { len: 1, size, leading: None }]; - let data = tysh("M", &runs, None, transform, [10.0, 10.0, 20.0, 20.0]); - // Extremely large TySh transforms are now rejected before any layout or rendering. - // Keep testing the import-time render cap for matrices within the safety gate. - if !photocraft_text::psd::usable_tysh_transform(&transform.m) { - assert!(photocraft_text::psd::text_layer_from_tysh(&data, 72.0).is_none(), "unsafe transform must be rejected before layout"); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(photocraft_text::psd::text_layer_from_tysh(&data, 72.0).is_none(), "unsafe transform must be rejected before layout"); - continue; - } - let mut t = photocraft_text::psd::text_layer_from_tysh(&data, 72.0).expect("valid TySh transform"); - assert!(px > 64 * 1024 * 1024 && px <= 256 * 1024 * 1024, "{ink:?}"); - } - t.cache = None; - t.psd_raw = Some(std::sync::Arc::new(data)); - assert!(cache.content_bounds().is_empty(), "size {size}: drew {:?}", cache.content_bounds()); - } -} -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - -## PR-8347: Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment) - -- Fixture/setup: Trailing punctuation, centered/justified lines and forced breaks in text layout fixtures. -- Action: run each command separately: - - - `cargo test -p photocraft-text --locked hanging_marks_do_not_steer_the_alignment -- --nocapture` - - `cargo test -p photocraft-text --locked hanging_centers_a_kerned_line_exactly -- --nocapture` - - `cargo test -p photocraft-text --locked hanging_only_the_mark_list_trims -- --nocapture` - - `cargo test -p photocraft-text --locked hanging_keeps_marks_after_a_forced_break_on_their_row -- --nocapture` - - `cargo test -p photocraft-text --locked hanging_marks_stay_off_a_justified_paragraphs_last_line -- --nocapture` - - `cargo test -p photocraft-text --locked hanging_leaves_plain_paragraphs_untouched -- --nocapture` - - `cargo test -p photocraft-ui-egui --locked character_panel_hanging_checkbox_toggles_the_paragraph_style -- --nocapture` - - `cargo test -p photocraft-engine --locked set_style_paragraph_hanging_round_trips -- --nocapture` - -- Expected: Listed marks hang without steering alignment; kerning-aware centering stays exact; unlisted marks/plain paragraphs are unchanged; forced breaks and justified last lines retain their asserted rules. Checkbox edits actual paragraph style. -- Separate coverage / prerequisites: A stored boolean alone is not layout evidence. -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-9457: raw: faster tone stage (Shadows, Highlights, Clarity, Texture, Dehaze), same bytes - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -5072,108 +4107,6 @@ assert!(!file.global_blocks.iter().any(|b| b.key == *b"Txt2"), "no type layers: - Tester/date: - Tested SHA: -## PR-3345: Add grouped gradient presets and selectable style icons - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-engine --locked native_gradient_icons_persist_reset_and_preserve_legacy_defaults -- --nocapture` — [crates/engine/src/prefs/tests.rs / native_gradient_icons_persist_reset_and_preserve_legacy_defaults](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/engine/src/prefs/tests.rs) - - `cargo test -p photocraft-ui-egui --locked opaque_ramp_has_no_checkerboard_fringe_at_fractional_pixel_edges -- --nocapture` — [crates/ui-egui/src/gradient_ui.rs / opaque_ramp_has_no_checkerboard_fringe_at_fractional_pixel_edges](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui.rs) - - `cargo test -p photocraft-ui-egui --locked genuinely_transparent_ramp_still_shows_checkerboard_at_both_scales -- --nocapture` — [crates/ui-egui/src/gradient_ui.rs / genuinely_transparent_ramp_still_shows_checkerboard_at_both_scales](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui.rs) - - `cargo test -p photocraft-ui-egui --locked options_bar_swatch_opens_the_editor_and_reaches_the_gradients_panel -- --nocapture` — [crates/ui-egui/src/gradient_ui.rs / options_bar_swatch_opens_the_editor_and_reaches_the_gradients_panel](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui.rs) - - `cargo test -p photocraft-ui-egui --locked the_options_bar_ribbon_opens_the_editor_only_in_classic_mode -- --nocapture` — [crates/ui-egui/src/gradient_ui.rs / the_options_bar_ribbon_opens_the_editor_only_in_classic_mode](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui.rs) - - `cargo test -p photocraft-ui-egui --locked the_ramp_covers_its_checkerboard_at_half_pixel_offsets -- --nocapture` — [crates/ui-egui/src/gradient_ui.rs / the_ramp_covers_its_checkerboard_at_half_pixel_offsets](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui.rs) - - `cargo test -p photocraft-ui-egui --locked selected_editor_opacity_commits_once_and_delete_preserves_minimum -- --nocapture` — [crates/ui-egui/src/gradient_ui/editor_tests.rs / selected_editor_opacity_commits_once_and_delete_preserves_minimum](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/editor_tests.rs) - - `cargo test -p photocraft-ui-egui --locked preset_editor_opacity_fields_do_not_edit_document_history -- --nocapture` — [crates/ui-egui/src/gradient_ui/editor_tests.rs / preset_editor_opacity_fields_do_not_edit_document_history](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/editor_tests.rs) - - `cargo test -p photocraft-ui-egui --locked editor_browser_expands_groups_and_selects_without_opening_the_sidebar -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / editor_browser_expands_groups_and_selects_without_opening_the_sidebar](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked layer_browser_replaces_color_and_opacity_preserves_geometry_and_undoes_once -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / layer_browser_replaces_color_and_opacity_preserves_geometry_and_undoes_once](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked classic_browser_and_editor_edit_the_tool_even_with_a_gradient_layer_selected -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / classic_browser_and_editor_edit_the_tool_even_with_a_gradient_layer_selected](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked picking_a_preset_discards_an_uncommitted_location_draft -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / picking_a_preset_discards_an_uncommitted_location_draft](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked toolbar_popup_selects_in_both_modes_without_creating_a_layer -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / toolbar_popup_selects_in_both_modes_without_creating_a_layer](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked editor_browser_can_choose_presets_without_a_document -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / editor_browser_can_choose_presets_without_a_document](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked browser_new_saves_the_edited_layer_instead_of_the_tool_gradient -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / browser_new_saves_the_edited_layer_instead_of_the_tool_gradient](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked double_clicking_an_editor_preset_does_not_create_another_layer -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / double_clicking_an_editor_preset_does_not_create_another_layer](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked presets_can_collapse_to_reach_controls_in_a_small_window_across_themes -- --nocapture` — [crates/ui-egui/src/gradient_ui/preset_browser_tests.rs / presets_can_collapse_to_reach_controls_in_a_small_window_across_themes](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/preset_browser_tests.rs) - - `cargo test -p photocraft-ui-egui --locked properties_dropdown_edits_the_layer_in_both_tool_modes_and_saves_it -- --nocapture` — [crates/ui-egui/src/gradient_ui/properties_panel.rs / properties_dropdown_edits_the_layer_in_both_tool_modes_and_saves_it](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/properties_panel.rs) - - `cargo test -p photocraft-ui-egui --locked style_icons_edit_the_selected_layer_with_one_undo_step -- --nocapture` — [crates/ui-egui/src/gradient_ui/properties_panel.rs / style_icons_edit_the_selected_layer_with_one_undo_step](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/gradient_ui/properties_panel.rs) - - `cargo test -p photocraft-ui-egui --locked native_gradient_icons_apply_cancel_and_restart -- --nocapture` — [crates/ui-egui/src/prefs_ui.rs / native_gradient_icons_apply_cancel_and_restart](https://github.com/storytold/photocraft/blob/bfb691f84896fe68543980426632153dc4b4ea69/crates/ui-egui/src/prefs_ui.rs) - -- Expected behavior: native gradient icons persist reset and preserve legacy defaults; opaque ramp has no checkerboard fringe at fractional pixel edges; genuinely transparent ramp still shows checkerboard at both scales; options bar swatch opens the editor and reaches the gradients panel; the options bar ribbon opens the editor only in classic mode; the ramp covers its checkerboard at half pixel offsets; selected editor opacity commits once and delete preserves minimum; preset editor opacity fields do not edit document history; editor browser expands groups and selects without opening the sidebar; layer browser replaces color and opacity preserves geometry and undoes once; classic browser and editor edit the tool even with a gradient layer selected; picking a preset discards an uncommitted location draft; toolbar popup selects in both modes without creating a layer; editor browser can choose presets without a document; browser new saves the edited layer instead of the tool gradient; double clicking an editor preset does not create another layer; presets can collapse to reach controls in a small window across themes; properties dropdown edits the layer in both tool modes and saves it; style icons edit the selected layer with one undo step; native gradient icons apply cancel and restart. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`native_gradient_icons_persist_reset_and_preserve_legacy_defaults` setup/action excerpt: - -```rust -let mut s = session(); - let history = s.active().unwrap().history.past_len(); - assert!(!s.prefs().interface.native_gradient_icons); - s.execute("prefs.set", json!({"path": "interface.nativeGradientIcons", "value": true})).unwrap(); - let mut restarted = Session::new(); - restarted.load_prefs_json(&s.prefs_to_json()).unwrap(); - assert!(restarted.prefs().interface.native_gradient_icons); - assert_eq!(s.active().unwrap().history.past_len(), history); - s.execute("prefs.reset", json!({"path": "interface.nativeGradientIcons"})).unwrap(); - assert!(!s.prefs().interface.native_gradient_icons); - let before = s.prefs().to_json(); - for invalid in [json!("true"), json!(1), Value::Null] { -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(!s.prefs().interface.native_gradient_icons); - s.execute("prefs.set", json!({"path": "interface.nativeGradientIcons", "value": true})).unwrap(); - let mut restarted = Session::new(); - restarted.load_prefs_json(&s.prefs_to_json()).unwrap(); - assert!(restarted.prefs().interface.native_gradient_icons); - assert_eq!(s.active().unwrap().history.past_len(), history); - s.execute("prefs.reset", json!({"path": "interface.nativeGradientIcons"})).unwrap(); - assert!(!s.prefs().interface.native_gradient_icons); - let before = s.prefs().to_json(); - for invalid in [json!("true"), json!(1), Value::Null] { - assert!(s.execute("prefs.set", json!({"values": {"interface.showTooltips": false, "interface.nativeGradientIcons": invalid}})).is_err()); - assert_eq!(s.prefs().to_json(), before, "invalid updates are atomic"); - } - restarted.load_prefs_json(r#"{"interface":{"showTooltips":false}}"#).unwrap(); - assert!(!restarted.prefs().interface.native_gradient_icons); - assert!(!restarted.prefs().interface.show_tooltips); -} -``` - -`opaque_ramp_has_no_checkerboard_fringe_at_fractional_pixel_edges` setup/action excerpt: - -```rust -use egui_kittest::Harness; - for scale in [1.0, 2.0] { - let edge = 20.0 + 0.3 / scale; - let rect = Rect::from_min_size(pos2(edge, edge), vec2(96.0, 20.0)); - let Ok(mut h) = std::panic::catch_unwind(|| { - Harness::builder().with_size(vec2(160.0, 80.0)).with_pixels_per_point(scale).wgpu().build_ui(move |ui| { - paint_ramp(ui.painter(), rect, |_| [0.1, 0.25, 0.2, 1.0]); - }) - }) else { - eprintln!("skipping: no GPU adapter"); - return; - }; -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(pixel[channel].abs_diff(background[channel]) <= 2, "scale={scale} at ({right}, {y}): {pixel:?} vs {background:?}"); - } - } - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-3461: Replay recorded layer visibility on the intended named layer - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -5338,80 +4271,6 @@ Expected assertion context (overlapping windows merged): - Tester/date: - Tested SHA: -## PR-4042: feat(canvas): rotate view with Shift + middle-mouse drag - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-ui-egui --locked middle_drag_rotates_about_the_pointer_without_panning -- --nocapture` — [crates/ui-egui/src/rotate_view.rs / middle_drag_rotates_about_the_pointer_without_panning](https://github.com/storytold/photocraft/blob/f05098562220a663d052ee3918fd36c8c3e17a51/crates/ui-egui/src/rotate_view.rs) - - `cargo test -p photocraft-ui-egui --locked middle_drag_ignores_invalid_or_stationary_input -- --nocapture` — [crates/ui-egui/src/rotate_view.rs / middle_drag_ignores_invalid_or_stationary_input](https://github.com/storytold/photocraft/blob/f05098562220a663d052ee3918fd36c8c3e17a51/crates/ui-egui/src/rotate_view.rs) - -- Expected behavior: middle drag rotates about the pointer without panning; middle drag ignores invalid or stationary input. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`middle_drag_rotates_about_the_pointer_without_panning` setup/action excerpt: - -```rust -let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); - let pointer = pos2(600.0, 180.0); - for flip in [false, true] { - let mut view = View { zoom: 2.0, center: [130.0, 80.0], ..Default::default() }; - let xf = ViewXform { rect, zoom: 2.0, center: view.center, flip, rotation: 0.0, aspect: 1.25 }; - let original = xf.to_doc(pointer); - assert!(middle_drag(&mut view, &xf, pointer, 90.0)); - assert!((view.rotation - 31.5).abs() < 0.001); - let next = ViewXform { center: view.center, rotation: view.rotation, ..xf }.to_doc(pointer); - assert!((next[0] - original[0]).abs() < 0.001 && (next[1] - original[1]).abs() < 0.001, "{original:?} {next:?}"); - } - } -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(middle_drag(&mut view, &xf, pointer, 90.0)); - assert!((view.rotation - 31.5).abs() < 0.001); - let next = ViewXform { center: view.center, rotation: view.rotation, ..xf }.to_doc(pointer); - assert!((next[0] - original[0]).abs() < 0.001 && (next[1] - original[1]).abs() < 0.001, "{original:?} {next:?}"); - } - } -``` - -`middle_drag_ignores_invalid_or_stationary_input` setup/action excerpt: - -```rust -let mut view = View::default(); - let xf = ViewXform { - rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)), - zoom: 1.0, - center: view.center, - flip: false, - rotation: 0.0, - aspect: 1.0, - }; - assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), 0.0)); - assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), f32::NAN)); - assert!(!middle_drag(&mut view, &xf, pos2(f32::INFINITY, 120.0), 20.0)); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), 0.0)); - assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), f32::NAN)); - assert!(!middle_drag(&mut view, &xf, pos2(f32::INFINITY, 120.0), 20.0)); - assert_eq!(view.rotation, 0.0); - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-4547: fix(icons): PhotoCraft icon corner radius matches other Craft apps (#4478) - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -5451,177 +4310,6 @@ assert_eq!(icon.width, 256); - Tester/date: - Tested SHA: -## PR-4883: feat(ui): show selection and active-layer bounds in the status bar - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-ui-egui --locked status_bounds_follow_the_actual_selection_and_active_layer -- --nocapture` — [crates/ui-egui/src/chrome_ui.rs / status_bounds_follow_the_actual_selection_and_active_layer](https://github.com/storytold/photocraft/blob/ea500486fb40511d3b4782dfacbb89fbff846d8a/crates/ui-egui/src/chrome_ui.rs) - - `cargo test -p photocraft-ui-egui --locked status_dimensions_follow_ruler_units_and_document_resolution -- --nocapture` — [crates/ui-egui/src/chrome_ui.rs / status_dimensions_follow_ruler_units_and_document_resolution](https://github.com/storytold/photocraft/blob/ea500486fb40511d3b4782dfacbb89fbff846d8a/crates/ui-egui/src/chrome_ui.rs) - -- Expected behavior: status bounds follow the actual selection and active layer; status dimensions follow ruler units and document resolution. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`status_bounds_follow_the_actual_selection_and_active_layer` setup/action excerpt: - -```rust -let d = doc(); - let id = d.layers[0].id; - assert_eq!(status_bounds_text(&d, Some(id)), "Layer: X 0 Y 0 W 2400 H 1500"); - assert_eq!(status_bounds_text(&d, None), "No selection or layer"); - - let mut s = photocraft_engine::Session::new(); - s.execute("file.new", json!({"width": 120, "height": 80})).unwrap(); - s.execute("select.all", json!({})).unwrap(); - let doc = &s.active().unwrap().doc; - assert_eq!(status_bounds_text(doc, None), "Selection: X 0 Y 0 W 120 H 80"); - } -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(status_bounds_text(&d, Some(id)), "Layer: X 0 Y 0 W 2400 H 1500"); - assert_eq!(status_bounds_text(&d, None), "No selection or layer"); - - let mut s = photocraft_engine::Session::new(); - s.execute("file.new", json!({"width": 120, "height": 80})).unwrap(); - assert_eq!(status_bounds_text(doc, None), "Selection: X 0 Y 0 W 120 H 80"); - } -``` - -`status_dimensions_follow_ruler_units_and_document_resolution` setup/action excerpt: - -```rust -use photocraft_engine::prefs::{PointSize, Unit, UnitsAndRulers}; - let mut d = doc(); - d.resolution_dpi = 300.0; - for (unit, expected) in [ - (Unit::Pixels, "2400 px x 1500 px (300 ppi)"), - (Unit::Inches, "8 in x 5 in (300 ppi)"), - (Unit::Centimeters, "20.32 cm x 12.7 cm (300 ppi)"), - (Unit::Millimeters, "203.2 mm x 127 mm (300 ppi)"), - (Unit::Points, "576 pt x 360 pt (300 ppi)"), - (Unit::Picas, "48 pica x 30 pica (300 ppi)"), - (Unit::Percent, "100 % x 100 % (300 ppi)"), - ] { -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), expected); - for (key, _) in STATUS_INFO.iter().filter(|(key, _)| *key != "dimensions" && *key != "coordinates") { - assert_eq!(status_info_text_with_units(&d, key, "Brush Tool", "sRGB", &units), status_info_text(&d, key, "Brush Tool", "sRGB")); - } - } - d.resolution_dpi = 240.0; - assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), "722.7 pt x 451.7 pt (240 ppi)"); - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - -## PR-4890: Add scrollbar visibility preferences (#3262) - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-engine --locked scrollbar_visibility_round_trips_and_old_preferences_keep_the_theme_default -- --nocapture` — [crates/engine/src/prefs/tests.rs / scrollbar_visibility_round_trips_and_old_preferences_keep_the_theme_default](https://github.com/storytold/photocraft/blob/ab84cab7355ac999fb1be226950978bafec39605/crates/engine/src/prefs/tests.rs) - - `cargo test -p photocraft-ui-egui --locked scrollbar_visibility_applies_in_every_theme_and_survives_theme_changes -- --nocapture` — [crates/ui-egui/src/theme.rs / scrollbar_visibility_applies_in_every_theme_and_survives_theme_changes](https://github.com/storytold/photocraft/blob/ab84cab7355ac999fb1be226950978bafec39605/crates/ui-egui/src/theme.rs) - - `cargo test -p photocraft-ui-egui --locked hidden_scrollbars_keep_wheel_scrolling_available -- --nocapture` — [crates/ui-egui/src/theme.rs / hidden_scrollbars_keep_wheel_scrolling_available](https://github.com/storytold/photocraft/blob/ab84cab7355ac999fb1be226950978bafec39605/crates/ui-egui/src/theme.rs) - -- Expected behavior: scrollbar visibility round trips and old preferences keep the theme default; scrollbar visibility applies in every theme and survives theme changes; hidden scrollbars keep wheel scrolling available. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`scrollbar_visibility_round_trips_and_old_preferences_keep_the_theme_default` setup/action excerpt: - -```rust -let old = serde_json::from_value::(json!({"interface": {"uiFontSize": "small"}})).unwrap(); - assert_eq!(old.interface.scrollbar_visibility, ScrollbarVisibility::Default); - assert_eq!(choices("interface.scrollbarVisibility"), Some(ScrollbarVisibility::NAMES)); - let mut s = Session::new(); - for mode in ScrollbarVisibility::NAMES { - s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": mode}})).unwrap(); - let saved = s.prefs().to_json(); - let loaded: Preferences = serde_json::from_value(saved.clone()).unwrap(); - assert_eq!(loaded.interface.scrollbar_visibility, s.prefs().interface.scrollbar_visibility); - assert_eq!(saved["interface"]["scrollbarVisibility"], json!(mode)); - } - let before = s.prefs().to_json(); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(old.interface.scrollbar_visibility, ScrollbarVisibility::Default); - assert_eq!(choices("interface.scrollbarVisibility"), Some(ScrollbarVisibility::NAMES)); - let mut s = Session::new(); - for mode in ScrollbarVisibility::NAMES { - s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": mode}})).unwrap(); - assert_eq!(loaded.interface.scrollbar_visibility, s.prefs().interface.scrollbar_visibility); - assert_eq!(saved["interface"]["scrollbarVisibility"], json!(mode)); - } - let before = s.prefs().to_json(); - assert!(s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": "sometimes"}})).is_err()); - assert_eq!(s.prefs().to_json(), before); - s.execute("prefs.reset", json!({"path": "interface.scrollbarVisibility"})).unwrap(); - assert_eq!(s.prefs().interface.scrollbar_visibility, ScrollbarVisibility::Default); -} -``` - -`scrollbar_visibility_applies_in_every_theme_and_survives_theme_changes` setup/action excerpt: - -```rust -use photocraft_engine::prefs::ScrollbarVisibility as Mode; - let ctx = egui::Context::default(); - for kind in ThemeKind::ALL { - apply(&ctx, kind); - let original = ctx.global_style().spacing.scroll; - sync_scrollbars(&ctx, Mode::Default); - assert_eq!(ctx.global_style().spacing.scroll, original); - sync_scrollbars(&ctx, Mode::Always); - assert!(!ctx.global_style().spacing.scroll.floating); - assert!(ctx.global_style().spacing.scroll.bar_width > 0.0); - sync_scrollbars(&ctx, Mode::AutoHide); - assert!(ctx.global_style().spacing.scroll.floating); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(ctx.global_style().spacing.scroll, original); - sync_scrollbars(&ctx, Mode::Always); - assert!(!ctx.global_style().spacing.scroll.floating); - assert!(ctx.global_style().spacing.scroll.bar_width > 0.0); - sync_scrollbars(&ctx, Mode::AutoHide); - assert!(ctx.global_style().spacing.scroll.floating); - assert_eq!(ctx.global_style().spacing.scroll.dormant_handle_opacity, 0.0); - sync_scrollbars(&ctx, Mode::Hidden); - assert_eq!(ctx.global_style().spacing.scroll.bar_width, 0.0); - apply(&ctx, kind); - sync_scrollbars(&ctx, Mode::Hidden); - assert_eq!(ctx.global_style().spacing.scroll.bar_width, 0.0, "theme changes retain the saved override"); - sync_scrollbars(&ctx, Mode::Default); - assert_eq!(ctx.global_style().spacing.scroll, original); - } - } -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-4938: Layer Style: one dialog size on every page, and it fits short screens - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. @@ -6258,142 +4946,6 @@ assert!(validate(version).is_err(), "validator accepted invalid version {version - Tester/date: - Tested SHA: -## PR-9172: Gradient Fill layer: default to Foreground to Transparent (#8708) - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-engine --locked new_gradient_fill_layer_defaults_to_foreground_to_transparent -- --nocapture` — [crates/engine/src/gradient_fill_cmds/tests.rs / new_gradient_fill_layer_defaults_to_foreground_to_transparent](https://github.com/storytold/photocraft/blob/8ebb04999d25790f71f9bd88f7a268a6a2754aec/crates/engine/src/gradient_fill_cmds/tests.rs) - -- Expected behavior: new gradient fill layer defaults to foreground to transparent. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`new_gradient_fill_layer_defaults_to_foreground_to_transparent` setup/action excerpt: - -```rust -// #8708: Photoshop's Layer › New Fill Layer › Gradient… starts from Foreground to - // Transparent: the foreground at both ends, its opacity 100% to 0%. A `to` colour keeps a - // two-colour gradient. - let mut s = session(8, "transparent"); - s.tools.foreground = [0.2, 0.4, 0.6, 1.0]; - s.tools.background = [1.0, 1.0, 1.0, 1.0]; - s.execute("layer.newFillLayer.gradient", json!({})).unwrap(); - let Fill::Gradient { stops, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") }; - let fg = Color::rgba(0.2, 0.4, 0.6, 1.0); - assert_eq!(stops, vec![(0.0, fg), (1.0, fg)]); - assert_eq!(opacity_stops, vec![(0.0, 1.0), (1.0, 0.0)]); - s.execute("layer.newFillLayer.gradient", json!({"to": "#ff0000"})).unwrap(); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(stops, vec![(0.0, fg), (1.0, fg)]); - assert_eq!(opacity_stops, vec![(0.0, 1.0), (1.0, 0.0)]); - s.execute("layer.newFillLayer.gradient", json!({"to": "#ff0000"})).unwrap(); - let Fill::Gradient { stops, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") }; - assert_eq!(stops[1].1, Color::rgba(1.0, 0.0, 0.0, 1.0)); - assert!(opacity_stops.is_empty()); -} -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - -## PR-9282: feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer - -- Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. - - - `cargo test -p photocraft-ui-egui --locked adjustment_preset_menus_load_presets_and_read_custom_after_an_edit -- --nocapture` — [crates/ui-egui/src/adjust_dialog.rs / adjustment_preset_menus_load_presets_and_read_custom_after_an_edit](https://github.com/storytold/photocraft/blob/20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce/crates/ui-egui/src/adjust_dialog.rs) - - `cargo test -p photocraft-ui-egui --locked properties_preset_menus_load_every_setting_in_one_step -- --nocapture` — [crates/ui-egui/src/adjust_editors_tests.rs / properties_preset_menus_load_every_setting_in_one_step](https://github.com/storytold/photocraft/blob/20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce/crates/ui-egui/src/adjust_editors_tests.rs) - - `cargo test -p photocraft-ui-egui --locked preset_menus_say_they_are_photocrafts_own -- --nocapture` — [crates/ui-egui/src/adjust_editors_tests.rs / preset_menus_say_they_are_photocrafts_own](https://github.com/storytold/photocraft/blob/20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce/crates/ui-egui/src/adjust_editors_tests.rs) - - `cargo test -p photocraft-ui-egui --locked presets_apply_and_are_recognised -- --nocapture` — [crates/ui-egui/src/adjust_presets.rs / presets_apply_and_are_recognised](https://github.com/storytold/photocraft/blob/20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce/crates/ui-egui/src/adjust_presets.rs) - - `cargo test -p photocraft-ui-egui --locked presets_give_their_looks -- --nocapture` — [crates/ui-egui/src/adjust_presets.rs / presets_give_their_looks](https://github.com/storytold/photocraft/blob/20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce/crates/ui-egui/src/adjust_presets.rs) - -- Expected behavior: adjustment preset menus load presets and read custom after an edit; properties preset menus load every setting in one step; preset menus say they are photocrafts own; presets apply and are recognised; presets give their looks. Each listed test must actually execute its assertions; these are distinct behavior-specific oracles, not a single interchangeable guard. - -
Historical source context (deduplicated excerpts; not additional cases or execution evidence) - -`adjustment_preset_menus_load_presets_and_read_custom_after_an_edit` setup/action excerpt: - -```rust -use egui_kittest::kittest::{NodeT, Queryable}; - for (kind, label, key) in [ - ("levels", "Increase Contrast 2", "inBlack"), - ("exposure", "Plus 1.0", "exposure"), - ("blackWhite", "Red Filter", "reds"), - ("channelMixer", "Black & White with Red Filter (RGB)", "monochrome"), - ] { - 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_with_image()); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); - let id = open(h.state_mut(), &format!("image.adjustments.{kind}")).unwrap(); - h.run_steps(4); - let preset = |h: &Harness<'_, PhotocraftApp>| { -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(preset(&h).as_deref(), Some("Default"), "{kind} opens on Default"); - h.query_all_by_role(egui::accesskit::Role::ComboBox).next().unwrap().click(); - h.run_steps(2); - h.get_by_label(label).click(); - assert_eq!(preset(&h).as_deref(), Some(label), "{kind}: the menu shows the preset"); - let want = crate::adjust_presets::presets(kind, false) - .iter() - .find(|p| p.label == label) - assert_eq!(fields[k], *v, "{kind}: the preset's `{k}` is loaded"); - } - assert_eq!(fields[key], want[key]); - let edit = match kind { - "levels" => ("gamma", json!(1.37)), - "exposure" => ("offset", json!(0.05)), - assert_eq!(preset(&h).as_deref(), Some("Custom"), "{kind}: an edit is Custom"); - crate::dialogs::confirm(h.state_mut(), id).unwrap(); - } - } -``` - -`properties_preset_menus_load_every_setting_in_one_step` setup/action excerpt: - -```rust -use egui_kittest::kittest::NodeT; - for (kind, label) in - [("levels", "Increase Contrast 2"), ("exposure", "Plus 1.0"), ("blackWhite", "Red Filter"), ("channelMixer", "Black & White with Red Filter (RGB)")] - { - let mut h = app_harness(kind, "rgb", 8); - let steps = h.state().session.active().unwrap().history.past_len(); - let menu = h - .query_all_by_role(egui::accesskit::Role::ComboBox) - .find(|n| n.accesskit_node().value().as_deref() == Some("Default")) - .unwrap_or_else(|| panic!("{kind}: the Preset menu, at Default")); - menu.click(); - h.run_steps(2); -``` - -Expected assertion context (overlapping windows merged): - -```rust -assert_eq!(got[k], *v, "{kind}: the layer's `{k}`"); - } - assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "{kind}: one step"); - } -} -``` - -
- -- Status: NOT RUN -- Actual: -- Evidence: -- Tester/date: -- Tested SHA: - ## PR-9354: fix(ui): Full Screen Mode needs a document (#9349) - Fixture/setup and action: execute **all** identified changed regression functions below on the final frozen tree. Their checked-in setup constructs the concrete inputs; preserve those inputs, capability prerequisites and assertions. diff --git a/docs/integration-testing/trimmed-integration-2026-10-29.md b/docs/integration-testing/trimmed-integration-2026-10-29.md new file mode 100644 index 0000000000..17e91991ae --- /dev/null +++ b/docs/integration-testing/trimmed-integration-2026-10-29.md @@ -0,0 +1,35 @@ +# PhotoCraft trimmed integration, 2026-10-29 + +Removed all 21 requested PR contributions from the existing 108-PR integration, leaving 87 retained PRs. Reversals preserve the original branch history. Overlapping integration fixes are reconciled so unrelated retained work remains. + +## Removed PRs + +- [#3139](https://github.com/storytold/photocraft/pull/3139): Draw right-to-left layer names in visual word order +- [#3345](https://github.com/storytold/photocraft/pull/3345): Add grouped gradient presets and selectable style icons +- [#3967](https://github.com/storytold/photocraft/pull/3967): Add move and delete actions to Smart Filter rows +- [#4042](https://github.com/storytold/photocraft/pull/4042): feat(canvas): rotate view with Shift + middle-mouse drag +- [#4783](https://github.com/storytold/photocraft/pull/4783): Fix/4758 type extreme finite geometry 20261013s1215 +- [#4890](https://github.com/storytold/photocraft/pull/4890): Add scrollbar visibility preferences (#3262) +- [#5453](https://github.com/storytold/photocraft/pull/5453): Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1 +- [#5493](https://github.com/storytold/photocraft/pull/5493): Import Photoshop custom shape sets into the Shapes panel +- [#6621](https://github.com/storytold/photocraft/pull/6621): Crop: right-click on the pending crop opens the crop menu (#6561) +- [#8347](https://github.com/storytold/photocraft/pull/8347): Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment) +- [#9282](https://github.com/storytold/photocraft/pull/9282): feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer +- [#7742](https://github.com/storytold/photocraft/pull/7742): Actions: a fractional setting an integer field cannot hold is refused, not rounded +- [#5090](https://github.com/storytold/photocraft/pull/5090): Actions: add editable PhotoCraft Essentials starter presets +- [#3455](https://github.com/storytold/photocraft/pull/3455): Undo and redo action definition edits from the Actions panel +- [#1991](https://github.com/storytold/photocraft/pull/1991): feat(actions): import and export reusable Actions JSON files +- [#4305](https://github.com/storytold/photocraft/pull/4305): Filter > Other > Frequency Separation (#1714) +- [#5939](https://github.com/storytold/photocraft/pull/5939): fix(paint): scale blur sigma with brush size (#5892) +- [#4883](https://github.com/storytold/photocraft/pull/4883): feat(ui): show selection and active-layer bounds in the status bar +- [#9172](https://github.com/storytold/photocraft/pull/9172): Gradient Fill layer: default to Foreground to Transparent (#8708) +- [#7153](https://github.com/storytold/photocraft/pull/7153): Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace +- [#5870](https://github.com/storytold/photocraft/pull/5870): fix(format): reject duplicate native layer identities + +## Validation and testing + +New GitHub CI is required, including full-sweep Linux and Windows tests, corpus, WebAssembly and lint. Until that run completes, this trimmed source is not validated. The previous Windows portable ZIP still contains the removed changes and must not be used for acceptance of this branch. Manual UI checks are NOT RUN. + +Use the retained 87 cases in [the regression appendix](per-pr-regressions.md). Verify basic editing/save/reopen/Undo/Redo, retained layer-name visibility playback, guide rotation/deletion, pattern persistence, gradient editing and type layout after the removals. Verify the removed menus, preferences and shortcuts are absent or return to base behavior. Custom shape import and native layer-identity rejection are excluded; do not mark their old tests as passing. + +The previous build records and approval snapshot are retained only as historical provenance. The frozen retained head list is [here](trimmed-selection-2026-10-29.json). diff --git a/docs/integration-testing/trimmed-selection-2026-10-29.json b/docs/integration-testing/trimmed-selection-2026-10-29.json new file mode 100644 index 0000000000..02c75d5739 --- /dev/null +++ b/docs/integration-testing/trimmed-selection-2026-10-29.json @@ -0,0 +1,551 @@ +{ + "previous_head": "8f9f1a9481f274ae15f952fd946eab13007f592d", + "base": "5f9b4391874aabc9deb2fcb905267613e4d93ec8", + "retained_count": 87, + "removed_count": 21, + "removed": [ + { + "number": 3139, + "commit": "ab7b1412d0e62257049af1ce66f24710d86ef9b7", + "title": "Merge approved PhotoCraft PR #3139: Draw right-to-left layer names in visual word order" + }, + { + "number": 3345, + "commit": "5a5e2c48ddbd36bb98c1e4378e34dc3d906915c4", + "title": "Merge approved PhotoCraft PR #3345: Add grouped gradient presets and selectable style icons" + }, + { + "number": 3967, + "commit": "97dfeda00cbcd1399532db4dca7eb419a01f47b2", + "title": "Merge approved PhotoCraft PR #3967: Add move and delete actions to Smart Filter rows" + }, + { + "number": 4042, + "commit": "b753d0e942e67a5eb4d0dcefa9b1a53804ccfb8c", + "title": "Merge approved PhotoCraft PR #4042: feat(canvas): rotate view with Shift + middle-mouse drag" + }, + { + "number": 4783, + "commit": "7836d4b9e6914452cce273de75e786db1ae2932c", + "title": "Merge approved PhotoCraft PR #4783: Fix/4758 type extreme finite geometry 20261013s1215" + }, + { + "number": 4890, + "commit": "1daf3bc079e7105ee5a8536ebf1b41480ba0cfeb", + "title": "Merge approved PhotoCraft PR #4890: Add scrollbar visibility preferences (#3262)" + }, + { + "number": 5453, + "commit": "8323688e1f21cb4362e3386bd027bcc5d5c33806", + "title": "Merge approved PhotoCraft PR #5453: Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1" + }, + { + "number": 5493, + "commit": "c81347aaa86e7304fbcd11f873f5b4de1e910263", + "title": "Merge approved PhotoCraft PR #5493: Import Photoshop custom shape sets into the Shapes panel" + }, + { + "number": 6621, + "commit": "78f9c137b36b38fe90ebbebe2d3661a174a52622", + "title": "Merge approved PhotoCraft PR #6621: Crop: right-click on the pending crop opens the crop menu (#6561)" + }, + { + "number": 8347, + "commit": "a62b9ee21771e5d7d312cc2be02ba02a8d3b6383", + "title": "Merge approved PhotoCraft PR #8347: Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment)" + }, + { + "number": 9282, + "commit": "0fd326b1ccb0d548e78d5eeabcbf80aeb44d54b0", + "title": "Merge approved PhotoCraft PR #9282: feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer" + }, + { + "number": 7742, + "commit": "99cf87f5ec7fb6b25d121b6c29c33a83be020d68", + "title": "Merge approved PhotoCraft PR #7742: Actions: a fractional setting an integer field cannot hold is refused, not rounded" + }, + { + "number": 5090, + "commit": "2f6156bebe1271bfe64fe0e594cc68e62d792726", + "title": "Merge approved PhotoCraft PR #5090: Actions: add editable PhotoCraft Essentials starter presets" + }, + { + "number": 3455, + "commit": "6e13443ca662e262bd9f8c1eb33718c69259fc85", + "title": "Merge approved PhotoCraft PR #3455: Undo and redo action definition edits from the Actions panel" + }, + { + "number": 1991, + "commit": "45e790c2166c89db505bf7926982ebfddf6673ed", + "title": "Merge approved PhotoCraft PR #1991: feat(actions): import and export reusable Actions JSON files" + }, + { + "number": 4305, + "commit": "17c4dabf90b48ce284fb8b1fb6e5a151d5dd1777", + "title": "Merge approved PhotoCraft PR #4305: Filter > Other > Frequency Separation (#1714)" + }, + { + "number": 5939, + "commit": "cb1c76e6cf29015ca80683ae83b40562383c79f0", + "title": "Merge approved PhotoCraft PR #5939: fix(paint): scale blur sigma with brush size (#5892)" + }, + { + "number": 4883, + "commit": "bb436088f14e88a56762b2d320527a3b19f7ee2d", + "title": "Merge approved PhotoCraft PR #4883: feat(ui): show selection and active-layer bounds in the status bar" + }, + { + "number": 9172, + "commit": "a074b07b4b697de4887b09b4c4b6b4fbe21a090f", + "title": "Merge approved PhotoCraft PR #9172: Gradient Fill layer: default to Foreground to Transparent (#8708)" + }, + { + "number": 7153, + "commit": "fe2379f27fb1ebb53a10aeec79f57b47e387ea46", + "title": "Merge approved PhotoCraft PR #7153: Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace" + }, + { + "number": 5870, + "commit": "63fbd96e2be807496d458d406c5ed52fc5a56580", + "title": "Merge approved PhotoCraft PR #5870: fix(format): reject duplicate native layer identities" + } + ], + "retained": [ + { + "number": 9703, + "head": "3605d30c38294c4d970e52f4a02f44f97e8bc1a9", + "title": "Fix adjustment layer name clicks targeting masks" + }, + { + "number": 9651, + "head": "67666bdf72a58066df868868cef82e5b9cc838de", + "title": "perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426)" + }, + { + "number": 9623, + "head": "652075f4da7fe905ad651eebc3a6c276fe1d7a5d", + "title": "Pencil: keep a 1 px freehand line connected where it turns" + }, + { + "number": 9606, + "head": "6cc516a3d20120b678040050d95ccadc8d1dfd3f", + "title": "fix(ui): the Color panel remembers its options across restarts" + }, + { + "number": 9598, + "head": "47aea89e9220b996f0d5a0d2bb8b5938100b4ad1", + "title": "fix(ui): Liquify's Twirl Counterclockwise gets a mirrored twirl icon" + }, + { + "number": 9597, + "head": "9429df26b371c04c2afa65228a60ded739b5fbc2", + "title": "fix(ui): the layer list doesn't move with no layer selected" + }, + { + "number": 9596, + "head": "e62013cfd652fa60470a0f7a185fbc8f3d2a2c92", + "title": "fix(io): drop a kept Txt2 once no type layers are left" + }, + { + "number": 9595, + "head": "75fa71a4f55a098061b12d91508b514565ab8dcf", + "title": "fix(compose, gpu): a Pass Through group with Blend If renders isolated" + }, + { + "number": 9594, + "head": "46512521fec2b4b462c29d9fefd16398cb81212c", + "title": "fix(engine): copied type keeps its size across resolutions after an edit" + }, + { + "number": 9590, + "head": "a9744ed44dc782613b19a97b8c159a091f73e669", + "title": "fix(io): PSD layer-effect contours keep at most 16 points" + }, + { + "number": 9589, + "head": "da157945077190e550b53a5e14b0536f62761292", + "title": "fix(compose, gpu): an adjustment layer's This Layer range judges its result before Opacity" + }, + { + "number": 9584, + "head": "fc0f7b1132f799b5bba7be642c9047499d95a887", + "title": "fix(compose, gpu): Blend If hard points match Photoshop at 16 bits and for Gray" + }, + { + "number": 9523, + "head": "728782c595e768bdb6febc7f30e4d1456d13fd36", + "title": "Fix Type on a Path wrapping and caret positioning across closed-path seams" + }, + { + "number": 9517, + "head": "81b434c6149732eadc94e0465fea81bd075facd4", + "title": "fix(ui): Layer Style scrolls on a short window so OK stays on screen" + }, + { + "number": 9512, + "head": "6c55561128caf5ac917eb3d174e8e7bb4bd48e23", + "title": "fix(compose, gpu): adjustment layers apply Fill inside the special eight blend modes" + }, + { + "number": 9509, + "head": "d78d7493d3f0b6e760c88de969ac810e9bc5d910", + "title": "fix: an edit at full coverage keeps its result over huge 32-bit values" + }, + { + "number": 9504, + "head": "993c9ed167c6acd9bc8f87c6cff6e2c7abba17a8", + "title": "fix(ui): Keyboard Shortcuts records \u2318Z instead of undoing the capture" + }, + { + "number": 9493, + "head": "61a413d8df9e90ee545eb08e57c5615aa2b29cea", + "title": "fix(vector): a mask feather continues the mask past the canvas edge" + }, + { + "number": 9481, + "head": "7937adff961027154d795b8905e821244ae4f929", + "title": "fix(ui): new layer effects start from the Make Default values" + }, + { + "number": 9478, + "head": "cc208ee0440bbdf1858565dca49667272a490c56", + "title": "fix(text): vertical type sets roman letters upright, one per em, as Photoshop does (#9470)" + }, + { + "number": 9472, + "head": "4bab657cd4fb4a91ced44d3721e63a002ca5068c", + "title": "fix(io): import a Displace smart filter that names its map file without embedding it (#9162)" + }, + { + "number": 9457, + "head": "2863c3281c2a22dade0ce92d42bf8900074f76c6", + "title": "raw: faster tone stage (Shadows, Highlights, Clarity, Texture, Dehaze), same bytes" + }, + { + "number": 9436, + "head": "819f2692db4302019638c57b6ea34cd1e8f4c24b", + "title": "Startup: a failed launch says why in a dialog instead of closing silently (#7643)" + }, + { + "number": 9418, + "head": "da4b838e54f6073cda4798fa7b63b3b7214aaf93", + "title": "Color Lookup: honour the .3dl Mesh header's cube edge and output depth (#9078, #9073)" + }, + { + "number": 9416, + "head": "742b06805284d8d76d5a29ec1821193f4835fedf", + "title": "Affinity reader: contiguous curve records and a cheaper object tag index" + }, + { + "number": 9401, + "head": "f241bba1adfb9c7ec5dd5ed9d86f95b63c7bc2c3", + "title": "fix(format): a save on macOS keeps the file's creation time (#9388)" + }, + { + "number": 9354, + "head": "41eb1de5cff17f5f5b9c22913e48b71565b56a2a", + "title": "fix(ui): Full Screen Mode needs a document (#9349)" + }, + { + "number": 9342, + "head": "c46a74621d36adae9707ab8f3eb39cf6b4c302b9", + "title": "fix(io, engine): Embed Color Profile writes sRGB for an untagged document (#9325)" + }, + { + "number": 9339, + "head": "393645ab91460af2e37cc3ba8f6921e7551f676e", + "title": "fix(export): embed requested sRGB profile for untagged sources (#9325)" + }, + { + "number": 9110, + "head": "2e716f93b2e2135944e07d821be4eb38fce13ec9", + "title": "test: GPU tests honour WGPU_BACKEND, so they run on Windows with an Intel GPU (#4)" + }, + { + "number": 9019, + "head": "364c4be1c19728aefa601ee924bc7a0cd6a09789", + "title": "fix(xtask): reject unknown performance benchmark selections" + }, + { + "number": 9015, + "head": "afb4364a8795c42e6353762891b4890d878f9a68", + "title": "fix(xtask): reject leading zeroes in numeric prerelease identifiers" + }, + { + "number": 9004, + "head": "39cf823cdfe486bb967cf81df725d229f7eed903", + "title": "fix(ui): enlarge Move tool alignment icons for legibility" + }, + { + "number": 8986, + "head": "390dfbbcb026b33d46c52c62360b65deca00f258", + "title": "Path tools: edit the saved path selected in the Paths panel (#8478)" + }, + { + "number": 8985, + "head": "8e695caf885b529f01f482b3f0c90cdb34df1915", + "title": "Color panel and Color Picker: clip the round cursor to the colour field" + }, + { + "number": 8931, + "head": "d42dd4c6302c77c1f9dc46b7b53ffa5eb0047a13", + "title": "feat(algo): describe the Gaussian blur as data for other devices" + }, + { + "number": 8905, + "head": "0fdc6259193aab32b8feab7347ddc36196f40a19", + "title": "fix(plugins): bound a run's total plug-in memory, not just each instance" + }, + { + "number": 8903, + "head": "d387f52f1f2f5681b2c7d1c517aa5b5992c7bc88", + "title": "Box-filter minified patterns in the CPU and GPU samplers (#8831)" + }, + { + "number": 8897, + "head": "eb06798f15516eb7e4fac24c98346fd9052e7f8e", + "title": "fix(io): read linked smart objects for PSD export as regular files only" + }, + { + "number": 8896, + "head": "19fa473039ef11deda7c3200c8fac1de41309086", + "title": "Select and Mask: Photoshop's full-window workspace (#7069, #8148)" + }, + { + "number": 8892, + "head": "9267c4fcf83a260d4bae0e441dc1af9bc48aa23b", + "title": "ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte" + }, + { + "number": 8193, + "head": "864024609b872de3d869879f1632b951c896c6cd", + "title": "Filter Gallery \u203a Cutout 3/4: edges and curves stage" + }, + { + "number": 8192, + "head": "412e3b7cf1f4836a5772d93391d21728dbe1f74f", + "title": "Filter Gallery \u203a Cutout 2/4: regions stage" + }, + { + "number": 8191, + "head": "91e4cec43525ca739c05cff614701fbd3576e389", + "title": "Filter Gallery \u203a Cutout 1/4: colour and palette stage" + }, + { + "number": 8074, + "head": "290f924d0fecc3bf6bea159c0d9e86255d326a96", + "title": "Transform: remember selected interpolation (#8067)" + }, + { + "number": 8039, + "head": "5579cf5439856fed93f41a669a3fc9b56d5813cf", + "title": "Compose: Extend Softer outer glows past Size (#7899)" + }, + { + "number": 7903, + "head": "5c30699087b02edcaa23397a0f7076bf503be048", + "title": "Filter Gallery \u203a Cutout 4/4: trace the image into flat paper shapes" + }, + { + "number": 7848, + "head": "389a1d5bd67072d5581c27989080222a7065b585", + "title": "fix(retouch): preserve Smudge pickup with live-pixel stroke commits" + }, + { + "number": 7743, + "head": "fd6fc92015fd08560362b4b32844edf97722f292", + "title": "packaging: the portable zip holds the files, not a folder with the files in it" + }, + { + "number": 7648, + "head": "d24669d412f64ab079a668b0b8520ddbc607410c", + "title": "Pen: Path Operations menu, as in Photoshop (#1397)" + }, + { + "number": 7255, + "head": "1b810052bc3a876c1eaf1a8766870ffdba9f8a80", + "title": "fix(engine): guard trim and duplicate merged against documents exceeding render limits (#7223)" + }, + { + "number": 6510, + "head": "8bb0a5e67474d36ae08d565251edb1ae780dd044", + "title": "Automation: resolve configured root aliases for absolute paths (#6426)" + }, + { + "number": 6492, + "head": "657cfd503d4ce276df6b5e9f763cb3a5cf2deaeb", + "title": "Fail control startup promptly when its port is occupied" + }, + { + "number": 6449, + "head": "25c38a0b69b7bff31badcdbe2ef9f2ff6aba1c63", + "title": "Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop" + }, + { + "number": 6387, + "head": "8bfbbf816025942b74130adf16fb07265817b691", + "title": "Video Frames to Layers: every page of a multi-page TIFF (part of #6379)" + }, + { + "number": 6383, + "head": "74aadae69e3b3f5cd257bca1926acc1b1297b1a2", + "title": "Windows releases ship a symbols zip: the release build's PDBs for profilers (#688)" + }, + { + "number": 6236, + "head": "ce546dd4b5451a17b843ef1e129bdb40fbfe3160", + "title": "Shift+Alt+right-click opens Photoshop's HUD Color Picker (#6098)" + }, + { + "number": 6139, + "head": "e2347f8ead85d929193044f139788b850e31c06f", + "title": "Fix painting workflows, File Info and layer selection" + }, + { + "number": 6026, + "head": "248ba660576f4896f69cbb6639353e2f1d295271", + "title": "Marquee: Fixed Size selects exactly the entered width and height (fixes #6015)" + }, + { + "number": 5744, + "head": "50b1f6734799cf7b5d45dd84868976afd31aa711", + "title": "fix(ui): keep detached documents in one interactive window" + }, + { + "number": 5650, + "head": "4b1bd6fb6d264f885cb9caa3b8a6578ee527bd7b", + "title": "Guides: \u2325 turns a dragged guide; dropping one on either ruler deletes it (#5034)" + }, + { + "number": 5515, + "head": "375d4894a119b6cd5933d53e8fe7f506d2783392", + "title": "Selecting subject now automatically applies to selected layer, and us\u2026" + }, + { + "number": 5270, + "head": "9db1c7ae1d48ce4f31622b5f9029c70c590842f8", + "title": "Layer Style: Gradient Overlay takes any number of colour stops (#5177)" + }, + { + "number": 5176, + "head": "c96891c298151c5cb11a55928b9e1c27911f38bd", + "title": "Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale" + }, + { + "number": 5165, + "head": "27bc6e4b37b701480efa69559ab13eafc52bd94c", + "title": "Fix: brush section selection" + }, + { + "number": 5082, + "head": "23bab3cc9204568aaeb012b9e1da70a2caacb879", + "title": "fix(mixer-brush): Alt-click samples the foreground colour like the Brush (#4873)" + }, + { + "number": 5010, + "head": "cc865221894e21f846d92b721cce7421ea338363", + "title": "Layer Style: Photoshop's layout (three columns, OK/Cancel/New Style/Preview on the right, one-line rows) [depends on #4938]" + }, + { + "number": 4993, + "head": "81964dc5ef77a71a2ae2a938958cf3ed77d90719", + "title": "Prevent control-channel engine commands from editing behind modal dialogs" + }, + { + "number": 4992, + "head": "194a8976cd6c14441222b287b9b4f4841a0eb2f9", + "title": "Preserve Dissolve coverage when compositing clipped layers" + }, + { + "number": 4985, + "head": "1dc98f80f60e993f3edf20af815391b6a7e7add7", + "title": "feat(ui): add Shift+right-click brush mode picker" + }, + { + "number": 4938, + "head": "63b7c921634835259139e4d3e7b9d3c396998b4d", + "title": "Layer Style: one dialog size on every page, and it fits short screens" + }, + { + "number": 4763, + "head": "f2804a7cf013519d9ba8bac1da3d96458c4f656b", + "title": "refactor(liquify): reuse native brush resizing and anchored HUD feedback" + }, + { + "number": 4742, + "head": "b7145b5a8afb85d0eef3922931d6749436966943", + "title": "fix(macos): bundle photocraft-cli and resolve it from droplet scripts" + }, + { + "number": 4711, + "head": "db546ff4822a011a7915b66af3834f94c05767db", + "title": "Treat Dissolve as a per-pixel choice" + }, + { + "number": 4547, + "head": "a08f7a2295488c2692f68902835a693322017b64", + "title": "fix(icons): PhotoCraft icon corner radius matches other Craft apps (#4478)" + }, + { + "number": 4035, + "head": "15ebf8fc940031abdf772e5fdd687c8b55938639", + "title": "fix(cmyk): Flatten Image and merges keep the inks of a tagged CMYK document (#3778)" + }, + { + "number": 4023, + "head": "303a5346fcb5961cf3a776024db58507b401e4ea", + "title": "i18n(es): add 548 missing rows, fix five filter and video terms" + }, + { + "number": 3874, + "head": "92488307722c3b840507827cac7ee9a88470fc8a", + "title": "fix(layer-style): support Blend If Alt-split handle feathering and throttle live preview" + }, + { + "number": 3748, + "head": "c45d01eafe84e600bb28033a6853cc19c827b7bf", + "title": "control: suggest near matches for unknown command ids and methods (#3674)" + }, + { + "number": 3554, + "head": "dd21e325b515cbf67fd4a461ae5b3e1b195aa53d", + "title": "fix(packaging): launch bundled CLI directly from AppImage" + }, + { + "number": 3520, + "head": "d36ec9a21e9c1ed70de2e0d185efd7e084644157", + "title": "fix(ui): restore square color chips in Studio Dark theme" + }, + { + "number": 3519, + "head": "807de6c043a512cb947d86b944c758b3db1277c7", + "title": "feat(export): optionally trim transparent margins from layer files" + }, + { + "number": 3464, + "head": "1eecd00b2dff4ae4c642269cec211cd06ce1c3aa", + "title": "fix(vector): target selected saved paths and own Pen gestures" + }, + { + "number": 3461, + "head": "5d8cb2a39ad70e31260f1f3fc2010ca132daff65", + "title": "Replay recorded layer visibility on the intended named layer" + }, + { + "number": 3387, + "head": "58b83f42a4b39cf56751be53ec8d227efee70f68", + "title": "feat(codecs): add lossless JPEG XL export for 8/16-bit images" + }, + { + "number": 3142, + "head": "e2ed234e654e574109ffcdb9756f8e8d1b8a00b2", + "title": "Read applied Sony RAW metadata and crop ILCE-6000 images" + }, + { + "number": 2300, + "head": "ebf3777aec961d1815aa141d9987eaa645c2273f", + "title": "Scroll the viewport while outlining with Polygonal Lasso" + } + ], + "validation": "New combined CI pending; previous 108-PR CI and Windows artifact do not validate this trimmed source. Manual tests NOT RUN." +} diff --git a/docs/integration-testing/windows-portable-test-protocol.md b/docs/integration-testing/windows-portable-test-protocol.md index 922d8f76c1..4e1cb9affd 100644 --- a/docs/integration-testing/windows-portable-test-protocol.md +++ b/docs/integration-testing/windows-portable-test-protocol.md @@ -1,3 +1,5 @@ +> HISTORICAL 108-PR BUILD: this record describes the earlier source and artifact. The current branch has 21 requested PRs removed. See [trimmed integration](trimmed-integration-2026-10-29.md) for current scope and validation. Do not use the old portable artifact to validate these removals. + # PhotoCraft Windows portable test protocol TEST BUILD — manual results are NOT RUN until a tester records them. This sheet is the entry point; the per-PR appendix remains the detailed regression specification. diff --git a/docs/scorecard.md b/docs/scorecard.md index 89bbe72361..a41a9b4eff 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -246,7 +246,6 @@ Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace. | TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | done | [#218](https://github.com/storytold/photocraft/issues/218) | Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs:503 warp_defaults; warp_text_dialog_tests.rs:80 warp_text_opens_with_the_type_layers_current_warp, :99 warp_text_opens_at_none_for_a_layer_without_warp, :108 remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (:138 warp_text_dialog_previews_the_warp_without_editing_the_document, :160 cancelling_warp_text_leaves_the_document_and_history_untouched, :179 confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs:2150 warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy) | | TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) | | TYPE-218-9 | Distort/Perspective/Warp on live type as in Photoshop (was: offer 'Rasterize and continue?'; Photoshop doesn't prompt) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Photoshop 2026 prompts for none of these: Edit › Transform › Warp on live type stays enabled with no rasterize prompt and offers only the warp styles (the mesh can't be dragged), so the type stays editable; Distort, Perspective and the split warps are greyed. PhotoCraft does the same: Warp on type commits the layer's Warp Text in one step (transform_tool.rs enter_type_warp, warp_cmds.rs warp_type_layer, #3004; warp_on_type_takes_a_style_and_keeps_the_type_editable), Distort/Perspective greyed (enable_rules.rs, #2701) | -| TYPE-218-12 | Roman Hanging Punctuation: trailing marks hang past the box edge and don't steer the alignment | partial | [#218](https://github.com/storytold/photocraft/issues/218) | The Paragraph panel's checkbox (Photoshop's Roman Hanging Punctuation) and a Paragraph Styles editor row set the flag per paragraph through type.setStyle ("hanging":bool; type_cmds.rs apply_para_props). Layout (text layout.rs): on point text and on centered/right box lines a trailing run of marks trims out of the aligned width with Photoshop's weights (period/comma/quote/apostrophe/hyphen 100%, colon/semicolon/ellipsis/dash 50%, `!` and `?` aren't in the list; a merged marks-only row steps back onto the line above to hang past the box edge, but only the edge tip: rows of at most two clusters, once per row (no chaining: a run the box split over rows keeps its rows instead of running past the edge by almost the whole run, and the last line of a justified paragraph keeps its align instead of the merge-back's spread)), so a centered line ending in an ellipsis stays centered. Missing: left and justified box lines keep their marks inside the margin and the line breaker still counts the marks (UAX #14 LB13 has no break before the marks), so a box wraps a mark-glued word whole instead of letting its mark hang off the line above — the hanging run must come out of the break measure and the justify path. RTL, vertical type and area type lines don't hang. PSD round-trips /Hanging in the paragraph sheet (text psd.rs para_style/paragraph_properties; tests: hanging_marks_do_not_steer_the_alignment, hanging_only_the_mark_list_trims, hanging_keeps_marks_after_a_forced_break_on_their_row, a_hanging_run_steps_back_onto_the_line_above, hanging_marks_of_a_split_run_keep_their_breaks, hanging_marks_stay_off_a_justified_paragraphs_last_line, hanging_centers_the_box_content_behind_its_marks, the engine's set_style_paragraph_hanging_round_trips, the Paragraph-panel harness test character_panel_hanging_checkbox_toggles_the_paragraph_style, io/tests/psd_style_sheets.rs hanging_punctuation_comes_from_the_paragraph_sheet) | | TYPE-218-10 | Area type: Type tool click inside a closed path or shape wraps text inside it, holes and dents included, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Area (doc text.rs) laid out line by line into the spans the path leaves (text area.rs, layout.rs break_area), type.fillPath (engine type_area_cmds.rs), released to box text by type.detachFromPath, area/path cursors and click-to-place (ui-egui type_tool.rs path_target), .pcraft round trip. Missing: PSD area type (saves as paragraph text over the path's bounds, with an export warning); mixed type sizes in one area paragraph space at its largest line; area type takes paths of at most 4096 anchors: beyond that a Type tool click makes point text (the pointer shows the plain I-beam), and type.fillPath refuses the path with "the path has too many anchor points" (type.attachToPath still takes a subpath of up to 100,000 anchors); a word too wide for every span in the shape overflows its span (box text breaks it at the edge, #6694); the first line starts about 0.24 em lower than in a text box of the same size; Photoshop area type in a PSD opens as box text; the area path has no handles to edit it after the type is placed | ## Automation diff --git a/docs/ui-design.md b/docs/ui-design.md index 71887e89bd..58e07207f1 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -166,12 +166,6 @@ On Windows and Linux, System Title Bar displays “Applies at next launch.” be including before editing and after Apply. Saving this preference does not restart the app or change the current window's decorations; the next launch reads the saved choice. -Interface → Scrollbar Visibility applies immediately. Default follows the selected theme. -Always keeps scrollbars expanded when the content needs them; Auto Hide uses overlay bars -that appear while hovering or scrolling. Hidden removes the bars while keeping wheel, -trackpad and keyboard scrolling available. The setting also applies to the canvas in -Standard Screen Mode and survives restarts and theme changes (#3262). - ## High DPI and 4K displays Edit → Preferences → Interface → UI Scale applies immediately. Auto follows the operating diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f462468ec6..db5e603b78 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -170,7 +170,6 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Ruler corner and gaps drawn as Photoshop | partial | #2600, #2601, #2551 | | Guides stored as f64 | missing | `Vec` (UI-217-20) | | Geometry precision | partial | `geom::Point` f64, paths f64; paint internals mostly f32 | -| Scrollbar visibility preference | done | Default, Always, Auto Hide and Hidden apply to panels and the canvas; live settings, theme changes and scrolling are covered (#3262) | | Numeric fields: arithmetic, scrubby labels | partial | units in expressions and double-click reset missing (UI-217-13) | | Wheel steps a value field, ⇧ ×10: clicked into, wherever the pointer is; not clicked, a gesture that starts over it | partial | `field_wheel.rs` (#5473, #4061). Photoshop: a field takes the wheel once clicked into (shortcut guides, user reports); whether the pointer must stay over it is unconfirmed. Stepping on hover alone is Photopea's, kept deliberately | | Type options bar: drag the "T" icon to change the font size | done | `type_tool.rs` `options_bar`: the icon scrubs the size field like the Character panel's icons (Shift x10, Alt fine), at layer or selection scope, one history step per drag, within 1–1296 pt (#6437) | @@ -249,7 +248,6 @@ Paragraph; the rest open as in-app floating windows. | Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes; with a row selected, the Delete button and the ⌫/Delete key remove that filter, not the smart object | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271), `smart_ui::keys` (#4751) | | Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | | Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` | -| Paragraph panel: Roman Hanging Punctuation checkbox (trailing marks hang past the box edge, a centered line ending in an ellipsis keeps its centering); the Paragraph Styles editor carries the attribute too | partial (2026-10-25) | Differs: left and justified box lines keep their trailing marks inside the margin, and the line breaker still counts the marks, so a box can wrap a mark-glued word whole instead of letting the mark hang from the line above (UAX #14 LB13 has no break before `, . : ; …`): leaving the hanging run out of the break measure and the justify path is the next step. Photoshop's list is in (period/comma/quote/apostrophe/hyphen 100%, colon/semicolon/ellipsis/dash 50%, `!` and `?` not in the list); the merge-back has the edge-tip width limit now — a marks row steps back only while it is at most two clusters and the row above hasn't already taken a hang, so a run the box had to split keeps its rows (no fold of the whole run past the edge), and a justified paragraph's last line keeps its own align (the merge would carry the spread); RTL lines, vertical type and area type lines don't hang (vertical is Burasagari's field); marks drop optical kerning and decorations when stepped back onto the line above (`type_tool.rs` type_sections, `type_panels_ui.rs`, `text/layout.rs`, `io/tests/psd_style_sheets.rs` `hanging_punctuation_comes_from_the_paragraph_sheet`) | ## Revision history @@ -261,17 +259,13 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-25 | minor | Every preference takes effect: 12 of 178 do nothing, the scorecard's count (it still said 46 of 149) | | 2026-10-25 | minor | Art History Brush is in the History group of the Y flyout (partial, TOOL-213-20, #1610) | | 2026-10-24 | minor | Color panel: Hue Cube and Brightness Cube show Photoshop's out-of-gamut ⚠ and the nearest printable colour under the chips when the field is at least 53 pt tall, as the slider modes did; a click takes it (#8672). No percentage changed | -| 2026-10-24 | minor | Levels, Exposure, Black & White and Channel Mixer gain a Preset menu (Custom after an edit) in the dialog and Properties, with the preset names Photoshop uses. The settings are PhotoCraft's own, designed for each look, not Photoshop's preset data (#9263, #9265, #9268, #9270). Scores unchanged. | -| 2026-10-25 | minor | Roman Hanging Punctuation merge-back review: the edge-tip width limit — a marks row steps back only while it is at most two clusters, a row above that already ends in a mark takes no more (a split run keeps its rows instead of folding past the edge), and the last line of a justified paragraph keeps its align instead of the merge-back's spread; left/justified trim is still open | | 2026-10-24 | minor | Swatches: a click recolours the selected type layers and shape fills whatever the tool, as Photoshop 2025 does (measured with the Move and Brush tools); the Color panel still recolours them only with a Type, shape or Pen tool (#9238). Scores unchanged. | | 2026-10-24 | minor | Type options bar: the alignment and paragraph-direction buttons show the edited or selected type layer's paragraph instead of the tool defaults, as Photoshop's bar does. Scores unchanged. | | 2026-10-24 | minor | Font menus (options bar, Character panel): the current family is marked in the recent group when it is a recent one, and the menu opens showing it, as Photoshop's does, scrolled to the marked row when it sits below the fold; the arrows walk on from the marked row (#9160). Scores unchanged. | | 2026-10-24 | minor | Properties › Transform shows and edits a shape layer's path geometry (W/H/X/Y), not the pixel bounds its stroke adds, as in Photoshop; W/H scale the path to exactly the typed size. Scores unchanged. | -| 2026-10-24 | minor | Roman Hanging Punctuation follow-up review: Photoshop's hang list with weights (period/comma/quote/apostrophe/hyphen 100%, colon/semicolon/ellipsis/dash 50%, `!` and `?` out), a forced break keeps its marks-only row, the kerning style in the merge-back reads the mapped byte, the box slack no longer scales the trimmed advance twice, area type keeps its rows; left/justified trim is still open | | 2026-10-22 | minor | Crop default mode: a drag inside an upright box snaps the frame's edges and centre to the document, layers, guides and grid, as a Classic Mode frame's move does (#8580); no percentage changed | | 2026-10-21 | minor | Adjustment clipping moved above Properties editors to remain visible in compact docks (#5231); scope, undo/redo and 8/16/32-bit pixel regressions; readiness estimate unchanged | | 2026-10-19 | minor | Pen, Freeform Pen and Curvature Pen Path Operations menu (#1397): the operation a new path is added with, and Merge Shape Components for paths (`path.mergeShapeComponents`); row stays partial until the Shape tools and Path Selection have it; no percentage changed | -| 2026-10-21 | minor | Paragraph panel: Roman Hanging Punctuation done — layout hang + merge-back, panel checkbox, PSD `/Hanging` round trip | | 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) | | 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed | | 2026-10-17 | minor | Object Finder foreground/background discrimination (#6548) | @@ -287,7 +281,6 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | ⌥ while dragging a guide turns it horizontal ↔ vertical, as in Photoshop; a guide dropped on either ruler is deleted (#5034) | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | | 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped | -| 2026-10-13 | minor | Scrollbar visibility preference for panels and the canvas (#3262), with live application, theme retention and wheel-scrolling coverage | | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | | 2026-10-12 | minor | New Group button groups two or more selected layers (CC 2014+); New Group dialog (Layer › New, Layers panel menu, row menu, ⌥-click); ⌘-click New Group adds the group below; groups made from layers start collapsed (#2561) | | 2026-10-12 | minor | New layers go into an open active group (or the artboard their content is on) and join the clip group they land in (#2203); ⌘-click New Layer adds below | diff --git a/scorecard/type.toml b/scorecard/type.toml index e8fc54c6fb..c119ac984d 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -67,15 +67,9 @@ status = "done" issue = 218 note = "Photoshop 2026 prompts for none of these: Edit › Transform › Warp on live type stays enabled with no rasterize prompt and offers only the warp styles (the mesh can't be dragged), so the type stays editable; Distort, Perspective and the split warps are greyed. PhotoCraft does the same: Warp on type commits the layer's Warp Text in one step (transform_tool.rs enter_type_warp, warp_cmds.rs warp_type_layer, #3004; warp_on_type_takes_a_style_and_keeps_the_type_editable), Distort/Perspective greyed (enable_rules.rs, #2701)" -[[item]] -id = "TYPE-218-12" -target = "Roman Hanging Punctuation: trailing marks hang past the box edge and don't steer the alignment" -status = "partial" -issue = 218 -note = "The Paragraph panel's checkbox (Photoshop's Roman Hanging Punctuation) and a Paragraph Styles editor row set the flag per paragraph through type.setStyle (\"hanging\":bool; type_cmds.rs apply_para_props). Layout (text layout.rs): on point text and on centered/right box lines a trailing run of marks trims out of the aligned width with Photoshop's weights (period/comma/quote/apostrophe/hyphen 100%, colon/semicolon/ellipsis/dash 50%, `!` and `?` aren't in the list; a merged marks-only row steps back onto the line above to hang past the box edge, but only the edge tip: rows of at most two clusters, once per row (no chaining: a run the box split over rows keeps its rows instead of running past the edge by almost the whole run, and the last line of a justified paragraph keeps its align instead of the merge-back's spread)), so a centered line ending in an ellipsis stays centered. Missing: left and justified box lines keep their marks inside the margin and the line breaker still counts the marks (UAX #14 LB13 has no break before the marks), so a box wraps a mark-glued word whole instead of letting its mark hang off the line above — the hanging run must come out of the break measure and the justify path. RTL, vertical type and area type lines don't hang. PSD round-trips /Hanging in the paragraph sheet (text psd.rs para_style/paragraph_properties; tests: hanging_marks_do_not_steer_the_alignment, hanging_only_the_mark_list_trims, hanging_keeps_marks_after_a_forced_break_on_their_row, a_hanging_run_steps_back_onto_the_line_above, hanging_marks_of_a_split_run_keep_their_breaks, hanging_marks_stay_off_a_justified_paragraphs_last_line, hanging_centers_the_box_content_behind_its_marks, the engine's set_style_paragraph_hanging_round_trips, the Paragraph-panel harness test character_panel_hanging_checkbox_toggles_the_paragraph_style, io/tests/psd_style_sheets.rs hanging_punctuation_comes_from_the_paragraph_sheet)" [[item]] id = "TYPE-218-10" target = "Area type: Type tool click inside a closed path or shape wraps text inside it, holes and dents included, PSD round trip" status = "partial" issue = 218 -note = "TextShape::Area (doc text.rs) laid out line by line into the spans the path leaves (text area.rs, layout.rs break_area), type.fillPath (engine type_area_cmds.rs), released to box text by type.detachFromPath, area/path cursors and click-to-place (ui-egui type_tool.rs path_target), .pcraft round trip. Missing: PSD area type (saves as paragraph text over the path's bounds, with an export warning); mixed type sizes in one area paragraph space at its largest line; area type takes paths of at most 4096 anchors: beyond that a Type tool click makes point text (the pointer shows the plain I-beam), and type.fillPath refuses the path with \"the path has too many anchor points\" (type.attachToPath still takes a subpath of up to 100,000 anchors); a word too wide for every span in the shape overflows its span (box text breaks it at the edge, #6694); the first line starts about 0.24 em lower than in a text box of the same size; Photoshop area type in a PSD opens as box text; the area path has no handles to edit it after the type is placed" \ No newline at end of file +note = "TextShape::Area (doc text.rs) laid out line by line into the spans the path leaves (text area.rs, layout.rs break_area), type.fillPath (engine type_area_cmds.rs), released to box text by type.detachFromPath, area/path cursors and click-to-place (ui-egui type_tool.rs path_target), .pcraft round trip. Missing: PSD area type (saves as paragraph text over the path's bounds, with an export warning); mixed type sizes in one area paragraph space at its largest line; area type takes paths of at most 4096 anchors: beyond that a Type tool click makes point text (the pointer shows the plain I-beam), and type.fillPath refuses the path with \"the path has too many anchor points\" (type.attachToPath still takes a subpath of up to 100,000 anchors); a word too wide for every span in the shape overflows its span (box text breaks it at the edge, #6694); the first line starts about 0.24 em lower than in a text box of the same size; Photoshop area type in a PSD opens as box text; the area path has no handles to edit it after the type is placed" diff --git a/scorecard/ui.toml b/scorecard/ui.toml index cf38b2e050..cb73dd0c57 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -59,7 +59,7 @@ id = "UI-217-8" target = "Tool-specific canvas right-click menu; right-click spelling suggestions in text fields" status = "partial" issue = 217 -note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); the Crop tool opens its menu on a pending frame (ratios, saved crop presets, gear options, Reset/Cancel/Commit, #6561); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" +note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" [[item]] id = "UI-217-9"