diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c3597f5ee..d0f8c9770d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,7 +162,7 @@ jobs: PHOTOCRAFT_NO_PRINT=1 LPDEST=photocraft-no-such-printer timeout-minutes: 100 - # fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with + # fmt, clippy, layers, panel-commands, scorecard, tests, panic_hunt, doctests and the wasm32 check, with # `--locked --features heif`, then the missing-translations report (xtask/src/main.rs). command: python3 scripts/test-ci-gpu-lavapipe.py && cargo xtask ci && python3 scripts/ci-gpu-lavapipe.py diff --git a/AGENTS.md b/AGENTS.md index 70cbd9e355..e2b8e229a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,7 @@ When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower cargo test -p cargo clippy -p --all-targets -- -D warnings cargo xtask layers +cargo xtask panel-commands # if you touched ui-egui: click handlers dispatch command ids (rule 1) cargo xtask wasm # if you touched L0–L6 cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 42ef6f3aa3..f11c7f30e0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -38,6 +38,7 @@ next to them. | `crates/ui-egui/src/i18n/ro.tsv` | Romanian UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/th.tsv` | Thai UI translations | @Thanawatttt and PhotoCraft contributors, with opencode (DeepSeek) as an AI contributor | Machine-assisted first draft from PhotoCraft's English labels, reviewed for UI terminology | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/ta.tsv` | Tamil UI translations | @newsbaba24x7 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ka.tsv` | Georgian UI translations | @devius with Claude Code, AI-assisted | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | diff --git a/Cargo.lock b/Cargo.lock index cec58ffb71..2fbcbb11d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5242,6 +5242,7 @@ dependencies = [ "clap_complete", "clap_mangen", "photocraft-automation", + "photocraft-cms", "photocraft-codecs", "photocraft-doc", "photocraft-engine", diff --git a/README.md b/README.md index b1b453a318..c1789ab7d2 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 68 tools

- Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 69 tools

+ Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Star · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 4542a71da9..4ddde07142 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -47,6 +47,8 @@ serde = { workspace = true } tempfile = "3" # Reading back a saved PSD's merged-image flag (#8064). photocraft-psd = { workspace = true } +# A real ICC profile for the --metadata test (#9466). +photocraft-cms = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } # Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 87c0b5599c..145bef30c9 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -17,7 +17,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars}; use photocraft_codecs::{JpegFormat, TiffCompression}; -use photocraft_io::{DdsCompression, DpxBits, TgaBits}; +use photocraft_io::{DdsCompression, DpxBits, TgaBits, XmpEmbed}; use serde_json::Value; /// photocraft-cli: headless PhotoCraft @@ -93,6 +93,12 @@ pub(crate) struct ExportArgs { /// real image #[arg(long)] pub(crate) no_maximize_compatibility: bool, + /// The document metadata a flat export embeds: all (the default) keeps the document's XMP + /// packet and free-form text where the format holds them, as Save As does; none leaves them + /// out, as Export As's Metadata: None. Nothing else changes: the ICC profile, EXIF and + /// resolution are written as they are without it. PSD, PSB and .pcraft always keep the XMP + #[arg(long, value_name = "none|all", value_parser = metadata)] + pub(crate) metadata: Option, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) @@ -312,6 +318,8 @@ enum ValueError { TiffCompression, #[error("expected baseline, optimized or progressive")] JpegFormat, + #[error("expected none or all")] + Metadata, #[error("expected on or off")] OnOff, /// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused. @@ -386,6 +394,14 @@ fn jpeg_format(s: &str) -> Result { JpegFormat::parse(s).ok_or(ValueError::JpegFormat) } +fn metadata(s: &str) -> Result { + match s { + "none" => Ok(XmpEmbed::None), + "all" => Ok(XmpEmbed::All), + _ => Err(ValueError::Metadata), + } +} + /// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:` for a port. #[derive(Clone)] pub(crate) enum Bridge { diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 05861d7477..1628e8a5cb 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -34,6 +34,10 @@ impl From<&ExportArgs> for ExportOptions { } o.encode.dds.mipmaps = a.dds_mipmaps; o.psd_maximize_compatibility = !a.no_maximize_compatibility; + // Conversions keep the whole packet unless asked, as Save As does (#733, #9466). + if let Some(m) = a.metadata { + o.xmp = m; + } o } } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index a0c72c39b2..2cb64e6e55 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -980,6 +980,88 @@ fn jpeg_format_chooses_the_scans() { } } +/// #9466: `--metadata none` leaves the document's XMP packet and free-form text out of a flat +/// export from `convert`, `run --out` and `batch`, as Export As's Metadata: None does, and changes +/// nothing else (pixels, ICC profile, resolution). The default, and `all`, keep them as before (the +/// text where the format holds it, as PNG does), and a PSD or `.pcraft` keeps the XMP either way. +/// A value it doesn't name is a usage error and nothing is written. +#[test] +fn metadata_none_leaves_out_the_document_xmp() { + const XMP: &str = "\ +\ +xmp.did:11111111-2222-3333-4444-555555555555\ +"; + let d = tmp("metadata"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + let a = input.join("a.png"); + let data: Vec = (0..8 * 6 * 4).map(|i| (i as u8).wrapping_mul(7).wrapping_add(3)).collect(); + let mut img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); + img.meta.xmp = Some(XMP.into()); + img.meta.dpi = Some((300.0, 300.0)); + img.meta.text = vec![("Comment".into(), "free-form-note".into())]; + // Display P3 rather than sRGB, so no export can pass for keeping it by writing its default. + let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec(); + img.icc = Some(p3.clone()); + std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors"); + let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap(); + let note = |p: &Path| decoded(p).meta.text.iter().any(|(_, v)| v == "free-form-note"); + assert!(ancestors(&a) && note(&a), "the input carries the packet and the text"); + let invert = r#"[{"command":"image.adjustments.invert"}]"#; + for ext in ["png", "webp", "jpg", "tif"] { + let mut converted_files = Vec::new(); + for flag in [None, Some("all"), Some("none")] { + let extra: Vec<&str> = flag.map(|f| vec!["--metadata", f]).unwrap_or_default(); + let name = format!("{ext}-{}", flag.unwrap_or("default")); + let converted = d.join(format!("convert-{name}.{ext}")); + ok(bin().arg("convert").arg(&a).arg(&converted).args(&extra)); + let ran = d.join(format!("run-{name}.{ext}")); + ok(bin().arg("run").arg(&a).arg("--out").arg(&ran).args(&extra)); + let o = batch_with(&d, &input, &format!("batch-{name}"), invert, &[&["--format", ext][..], &extra[..]].concat()); + assert!(o.status.success(), "batch {name}: {}", String::from_utf8_lossy(&o.stderr)); + let batched = d.join(format!("batch-{name}")).join(format!("a.{ext}")); + for out in [&converted, &ran, &batched] { + assert_eq!(ancestors(out), flag != Some("none"), "{name}: {}", out.display()); + // PNG holds free-form text; JPEG, WebP and TIFF (but for its named tags) don't. + assert_eq!(note(out), ext == "png" && flag != Some("none"), "{name} text: {}", out.display()); + } + converted_files.push(converted); + } + // Only the XMP and text go: the pixels, profile and resolution are the default export's. + let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2])); + assert_eq!(none.data(), default.data(), "{ext}: pixels"); + assert_eq!(default.icc.as_deref(), Some(&p3[..]), "{ext}: the default export keeps the input's Display P3 profile"); + assert_eq!(none.icc, default.icc, "{ext}: ICC profile"); + assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution"); + assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi); + } + // A layered save keeps the document's XMP whatever the option. + for ext in ["psd", "pcraft"] { + let out = d.join(format!("layered.{ext}")); + ok(bin().arg("convert").arg(&a).arg(&out).args(["--metadata", "none"])); + let back = d.join(format!("layered-{ext}.png")); + ok(bin().arg("convert").arg(&out).arg(&back)); + assert!(ancestors(&back), "{ext} kept the packet"); + } + for bad in ["None", "exif", "0", ""] { + let out = d.join("bad.png"); + let (mut convert, mut run) = (bin(), bin()); + convert.arg("convert").arg(&a).arg(&out).args(["--metadata", bad]); + run.arg("run").arg(&a).arg("--out").arg(&out).args(["--metadata", bad]); + for cmd in [&mut convert, &mut run] { + let o = cmd.output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--metadata {bad}: {err}"); + assert!(err.contains("--metadata") && err.contains("expected none or all"), "--metadata {bad}: {err}"); + assert!(!out.exists(), "--metadata {bad} wrote a file"); + } + let o = batch_with(&d, &input, "bad-batch", invert, &["--metadata", bad]); + assert_eq!(o.status.code(), Some(2), "batch --metadata {bad}"); + assert!(!d.join("bad-batch").exists(), "batch --metadata {bad} wrote a folder"); + } +} + /// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before /// anything is written, unless `--in-place` asks for it. #[test] diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 6137b12466..c7278455ef 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -35,6 +35,27 @@ Warnings do not change the saved document's font declarations and do not stop the operation. To list or replace the missing families intentionally, use `type.resolveMissingFonts` on the document. +### Document metadata + +`convert`, `run --out` and `batch` keep the document's XMP packet in a flat +export whose format holds one (PNG, JPEG, WebP, TIFF, …), and its free-form +text where the format holds that too (PNG text chunks, TIFF's named text tags +such as ImageDescription; JPEG and WebP have no place for it), as Save As +does. `--metadata none` leaves them out, as Export As's Metadata: None does: +a Photoshop file's XMP can list every placed document's id +(`DocumentAncestors`) and the text of every type layer, and can be far larger +than the image. `--metadata all` is the default. The option changes nothing +else: the ICC profile, EXIF and resolution are written as they are without +it, where the format holds them. An Indexed Color document saved as a palette +PNG carries no XMP, text, ICC profile or EXIF with either value, only its +resolution. PSD, PSB and `.pcraft` saves always keep the XMP. It is not a full +metadata scrub. + +```sh +photocraft-cli batch --actions resize.json --in masters/ --out web/ \ + --format webp --metadata none +``` + ### Export selected adjustment layers as a .cube LUT The engine command `file.export.colorLookupTables` creates a 3D LUT from an diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index a1b8cbd91d..ba5e2631f8 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -1117,6 +1117,50 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out) } +/// How many times over a stored mesh's triangles may cover its deformed box, counting each +/// triangle's bounding box: a real mesh tiles the box about twice (a fold adds a layer), a +/// crafted one stacks large triangles to multiply the drawing work. +const MAX_MESH_COVERAGE: f64 = 16.0; + +/// Draws `src` (content inside `bounds`) through a mesh solved elsewhere, as a Photoshop file +/// stores its Puppet Warp: `mesh.verts` move to `deformed`, the triangles drawn in their stored +/// order. `None` for a malformed mesh (sizes that don't match, an index out of range, a vertex +/// that isn't finite), one whose deformed box spans more than [`MAX_WARP_PIXELS`] or whose +/// triangles cover that box more than [`MAX_MESH_COVERAGE`] times. +pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64; 2]], interp: Interp) -> Option { + let n = mesh.verts.len(); + if mesh.tris.is_empty() || deformed.len() != n || mesh.tris.iter().flatten().any(|&i| i >= n) { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for p in mesh.verts.iter().chain(deformed) { + if !(p[0].is_finite() && p[1].is_finite()) { + return None; + } + } + for p in deformed { + (x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1])); + } + let area = (x1 - x0 + 1.0) * (y1 - y0 + 1.0); + if area > MAX_WARP_PIXELS { + return None; + } + let covered: f64 = mesh + .tris + .iter() + .map(|t| { + let (xs, ys) = (t.map(|i| deformed[i][0]), t.map(|i| deformed[i][1])); + let span = |v: [f64; 3]| v.iter().fold(f64::MIN, |m, &a| m.max(a)) - v.iter().fold(f64::MAX, |m, &a| m.min(a)) + 1.0; + span(xs) * span(ys) + }) + .sum(); + if covered > MAX_MESH_COVERAGE * area { + return None; + } + let order: Vec = (0..mesh.tris.len()).collect(); + Some(render(src, bounds, mesh, deformed, &order, interp)) +} + /// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same /// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the /// result when none was given. @@ -1267,6 +1311,50 @@ mod tests { assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0])); } + #[test] + fn a_stored_mesh_is_drawn_as_it_is() { + // A Photoshop file's Puppet Warp keeps its solved mesh: two triangles over a 40 × 40 box, + // sheared and moved. A dot lands where the mesh's affine map puts it; pixels outside the + // mesh are dropped. + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + for y in 0..48 { + for x in 0..48 { + let dot = (f64::from(x) + 0.5 - 12.0).hypot(f64::from(y) + 0.5 - 20.0) <= 2.0; + s.write_pixel(x, y, &if dot { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let mesh = PuppetMesh { verts: vec![[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]], tris: vec![[0, 1, 2], [0, 2, 3]], spacing: 20.0 }; + let map = |p: [f64; 2]| [p[0] + 0.25 * p[1] + 3.0, p[1] + 5.0]; + let deformed: Vec<[f64; 2]> = mesh.verts.iter().map(|v| map(*v)).collect(); + let b = s.content_bounds(); + let out = warp_with_mesh(&s, b, &mesh, &deformed, Interp::Bilinear).expect("a valid mesh draws"); + let ob = out.content_bounds(); + let (mut sw, mut sx, mut sy) = (0.0, 0.0, 0.0); + for (i, px) in out.read_region(ob).as_chunks::<4>().0.iter().enumerate() { + let w = f64::from(px[0] * px[3]); + let (x, y) = (f64::from(ob.x0) + (i % ob.width() as usize) as f64 + 0.5, f64::from(ob.y0) + (i / ob.width() as usize) as f64 + 0.5); + (sw, sx, sy) = (sw + w, sx + w * x, sy + w * y); + } + let want = map([12.0, 20.0]); + assert!((sx / sw - want[0]).abs() < 0.05 && (sy / sw - want[1]).abs() < 0.05, "dot at {:?}, want {want:?}", [sx / sw, sy / sw]); + // The source column x = 44 is outside the mesh: nothing lands right of the sheared box. + assert!(ob.x1 <= 54 && ob.y1 <= 45, "{ob:?}"); + // Malformed meshes are refused rather than drawn. + assert!(warp_with_mesh(&s, b, &mesh, &deformed[..3], Interp::Bilinear).is_none()); + let bad = PuppetMesh { tris: vec![[0, 1, 4]], ..mesh.clone() }; + assert!(warp_with_mesh(&s, b, &bad, &deformed, Interp::Bilinear).is_none()); + let mut far = deformed.clone(); + far[2] = [f64::NAN, 0.0]; + assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + far[2] = [1.0e6, 1.0e6]; + assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + // The same two triangles stacked 40 times: far more drawing than the box needs. + let stacked = PuppetMesh { tris: mesh.tris.repeat(40), ..mesh.clone() }; + assert!(warp_with_mesh(&s, b, &stacked, &deformed, Interp::Bilinear).is_none()); + let none = PuppetMesh { tris: Vec::new(), ..mesh }; + assert!(warp_with_mesh(&s, b, &none, &deformed, Interp::Bilinear).is_none()); + } + #[test] fn no_pins_or_unmoved_pins_are_identity() { for st in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 3e0f5e7d82..1759016726 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -732,7 +732,9 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.info" | "path.addToSelection" | "path.subtractFromSelection" + | "path.intersectWithSelection" | "path.set" + | "actions.insertPath" | "path.transform" | "path.moveAnchors" | "path.moveHandle" diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 8deaa68de3..952d29be63 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -60,7 +60,11 @@ fn over(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka { } let mut o = [0.0; 5]; for i in 0..4 { - o[i] = (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a; + // Over nothing, under a covering source, or where both inks agree, the result is the + // source's ink exactly: `s * sa / sa` can be one ulp off, which a later mode or + // rounding step can turn into a whole level. + let shows_through = b[4] > 0.0 && sa < 1.0; + o[i] = if !shows_through || b[i] == s[i] { s[i] } else { (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a }; } o[4] = a; o @@ -762,6 +766,37 @@ fn clips_onto_pass_through(layers: &[LayerSpec]) -> bool { }) || layers.iter().any(|l| matches!(l, LayerSpec::Group { children, .. } if clips_onto_pass_through(children))) } +/// A proptest case from main's CI: a masked shape below 100% Fill, then Posterize in Color +/// Burn (Paint.NET) inside an isolated group. The burned brightness is exactly 73.5/255. +#[test] +fn posterize_in_paint_net_color_burn_on_a_rounding_tie_matches_the_reference() { + let shape = LayerSpec::Pixels { + kind: Kind::Shape, + blend: BlendMode::Normal, + fills: vec![(Rect::new(0, 0, 1, 1), [0.47422728, 0.0, 0.0, 0.0, 1.0])], + opacity: 1.0, + fill: 0.876696, + clipped: false, + visible: true, + mask: Some(MaskSpec { rects: vec![], default: 0.06425183, density: 1.0, enabled: true }), + }; + let posterize = LayerSpec::Pixels { + kind: Kind::Adjust(AdjSpec::Posterize(4)), + blend: BlendMode::PaintNetColorBurn, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])], + opacity: 1.0, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let group = LayerSpec::Group { children: vec![shape, posterize], pass_through: false, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None }; + let s = Spec { w: 8, h: 6, layers: vec![group] }; + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + assert_matches_reference(&d); +} + proptest! { #![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })] diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b351bdef06..30c64c7bdf 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -344,6 +344,9 @@ impl Tile { /// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). const MAX_TAPS: usize = 8; +/// The smallest pattern scale a placement draws at (a fraction): keeps tile coordinates finite. +pub const MIN_PLACEMENT_SCALE: f64 = 1e-6; + /// 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 { @@ -387,7 +390,9 @@ impl 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 }; + // Any positive scale draws: a preview at a reduced work scale takes a stored 2 % pattern + // well below 0.1 %. Only 0, negatives and non-finite values fall back to 100 %. + let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(MIN_PLACEMENT_SCALE) } else { 1.0 }; let a = f64::from(angle).to_radians(); Placement { origin, cs: (a.cos(), a.sin()), inv_scale } } @@ -608,6 +613,31 @@ mod tests { assert_eq!((row[0][0], row[1][0]), (0.0, 1.0)); } + /// Review of #9256: a preview at 4 % work scale draws a stored 2 % pattern at 0.08 %, which + /// used to fall back to 100 % and show the stripes full size. + #[test] + fn placement_keeps_scales_below_a_tenth_of_a_percent() { + let place = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 0.0008, 0.0); + let (u, v) = place.map(1.0, 2.0); + assert!((u - 1250.0).abs() < 1e-3 && (v - 2500.0).abs() < 1e-3, "{u},{v}"); + // 1-px dark / light stripes: at 100 % the pixels alternate 0 / 1; at 0.08 % each pixel + // covers 1250 of them, so every pixel is their mean. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + for x in (0..8).step_by(2) { + s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]); + } + let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap(); + for p in render(&t, &place, Rect::new(0, 0, 8, 1)) { + assert!((p[0] - 0.5).abs() < 0.1, "{p:?}"); + } + // 0, negative and non-finite scales still draw at 100 %. + for bad in [0.0, -0.5, f32::NAN, f32::INFINITY] { + let p = Placement::new(Rect::EMPTY, false, (0.0, 0.0), bad, 0.0); + assert_eq!(p.map(3.0, 0.0).0, 3.0, "{bad}"); + } + } + #[test] fn sampling_at_hostile_phases_does_not_overflow() { let patterns = [checker()]; diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 4e3f32a97f..a71aa883dd 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize}; use crate::adjust::CurvePoint; +/// The range of a style's pattern scale as a fraction (1 = 100 %): Pattern Overlay, a pattern +/// Stroke and the Bevel & Emboss Texture all take 1..1000 % in the Layer Style dialog. +pub const PATTERN_SCALE_RANGE: (f32, f32) = (0.01, 10.0); + /// Gradient geometry (fill layers, gradient overlays, gradient strokes). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum GradientStyle { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index aa376fea02..9cc2dd7b48 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -564,6 +564,12 @@ impl SmartObject { perspective: None, } } + + /// Where the placed source lands (transform, perspective and warp), as JSON: the placement a + /// smart filter stored in document pixels (an imported Puppet Warp mesh) was made for. + pub fn placement_json(&self) -> serde_json::Value { + serde_json::json!({"transform": self.transform.m, "perspective": self.perspective, "warp": self.warp}) + } } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 3f27d91137..3392c5361a 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -94,6 +94,9 @@ pub const MAX_PLAYBACK_STEPS: u64 = 100_000; #[serde(default)] pub struct ActionState { pub list: Vec, + /// Painting-tool strokes are recorded only when the user enables them for this session. + #[serde(skip)] + pub record_tools: bool, /// Journal length when recording started, and the action index being recorded into. #[serde(skip)] pub recording: Option<(usize, usize)>, @@ -165,7 +168,7 @@ pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result { } } } - if id.starts_with("filter.gallery.") + if (id.starts_with("filter.gallery.") || id == crate::atn_cmds::MENU_ITEM) && let Some(object) = p.as_object_mut() { object.remove(crate::atn_cmds::RECORDED_EVENT); @@ -276,6 +279,63 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> { Ok((action, from)) } +/// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched. +fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) { + let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect(); + while let Some((id, params)) = pending.pop() { + let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null()); + if id == "file.automate.batch" { + let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") }; + if let Some(key) = inline { + if scoped { + continue; + } + // A present invalid `steps` still wins over `action`, just as at runtime. + let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue }; + for step in steps.iter_mut().rev() { + let call = match step { + Value::Array(a) => { + let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue }; + a.get_mut(1).map(|params| (id, params)) + } + Value::Object(o) => { + let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue }; + o.get_mut("params").map(|params| (id, params)) + } + // String steps and omitted params use `{}`, with no action target. + _ => None, + }; + if let Some(call) = call { + pending.push(call); + } + } + continue; + } + } + f(&id, params, scoped); + } +} + +/// Shift numeric selectors in the session list, including calls in unscoped inline Batch steps. +pub(crate) fn shift_action_calls(list: &mut [Action], at: usize, count: usize) { + for action in list { + visit_calls(&mut action.steps, &mut |id, params, _| { + if !matches!(id, "actions.play" | "file.automate.batch") { + return; + } + let Some(value) = params.get("action") else { return }; + let Some(index) = num(value).and_then(|i| usize::try_from(i).ok()) else { return }; + if index >= at { + params["action"] = if let Some(fractional) = value.as_f64().filter(|_| value.is_f64()) { + json!(fractional + count as f64) + } else { + json!(index.saturating_add(count)) + }; + } + }); + } +} + /// Capture transitive Play Action, Conditional and Batch dependencies for a standalone droplet /// (#2794, #6804). Ordinary references become names; normalized copies keep source-list order. /// A named Batch with its own action list selects an untouched source copy at an exported index, @@ -286,42 +346,6 @@ pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec Result { if id == "file.automate.batch" { crate::file_cmds::batch_action(list, params) } else { resolve(list, params, id) } } - /// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched. - fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) { - let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect(); - while let Some((id, params)) = pending.pop() { - let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null()); - if id == "file.automate.batch" { - let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") }; - if let Some(key) = inline { - if scoped { - continue; - } - // A present invalid `steps` still wins over `action`, just as at runtime. - let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue }; - for step in steps.iter_mut().rev() { - let call = match step { - Value::Array(a) => { - let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue }; - a.get_mut(1).map(|params| (id, params)) - } - Value::Object(o) => { - let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue }; - o.get_mut("params").map(|params| (id, params)) - } - // String steps and omitted params use `{}`, with no action target. - _ => None, - }; - if let Some(call) = call { - pending.push(call); - } - } - continue; - } - } - f(&id, params, scoped); - } - } /// Capture ordinary calls transitively, but retain an untouched source for scoped named Batch. fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut VecDeque<(usize, usize)>, scoped: &mut BTreeSet) { if depth > 16 { @@ -482,7 +506,7 @@ fn from_step(p: &Value, len: usize) -> Result { fn list(s: &mut Session, _p: &Value) -> Result { let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect(); let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone())); - Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording})) + Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording, "toolRecording": s.actions.record_tools})) } fn get(s: &mut Session, p: &Value) -> Result { @@ -545,7 +569,32 @@ const RECORDED_WITH_DIALOG: &[&str] = &["edit.checkSpelling"]; /// Replayable journal entries not yet committed to this action while recording. pub fn pending_steps(s: &Session, action: usize) -> impl Iterator { let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from); - s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p)) + s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p) && (s.actions.record_tools || !tool_stroke(id))) +} + +/// The point-based painting tools committed by the canvas; fills and gradients remain commands. +pub(crate) fn tool_stroke(id: &str) -> bool { + matches!( + id, + "paint.stroke" + | "paint.pencil" + | "paint.mixerBrush" + | "paint.colorReplacement" + | "paint.blur" + | "paint.sharpen" + | "paint.smudge" + | "paint.dodge" + | "paint.burn" + | "paint.sponge" + | "paint.cloneStamp" + | "paint.healingBrush" + | "paint.historyBrush" + | "paint.artHistoryBrush" + | "paint.patternStamp" + | "paint.backgroundEraser" + | "paint.spotHealing" + | "paint.remove" + ) } pub(crate) fn flush_recording(s: &mut Session) -> Result<()> { @@ -1094,7 +1143,7 @@ pub fn specs() -> Vec { label: "List Actions", menu: &[], shortcut: None, - params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null}", + params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null, toolRecording:bool}", enabled: always, run: list, journal: false, diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index e9936199b9..d527b6d773 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -13,6 +13,11 @@ use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod insert_path; +mod record_again; +pub use insert_path::selected_path as selected_recording_path; +pub use record_again::{RecordAgainTarget, target as record_again_target}; + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -21,6 +26,35 @@ fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } +/// Flush under the old mode so changing the toggle never retroactively records or loses a stroke. +fn allow_tool_recording(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.allowToolRecording"; + not_playing(s, cmd)?; + let enabled = match p.get("enabled") { + None | Some(Value::Null) => !s.actions.record_tools, + Some(Value::Bool(enabled)) => *enabled, + Some(_) => return Err(bad(cmd, "`enabled` must be true or false")), + }; + if enabled != s.actions.record_tools { + if s.actions.recording.is_some() && s.tool_stroke_job_running() { + return Err(bad(cmd, "wait for the current tool stroke before changing recording")); + } + flush_recording(s)?; + s.actions.record_tools = enabled; + } + Ok(json!({"enabled": enabled})) +} + +/// Insert a menu event with absent settings, reusing the ordinary validated step insertion. +fn insert_menu_item(s: &mut Session, p: &Value) -> Result { + if p.as_object().is_some_and(|o| o.keys().any(|k| !matches!(k.as_str(), "action" | "at" | "id" | "eventKind"))) { + return Err(bad("actions.insertMenuItem", "menu items accept no recorded settings")); + } + let params = json!({"id":p.get("id"),"eventKind":p.get("eventKind").cloned().unwrap_or(json!("name"))}); + crate::atn_cmds::menu_item_command(¶ms)?; + s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":crate::atn_cmds::MENU_ITEM,"params":params})) +} + fn index(p: &Value, key: &str, cmd: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), @@ -66,18 +100,113 @@ fn insert(s: &mut Session, p: &Value) -> Result { if s.actions.recording.is_some_and(|(_, i)| i == idx) { flush_recording(s)?; } - let action = &mut s.actions.list[idx]; - action.steps.insert(at, (id.clone(), params)); - action.remap_steps(|i| Some(if i >= at { i + 1 } else { i })); - // An inserted Stop has its dialog toggle on, so it shows its message in the app. - if id == crate::atn_cmds::STOP_MESSAGE { - action.dialogs.insert(at); + let is_stop = id == crate::atn_cmds::STOP_MESSAGE; + let result = insert_step(s, idx, at, (id, params)); + // A newly inserted Stop shows its message in the app. Duplicates keep their own flags. + if is_stop { + s.actions.list[idx].dialogs.insert(at); } + Ok(result) +} + +/// Insert an already validated step without normalizing a copied step's payload. +fn insert_step(s: &mut Session, idx: usize, at: usize, step: (String, Value)) -> Value { + let action = &mut s.actions.list[idx]; + let id = step.0.clone(); + action.steps.insert(at, step); + action.remap_steps(|i| Some(if i >= at { i + 1 } else { i })); let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()}); s.actions.touch(); + result +} + +/// Duplicate a step or action below its source, preserving settings and per-step flags. +fn duplicate(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.duplicate"; + not_playing(s, cmd)?; + if let Some(set) = p.get("set") { + if p.get("action").is_some() || p.get("step").is_some() { + return Err(bad(cmd, "select a set or an action step, not both")); + } + return duplicate_set(s, set.as_str().ok_or_else(|| bad(cmd, "set must be a name"))?); + } + let idx = resolve(&s.actions.list, p, cmd)?; + if let Some(step) = index(p, "step", cmd)? { + let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count()); + if step >= len { + return Err(bad(cmd, "step is out of range")); + } + if s.actions.recording.is_some_and(|(_, i)| i == idx) { + flush_recording(s)?; + } + let action = &s.actions.list[idx]; + let copied = action.steps.get(step).cloned().ok_or_else(|| bad(cmd, "step is out of range"))?; + let (disabled, dialog) = (action.disabled.contains(&step), action.dialogs.contains(&step)); + let at = step + 1; + let mut result = insert_step(s, idx, at, copied); + let action = &mut s.actions.list[idx]; + if disabled { + action.disabled.insert(at); + } + if dialog { + action.dialogs.insert(at); + } + result["index"] = json!(idx); + return Ok(result); + } + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before duplicating an action")); + } + let mut copied = s.actions.list[idx].clone(); + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + copied.name = names.claim(&format!("{} copy", copied.name)); + let at = idx + 1; + let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()}); + s.actions.list.insert(at, copied); + crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, 1); + s.actions.touch(); Ok(result) } +/// Copy the named group represented by main, including a session-only empty New Set. +fn duplicate_set(s: &mut Session, source: &str) -> Result { + let cmd = "actions.duplicate"; + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before duplicating a set")); + } + let sets = set_names(s); + if !sets.iter().any(|name| name == source) { + return Err(bad(cmd, "the selected set no longer exists")); + } + let name = crate::atn_cmds::UniqueNames::new(sets).claim(&format!("{source} copy")); + if let Some(first) = s.actions.list.iter().position(|action| action.set.as_deref() == Some(source)) { + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|action| action.name.clone())); + let copies: Vec<_> = s + .actions + .list + .iter() + .filter(|action| action.set.as_deref() == Some(source)) + .cloned() + .map(|mut action| { + action.name = names.claim(&format!("{} copy", action.name)); + action.set = Some(name.clone()); + action + }) + .collect(); + // Classic groups rows by name even when a set's actions are interleaved. + let at = first + s.actions.list[first..].iter().take_while(|action| action.set.as_deref() == Some(source)).count(); + let count = copies.len(); + s.actions.list.splice(at..at, copies); + crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, count); + s.actions.touch(); + return Ok(json!({"set": name, "index": at, "actions": count})); + } + let at = s.actions.sets.iter().position(|set| set == source).ok_or_else(|| bad(cmd, "the selected set no longer exists"))? + 1; + s.actions.sets.insert(at, name.clone()); + s.actions.touch(); + Ok(json!({"set": name, "actions": 0})) +} + /// The panel's dialog toggle column. With `step`, switch one step's dialog on or off; without /// it, every step (all on unless all already are, like clicking the action's own toggle). fn dialog(s: &mut Session, p: &Value) -> Result { @@ -277,6 +406,56 @@ fn conditional(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.insertMenuItem", + label: "Insert Menu Item…", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"id":menu command,"at":index?,"eventKind":"name"|"code"="name"} → {action,step,id,steps}. Records an unambiguous supported menu event without settings. Image Size and mapped filter menu events are supported. Desktop playback opens the existing editor when settings are needed; no-settings items run immediately."##, + enabled: always, + run: insert_menu_item, + journal: false, + }, + CommandSpec { + id: "actions.insertPath", + label: "Insert Path", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"name":"work"|"layer"|saved path name,"at":index?} → {action,step,id,steps}. Snapshot exactly the selected path as a path.set work-path step, preserving cubic handles and path options. No fallback from a missing Work Path to a layer path. The measured Work Path ATN descriptor supports absolute coordinates, cubic handles, even-odd subpaths and Add/Subtract/XOR. Paths outside that measured form still play natively and are reported as unsupported by ATN export."##, + enabled: always, + run: insert_path::run, + journal: false, + }, + CommandSpec { + id: "actions.allowToolRecording", + label: "Allow Tool Recording", + menu: &[], + shortcut: None, + params: r##"{"enabled":bool?=toggle} → {enabled:bool}. Opt in to recording painting-tool strokes (off initially). Existing pending steps are committed under the previous mode. Painting, history and the journal are unchanged. actions.list reports toolRecording."##, + enabled: always, + run: allow_tool_recording, + journal: false, + }, + CommandSpec { + id: "actions.recordAgain", + label: "Record Again", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"step":index,"params":{new settings},"revision":u64?} → {action,step,id,result}. Replays the selected command and stores its new settings only after success, preserving its enabled/dialog flags. The panel opens the command's editor at the recorded settings. Stop recording or playback first; revision optionally rejects stale library state."##, + enabled: always, + run: record_again::run, + journal: false, + }, + CommandSpec { + id: "actions.duplicate", + label: "Duplicate Action", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"step":index?} or {"set":name} → {action?,index?,step?,steps?,set?,actions?}. Copy an action, step or named set below the source. Copies retain settings and flags, and leave shortcuts with the originals."##, + enabled: always, + run: duplicate, + journal: false, + }, CommandSpec { id: "actions.insert", label: "Insert Step", diff --git a/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs b/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs new file mode 100644 index 0000000000..9be10d94ad --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs @@ -0,0 +1,26 @@ +//! Capture full native geometry without substituting another path for a stale selection. +use super::{Result, Session, Value, bad, json}; +use photocraft_doc::{LayerContent, Path}; + +/// Borrow exactly the path the panel selected, without Work Path's ordinary layer fallback. +pub fn selected_path<'a>(s: &'a Session, name: &str) -> Result<&'a Path> { + let st = s.active().ok_or(crate::EngineError::NoDocument)?; + let path = match name { + "work" => st.doc.work_path.as_ref(), + "layer" => st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| match &l.content { + LayerContent::Shape(shape) => Some(&shape.path), + _ => l.vector_mask.as_ref().map(|v| &v.path), + }), + name => st.doc.paths.iter().find(|p| p.name == name).map(|p| &p.path), + }; + path.ok_or_else(|| bad("actions.insertPath", "the selected path no longer exists")) +} + +/// The native path step is also the source for a future independently verified ATN wire mapping. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("actions.insertPath", "select a path"))?; + let path = selected_path(s, name)?; + photocraft_vector::flatten::validate_path(path).map_err(|e| bad("actions.insertPath", e))?; + let path = crate::vector_cmds::path_json(path); + s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":"path.set","params":{"name":"work","path":path}})) +} diff --git a/crates/engine/src/cmd/actions_edit_cmds/record_again.rs b/crates/engine/src/cmd/actions_edit_cmds/record_again.rs new file mode 100644 index 0000000000..b6049967bb --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/record_again.rs @@ -0,0 +1,55 @@ +//! Record Again executes the selected command before replacing its stored settings. +use super::{Result, Session, Value, bad, index, json, not_playing, resolve}; + +const COMMAND: &str = "actions.recordAgain"; + +/// A validated selection shared by the engine and the panel's seeded editor. +pub struct RecordAgainTarget { + pub action: usize, + pub step: usize, + pub command: String, + pub params: Value, +} + +/// Resolve only executable steps, without flushing recording or editing the library. +pub fn target(s: &Session, p: &Value) -> Result { + not_playing(s, COMMAND)?; + if s.actions.recording.is_some() { + return Err(bad(COMMAND, "stop recording before recording a step again")); + } + if let Some(revision) = p.get("revision") { + let revision = revision.as_u64().ok_or_else(|| bad(COMMAND, "`revision` must be an unsigned integer"))?; + if revision != s.actions.rev { + return Err(bad(COMMAND, "the actions changed; select the step again")); + } + } + let action = resolve(&s.actions.list, p, COMMAND)?; + let step = index(p, "step", COMMAND)?.ok_or_else(|| bad(COMMAND, "select a step"))?; + let (id, params) = s.actions.list.get(action).and_then(|a| a.steps.get(step)).ok_or_else(|| bad(COMMAND, "step is out of range"))?; + let (command, params) = + if id == crate::atn_cmds::MENU_ITEM { (crate::atn_cmds::menu_item_command(params)?.to_string(), json!({})) } else { (id.clone(), params.clone()) }; + if command.starts_with("actions.") || !crate::commands::find(&command).is_some_and(|c| c.journal) { + return Err(bad(COMMAND, "the selected step has no editable engine command")); + } + Ok(RecordAgainTarget { action, step, command, params }) +} + +/// Keep the original definition on invalid settings, denied execution or changed ownership. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let selected = target(s, p)?; + let params = p.get("params").filter(|p| p.is_object()).cloned().ok_or_else(|| bad(COMMAND, "supply the step's new settings in `params`"))?; + let original = s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)).cloned().ok_or_else(|| bad(COMMAND, "step is out of range"))?; + let revision = s.actions.rev; + // Resolve portable recorded targets for execution; keep their portable form in the step. + let resolved = crate::actions_cmds::playback_params(s, &selected.command, ¶ms)?; + let result = s.execute(&selected.command, resolved)?; + if s.actions.rev != revision || s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)) != Some(&original) { + return Err(bad(COMMAND, "the action changed during execution; its step was not overwritten")); + } + let action = s.actions.list.get_mut(selected.action).ok_or_else(|| bad(COMMAND, "the action no longer exists"))?; + let step = action.steps.get_mut(selected.step).ok_or_else(|| bad(COMMAND, "the step no longer exists"))?; + *step = (selected.command.clone(), params); + let name = action.name.clone(); + s.actions.touch(); + Ok(json!({"action": name, "step": selected.step, "id": selected.command, "result": result})) +} diff --git a/crates/engine/src/cmd/artboard_cmds.rs b/crates/engine/src/cmd/artboard_cmds.rs index 470971831a..91391edc25 100644 --- a/crates/engine/src/cmd/artboard_cmds.rs +++ b/crates/engine/src/cmd/artboard_cmds.rs @@ -19,6 +19,7 @@ use crate::{EngineError, Result, Session}; /// Artboard size presets (name, width, height) in pixels, Photoshop's common devices and pages. pub const PRESETS: &[(&str, u32, u32)] = &[ + ("iPhone 8/7/6", 750, 1334), ("iPhone 14", 390, 844), ("iPhone 14 Pro Max", 430, 932), ("Android 1080p", 360, 640), @@ -414,6 +415,9 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result { /// Changing X/Y moves the board with its contents; changing W/H resizes it from the top left. fn set_props(s: &mut Session, p: &Value) -> Result { let cmd = "layer.artboard.set"; + if p.get("layers").is_some() { + return set_backgrounds(s, p); + } let d = s.active().ok_or(EngineError::NoDocument)?; let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?; let old = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?; @@ -469,6 +473,34 @@ fn set_props(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]})) } +/// `layer.artboard.set` with a `layers` array: the background of every listed board in one undo +/// step, "Edit Artboard" for one board and "Edit Artboards" for several (the options bar's +/// background control with boards selected). Only `background`/`color` apply to several boards. +fn set_backgrounds(s: &mut Session, p: &Value) -> Result { + let cmd = "layer.artboard.set"; + let d = s.active().ok_or(EngineError::NoDocument)?; + let mut ids: Vec = Vec::new(); + for v in p.get("layers").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`layers` must be an array of layer ids"))? { + let id = LayerId(v.as_u64().ok_or_else(|| bad(cmd, "`layers` must be an array of layer ids"))?); + d.doc.layer(id).and_then(Layer::artboard).ok_or_else(|| bad(cmd, format!("layer {} is not an artboard", id.0)))?; + if !ids.contains(&id) { + ids.push(id); + } + } + if ids.is_empty() { + return Err(bad(cmd, "`layers` is empty")); + } + let background = background_param(p, cmd)?.ok_or_else(|| bad(cmd, "`layers` needs a `background`"))?; + let label = if ids.len() == 1 { "Edit Artboard" } else { "Edit Artboards" }; + s.edit(label, |doc, _| { + for id in &ids { + doc.layer_mut(*id).and_then(Layer::artboard_mut).ok_or(EngineError::NoLayer(*id))?.background = background; + } + Ok(()) + })?; + Ok(json!({"layers": ids.iter().map(|i| i.0).collect::>()})) +} + /// The Artboard tool's "+" buttons: a board the size of board `layer` on one of its sides, a /// [`GAP`] away (further out past any board in the way), empty or (`duplicate`, the ⌥-click) a /// copy with its contents. A board left of or above the canvas origin shifts the whole document diff --git a/crates/engine/src/cmd/artboard_cmds/tests.rs b/crates/engine/src/cmd/artboard_cmds/tests.rs index 91c860e540..e079c5b54c 100644 --- a/crates/engine/src/cmd/artboard_cmds/tests.rs +++ b/crates/engine/src/cmd/artboard_cmds/tests.rs @@ -1080,3 +1080,28 @@ fn a_layer_centred_just_left_of_a_board_at_negative_coordinates_stays_off_it() { s.execute("layer.translate", json!({"layer": id.0, "dx": 1, "dy": 0, "nest": true})).unwrap(); assert_eq!(doc(&s).artboard_of(id), Some(b)); } + +#[test] +fn a_layers_array_sets_every_listed_boards_background_in_one_step() { + let mut s = session(8); + let a = LayerId(s.execute("layer.new.artboard", json!({"rect": [0, 0, 50, 50]})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.new.artboard", json!({"rect": [200, 0, 50, 50]})).unwrap()["layer"].as_u64().unwrap()); + let steps = s.active().unwrap().history.entries().len(); + s.execute("layer.artboard.set", json!({"layers": [a.0, b.0], "background": "black"})).unwrap(); + let entries = s.active().unwrap().history.entries(); + assert_eq!(entries.len(), steps + 1); + assert_eq!(entries.last().map(String::as_str), Some("Edit Artboards")); + for id in [a, b] { + assert_eq!(doc(&s).layer(id).and_then(Layer::artboard).unwrap().background, ArtboardBackground::Black); + } + s.execute("layer.artboard.set", json!({"layers": [a.0], "background": "custom", "color": "#ff0000"})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Edit Artboard")); + // Undo is one step; errors change nothing. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(doc(&s).layer(a).and_then(Layer::artboard).unwrap().background, ArtboardBackground::Black); + let n = s.active().unwrap().history.entries().len(); + assert!(s.execute("layer.artboard.set", json!({"layers": [a.0, 9999], "background": "white"})).is_err()); + assert!(s.execute("layer.artboard.set", json!({"layers": [], "background": "white"})).is_err()); + assert!(s.execute("layer.artboard.set", json!({"layers": [a.0]})).is_err()); + assert_eq!(s.active().unwrap().history.entries().len(), n); +} diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index a51022dceb..0e90e5d3dc 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -32,6 +32,7 @@ mod descriptors; mod events; mod fields; mod files; +mod menu_item; mod steps_artboard; mod steps_batch; @@ -41,6 +42,7 @@ mod steps_content_aware; mod steps_direct_adjustments; mod steps_distort; mod steps_doc; +mod steps_droplet; mod steps_gallery; mod steps_gradient; mod steps_image; @@ -48,6 +50,7 @@ mod steps_layer; mod steps_layer_style; mod steps_lookup; mod steps_make; +mod steps_path; mod steps_perspective_warp; mod steps_type_path; @@ -62,6 +65,7 @@ mod tables; #[cfg(test)] use adjustments::{levels_from, selective_from}; +pub(crate) use convert::UniqueNames; pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key}; #[cfg(test)] use convert::{MAX_NAME_CHARS, export_item, normalise}; @@ -76,6 +80,7 @@ use events::{EVENTS, event_and_descriptor, event_for}; #[cfg(test)] use fields::Mapped; use files::{load, save}; +pub use menu_item::{MENU_ITEM, menu_item_command, menu_item_commands, menu_item_needs_settings}; #[cfg(test)] use steps_doc::crop_from; pub(crate) use steps_gallery::RECORDED_EVENT; @@ -150,6 +155,16 @@ pub fn specs() -> Vec { run: stop_message, journal: true, }, + CommandSpec { + id: MENU_ITEM, + label: "Menu Item", + menu: &[], + shortcut: None, + params: r##"{"id":menu command,"eventKind":"name"|"code"="name"} (a descriptor-free Image Size or unambiguous mapped filter menu event; desktop playback asks for settings when needed, headless playback only runs stateless menu items)"##, + enabled: always, + run: menu_item::run, + journal: true, + }, CommandSpec { id: PHOTOSHOP_STEP, label: "Photoshop Step", @@ -177,3 +192,6 @@ mod selection_tests; #[cfg(test)] mod render_tests; + +#[cfg(test)] +mod recorded_tools_tests; diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 8757b6ba04..71b64db4cb 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -69,10 +69,12 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); for (i, item) in a.items.iter().enumerate() { - let mapped = match (event_for(&item.event), &item.descriptor) { - (Some(e), Some(d)) => (e.from)(d), - (Some(e), None) => (e.from)(&Descriptor::default()), - (None, _) => Mapped::Unsupported { harmless: false }, + let menu = item.descriptor.is_none().then(|| super::menu_item::from_event(&item.event)).flatten(); + let mapped = match (menu, event_for(&item.event), &item.descriptor) { + (Some(params), _, _) => Mapped::Steps(vec![(super::MENU_ITEM.into(), params)]), + (_, Some(e), Some(d)) => (e.from)(d), + (_, Some(e), None) => (e.from)(&Descriptor::default()), + _ => Mapped::Unsupported { harmless: false }, }; let (new_steps, enabled) = match mapped { Mapped::Steps(mut s) => { @@ -136,6 +138,9 @@ pub struct Skipped { } pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option { + if id == super::MENU_ITEM { + return super::menu_item::export(params); + } if id == PHOTOSHOP_STEP { let event = jstr(params, "event")?; let event = match params.get("eventKind") { @@ -166,7 +171,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option, next_suffix: std::collections::HashMap, } impl UniqueNames { - pub(super) fn new>(existing: I) -> Self { + pub(crate) fn new>(existing: I) -> Self { UniqueNames { taken: existing.into_iter().collect(), next_suffix: std::collections::HashMap::new() } } /// Reserves and returns a unique name derived from `name`, recording it as taken. - pub(super) fn claim(&mut self, name: &str) -> String { + pub(crate) fn claim(&mut self, name: &str) -> String { if self.taken.insert(name.to_string()) { return name.to_string(); } diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index f3aa3b25c0..162bf22f8a 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -88,18 +88,21 @@ fn applies_at_canvas_bounds(p: &Value) -> bool { } /// Settings a mapped event has no field for, by command: `(param, the value the event plays as)`. -/// Photoshop's event has nowhere to put these, so a step that sets one cannot be written: it +/// The exported event has nowhere to put these, so a step that sets one cannot be written: it /// would import as a different edit, silently. Such a step is refused, the way [`pixel_target`] /// refuses a filter aimed at a mask -- and no key Photoshop does not define is invented for it, /// which the clean-room rule forbids and which would make the file wrong in another reader. /// /// A step that leaves the setting at the value the event plays as exports as before. /// How a setting the event cannot carry behaves once the step is replayed without it. Which of -/// the two a setting takes follows the command's own reading of it, not the parameter spec. +/// these a setting takes follows the command's own reading of it, not the parameter spec. enum Lost { - /// Replay uses this value, so only a step already at it survives. Numbers and strings are - /// both compared as written, since the commands below read these settings as written. + /// Replay uses this value, so only a step already at it survives. Numbers, strings and + /// booleans are compared as written, since the commands below read these settings as written. ReplaysAs(&'static str), + /// Replay uses this boolean, and the command refuses any other type, so only a step at this + /// boolean survives: a string such as `"true"` is refused. + ReplaysAsBool(bool), /// The command reads only this one spelling as anything other than what replay does, so every /// other value survives -- a differently cased one included, because it too plays as replay's. DiffersAt(&'static str), @@ -114,6 +117,8 @@ const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[ // the exact string "roundness" -- see `preserve` in filters.rs. ("filter.other.minimum", &[("preserve", Lost::DiffersAt("roundness"))]), ("filter.other.maximum", &[("preserve", Lost::DiffersAt("roundness"))]), + // Eqlz is exported with no fields; replay equalizes the selected area only. + ("image.adjustments.equalize", &[("selectedAreaOnly", Lost::ReplaysAsBool(true))]), ]; /// Whether every setting of `p` that its event cannot hold would play the same without it @@ -121,8 +126,15 @@ const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[ fn holds_its_settings(id: &str, p: &Value) -> bool { let Some((_, settings)) = UNHOLDABLE.iter().find(|(cmd, _)| *cmd == id) else { return true }; settings.iter().all(|(param, lost)| match (p.get(param), lost) { - (None | Some(Value::Null), _) => true, - (Some(v), Lost::ReplaysAs(played)) => v.as_str() == Some(played) || v.as_f64().is_some_and(|n| played.parse::().is_ok_and(|d| n == d)), + (None, _) => true, + // The command refuses null here too, so a null step is refused like any other non-boolean. + (Some(v), Lost::ReplaysAsBool(played)) => v.as_bool() == Some(*played), + (Some(Value::Null), _) => true, + (Some(v), Lost::ReplaysAs(played)) => { + v.as_str() == Some(played) + || v.as_f64().is_some_and(|n| played.parse::().is_ok_and(|d| n == d)) + || v.as_bool().is_some_and(|b| played.parse::() == Ok(b)) + } (Some(v), Lost::DiffersAt(spelling)) => v.as_str() != Some(spelling), }) } @@ -898,6 +910,7 @@ pub(super) const EVENTS: &[Event] = &[ "layer.new.layerFromBackground", "layer.layerStyle.blendingOptions", "layer.smartFilter.blendingOptions", + "path.set", ], from: set_from, to: set_to, @@ -1187,6 +1200,15 @@ pub(super) const EVENTS: &[Event] = &[ from: super::steps_batch::batch_from, to: super::steps_batch::batch_to, }, + // Create Droplet (#10710): Photoshop has no string ID for it. + Event { + code: "CrtD", + name: "CrtD", + label: "Create Droplet", + ids: &["file.automate.createDroplet"], + from: super::steps_droplet::droplet_from, + to: super::steps_droplet::droplet_to, + }, Event { code: "", name: "applyComp", label: "Apply Layer Comp", ids: &["layerComp.apply"], from: apply_comp_from, to: apply_comp_to }, Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 1c8633b995..14be047374 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -51,21 +51,22 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { names.push(name); added.push(a); } - // Play, Conditional and Batch steps call actions by name: follow the renames in the actions just - // loaded, each target looked up once by the name the file gave it. + // Play, Conditional, Batch and Create Droplet steps call actions by name: follow the renames in + // the actions just loaded, each target looked up once by the name the file gave it. for a in &mut added { for (id, params) in &mut a.steps { - // A qualified Batch reference belongs to the file's original set, not to a - // foreign set which happens to have the destination override's name. - if id == "file.automate.batch" && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) { + // A qualified Batch or Create Droplet reference belongs to the file's original set, not + // to a foreign set which happens to have the destination override's name. + let by_set = matches!(id.as_str(), "file.automate.batch" | "file.automate.createDroplet"); + if by_set && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) { params["set"] = json!(imp.set); - } else if id == "file.automate.batch" && params.get("set").is_some_and(|set| !set.is_null()) { + } else if by_set && params.get("set").is_some_and(|set| !set.is_null()) { continue; } let keys: &[&str] = match id.as_str() { "actions.play" => &["action"], "actions.conditional" => &["then", "else"], - "file.automate.batch" => &["action"], + "file.automate.batch" | "file.automate.createDroplet" => &["action"], _ => &[], }; for key in keys { diff --git a/crates/engine/src/cmd/atn_cmds/menu_item.rs b/crates/engine/src/cmd/atn_cmds/menu_item.rs new file mode 100644 index 0000000000..69ed161cd6 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/menu_item.rs @@ -0,0 +1,90 @@ +//! Descriptor-free menu intent is distinct from a command with recorded settings. +//! Image Size and unambiguous mapped filter events keep absent settings distinct from empty ones. + +use super::events::{EVENTS, Event, event_for}; +use super::{AtnEvent, AtnItem, EngineError, Result, Session, Value, bad, json}; + +/// Find a supported, unambiguous event for a menu step with no settings. +fn menu_event(id: &str) -> Option<&'static Event> { + // Camera Raw's ATN mapping requires a verified settings descriptor (#8714). + if id == crate::lens_cmds::RAW || (id != "image.imageSize" && !id.starts_with("filter.")) { + return None; + } + EVENTS.iter().find(|e| { + e.ids == [id] + && !e.name.is_empty() + && event_for(&AtnEvent::name(e.name)).is_some_and(|mapped| mapped.code == e.code && mapped.ids == e.ids) + && crate::commands::find(id).is_some() + }) +} + +/// Menu choices use existing event mappings rather than inventing event names from command ids. +pub fn menu_item_commands() -> Vec<&'static str> { + let mut seen = std::collections::HashSet::new(); + EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| menu_event(id).is_some() && seen.insert(*id)).collect() +} + +/// Stateless filter menu items execute immediately; settings-bearing ones require the editor. +pub fn menu_item_needs_settings(id: &str) -> bool { + crate::commands::find(id).is_none_or(|c| !c.params.trim_start().starts_with("{}")) +} + +/// An Insert Menu Item step with no recorded command descriptor. +pub const MENU_ITEM: &str = "actions.menuItem"; + +/// Validate menu identity before the shell gates and opens its actual command. +pub fn menu_item_command(p: &Value) -> Result<&str> { + let object = p.as_object().ok_or_else(|| bad(MENU_ITEM, "parameters must be an object"))?; + if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind" | super::RECORDED_EVENT)) { + return Err(bad(MENU_ITEM, "only `id` and `eventKind` are supported; a menu item has no recorded settings")); + } + if object.get("eventKind").is_some_and(|kind| !matches!(kind.as_str(), Some("code" | "name"))) { + return Err(bad(MENU_ITEM, "`eventKind` must be name or code")); + } + let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(MENU_ITEM, "select a menu command"))?; + let event = menu_event(id).ok_or_else(|| bad(MENU_ITEM, "the command has no unambiguous supported menu event"))?; + if p.get("eventKind").and_then(Value::as_str) == Some("code") { + let code = event.code.as_bytes().try_into().ok().map(AtnEvent::Code); + if !code.as_ref().and_then(event_for).is_some_and(|mapped| mapped.name == event.name && mapped.ids == event.ids) { + return Err(bad(MENU_ITEM, "this menu event has no unambiguous four-character code")); + } + } + Ok(id) +} + +/// Preserve the exact code/name kind only for the supported descriptor-free event. +pub(super) fn from_event(event: &AtnEvent) -> Option { + let mapped = event_for(event)?; + let id = *mapped.ids.first()?; + let canonical = menu_event(id)?; + if canonical.name != mapped.name || canonical.code != mapped.code { + return None; + } + let kind = match event { + AtnEvent::Code(code) if mapped.code.as_bytes() == code => "code", + AtnEvent::Name(name) if mapped.name.as_bytes() == name => "name", + _ => return None, + }; + Some(json!({"id":id,"eventKind":kind})) +} + +/// Write absent settings as `None`, never as an empty or current-target descriptor. +pub(super) fn export(p: &Value) -> Option { + let mapped = menu_event(menu_item_command(p).ok()?)?; + let event = if p.get("eventKind").and_then(Value::as_str) == Some("code") { + AtnEvent::Code(mapped.code.as_bytes().try_into().ok()?) + } else { + AtnEvent::name(mapped.name) + }; + let (event, dictionary) = super::steps_gallery::recorded_event(super::MENU_ITEM, p).unwrap_or_else(|| (event, mapped.label.as_bytes().to_vec())); + Some(AtnItem { event, dictionary, descriptor: None, ..Default::default() }) +} + +/// Headless execution runs stateless items and refuses settings deliberately left for a dialog. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let id = menu_item_command(p)?; + if !menu_item_needs_settings(id) { + return s.execute(id, json!({})); + } + Err(EngineError::Other(format!("interactive-required: `{id}` needs its menu item dialog; no recorded settings were supplied"))) +} diff --git a/crates/engine/src/cmd/atn_cmds/path_tests.rs b/crates/engine/src/cmd/atn_cmds/path_tests.rs new file mode 100644 index 0000000000..25efc33c0d --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/path_tests.rs @@ -0,0 +1,227 @@ +use super::super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, UnicodeString, atn}; +use super::*; + +/// Construct a pixel point directly from the measured #10706 wire format. +fn wire_point(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y })) +} + +/// Encode a fixture corner without cubic handles. +fn corner(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pthp").with("Anch", wire_point(x, y))) +} + +/// Build the measured outer and inner contours independently of the exporter. +fn fixture(operation: &str, joined: bool, reversed: bool) -> Descriptor { + let mut outer = vec![corner(50.0, 250.0), corner(50.0, 100.0), corner(350.0, 100.0), corner(350.0, 250.0)]; + if !joined { + outer[1] = D::Descriptor( + Descriptor::new("Pthp") + .with("Anch", wire_point(50.0, 100.0)) + .with("Fwd ", wire_point(50.0, 100.0)) + .with("Bwd ", wire_point(100.0, 20.0)) + .with("Smoo", D::Boolean(false)), + ); + outer[2] = D::Descriptor( + Descriptor::new("Pthp") + .with("Anch", wire_point(350.0, 100.0)) + .with("Fwd ", wire_point(300.0, 20.0)) + .with("Bwd ", wire_point(350.0, 100.0)) + .with("Smoo", D::Boolean(false)), + ); + } + let mut inner = vec![corner(150.0, 150.0), corner(250.0, 150.0), corner(250.0, 220.0), corner(150.0, 220.0)]; + if reversed { + inner.reverse(); + } + let sub = |points| D::Descriptor(Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(points))); + let component = |op, subs| { + D::Descriptor( + Descriptor::new("PaCm") + .with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new(op) }) + .with("SbpL", D::List(subs)), + ) + }; + let components = if joined { + vec![component("Add ", vec![sub(outer), sub(inner)])] + } else { + vec![component("Add ", vec![sub(outer)]), component(operation, vec![sub(inner)])] + }; + Descriptor::new(if joined { "null" } else { "setd" }) + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])) + .with("T ", D::List(components)) +} + +/// Extract the single executable Work Path assignment from a measured descriptor. +fn params(d: &Descriptor) -> Value { + let Mapped::Steps(steps) = from(d) else { panic!("measured path must map") }; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].0, "path.set"); + steps[0].1.clone() +} + +/// Sample the filled contour, its hole and the untouched exterior independently. +fn check_area(path: &Path) { + let pixels = photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::new(0, 0, 400, 300)); + for (x, y, expected) in [(100, 185, 1.0), (200, 185, 0.0), (10, 20, 0.0)] { + assert_eq!(pixels[y * 400 + x], expected, "sample {x},{y}"); + } +} + +#[test] +fn measured_curves_and_component_operations_round_trip_and_cut_holes() { + for (operation, expected) in [("Sbtr", PathOp::Subtract), ("xor", PathOp::Exclude)] { + let d = fixture(operation, false, false); + let p = params(&d); + let path = crate::vector_cmds::parse_path(&p["path"]).unwrap(); + assert_eq!(path.fill_rule, FillRule::EvenOdd); + assert_eq!(path.subpaths[1].op, expected); + let knots = &path.subpaths[0].knots; + assert_eq!(knots[1].out_ctrl, Point::new(50.0, 100.0)); + assert_eq!(knots[1].in_ctrl, Point::new(100.0, 20.0)); + assert_eq!(knots[2].out_ctrl, Point::new(300.0, 20.0)); + assert_eq!(knots[2].in_ctrl, Point::new(350.0, 100.0)); + assert_eq!(to(&p), Some(d)); + check_area(&path); + } +} + +#[test] +fn one_component_has_even_odd_holes_with_either_inner_winding() { + for reversed in [false, true] { + let d = fixture("Add ", true, reversed); + let p = params(&d); + let path = crate::vector_cmds::parse_path(&p["path"]).unwrap(); + assert_eq!(path.components(), vec![0..2]); + assert_eq!(path.subpaths[1].op, PathOp::Join); + check_area(&path); + assert_eq!(to(&p), Some(d)); + } +} + +#[test] +fn whole_atn_and_native_insert_path_replay_keep_geometry() { + let original = fixture("Sbtr", false, false); + let file = AtnFile { + actions: vec![AtnAction { + items: vec![AtnItem { event: AtnEvent::name("set"), descriptor: Some(original.clone()), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + let imported = super::super::import(&atn::write(&file).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let (bytes, skipped) = super::super::export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(original.clone())); + let expected = path_from(&original).unwrap(); + let mut s = crate::Session::new(); + s.execute("file.new", json!({"width":400,"height":300})).unwrap(); + s.execute("path.set", json!({"name":"Source","path":crate::vector_cmds::path_json(&expected)})).unwrap(); + s.actions.list.push(Action::new("Captured", Vec::new())); + let revision = s.active().unwrap().revision; + s.execute("actions.insertPath", json!({"action":0,"name":"Source"})).unwrap(); + assert_eq!(s.active().unwrap().revision, revision); + let (bytes, skipped) = super::super::export("S", &[&s.actions.list[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = super::super::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + s.actions.list = imported.actions; + s.execute("path.set", json!({"name":"Source","path":{"subpaths":[]}})).unwrap(); + let played = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().doc.work_path.as_ref(), Some(&expected)); + check_area(s.active().unwrap().doc.work_path.as_ref().unwrap()); +} + +#[test] +fn source_wire_identity_is_retained_only_for_unchanged_geometry() { + let mut d = fixture("xor", false, false); + d.name = UnicodeString::new_nul("Set"); + d.items.reverse(); + let mut p = params(&d); + assert_eq!(to(&p).unwrap().to_bytes(), d.to_bytes()); + p["path"]["subpaths"][0]["knots"][0]["anchor"][0] = json!(51.0); + assert!(to(&p).is_none()); +} + +#[test] +fn unknown_and_unrepresented_settings_are_not_silently_lost() { + let base = fixture("Sbtr", false, false); + let mut empty_components = base.clone(); + let (_, components) = empty_components.items.iter_mut().find(|(key, _)| key.is("T ")).unwrap(); + *components = D::List(Vec::new()); + for d in [base.clone().with("extra", D::Boolean(true)), empty_components, fixture("Intr", false, false)] { + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); + } + let p = params(&base); + let mut empty = p.clone(); + empty["path"]["subpaths"] = json!([]); + empty.as_object_mut().unwrap().remove(SOURCE); + assert!(to(&empty).is_none()); + for key in ["inverted", "fillRule"] { + let mut changed = p.clone(); + changed["path"][key] = if key == "inverted" { json!(true) } else { json!("nonzero") }; + changed.as_object_mut().unwrap().remove(SOURCE); + assert!(to(&changed).is_none()); + } + let mut changed = p.clone(); + changed["path"]["unrepresented"] = json!(1); + assert!(to(&changed).is_none()); + let mut changed = p; + changed[SOURCE] = json!("not base64"); + assert!(to(&changed).is_none()); +} + +#[test] +fn malformed_coordinates_and_oversized_paths_stay_unsupported() { + fn list(value: &mut D) -> &mut Vec { + match value { + D::List(v) => v, + _ => panic!("fixture list"), + } + } + fn object(value: &mut D) -> &mut Descriptor { + match value { + D::Descriptor(v) => v, + _ => panic!("fixture object"), + } + } + for bad in [D::UnitFloat { unit: *b"#Prc", value: 50.0 }, D::UnitFloat { unit: *b"#Pxl", value: f64::NAN }, D::Double(50.0)] { + let mut d = fixture("Sbtr", false, false); + let components = list(&mut d.items[1].1); + let subs = list(&mut object(&mut components[0]).items[1].1); + let points = list(&mut object(&mut subs[0]).items[1].1); + let anchor = object(&mut points[0]); + let coordinates = object(&mut anchor.items[0].1); + coordinates.items[0].1 = bad; + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); + } + let mut p = params(&fixture("Sbtr", false, false)); + p.as_object_mut().unwrap().remove(SOURCE); + let knot = p["path"]["subpaths"][0]["knots"][0].clone(); + p["path"]["subpaths"][0]["knots"] = json!(vec![knot.clone(); MAX_PARTS - 4]); + assert!(to(&p).is_some(), "the existing 4096-knot ceiling remains representable"); + p["path"]["subpaths"][0]["knots"] = json!(vec![knot; MAX_PARTS + 1]); + assert!(to(&p).is_none()); +} + +#[test] +fn oversized_descriptor_names_are_rejected_before_retained_wire_serialization() { + // Each name meets the local limit, but their aggregate exceeds the carrier. + // Keep this typed input independent of the serializer under test. + let mut point = + Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }); + point.name = UnicodeString(vec![b'x' as u16; 4096]); + let mut knot = Descriptor::new("Pthp").with("Anch", D::Descriptor(point)); + knot.name = UnicodeString(vec![b'x' as u16; 4096]); + let sub = Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(vec![D::Descriptor(knot); 300])); + let component = Descriptor::new("PaCm") + .with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new("Add ") }) + .with("SbpL", D::List(vec![D::Descriptor(sub)])); + let d = Descriptor::new("setd") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])) + .with("T ", D::List(vec![D::Descriptor(component)])); + assert!(path_from(&d).is_none(), "recognition must reject before from() serializes the retained descriptor"); + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); +} diff --git a/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs b/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs new file mode 100644 index 0000000000..c63c863b32 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs @@ -0,0 +1,214 @@ +//! #10710: Actions steps recorded with the Gradient tool and Create Droplet, as the issue's table +//! gives them (measured in `docs/atn.md` › "Measured through dialogs and recordings"; once mapped, +//! the events are listed with the supported events instead). + +use super::*; + +fn action(items: Vec) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Recorded"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + }) + .unwrap() +} + +fn step(event: &[u8; 4], label: &str, d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::Code(*event), dictionary: label.as_bytes().to_vec(), descriptor: Some(d), ..Default::default() } +} + +fn saved(s: &mut Session) -> Vec { + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap().actions.remove(0).items +} + +/// The gradient object the Gradient tool records: dither, classic interpolation, angle, type, +/// scale and offset in percent, then the two-stop gradient. +fn tool_gradient(angle: f64, scale: f64, offset: (f64, f64)) -> Descriptor { + let stop = |hex: &str, at: i32| { + D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(color_desc(hex).unwrap())) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", D::Integer(at)) + .with("Mdpn", D::Integer(50)), + ) + }; + let trn = |at: i32| D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50))); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(vec![stop("#102030", 0), stop("#f0e0d0", 4096)])) + .with("Trns", D::List(vec![trn(0), trn(4096)])); + desc("gradientLayer") + .with("Dthr", D::Boolean(true)) + .with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Gcls")) + .with("Angl", ang(angle)) + .with("Type", enm("GrdT", "Lnr ")) + .with("Scl ", pct(scale)) + .with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(offset.0)).with("Vrtc", pct(offset.1)))) + .with("Grad", D::Descriptor(grad)) +} + +/// The Gradient tool (default mode) records a Make of a gradient fill layer at the first click and +/// a Set with the finished drag (`Algn` false). Both load as their commands, play, and save back +/// as recorded. +#[test] +fn gradient_tool_steps_load_play_and_save_back() { + let make = desc("null") + .with("null", class_ref("contentLayer")) + .with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(tool_gradient(90.0, 100.0, (0.0, 0.0)))))); + let set = desc("null") + .with("null", target_ref("contentLayer")) + .with("T ", D::Descriptor(tool_gradient(-35.0, 140.0, (12.5, -8.0)).with("Algn", D::Boolean(false)))); + let items = vec![step(b"Mk ", "Make", make.clone()), step(b"setd", "Set", set.clone())]; + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(items))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["layer.newFillLayer.gradient", "gradient.fill.set"]); + let set_params = &s.actions.list[0].steps[1].1; + assert_eq!( + (set_params["angle"].as_f64(), set_params["scale"].as_f64(), set_params["align"].as_bool()), + (Some(-35.0), Some(140.0), Some(false)), + "{set_params}" + ); + + s.execute("file.new", json!({"width": 20, "height": 12})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(played["ran"], 2, "{played}"); + + let back = saved(&mut s); + assert_eq!(back.len(), 2); + assert_eq!((back[0].event.id(), back[0].descriptor.as_ref()), ("Mk ".to_string(), Some(&make))); + assert_eq!((back[1].event.id(), back[1].descriptor.as_ref()), ("setd".to_string(), Some(&set))); +} + +/// A Create Droplet step as the reference application records it with a Folder destination and +/// Override Action "Save As" on. +fn droplet(file: &str, output: &str, extra: &[(&str, D)]) -> Descriptor { + let reference = D::Reference(vec![ + ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") }, + ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Tools") }, + ]); + let mut d = desc("null").with("In ", path_value(file)).with("Fldr", D::Boolean(false)).with("Usng", reference); + for (k, v) in extra.iter().take_while(|(k, _)| *k == "OvrO") { + d = d.with(k, v.clone()); + } + d = d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true)); + for (k, v) in extra.iter().filter(|(k, _)| *k != "OvrO") { + d = d.with(k, v.clone()); + } + d +} + +/// Create Droplet loads as `file.automate.createDroplet`, plays (writes the droplet with the +/// action's steps) and saves back as recorded. +#[test] +fn create_droplet_step_loads_plays_and_saves_back() { + let dir = std::env::temp_dir().join(format!("pc-10710-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Sharpen.pcdroplet").to_string_lossy().into_owned(); + let out = dir.join("out").to_string_lossy().into_owned(); + let recorded = droplet(&file, &out, &[("OvrO", D::Boolean(true))]); + let mut s = Session::new(); + // The action the droplet runs. + s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![("filter.sharpen.sharpen".into(), json!({}))])); + s.actions.list[0].set = Some("Tools".into()); + let loaded = s + .execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(vec![step(b"CrtD", "Create Droplet", recorded.clone())]))})) + .unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let a = s.actions.list.iter().find(|a| a.name == "A").unwrap(); + assert_eq!( + a.steps, + [("file.automate.createDroplet".to_string(), json!({"path": file, "action": "Sharpen", "set": "Tools", "output": out, "overrideOpen": true}))] + ); + let r = s.execute("file.automate.createDroplet", a.steps[0].1.clone()).unwrap(); + assert_eq!(r["path"], json!(file)); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(written["action"]["steps"], json!([["filter.sharpen.sharpen", {}]])); + assert_eq!((written["options"]["output"].as_str(), written["options"]["overrideOpen"].as_bool()), (Some(out.as_str()), Some(true))); + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + let back = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let item = back.actions.iter().find(|a| a.name.to_string_lossy() == "A").unwrap().items[0].clone(); + assert_eq!((item.event.id(), item.descriptor), ("CrtD".to_string(), Some(recorded))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// What the droplet cannot do keeps the Photoshop step: Include All Subfolders, Save and Close, +/// no destination, Save As steps left to save where recorded, suppressed dialogs, custom naming +/// and an error log. +#[test] +fn create_droplet_steps_the_droplet_cannot_play_are_kept() { + let kept = |d: Descriptor| matches!(steps_droplet::droplet_from(&d), Mapped::Unsupported { harmless: false }); + let base = || droplet("/d/S.pcdroplet", "/d/out", &[]); + let replaced = |key: &str, v: D| { + let mut d = base(); + d.items.retain(|(k, _)| !k.is(key)); + d.with(key, v) + }; + assert!(!kept(base()), "the plain form maps"); + assert!(kept(replaced("Fldr", D::Boolean(true)))); + assert!(kept(replaced("Ovrd", D::Boolean(false)))); + let mut no_destination = base(); + no_destination.items.retain(|(k, _)| !k.is("T ")); + assert!(kept(no_destination.clone())); + assert!(kept(no_destination.with("SvAn", D::Boolean(true)))); + assert!(kept(base().with("suppressOpenOptions", D::Boolean(true)))); + assert!(kept(base().with("fileNamingRules", D::Descriptor(desc("fileNamingRules"))))); + assert!(kept(base().with("Log ", path_value("/d/logs")))); +} + +/// Settings the event has no key for refuse the step on save rather than replay differently. +#[test] +fn create_droplet_steps_that_cannot_be_written_are_refused() { + let p = json!({"path": "/d/S.pcdroplet", "action": "Sharpen", "output": "/d/out"}); + assert!(export_item("file.automate.createDroplet", &p, "S").is_some()); + for extra in [json!({"format": "png"}), json!({"quality": 8}), json!({"steps": [["filter.sharpen.sharpen", {}]]}), json!({"name": "Other"})] { + let mut q = p.clone(); + q.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + assert!(export_item("file.automate.createDroplet", &q, "S").is_none(), "{q}"); + } + let mut no_output = p.clone(); + no_output.as_object_mut().unwrap().remove("output"); + assert!(export_item("file.automate.createDroplet", &no_output, "S").is_none()); +} + +/// Loading a set whose action clashes with one already in the panel renames the loaded copy; its +/// Create Droplet steps follow the rename, as Batch steps do, whether the reference names the +/// action alone or the action in the file's own set (CodeRabbit review on steps_droplet.rs:59). +#[test] +fn create_droplet_steps_follow_a_renamed_loaded_action() { + let with_ref = |items: Vec| { + let mut d = droplet("/d/S.pcdroplet", "/d/out", &[]); + d.items.retain(|(k, _)| !k.is("Usng")); + d.with("Usng", D::Reference(items)) + }; + let actn = || ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") }; + let own_set = ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Recorded") }; + let file = atn::write(&AtnFile { + name: UnicodeString::new_nul("Recorded"), + actions: vec![ + AtnAction { name: UnicodeString::new_nul("Sharpen"), ..Default::default() }, + AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![step(b"CrtD", "Create Droplet", with_ref(vec![actn()])), step(b"CrtD", "Create Droplet", with_ref(vec![actn(), own_set]))], + ..Default::default() + }, + ], + ..Default::default() + }) + .unwrap(); + let mut s = Session::new(); + s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![])); + s.actions.list[0].set = Some("Tools".into()); + let r = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file), "set": "Loaded"})).unwrap(); + assert_eq!(r["actions"], json!(["Sharpen 2", "A"]), "{r}"); + let a = s.actions.list.iter().find(|a| a.name == "A").unwrap(); + assert_eq!(a.steps[0].1["action"], "Sharpen 2", "{:?}", a.steps[0]); + assert_eq!((&a.steps[1].1["action"], &a.steps[1].1["set"]), (&json!("Sharpen 2"), &json!("Loaded")), "{:?}", a.steps[1]); +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_droplet.rs b/crates/engine/src/cmd/atn_cmds/steps_droplet.rs new file mode 100644 index 0000000000..beee14d2f9 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_droplet.rs @@ -0,0 +1,85 @@ +//! File › Automate › Create Droplet (`CrtD`, #10710): the droplet file (`In `), the action +//! (`Usng`) and the Batch keys, as #10710 lists them from the reference application. Only the form +//! `file.automate.createDroplet` plays the same way maps; any other stays a Photoshop step. + +use super::descriptors::{class, desc, jstr, path_text, path_value}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; + +/// The keys a recorded Create Droplet step can carry (the Batch keys, with `In ` for the source). +const KEYS: &[&str] = &["In ", "Fldr", "Usng", "OvrO", "suppressWarnings", "suppressOpenOptions", "T ", "SvAn", "Ovrd", "fileNamingRules", "Log ", "Nm "]; + +fn flag(d: &Descriptor, key: &str) -> Option { + match d.get(key) { + None => Some(false), + Some(D::Boolean(b)) => Some(*b), + Some(_) => None, + } +} + +/// A droplet that saves each file into a folder and overrides the action's Save As steps maps: +/// that is what `file.automate.createDroplet` writes (its output folder replaces recorded saves). +/// Include All Subfolders (the droplet lists a folder's own images only), Save and Close or no +/// destination, Save As steps left to save where they were recorded, suppressed warnings or open +/// options, custom file naming and an error log have no field in the droplet, so they keep the +/// Photoshop step. +pub(super) fn droplet_from(d: &Descriptor) -> Mapped { + read(d).map_or(Mapped::Unsupported { harmless: false }, |p| step("file.automate.createDroplet", Value::Object(p))) +} + +fn read(d: &Descriptor) -> Option> { + if !d.items.iter().all(|(k, _)| KEYS.iter().any(|s| k.is(s))) { + return None; + } + let off = |k: &str| flag(d, k) == Some(false); + if !off("Fldr") || !off("SvAn") || !off("suppressWarnings") || !off("suppressOpenOptions") || flag(d, "Ovrd") != Some(true) { + return None; + } + if ["fileNamingRules", "Log ", "Nm "].iter().any(|k| d.get(k).is_some()) { + return None; + } + let mut p = Map::new(); + p.insert("path".into(), json!(path_text(d.get("In ")?)?)); + let D::Reference(items) = d.get("Usng")? else { return None }; + match items.as_slice() { + [ReferenceItem::Name { class: action, name }, ReferenceItem::Name { class: set, name: set_name }] + if action.class_id.is("Actn") && set.class_id.is("ASet") => + { + p.insert("action".into(), json!(name.to_string_lossy())); + p.insert("set".into(), json!(set_name.to_string_lossy())); + } + [ReferenceItem::Name { class: action, name }] if action.class_id.is("Actn") => { + p.insert("action".into(), json!(name.to_string_lossy())); + } + _ => return None, + } + p.insert("output".into(), json!(path_text(d.get("T ")?)?)); + if flag(d, "OvrO")? { + p.insert("overrideOpen".into(), json!(true)); + } + Some(p) +} + +/// Written as `read` reads it: the droplet file, Include All Subfolders off, the action by name +/// (in its set when the step names one), Override Action "Open" only when on, the destination +/// folder and Override Action "Save As" on. Any other setting refuses the step. +pub(super) fn droplet_to(_: &str, p: &Value) -> Option { + let allowed = ["path", "action", "set", "output", "overrideOpen"]; + if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !allowed.contains(&k.as_str())) { + return None; + } + let path = jstr(p, "path").filter(|s| !s.is_empty())?; + let action = jstr(p, "action").filter(|s| !s.is_empty())?; + let output = jstr(p, "output").filter(|s| !s.is_empty())?; + let mut reference = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(action) }]; + if let Some(set) = p.get("set").filter(|v| !v.is_null()) { + reference.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set.as_str()?) }); + } + let mut d = desc("null").with("In ", path_value(path)).with("Fldr", D::Boolean(false)).with("Usng", D::Reference(reference)); + match p.get("overrideOpen") { + None | Some(Value::Null) | Some(Value::Bool(false)) => {} + Some(Value::Bool(true)) => d = d.with("OvrO", D::Boolean(true)), + Some(_) => return None, + } + Some(d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true))) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index 215fab4a67..e844ce7daf 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -600,11 +600,12 @@ pub(crate) const RECORDED_EVENT: &str = "_atnEvent"; /// Remember the event of a step loaded from an effect's own event (`AccE`, `accentedEdges`). /// A `GEfc` step carries nothing and saves as `GEfc`, as does a step made in PhotoCraft. pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { - if !is_effect(&item.event) { - return; - } let [(id, Value::Object(p))] = steps else { return }; - if effect_command(&item.event) != Some(id.as_str()) { + let menu = id == super::MENU_ITEM + && item.descriptor.is_none() + && super::menu_item::from_event(&item.event) + .is_some_and(|expected| p.get("id") == expected.get("id") && p.get("eventKind") == expected.get("eventKind")); + if !menu && effect_command(&item.event) != Some(id.as_str()) { return; } let kind = match item.event { @@ -617,8 +618,8 @@ pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary(), "dictionary": dictionary})); } -/// The event and panel name bytes a step of command `id` remembered by [`remember_event`] saves -/// as; `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect. +/// Return remembered event/name bytes only while they still match the current command and kind. +/// Otherwise the exporter uses that command's canonical event and name. pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec)> { let r = params.get(RECORDED_EVENT)?; let event = jstr(r, "event")?; @@ -631,7 +632,13 @@ pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec< Some(bytes) => bytes, None => jstr(r, "name").unwrap_or("").as_bytes().to_vec(), }; - (effect_command(&event) == Some(id)).then_some((event, name)) + let matches = if id == super::MENU_ITEM { + super::menu_item::from_event(&event) + .is_some_and(|expected| params.get("id") == expected.get("id") && params.get("eventKind") == expected.get("eventKind")) + } else { + effect_command(&event) == Some(id) + }; + matches.then_some((event, name)) } /// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 7e26df4c16..c331d1c585 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -1164,6 +1164,7 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; let unsupported = Mapped::Unsupported { harmless: false }; match (ref_class(r).as_deref(), target(r)) { + (Some("Path"), Target::Property(p)) if p == "WrPt" => super::steps_path::from(d), (Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d), // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), @@ -1270,6 +1271,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]); let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t); Some(match id { + "path.set" => return super::steps_path::to(p), "select.all" => sel(enm("Ordn", "Al ")), "select.deselect" => sel(enm("Ordn", "None")), "select.reselect" => sel(enm("Ordn", "Prvs")), diff --git a/crates/engine/src/cmd/atn_cmds/steps_path.rs b/crates/engine/src/cmd/atn_cmds/steps_path.rs new file mode 100644 index 0000000000..584f5b6c9d --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_path.rs @@ -0,0 +1,212 @@ +//! Measured Insert Path: per-component operations, with even-odd subpaths in each component. +use super::descriptors::{PX, class, desc, enm, get_list, get_ref, jstr, px}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Id, ReferenceItem, Value, json}; +use photocraft_doc::{FillRule, Knot, Path, PathOp, Subpath}; +use photocraft_geom::Point; + +pub(super) const SOURCE: &str = "_atnInsertPathDescriptor"; +// The native path validator admits at most 4096 knots/subpaths. Larger wire +// records remain complete kept steps rather than secondary geometry allocations. +const MAX_PARTS: usize = 4096; +const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024; + +#[cfg(test)] +#[path = "path_tests.rs"] +mod tests; + +/// Bound the retained wire form before serializing it into a second allocation. +/// Names use UTF-16; 64 bytes per measured field conservatively covers its fixed +/// key/type/scalar or list header. Nested descriptors charge their own fields. +fn keys(d: &Descriptor, required: &[&str], optional: &[&str], budget: &mut usize) -> bool { + let size = d.name.0.len().checked_mul(2).and_then(|names| d.items.len().checked_mul(64)?.checked_add(names)?.checked_add(32)); + let Some(remaining) = size.and_then(|size| budget.checked_sub(size)) else { return false }; + *budget = remaining; + d.name.0.len() <= 4096 + && d.items.iter().all(|(k, _)| required.iter().chain(optional).any(|key| k.is(key))) + && required.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1) + && optional.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() <= 1) +} + +/// Require the recorded object class before interpreting its fields as geometry. +fn object<'a>(value: &'a D, class_id: &str) -> Option<&'a Descriptor> { + match value { + D::Descriptor(d) if d.class_id.is(class_id) => Some(d), + _ => None, + } +} + +/// Read a pixel point within the native coordinate range and charge its fields to the wire budget. +fn point(value: &D, budget: &mut usize) -> Option { + let d = object(value, "Pnt ")?; + if !keys(d, &["Hrzn", "Vrtc"], &[], budget) { + return None; + } + let coordinate = |key| match d.get(key)? { + D::UnitFloat { unit, value } if *unit == PX && value.is_finite() && value.abs() <= f64::from(f32::MAX) => Some(*value), + _ => None, + }; + Some(Point::new(coordinate("Hrzn")?, coordinate("Vrtc")?)) +} + +/// A corner stores only its anchor. A cubic knot must supply both handles and its smooth flag. +fn knot(value: &D, budget: &mut usize) -> Option { + let d = object(value, "Pthp")?; + if !keys(d, &["Anch"], &["Fwd ", "Bwd ", "Smoo"], budget) { + return None; + } + let anchor = point(d.get("Anch")?, budget)?; + match (d.get("Fwd "), d.get("Bwd "), d.get("Smoo")) { + (None, None, None) => Some(Knot::corner(anchor.x, anchor.y)), + (Some(out), Some(input), Some(D::Boolean(smooth))) => { + Some(Knot { anchor, out_ctrl: point(out, budget)?, in_ctrl: point(input, budget)?, smooth: *smooth }) + } + _ => None, + } +} + +/// Decode a Work Path property assignment, preserving the grouping and operation of each component. +fn path_from(d: &Descriptor) -> Option { + let mut budget = MAX_SOURCE_BYTES; + if !(d.class_id.is("null") || d.class_id.is("setd")) || !keys(d, &["null", "T "], &[], &mut budget) { + return None; + } + let Some([ReferenceItem::Property { class, key }]) = get_ref(d, "null") else { return None }; + if !class.class_id.is("Path") || class.name.0.len() > 4096 || !key.is("WrPt") { + return None; + } + budget = budget.checked_sub(class.name.0.len().checked_mul(2)?.checked_add(64)?)?; + let components = get_list(d, "T ")?; + if components.is_empty() || components.len() > MAX_PARTS { + return None; + } + let mut path = Path { fill_rule: FillRule::EvenOdd, ..Default::default() }; + let mut count = 0usize; + for value in components { + let component = object(value, "PaCm")?; + if !keys(component, &["shapeOperation", "SbpL"], &[], &mut budget) { + return None; + } + let op = match component.get("shapeOperation")? { + D::Enumerated { type_id, value } if type_id.is("shapeOperation") => { + if value.is("Add ") { + PathOp::Combine + } else if value.is("Sbtr") { + PathOp::Subtract + } else if value.is("xor") { + PathOp::Exclude + } else { + return None; + } + } + _ => return None, + }; + if path.subpaths.is_empty() && op != PathOp::Combine { + return None; + } + let subpaths = get_list(component, "SbpL")?; + if subpaths.is_empty() || path.subpaths.len().checked_add(subpaths.len())? > MAX_PARTS { + return None; + } + for (i, value) in subpaths.iter().enumerate() { + let sub = object(value, "Sbpl")?; + if !keys(sub, &["Clsp", "Pts "], &[], &mut budget) { + return None; + } + let D::Boolean(closed) = sub.get("Clsp")? else { return None }; + let points = get_list(sub, "Pts ")?; + count = count.checked_add(points.len())?; + if count > MAX_PARTS { + return None; + } + let knots = points.iter().map(|value| knot(value, &mut budget)).collect::>>()?; + path.subpaths.push(Subpath { closed: *closed, knots, op: if i == 0 { op } else { PathOp::Join } }); + } + } + photocraft_vector::flatten::validate_path(&path).ok()?; + Some(path) +} + +/// Decode only the measured Work Path form; unsupported data stays a complete kept step. +pub(super) fn from(d: &Descriptor) -> Mapped { + let Some(path) = path_from(d) else { return Mapped::Unsupported { harmless: false } }; + let raw = d.to_bytes(); + if raw.len() > MAX_SOURCE_BYTES { + return Mapped::Unsupported { harmless: false }; + } + step("path.set", json!({"name":"work", "path":crate::vector_cmds::path_json(&path), SOURCE:photocraft_paint::tile::b64_encode(&raw)})) +} + +/// Write a point using pixel units for both coordinates. +fn point_to(p: Point) -> D { + D::Descriptor(desc("Pnt ").with("Hrzn", px(p.x)).with("Vrtc", px(p.y))) +} + +/// Emit the compact corner form only when the handles coincide and the knot has no smooth flag. +fn knot_to(k: &Knot) -> D { + let mut d = desc("Pthp").with("Anch", point_to(k.anchor)); + if k.in_ctrl != k.anchor || k.out_ctrl != k.anchor || k.smooth { + d = d.with("Fwd ", point_to(k.out_ctrl)).with("Bwd ", point_to(k.in_ctrl)).with("Smoo", D::Boolean(k.smooth)); + } + D::Descriptor(d) +} + +/// Export representable native geometry or an unchanged validated source descriptor. +/// Nonzero winding, inversion and unmeasured operations cannot silently become even-odd. +pub(super) fn to(p: &Value) -> Option { + if jstr(p, "name") != Some("work") || p.as_object()?.keys().any(|k| !matches!(k.as_str(), "name" | "path" | SOURCE)) { + return None; + } + let value = p.get("path")?; + let subpaths = value.get("subpaths")?.as_array()?; + if subpaths.is_empty() + || subpaths.len() > MAX_PARTS + || subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.get("knots")?.as_array()?.len()))? > MAX_PARTS + { + return None; + } + let path = crate::vector_cmds::parse_path(value).ok()?; + photocraft_vector::flatten::validate_path(&path).ok()?; + let mut canonical = crate::vector_cmds::path_json(&path); + let mut supplied = value.clone(); + super::convert::normalise(&mut canonical); + super::convert::normalise(&mut supplied); + if path.inverted || path.fill_rule != FillRule::EvenOdd || canonical != supplied { + return None; + } + if let Some(source) = p.get(SOURCE) { + let text = source.as_str()?; + if text.len() > MAX_SOURCE_BYTES.div_ceil(3) * 4 { + return None; + } + let raw = photocraft_paint::tile::b64_decode(text)?; + if raw.len() > MAX_SOURCE_BYTES { + return None; + } + let original = Descriptor::from_bytes(&raw).ok()?; + return (path_from(&original)? == path).then_some(original); + } + let mut components = Vec::new(); + for range in path.components() { + let first = path.subpaths.get(range.start)?; + let op = match first.op { + PathOp::Combine => "Add ", + PathOp::Subtract => "Sbtr", + PathOp::Exclude => "xor", + _ => return None, + }; + if components.is_empty() && first.op != PathOp::Combine { + return None; + } + let subs = path + .subpaths + .get(range)? + .iter() + .map(|s| D::Descriptor(desc("Sbpl").with("Clsp", D::Boolean(s.closed)).with("Pts ", D::List(s.knots.iter().map(knot_to).collect())))) + .collect(); + components.push(D::Descriptor(desc("PaCm").with("shapeOperation", enm("shapeOperation", op)).with("SbpL", D::List(subs)))); + } + Some( + desc("setd").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])).with("T ", D::List(components)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 90b965d939..27b6f6500b 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -600,10 +600,11 @@ fn a_selective_color_step_in_the_scalar_form_keeps_its_values() { assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}"); } -/// A setting Photoshop's event has no field for is dropped by export, and the step then plays back +/// A setting the exported event has no field for is dropped by export, and the step then plays back /// as a different edit: Crystallize's `seed` (#7668), Radial Blur's centre, Minimum's and -/// Maximum's `preserve`. Such a step is refused, as one with no equivalent event already is. So is -/// an adjustment edit that names its layer: the event edits the active one (#8532). +/// Maximum's `preserve`, Equalize's `selectedAreaOnly`. Such a step is refused, as one with no +/// equivalent event already is. So is an adjustment edit that names its layer: the event edits the +/// active one (#8532). #[test] fn a_setting_the_event_cannot_hold_is_refused() { for (id, params) in [ @@ -611,6 +612,10 @@ fn a_setting_the_event_cannot_hold_is_refused() { ("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.25, "centerY": 0.5})), ("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})), ("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})), + ("image.adjustments.equalize", json!({"selectedAreaOnly": false})), + // Equalize reads `selectedAreaOnly` as a boolean only and refuses the string and null. + ("image.adjustments.equalize", json!({"selectedAreaOnly": "true"})), + ("image.adjustments.equalize", json!({"selectedAreaOnly": null})), ("layer.setAdjustment", json!({"kind": "exposure", "layer": "Target_existing", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})), ("layer.setAdjustment", json!({"kind": "exposure", "document": 2, "exposure": -0.75})), ] { @@ -645,6 +650,8 @@ fn settings_at_the_value_the_event_plays_still_export() { // review). ("filter.other.minimum", json!({"radius": 4, "preserve": "Squareness"})), ("filter.other.maximum", json!({"radius": 4, "preserve": "Roundness"})), + ("image.adjustments.equalize", json!({"selectedAreaOnly": true})), + ("image.adjustments.equalize", json!({})), ] { let mut s = Session::new(); s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())])); @@ -734,6 +741,7 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("file.automate.createDroplet", json!({"path": "/d/Sharpen.pcdroplet", "action": "Sharpen", "set": "Tools", "output": "/d/out", "overrideOpen": true})), ("filter.distort.shear", json!({"points": [[0,0], [0.5,0.25], [1,0]], "undefinedAreas": "repeat"})), ("filter.distort.pinch", json!({"amount": -30})), ("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})), @@ -827,6 +835,13 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("type.convertToShape", json!({})), ("type.createWorkPath", json!({})), + ( + "path.set", + json!({"name":"work","path":{"fillRule":"evenodd","inverted":false,"subpaths":[{"closed":false,"op":"combine","knots":[ + {"anchor":[1,2],"in":[1,2],"out":[3,4],"smooth":false}, + {"anchor":[8,9],"in":[6,7],"out":[8,9],"smooth":false} + ]}]}}), + ), ("filter.blur.shapeBlur", json!({})), ("filter.blur.shapeBlur", json!({"radius": 10})), ("layer.new.group", json!({"name": "G"})), @@ -1107,6 +1122,10 @@ fn every_mapped_command_exports_and_imports_back() { // Executable values match; independent public tests verify the source carrier. back.as_object_mut().unwrap().remove("__atnClassicGradient"); } + if *id == "path.set" { + // The imported step retains the original descriptor alongside its playable path. + back.as_object_mut().unwrap().remove(super::steps_path::SOURCE).unwrap(); + } if *id == "edit.perspectiveWarp" { back.as_object_mut().unwrap().remove("__atnPerspectiveWarp"); } @@ -1400,6 +1419,7 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + item("GsnB", "Gaussian Blur", Some(desc("null"))), // Duplicate into another open document: the `T` reference means nothing at // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. item( @@ -1421,8 +1441,11 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); - assert_eq!(imp.unsupported.len(), 5); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.actions[0].steps[4].1["id"], "filter.blur.gaussianBlur"); + assert_eq!(imp.actions[0].steps[4].1["eventKind"], "code"); + assert_eq!(imp.actions[0].steps[5].1, json!({})); + assert_eq!(imp.unsupported.iter().map(|step| step.step).collect::>(), [0, 1, 2, 3, 6]); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index e09ff2098c..61a35e5150 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -1105,6 +1105,8 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { } b = merge_brush(&b, &patch, cmd)?; validate_brush(&b, cmd)?; + // The painting tools' Opacity takes 1-100 %, as in Photoshop: a 0 % brush paints nothing (#11812). + b.opacity = b.opacity.max(0.01); // Loading a stored tip can fail. Finish preparing the brush before changing its identity or // foreground colour, so a failed pick leaves the entire current brush state intact. s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; @@ -1170,8 +1172,8 @@ pub fn remembered_settings(b: &BrushSettings) -> Option { /// A brush remembered by [`remembered_settings`], read back at launch: `None` when the value is /// not a brush or fails the checks `tools.setBrush` makes (a size or scatter out of range). The -/// options-bar values are clamped to the ranges their controls allow (size 1..max px; hardness, -/// opacity, flow and smoothing 0..100 %), and stored tips load when the store is attached (else +/// options-bar values are clamped to the ranges their controls allow (size 1..max px; opacity +/// 1..100 %; hardness, flow and smoothing 0..100 %), and stored tips load when the store is attached (else /// they paint with their previews until `Session::attach_preset_store`). pub fn restored_settings(s: &Session, v: &Value) -> Option { if !v.is_object() { @@ -1183,6 +1185,9 @@ pub fn restored_settings(s: &Session, v: &Value) -> Option { for f in [&mut b.hardness, &mut b.opacity, &mut b.flow, &mut b.smoothing.amount] { *f = if f.is_nan() { 1.0 } else { f.clamp(0.0, 1.0) }; } + // Opacity takes 1-100 %, as `tools.setBrush` floors it: a brush remembered at 0 % (saved + // before that floor) would show 1 % but paint nothing (#11812). + b.opacity = b.opacity.max(0.01); let _ = s.load_brush_tips(&mut b); Some(b) } @@ -1365,7 +1370,7 @@ pub fn specs() -> Vec { spec!( "tools.setBrush", "Set Brush", - r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias)}"##, + r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias; opacity floors at 0.01)}"##, always, set_brush, true diff --git a/crates/engine/src/cmd/brush_cmds/tests.rs b/crates/engine/src/cmd/brush_cmds/tests.rs index f72a0e207b..3abf932823 100644 --- a/crates/engine/src/cmd/brush_cmds/tests.rs +++ b/crates/engine/src/cmd/brush_cmds/tests.rs @@ -1380,6 +1380,22 @@ fn restored_brush_settings_are_clamped_or_rejected() { } } +/// #11812: a brush remembered at 0 % opacity (saved before the 1 % floor) reads back at 1 %, the +/// value its options-bar field shows, so it paints instead of laying down nothing. +#[test] +fn restored_brush_opacity_floors_at_one_percent() { + let s = Session::new(); + for opacity in [0.0, -1.0, 0.004] { + let b = restored_settings(&s, &json!({"opacity": opacity})).unwrap(); + assert_eq!(b.opacity, 0.01, "{opacity}"); + } + assert_eq!(restored_settings(&s, &json!({"opacity": 0.37})).unwrap().opacity, 0.37, "values above the floor are kept"); + let mut t = Session::new(); + t.prefs.edit(|p| p.last_brush = json!({"brush": {"opacity": 0.0}})); + restore_brush(&mut t); + assert_eq!(t.tools.brush.opacity, 0.01, "the remembered session brush"); +} + #[test] fn remembered_brushes_never_embed_large_bitmaps() { use std::sync::Arc; diff --git a/crates/engine/src/cmd/distort_cmds.rs b/crates/engine/src/cmd/distort_cmds.rs index 7d820f0620..6eb7949c57 100644 --- a/crates/engine/src/cmd/distort_cmds.rs +++ b/crates/engine/src/cmd/distort_cmds.rs @@ -14,7 +14,7 @@ use std::cell::RefCell; use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, apply_liquify, auto_cell}; use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten}; -use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask}; +use photocraft_algo::puppet::{PuppetDensity, PuppetMesh, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask, warp_with_mesh}; use photocraft_algo::transform::Interp; use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface}; use photocraft_color::PixelFormat; @@ -86,6 +86,51 @@ pub fn puppet_params(cmd: &str, p: &Value) -> Result { Ok(PuppetWarp { pins, mode, density, expansion }) } +/// The most vertices a stored Puppet Warp mesh may have (Photoshop's meshes have hundreds); the +/// PSD importer reads no more. +const MAX_STORED_VERTS: usize = 1 << 16; + +/// A Puppet Warp's stored mesh, as a Photoshop file keeps it (`mesh`: `{"pins", "verts", +/// "deformed", "tris"}`, document pixels): the mesh solved for `pins`, drawn as it is instead of +/// solving again. `None` when there is none, it is malformed, or the filter was edited since: its +/// pins are no longer the ones it was solved for, or a solver setting is set (imported params +/// set none). +pub fn stored_puppet_mesh(p: &Value) -> Option<(PuppetMesh, Vec<[f64; 2]>)> { + let m = p.get("mesh")?; + if m.get("pins") != p.get("pins") || ["mode", "density", "expansion"].iter().any(|k| p.get(*k).is_some()) { + return None; + } + let points = |k: &str| -> Option> { + let v = m.get(k)?.as_array()?; + if v.len() > MAX_STORED_VERTS { + return None; + } + v.iter().map(|q| serde_json::from_value::<[f64; 2]>(q.clone()).ok()).collect() + }; + let (verts, deformed) = (points("verts")?, points("deformed")?); + let tris = m.get("tris")?.as_array()?; + if tris.len() > 2 * MAX_STORED_VERTS { + return None; + } + let tris: Vec<[usize; 3]> = tris.iter().map(|t| serde_json::from_value(t.clone()).ok()).collect::>()?; + Some((PuppetMesh { verts, tris, spacing: 0.0 }, deformed)) +} + +/// `params` of smart filter `command` on `sm`, without a stored Puppet Warp mesh made for another +/// placement than `sm`'s (`mesh.placement`, recorded on import): a mesh in document pixels doesn't +/// fit a smart object moved, transformed or warped since, so its pins are solved instead. +pub fn placed_puppet_params<'a>(command: &str, params: &'a Value, sm: &photocraft_doc::SmartObject) -> std::borrow::Cow<'a, Value> { + let stale = command == PUPPET_SMART && params.get("mesh").is_some_and(|m| m.get("placement") != Some(&sm.placement_json())); + if !stale { + return std::borrow::Cow::Borrowed(params); + } + let mut p = params.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("mesh"); + } + std::borrow::Cow::Owned(p) +} + /// Perspective Warp params (planes, after the optional straighten). pub fn perspective_params(cmd: &str, p: &Value) -> Result> { let mut planes: Vec = match p.get("planes") { @@ -182,7 +227,17 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect) Some(liquify_surface(surf, &strokes, cell, canvas)) } // A stored warp too far to render leaves the smart object as it is (#1019). - PUPPET | PUPPET_SMART => Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())), + PUPPET | PUPPET_SMART => { + // A mesh stored with the filter (from a Photoshop file) is drawn as it is; one that + // can't be drawn leaves the pins to solve. + let b = surf.content_bounds(); + if let Some((mesh, deformed)) = stored_puppet_mesh(params).filter(|_| !b.is_empty()) + && let Some(out) = warp_with_mesh(surf, b, &mesh, &deformed, interp(params)) + { + return Some(out); + } + Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())) + } PERSPECTIVE | PERSPECTIVE_SMART => Some(perspective_surface(surf, &perspective_params(id, params).ok()?, interp(params))), _ => None, } diff --git a/crates/engine/src/cmd/distort_cmds/tests.rs b/crates/engine/src/cmd/distort_cmds/tests.rs index 81d17c28c2..851b785814 100644 --- a/crates/engine/src/cmd/distort_cmds/tests.rs +++ b/crates/engine/src/cmd/distort_cmds/tests.rs @@ -473,3 +473,71 @@ fn unlocked_or_position_locked_smart_objects_take_distortions() { } } } + +#[test] +fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() { + // A Photoshop file's Puppet Warp carries its solved mesh: it is drawn as it is (here a whole + // move by (2, 1), although one pin alone would move the block by (6, 4)), until the pins + // change; then the pins are solved. + let surf = active_surface(&session(8)); + let canvas = Rect::new(0, 0, 80, 60); + let pins = json!([{"src": [30, 25], "dst": [36, 29]}]); + let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]]; + let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); + let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]]}); + let shifted = |dx, dy| photocraft_algo::resample::translate_surface(&surf, dx, dy); + let p = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh}); + let out = apply_to_surface(PUPPET_SMART, &p, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(12, 11, 62, 41)); + assert!(worst(&out, &shifted(2, 1), Rect::new(12, 11, 62, 41)) <= 1.0 / 255.0); + // Pins edited after import: the stored mesh no longer applies. + let mut edited = p.clone(); + edited["mesh"]["pins"][0]["dst"] = json!([40, 29]); + let out = apply_to_surface(PUPPET_SMART, &edited, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + // So does a solver setting set after import (imported params set none). + for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] { + let mut q = p.clone(); + q[k] = v; + let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44), "{k}"); + } + // A malformed mesh is ignored the same way. + for bad in [ + json!({"pins": pins, "verts": verts, "deformed": [[0, 0]], "tris": [[0, 1, 2]]}), + json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 9]]}), + ] { + let mut q = p.clone(); + q["mesh"] = bad; + let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + } +} + +#[test] +fn a_stored_puppet_mesh_is_drawn_only_on_the_placement_it_was_made_for() { + // The mesh sits in document pixels where the layer was on import (`mesh.placement`); once the + // smart object is moved, transformed or warped, its pins are solved instead. + let surf = active_surface(&session(8)); + let canvas = Rect::new(0, 0, 80, 60); + let pins = json!([{"src": [30, 25], "dst": [36, 29]}]); + let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]]; + let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); + let src = photocraft_doc::SmartSource::Linked { path: "x.png".into() }; + let mut sm = photocraft_doc::SmartObject::new(src, photocraft_geom::Affine::IDENTITY, None); + let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]], "placement": sm.placement_json()}); + let params = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh}); + sm.smart_filters.push(SmartFilter { command: PUPPET_SMART.into(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }); + let render = |sm: &photocraft_doc::SmartObject| crate::smart_cmds::apply_smart_filters(&surf, sm, canvas).unwrap().content_bounds(); + assert_eq!(render(&sm), Rect::new(12, 11, 62, 41), "the stored mesh"); + let mut placed = sm.clone(); + placed.transform = photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 4.0, 0.0] }; + assert_eq!(render(&placed), Rect::new(16, 14, 66, 44), "moved: solved"); + let mut warped = sm.clone(); + warped.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 50.0, 30.0])); + assert_eq!(render(&warped), Rect::new(16, 14, 66, 44), "warped: solved"); + // A mesh with no placement recorded is treated the same way. + let mut bare = sm.clone(); + bare.smart_filters[0].params["mesh"].as_object_mut().unwrap().remove("placement"); + assert_eq!(render(&bare), Rect::new(16, 14, 66, 44), "no placement: solved"); +} diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index 38eb4aed5e..dbf1a9a6f1 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -989,12 +989,16 @@ fn guide_position(s: &Session, p: &Value, vertical: bool) -> Result> let px = match (v.as_f64(), v.as_str()) { (Some(n), _) => n, (None, Some(text)) => { - let (n, unit) = crate::prefs::Unit::parse_length(text).ok_or_else(|| { - EngineError::Other(format!("`position` {text:?} is not a length: give a number of pixels, or a number with px, in, cm, mm, pt, pc or %")) - })?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let extent = if vertical { doc.size.width } else { doc.size.height }; - unit.unwrap_or(crate::prefs::Unit::Pixels).to_px(n, doc.resolution_dpi as f64, extent as f64, s.prefs().units_and_rulers.point_size.per_inch()) + let per_inch = s.prefs().units_and_rulers.point_size.per_inch(); + // A length, or arithmetic on lengths as Photoshop's field takes ("100% / 3", #10440). + let to_px = |n: f64, unit: crate::prefs::Unit| unit.to_px(n, doc.resolution_dpi as f64, extent as f64, per_inch); + crate::prefs::Unit::eval_length(text, crate::prefs::Unit::Pixels, to_px).ok_or_else(|| { + EngineError::Other(format!( + "`position` {text:?} is not a length: give a number of pixels, a number with px, in, cm, mm, pt, pc or %, or arithmetic on them (+ - * /)" + )) + })? } _ => return Err(EngineError::Other("`position` must be a number of pixels or text with a unit".into())), }; diff --git a/crates/engine/src/cmd/edit_cmds/tests.rs b/crates/engine/src/cmd/edit_cmds/tests.rs index 288dcb5266..089a9873ba 100644 --- a/crates/engine/src/cmd/edit_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_cmds/tests.rs @@ -1292,6 +1292,56 @@ fn guide_positions_take_photoshop_units() { assert_eq!(s.active().unwrap().doc.guides.horizontal[0], 50.0); } +/// #10440: the position takes arithmetic as Photoshop's New Guide field does. Measured in +/// Photoshop 2025 on a 600 × 400, 72 ppi document: "100% / 3" → 200, "100% - 50px" → 550, +/// "300 / 2" → 150 (pixels), "1in + 10" → 792 (the bare 10 is in the first operand's unit). +#[test] +fn guide_positions_take_arithmetic() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + for (text, px) in [ + ("100% / 3", 200.0), + ("100% - 50px", 550.0), + ("300 / 2", 150.0), + ("1in + 10", 792.0), + ("2 * 50 px", 100.0), + ("10 + 2 * 3", 16.0), + ("-50px + 100", 50.0), + ] { + assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}"); + } + // Horizontal guides take percent of the height. + assert!((new_guide(&mut s, "horizontal", json!("50% + 10px")) - 210.0).abs() < 1e-6); + for bad in ["100% /", "1in * 2in", "10 / 0", "10 / 2px", "5 ^ 2", "+", "50% 3"] { + let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": bad})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{bad}: {err}"); + } +} + +/// The Unicode minus (U+2212, what some keyboards and pasted text give) is a minus, as in the +/// numeric fields' own evaluator. +#[test] +fn guide_position_arithmetic_takes_the_unicode_minus() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + for (text, px) in [("100% − 50px", 550.0), ("−50px + 100", 50.0), ("1in − −10", 792.0), ("−100", -100.0)] { + assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}"); + } +} + +/// Text longer than the numeric fields' 1024-byte cap is refused before it is tokenised. +#[test] +fn guide_position_arithmetic_is_capped_at_1024_bytes() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + let fits = format!("{}100", "0 + ".repeat(255)); + assert_eq!(fits.len(), 1023); + assert_eq!(new_guide(&mut s, "vertical", json!(fits)), 100.0); + let long = format!("{}100", "0 + ".repeat(256)); + let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": long})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{err}"); +} + #[test] fn new_guides_keep_their_own_colour_through_moves_deletes_and_undo() { let mut s = Session::new(); @@ -1381,6 +1431,26 @@ fn editing_a_guide_changes_what_is_given_in_one_undoable_step() { assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff])); } +/// #10440: Edit Selected Guides reads its position as New Guide does, arithmetic included; % is of +/// the new orientation's extent. +#[test] +fn editing_a_guide_takes_arithmetic_in_its_position() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap(); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "100% / 4"})).unwrap(); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "1in + 1"})).unwrap(); + assert_eq!(s.active().unwrap().doc.guides.vertical, vec![100.0, 200.0]); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "newOrientation": "horizontal", "position": "50% − 10px"})).unwrap(); + let g = &s.active().unwrap().doc.guides; + assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![200.0], vec![90.0])); + let before = g.clone(); + let err = s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "1in * 2in"})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{err}"); + assert_eq!(s.active().unwrap().doc.guides, before); +} + #[test] fn bad_guide_edits_are_refused_without_a_history_step() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit_menu_cmds.rs b/crates/engine/src/cmd/edit_menu_cmds.rs index b4287e649e..98ea8b4619 100644 --- a/crates/engine/src/cmd/edit_menu_cmds.rs +++ b/crates/engine/src/cmd/edit_menu_cmds.rs @@ -313,7 +313,7 @@ pub use content_aware::{CafPlan, FillEdit, MAX_CAF_EDITS, MAX_CAF_POINTS, SelOp, use content_aware::{can_caf, content_aware_fill, delete_and_fill}; pub use content_aware_scale::ContentAwareScalePreview; use content_aware_scale::content_aware_scale; -pub use fade::fade_surface; +pub use fade::{FadePreview, fade_preview, fade_surface}; use fade::{can_fade, fade}; pub use find_replace::find_matches; use find_replace::{find_replace, has_type_layer}; diff --git a/crates/engine/src/cmd/edit_menu_cmds/fade.rs b/crates/engine/src/cmd/edit_menu_cmds/fade.rs index 14f9b8365d..ad767c584c 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/fade.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/fade.rs @@ -1,5 +1,5 @@ use super::{ - BlendMode, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of, + BlendMode, Document, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of, surface_of_mut, to_rgba, }; @@ -67,7 +67,18 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble Ok(()) } -pub(super) fn fade(s: &mut Session, p: &Value) -> Result { +/// What Edit › Fade with `p` blends: the layer, mask or channel of the last step, its pixels +/// before it, the area to blend, the mode and the opacity. +struct FadeJob { + target: FadeTarget, + label: String, + before: Surface, + area: Rect, + mode: BlendMode, + opacity: f32, +} + +fn fade_job(s: &Session, p: &Value) -> Result { let cmd = "edit.fade"; let src = s.edit_state.fade.clone().ok_or_else(|| bad(cmd, "nothing to fade"))?; let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0; @@ -82,12 +93,45 @@ pub(super) fn fade(s: &mut Session, p: &Value) -> Result { // A uniform mask can change every pixel without storing a single tile. In that case // the content bounds are empty, but Fade must still blend across the canvas. let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; - let label = format!("Fade {}", src.label); - s.edit(&label, |doc, _| { - let surf = surface_of_mut(doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; - fade_surface(&before, surf, area, mode, opacity)?; - surf.prune(); - Ok(()) - })?; - Ok(json!({"faded": src.label, "opacity": opacity * 100.0, "mode": mode.label()})) + Ok(FadeJob { target: src.target, label: src.label, before, area, mode, opacity }) +} + +fn apply_fade(doc: &mut Document, j: &FadeJob) -> Result<()> { + let surf = surface_of_mut(doc, j.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; + fade_surface(&j.before, surf, j.area, j.mode, j.opacity)?; + surf.prune(); + Ok(()) +} + +pub(super) fn fade(s: &mut Session, p: &Value) -> Result { + let j = fade_job(s, p)?; + let label = format!("Fade {}", j.label); + s.edit(&label, |doc, _| apply_fade(doc, &j))?; + Ok(json!({"faded": j.label, "opacity": j.opacity * 100.0, "mode": j.mode.label()})) +} + +/// Edit › Fade with given params, captured from the session without a history step: the Fade +/// dialog's live preview (#8230). [`FadePreview::render`] does the blend and can run off the +/// session (the preview worker). The document copy shares its tiles with the session's, and +/// only the tiles in the faded area are blended and rewritten. +pub struct FadePreview { + doc: Document, + job: FadeJob, +} + +impl FadePreview { + /// The active document as Edit › Fade would leave it. + pub fn render(mut self) -> Result { + apply_fade(&mut self.doc, &self.job)?; + Ok(self.doc) + } +} + +/// What Edit › Fade with `p` would do to the active document. `Err` when Fade isn't available +/// or `p` is invalid. +pub fn fade_preview(s: &Session, p: &Value) -> Result { + can_fade(s).map_err(|e| bad("edit.fade", e))?; + let job = fade_job(s, p)?; + let doc = Document::clone(&s.active().ok_or(EngineError::NoDocument)?.doc); + Ok(FadePreview { doc, job }) } diff --git a/crates/engine/src/cmd/edit_menu_cmds/tests.rs b/crates/engine/src/cmd/edit_menu_cmds/tests.rs index cff0d0d315..069ff2573c 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/tests.rs @@ -23,6 +23,33 @@ fn active(s: &Session) -> &Layer { st.doc.layer(st.active_layer.unwrap()).unwrap() } +/// #8230: the Fade dialog's preview is the document Fade would leave, at every depth and in a +/// blend mode, with no history step and nothing changed until OK; with nothing to fade or a bad +/// mode it is an error, not a panic. +#[test] +fn fade_preview_matches_fade_without_a_step() { + for depth in [8, 16, 32] { + assert!(fade_preview(&session(depth), &json!({"opacity": 50})).is_err(), "nothing to fade yet"); + for p in [json!({"opacity": 25}), json!({"opacity": 60, "mode": "difference"}), json!({})] { + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + let (steps, revision) = (s.active().unwrap().history.past_len(), s.active().unwrap().revision); + let preview = fade_preview(&s, &p).and_then(FadePreview::render).unwrap(); + assert_eq!((s.active().unwrap().history.past_len(), s.active().unwrap().revision), (steps, revision), "depth {depth} {p}: no step"); + assert_eq!(px(&s, 5, 5)[0], 0.0, "depth {depth} {p}: the layer is untouched"); + let id = s.active().unwrap().active_layer.unwrap(); + let shown = preview.layer(id).unwrap().surface().unwrap().pixel(5, 5); + s.execute("edit.fade", p.clone()).unwrap(); + assert_eq!(shown, px(&s, 5, 5), "depth {depth} {p}: the preview is what OK applies"); + } + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + assert!(fade_preview(&s, &json!({"mode": "nonsense"})).is_err()); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(fade_preview(&s, &json!({})).is_err(), "not right after the step"); + } +} + #[test] fn fade_blends_last_step_at_all_depths() { for depth in [8, 16, 32] { @@ -119,7 +146,11 @@ fn fade_an_alpha_channel_or_the_quick_mask_at_all_depths() { assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "depth {depth}, quick mask {quick_mask}: inverted"); let expected = was + (1.0 - 2.0 * was) * k; let steps = s.active().unwrap().history.past_len(); + // The Fade dialog's preview (#8230) shows the channel as OK leaves it. + let preview = fade_preview(&s, &json!({"opacity": opacity})).and_then(FadePreview::render).unwrap(); + let shown = if quick_mask { preview.quick_mask.as_ref() } else { preview.channels.first() }.unwrap().surface.pixel(5, 5)[0]; s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + assert_eq!(shown, channel(&s, 5, 5), "depth {depth}, quick mask {quick_mask}, {opacity} %: the preview is what OK applies"); for (x, y) in [(0, 0), (5, 5), (63, 47)] { let v = channel(&s, x, y); assert!((v - expected).abs() < 0.02, "depth {depth}, quick mask {quick_mask}, {opacity} %, ({x}, {y}): {v}"); diff --git a/crates/engine/src/cmd/eraser_cmds.rs b/crates/engine/src/cmd/eraser_cmds.rs index 1433498078..bf08c18ac2 100644 --- a/crates/engine/src/cmd/eraser_cmds.rs +++ b/crates/engine/src/cmd/eraser_cmds.rs @@ -140,7 +140,7 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet let bs = BgEraseSettings { sampling: enum_param(p, CMD, "sampling", Sampling::Continuous, "continuous|once|backgroundSwatch")?, limits: enum_param(p, CMD, "limits", Limits::Contiguous, "discontiguous|contiguous|findEdges")?, - tolerance: number(p, CMD, "tolerance", 50.0, 0.0..=100.0)? as f32 / 100.0, + tolerance: number(p, CMD, "tolerance", 50.0, 1.0..=100.0)? as f32 / 100.0, protect_foreground: flag(p, "protectForegroundColor", false), foreground: s.tools.foreground, background: s.tools.background, @@ -267,7 +267,7 @@ pub fn specs() -> Vec { label: "Background Eraser", menu: &[], shortcut: None, - params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":1..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, enabled: has_pixel_layer, run: background_eraser, journal: true, diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index b40ebe573c..ca6ed6b386 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -76,12 +76,18 @@ pub(crate) fn step_key(s: &Session, what: &str) -> String { /// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that /// grayscale surface is rewritten instead, as the core filters do (#935). fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { + map_pixels_in(s, label, p, false, f) +} + +/// [`map_pixels`], or with `whole` the whole canvas: `f` still gets the selection coverage, and +/// every pixel takes its result in full. +fn map_pixels_in(s: &mut Session, label: &str, p: &Value, whole: bool, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { let id = layer_param(s, p)?; let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels; s.edit(label, |doc, _| { let canvas = doc.bounds(); let selection = doc.selection.clone(); - let area = selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)); + let area = if whole { canvas } else { selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)) }; let (surf, lock_alpha) = if channel { let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(id), p)? else { return Ok(()) }; (surf, false) @@ -109,7 +115,7 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut f(area, &mut px, &cover); let mut enc = [0.0f32; 8]; for (i, out) in raw.chunks_exact_mut(n).enumerate() { - let k = cover[i]; + let k = if whole { 1.0 } else { cover[i] }; if k <= 0.0 { continue; } @@ -230,9 +236,16 @@ fn stroke(s: &mut Session, p: &Value) -> Result { } /// Histogram equalization, like Photoshop's: one CDF over the R, G and B values of -/// the selected pixels, applied to every channel. +/// the selected pixels, applied to every channel. With a selection, `selectedAreaOnly: false` +/// applies the selection's mapping to the layer within the canvas (Photoshop's Equalize Entire +/// Image Based On Selected Area). fn equalize(s: &mut Session, p: &Value) -> Result { - map_pixels(s, "Equalize", p, |_, px, cover| { + let whole = match p.get("selectedAreaOnly") { + None => false, + Some(Value::Bool(only)) => !only, + Some(_) => return Err(EngineError::BadParams { cmd: "image.adjustments.equalize".into(), msg: "selectedAreaOnly must be true or false".into() }), + }; + map_pixels_in(s, "Equalize", p, whole, |_, px, cover| { let mut hist = [0f64; 256]; for (p, &k) in px.iter().zip(cover) { if k > 0.0 && p[3] > 0.0 { @@ -861,7 +874,15 @@ pub fn specs() -> Vec { })?; Ok(json!({"selected": true})) }), - spec!("image.adjustments.equalize", "Equalize", &["Image", "Adjustments"], None, "{}", has_pixels, equalize), + spec!( + "image.adjustments.equalize", + "Equalize", + &["Image", "Adjustments"], + None, + r#"{"selectedAreaOnly":bool=true (with a selection, false equalizes the layer within the canvas by the selection's histogram)}"#, + has_pixels, + equalize + ), spec!("image.revealAll", "Reveal All", &["Image"], None, "{}", has_doc, |s, _| { let d = s.active().ok_or(EngineError::NoDocument)?; let all = reveal_all_bounds(&d.doc); diff --git a/crates/engine/src/cmd/extra_cmds/tests.rs b/crates/engine/src/cmd/extra_cmds/tests.rs index acacf93ad4..ba0140504e 100644 --- a/crates/engine/src/cmd/extra_cmds/tests.rs +++ b/crates/engine/src/cmd/extra_cmds/tests.rs @@ -489,6 +489,64 @@ fn equalize_spreads_values() { } } +/// Equalize with a selection: by default only the selected pixels change, as Photoshop's +/// Equalize Selected Area Only; `selectedAreaOnly: false` maps the layer within the canvas by the +/// selected pixels' histogram, as its Equalize Entire Image Based On Selected Area. +#[test] +fn equalize_entire_image_based_on_the_selected_area() { + let setup = |depth: u32| { + let mut s = session(depth); + paint_square(&mut s, Rect::new(0, 0, 30, 15), [0.4, 0.4, 0.4, 1.0]); + paint_square(&mut s, Rect::new(0, 15, 30, 30), [0.5, 0.5, 0.5, 1.0]); + // Darker than anything selected: the selection's mapping sends it to black. + paint_square(&mut s, Rect::new(30, 0, 40, 30), [0.3, 0.3, 0.3, 1.0]); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + s + }; + for depth in [8, 16, 32] { + for params in [json!({}), json!({"selectedAreaOnly": true})] { + let mut s = setup(depth); + s.execute("image.adjustments.equalize", params.clone()).unwrap(); + let inside = (pixel(&s, 1, 1)[0], pixel(&s, 1, 20)[0]); + assert!(inside.0 < 0.05 && inside.1 > 0.95, "{depth} {params}: inside {inside:?}"); + let outside = [pixel(&s, 25, 1)[0], pixel(&s, 25, 20)[0], pixel(&s, 35, 1)[0]]; + assert!(outside.iter().zip([0.4, 0.5, 0.3]).all(|(v, want)| (v - want).abs() < 0.01), "{depth} {params}: outside {outside:?}"); + } + let mut s = setup(depth); + s.execute("image.adjustments.equalize", json!({"selectedAreaOnly": false})).unwrap(); + for (x, y, want_light) in [(1, 1, false), (1, 20, true), (25, 1, false), (25, 20, true), (35, 1, false), (35, 20, false)] { + let v = pixel(&s, x, y)[0]; + assert!(if want_light { v > 0.95 } else { v < 0.05 }, "{depth}: ({x}, {y}) = {v}"); + } + assert!(doc(&s).selection.is_some(), "{depth}: the selection stays"); + s.undo(); + let undone = [pixel(&s, 1, 1)[0], pixel(&s, 25, 20)[0], pixel(&s, 35, 1)[0]]; + assert!(undone.iter().zip([0.4, 0.5, 0.3]).all(|(v, want)| (v - want).abs() < 0.01), "{depth}: one undo step {undone:?}"); + } +} + +/// Without a selection, `selectedAreaOnly: false` gives the same pixels as plain Equalize. +#[test] +fn equalize_entire_image_without_a_selection_is_plain_equalize() { + let run = |params: Value| { + let mut s = session(8); + paint_square(&mut s, Rect::new(0, 0, 40, 15), [0.4, 0.4, 0.4, 1.0]); + paint_square(&mut s, Rect::new(0, 15, 40, 30), [0.5, 0.5, 0.5, 1.0]); + s.execute("image.adjustments.equalize", params).unwrap(); + active(&s).surface().unwrap().read_region(Rect::new(0, 0, 40, 30)) + }; + assert_eq!(run(json!({"selectedAreaOnly": false})), run(json!({}))); +} + +#[test] +fn equalize_refuses_a_selected_area_only_that_is_not_a_boolean() { + for bad in [json!(null), json!("false"), json!(0), json!([true]), json!({})] { + let mut s = session(8); + let e = s.execute("image.adjustments.equalize", json!({"selectedAreaOnly": bad})).unwrap_err().to_string(); + assert!(e.contains("selectedAreaOnly"), "{bad}: {e}"); + } +} + /// A selection masks Clouds but doesn't change their size: inside it they are the canvas's /// clouds, as when Clouds sized itself by the document (before it became a filter). #[test] diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index fa6a50e0a1..39c0b4acc6 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -134,9 +134,21 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { } } -/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › -/// Scale Effects, and previews that draw a style at a reduced resolution). +/// Scale every size and distance of a style by `k` (Image Size's Scale Styles and Layer Style › +/// Scale Effects), with pattern scales kept within the dialog's 1..1000 %. pub fn scale_effects(fx: &mut Effects, k: f32) { + scale_effects_with(fx, k, true); +} + +/// [`scale_effects`] for a preview that draws a style at a reduced resolution: pattern scales +/// follow `k` exactly, so the preview matches the style it stands for (a 2 % pattern drawn at a +/// quarter of the size is 0.5 %, not the dialog's 1 % minimum). +pub fn scale_effects_preview(fx: &mut Effects, k: f32) { + scale_effects_with(fx, k, false); +} + +fn scale_effects_with(fx: &mut Effects, k: f32, clamp: bool) { + let pattern = |scale: &mut f32| scale_pattern(scale, k, clamp); for e in &mut fx.items { match e { Effect::DropShadow(s) | Effect::InnerShadow(s) => { @@ -152,14 +164,17 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { Effect::BevelEmboss(b) => { b.size *= k; b.soften *= k; + if let Some(t) = &mut b.texture { + pattern(&mut t.scale); + } } - Effect::PatternOverlay { scale, .. } => *scale *= k, + Effect::PatternOverlay { scale, .. } => pattern(scale), Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {} } if let Effect::Stroke(s) = e && let FxPaint::Pattern { scale, .. } = &mut s.paint { - *scale *= k; + pattern(scale); } } // Effects changed: the preserved PSD block no longer applies. @@ -168,6 +183,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { } } +/// Scale a style's pattern scale; with `clamp`, kept within the 1..1000 % the Layer Style dialog +/// and the reference application allow (a 10 % pattern reduced to 5 % stays at 1 %). +fn scale_pattern(scale: &mut f32, k: f32, clamp: bool) { + let (min, max) = photocraft_doc::effects::PATTERN_SCALE_RANGE; + let v = *scale * k; + if !v.is_nan() { + *scale = if clamp { v.clamp(min, max) } else { v }; + } +} + /// Image Size's resampling methods; `None` changes the resolution only. #[derive(Clone, Copy)] enum Method { @@ -755,6 +780,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result let unlock = !delete && !content_aware && !keep_background; // Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to // the numbers passed; reject them (see `commands::int_i32`). + let given = ["x", "y", "width", "height"].into_iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())); let explicit = match ( crate::commands::int_i32("image.crop", p, "x")?, crate::commands::int_i32("image.crop", p, "y")?, @@ -775,6 +801,14 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result }; Some(Rect::new(x, y, end(x, w, "x", "width")?, end(y, h, "y", "height")?)) } + // A rectangle that is passed but incomplete or empty is refused: falling back to the + // selection would crop somewhere the caller didn't ask for (#9279). + _ if given => { + return Err(bad( + "image.crop", + "`x`, `y`, `width` and `height` go together: pass all four as numbers of pixels, with `width` and `height` rounding to at least 1", + )); + } _ => None, }; let target = crop_target(p)?; @@ -1206,7 +1240,7 @@ pub fn specs() -> Vec { "image.crop", "Crop", ["Image"], - r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##, + r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (x/y/width/height, all four or none: without them the selection's bounds are cropped to; targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##, has_doc, crop ), diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index 1e0fe5f223..2bc3214c1d 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -429,6 +429,101 @@ fn crop_to_explicit_rect_can_extend_canvas() { assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 5), vec![1.0, 0.0, 0.0, 1.0]); } +/// A rectangle that is passed but invalid is refused, not replaced by the selection (#9279). +#[test] +fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() { + let bad = [ + json!({"x": 10, "y": 10, "width": 0, "height": 50}), + json!({"x": 10, "y": 10, "width": 20, "height": 0}), + json!({"x": 10, "y": 10, "width": -50, "height": 50}), + json!({"x": 10, "y": 10, "width": "100", "height": 50}), + json!({"x": 10, "y": 10, "width": true, "height": 50}), + json!({"width": 20, "height": 10}), + json!({"x": 1, "y": 1}), + json!({"x": 1, "y": null, "width": 20, "height": 10}), + json!({"height": 10}), + ]; + for p in bad { + for selection in [true, false] { + let mut s = session(); + if selection { + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + } + let (size, past) = (doc(&s).size, s.active().unwrap().history.past_len()); + let err = s.execute("image.crop", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p} (selection: {selection}): {err:?}"); + assert_eq!(doc(&s).size, size, "{p} (selection: {selection})"); + assert_eq!(doc(&s).selection.is_some(), selection, "{p}: the selection stays"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{p} (selection: {selection})"); + } + } + // No rectangle at all (or every side null) still crops to the selection. + for p in [json!({}), json!({"x": null, "y": null, "width": null, "height": null})] { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("image.crop", p.clone()).unwrap(); + assert_eq!(doc(&s).size, Size::new(20, 10), "{p}"); + } + // Whole-number floats, as the Crop tool sends them, are a valid rectangle. + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("image.crop", json!({"x": 1.0, "y": 1.0, "width": 30.0, "height": 15.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(30, 15)); + // Fractional pixels round, like the rectangle's `x`/`y` and every other pixel param + // (`commands::int`); a side that rounds to 0 is refused. + let mut s = session(); + s.execute("image.crop", json!({"x": 0.4, "y": 0.6, "width": 20.6, "height": 9.5})).unwrap(); + assert_eq!(doc(&s).size, Size::new(21, 10)); + let err = s.execute("image.crop", json!({"x": 0, "y": 0, "width": 0.4, "height": 5})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}"); + assert_eq!(doc(&s).size, Size::new(21, 10)); +} + +/// The explicit rectangle of #9279's edge cases, each with a selection elsewhere that a fallback +/// would crop to: `0` is a given origin, numeric strings are not numbers (refused, as `x`/`y` +/// already were), and a rectangle partly past the canvas is used as passed, never clamped. +#[test] +fn crop_rect_edge_cases_never_fall_back_to_the_selection() { + let with_selection = || { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s + }; + // `x`/`y` of 0 count as given: the red square (10, 5)..(20, 15) stays where it was, where a + // crop to the selection would have moved it to (5, 3). + let mut s = with_selection(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 15, "height": 10})).unwrap(); + assert_eq!(doc(&s).size, Size::new(15, 10)); + assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(10, 5, 15, 10)); + // Numeric strings are refused on every side, with or without a selection. + for p in [ + json!({"x": "0", "y": "0", "width": "15", "height": "10"}), + json!({"x": "0", "y": 0, "width": 15, "height": 10}), + json!({"x": 0, "y": 0, "width": 15, "height": "10"}), + ] { + for mut s in [with_selection(), session()] { + let err = s.execute("image.crop", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err:?}"); + assert_eq!(doc(&s).size, Size::new(40, 20), "{p}"); + } + } + // Partly outside the canvas: the canvas grows to the rectangle, not to its overlap with the + // canvas or the selection. + let mut s = with_selection(); + s.execute("image.crop", json!({"x": -10, "y": -5, "width": 30, "height": 40})).unwrap(); + assert_eq!(doc(&s).size, Size::new(30, 40)); + assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 10), vec![1.0, 0.0, 0.0, 1.0]); + // Reveal All passes all four sides through the same check (here x = y = 0: the hidden pixels + // are past the right and bottom edges), so a selection doesn't change what it reveals. + let mut s = with_selection(); + s.execute("image.canvasSize", json!({"width": 12, "height": 8, "anchor": "topLeft"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 4, "height": 4})).unwrap(); + assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], true); + let size = doc(&s).size; + assert!(size.width >= 20 && size.height >= 15, "{size:?}: the red square is revealed"); + assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(19, 14), vec![1.0, 0.0, 0.0, 1.0]); +} + #[test] fn crop_extension_fills_the_background_with_the_background_colour() { let mut shrink = Session::new(); diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index c967f86964..0dcd7c2333 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -140,6 +140,119 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() { + let mut s = session(8, "rgb"); + paint(&mut s, disc); + s.execute("layer.layerStyle.patternOverlay", json!({"scale": 100})).unwrap(); + s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": 100})).unwrap(); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap(); + let fx = &active(&s).effects.items; + let overlay = fx.iter().find_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + _ => None, + }); + let texture = fx.iter().find_map(|e| match e { + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + _ => None, + }); + // Both patterns tile at half size, so a texture aligned with the overlay stays aligned. + assert_eq!((overlay, texture), (Some(0.5), Some(0.5))); +} + +/// The pattern scales of Pattern Overlay, the Bevel & Emboss Texture and a pattern Stroke, in +/// that order. +fn pattern_scales(s: &Session) -> (Option, Option, Option) { + let fx = &active(s).effects.items; + let overlay = fx.iter().find_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + _ => None, + }); + let texture = fx.iter().find_map(|e| match e { + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + _ => None, + }); + let stroke = fx.iter().find_map(|e| match e { + Effect::Stroke(st) => match &st.paint { + photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale), + _ => None, + }, + _ => None, + }); + (overlay, texture, stroke) +} + +/// A layer with a Pattern Overlay, a Bevel & Emboss Texture and a pattern Stroke, all at `pct` %. +fn pattern_styled(pct: f64) -> Session { + let mut s = session(8, "rgb"); + paint(&mut s, disc); + s.execute("layer.layerStyle.patternOverlay", json!({"scale": pct})).unwrap(); + s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": pct})).unwrap(); + s.execute("layer.layerStyle.stroke", json!({"size": 3})).unwrap(); + s.edit("pattern stroke", |doc, active| { + let layer = doc.layer_mut(active.unwrap()).unwrap(); + for e in &mut layer.effects.items { + if let Effect::Stroke(st) = e { + st.paint = photocraft_doc::FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale: pct as f32 / 100.0 }; + } + } + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn scale_effects_keeps_pattern_scales_within_the_dialog_range() { + // 10 % reduced to 1 % would be 0.1 %: the patterns stop at the dialog's 1 %. + let mut s = pattern_styled(10.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(0.01), Some(0.01), Some(0.01))); + // 500 % enlarged to 1000 % would be 5000 %: they stop at 1000 %. + let mut s = pattern_styled(500.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1000})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0))); + // Inside the range nothing is clamped: 200 % halved is 100 %. + let mut s = pattern_styled(200.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(1.0), Some(1.0), Some(1.0))); +} + +/// Previews at a reduced resolution scale the patterns exactly, past the dialog's range: only +/// commands that store the style clamp them. +#[test] +fn preview_scaling_leaves_pattern_scales_unclamped() { + let s = pattern_styled(2.0); + let mut fx = active(&s).effects.clone(); + crate::image_cmds::scale_effects_preview(&mut fx, 0.25); + let mut clamped = active(&s).effects.clone(); + crate::image_cmds::scale_effects(&mut clamped, 0.25); + let scales = |fx: &photocraft_doc::Effects| { + fx.items + .iter() + .filter_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + Effect::Stroke(st) => match &st.paint { + photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale), + _ => None, + }, + _ => None, + }) + .collect::>() + }; + assert_eq!(scales(&fx), vec![0.005; 3]); + assert_eq!(scales(&clamped), vec![0.01; 3]); +} + +#[test] +fn image_size_scale_styles_keeps_pattern_scales_within_the_dialog_range() { + // 40x30 -> 400x300 with Scale Styles multiplies by 10: 500 % would become 5000 %. + let mut s = pattern_styled(500.0); + s.execute("image.imageSize", json!({"width": 400, "height": 300, "scaleStyles": true})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0))); +} + #[test] fn global_light_rejects_numbers_beyond_f32() { let mut s = session(8, "rgb"); diff --git a/crates/engine/src/cmd/layer_multi_cmds.rs b/crates/engine/src/cmd/layer_multi_cmds.rs index 4ca3074b13..5076d665ff 100644 --- a/crates/engine/src/cmd/layer_multi_cmds.rs +++ b/crates/engine/src/cmd/layer_multi_cmds.rs @@ -101,7 +101,15 @@ pub fn specs() -> Vec { } Ok(Value::Null) }), - spec!("select.findLayers", "Find Layers", &["Select"], Some("Cmd+Alt+Shift+F"), r##"{"name":str (case-insensitive substring)}"##, has_doc, find_layers), + spec!( + "select.findLayers", + "Find Layers", + &["Select"], + Some("Cmd+Alt+Shift+F"), + r##"{"name":str?} (filters the Layers panel by Name, a case-insensitive substring, selecting none of the matches; without `name` the panel's Name text is kept; replies with the matching layers)"##, + has_doc, + find_layers + ), spec!("layer.selectLinkedLayers", "Select Linked Layers", &["Layer"], None, "{}", has_linked, |s, _| select_linked(s)), spec!("layer.align.topEdges", "Top Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "topEdges")), spec!("layer.align.verticalCenters", "Vertical Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "verticalCenters")), diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index cb3dad8c75..378b356190 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -336,16 +336,27 @@ pub(super) fn select_all_layers(s: &mut Session) -> Result { Ok(json!({"selected": n})) } +/// Select › Find Layers (#10884): as in Photoshop it filters the Layers panel by Name and leaves the +/// layer selection alone. The reply's `layerFilter` is the Name filter the app applies (`name` only +/// when one is given) and `selected` lists the layers it matches: part of the name, trimmed and +/// ignoring case, as the panel matches: every layer for a blank name, which the panel doesn't filter +/// by (none without a name). pub(super) fn find_layers(s: &mut Session, p: &Value) -> Result { - let needle = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("select.findLayers", "missing `name`"))?.to_lowercase(); + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad("select.findLayers", "`name` must be a string"))?), + }; let d = s.active().ok_or(EngineError::NoDocument)?; - let ids: Vec = d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(&needle)).map(|(_, _, l)| l.id).collect(); - if ids.is_empty() { - return Err(EngineError::Other(format!("no layer name contains \"{needle}\""))); + let needle = name.map(|n| n.trim().to_lowercase()); + let ids: Vec = match &needle { + Some(n) => d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(n.as_str())).map(|(_, _, l)| l.id.0).collect(), + None => Vec::new(), + }; + let mut filter = json!({"by": "name"}); + if let Some(n) = name { + filter["name"] = json!(n); } - let out = ids.iter().map(|l| l.0).collect::>(); - set_selection(s, ids, None, None)?; - Ok(json!({"selected": out})) + Ok(json!({"selected": ids, "layerFilter": filter})) } pub(super) fn select_linked(s: &mut Session) -> Result { diff --git a/crates/engine/src/cmd/layer_multi_cmds/tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tests.rs index 0d27dab4a3..fc39d25f5d 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tests.rs @@ -586,9 +586,23 @@ fn find_layers_by_name() { s.execute("layer.renameLayer", json!({"layer": a.0, "name": "Sky photo"})).unwrap(); s.execute("layer.renameLayer", json!({"layer": b.0, "name": "Photo of sea"})).unwrap(); assert_eq!(doc(&s).layer(a).unwrap().name, "Sky photo"); - let r = s.execute("select.findLayers", json!({"name": "PHOTO"})).unwrap(); - assert_eq!(r["selected"], json!([a.0, b.0])); - assert!(s.execute("select.findLayers", json!({"name": "nothing"})).is_err()); + // #10884: Find Layers filters the Layers panel by Name and leaves the selection alone. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let r = s.execute("select.findLayers", json!({"name": " PHOTO "})).unwrap(); + assert_eq!(r["selected"], json!([a.0, b.0]), "the matching layers, trimmed and ignoring case"); + assert_eq!(r["layerFilter"], json!({"by": "name", "name": " PHOTO "}), "the app sets the Name text as given"); + assert_eq!(s.active().unwrap().selected_layers(), [a], "the selection is unchanged"); + let r = s.execute("select.findLayers", json!({"name": "nothing"})).unwrap(); + assert_eq!(r["selected"], json!([]), "no match lists nothing and is no error"); + let all: Vec = doc(&s).walk().into_iter().map(|(_, _, l)| l.id.0).collect(); + for blank in ["", " "] { + let r = s.execute("select.findLayers", json!({"name": blank})).unwrap(); + assert_eq!(r["selected"], json!(all), "a blank name filters nothing, as in the panel: {blank:?}"); + } + let r = s.execute("select.findLayers", json!({})).unwrap(); + assert_eq!((&r["selected"], &r["layerFilter"]), (&json!([]), &json!({"by": "name"})), "without a name it only switches to Name"); + assert_eq!(s.active().unwrap().selected_layers(), [a]); + assert!(s.execute("select.findLayers", json!({"name": 3})).is_err()); assert!(s.execute("layer.renameLayer", json!({})).is_err()); } diff --git a/crates/engine/src/cmd/magnetic_cmds.rs b/crates/engine/src/cmd/magnetic_cmds.rs index f13d96fa5c..50bd40fdd0 100644 --- a/crates/engine/src/cmd/magnetic_cmds.rs +++ b/crates/engine/src/cmd/magnetic_cmds.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use photocraft_algo::magnetic::{self, Settings, Tracer}; use photocraft_algo::selection as sel; -use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_doc::{ColorMode, DocId, Document, LayerId}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -23,9 +23,12 @@ const MAX_POINTS: usize = 100_000; /// Longest border one command traces (px, measured along the fastening points). const MAX_LENGTH: f64 = 200_000.0; -/// The pixels the Magnetic Lasso follows: the active layer's when it is a pixel layer (Photoshop's -/// tool sees only the current layer), else the composite. A snapshot of one document revision, -/// read lazily and cached while a border is drawn. +/// The pixels the Magnetic Lasso follows: the visible composite of an RGB document, whichever layer +/// is active (hidden layers add no edges). Documents in other modes (CMYK, Grayscale, Indexed, ...) +/// keep the older rule: the active layer's own pixels when it is a pixel layer, else the composite. +/// A snapshot of one document revision, read lazily (only the tiles near the border) and cached by +/// the tracer while a border is drawn; [`EdgeSource::is_current`] drops it when the document changes, +/// so the composite is rendered once per revision and tile, not once per pointer event. pub struct EdgeSource { doc: Arc, layer: Option, @@ -33,8 +36,12 @@ pub struct EdgeSource { tracer: Tracer, } -/// The active layer when it has pixels of its own. +/// The one layer whose pixels the border follows, or `None` for the composite: always the composite +/// in an RGB document; in other modes the active layer when it has pixels of its own. fn pixel_layer(d: &DocState) -> Option { + if d.doc.mode == ColorMode::Rgb { + return None; + } d.active_layer.filter(|id| d.doc.layer(*id).is_some_and(|l| l.surface().is_some())) } @@ -45,7 +52,13 @@ impl EdgeSource { Some(EdgeSource { doc: d.doc.clone(), layer: pixel_layer(d), revision: d.revision, tracer: Tracer::new(d.doc.bounds()) }) } - /// Whether this still shows the session's active document, layer and revision. + /// Whether this still shows the session's active document and revision (and, outside RGB + /// documents, active layer). + /// + /// When this returns `false` the caller must drop the `EdgeSource` and build a new one with + /// [`EdgeSource::new`]: [`EdgeSource::snap`] and [`EdgeSource::trace`] keep reading the old + /// snapshot and its cached pixels otherwise. The Magnetic Lasso tool does so in `source()` + /// (`crates/ui-egui/src/tools/magnetic_lasso_ui.rs`) before every snap and trace. pub fn is_current(&self, s: &Session) -> bool { s.active().is_some_and(|d| d.doc.id == self.doc.id && d.revision == self.revision && pixel_layer(d) == self.layer) } @@ -70,7 +83,7 @@ impl EdgeSource { } } -/// Straight RGBA8 pixels of `r` from the layer, or the composite without one. +/// Straight RGBA8 pixels of `r` from the layer, or the visible composite without one. fn read(doc: &Document, layer: Option, r: Rect) -> Vec<[u8; 4]> { match layer.and_then(|id| doc.layer(id)).and_then(|l| l.surface()) { Some(surface) => sel::rgba8_image(surface, r), diff --git a/crates/engine/src/cmd/magnetic_cmds/tests.rs b/crates/engine/src/cmd/magnetic_cmds/tests.rs index 39f7bef2bb..dd1c163346 100644 --- a/crates/engine/src/cmd/magnetic_cmds/tests.rs +++ b/crates/engine/src/cmd/magnetic_cmds/tests.rs @@ -65,16 +65,16 @@ fn modes_and_a_given_outline() { } #[test] -fn edges_come_from_the_active_pixel_layer() { +fn edges_come_from_the_visible_composite() { let mut s = session(); s.execute("layer.new.layer", json!({})).unwrap(); - // An empty layer has no edges: the point stays where it is. + // An empty layer on top adds no pixels: the edge of the Background's square still counts. let settings = Settings::default(); let mut src = EdgeSource::new(&s).unwrap(); - assert_eq!(src.snap([44.0, 50.0], settings), Some([44.0, 50.0])); + let p = src.snap([44.0, 50.0], settings).unwrap(); + assert!((p[0] - 40.0).abs() < 0.01, "{p:?}"); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.select", json!({"layer": bg.0})).unwrap(); - assert!(!src.is_current(&s), "another layer"); let mut src = EdgeSource::new(&s).unwrap(); assert!(src.is_current(&s)); let p = src.snap([44.0, 50.0], settings).unwrap(); diff --git a/crates/engine/src/cmd/preset_import_cmds.rs b/crates/engine/src/cmd/preset_import_cmds.rs index 3adbee6f06..4430f4d1d2 100644 --- a/crates/engine/src/cmd/preset_import_cmds.rs +++ b/crates/engine/src/cmd/preset_import_cmds.rs @@ -176,6 +176,9 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io && let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first) { let mut b = pr.brush.clone().picked_over(&s.tools.brush); + // Opacity takes 1-100 %, as `tools.setBrush` floors it: a preset saved at 0 % would show + // 1 % but paint nothing (#11812). + b.opacity = b.opacity.max(0.01); s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?; s.tools.brush = b; s.tools.brush_changed(); @@ -477,6 +480,24 @@ mod tests { .unwrap() } + /// #11812: selecting an imported brush whose tool options hold 0 % opacity floors it at 1 %, + /// the value its options-bar field shows, so it paints instead of laying down nothing. + #[test] + fn selecting_an_imported_zero_opacity_brush_floors_it_at_one_percent() { + use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; + let preset = |opacity: i32| { + Descriptor::new("brushPreset") + .with("Nm ", D::Text(UnicodeString::new_nul("Faint"))) + .with("toolOptions", D::Descriptor(Descriptor::new("currentToolOptions").with("Opct", D::Integer(opacity)))) + }; + for (opacity, expected) in [(0, 0.01), (37, 0.37)] { + let abr = photocraft_psd::abr::write_v6_folders(2, &[], &[], &[preset(opacity)], &[vec![]], true).unwrap(); + let mut s = Session::new(); + s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "select": true})).unwrap(); + assert!((s.tools.brush.opacity - expected).abs() < 1e-6, "{opacity}: {}", s.tools.brush.opacity); + } + } + #[test] fn import_adds_a_group_and_paints() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/smart_cmds.rs b/crates/engine/src/cmd/smart_cmds.rs index 65b2ab2d09..5832fb5df9 100644 --- a/crates/engine/src/cmd/smart_cmds.rs +++ b/crates/engine/src/cmd/smart_cmds.rs @@ -193,7 +193,7 @@ pub(crate) use filters::{add_smart_filter, add_smart_filter_in, filter_mask_pain use filters::{blending_options, delete_filter, edit_filters, move_filter, set_filter_params, set_filter_visible, smart, transfer_filters}; pub(crate) use placing::detach_psd; pub(crate) use placing::{affine_homography, set_placement, shift_smart, transform_placement}; -use placing::{convert, shift_layer, via_copy}; +use placing::{convert, reset_transform, shift_layer, via_copy}; pub use placing::{layer_to_smart, layers_to_smart, placement}; #[cfg(test)] pub(crate) use rendering::DISPLAY_NATIVE_HASH_BYTES; @@ -239,6 +239,14 @@ pub fn specs() -> Vec { has_smart, edit_contents ), + spec!( + "layer.smartObjects.resetTransform", + "Reset Transform", + SO, + r##"{"layer":id?} (the placement returns to 100 % of the current contents' own size with rotation, skew, Distort, Perspective and the Warp cleared, centred on the average of the placement's four corners; the smart filters stay; refused with nothing changed when the object can't be re-rendered (a visible filter PhotoCraft doesn't have, or contents that can't be read); one undo step)"##, + has_smart, + reset_transform + ), spec!("layer.smartObjects.saveContents", "Save Contents", &[], "{} (in an Edit Contents document)", is_smart_child, |s, _| { let i = s.active_index().ok_or(EngineError::NoDocument)?; Ok(json!({"updated": commit_child(s, i)?})) diff --git a/crates/engine/src/cmd/smart_cmds/placing.rs b/crates/engine/src/cmd/smart_cmds/placing.rs index 847ad700db..48cede87e4 100644 --- a/crates/engine/src/cmd/smart_cmds/placing.rs +++ b/crates/engine/src/cmd/smart_cmds/placing.rs @@ -1,8 +1,10 @@ +use super::rendering::{refresh_or_fail, unapplied_filters}; use super::source::{SourceImage, buffer_to_surface, cache_key, cache_put, source_bytes}; use super::{ Affine, Arc, BlendMode, Document, EngineError, Homography, Layer, LayerContent, LayerId, PLACED_KEYS, Rect, Result, Session, Size, SmartContentsId, - SmartObject, SmartSource, Surface, Value, encode_source, json, layer_param, other, translate_surface, + SmartObject, SmartSource, Surface, Value, encode_source, json, layer_param, other, source_box, translate_surface, }; +use photocraft_algo::transform::Interp; /// Rounds float noise off a composed transform (scale 0.1 then 10 should be exactly 1), so /// repeated transforms land back on exact, lossless placements. @@ -57,6 +59,73 @@ pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) { } } +/// Layer ▸ Smart Objects ▸ Reset Transform, and the same item of the Layers panel menu (#11662): +/// the placement goes back to 100 % of the current contents' own size with rotation, skew, Distort, +/// Perspective **and Warp** cleared, centred where the object sits. Measured in the reference +/// application 2026-11-06 (see the #11675 discussion): a smart object warped with Arc 50, rotated +/// and scaled came back with no warp at all, at its contents' own size, unrotated, and centred on +/// the average of its four corners. The smart filters stay. +/// +/// The step needs the contents re-rendered from their source: a stack whose filters can't all be +/// applied, or a source that can't be read, refuses it whole rather than recording the old (still +/// warped) pixels under a placement that no longer describes them. +pub(super) fn reset_transform(s: &mut Session, p: &Value) -> Result { + let id = layer_param(s, p)?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let LayerContent::Smart(sm) = &st.doc.layer(id).ok_or(EngineError::NoLayer(id))?.content else { + return Err(other("the layer is not a smart object")); + }; + // Checked before anything changes, so a filter PhotoCraft doesn't have (kept verbatim from a + // PSD) leaves the document as it was, as `layer.smartObjects.updateModifiedContent` insists on + // a re-render too. + let stuck = unapplied_filters(sm); + if let Some(name) = stuck.first() { + return Err(other(format!("the {name} smart filter is not supported, so the smart object's transform can't be reset"))); + } + // The contents' own box in source pixels: 100 % maps it one-to-one onto the document. + let [x0, y0, x1, y1] = source_box(&st.doc, sm).ok_or_else(|| other("the smart object's contents are unavailable"))?; + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let old = placement(sm); + // The object stays where it is: the average of the placement's four corners, which is its + // centre for an affine placement and the quad's centre once Distort or Perspective bent it. + let (mut sx, mut sy) = (0.0, 0.0); + for (u, v) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] { + let (px, py) = old.apply(u, v); + sx += px; + sy += py; + } + let (dx, dy) = (sx / 4.0 - cx, sy / 4.0 - cy); + let to = Affine { m: [1.0, 0.0, 0.0, 1.0, dx, dy] }; + let new = affine_homography(&to); + // Everything the accumulated transform moved the pixels by, so the linked masks and the Smart + // Filter mask follow the placement back as they do under Free Transform. + let rel = new.mul(&old.inverse().ok_or_else(|| other("the smart object has a degenerate transform"))?); + if ![dx, dy, cx, cy].iter().all(|v| v.is_finite()) || !rel.0.iter().all(|v| v.is_finite()) { + return Err(other("the smart object's placement is not a usable transform")); + } + s.edit("Reset Transform", |doc, _| { + // The Warp is part of what the reference takes back, so the re-render below starts from the + // unwarped contents. How far a painted Smart Filter mask should follow a cleared warp is + // unmeasured: here it follows the placement, as it does under Free Transform. + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.warp = None; + } + crate::transform_cmds::transform_by_map(doc, id, &rel, None, Interp::Bicubic)?; + // Required, not best-effort: `transform_by_map` re-renders too, but falls back to the old + // pixels when that isn't possible. `Session::edit` runs this on a copy, so an `Err` here + // records no step and leaves the document exactly as it was. The state is unchanged since + // that render, so the guarded refresh answers from the stack cache rather than placing the + // contents a second time. + refresh_or_fail(doc, id)?; + // `new ∘ old⁻¹ ∘ old` only lands on the reset map to within float noise, and could leave a + // Distort placement behind as a nearly affine one: store the exact map. + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + set_placement(sm, new); + } + Ok(json!({"layer": id.0, "transform": to.m})) + }) +} + /// Forget PSD placed-layer data that no longer describes the smart object (its source changed): /// PSD export then writes fresh placed-layer blocks and embeds the new source. pub(crate) fn detach_psd(l: &mut Layer) { diff --git a/crates/engine/src/cmd/smart_cmds/rendering.rs b/crates/engine/src/cmd/smart_cmds/rendering.rs index 0e88a2a515..e922e125ef 100644 --- a/crates/engine/src/cmd/smart_cmds/rendering.rs +++ b/crates/engine/src/cmd/smart_cmds/rendering.rs @@ -129,7 +129,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let extent = canvas.union(&placed.content_bounds()); for f in sm.smart_filters.iter().filter(|f| f.visible) { - cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?; + cur = apply_smart_filter(f, sm, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?; } Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), @@ -155,8 +155,9 @@ fn renders_in_object(f: &SmartFilter) -> bool { /// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the /// requesting session's authority denies the filter's params: callers surface the error instead of /// rendering (and caching) unfiltered pixels. -fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result { - let out = match crate::filters::apply_filter_to_surface_in(&f.command, &f.params, cur, canvas, None, extent, ctx)? { +fn apply_smart_filter(f: &SmartFilter, sm: &SmartObject, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result { + let params = crate::distort_cmds::placed_puppet_params(&f.command, &f.params, sm); + let out = match crate::filters::apply_filter_to_surface_in(&f.command, ¶ms, cur, canvas, None, extent, ctx)? { // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. Some(out) => out, None => cur.clone(), @@ -367,7 +368,7 @@ pub(super) fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Resul Some(b) => b, None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)), }; - cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent }; + cur = Stage { surface: apply_smart_filter(f, sm, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent }; stack_cache::put(*key, &cur); } ctx.progress(1.0, ""); diff --git a/crates/engine/src/cmd/smart_cmds/tests.rs b/crates/engine/src/cmd/smart_cmds/tests.rs index 6ae2082b3d..2b1af57216 100644 --- a/crates/engine/src/cmd/smart_cmds/tests.rs +++ b/crates/engine/src/cmd/smart_cmds/tests.rs @@ -503,6 +503,193 @@ fn transforms_re_render_from_source_losslessly() { assert!(active_smart(&smart).perspective.is_some()); } +/// The contents' box centre in document pixels under `sm`'s placement: what an affine placement is +/// centred on, and where the box centre of a projective one maps to. +fn placed_centre(s: &Session, sm: &SmartObject) -> (f64, f64) { + let b = crate::smart_cmds::source_box(&s.active().unwrap().doc, sm).unwrap(); + placement(sm).apply((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0) +} + +/// The average of the four corners of `sm`'s placement in document pixels: the point Reset +/// Transform keeps (measured in the reference application 2026-11-06, see the #11675 discussion). +/// It is the box centre under an affine placement, and the quad's centre under a projective one. +fn quad_centre(s: &Session, sm: &SmartObject) -> (f64, f64) { + let b = crate::smart_cmds::source_box(&s.active().unwrap().doc, sm).unwrap(); + let h = placement(sm); + let (mut x, mut y) = (0.0, 0.0); + for (u, v) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])] { + let (px, py) = h.apply(u, v); + x += px; + y += py; + } + (x / 4.0, y / 4.0) +} + +/// Layer ▸ Smart Objects ▸ Reset Transform (#11662): 100 % of the contents' own size, no rotation, +/// skew, Distort or Perspective, and the object stays centred where it is. +#[test] +fn reset_transform_restores_100_percent_in_place() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + let fresh = active_smart(&s).cache.unwrap().content_bounds(); + s.execute("edit.transform", json!({"layer": id, "scaleX": 200, "scaleY": 200, "rotate": 30})).unwrap(); + s.execute("layer.translate", json!({"dx": 7, "dy": 4})).unwrap(); + let sm = active_smart(&s); + assert!(sm.transform.m[0].hypot(sm.transform.m[1]) > 1.5, "the object really is scaled up"); + assert_ne!(sm.transform.m[1], 0.0, "…and rotated"); + let centre = placed_centre(&s, &sm); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let after = active_smart(&s); + assert!(after.perspective.is_none(), "no projective placement left"); + assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "100 %, square, neither rotated nor skewed"); + let now = placed_centre(&s, &after); + assert!((now.0 - centre.0).abs() < 1e-6 && (now.1 - centre.1).abs() < 1e-6, "centred where it was: {now:?} vs {centre:?}"); + let r = after.cache.unwrap().content_bounds(); + assert_eq!((r.width(), r.height()), (fresh.width(), fresh.height()), "the contents at their own size"); + // Re-rendered from the source rather than resampled from the rotated pixels: the reset object + // is exactly the converted layer moved by the same whole-pixel distance. + let mut control = session(8); + paint(&mut control); + convert(&mut control); + control.execute("layer.translate", json!({"dx": 7, "dy": 4})).unwrap(); + let (got, want) = (flat(&s), flat(&control)); + assert!(max_diff(&got, &want) < 1e-6, "lossless reset (mean {})", mean_diff(&got, &want)); + // Already reset: the second run changes nothing. + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + assert_eq!(active_smart(&s).transform, after.transform, "idempotent"); +} + +/// Reset Transform takes the Warp back with the transform and keeps the smart filter stack: +/// measured in the reference application 2026-11-06 (an Arc 50 warp came back as no warp at all), +/// see the #11675 discussion. +#[test] +fn reset_transform_clears_the_warp_and_keeps_the_smart_filters() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + s.execute("edit.transform.warp", json!({"style": "arc", "bend": 30})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let before = active_smart(&s); + assert!(before.warp.is_some(), "the fixture is warped"); + let r = before.cache.unwrap().content_bounds(); + let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)); + s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": [[x0, y0], [x1, y0], [x1 - 12.0, y1 - 8.0], [x0, y1]]})).unwrap(); + assert!(active_smart(&s).perspective.is_some(), "Distort keeps a projective placement"); + let centre = quad_centre(&s, &active_smart(&s)); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let after = active_smart(&s); + assert!(after.warp.is_none(), "the Warp goes with the transform, as the reference does"); + assert_eq!(after.smart_filters.len(), 1, "the smart filter stack stays"); + assert!(after.perspective.is_none(), "Distort is cleared"); + assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0]); + let now = placed_centre(&s, &after); + assert!((now.0 - centre.0).abs() < 1e-6 && (now.1 - centre.1).abs() < 1e-6, "centred where it was: {now:?} vs {centre:?}"); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let again = active_smart(&s); + assert_eq!(again.transform, after.transform, "idempotent"); + assert!(again.warp.is_none(), "and still unwarpd"); +} + +/// The centre Reset Transform keeps is the average of the placement's four corners, not the point +/// its box centre maps to: they differ once Distort or Perspective bends the quad, and the +/// reference application keeps the corners' average (measured 2026-11-06, #11675 discussion). +#[test] +fn reset_transform_centres_a_distorted_object_on_its_corner_average() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + let r = active_smart(&s).cache.unwrap().content_bounds(); + let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)); + let quad = [[x0 - 10.0, y0 - 6.0], [x1 + 26.0, y0 - 2.0], [x1 + 4.0, y1 + 18.0], [x0 - 16.0, y1 + 8.0]]; + s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": quad})).unwrap(); + let sm = active_smart(&s); + assert!(sm.perspective.is_some(), "a projective placement"); + let corners = quad_centre(&s, &sm); + let middle = placed_centre(&s, &sm); + assert!((corners.0 - middle.0).abs() + (corners.1 - middle.1).abs() > 1.0, "the fixture splits the two centres: {corners:?} vs {middle:?}"); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let after = active_smart(&s); + assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "100 %, square"); + assert!(after.perspective.is_none() && after.warp.is_none(), "no projective placement or warp left"); + let now = placed_centre(&s, &after); + assert!((now.0 - corners.0).abs() < 1e-6 && (now.1 - corners.1).abs() < 1e-6, "centred on the corners: {now:?} vs {corners:?}"); + assert!((now.0 - middle.0).abs() + (now.1 - middle.1).abs() > 1.0, "not on the point the box centre mapped to"); +} + +/// A stack holding a filter this build can't apply can't be re-rendered without the warp, so Reset +/// Transform refuses the whole step instead of recording the old warped pixels at the new +/// placement: no history state, and placement, warp and stack left exactly as they were. +#[test] +fn reset_transform_refuses_a_stack_it_cannot_re_render() { + let mut s = session(8); + paint(&mut s); + convert(&mut s); + s.execute("edit.transform.warp", json!({"style": "arc", "bend": 30})).unwrap(); + s.execute("edit.transform", json!({"scaleX": 160, "scaleY": 160})).unwrap(); + // A smart filter whose command this build doesn't have, as an imported PSD leaves it. + s.edit("unsupported filter", |doc, active| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("a smart object") }; + sm.smart_filters.push(SmartFilter { + command: UNSUPPORTED_FILTER.into(), + params: json!({"name": "Camera Raw Filter"}), + blend: BlendMode::Normal, + opacity: 1.0, + visible: true, + }); + Ok(()) + }) + .unwrap(); + let before = active_smart(&s); + assert!(!super::rendering::unapplied_filters(&before).is_empty(), "the fixture's filter can't be applied"); + let states = s.active().unwrap().history.entries().len(); + let err = s.execute("layer.smartObjects.resetTransform", json!({})).unwrap_err().to_string(); + assert!(err.contains("Camera Raw Filter"), "the error names the filter that blocks it: {err}"); + let after = active_smart(&s); + assert_eq!(after.transform, before.transform, "the placement is left alone"); + assert_eq!(after.warp, before.warp, "the warp is still there"); + assert_eq!(after.smart_filters, before.smart_filters, "so is the stack"); + assert_eq!(s.active().unwrap().history.entries().len(), states, "a refused step records no history state"); + // Only this step is refused: with the stack gone the same object resets normally. + s.execute("layer.smartFilter.clearSmartFilters", json!({})).unwrap(); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + let cleared = active_smart(&s); + assert_eq!(&cleared.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "then the reset goes through"); + assert!(cleared.warp.is_none(), "with the warp"); +} + +/// Enabled for a smart object only, and one undo step back (#11662). +#[test] +fn reset_transform_needs_a_smart_object_and_undoes_in_one_step() { + let mut empty = Session::new(); + assert!(!empty.is_enabled("layer.smartObjects.resetTransform"), "no document"); + assert!(empty.execute("layer.smartObjects.resetTransform", json!({})).is_err()); + let mut s = session(8); + paint(&mut s); + let plain = s.active().unwrap().active_layer.unwrap().0; + assert!(!s.is_enabled("layer.smartObjects.resetTransform"), "a pixel layer has no placement to reset"); + assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": plain})).is_err()); + let id = convert(&mut s); + assert!(s.is_enabled("layer.smartObjects.resetTransform")); + s.execute("edit.transform", json!({"layer": id, "matrix": [2, 0, 0, 2, 0, 0]})).unwrap(); + let scaled = active_smart(&s); + s.execute("layer.smartObjects.resetTransform", json!({})).unwrap(); + assert_eq!(&active_smart(&s).transform.m[..4], &[1.0, 0.0, 0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + let back = active_smart(&s); + assert_eq!(back.transform, scaled.transform, "one history step back to the scaled placement"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(&active_smart(&s).transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "…and forward again"); + // Hostile targets are refused, never acted on. + assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": u64::MAX})).is_err()); + assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": "nope"})).is_err()); + // A locked layer can't be moved. + s.execute("layer.lockLayers", json!({"layer": id, "position": true})).unwrap(); + let locked = active_smart(&s).transform; + assert!(s.execute("layer.smartObjects.resetTransform", json!({})).is_err()); + assert_eq!(active_smart(&s).transform, locked, "the placement is left alone"); +} + #[test] fn filters_follow_transforms() { let mut s = session(16); @@ -712,6 +899,7 @@ fn commands_are_registered_with_catalog_ids() { "layer.smartObjects.newSmartObjectViaCopy", "layer.smartObjects.rasterize", "layer.smartObjects.editContents", + "layer.smartObjects.resetTransform", "layer.smartObjects.replaceContents", "layer.smartObjects.exportContents", "layer.smartObjects.updateModifiedContent", diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index cf9004bbac..4243569f68 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -634,18 +634,23 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface /// masks and Smart Filter mask follow, and type, shapes and smart objects re-render. The document /// selection is left alone. pub(crate) fn transform_by(doc: &mut Document, id: LayerId, affine: Affine, interp: Interp) -> Result<()> { - let h = crate::smart_cmds::affine_homography(&affine); + transform_by_map(doc, id, &crate::smart_cmds::affine_homography(&affine), Some(affine), interp) +} + +/// [`transform_by`] with the map already built, and `affine` only when it is affine (Distort and +/// Perspective results aren't: their smart object keeps the full projective placement). +pub(crate) fn transform_by_map(doc: &mut Document, id: LayerId, h: &Homography, affine: Option, interp: Interp) -> Result<()> { let group = group_locks(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; // Price the smart object caches before they are taken: the fallback warp below isn't budgeted. - check_layer_budget(None, l, &h)?; + check_layer_budget(None, l, h)?; let taken = crate::smart_cmds::take_caches(std::slice::from_mut(l)); - apply_transform(None, group, l, &h, Some(affine), interp, &Interrupt::NONE)?; + apply_transform(None, group, l, h, affine, interp, &Interrupt::NONE)?; let snapshot = doc.clone(); if let Some(l) = doc.layer_mut(id) { refresh_text(&snapshot, l); } - crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), &h, interp))?; + crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), h, interp))?; Ok(()) } diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index bec8a3f242..1f1cfb8622 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -230,6 +230,17 @@ pub fn specs() -> Vec { run: |s, p| path_to_selection_with_mode(s, p, "subtract"), journal: true, }, + // ⇧⌥⌘↩ / Ctrl+Alt+Shift+Enter intersects the path with the selection (#7726). + CommandSpec { + id: "path.intersectWithSelection", + label: "Intersect Path with Selection", + menu: &[], + shortcut: Some("Cmd+Alt+Shift+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "intersect")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "intersect"), + journal: true, + }, spec!( "path.toShape", "Make Shape from Path", diff --git a/crates/engine/src/cmd/vector_cmds/paths.rs b/crates/engine/src/cmd/vector_cmds/paths.rs index 7c7f550a29..e54c38fd19 100644 --- a/crates/engine/src/cmd/vector_cmds/paths.rs +++ b/crates/engine/src/cmd/vector_cmds/paths.rs @@ -371,7 +371,8 @@ pub(super) fn path_to_selection(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": selected })) } -/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts); +/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts, +/// ⇧⌥⌘↩ intersects); /// a `mode` in the params is overridden, the rest is forwarded and validated as usual. pub(super) fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { let mut q = match p { diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index 5a44943345..93d220a1de 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -568,6 +568,26 @@ fn open_pen_paths_make_the_same_selection_as_closed_paths() { } } +/// ⇧⌘↩, ⌥⌘↩ and ⇧⌥⌘↩'s commands are Make Selection from Path with their mode (#7726), whatever +/// `mode` the params ask for. +#[test] +fn the_fixed_mode_path_commands_combine_as_their_mode() { + let mut s = session(80, 80, 8); + let square = |x: f64| path_json(&vector::shapes::rect(x, 20.0, 30.0, 30.0)); + s.execute("path.set", json!({"name": "Sq", "path": square(30.0)})).unwrap(); + for (id, mode) in [("path.addToSelection", "add"), ("path.subtractFromSelection", "subtract"), ("path.intersectWithSelection", "intersect")] { + s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap(); + s.execute("path.toSelection", json!({"name": "Sq", "mode": mode, "antiAlias": false})).unwrap(); + let want = selection_mask(&s); + s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap(); + s.execute(id, json!({"name": "Sq", "mode": "replace", "antiAlias": false})).unwrap(); + assert_eq!(selection_mask(&s), want, "{id} is mode {mode}"); + } + // Intersecting keeps only the 10 px both squares cover. + let mask = selection_mask(&s); + assert_eq!(mask.iter().filter(|v| **v > 0.5).count(), 10 * 30); +} + #[test] fn selection_to_work_path_roundtrip_iou() { let mut s = session(240, 180, 8); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 06123cbab1..cb9bc5fe8a 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -200,7 +200,7 @@ pub struct ToolState { impl ToolState { /// How many CMYK picks are remembered. - const CMYK_PICKS: usize = 8; + pub const CMYK_PICKS: usize = 8; /// Remember that sRGB colour `rgb` was picked as the inks `cmyk` (each 0..1). pub fn remember_cmyk(&mut self, rgb: [f32; 3], cmyk: [f32; 4]) { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 8654a38507..1390e89902 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -507,9 +507,9 @@ struct Running { label: String, params: Value, journal: bool, - /// The recording (`ActionState::take`) running when the job started, if any (set by - /// `dispatch`, once recording resumes). Only that recording gets the job's step: one started - /// later must not pick up an edit made before it. + /// The take active at dispatch, including strokes started with tool recording off. + started_in_take: Option, + /// The take eligible to receive this job's step under the mode at dispatch. recorded_by: Option, pre_selected: Option>, before: crate::actions_record::Before, @@ -863,6 +863,7 @@ fn new_job( label: label.to_string(), params: Value::Null, journal: true, + started_in_take: None, recorded_by: None, pre_selected: None, before: crate::actions_record::Before::capture(s), @@ -1031,6 +1032,11 @@ pub struct PendingPsdPreflight { } impl Session { + /// A recording mode change must not reclassify an in-flight stroke from this take. + pub(crate) fn tool_stroke_job_running(&self) -> bool { + self.jobs.running.iter().any(|job| job.started_in_take == Some(self.actions.take) && crate::actions_cmds::tool_stroke(&job.command)) + } + /// Run the heavy part of inline (not background) job commands under `ctx`, so another thread /// can cancel them: a live preview whose parameters changed stops its stale computation /// (`Err(Cancelled)`, the document unchanged) instead of finishing it. `None` restores the @@ -1814,7 +1820,8 @@ impl Session { job.command = id.to_string(); job.params = params; job.journal = spec.journal; - job.recorded_by = self.actions.recording.map(|_| self.actions.take); + job.started_in_take = self.actions.recording.map(|_| self.actions.take); + job.recorded_by = job.started_in_take.filter(|_| self.actions.record_tools || !crate::actions_cmds::tool_stroke(id)); job.pre_selected = pre_selected.clone(); job.before = std::mem::take(&mut record_before); let jid = job.id; diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 9bb6ab7693..f89779954b 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -2,6 +2,7 @@ use photocraft_color::{BlendMode, Color}; use photocraft_doc::adjust::CurvePoint; +use photocraft_doc::effects::PATTERN_SCALE_RANGE; use photocraft_doc::{ Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, StrokePosition, @@ -804,7 +805,7 @@ fn overlay_pattern(fx: &mut Effect, p: &Value) { *id = String::new(); } if let Some(x) = f_of(p, "scale") { - *scale = (x / 100.0).clamp(0.01, 10.0); + *scale = (x / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1); } if let Some(x) = f_of(p, "angle") { *angle = x; @@ -912,7 +913,7 @@ fn overlay_bevel(bv: &mut Bevel, p: &Value) { bv.texture = Some(photocraft_doc::BevelTexture { name: t.to_string(), id: t.to_string(), - scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0), + scale: (f(p, "textureScale", 100.0) / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1), depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0), invert: b(p, "textureInvert", false), link: b(p, "textureLink", true), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ba36f1f850..65f5f735cb 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -188,6 +188,78 @@ impl Unit { let unit = if suffix.is_empty() { None } else { Some(Unit::from_suffix(suffix)?) }; Some((v, unit)) } + /// A length typed as Photoshop's numeric fields take arithmetic (#10440): lengths and numbers + /// joined by `+`, `-`, `*` and `/`, e.g. "100% / 3", "100% - 50px", "1in + 10", "2 * 50 px". + /// `*` and `/` bind tighter, and `−` (U+2212) is a minus. A number without a unit added or + /// subtracted takes the first operand's unit (Photoshop: "1in + 10" is 11 in), or `default` + /// when the first has none; one multiplied or divided is a plain number. `to_px` converts a + /// value in a unit. `None` when the text is over 1024 bytes (the numeric fields' cap) or isn't + /// such an expression, multiplies two lengths, divides by a length or by zero, or the result + /// isn't finite. + pub fn eval_length(text: &str, default: Unit, to_px: impl Fn(f64, Unit) -> f64) -> Option { + if text.len() > 1024 { + return None; + } + let text = text.replace('−', "-"); + // Tokens: operators, and operands (a number with an optional unit suffix). + let mut ops = Vec::new(); + let mut operands: Vec<(f64, Option)> = Vec::new(); + let mut rest = text.trim(); + let mut want_operand = true; + while !rest.is_empty() { + if want_operand { + let num_end = rest + .char_indices() + .find(|&(i, c)| !(c.is_ascii_digit() || matches!(c, '.' | ',') || (i == 0 && matches!(c, '-' | '+')))) + .map_or(rest.len(), |(i, _)| i); + let after = &rest[num_end..]; + let unit_end = after.find(['+', '-', '*', '/']).unwrap_or(after.len()); + let (v, unit) = Unit::parse_length(&rest[..num_end + unit_end])?; + operands.push((v, unit)); + rest = after[unit_end..].trim_start(); + want_operand = false; + } else { + let op = rest.chars().next()?; + if !matches!(op, '+' | '-' | '*' | '/') { + return None; + } + ops.push(op); + rest = rest[1..].trim_start(); + want_operand = true; + } + } + if want_operand || operands.is_empty() { + return None; + } + let first_unit = operands[0].1.unwrap_or(default); + // Fold the products and quotients into terms: (sum of px for unit factors, scalar, has a unit). + let mut terms: Vec<(char, f64, Option)> = Vec::new(); + let (mut sign, mut value, mut unit) = ('+', operands[0].0, operands[0].1); + for (op, (v, u)) in ops.iter().zip(operands.iter().skip(1)) { + match op { + '*' => { + if unit.is_some() && u.is_some() { + return None; + } + value *= v; + unit = unit.or(*u); + } + '/' => { + if u.is_some() || *v == 0.0 { + return None; + } + value /= v; + } + _ => { + terms.push((sign, value, unit)); + (sign, value, unit) = (*op, *v, *u); + } + } + } + terms.push((sign, value, unit)); + let px: f64 = terms.iter().map(|&(s, v, u)| if s == '-' { -1.0 } else { 1.0 } * to_px(v, u.unwrap_or(first_unit))).sum(); + px.is_finite().then_some(px) + } /// The unit a typed suffix names, in any case: `px`, `in` or `"`, `cm`, `mm`, `pt`, `pc` or /// `pica`, `%`, and their long forms (`pixels`, `inches`, `centimetres`…). The one table every /// length field reads. @@ -1301,6 +1373,9 @@ pub struct Preferences { /// Last selected tool, restored at launch. A tool-name string owned and validated by the /// shell; null in older files. Kept separate from tool options so neither overwrites the other. pub selected_tool: Value, + /// Foreground/background swatches for pixels and masks and exact CMYK picks, restored by the shell at launch. + /// Null in older files, which retain the default black/white pixel colours. + pub tool_colors: Value, /// The tools used last, most recent first, restored at launch so each toolbar slot shows the /// tool it was left on and each tool key brings it back, as in Photoshop (#3569). Tool-name /// strings owned and validated by the shell; null in older files. diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index c92528de22..0bb3a13857 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -612,6 +612,61 @@ fn tool_presets_save_filter_select() { assert!(s.execute("tool.presets.new", json!({"name": "x"})).is_err()); } +#[test] +fn tool_presets_reject_invalid_colors_before_applying_the_brush() { + let mut invalid = vec![ + json!(null), + json!(true), + json!(7), + json!("red"), + json!({}), + json!([]), + json!([1]), + json!([1, 0]), + json!([1, 0, 0, 1, 0]), + json!([null, 0.4, 0.6]), + ]; + for channel in 0..4 { + for bad_channel in [json!(1e100), json!(-1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] { + let mut values = json!([0.2, 0.4, 0.6, 0.8]); + values[channel] = bad_channel; + invalid.push(values); + } + } + for values in invalid { + let mut s = Session::new(); + s.execute("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 0.8]})).unwrap(); + let color = s.tools.foreground; + let brush = s.tools.brush.clone(); + let brush_rev = s.tools.brush_rev; + s.execute( + "tool.presets.new", + json!({ + "name": "Invalid", "tool": "brush", "options": {"brush": {"size": 77}, "foreground": values} + }), + ) + .unwrap(); + assert!(s.execute("tool.presets.select", json!({"preset": "Invalid"})).is_err(), "{values}"); + assert_eq!(s.tools.foreground, color, "{values}"); + assert_eq!(s.tools.brush, brush, "{values}"); + assert_eq!(s.tools.brush_rev, brush_rev, "{values}"); + } +} + +#[test] +fn tool_presets_keep_finite_hdr_colors_and_leave_absent_colors_unchanged() { + let mut s = Session::new(); + for (options, expected) in [ + (json!({"foreground": [1.5, -0.1, 0.6, 0.8]}), [1.5, -0.1, 0.6, 0.8]), + (json!({"foreground": [0.2, 0.4, 0.6]}), [0.2, 0.4, 0.6, 1.0]), + (json!({}), [0.2, 0.4, 0.6, 1.0]), + ] { + let preset = s.execute("tool.presets.new", json!({"tool": "move", "options": options})).unwrap(); + s.execute("tool.presets.select", json!({"preset": preset["name"]})).unwrap(); + assert_eq!(s.tools.foreground, expected); + } +} + /// An Art History Brush preset captures the brush's opacity and selecting it restores it. #[test] fn art_history_brush_preset_carries_the_opacity() { diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index d2fd6c52d2..25b70eb185 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -158,13 +158,21 @@ fn select(s: &mut Session, p: &Value) -> Result { .find(|t| t.name == name) .cloned() .ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?; + // Validate before applying the brush too: an invalid colour must leave the preset unapplied. + let foreground = match t.options.get("foreground") { + None => s.tools.foreground, + Some(value) => { + let valid = value.as_array().is_some_and(|channels| matches!(channels.len(), 3 | 4) && channels.iter().all(Value::is_number)); + if !valid { + return Err(bad(CMD, "`foreground` must be [r, g, b] or [r, g, b, a] numbers")); + } + crate::commands::color_param(CMD, &t.options, "foreground", s.tools.foreground)? + } + }; if let Some(b) = t.options.get("brush").filter(|b| b.is_object()) { super::call(s, "tools.setBrush", json!({"brush": b}))?; } - if let Some(c) = t.options.get("foreground").and_then(Value::as_array).filter(|a| a.len() >= 3) { - let v: Vec = c.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - s.tools.foreground = [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)]; - } + s.tools.foreground = foreground; Ok(json!({"name": t.name, "tool": t.tool, "options": t.options})) } diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 49db9509e5..7aaf58ffcd 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -169,6 +169,7 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ ("tools.select.ellipseShape", "Ellipse Tool", Some("U")), ("tools.select.triangle", "Triangle Tool", Some("U")), ("tools.select.polygon", "Polygon Tool", Some("U")), + ("tools.select.star", "Star Tool", Some("U")), ("tools.select.line", "Line Tool", Some("U")), ("tools.select.customShape", "Custom Shape Tool", Some("U")), ("tools.select.slice", "Slice Tool", Some("C")), diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index 4f65bcc34e..72b91241a4 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -125,7 +125,7 @@ fn native_default_is_written_explicitly_and_keeps_its_behavior() { } } -/// Unsupported flags and empty steps remain complete descriptors instead of becoming resizes. +/// Only an absent descriptor is menu intent. Empty or invalid descriptors remain kept steps. #[test] fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { for event in ["ImgS", "imageSize"] { @@ -143,12 +143,20 @@ fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { let original = atn::parse(&source).unwrap(); let mut s = Session::new(); let loaded = load(&mut s, &source); - assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); - assert_eq!(s.actions.list[0].steps.len(), 1); - assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (step, params) = &s.actions.list[0].steps[0]; + if original.actions[0].items[0].descriptor.is_none() { + assert_eq!(loaded["unsupported"], json!([])); + assert_eq!(step, atn_cmds::MENU_ITEM); + assert_eq!(params["id"], "image.imageSize"); + assert_eq!(params["eventKind"], if event == "ImgS" { "code" } else { "name" }); + } else { + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(step, atn_cmds::PHOTOSHOP_STEP); + } let saved = atn::parse(&save(&mut s)).unwrap(); assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + assert_eq!(saved.actions[0].items[0].dictionary, original.actions[0].items[0].dictionary); } } } diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs new file mode 100644 index 0000000000..e5c97d294d --- /dev/null +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -0,0 +1,122 @@ +//! Duplicate keeps action identities, complete settings and per-step flags stable. +use photocraft_engine::{Session, actions_cmds::Action}; +use serde_json::{Value, json}; + +/// A tiny document to distinguish definition edits from document mutations and playback. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s +} + +#[test] +fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() { + let mut s = session(); + let mut source = Action::new("Source", vec![("actions.play".into(), json!({"action": 1})), ("layer.select".into(), json!({"layer": "Photo"}))]); + source.disabled.insert(1); + source.dialogs.insert(0); + source.set = Some("Retouch".into()); + source.color = "red".into(); + let foreign = json!({"actions": [{"name": "Foreign", "steps": [["actions.play", {"action": 1}]]}]}); + s.actions.list = vec![ + source.clone(), + Action::new("Target", vec![("select.all".into(), json!({}))]), + Action::new( + "Caller", + vec![ + ("actions.play".into(), json!({"action": 1})), + ("actions.play".into(), json!({"action": 3})), // Unresolved before and after insertion. + ("actions.play".into(), json!({"action": 1.0})), + ("file.automate.batch".into(), foreign.clone()), + ("actions.conditional".into(), json!({"if": "always", "then": "Target", "else": "Source"})), + ], + ), + ]; + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:Source".into(), "F6".into()); + }); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": "Source"})).unwrap(); + assert_eq!(result["index"], 1); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Source", "Source copy", "Target", "Caller"]); + let mut expected = source; + expected.name = "Source copy".into(); + expected.steps[0].1["action"] = json!(2); + assert_eq!(s.actions.list[1], expected); + assert_eq!(s.actions.list[0].steps[0].1["action"], 2); + let caller = &s.actions.list[3]; + assert_eq!(caller.steps[0].1["action"], 2); + assert_eq!(caller.steps[1].1["action"], 4); + assert_eq!(caller.steps[2].1["action"], 2.0); + assert_eq!(caller.steps[3].1, foreign, "embedded libraries keep their own indices"); + assert_eq!(caller.steps[4].1, json!({"if": "always", "then": "Target", "else": "Source"})); + assert_eq!(s.prefs().shortcuts.get("actions.play:Source").map(String::as_str), Some("F6")); + assert!(!s.prefs().shortcuts.contains_key("actions.play:Source copy")); + assert_eq!(s.active().unwrap().revision, revision); + let played = s.execute("actions.play", json!({"action": "Source copy"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.selection.is_some(), "the copied call still plays Target rather than itself"); + s.execute("actions.duplicate", json!({"action": 0})).unwrap(); + assert_eq!(s.actions.list[1].name, "Source copy 2"); + assert_eq!(s.actions.list[2].name, "Source copy"); + assert_eq!(s.actions.list[1].steps[0].1["action"], 3); +} + +#[test] +fn step_copies_preserve_payloads_and_shift_both_flag_columns() { + let mut s = session(); + let payload = json!({"layer": "Photo", "_actionLayer": {"name": "Photo", "ordinal": 2}}); + let mut a = Action::new("Steps", vec![("layer.select".into(), payload.clone()), ("select.all".into(), Value::Null), ("select.deselect".into(), json!({}))]); + a.disabled.extend([0, 2]); + a.dialogs.extend([0, 1]); + s.actions.list.push(a); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(result["step"], 1); + assert_eq!(result["index"], 0); + let a = &s.actions.list[0]; + assert_eq!(a.name, "Steps"); + assert_eq!(a.steps[0], a.steps[1]); + assert_eq!(a.disabled.iter().copied().collect::>(), [0, 1, 3]); + assert_eq!(a.dialogs.iter().copied().collect::>(), [0, 1, 2]); + s.execute("actions.duplicate", json!({"action": 0, "step": 2})).unwrap(); + assert!(s.actions.list[0].steps[2].1.is_null()); + assert!(s.actions.list[0].steps[3].1.is_null(), "copying does not normalize null settings to an empty object"); + s.actions.list[0].steps[1].1["layer"] = json!(99); + assert_eq!(s.actions.list[0].steps[0].1, payload, "the copy owns its settings"); + assert_eq!(s.active().unwrap().revision, revision); +} + +#[test] +fn invalid_copies_do_not_flush_recording_and_valid_step_copies_keep_recording() { + let mut s = session(); + s.execute("actions.record", json!({"name": "Recording"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let before = s.actions.list.clone(); + let recording = s.actions.recording; + let revision = s.active().unwrap().revision; + for params in [ + json!({}), + json!({"action": 9}), + json!({"action": 0}), + json!({"action": 0, "step": -1}), + json!({"action": 0, "step": 5}), + json!({"action": 0, "step": "x"}), + ] { + assert!(s.execute("actions.duplicate", params.clone()).is_err(), "{params}"); + assert_eq!(s.actions.list, before); + assert_eq!(s.actions.recording, recording); + assert_eq!(s.active().unwrap().revision, revision); + } + s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("select.all".into(), json!({})); 2]); + assert!(s.actions.recording.is_some()); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.len(), 3); + s.actions.list.push(Action::new("Edit during playback", vec![("actions.duplicate".into(), json!({"action": 0}))])); + let before = s.actions.list.clone(); + let played = s.execute("actions.play", json!({"action": "Edit during playback"})).unwrap(); + assert!(played["failed"]["error"].as_str().is_some_and(|e| e.contains("playback")), "{played}"); + assert_eq!(s.actions.list, before); +} diff --git a/crates/engine/tests/it/actions_panel_menu.rs b/crates/engine/tests/it/actions_panel_menu.rs new file mode 100644 index 0000000000..6b65f2f08d --- /dev/null +++ b/crates/engine/tests/it/actions_panel_menu.rs @@ -0,0 +1,200 @@ +//! The remaining panel commands compose with the same definitions, journal and ATN boundary. +use photocraft_engine::{Session, actions_cmds::Action, actions_edit_cmds, atn_cmds, jobs::Started}; +use serde_json::json; + +/// Tiny real document with one empty action keeps recording and background jobs observable. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":40,"height":30})).unwrap(); + s.actions.list.push(Action::new("A", vec![])); + s +} + +/// A painted stripe gives both the remove tool and blur a real input before recording starts. +fn stroke(s: &mut Session, color: &str) { + s.execute("paint.stroke", json!({"points":[[8,15],[32,15]],"size":3,"color":color})).unwrap(); +} + +/// Mode changes flush under the previous mode without altering painting or ordinary recording. +#[test] +fn tool_recording_is_opt_in_and_toggles_do_not_reclassify_pending_steps() { + let mut s = session(); + s.execute("actions.record", json!({"action":"A"})).unwrap(); + stroke(&mut s, "#ff0000"); + s.execute("select.all", json!({})).unwrap(); + let original = s.actions.clone(); + assert!(s.execute("actions.allowToolRecording", json!({"enabled":"yes"})).is_err()); + assert_eq!(s.actions, original, "invalid mode must not flush pending work"); + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + stroke(&mut s, "#00ff00"); + s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap(); + stroke(&mut s, "#0000ff"); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::>(), ["select.all", "paint.stroke", "select.deselect"]); + assert_eq!(s.actions.list[0].steps[1].1["color"], "#00ff00"); + assert_eq!(s.journal.iter().filter(|s| s.0 == "paint.stroke").count(), 3, "all actual painting remains journaled"); + assert_eq!(s.execute("actions.list", json!({})).unwrap()["toolRecording"], false); +} + +/// A stroke started while off never joins a later enabled recording; ordinary jobs keep their owner. +#[test] +fn background_stroke_ownership_does_not_suppress_ordinary_filter_jobs() { + for command in ["paint.remove", "filter.blur.gaussianBlur"] { + let mut s = session(); + stroke(&mut s, "#bb3300"); + s.execute("actions.record", json!({"action":"A"})).unwrap(); + let params = if command == "paint.remove" { json!({"points":[[20,15],[21,15]],"size":3}) } else { json!({"radius":1}) }; + let Started::Job(job) = s.start(command, params).unwrap() else { panic!("expected the real background job: {command}") }; + let changed = s.execute("actions.allowToolRecording", json!({"enabled":true})); + if command == "paint.remove" { + assert!(changed.is_err(), "the in-flight stroke retains the mode it started under"); + assert!(!s.actions.record_tools); + } else { + changed.unwrap(); + } + s.wait_job(job).unwrap(); + if command == "paint.remove" { + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + let ids = s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::>(); + if command == "paint.remove" { + assert!(ids.is_empty()); + } else { + assert_eq!(ids, [command]); + } + } +} + +/// A Remove job started outside the current take neither blocks its toggle nor joins its steps. +#[test] +fn background_strokes_from_outside_the_current_take_do_not_block_the_toggle() { + for previous_take in [false, true] { + let mut s = session(); + stroke(&mut s, "#bb3300"); + if previous_take { + s.execute("actions.record", json!({"action":"A"})).unwrap(); + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + } + let Started::Job(job) = s.start("paint.remove", json!({"points":[[20,15],[21,15]],"size":3})).unwrap() else { + panic!("expected the real Remove job"); + }; + if previous_take { + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap(); + } + s.execute("actions.record", json!({"action":"A"})).unwrap(); + assert_eq!(s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap()["enabled"], true); + s.wait_job(job).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert!(s.actions.list[0].steps.is_empty()); + assert_eq!(s.journal.iter().filter(|step| step.0 == "paint.remove").count(), 1); + } +} + +/// Interleaved membership and numeric calls expose incorrect folder placement or target rewriting. +#[test] +fn set_duplication_preserves_metadata_call_targets_and_empty_folder_order() { + let mut s = session(); + s.actions.list.clear(); + for (name, set) in [("A", "S"), ("Other", "T"), ("B", "S")] { + let mut a = Action::new(name, vec![("actions.play".into(), json!({"action":2}))]); + a.set = Some(set.into()); + a.color = "red".into(); + a.disabled.insert(0); + a.dialogs.insert(0); + s.actions.list.push(a); + } + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:A".into(), "F5".into()); + }); + let result = s.execute("actions.duplicate", json!({"set":"S"})).unwrap(); + assert_eq!(result["set"], "S copy"); + assert_eq!(actions_edit_cmds::set_names(&s), ["S", "S copy", "T"]); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["A", "A copy", "B copy", "Other", "B"]); + for a in &s.actions.list { + assert_eq!(a.steps[0].1["action"], 4); + assert_eq!(a.color, "red"); + assert!(a.disabled.contains(&0)); + assert!(a.dialogs.contains(&0)); + } + assert!(!s.prefs().shortcuts.contains_key("actions.play:A copy")); + let mut empty = Session::new(); + for name in ["E", "F"] { + empty.execute("actions.newSet", json!({"name":name})).unwrap(); + } + empty.execute("actions.duplicate", json!({"set":"E"})).unwrap(); + assert_eq!(actions_edit_cmds::set_names(&empty), ["E", "E copy", "F"]); + let original = empty.actions.clone(); + for params in [json!({"set":"Missing"}), json!({"set":"E","action":0}), json!({"set":false})] { + assert!(empty.execute("actions.duplicate", params).is_err()); + assert_eq!(empty.actions, original); + } +} + +/// Menu insertion keeps descriptor absence and kind; empty descriptors and ambiguous commands differ. +#[test] +fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() { + assert!(!atn_cmds::menu_item_needs_settings("filter.noise.despeckle")); + assert!(atn_cmds::menu_item_needs_settings("filter.blur.gaussianBlur")); + for id in ["image.imageSize", "filter.blur.gaussianBlur", "filter.noise.despeckle"] { + for kind in ["name", "code"] { + let mut s = session(); + s.execute("actions.insertMenuItem", json!({"action":"A","id":id,"eventKind":kind})).unwrap(); + let bytes = atn_cmds::export("S", &[&s.actions.list[0]], &[], false).unwrap().0; + let raw = photocraft_psd::atn::parse(&bytes).unwrap(); + assert!(raw.actions[0].items[0].descriptor.is_none()); + let imported = atn_cmds::import(&bytes).unwrap(); + let (step, params) = &imported.actions[0].steps[0]; + assert_eq!(step, atn_cmds::MENU_ITEM); + assert_eq!(params["id"], id); + assert_eq!(params["eventKind"], kind); + let (saved, skipped) = atn_cmds::export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let saved = photocraft_psd::atn::parse(&saved).unwrap(); + assert_eq!(saved.actions[0].items[0], raw.actions[0].items[0]); + let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); + assert_eq!(played.get("failed").is_none(), !atn_cmds::menu_item_needs_settings(id), "{id}: {played}"); + } + } + let mut s = session(); + let original = s.actions.clone(); + for params in + [json!({"action":"A","id":"layer.setProps"}), json!({"action":"A","id":"file.open"}), json!({"action":"A","id":"image.imageSize","params":{"width":4}})] + { + assert!(s.execute("actions.insertMenuItem", params).is_err()); + assert_eq!(s.actions, original); + } + let mut raw = photocraft_psd::atn::AtnFile::default(); + raw.actions.push(photocraft_psd::atn::AtnAction { + items: vec![photocraft_psd::atn::AtnItem { + event: photocraft_psd::atn::AtnEvent::name("gaussianBlur"), + descriptor: Some(photocraft_psd::descriptor::Descriptor::new("null")), + ..Default::default() + }], + ..Default::default() + }); + let loaded = atn_cmds::import(&photocraft_psd::atn::write(&raw).unwrap()).unwrap(); + assert_eq!(loaded.actions[0].steps[0].0, "filter.blur.gaussianBlur"); +} + +/// A full curve is snapshotted independently of later source edits and replays as the work path. +#[test] +fn insert_path_captures_cubic_geometry_without_editing_the_document() { + let mut s = session(); + let path = json!({"fillRule":"evenodd","inverted":true,"subpaths":[{"closed":false,"op":"combine","knots":[{"anchor":[1.5,2.25],"in":[-1.0,3.0],"out":[5.0,8.0],"smooth":true},{"anchor":[20.0,18.0],"in":[12.0,5.0],"out":[21.0,19.0]}]}]}); + s.execute("path.set", json!({"name":"Curve","path":path})).unwrap(); + let expected = s.execute("path.info", json!({"name":"Curve"})).unwrap()["path"].clone(); + let revision = s.active().unwrap().revision; + assert!(s.execute("actions.insertPath", json!({"action":"A","name":"work"})).is_err()); + assert!(s.actions.list[0].steps.is_empty()); + s.execute("actions.insertPath", json!({"action":"A","name":"Curve"})).unwrap(); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.actions.list[0].steps[0].1["path"], expected); + s.execute("path.set", json!({"name":"Curve","path":{"subpaths":[]}})).unwrap(); + let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.execute("path.info", json!({"name":"work"})).unwrap()["path"], expected); + assert!(atn_cmds::export("S", &[&s.actions.list[0]], &[], false).is_err(), "the measured ATN descriptor cannot represent inversion"); +} diff --git a/crates/engine/tests/it/actions_record_again.rs b/crates/engine/tests/it/actions_record_again.rs new file mode 100644 index 0000000000..9e3b02367b --- /dev/null +++ b/crates/engine/tests/it/actions_record_again.rs @@ -0,0 +1,77 @@ +//! Record Again edits one definition only after its concrete command succeeds. +use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds}; +use serde_json::{Value, json}; + +/// A disabled, dialog-enabled resize followed by another step exposes flag and ordering loss. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + let mut a = Action::new("A", vec![("image.imageSize".into(), json!({"width": 4, "height": 3, "resample": "nearest"})), ("select.all".into(), json!({}))]); + a.disabled.insert(0); + a.dialogs.insert(0); + s.actions.list.push(a); + s +} + +/// Only the selected step runs, retains its flags, and exports its new settings to ATN. +#[test] +fn successful_rerecording_updates_one_step_and_roundtrips_new_settings() { + for menu in [false, true] { + let mut s = session(); + if menu { + s.actions.list[0].steps[0] = (atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"})); + } + let revision = s.actions.rev; + s.execute("actions.recordAgain", json!({"action": "A", "step": 0, "revision": revision, "params": {"width": 6, "height": 5, "resample": "nearest"}})) + .unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_engine::doc::Size::new(6, 5)); + assert!(s.active().unwrap().doc.selection.is_none(), "the following step did not play"); + let a = &s.actions.list[0]; + assert_eq!(a.steps.len(), 2); + assert_eq!(a.steps[0].0, "image.imageSize"); + assert_eq!(a.steps[0].1["width"], 6); + assert!(a.disabled.contains(&0)); + assert!(a.dialogs.contains(&0)); + assert_eq!(s.actions.rev, revision + 1); + assert_eq!(s.journal.last().unwrap().0, "image.imageSize"); + let bytes = atn_cmds::export("Set", &[a], &[], false).unwrap().0; + let loaded = atn_cmds::import(&bytes).unwrap(); + assert_eq!(loaded.actions[0].steps[0].1["width"].as_f64(), Some(6.0)); + assert!(loaded.actions[0].disabled.contains(&0)); + assert!(loaded.actions[0].dialogs.contains(&0)); + } +} + +/// Permit the wrapper while denying its concrete edit, proving nested authorization remains active. +fn deny_resize(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" { Err(EngineError::Other("resize denied".into())) } else { Ok(()) } +} + +/// Invalid settings, stale selections, denied execution and recording preserve the original step. +#[test] +fn failed_or_stale_rerecording_never_replaces_the_original_step() { + let mut s = session(); + let original = s.actions.list.clone(); + let revision = s.active().unwrap().revision; + for request in [ + json!({"action":"A","step":0}), + json!({"action":"A","step":0,"params":[]}), + json!({"action":"A","step":0,"params":{"percent":-1}}), + json!({"action":"A","step":99,"params":{}}), + json!({"action":"Missing","step":0,"params":{}}), + json!({"action":"A","step":0,"revision":1,"params":{}}), + ] { + assert!(s.execute("actions.recordAgain", request.clone()).is_err(), "{request}"); + assert_eq!(s.actions.list, original); + assert_eq!(s.actions.rev, 0); + assert_eq!(s.active().unwrap().revision, revision); + } + s.authorize = Some(deny_resize); + assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{"width":6,"height":5}})).is_err()); + assert_eq!(s.actions.list, original); + s.authorize = None; + s.execute("actions.record", json!({"action":"A"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{}})).is_err()); + assert_eq!(s.actions.list, original, "recording was not flushed by a refused edit"); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index bc8192a235..33e9e9e7cf 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,5 +1,8 @@ //! The crate's integration tests, linked into one test binary. +mod actions_duplicate; +mod actions_panel_menu; +mod actions_record_again; mod actions_single_history; mod affinity; mod artboard_export_names; diff --git a/crates/engine/tests/magnetic_lasso_composite.rs b/crates/engine/tests/magnetic_lasso_composite.rs new file mode 100644 index 0000000000..898e62e2e9 --- /dev/null +++ b/crates/engine/tests/magnetic_lasso_composite.rs @@ -0,0 +1,125 @@ +//! The Magnetic Lasso follows the edges of the visible composite, whichever layer is active +//! (#11686), in the live `EdgeSource` and in `select.magneticLasso` with `trace: true`. + +use photocraft_algo::magnetic::Settings; +use photocraft_doc::LayerId; +use photocraft_engine::Session; +use photocraft_engine::magnetic_cmds::EdgeSource; +use photocraft_geom::Rect; +use serde_json::json; + +/// The red rectangle's pixels: x 50..125, y 40..100, 75 x 60 = 4500 px. +const RED: Rect = Rect { x0: 50, y0: 40, x1: 125, y1: 100 }; +const RED_AREA: f32 = 4500.0; +/// Fastening points 4 px outside the rectangle's corners. +const OUTSIDE: [[f64; 2]; 4] = [[46.0, 36.0], [129.0, 36.0], [129.0, 104.0], [46.0, 104.0]]; + +struct Doc { + s: Session, + background: LayerId, + red: LayerId, +} + +/// A 200 x 150 RGB document: a white Background and a layer above it filled red over `RED`. The +/// red layer is active. +fn doc() -> Doc { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 150})).unwrap(); + let background = s.active().unwrap().doc.layers[0].id; + s.edit("paint", |doc, _| { + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 150), &[1.0, 1.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let red = s.active().unwrap().active_layer.unwrap(); + assert_ne!(red, background); + s.edit("paint", |doc, _| { + doc.layer_mut(red).unwrap().surface_mut().unwrap().fill_rect(RED, &[0.86, 0.12, 0.12, 1.0]); + Ok(()) + }) + .unwrap(); + Doc { s, background, red } +} + +fn select(s: &mut Session, id: LayerId) { + s.execute("layer.select", json!({"layer": id.0})).unwrap(); +} + +fn coverage(s: &Session, x: i32, y: i32) -> f32 { + s.active().unwrap().doc.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0)) +} + +fn selected_area(s: &Session) -> f32 { + (0..150).flat_map(|y| (0..200).map(move |x| (x, y))).map(|(x, y)| coverage(s, x, y)).sum() +} + +/// Traces a border through `OUTSIDE` and asserts it selected the rectangle, not the points' own +/// quadrilateral (83 x 68 = 5644 px). +fn assert_snaps(s: &mut Session, what: &str) { + s.execute("select.magneticLasso", json!({"points": OUTSIDE, "trace": true})).unwrap(); + let area = selected_area(s); + assert!((area - RED_AREA).abs() < 150.0, "{what}: the border should select the rectangle ({RED_AREA} px), selected {area} px"); + assert!(coverage(s, 52, 42) > 0.99 && coverage(s, 122, 97) > 0.99, "{what}: the rectangle's corners are inside"); + assert!(coverage(s, 48, 70) < 0.01 && coverage(s, 127, 70) < 0.01, "{what}: just outside the rectangle is not selected"); + s.execute("select.deselect", json!({})).unwrap(); +} + +/// Whether a fastening point 4 px outside the rectangle's left edge snaps onto it. +fn edge_snaps(s: &Session) -> bool { + let mut src = EdgeSource::new(s).unwrap(); + let p = src.snap([46.0, 70.0], Settings::default()).unwrap(); + (p[0] - 50.0).abs() < 1.5 +} + +#[test] +fn the_border_snaps_whichever_layer_is_active() { + let mut d = doc(); + assert_snaps(&mut d.s, "the red layer active"); + select(&mut d.s, d.background); + assert_snaps(&mut d.s, "the Background active"); + assert!(edge_snaps(&d.s), "Background active: the live source sees the red layer's edge"); + d.s.execute("layer.new.layer", json!({})).unwrap(); + assert!(d.s.active().unwrap().active_layer.is_some_and(|id| id != d.background && id != d.red)); + assert!(edge_snaps(&d.s), "a new empty layer active"); + assert_snaps(&mut d.s, "a new empty layer active"); +} + +#[test] +fn an_adjustment_or_group_layer_active_snaps_too() { + let mut d = doc(); + d.s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + let st = d.s.active().unwrap(); + assert!(st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.surface().is_none())); + assert!(edge_snaps(&d.s), "an adjustment layer active"); + assert_snaps(&mut d.s, "an adjustment layer active"); + select(&mut d.s, d.background); + d.s.execute("layer.new.group", json!({})).unwrap(); + assert!(edge_snaps(&d.s), "an empty group active"); + assert_snaps(&mut d.s, "an empty group active"); +} + +#[test] +fn a_hidden_layer_contributes_no_edges() { + let mut d = doc(); + select(&mut d.s, d.background); + d.s.execute("layer.hideLayers", json!({"layer": d.red.0})).unwrap(); + assert!(!edge_snaps(&d.s), "the hidden red layer has no edge to snap to"); + d.s.execute("select.magneticLasso", json!({"points": OUTSIDE, "trace": true})).unwrap(); + let area = selected_area(&d.s); + // The border follows the pointer: the points' own 83 x 68 quadrilateral. + assert!((area - 5644.0).abs() < 150.0, "selected {area} px"); + // Showing it again brings the edge back, for a source made after the change. + d.s.execute("layer.showLayers", json!({"layer": d.red.0})).unwrap(); + assert!(edge_snaps(&d.s)); +} + +#[test] +fn a_live_source_goes_stale_when_the_composite_changes() { + let mut d = doc(); + select(&mut d.s, d.background); + let src = EdgeSource::new(&d.s).unwrap(); + assert!(src.is_current(&d.s)); + d.s.execute("layer.hideLayers", json!({"layer": d.red.0})).unwrap(); + assert!(!src.is_current(&d.s), "hiding a layer changes what the border follows"); +} diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs index 202f0216e1..4bda862af1 100644 --- a/crates/gpu/src/plan/lower.rs +++ b/crates/gpu/src/plan/lower.rs @@ -24,7 +24,7 @@ pub(super) fn placement(anchor: (f64, f64), link: bool, phase: (f32, f32), scale 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 inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(photocraft_compose::pattern::MIN_PLACEMENT_SCALE) } else { 1.0 }; let a = f64::from(angle).to_radians(); Placement { origin, cs: (a.cos(), a.sin()), inv_scale } } diff --git a/crates/io/src/annotations_map.rs b/crates/io/src/annotations_map.rs index 27b6c04477..9c41899170 100644 --- a/crates/io/src/annotations_map.rs +++ b/crates/io/src/annotations_map.rs @@ -13,13 +13,14 @@ //! (`pixelLength`, `logicalLength`, `logicalUnits`) follow Photoshop's scripting names for the //! measurement scale; no sample file was available, so parsing is lenient and the raw resource //! is kept byte-exact while the scale is unchanged. -//! * Count information: image resource 1080 is kept verbatim (its layout is not public) unless -//! the document has its own count markers, which only `.pcraft` stores. +//! * Count information: image resource 1080 (a version-16 `Cnt ` descriptor) maps to the +//! document's ordered Count groups. Unreadable resources are kept while there are no groups. use std::sync::Arc; -use photocraft_doc::{Color, Document, MeasurementScale, Note}; -use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; +use photocraft_doc::{Color, CountGroup, Document, MeasurementScale, Note}; +use photocraft_psd::ImageResource; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; pub const MEASUREMENT_SCALE: u16 = 1074; pub const COUNT_INFO: u16 = 1080; @@ -341,17 +342,118 @@ pub fn fresh_scale_resource(doc: &Document) -> Option> { (!doc.measurement.scale.is_default()).then(|| write_scale_resource(&doc.measurement.scale)) } +/// Decode the measured resource as a whole so unknown or damaged groups stay opaque. +fn count_groups_from_resource(data: &[u8]) -> Option> { + let d = VersionedDescriptor::from_bytes(data).ok()?.descriptor; + if !d.class_id.is("Cnt ") || d.items.len() != 2 || d.get("Vrsn") != Some(&Value::Integer(1)) { + return None; + } + let Value::List(groups) = d.get("countGroupList")? else { return None }; + groups.iter().map(count_group_from_descriptor).collect() +} + +/// Decode the fields the Count tool can use without coercing unrecognized data. +fn count_group_from_descriptor(value: &Value) -> Option { + let Value::Descriptor(d) = value else { return None }; + if !d.class_id.is("countGroup") || d.items.len() != 8 { + return None; + } + let integer = |key| match d.get(key)? { + Value::Integer(n) => u32::try_from(*n).ok(), + _ => None, + }; + let channel = |key| u8::try_from(integer(key)?).ok().map(|n| f32::from(n) / 255.0); + let color = Color::rgb(channel("Rd ")?, channel("Grn ")?, channel("Bl ")?); + let Value::Text(name) = d.get("Nm ")? else { return None }; + let Value::Boolean(visible) = d.get("Vsbl")? else { return None }; + let Value::List(objects) = d.get("countObjectList")? else { return None }; + let marker_size = integer("Rds ")?; + let label_size = integer("fontSize")?; + // Keep out-of-range sizes opaque instead of passing them to the marker and text renderers. + if !(1..=10).contains(&marker_size) || !(8..=72).contains(&label_size) { + return None; + } + let name = String::from_utf16(name.0.strip_suffix(&[0]).unwrap_or(&name.0)).ok()?; + let points = objects.iter().map(count_point_from_descriptor).collect::>()?; + Some(CountGroup { name, color, marker_size, label_size, visible: *visible, points }) +} + +/// Keep document coordinates as doubles and reject nonfinite values. +fn count_point_from_descriptor(value: &Value) -> Option<[f64; 2]> { + let Value::Descriptor(d) = value else { return None }; + if !d.class_id.is("countObject") || d.items.len() != 2 { + return None; + } + let (Value::Double(x), Value::Double(y)) = (d.get("X ")?, d.get("Y ")?) else { return None }; + (x.is_finite() && y.is_finite()).then_some([*x, *y]) +} + +/// Consume readable Count data. Keep only opaque bytes when no resource can be read. +pub(crate) fn import_count_resource(doc: &mut Document) { + if let Some(groups) = doc.metadata.psd_resources.iter().filter(|(id, _, _)| *id == COUNT_INFO).find_map(|(_, _, data)| count_groups_from_resource(data)) { + doc.measurement.count_groups = groups; + doc.metadata.psd_resources.retain(|(id, _, _)| *id != COUNT_INFO); + } +} + +/// Resource 1080 from the current groups, warning when PSD changes sizes or omits points. +pub(crate) fn fresh_count_resource(doc: &Document, warnings: &mut Vec) -> Option { + if doc.measurement.count_groups.is_empty() { + return None; + } + let groups = doc.measurement.count_groups.iter().map(|group| count_group_descriptor(group, warnings)).collect(); + let d = Descriptor::new("Cnt ").with("Vrsn", Value::Integer(1)).with("countGroupList", Value::List(groups)); + Some(ImageResource::new(COUNT_INFO, VersionedDescriptor::new(d).to_bytes())) +} + +/// Write the measured group layout and preserve marker order. +fn count_group_descriptor(group: &CountGroup, warnings: &mut Vec) -> Value { + let marker_size = group.marker_size.clamp(1, 10); + let label_size = group.label_size.clamp(8, 72); + if marker_size != group.marker_size || label_size != group.label_size { + warnings.push(format!( + "Count group {:?} sizes adjusted for PSD (marker {} to {marker_size}, label {} to {label_size})", + group.name, group.marker_size, group.label_size + )); + } + let [r, g, b, _] = group.color.to_rgba8(); + let objects: Vec<_> = group + .points + .iter() + .filter(|[x, y]| x.is_finite() && y.is_finite()) + .map(|&[x, y]| Value::Descriptor(Descriptor::new("countObject").with("X ", Value::Double(x)).with("Y ", Value::Double(y)))) + .collect(); + let omitted = group.points.len() - objects.len(); + if omitted != 0 { + warnings.push(format!("Count group {:?} had nonfinite points omitted from PSD export ({omitted} total)", group.name)); + } + Value::Descriptor( + Descriptor::new("countGroup") + .with("Rd ", Value::Integer(i32::from(r))) + .with("Grn ", Value::Integer(i32::from(g))) + .with("Bl ", Value::Integer(i32::from(b))) + .with("Nm ", Value::Text(UnicodeString::new_nul(&group.name))) + .with("Rds ", Value::Integer(marker_size as i32)) + .with("fontSize", Value::Integer(label_size as i32)) + .with("Vsbl", Value::Boolean(group.visible)) + .with("countObjectList", Value::List(objects)), + ) +} + /// Whether to keep a preserved resource with this id on export. pub fn keep_resource(doc: &Document, id: u16) -> bool { match id { MEASUREMENT_SCALE => keep_raw_scale(doc), - // Our count markers can't be written into Photoshop's (unpublished) layout; a stale copy - // would contradict them, so it is dropped once the document has markers of its own. - COUNT_INFO => doc.measurement.count_total() == 0, + // An opaque copy is only used when there are no document groups to write. + COUNT_INFO => doc.measurement.count_groups.is_empty(), _ => true, } } +#[cfg(test)] +#[path = "count_resource_tests.rs"] +mod count_resource_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/io/src/count_resource_tests.rs b/crates/io/src/count_resource_tests.rs new file mode 100644 index 0000000000..fa84fc2233 --- /dev/null +++ b/crates/io/src/count_resource_tests.rs @@ -0,0 +1,213 @@ +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Color, ColorTable, CountGroup, Document}; +use photocraft_geom::Size; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::{ColorMode as PsdMode, ImageResource, PsdFile}; + +use super::{COUNT_INFO, count_groups_from_resource}; + +fn measured_resource(second_y: f64) -> Vec { + let point = |x, y| Value::Descriptor(Descriptor::new("countObject").with("X ", Value::Double(x)).with("Y ", Value::Double(y))); + let first = Descriptor::new("countGroup") + .with("Rd ", Value::Integer(51)) + .with("Grn ", Value::Integer(102)) + .with("Bl ", Value::Integer(204)) + .with("Nm ", Value::Text(UnicodeString::new_nul("Cells"))) + .with("Rds ", Value::Integer(3)) + .with("fontSize", Value::Integer(12)) + .with("Vsbl", Value::Boolean(true)) + .with("countObjectList", Value::List(vec![point(9.5, 10.5), point(1.125, second_y), point(3.5, 4.5)])); + let second = Descriptor::new("countGroup") + .with("Rd ", Value::Integer(255)) + .with("Grn ", Value::Integer(0)) + .with("Bl ", Value::Integer(0)) + .with("Nm ", Value::Text(UnicodeString::new_nul("Café"))) + .with("Rds ", Value::Integer(10)) + .with("fontSize", Value::Integer(72)) + .with("Vsbl", Value::Boolean(false)) + .with("countObjectList", Value::List(vec![point(7.5, 8.5), point(5.5, 6.5)])); + VersionedDescriptor::new( + Descriptor::new("Cnt ").with("Vrsn", Value::Integer(1)).with("countGroupList", Value::List(vec![Value::Descriptor(first), Value::Descriptor(second)])), + ) + .to_bytes() +} + +fn groups() -> Vec { + vec![ + CountGroup { + name: "Cells".into(), + color: Color::rgb(51.0 / 255.0, 102.0 / 255.0, 204.0 / 255.0), + marker_size: 3, + label_size: 12, + visible: true, + points: vec![[9.5, 10.5], [1.125, 2.75], [3.5, 4.5]], + }, + CountGroup { + name: "Café".into(), + color: Color::rgb(1.0, 0.0, 0.0), + marker_size: 10, + label_size: 72, + visible: false, + points: vec![[7.5, 8.5], [5.5, 6.5]], + }, + ] +} + +fn document(mode: ColorMode) -> Document { + Document::new("Counts", Size::new(16, 16), mode, SampleType::U8) +} + +fn to_psd(doc: &Document) -> Vec { + crate::export(doc, "counts.psd", &crate::ExportOptions::default()).unwrap().bytes +} + +fn import_counts(data: Vec) -> Document { + let mut file = PsdFile::from_bytes(&to_psd(&document(ColorMode::Rgb))).unwrap(); + file.resources.push(ImageResource::new(COUNT_INFO, data)); + crate::import("counts.psd", &file.to_bytes().unwrap()).unwrap().document +} + +/// Two independently constructed groups preserve every option and the order of all five points. +#[test] +fn parses_hand_built_count_descriptor() { + assert_eq!(count_groups_from_resource(&measured_resource(2.75)), Some(groups())); + assert!(count_groups_from_resource(&measured_resource(f64::INFINITY)).is_none()); + let mut trailing = measured_resource(2.75); + trailing.push(1); + assert!(count_groups_from_resource(&trailing).is_none()); +} + +/// Count groups round-trip through the real PSD reader and both export routes. +#[test] +fn count_groups_round_trip_through_psd() { + for (mode, wire_mode) in [(ColorMode::Rgb, PsdMode::Rgb), (ColorMode::Bitmap, PsdMode::Bitmap), (ColorMode::Multichannel, PsdMode::Multichannel)] { + let mut doc = document(mode); + doc.measurement.count_groups = groups(); + let exported = crate::export(&doc, "counts.psd", &crate::ExportOptions::default()).unwrap(); + assert!(!exported.warnings.iter().any(|warning| warning.starts_with("Count group "))); + let bytes = exported.bytes; + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(file.header.color_mode, wire_mode); + let resources: Vec<_> = file.resources.iter().filter(|r| r.id == COUNT_INFO).collect(); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].data, measured_resource(2.75)); + let reopened = crate::import("counts.psd", &bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); + assert!(!reopened.metadata.psd_resources.iter().any(|(id, _, _)| *id == COUNT_INFO)); + } +} + +/// Both export routes report clamped sizes and preserve the original Count groups. +#[test] +fn count_size_clamps_warn_without_changing_source_groups() { + for mode in [ColorMode::Rgb, ColorMode::Bitmap] { + let mut doc = document(mode); + let mut original = groups(); + original[0].marker_size = 0; + original[0].label_size = 73; + original[1].marker_size = 11; + original[1].label_size = 7; + doc.measurement.count_groups = original.clone(); + + let exported = crate::export(&doc, "counts.psd", &crate::ExportOptions::default()).unwrap(); + let warnings: Vec<_> = exported.warnings.iter().filter(|warning| warning.starts_with("Count group ")).map(String::as_str).collect(); + assert_eq!( + warnings, + [ + "Count group \"Cells\" sizes adjusted for PSD (marker 0 to 1, label 73 to 72)", + "Count group \"Café\" sizes adjusted for PSD (marker 11 to 10, label 7 to 8)", + ] + ); + assert_eq!(doc.measurement.count_groups, original); + + let reopened = crate::import("counts.psd", &exported.bytes).unwrap().document; + let mut expected = original; + expected[0].marker_size = 1; + expected[0].label_size = 72; + expected[1].marker_size = 10; + expected[1].label_size = 8; + assert_eq!(reopened.measurement.count_groups, expected); + } +} + +/// Opaque data survives an untouched save and an add-then-delete, but current groups win. +#[test] +fn unreadable_count_resource_is_preserved_without_groups() { + let raw = vec![0, 0, 0, 16, 0]; + let mut doc = import_counts(raw.clone()); + assert!(doc.measurement.count_groups.is_empty()); + let check_raw = |doc: &Document| { + let file = PsdFile::from_bytes(&to_psd(doc)).unwrap(); + assert_eq!(file.resource(COUNT_INFO).unwrap().data, raw); + }; + check_raw(&doc); + doc.measurement.count_groups.push(CountGroup::default()); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + let mut resources = file.resources.iter().filter(|r| r.id == COUNT_INFO); + assert_eq!(count_groups_from_resource(&resources.next().unwrap().data), Some(doc.measurement.count_groups.clone())); + assert!(resources.next().is_none()); + doc.measurement.count_groups.clear(); + check_raw(&doc); +} + +/// Deleting the imported groups cannot bring back the readable resource. +#[test] +fn deleting_all_readable_count_groups_writes_no_resource() { + let mut doc = import_counts(measured_resource(2.75)); + assert_eq!(doc.measurement.count_groups, groups()); + assert!(!doc.metadata.psd_resources.iter().any(|(id, _, _)| *id == COUNT_INFO)); + doc.measurement.count_groups.clear(); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + assert!(file.resource(COUNT_INFO).is_none()); +} + +/// Indexed export uses the shared layerless writer and keeps exactly one Count resource. +#[test] +fn indexed_count_groups_round_trip_through_psd() { + let mut doc = document(ColorMode::Indexed); + doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: None }); + let opaque = vec![0, 0, 0, 16, 0]; + doc.metadata.psd_resources.push((COUNT_INFO, String::new(), std::sync::Arc::new(opaque.clone()))); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + assert_eq!(file.header.color_mode, PsdMode::Indexed); + assert_eq!(file.resource(COUNT_INFO).unwrap().data, opaque); + + doc.measurement.count_groups = groups(); + let bytes = to_psd(&doc); + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(file.header.color_mode, PsdMode::Indexed); + assert!(file.layer_info.is_none()); + let resources: Vec<_> = file.resources.iter().filter(|r| r.id == COUNT_INFO).collect(); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].data, measured_resource(2.75)); + let reopened = crate::import("counts.psd", &bytes).unwrap().document; + assert_eq!(reopened.color_table, doc.color_table); + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); +} + +/// Both PSD routes report omitted points, keeping the finite points in order. +#[test] +fn nonfinite_count_points_warn_on_psd_export() { + for (mode, wire_mode) in [(ColorMode::Rgb, PsdMode::Rgb), (ColorMode::Bitmap, PsdMode::Bitmap)] { + let mut doc = document(mode); + doc.measurement.count_groups = groups(); + let options = crate::ExportOptions::default(); + let clean = crate::export(&doc, "counts.psd", &options).unwrap(); + assert!(!clean.warnings.iter().any(|warning| warning.starts_with("Count group "))); + let reopened = crate::import("counts.psd", &clean.bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); + + let mut expected = groups(); + expected[0].points.remove(1); + doc.measurement.count_groups[0].points[1][1] = f64::INFINITY; + let original = doc.measurement.count_groups.clone(); + let exported = crate::export(&doc, "counts.psd", &options).unwrap(); + let warnings: Vec<_> = exported.warnings.iter().filter(|warning| warning.starts_with("Count group ")).map(String::as_str).collect(); + assert_eq!(warnings, ["Count group \"Cells\" had nonfinite points omitted from PSD export (1 total)"]); + assert_eq!(doc.measurement.count_groups, original); + let file = PsdFile::from_bytes(&exported.bytes).unwrap(); + assert_eq!(file.header.color_mode, wire_mode); + let reopened = crate::import("counts.psd", &exported.bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, expected); + } +} diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index 9f8850683a..b227d28f1f 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -196,6 +196,7 @@ pub(crate) fn layerless_file( if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) { resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data)); } + resources.extend(crate::annotations_map::fresh_count_resource(doc, warnings)); resources.push(version_info_resource(true)); // Document-level tagged blocks (patterns, notes, ...) are valid without a layer section; a // kept layer block would describe layers the document doesn't have. Their signatures follow diff --git a/crates/io/src/psd_export/document.rs b/crates/io/src/psd_export/document.rs index d2728f9f24..69231c7aee 100644 --- a/crates/io/src/psd_export/document.rs +++ b/crates/io/src/psd_export/document.rs @@ -235,6 +235,7 @@ fn export_supported_document( if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) { resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data)); } + resources.extend(crate::annotations_map::fresh_count_resource(doc, &mut ex.warnings)); if let Some(Some(data)) = comps_resource { resources.push(ImageResource::new(crate::comps_map::LAYER_COMPS, data)); } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 20e366458e..5e582ffd81 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -710,7 +710,7 @@ impl Ctx<'_> { crate::linked::embedded_payload_in_blocks(global.iter().map(|(_, key, data)| (key, data.as_slice())), &id).map(|(_, source)| source) }) }); - LayerContent::Smart(SmartObject { + let mut sm = SmartObject { contents_id, source: SmartSource::Linked { path: id }, vector_preview, @@ -723,7 +723,9 @@ impl Ctx<'_> { warp, stack_mode: None, perspective, - }) + }; + crate::smart_map::pin_puppet_meshes(&mut sm); + LayerContent::Smart(sm) } /// Typed vector mask from `vsms`/`vmsk` (non-shape layers). A user mask that Photoshop @@ -1238,6 +1240,7 @@ fn read_initial_metadata( if let Some(scale) = crate::annotations_map::raw_scale(doc) { doc.measurement.scale = scale; } + crate::annotations_map::import_count_resource(doc); let _ = layered; (damaged, alloc_error) } diff --git a/crates/io/src/puppet_map.rs b/crates/io/src/puppet_map.rs new file mode 100644 index 0000000000..f1015af30b --- /dev/null +++ b/crates/io/src/puppet_map.rs @@ -0,0 +1,308 @@ +//! Puppet Warp smart filters (`Fltr` class `rigidTransform`) as observed in Photoshop-authored +//! mockup PSDs. +//! +//! The filter holds a `puppetShapeList` with one `puppetShape` per mesh. Each shape stores its +//! pins (`PinP` where they were placed, `posFinalPins` where they are now, x, y pairs in document +//! pixels) and the mesh Photoshop solved for them, as raw data: +//! +//! ```text +//! originalVertexArray, deformedVertexArray: f32 LE x, y per vertex (document pixels) +//! indexArray: i32 LE, three per triangle +//! ``` +//! +//! and the corners of the layer the mesh was built on (`PuX0..3`, `PuY0..3` on the filter). +//! Replace Contents keeps the mesh: Photoshop redraws the new contents through the stored +//! triangles (measured on a dot grid: 0.1 px mean). So the mesh is imported as the +//! `layer.smartObjects.puppetWarp` filter's `mesh`, which is drawn as it is while the pins are +//! unchanged, and the whole item is kept as a template ([`TEMPLATE_KEY`]) to write back. Pins +//! alone feed PhotoCraft's own solver once they are edited; the mode, density and rotation +//! settings aren't mapped (their stored values aren't identified), so those stay at defaults. + +use photocraft_doc::SmartFilter; +use photocraft_psd::descriptor::{Descriptor, Value}; +use serde_json::{Value as J, json}; + +use super::{blend_options, from_hex, get_desc, set, to_hex}; +use crate::blocks::num; + +pub(super) const CLASS: &str = "rigidTransform"; +pub(super) const COMMAND: &str = "layer.smartObjects.puppetWarp"; +pub(super) const TEMPLATE_KEY: &str = "__puppetPsd"; +/// Far beyond the few hundred vertices Photoshop's meshes have, yet small enough that the +/// copies a hostile mesh makes (arrays, JSON params, cloned filters) stay bounded. +const MAX_VERTS: usize = 1 << 16; + +fn raw<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [u8]> { + match d.get(key)? { + Value::RawData(b) => Some(b), + _ => None, + } +} + +fn floats(d: &Descriptor, key: &str) -> Option> { + let Value::List(v) = d.get(key)? else { return None }; + v.iter().map(|x| num(Some(x)).filter(|f| f.is_finite())).collect() +} + +/// `f32` LE x, y pairs; `None` unless whole, finite and at most [`MAX_VERTS`]. +fn vertices(b: &[u8]) -> Option> { + if !b.len().is_multiple_of(8) || b.len() / 8 > MAX_VERTS { + return None; + } + let f = |c: &[u8]| c.try_into().ok().map(f32::from_le_bytes).filter(|v| v.is_finite()).map(f64::from); + b.as_chunks::<8>().0.iter().map(|c| Some([f(&c[..4])?, f(&c[4..])?])).collect() +} + +/// `i32` LE triples, each index below `n`. +fn triangles(b: &[u8], n: usize) -> Option> { + if !b.len().is_multiple_of(12) || b.len() / 12 > 2 * MAX_VERTS { + return None; + } + let i = |c: &[u8]| c.try_into().ok().map(i32::from_le_bytes).and_then(|v| usize::try_from(v).ok()).filter(|&v| v < n); + b.as_chunks::<12>().0.iter().map(|c| Some([i(&c[..4])?, i(&c[4..8])?, i(&c[8..])?])).collect() +} + +/// One shape's pins (as params), vertices, deformed vertices and triangles. +type Shape = (Vec, Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[usize; 3]>); + +/// Pins and mesh of one `puppetShape`, with vertex indices offset by `base`. +fn shape(s: &Descriptor, base: usize) -> Option { + let (from, to) = (floats(s, "PinP")?, floats(s, "posFinalPins")?); + if from.len() != to.len() || !from.len().is_multiple_of(2) { + return None; + } + let pins = from.as_chunks::<2>().0.iter().zip(to.as_chunks::<2>().0).map(|(a, b)| json!({"src": a, "dst": b})).collect(); + let verts = vertices(raw(s, "originalVertexArray")?)?; + let deformed = vertices(raw(s, "deformedVertexArray")?)?; + if verts.len() != deformed.len() { + return None; + } + let tris = triangles(raw(s, "indexArray")?, verts.len())?; + Some((pins, verts, deformed, tris.into_iter().map(|t| t.map(|i| i + base)).collect())) +} + +/// Pins and mesh of a `rigidTransform` descriptor (shapes joined into one mesh), as +/// `{"pins", "mesh"}`. +fn settings(f: &Descriptor) -> Option { + let Some(Value::List(shapes)) = f.get("puppetShapeList") else { return None }; + let (mut pins, mut verts, mut deformed, mut tris) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for s in shapes { + let Value::Descriptor(s) = s else { return None }; + let (p, v, d, t) = shape(s, verts.len())?; + pins.extend(p); + verts.extend(v); + deformed.extend(d); + tris.extend(t); + } + if tris.is_empty() || verts.len() > MAX_VERTS { + return None; + } + let pins = J::Array(pins); + Some(json!({"pins": pins, "mesh": {"pins": pins, "verts": verts, "deformed": deformed, "tris": tris}})) +} + +/// The corners of the layer the mesh was built on (`PuX0`, `PuY0` … `PuX3`, `PuY3`), as x, y +/// pairs. +pub(super) fn built_on(params: &J) -> Option<[f64; 8]> { + let item = template(params).ok()?; + let f = get_desc(&item, "Fltr")?; + let mut q = [0.0; 8]; + for i in 0..4 { + q[2 * i] = num(f.get(&format!("PuX{i}"))).filter(|v| v.is_finite())?; + q[2 * i + 1] = num(f.get(&format!("PuY{i}"))).filter(|v| v.is_finite())?; + } + Some(q) +} + +/// `layer.smartObjects.puppetWarp` params for a Puppet Warp item with a readable mesh. +pub(super) fn import_params(item: &Descriptor) -> Option { + let f = get_desc(item, "Fltr")?; + if !f.class_id.is(CLASS) { + return None; + } + let mut params = settings(f)?; + // Neutral blending in the template: the model's own fields replace it on export. + let mut template = item.clone(); + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + set(&mut template, "blendOptions", blend_options(&neutral)); + set(&mut template, "enab", Value::Boolean(true)); + params.as_object_mut()?.insert(TEMPLATE_KEY.into(), json!(to_hex(&template.to_bytes()))); + Some(params) +} + +/// The template item of `params`, checked to be a Puppet Warp filter. +fn template(params: &J) -> Result { + let raw = params.get(TEMPLATE_KEY).and_then(J::as_str).ok_or("a Puppet Warp made in PhotoCraft is not written to PSD")?; + let bytes = from_hex(raw).ok_or("the Puppet Warp PSD data is not valid hex")?; + let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Puppet Warp PSD data is unreadable ({e})"))?; + if !item.class_id.is("filterFX") || !get_desc(&item, "Fltr").is_some_and(|f| f.class_id.is(CLASS)) { + return Err("the Puppet Warp PSD data is not a Puppet Warp filter".into()); + } + Ok(item) +} + +/// The `filterFXList` item: the template, while the pins are still the imported ones. +pub(super) fn export_item(params: &J) -> Result { + let item = template(params)?; + let imported = get_desc(&item, "Fltr").and_then(settings).ok_or("the Puppet Warp PSD data has no readable mesh")?; + if params.get("pins") != imported.get("pins") { + return Err("Puppet Warp pins moved in PhotoCraft are not written to PSD".into()); + } + // Imported params set no solver settings, so a set one is an edit the template doesn't hold. + if let Some(k) = ["mode", "density", "expansion"].into_iter().find(|k| params.get(*k).is_some()) { + return Err(format!("the Puppet Warp `{k}` set in PhotoCraft is not written to PSD")); + } + Ok(item) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter, parse_sold}; + use photocraft_psd::descriptor::{UnicodeString, VersionedDescriptor}; + + const QUAD: [f64; 8] = [10.0, 20.0, 110.0, 22.0, 108.0, 220.0, 12.0, 218.0]; + + fn list(v: &[f64]) -> Value { + Value::List(v.iter().map(|x| Value::Double(*x)).collect()) + } + + fn f32s(v: &[[f32; 2]]) -> Value { + Value::RawData(v.iter().flatten().flat_map(|x| x.to_le_bytes()).collect()) + } + + fn i32s(v: &[i32]) -> Value { + Value::RawData(v.iter().flat_map(|x| x.to_le_bytes()).collect()) + } + + /// A Puppet Warp shape as Photoshop writes it: a box of two triangles with one pin. + fn shape(dx: f32) -> Descriptor { + let verts = [[10.0, 20.0], [110.0, 22.0], [108.0, 220.0], [12.0, 218.0]]; + let moved: Vec<[f32; 2]> = verts.iter().map(|v| [v[0] + dx, v[1]]).collect(); + Descriptor::new("puppetShape") + .with("rigidType", Value::Boolean(true)) + .with("originalVertexArray", f32s(&verts)) + .with("deformedVertexArray", f32s(&moved)) + .with("indexArray", i32s(&[0, 1, 2, 0, 2, 3])) + .with("pinOffsets", list(&[0.0, 0.0])) + .with("posFinalPins", list(&[60.0 + f64::from(dx), 120.0])) + .with("pinVertexIndices", list(&[0.0])) + .with("PinP", list(&[60.0, 120.0])) + .with("meshExpansion", Value::Integer(2)) + } + + fn item(shapes: Vec) -> Descriptor { + let mut f = Descriptor::new(CLASS) + .with("rigidType", Value::Boolean(true)) + .with("puppetShapeList", Value::List(shapes.into_iter().map(Value::Descriptor).collect())); + for i in 0..4 { + f = f.with(&format!("PuX{i}"), Value::Double(QUAD[2 * i])).with(&format!("PuY{i}"), Value::Double(QUAD[2 * i + 1])); + } + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Puppet Warp"))) + .with("blendOptions", blend_options(&neutral)) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(973)) + } + + #[test] + fn puppet_items_map_to_the_command_with_their_mesh() { + let src = item(vec![shape(4.0), shape(-2.0)]); + let f = filter_from_item(&src); + assert_eq!(f.command, COMMAND); + assert_eq!(f.params["pins"], json!([{"src": [60.0, 120.0], "dst": [64.0, 120.0]}, {"src": [60.0, 120.0], "dst": [58.0, 120.0]}])); + let mesh = &f.params["mesh"]; + assert_eq!(mesh["pins"], f.params["pins"]); + assert_eq!(mesh["verts"].as_array().unwrap().len(), 8); + assert_eq!(mesh["verts"][2], json!([108.0, 220.0])); + assert_eq!(mesh["deformed"][2], json!([112.0, 220.0])); + // The second shape's triangles index its own vertices. + assert_eq!(mesh["tris"], json!([[0, 1, 2], [0, 2, 3], [4, 5, 6], [4, 6, 7]])); + assert_eq!(built_on(&f.params), Some(QUAD)); + // Unchanged pins write the original item back; blending is the model's. + assert_eq!(item_for_filter(&f).unwrap(), src); + let mut faded = f.clone(); + faded.opacity = 0.5; + assert_eq!(filter_from_item(&item_for_filter(&faded).unwrap()).opacity, 0.5); + // Moved pins aren't written (the stored mesh would no longer match them). + let mut moved = f.clone(); + moved.params["pins"][0]["dst"] = json!([70.0, 120.0]); + assert!(item_for_filter(&moved).is_err()); + // Nor are solver settings set after import (the template holds the imported ones). + for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] { + let mut edited = f.clone(); + edited.params[k] = v; + assert!(item_for_filter(&edited).is_err(), "{k}"); + } + // The import records the placement the mesh was solved for; the item written back is + // unchanged by it. + let mut sm = photocraft_doc::SmartObject::new( + photocraft_doc::SmartSource::Linked { path: String::new() }, + photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 3.0, 4.0] }, + None, + ); + sm.smart_filters.push(f.clone()); + crate::smart_map::pin_puppet_meshes(&mut sm); + assert_eq!(sm.smart_filters[0].params["mesh"]["placement"], sm.placement_json()); + assert_eq!(item_for_filter(&sm.smart_filters[0]).unwrap(), src); + // A Puppet Warp made in PhotoCraft has no template: not written, as before. + let own = SmartFilter { params: json!({"pins": f.params["pins"]}), ..f }; + assert!(item_for_filter(&own).is_err()); + } + + #[test] + fn unreadable_puppet_filters_stay_opaque() { + let with = |key: &str, v: Value| { + let mut s = shape(4.0); + set(&mut s, key, v); + item(vec![s]) + }; + for src in [ + with("originalVertexArray", Value::RawData(vec![0; 31])), + with("originalVertexArray", f32s(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])), + with("deformedVertexArray", f32s(&[[f32::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])), + with("indexArray", i32s(&[0, 1, 4])), + with("indexArray", i32s(&[0, 1, -1])), + with("indexArray", Value::RawData(vec![0; 10])), + with("indexArray", i32s(&[])), + with("posFinalPins", list(&[60.0])), + with("PinP", Value::Integer(3)), + item(vec![]), + ] { + let f = filter_from_item(&src); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), src); + } + } + + #[test] + fn a_mesh_built_on_other_corners_is_dropped() { + // The mesh is in document pixels where the layer was when it was built: a layer whose + // corners differ from the stored ones keeps the pins but not the mesh. + let sold = |quad: [f64; 8]| { + let fx = Descriptor::new("filterFXStyle") + .with("enab", Value::Boolean(true)) + .with("filterFXList", Value::List(vec![Value::Descriptor(item(vec![shape(4.0)]))])); + let d = Descriptor::new("null").with("Trnf", list(&quad)).with("nonAffineTransform", list(&quad)).with("filterFX", Value::Descriptor(fx)); + let mut v = b"soLD".to_vec(); + v.extend_from_slice(&4u32.to_be_bytes()); + v.extend(VersionedDescriptor::new(d).to_bytes()); + v + }; + let filter = |quad| parse_sold(&sold(quad)).and_then(|p| p.stack).map(|s| s.filters[0].clone()).expect("a stack"); + let kept = filter(QUAD); + assert_eq!(kept.command, COMMAND); + assert!(kept.params.get("mesh").is_some()); + let mut moved = QUAD; + moved[0] += 5.0; + let f = filter(moved); + assert_eq!(f.command, COMMAND); + assert!(f.params.get("mesh").is_none()); + assert_eq!(f.params["pins"], kept.params["pins"]); + // Still written back as it was. + assert_eq!(item_for_filter(&f).unwrap(), item(vec![shape(4.0)])); + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3b19606c94..53c0c258e3 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -25,7 +25,7 @@ //! (`HStr`) maps to its adjustment command in `hue_sat_filter_map.rs`. use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{LayerMask, SmartFilter}; +use photocraft_doc::{LayerMask, SmartFilter, SmartObject}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use photocraft_geom::{Affine, Rect as GeomRect}; use photocraft_psd::Compression; @@ -49,6 +49,8 @@ mod displace; mod hue_sat; #[path = "perspective_warp_map.rs"] mod perspective_warp; +#[path = "puppet_map.rs"] +mod puppet; pub use displace::{embed_map as displace_embed_map, embedded_map as displace_embedded_map, without_map as displace_without_map}; pub use perspective_warp::{ @@ -133,10 +135,44 @@ pub fn parse_sold(data: &[u8]) -> Option { let d = sold_descriptor(data)?; let (idnt, transform) = crate::blocks::parse_smart(b"SoLd", data); let placed = text_of(&d, "placed").unwrap_or_default(); - let stack = get_desc(&d, "filterFX").map(filter_stack); + let mut stack = get_desc(&d, "filterFX").map(filter_stack); + if let Some(stack) = &mut stack { + drop_moved_puppet_meshes(stack, &d); + } Some(Placed { descriptor: d, idnt, placed, transform, stack }) } +/// A Puppet Warp's stored mesh sits where the layer was when it was built (document pixels); one +/// built on other corners than the layer's own doesn't fit it, so its pins are solved instead. +fn drop_moved_puppet_meshes(stack: &mut FilterStack, d: &Descriptor) { + let corners = |k: &str| match d.get(k) { + Some(Value::List(v)) if v.len() == 8 => v.iter().map(|x| num(Some(x))).collect::>>(), + _ => None, + }; + let quad = corners("nonAffineTransform").or_else(|| corners("Trnf")); + for f in stack.filters.iter_mut().filter(|f| f.command == puppet::COMMAND) { + let fits = match (&quad, puppet::built_on(&f.params)) { + (Some(q), Some(b)) => q.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01), + _ => false, + }; + if !fits && let Some(p) = f.params.as_object_mut() { + p.remove("mesh"); + } + } +} + +/// Records on each imported Puppet Warp mesh the placement it was solved for (`mesh.placement`, +/// [`SmartObject::placement_json`]): once the smart object is moved, transformed or warped, the +/// mesh no longer fits it and the pins are solved instead. +pub(crate) fn pin_puppet_meshes(sm: &mut SmartObject) { + let placement = sm.placement_json(); + for f in sm.smart_filters.iter_mut().filter(|f| f.command == puppet::COMMAND) { + if let Some(m) = f.params.get_mut("mesh").and_then(J::as_object_mut) { + m.insert("placement".into(), placement.clone()); + } + } +} + /// Reads a `filterFX` object. pub fn filter_stack(fx: &Descriptor) -> FilterStack { let filters = match fx.get("filterFXList") { diff --git a/crates/io/src/smart_map/export.rs b/crates/io/src/smart_map/export.rs index e057a7032f..bc96de4cd4 100644 --- a/crates/io/src/smart_map/export.rs +++ b/crates/io/src/smart_map/export.rs @@ -7,7 +7,7 @@ use super::import::{ use super::placed::enumv; use super::{ Class, Descriptor, FilterStack, Id, J, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, camera_raw, displace, hue_sat, - perspective_warp, + perspective_warp, puppet, }; // ---------- writing ---------- @@ -309,6 +309,12 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { if perspective_warp::handles(&f.command) { return perspective_warp::export_item(f); } + if f.command == puppet::COMMAND && f.params.get(puppet::TEMPLATE_KEY).is_some() { + let mut d = puppet::export_item(&f.params)?; + set(&mut d, "blendOptions", blend_options(f)); + set(&mut d, "enab", Value::Boolean(f.visible)); + return Ok(d); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; diff --git a/crates/io/src/smart_map/import.rs b/crates/io/src/smart_map/import.rs index f8f3f50e3f..e381efe058 100644 --- a/crates/io/src/smart_map/import.rs +++ b/crates/io/src/smart_map/import.rs @@ -2,7 +2,7 @@ use super::export::{CELL_SIZE, blend_options, is_meta, pi, rgbc, whole}; use super::placed::enumv; use super::{ BlendMode, Descriptor, Id, J, Map, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, bool_or, camera_raw, displace, enum_of, get_desc, - hue_sat, json, num, perspective_warp, text_of, + hue_sat, json, num, perspective_warp, puppet, text_of, }; // ---------- the filters we model ---------- @@ -867,6 +867,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), Some(hue_sat::CLASS) => fltr.and_then(hue_sat::import_params).map(|p| (hue_sat::COMMAND, p)), Some(perspective_warp::CLASS) => perspective_warp::import_params(item).map(|p| (perspective_warp::COMMAND, p)), + Some(puppet::CLASS) => puppet::import_params(item).map(|p| (puppet::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), Some(c @ ("Fbrs" | "Clds" | "DfrC")) => fltr.and_then(|f| coloured_params(c, item, f)), // A `Fltr` that is there but not a descriptor is settings we can't read: kept verbatim diff --git a/crates/raw/src/hasselblad.rs b/crates/raw/src/hasselblad.rs new file mode 100644 index 0000000000..1b9c7ec08c --- /dev/null +++ b/crates/raw/src/hasselblad.rs @@ -0,0 +1,130 @@ +//! Hasselblad 3FR / FFF: a TIFF whose IFD0 holds the camera's JPEG preview and +//! whose CFA image sits in a SubIFD with DNG-style tags (BlackLevel, WhiteLevel, +//! DefaultCropOrigin / DefaultCropSize, AsShotNeutral, ColorMatrix1) but no +//! DNGVersion and no CFA-pattern tag — neither the TIFF/EP 33421/33422 pair nor +//! the EXIF one — so the Bayer phase comes from measurements of each body. +//! +//! Decoded today: the X2D II 100C's 16-bit uncompressed samples (Compression 1, +//! one strip covering the whole sensor, masked borders included; the crop to the +//! usable area comes from the file's DefaultCrop tags). Bodies that store +//! lossless-JPEG strips (Compression 7, the earlier X and H generations) are +//! recognised but not decoded yet. +//! +//! Clean-room: derived from the TIFF 6.0 and DNG specifications, observation of +//! sample files, and the camera's own embedded JPEG — the oracle the +//! camera-profile measurements in [`crate::cameras`] use. No code or tables +//! from dcraw, LibRaw, rawspeed, rawler or darktable. + +use crate::cr2::clip_level; +use crate::error::{RawError, Result}; +use crate::sensor::{BlackLevels, Cfa, JpegLayout, Rect, Sensor, read_plane}; +use crate::tiff::{Ifd, Tiff, tag}; +use crate::tiffep; +use crate::{Limits, RawFormat}; + +/// The Bayer colours at the data origin, row-major (0 red, 1 green, 2 blue). +/// Hasselblad records no CFA tag, so a body's phase is measured against its own +/// embedded JPEG: on an X2D II 100C dusk landscape the blue sky reads red at the +/// origin site at about half the green rate while the blue site holds three +/// quarters of it, the reverse of the warm ground. +fn cfa_for(model: Option<&str>) -> (Cfa, bool) { + let measured = model.is_some_and(|m| m.eq_ignore_ascii_case("X2D II 100C")); + (Cfa { width: 2, height: 2, colors: vec![0, 1, 1, 2], origin_x: 0, origin_y: 0 }, measured) +} + +pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { + let ifds = t.all_ifds(); + let Some(raw) = tiffep::raw_ifd(t, &ifds) else { + return Err(RawError::unsupported("Hasselblad 3FR/FFF: no CFA image found")); + }; + match t.tag_uint(&raw, tag::COMPRESSION).unwrap_or(1) { + 1 => {} + 7 => return Err(RawError::unsupported("lossless-JPEG (compressed 3FR/FFF)")), + c => return Err(RawError::unsupported(format!("raw compression scheme {c}"))), + } + let plane = read_plane(t, &raw, limits, JpegLayout::Flat)?; + if plane.samples != 1 { + return Err(RawError::unsupported(format!("CFA data with {} samples per pixel", plane.samples))); + } + let make = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MAKE)); + let model = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MODEL)); + let (cfa, measured) = cfa_for(model.as_deref()); + let mut warnings = Vec::new(); + if !measured { + warnings.push(format!( + "{}: this body's Bayer phase is not measured yet; assumed the X2D II 100C's (red at the data origin)", + model.as_deref().unwrap_or("Hasselblad") + )); + } + let full = Rect::new(0, 0, plane.width, plane.height); + // DNG-style ActiveArea (top, left, bottom, right) in the raw IFD; an absent, + // inverted or out-of-plane one leaves the whole plane active. + let active = match t.tag_uints(&raw, tag::ACTIVE_AREA).as_slice() { + [top, left, bottom, right] if bottom > top && right > left => { + Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize).intersect(&full) + } + _ => full, + }; + let active = if active.is_empty() { full } else { active }; + // DNG-style DefaultCropOrigin / DefaultCropSize in the raw IFD, relative to + // the active area as in DNG. + let mut crop = active; + if let ([ox, oy], [cw, ch]) = (t.tag_floats(&raw, tag::DEFAULT_CROP_ORIGIN).as_slice(), t.tag_floats(&raw, tag::DEFAULT_CROP_SIZE).as_slice()) { + let f = |v: f64| if v.is_finite() && v >= 0.0 { v.round() as usize } else { 0 }; + let r = Rect::new(active.x.saturating_add(f(*ox)), active.y.saturating_add(f(*oy)), f(*cw), f(*ch)).intersect(&active); + if !r.is_empty() { + crop = r; + } + } + let bl = t.tag_floats(&raw, tag::BLACK_LEVEL); + let black = match bl.as_slice() { + [b] if b.is_finite() && *b >= 0.0 => BlackLevels::uniform(*b as f32), + // A 2×2 repeat is in the CFA's own row-major order. + [r, g1, g2, b] if [*r, *g1, *g2, *b].iter().all(|v| v.is_finite() && *v >= 0.0) => { + BlackLevels { rows: 2, cols: 2, values: [r, g1, g2, b].map(|v| *v as f32).to_vec(), delta_h: Vec::new(), delta_v: Vec::new() } + } + _ => { + warnings.push("Hasselblad 3FR/FFF: black level not recorded; assumed 0".into()); + BlackLevels::uniform(0.0) + } + }; + let white = + t.tag_floats(&raw, tag::WHITE_LEVEL).first().copied().filter(|w| *w > 0.0).map(|w| w as f32).unwrap_or_else(|| clip_level(&plane.data, plane.bits)); + // AsShotNeutral (IFD0) is the camera-space colour of a neutral patch: the + // green-normalised multipliers are the other channels over green. + let camera_wb = ifds + .iter() + .find_map(|i: &Ifd| t.tag_floats(i, tag::AS_SHOT_NEUTRAL).try_into().ok()) + .and_then(|n: [f64; 3]| match n { + [r, g, b] if g > 0.0 && r > 0.0 && b > 0.0 => Some([g / r, 1.0, g / b]), + _ => None, + }) + .filter(|m| m.iter().all(|v| (0.25..8.0).contains(v))); + let ifd0 = t.ifd_at(t.first_ifd, 0); + let baseline_exposure = ifd0.as_ref().map(|i| t.tag_floats(i, tag::BASELINE_EXPOSURE).first().copied().unwrap_or(0.0)).unwrap_or(0.0); + let mut sensor = Sensor { + format: RawFormat::Fff, + make, + model, + width: plane.width, + height: plane.height, + samples: 1, + data: plane.data, + cfa: Some(cfa), + linearization: None, + black, + white: [white; 3], + active, + crop, + color: Default::default(), + camera_wb, + orientation: crate::tiff::orientation(ifd0.as_ref().and_then(|i| t.tag_uint(i, tag::ORIENTATION))), + baseline_exposure: if baseline_exposure.is_finite() { baseline_exposure.clamp(-10.0, 10.0) } else { 0.0 }, + gain_maps: Vec::new(), + tone_curve: Vec::new(), + black_point: 0.0, + warnings, + }; + crate::cameras::apply(&mut sensor, plane.bits); + Ok(sensor) +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 46bd3e4f60..b5f241d5e2 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -33,8 +33,9 @@ //! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed), //! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW), //! Panasonic RW2 (RawFormat 5 packed and RawFormat 3/4 vendor-compressed), -//! uncompressed Olympus ORF and uncompressed Fujifilm RAF (Bayer and -//! X-Trans). Everything else reports [`RawError::Unsupported`]. +//! uncompressed Olympus ORF, uncompressed Fujifilm RAF (Bayer and +//! X-Trans), and Hasselblad 3FR/FFF (the X2D II 100C's uncompressed 16-bit CFA; +//! see [`crate::hasselblad`]). Everything else reports [`RawError::Unsupported`]. //! //! The crate is standalone (no workspace dependencies), does no I/O, builds for //! `wasm32-unknown-unknown` and never panics on hostile input: sizes are @@ -51,6 +52,7 @@ mod demosaic; mod develop; mod dng; mod error; +mod hasselblad; mod jxl; mod ljpeg; mod look; @@ -119,6 +121,8 @@ pub enum RawFormat { Rw2, /// Fujifilm RAF. Raf, + /// Hasselblad 3FR / FFF. + Fff, /// Another TIFF/EP raw. TiffEp, } @@ -135,13 +139,14 @@ impl RawFormat { RawFormat::Orf => "ORF", RawFormat::Rw2 => "RW2", RawFormat::Raf => "RAF", + RawFormat::Fff => "3FR/FFF", RawFormat::TiffEp => "TIFF/EP raw", } } } /// File extensions of the raw formats this crate recognises. -pub const EXTENSIONS: &[&str] = &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf"]; +pub const EXTENSIONS: &[&str] = &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf", "3fr", "fff"]; /// Decompression-bomb guards, checked on header values before allocating /// (the same shape and defaults as `photocraft-codecs`' limits). @@ -195,6 +200,8 @@ fn make_format(make: Option<&str>) -> RawFormat { RawFormat::Pef } else if m.starts_with("OLYMPUS") || m.starts_with("OM DIGITAL") { RawFormat::Orf + } else if m.starts_with("HASSELBLAD") { + RawFormat::Fff } else { RawFormat::TiffEp } @@ -249,6 +256,7 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { RawFormat::Cr2 => cr2::decode(&t, limits), RawFormat::Rw2 => rw2::decode(&t, limits), RawFormat::Orf => orf::decode(&t, limits), + RawFormat::Fff => hasselblad::decode(&t, limits), f => tiffep::decode(&t, f, limits), } } diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 7d06459365..20ebba446f 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -9,6 +9,7 @@ mod canon; mod dng; +mod hasselblad; mod lj92; mod orf; mod raf; @@ -17,6 +18,7 @@ mod tiff; pub use canon::{Cr2Spec, Cr2SrawSpec, craw_block, craw_encode, nef_compressed, sony_craw, tiff_ep}; pub use dng::{DngSpec, DngStorage, baseline_jpeg, gain_map_opcode_list, map_polynomial_opcode_list}; +pub use hasselblad::Hasselblad3frSpec; pub use lj92::{lj92_encode, lj92_encode_sraw, lj92_encode_sraw_tables, lj92_encode_tables}; pub use orf::orf; pub use raf::{RafPacking, RafSpec, XTRANS, mosaic, mosaic_cfa, scene}; diff --git a/crates/raw/src/testgen/hasselblad.rs b/crates/raw/src/testgen/hasselblad.rs new file mode 100644 index 0000000000..f234678160 --- /dev/null +++ b/crates/raw/src/testgen/hasselblad.rs @@ -0,0 +1,106 @@ +use super::tiff::{TiffBuilder, Val, urat}; + +// ---------------------------------------------------------------- Hasselblad 3FR + +/// A synthetic Hasselblad 3FR, as the X2D II 100C writes it: IFD0 with Make, +/// Model and DNG-style AsShotNeutral, and the CFA image in a SubIFD — +/// `bits`-bit uncompressed samples with BlackLevel, WhiteLevel and DefaultCrop +/// tags and no CFA-pattern tags. `compression` and the optional tags exist for +/// the unsupported and hostile variants. +#[derive(Debug, Clone)] +pub struct Hasselblad3frSpec { + pub width: usize, + pub height: usize, + /// Row-major sensor samples. + pub data: Vec, + pub bits: u16, + pub compression: u16, + /// The BlackLevel RATIONAL; `None` leaves the tag out. + pub black: Option<(u32, u32)>, + /// The WhiteLevel; `None` leaves the tag out. + pub white: Option, + /// AsShotNeutral; `None` leaves the tag out. + pub as_shot_neutral: Option<[f64; 3]>, + /// DefaultCropOrigin (x, y) and DefaultCropSize (w, h); `None` leaves both out. + pub crop: Option<([u16; 2], [u16; 2])>, + /// ActiveArea (top, left, bottom, right); `None` leaves the tag out. + pub active_area: Option<[u32; 4]>, + pub make: String, + pub model: String, +} + +impl Hasselblad3frSpec { + /// An X2D II 100C-shaped file: 16-bit uncompressed, black 4096, white + /// 64763, the sample file's as-shot neutral, no crop. + pub fn new(width: usize, height: usize, data: Vec) -> Self { + Hasselblad3frSpec { + width, + height, + data, + bits: 16, + compression: 1, + black: Some((4096, 1)), + white: Some(64763), + as_shot_neutral: Some([0.375, 1.0, 0.617]), + crop: None, + active_area: None, + make: "Hasselblad".into(), + model: "X2D II 100C".into(), + } + } + + pub fn build(&self) -> Vec { + let mut t = TiffBuilder::default(); + let len = self.data.len() * 2; + let strip = t.blob(self.data.iter().flat_map(|&v| v.to_le_bytes()).collect()); + let mut raw = vec![ + (254, Val::Long(vec![0])), + (256, Val::Long(vec![self.width as u32])), + (257, Val::Long(vec![self.height as u32])), + (258, Val::Short(vec![self.bits])), + (259, Val::Short(vec![self.compression])), + (262, Val::Short(vec![32803])), + (273, Val::Blobs(vec![strip])), + (277, Val::Short(vec![1])), + (278, Val::Long(vec![self.height as u32])), + (279, Val::Long(vec![len as u32])), + ]; + if let Some((n, d)) = self.black { + raw.push((50714, Val::Rational(vec![(n, d)]))); + } + if let Some(w) = self.white { + raw.push((50717, Val::Short(vec![w]))); + } + if let Some((origin, size)) = &self.crop { + raw.push((50719, Val::Short(origin.to_vec()))); + raw.push((50720, Val::Short(size.to_vec()))); + } + if let Some(area) = self.active_area { + raw.push((50829, Val::Long(area.to_vec()))); + } + let raw_ifd = t.ifd(raw); + let thumb = t.blob(vec![128; 12]); + let mut ifd0 = vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![2])), + (257, Val::Long(vec![2])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![7])), + (262, Val::Short(vec![6])), + (271, Val::Ascii(self.make.clone())), + (272, Val::Ascii(self.model.clone())), + (273, Val::Blobs(vec![thumb])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![2])), + (279, Val::Long(vec![12])), + (330, Val::Ifds(vec![raw_ifd])), + (50708, Val::Ascii(format!("{} {}", self.make, self.model))), + ]; + if let Some(n) = self.as_shot_neutral { + ifd0.push((50728, Val::Rational(n.iter().map(|&v| urat(v)).collect()))); + } + let i0 = t.ifd(ifd0); + t.chain = vec![i0]; + t.build() + } +} diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index fe0ea80a94..3a12f8eff9 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -28,7 +28,7 @@ const NIKON_BLACK_LEVEL: u16 = 0x003D; const SONY_BLACK_LEVEL: u16 = 0x7310; const SONY_WB_RGGB_LEVELS: u16 = 0x7313; -fn raw_ifd(t: &Tiff, ifds: &[Ifd]) -> Option { +pub(crate) fn raw_ifd(t: &Tiff, ifds: &[Ifd]) -> Option { ifds.iter() .filter(|i| t.tag_uint(i, tag::PHOTOMETRIC) == Some(PHOTOMETRIC_CFA)) .max_by_key(|i| u64::from(t.tag_uint(i, tag::IMAGE_WIDTH).unwrap_or(0)) * u64::from(t.tag_uint(i, tag::IMAGE_LENGTH).unwrap_or(0))) diff --git a/crates/raw/tests/it/hasselblad.rs b/crates/raw/tests/it/hasselblad.rs new file mode 100644 index 0000000000..8bdf5c2957 --- /dev/null +++ b/crates/raw/tests/it/hasselblad.rs @@ -0,0 +1,143 @@ +//! Synthetic Hasselblad 3FR files (the X2D II 100C layout): identification, +//! exact samples, metadata, the DefaultCrop area, and the unsupported and +//! hostile variants. + +use photocraft_raw::testgen::{Hasselblad3frSpec, mosaic, scene}; +use photocraft_raw::*; + +const RGGB: [u8; 4] = [0, 1, 1, 2]; + +fn spec() -> Hasselblad3frSpec { + let (w, h) = (16, 12); + Hasselblad3frSpec::new(w, h, mosaic(&scene(w, h), w, RGGB, 4096, 64763)) +} + +#[test] +fn identifies_as_fff() { + let b = spec().build(); + assert_eq!(identify(&b), Some(RawFormat::Fff)); + assert!(is_raw(&b)); + assert!(EXTENSIONS.contains(&"3fr") && EXTENSIONS.contains(&"fff")); +} + +#[test] +fn decodes_exactly() { + let s = spec(); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!((sensor.width, sensor.height), (16, 12)); + assert_eq!(sensor.samples, 1); + assert_eq!(sensor.data, s.data, "the samples are the stored strip"); + let cfa = sensor.cfa.as_ref().unwrap(); + assert_eq!((cfa.width, cfa.height), (2, 2)); + for (i, &c) in RGGB.iter().enumerate() { + assert_eq!(cfa.color(i % 2, i / 2), c, "position {i}"); + } + assert!(cfa.is_bayer()); + assert_eq!(sensor.format, RawFormat::Fff); +} + +#[test] +fn metadata() { + let mut s = spec(); + s.crop = Some(([2, 2], [12, 8])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(2, 2, 12, 8)); + assert_eq!(sensor.active, Rect::new(0, 0, 16, 12)); + assert_eq!(sensor.black, BlackLevels::uniform(4096.0)); + assert_eq!(sensor.white, [64763.0; 3]); + let wb = sensor.camera_wb.unwrap(); + assert!((wb[0] - 1.0 / 0.375).abs() < 1e-9, "{wb:?}"); + assert!((wb[2] - 1.0 / 0.617).abs() < 1e-9, "{wb:?}"); + assert_eq!((sensor.make.as_deref(), sensor.model.as_deref()), (Some("Hasselblad"), Some("X2D II 100C"))); + assert_eq!(sensor.orientation, 1); + assert!(sensor.warnings.is_empty(), "{:?}", sensor.warnings); +} + +#[test] +fn develops() { + let mut s = spec(); + s.crop = Some(([2, 2], [12, 8])); + let b = s.build(); + for m in [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd] { + let d = develop(&b, &DevelopOptions { demosaic: m, ..Default::default() }).unwrap_or_else(|e| panic!("{m:?}: {e}")); + assert_eq!((d.width, d.height), (12, 8), "{m:?}"); + } +} + +#[test] +fn unknown_body_warns_about_the_assumed_phase() { + let mut s = spec(); + s.model = "X2D 100C".into(); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert!(sensor.warnings.iter().any(|w| w.contains("Bayer phase")), "{:?}", sensor.warnings); +} + +#[test] +fn compressed_3fr_is_reported_unsupported() { + let mut s = spec(); + s.compression = 7; + let e = decode(&s.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&e, RawError::Unsupported(reason) if reason.contains("lossless-JPEG")), "{e:?}"); +} + +#[test] +fn missing_levels_fall_back() { + let mut s = spec(); + // Clipped highlights, so the clipping point stands in for the missing WhiteLevel. + for v in s.data.iter_mut().step_by(8) { + *v = u16::MAX; + } + s.black = None; + s.white = None; + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.black, BlackLevels::uniform(0.0)); + assert!(sensor.warnings.iter().any(|w| w.contains("black level")), "{:?}", sensor.warnings); + // clip_level backs the clipped plateau off slightly. + assert!((sensor.white[0] - f32::from(u16::MAX) * 0.995).abs() < 1.0, "{:?}", sensor.white); +} + +#[test] +fn a_crop_outside_the_plane_is_dropped() { + let mut s = spec(); + s.crop = Some(([100, 100], [4, 4])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(0, 0, 16, 12)); +} + +#[test] +fn active_area_bounds_the_image_and_the_crop() { + let mut s = spec(); + s.active_area = Some([1, 2, 11, 14]); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.active, Rect::new(2, 1, 12, 10)); + assert_eq!(sensor.crop, Rect::new(2, 1, 12, 10), "no default crop: the active area"); + let d = develop(&s.build(), &DevelopOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (12, 10)); + // The default crop is relative to the active area, as in DNG. + s.crop = Some(([1, 1], [8, 6])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(3, 2, 8, 6)); +} + +#[test] +fn an_active_area_outside_the_plane_is_ignored() { + let mut s = spec(); + s.active_area = Some([100, 100, 200, 200]); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!((sensor.active, sensor.crop), (Rect::new(0, 0, 16, 12), Rect::new(0, 0, 16, 12))); +} + +#[test] +fn truncated_strip_is_malformed() { + let mut b = spec().build(); + // The preview blob sits past the strip; cutting 60 bytes eats into the strip. + b.truncate(b.len() - 60); + assert!(matches!(decode(&b, &Limits::default()), Err(RawError::Malformed(_)))); +} + +#[test] +fn zero_dimensions_are_malformed() { + let mut s = spec(); + s.width = 0; + assert!(matches!(decode(&s.build(), &Limits::default()), Err(RawError::Malformed(_)))); +} diff --git a/crates/raw/tests/it/main.rs b/crates/raw/tests/it/main.rs index 27a1af788b..666514c594 100644 --- a/crates/raw/tests/it/main.rs +++ b/crates/raw/tests/it/main.rs @@ -3,6 +3,7 @@ mod cr3; mod development_limits; mod dng; +mod hasselblad; mod hostile; mod nef_packed; mod nikon_d4_depth; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 24a6300d04..e110aa82fa 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -133,6 +133,7 @@ impl PhotocraftApp { synthetic_reinject: false, synthetic_snapshot: None, automation_input: false, + programmatic_playback: false, run_depth: 0, paused_playbacks: Default::default(), channel_thumbs: None, @@ -169,7 +170,7 @@ impl PhotocraftApp { distort: Default::default(), gradient: Default::default(), camera_raw: None, - pending_raw_open: None, + raw_shelf: Vec::new(), raw_redevelop: Vec::new(), wide_angle: None, dialog_edits: Default::default(), @@ -208,6 +209,8 @@ impl PhotocraftApp { held_memory_prompts: Default::default(), prompt_order: Default::default(), held_preset_import: None, + held_record_again: None, + held_insert_menu: None, allow_close: false, stylus: Default::default(), control_click: Default::default(), @@ -234,7 +237,7 @@ impl PhotocraftApp { impl eframe::App for PhotocraftApp { fn on_exit(&mut self) { if let Err(err) = tool_memory::save_on_exit(self) { - log::warn!("Preferences: could not save the final tool selection: {err}"); + log::warn!("Preferences: could not save the final tool state: {err}"); } // Release a pending screenshot's image budget and answer its control request; the // callback is inert afterwards (it holds only a weak slot). diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index ab3654e4ce..6ad7c9fdf4 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -6,6 +6,7 @@ use super::{ impl PhotocraftApp { /// Run an engine command, reporting errors in the status bar. pub fn run(&mut self, id: &str, params: Value) -> Result { + let params = if id == "select.findLayers" { crate::layer_filter_ui::find::params_for_run(self, params) } else { params }; if let Some(result) = delete_layer_prompt::intercept(self, id, ¶ms) { return result; } @@ -31,6 +32,13 @@ impl PhotocraftApp { } self.sync_mask_targets(); } + // Find Layers sets the Layers panel's Name filter however it runs: the menu, the shortcut, + // `engine.execute` or an action step (#10884). + if id == "select.findLayers" + && let Ok(reply) = &result + { + crate::layer_filter_ui::find::apply(self, reply); + } result } @@ -264,6 +272,7 @@ impl PhotocraftApp { | "path.toSelection" | "path.addToSelection" | "path.subtractFromSelection" + | "path.intersectWithSelection" ) && self.session.active().is_some_and(|st| st.doc.selection.is_some()) { diff --git a/crates/ui-egui/src/artboard_add_mode_tests.rs b/crates/ui-egui/src/artboard_add_mode_tests.rs index 3b98287d41..04344298c0 100644 --- a/crates/ui-egui/src/artboard_add_mode_tests.rs +++ b/crates/ui-egui/src/artboard_add_mode_tests.rs @@ -85,14 +85,15 @@ fn a_click_with_the_mode_off_still_places_nothing() { } #[test] -fn with_no_board_the_click_uses_the_canvas_size() { +fn with_no_board_the_click_uses_the_bars_tool_option_size() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap(); app.sync_views(); app.ui.tool = Tool::Artboard; app.ui.artboard_tool.adding = true; click(&mut app, 40.0, 50.0); - assert_eq!(boards(&app), vec![Rect::new(40, 50, 840, 650)]); + // The size the bar shows (the iPhone 8/7/6 default), not the canvas's 800 x 600. + assert_eq!(boards(&app), vec![Rect::new(40, 50, 790, 1384)]); } #[test] diff --git a/crates/ui-egui/src/artboard_options_bar_tests.rs b/crates/ui-egui/src/artboard_options_bar_tests.rs new file mode 100644 index 0000000000..dd7375e4e3 --- /dev/null +++ b/crates/ui-egui/src/artboard_options_bar_tests.rs @@ -0,0 +1,538 @@ +//! The Artboard tool's options bar (#11385): the iPhone 8/7/6 default size, the background for the +//! boards it draws, the Align panel and the gear menu, and what Reset Tool puts back. + +use egui::Modifiers; +use photocraft_doc::{ArtboardBackground, LayerId}; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::artboard_tool::ArtboardToolUi; +use crate::artboard_tool::bar::{self, ALIGN_PANEL, GEAR, Gear}; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app_with_depth(depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 800, "height": 600, "depth": depth})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app.ui.tool = Tool::Artboard; + app +} + +fn app() -> PhotocraftApp { + app_with_depth(8) +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); +} + +fn boards(app: &PhotocraftApp) -> Vec<(LayerId, Rect, ArtboardBackground)> { + app.session.active().unwrap().doc.artboards().iter().map(|b| (b.0, b.2.rect, b.2.background)).collect() +} + +fn reset_tool(app: &mut PhotocraftApp) { + crate::menus::invoke(app, &egui::Context::default(), crate::tool_reset::RESET, json!({})).unwrap(); +} + +#[test] +fn the_presets_list_has_iphone_8_7_6() { + assert!(photocraft_engine::artboard_cmds::PRESETS.contains(&("iPhone 8/7/6", 750, 1334))); + assert!(photocraft_engine::artboard_cmds::PRESETS.contains(&("iPhone 14", 390, 844)), "the existing entries stay"); + // The engine takes the new preset by name. + let mut app = app(); + let r = app.run("layer.new.artboard", json!({"preset": "iPhone 8/7/6", "x": 0, "y": 0})).unwrap(); + assert_eq!(r["rect"], json!([0, 0, 750, 1334])); +} + +#[test] +fn a_fresh_bar_has_the_measured_defaults() { + let app = app(); + let o = &app.ui.artboard_tool; + assert_eq!((o.preset.as_str(), o.width, o.height), ("iPhone 8/7/6", 750, 1334)); + assert_eq!((o.background.as_str(), o.background_color.as_str()), ("white", "#ffffff")); + assert!(GEAR.iter().all(|(g, _)| g.checked(o)), "all four gear entries are checked"); + assert_eq!(bar::DEFAULT_SIZE, ("iPhone 8/7/6", 750, 1334)); +} + +#[test] +fn reset_tool_restores_the_options_and_only_for_the_artboard_tool() { + let mut app = app(); + bar::set_size(&mut app, 200.0, 300.0); + bar::set_custom_background(&mut app, "#336699").unwrap(); + app.ui.artboard_tool.auto_nest = false; + app.ui.artboard_tool.shrinkwrap_on_save = false; + // Another tool's Reset Tool leaves them. + app.ui.tool = Tool::Move; + reset_tool(&mut app); + assert_eq!(app.ui.artboard_tool.width, 200); + // The Artboard tool's puts every one back. + app.ui.tool = Tool::Artboard; + reset_tool(&mut app); + assert_eq!(app.ui.artboard_tool, ArtboardToolUi::default()); + assert_eq!(app.ui.artboard_tool.height, 1334); + // Reset All Tools too. + bar::set_preset(&mut app, "A4"); + app.ui.artboard_tool.auto_size_canvas = false; + crate::menus::invoke(&mut app, &egui::Context::default(), crate::tool_reset::RESET_ALL, json!({"confirmed": true})).unwrap(); + assert_eq!(app.ui.artboard_tool, ArtboardToolUi::default()); +} + +#[test] +fn the_options_are_serde_tool_options() { + let mut app = app(); + bar::set_preset(&mut app, "Letter"); + app.ui.artboard_tool.keep_relative_position = false; + let v = serde_json::to_value(&app.ui.artboard_tool).unwrap(); + assert_eq!(v["preset"], "Letter"); + assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(612), Some(792))); + assert_eq!(v["keep_relative_position"], false); + assert_eq!(v["auto_nest"], true); + assert!(v.get("drag").is_none(), "the drag in progress is not saved"); + let back: ArtboardToolUi = serde_json::from_value(v).unwrap(); + assert_eq!(back, app.ui.artboard_tool); + // Settings saved before these options existed load with the defaults. + let old: ArtboardToolUi = serde_json::from_value(json!({})).unwrap(); + assert_eq!(old, ArtboardToolUi::default()); + // The whole UI state carries them, so `ui.inspect` reaches them. + let ui = serde_json::to_value(&app.ui).unwrap(); + assert_eq!(ui["artboard_tool"]["preset"], "Letter"); +} + +#[test] +fn width_and_height_edit_the_tool_option_when_no_board_is_selected() { + let mut app = app(); + assert!(boards(&app).is_empty()); + assert_eq!(bar::bar_size(&app), [750, 1334]); + bar::set_size(&mut app, 400.0, 500.0); + assert_eq!((app.ui.artboard_tool.width, app.ui.artboard_tool.height), (400, 500)); + assert_eq!(app.ui.artboard_tool.preset, "", "a size that is no preset's reads Custom"); + assert_eq!(bar::bar_size(&app), [400, 500]); + // Back to a preset's size: the preset reads again only when chosen; choosing it sets the size. + bar::set_preset(&mut app, "iPhone 14"); + assert_eq!((app.ui.artboard_tool.width, app.ui.artboard_tool.height), (390, 844)); + assert_eq!(app.ui.artboard_tool.preset, "iPhone 14"); + // Out of range and not a number are held to 1..=300000. + bar::set_size(&mut app, 0.0, 1.0e9); + assert_eq!((app.ui.artboard_tool.width, app.ui.artboard_tool.height), (1, 300_000)); + bar::set_size(&mut app, f32::NAN, 5.0); + assert_eq!(app.ui.artboard_tool.width, 1); + // No document change. + assert_eq!(app.session.active().unwrap().doc.size.width, 800); + assert!(boards(&app).is_empty()); + assert!(!app.session.active().unwrap().history.entries().iter().any(|e| e.contains("Artboard"))); +} + +#[test] +fn with_a_board_selected_the_bar_size_is_the_boards_and_the_options_stay() { + let mut app = app(); + bar::set_size(&mut app, 400.0, 500.0); + drag(&mut app, [100.0, 100.0], [300.0, 300.0]); + assert_eq!(boards(&app).first().map(|b| b.1), Some(Rect::new(100, 100, 300, 300))); + // A 200 x 200 board reads 200 x 200 (Custom), not the options. + assert_eq!(bar::bar_size(&app), [200, 200]); + let id = boards(&app)[0].0; + let preset = app.session.active().unwrap().doc.layer(id).unwrap().artboard().unwrap().preset.clone(); + assert_eq!(preset, "", "Custom"); + assert_eq!((app.ui.artboard_tool.width, app.ui.artboard_tool.height), (400, 500), "the tool option is untouched"); + // Choosing iPhone 8/7/6 resizes the board in one step and keeps its top-left corner. + let steps = app.session.active().unwrap().history.entries().len(); + app.run("layer.artboard.set", json!({"layer": id.0, "preset": "iPhone 8/7/6"})).unwrap(); + assert_eq!(boards(&app)[0].1, Rect::new(100, 100, 850, 1434)); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1); + assert_eq!(bar::bar_size(&app), [750, 1334]); +} + +#[test] +fn bar_size_helper_prefers_the_selected_board() { + let mut app = app(); + assert_eq!(bar::bar_size(&app), [750, 1334]); + app.run("layer.new.artboard", json!({"rect": [0, 0, 120, 90]})).unwrap(); + assert_eq!(bar::bar_size(&app), [120, 90]); +} + +/// The background reaches `layer.new.artboard` for a board the tool draws, at 8 and 16 bit. +#[test] +fn a_drawn_board_gets_the_bars_background() { + for depth in [8, 16] { + for (name, want) in [("white", ArtboardBackground::White), ("black", ArtboardBackground::Black), ("transparent", ArtboardBackground::Transparent)] { + let mut app = app_with_depth(depth); + app.ui.artboard_tool.background = name.to_string(); + drag(&mut app, [50.0, 50.0], [150.0, 120.0]); + assert!(!app.ui.status_error, "{depth} bit {name}: {}", app.ui.status); + let b = boards(&app); + assert_eq!(b.len(), 1, "{depth} bit {name}"); + assert_eq!((b[0].1, b[0].2), (Rect::new(50, 50, 150, 120), want), "{depth} bit {name}"); + } + // Other…: the picked colour. + let mut app = app_with_depth(depth); + bar::set_custom_background(&mut app, "#ff0000").unwrap(); + drag(&mut app, [50.0, 50.0], [150.0, 120.0]); + let ArtboardBackground::Custom(c) = boards(&app)[0].2 else { panic!("{depth} bit: a custom background") }; + assert_eq!(c.to_rgba8(), [255, 0, 0, 255], "{depth} bit"); + } +} + +/// A board placed by the Add New Artboard click takes the bar's background too. +#[test] +fn a_placed_board_gets_the_bars_background() { + let place = |app: &mut PhotocraftApp| { + app.ui.artboard_tool.adding = true; + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: 20.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + }; + let mut app = self::app(); + place(&mut app); + assert_eq!(boards(&app).len(), 1); + assert_eq!(boards(&app)[0].2, ArtboardBackground::White, "the default setting keeps white"); + let mut app = self::app(); + app.ui.artboard_tool.background = "transparent".to_string(); + place(&mut app); + assert_eq!(boards(&app)[0].2, ArtboardBackground::Transparent); + let mut app = self::app(); + bar::set_custom_background(&mut app, "#00ff00").unwrap(); + place(&mut app); + let ArtboardBackground::Custom(c) = boards(&app)[0].2 else { panic!("a custom background") }; + assert_eq!(c.to_rgba8(), [0, 255, 0, 255]); +} + +/// With no board selected, a placed board takes the size the bar shows (the tool option), not the canvas's. +#[test] +fn a_placed_board_takes_the_bars_size_with_no_board_selected() { + let mut app = app(); + assert!(boards(&app).is_empty()); + bar::set_size(&mut app, 333.0, 222.0); + app.ui.artboard_tool.adding = true; + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(boards(&app).first().map(|b| b.1), Some(Rect::new(20, 30, 353, 252))); +} + +/// A placed board keeps the bar's preset name, so selected it shows that preset, not Custom. +#[test] +fn a_placed_board_keeps_the_bars_preset_name() { + let mut app = app(); + let place = |app: &mut PhotocraftApp, x: f64| { + app.ui.artboard_tool.adding = true; + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x, y: 30.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x, y: 30.0 }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (id, rect, _) = *boards(app).last().unwrap(); + app.run("layer.select", json!({"layer": id.0})).unwrap(); + (id, rect) + }; + let (id, rect) = place(&mut app, 20.0); + assert_eq!((rect.width(), rect.height()), (750, 1334)); + assert_eq!(bar::board_preset(&app, id), "iPhone 8/7/6"); + let stored = app.session.active().unwrap().doc.layer(id).unwrap().artboard().unwrap().preset.clone(); + assert_eq!(stored, "iPhone 8/7/6"); + // Deselect, edit the size by hand: the preset is Custom and so is the next placed board. + app.session.active_mut().unwrap().active_layer = None; + bar::set_size(&mut app, 333.0, 222.0); + let (id2, rect2) = place(&mut app, 2000.0); + assert_eq!((rect2.width(), rect2.height()), (333, 222)); + assert_eq!(bar::board_preset(&app, id2), ""); +} + +/// OK in the picker with the colour the swatch shows leaves the board's own colour alone. +#[test] +fn ok_in_the_picker_without_a_change_leaves_the_boards_alone() { + let mut app = app(); + let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.artboard.set", json!({"layer": a, "background": "custom", "color": [0.2, 0.4, 0.6, 0.5]})).unwrap(); + app.run("layer.select", json!({"layer": a})).unwrap(); + let before = (steps(&app), boards(&app)[0].2); + let hex = bar::shown_background(&app).1; + let id = crate::color_picker_ui::open_for_target(&mut app, "artboardBackground", "Color Picker (Color)", crate::color_picker_ui::parse_hex(&hex).unwrap()); + let fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + crate::color_picker_ui::confirm(&mut app, &fields).unwrap(); + assert_eq!((steps(&app), boards(&app)[0].2), before, "no history step, colour bit-exact"); + // A real change still recolours. + bar::set_custom_background(&mut app, "#00ff00").unwrap(); + assert_eq!(steps(&app), before.0 + 1); + assert_ne!(boards(&app)[0].2, before.1); +} + +/// Two boards, both selected. +fn two_boards(app: &mut PhotocraftApp) -> (u64, u64) { + let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap(); + let b = app.run("layer.new.artboard", json!({"rect": [300, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": a})).unwrap(); + app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); + (a, b) +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn last_label(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.entries().last().cloned() +} + +/// Two selected boards with different custom colours; the first board's colour is the swatch's. +fn two_coloured_boards(app: &mut PhotocraftApp) -> (u64, u64) { + let (a, b) = two_boards(app); + app.run("layer.artboard.set", json!({"layer": a, "background": "custom", "color": "#336699"})).unwrap(); + app.run("layer.artboard.set", json!({"layer": b, "background": "custom", "color": "#cc3300"})).unwrap(); + (a, b) +} + +/// Open the Color Picker from the swatch (as the bar does) and OK it with `color`, or as opened. +fn pick_from_swatch(app: &mut PhotocraftApp, color: Option<&str>) { + let hex = bar::shown_background(app).1; + let id = crate::color_picker_ui::open_for_target(app, "artboardBackground", "Color Picker (Color)", crate::color_picker_ui::parse_hex(&hex).unwrap()); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + if let Some(c) = color { + fields.insert("color".into(), json!(c)); + } + crate::color_picker_ui::confirm(app, &fields).unwrap(); +} + +fn colours(app: &PhotocraftApp) -> Vec { + boards(app).iter().map(|b| b.2).collect() +} + +#[test] +fn an_unchanged_ok_with_boards_of_different_colours_leaves_every_board_alone() { + let mut app = app(); + two_coloured_boards(&mut app); + let before = (steps(&app), colours(&app)); + pick_from_swatch(&mut app, None); + assert_eq!((steps(&app), colours(&app)), before, "no history step, both colours bit-exact"); + assert_eq!((app.ui.artboard_tool.background.as_str(), app.ui.artboard_tool.background_color.as_str()), ("custom", "#336699")); +} + +#[test] +fn a_changed_ok_with_boards_of_different_colours_recolours_both_in_one_step() { + let mut app = app(); + two_coloured_boards(&mut app); + let steps0 = steps(&app); + pick_from_swatch(&mut app, Some("#00ff00")); + assert_eq!(steps(&app), steps0 + 1); + assert_eq!(last_label(&app).as_deref(), Some("Edit Artboards")); + let green = |c: &ArtboardBackground| matches!(c, ArtboardBackground::Custom(c) if c.to_rgba8()[..3] == [0, 255, 0]); + assert!(colours(&app).iter().all(green)); +} + +#[test] +fn an_unchanged_ok_with_only_web_colors_leaves_the_boards_alone() { + let mut app = app(); + app.ui.color_panel.web_only = true; + let (a, _) = two_boards(&mut app); + app.run("layer.artboard.set", json!({"layer": a, "background": "custom", "color": [0.2, 0.4, 0.61, 1.0]})).unwrap(); + let before = (steps(&app), colours(&app)); + pick_from_swatch(&mut app, None); + assert_eq!((steps(&app), colours(&app)), before, "no history step, colours bit-exact"); +} + +#[test] +fn a_colour_some_boards_already_have_recolours_only_the_others() { + let mut app = app(); + let (_, b) = two_coloured_boards(&mut app); + let b_before = colours(&app)[1]; + let steps0 = steps(&app); + pick_from_swatch(&mut app, Some("#cc3300")); + assert_eq!(steps(&app), steps0 + 1); + assert_eq!(last_label(&app).as_deref(), Some("Edit Artboard"), "the label follows the one board that changed"); + assert_eq!(colours(&app)[1], b_before, "board {b} keeps its own value"); + assert!(matches!(colours(&app)[0], ArtboardBackground::Custom(c) if c.to_rgba8()[..3] == [0xcc, 0x33, 0x00])); +} + +#[test] +fn with_a_board_selected_the_bar_shows_its_background() { + let mut app = app(); + let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.artboard.set", json!({"layer": a, "background": "black"})).unwrap(); + app.run("layer.select", json!({"layer": a})).unwrap(); + assert_eq!(app.ui.artboard_tool.background, "white", "the tool option is still white"); + assert_eq!(bar::shown_background(&app).0, "black"); + app.run("layer.artboard.set", json!({"layer": a, "background": "custom", "color": "#336699"})).unwrap(); + assert_eq!(bar::shown_background(&app), ("custom".to_string(), "#336699".to_string())); +} + +#[test] +fn a_background_choice_recolours_every_selected_board_in_one_step() { + let mut app = app(); + let (a, b) = two_boards(&mut app); + let before = steps(&app); + bar::set_background(&mut app, "transparent", "#ffffff").unwrap(); + assert_eq!(steps(&app), before + 1); + assert_eq!(last_label(&app).as_deref(), Some("Edit Artboards")); + assert!(boards(&app).iter().all(|x| x.2 == ArtboardBackground::Transparent)); + assert_eq!(boards(&app).iter().map(|x| x.0.0).collect::>(), vec![a, b]); + assert_eq!(app.ui.artboard_tool.background, "transparent", "also the next drawn board's"); + // Other...: OK in the picker recolours them too. + bar::set_custom_background(&mut app, "#00ff00").unwrap(); + assert_eq!(steps(&app), before + 2); + for x in boards(&app) { + let ArtboardBackground::Custom(c) = x.2 else { panic!("a custom background") }; + assert_eq!(c.to_rgba8(), [0, 255, 0, 255]); + } + assert_eq!((app.ui.artboard_tool.background.as_str(), app.ui.artboard_tool.background_color.as_str()), ("custom", "#00ff00")); +} + +#[test] +fn one_selected_board_is_edit_artboard() { + let mut app = app(); + let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": a})).unwrap(); + let before = steps(&app); + bar::set_background(&mut app, "black", "#ffffff").unwrap(); + assert_eq!(steps(&app), before + 1); + assert_eq!(last_label(&app).as_deref(), Some("Edit Artboard")); + assert_eq!(boards(&app)[0].2, ArtboardBackground::Black); +} + +#[test] +fn with_no_board_selected_the_background_edits_only_the_tool_option() { + let mut app = app(); + let before = steps(&app); + bar::set_background(&mut app, "black", "#ffffff").unwrap(); + assert_eq!(app.ui.artboard_tool.background, "black"); + assert_eq!(steps(&app), before, "no history step"); + assert!(boards(&app).is_empty()); +} + +#[test] +fn the_background_param_carries_color_only_for_other() { + let mut app = app(); + assert_eq!(bar::with_background(&app, json!({"rect": [0, 0, 5, 5]})), json!({"rect": [0, 0, 5, 5], "background": "white"})); + bar::set_custom_background(&mut app, "#00ff00").unwrap(); + assert_eq!(bar::with_background(&app, json!({})), json!({"background": "custom", "color": "#00ff00"})); + assert!(bar::set_custom_background(&mut app, "green").is_err()); + assert_eq!(app.ui.artboard_tool.background_color, "#00ff00", "a bad colour changes nothing"); +} + +#[test] +fn other_opens_the_color_picker_and_ok_sets_the_option() { + let mut app = app(); + app.ui.artboard_tool.background = "black".to_string(); + // What the dropdown's Other… and the swatch do. + app.ui.artboard_tool.background_color = "#123456".to_string(); + let before = app.ui.dialogs.len(); + let rgb = crate::color_picker_ui::parse_hex("#123456").unwrap(); + let id = crate::color_picker_ui::open_for_target(&mut app, "artboardBackground", "Color Picker (Color)", rgb); + assert_eq!(app.ui.dialogs.len(), before + 1); + // Cancel leaves the option alone; OK sets it. + assert_eq!(app.ui.artboard_tool.background, "black"); + let fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + crate::color_picker_ui::confirm(&mut app, &fields).unwrap(); + assert_eq!((app.ui.artboard_tool.background.as_str(), app.ui.artboard_tool.background_color.as_str()), ("custom", "#123456")); +} + +#[test] +fn the_align_panel_has_the_eight_entries_in_the_measured_order() { + let labels: Vec> = ALIGN_PANEL.iter().map(|g| g.iter().map(|e| e.0).collect()).collect(); + assert_eq!( + labels, + vec![ + vec!["Left Edges", "Horizontal Centers", "Right Edges"], + vec!["Top Edges", "Vertical Centers", "Bottom Edges"], + vec!["Distribute Widths", "Distribute Heights"], + ] + ); + let specs = photocraft_engine::command_specs(); + for (label, cmd) in ALIGN_PANEL.iter().flat_map(|g| g.iter()) { + match cmd { + Some(c) => { + assert!(specs.iter().any(|s| s.id == *c), "{c} is a command"); + assert!(crate::align_bar::ALIGN.iter().any(|a| a.0 == *c && a.2 == *label), "{label} is the Move tool's"); + } + None => assert!(label.starts_with("Distribute"), "{label} has no command yet"), + } + } + // Distribute Widths and Heights run nothing. + assert!(bar::run_align(&mut app(), 2, 0).is_none()); + assert!(bar::run_align(&mut app(), 2, 1).is_none()); +} + +/// A filled square at `r` (`x, y, w, h`); the engine aligns a board by its contents. +fn square(app: &mut PhotocraftApp, r: [i64; 4]) -> u64 { + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": r[0], "y": r[1], "width": r[2], "height": r[3]})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.session.active().unwrap().active_layer.unwrap().0 +} + +fn wrap(app: &mut PhotocraftApp, layer: u64) -> u64 { + app.run("layer.select", json!({"layer": layer})).unwrap(); + app.run("layer.new.artboardFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap() +} + +#[test] +fn an_align_entry_moves_two_selected_boards_in_one_step() { + let mut app = app(); + let (sa, sb) = (square(&mut app, [40, 10, 100, 100]), square(&mut app, [300, 200, 80, 100])); + let (a, b) = (wrap(&mut app, sa), wrap(&mut app, sb)); + app.run("layer.select", json!({"layer": a})).unwrap(); + app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); + assert!(app.session.is_enabled("layer.align.leftEdges")); + let before: Vec = boards(&app).iter().map(|b| b.1).collect(); + assert_eq!(before.len(), 2); + let steps = app.session.active().unwrap().history.entries().len(); + let r = bar::run_align(&mut app, 0, 0); + assert!(matches!(r, Some(Ok(_))), "{r:?}"); + let after: Vec = boards(&app).iter().map(|b| b.1).collect(); + // Left Edges: the second board (with its contents) moves left to the first one's edge. + assert_eq!(after[0], before[0]); + assert_eq!((after[1].x0, after[1].y0), (before[0].x0, before[1].y0)); + assert_eq!((after[1].width(), after[1].height()), (before[1].width(), before[1].height())); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1, "one history step"); + // Top Edges (second group, first entry) moves it up to the first board's top. + assert!(matches!(bar::run_align(&mut app, 1, 0), Some(Ok(_)))); + let after: Vec = boards(&app).iter().map(|b| b.1).collect(); + assert_eq!((after[1].x0, after[1].y0), (before[0].x0, before[0].y0)); +} + +#[test] +fn the_gear_menu_has_four_entries_all_checked_and_only_auto_nest_live() { + let labels: Vec<&str> = GEAR.iter().map(|g| g.1).collect(); + assert_eq!(labels, vec!["Auto-nest Layers", "Auto-size Canvas", "Keep Relative Position During Layer Reordering", "Shrinkwrap Canvas on Save"]); + let live: Vec = GEAR.iter().map(|g| g.0).filter(|g| g.enabled()).collect(); + assert_eq!(live, vec![Gear::AutoNest]); + let mut o = ArtboardToolUi::default(); + for (g, _) in GEAR { + assert!(g.checked(&o)); + } + o.auto_nest = false; + o.shrinkwrap_on_save = false; + let checked: Vec = GEAR.iter().map(|g| g.0.checked(&o)).collect(); + assert_eq!(checked, vec![false, true, true, false]); +} + +/// Auto-nest Layers off: a layer dragged with the Move tool onto another board stays where it was. +#[test] +fn auto_nest_layers_turns_the_move_tools_nesting_on_and_off() { + for (nest, joins) in [(true, true), (false, false)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 300, "height": 100, "background": "transparent"})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("select.deselect", json!({})).unwrap(); + let red = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.new.artboardFromLayers", json!({"name": "A"})).unwrap(); + app.session.execute("layer.artboard.set", json!({"width": 40, "height": 40})).unwrap(); + let b = LayerId(app.session.execute("layer.new.artboard", json!({"rect": [200, 20, 40, 40], "name": "B"})).unwrap()["layer"].as_u64().unwrap()); + app.session.execute("layer.select", json!({"layer": red.0})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.artboard_tool.auto_nest = nest; + crate::canvas::move_ui::finish(&mut app, 190.0, 10.0); + let board = app.session.active().unwrap().doc.artboard_of(red); + assert_eq!(board == Some(b), joins, "auto_nest {nest}"); + } +} diff --git a/crates/ui-egui/src/background_eraser_alt_click_tests.rs b/crates/ui-egui/src/background_eraser_alt_click_tests.rs new file mode 100644 index 0000000000..aa7adbcc0c --- /dev/null +++ b/crates/ui-egui/src/background_eraser_alt_click_tests.rs @@ -0,0 +1,207 @@ +//! ⌥ (Alt) with the Background Eraser is the Eyedropper, as with the Brush (#11906): a click or +//! drag sets the foreground colour, adds no history state and changes no pixel, and the cursor +//! is the pipette while ⌥ is held. Control+⌥ stays the brush-resize gesture. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [100.0, 50.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) +} + +fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + h.run_steps(1); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, p: Pos2, down: bool, m: Modifiers) { + h.event(egui::Event::PointerButton { pos: p, button: b, pressed: down, modifiers: m }); + h.run_steps(1); +} + +/// Press `b` at document point `from` with `m` held from before the press, and move to `to`; +/// the button stays down. +fn press_and_move(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, from: [f32; 2], to: [f32; 2], m: Modifiers) { + let p = screen(h, from[0], from[1]); + mods(h, m); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + button(h, b, p, true, m); + if from != to { + for i in 1..=8 { + let t = i as f32 / 8.0; + let q = screen(h, from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + } +} + +/// Release `b` at document point `at`, then let go of the modifiers. +fn release(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, at: [f32; 2], m: Modifiers) { + button(h, b, screen(h, at[0], at[1]), false, m); + h.run_steps(2); + mods(h, Modifiers::NONE); +} + +/// A drag between document points with `m` held from before the press until after the +/// release; `from == to` is a click. +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f32; 2], to: [f32; 2], m: Modifiers) { + press_and_move(h, PointerButton::Primary, from, to, m); + release(h, PointerButton::Primary, to, m); +} + +/// Red on the left, green on the right of an unlocked layer, Background Eraser active, blue +/// foreground and white background colour. +fn background_eraser() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 0, "width": 100, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("tools.setColors", json!({"foreground": BLUE, "background": [1.0, 1.0, 1.0, 1.0]})).unwrap(); + app.sync_views(); + let mut h = harness(app); + h.state_mut().ui.tool = Tool::BackgroundEraser; + h.run_steps(1); + h +} + +/// The document and the number of history states, to show nothing changed. +fn snapshot(h: &Harness<'static, PhotocraftApp>) -> (std::sync::Arc, usize) { + let st = h.state().session.active().unwrap(); + (st.doc.clone(), st.history.past_len()) +} + +fn unchanged(h: &Harness<'static, PhotocraftApp>, before: &(std::sync::Arc, usize), what: &str) { + let (doc, steps) = snapshot(h); + assert!(std::sync::Arc::ptr_eq(&doc, &before.0), "{what}: no pixel changed"); + assert_eq!(steps, before.1, "{what}: no history state"); +} + +#[test] +fn alt_click_sets_the_foreground_without_erasing() { + let mut h = background_eraser(); + let before = snapshot(&h); + drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, GREEN, "⌥-click picked green"); + assert_eq!(h.state().session.tools.background, [1.0, 1.0, 1.0, 1.0], "the background colour stays"); + unchanged(&h, &before, "⌥-click"); + assert!(!h.state().alt_sampling, "the release ends the sampling"); +} + +#[test] +fn alt_drag_follows_the_pointer_without_erasing() { + let mut h = background_eraser(); + let before = snapshot(&h); + press_and_move(&mut h, PointerButton::Primary, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag samples while the button is held"); + release(&mut h, PointerButton::Primary, [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag ends on red"); + unchanged(&h, &before, "⌥-drag"); +} + +/// The same press without ⌥ still erases: the harness drives the real tool. +#[test] +fn a_click_without_alt_still_erases() { + let mut h = background_eraser(); + let (_, steps) = snapshot(&h); + drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::NONE); + assert_eq!(h.state().session.tools.foreground, BLUE); + assert_eq!(snapshot(&h).1, steps + 1, "the stroke added a history state"); + let st = h.state().session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert_eq!(layer.rgba(150, 50)[3], 0.0, "the clicked pixel was erased"); +} + +/// While ⌥ is held the cursor is the Eyedropper's pipette (`pipette_cursor`), where the +/// Background Eraser otherwise shows its own tip; Control+⌥ keeps the tip. +#[test] +fn alt_shows_the_eyedropper_cursor() { + let mut h = background_eraser(); + let p = screen(&h, 150.0, 50.0); + let pipette = |h: &mut Harness<'static, PhotocraftApp>, m: Modifiers| { + mods(h, m); + h.hover_at(p); + h.run_steps(2); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("pc-icon-cursor")); + let shown = h.ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer)); + (shown, h.output().platform_output.cursor_icon) + }; + assert_eq!(pipette(&mut h, Modifiers::ALT), (true, egui::CursorIcon::None), "⌥: the pipette replaces the pointer"); + assert!(!pipette(&mut h, Modifiers::NONE).0, "without ⌥: the eraser's own tip"); + assert!(!pipette(&mut h, Modifiers::ALT | Modifiers::CTRL).0, "Control+⌥ is not the Eyedropper"); + assert!(pipette(&mut h, Modifiers::ALT).0, "⌥ again: the pipette again"); +} + +/// Control+⌥-drag stays the brush-resize gesture: it samples nothing and erases nothing. +#[test] +fn ctrl_alt_drag_resizes_instead_of_sampling() { + assert!(crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT)); + assert!(!crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT | Modifiers::CTRL)); + let mut h = background_eraser(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 40}})).unwrap(); + let before = snapshot(&h); + drag(&mut h, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT | Modifiers::CTRL); + assert_eq!(h.state().session.tools.foreground, BLUE, "Control+⌥ sampled nothing"); + unchanged(&h, &before, "Control+⌥-drag"); + assert_ne!(h.state().session.tools.brush.size, 40.0, "Control+⌥-drag resized the brush"); +} + +/// ⌥+right-drag (not measured in the reference application) keeps today's meaning, the brush +/// resize: it samples nothing and erases nothing. +#[test] +fn alt_right_drag_resizes_instead_of_sampling() { + let mut h = background_eraser(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 40}})).unwrap(); + let before = snapshot(&h); + press_and_move(&mut h, PointerButton::Secondary, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT); + release(&mut h, PointerButton::Secondary, [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, BLUE, "⌥+right-drag sampled nothing"); + unchanged(&h, &before, "⌥+right-drag"); + assert_ne!(h.state().session.tools.brush.size, 40.0, "⌥+right-drag resized the brush"); +} diff --git a/crates/ui-egui/src/background_eraser_digit_keys_tests.rs b/crates/ui-egui/src/background_eraser_digit_keys_tests.rs new file mode 100644 index 0000000000..9cddb5a7eb --- /dev/null +++ b/crates/ui-egui/src/background_eraser_digit_keys_tests.rs @@ -0,0 +1,125 @@ +//! #11907: with the Background Eraser active, the number keys set its Tolerance as they set Opacity +//! for the painting tools (3 = 30%, 0 = 100%, 5 then 5 typed quickly = 55%), as in the reference +//! application. Other tools' digits are unchanged. + +use egui::{Event, Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::opacity_keys::press; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = tool; + app +} + +fn tolerance(app: &PhotocraftApp) -> f32 { + app.ui.tool_options.bg_tolerance +} + +fn layer(app: &PhotocraftApp) -> (f32, f32) { + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + (l.opacity, l.fill_opacity) +} + +#[test] +fn single_digits_set_the_tolerance() { + let mut a = app(Tool::BackgroundEraser); + assert_eq!(tolerance(&a), 50.0, "default"); + press(&mut a, 3, false, 0.0); + assert_eq!(tolerance(&a), 30.0); + press(&mut a, 0, false, 5000.0); + assert_eq!(tolerance(&a), 100.0, "0 = 100%"); + press(&mut a, 1, false, 10000.0); + assert_eq!(tolerance(&a), 10.0); +} + +#[test] +fn two_quick_digits_set_an_exact_tolerance() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 5, false, 0.0); + press(&mut a, 5, false, 300.0); + assert_eq!(tolerance(&a), 55.0); + // A slow second digit starts over. + press(&mut a, 4, false, 5000.0); + press(&mut a, 2, false, 9000.0); + assert_eq!(tolerance(&a), 20.0); +} + +#[test] +fn the_tolerance_is_shown_in_ui_inspect() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 3, false, 0.0); + let options = serde_json::to_value(&a.ui.tool_options).unwrap(); + assert_eq!(options["bg_tolerance"], json!(30.0)); +} + +#[test] +fn background_eraser_digits_leave_the_brush_and_the_layer_alone() { + let mut a = app(Tool::BackgroundEraser); + let brush = a.session.tools.brush.clone(); + press(&mut a, 7, false, 0.0); + assert_eq!(a.session.tools.brush, brush); + assert_eq!(layer(&a), (1.0, 1.0)); + assert_eq!(a.ui.tool_options.fill_opacity, 100.0); +} + +#[test] +fn unmeasured_shift_digits_and_double_zero_keep_the_tolerance() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 3, true, 0.0); + assert_eq!(tolerance(&a), 50.0, "⇧3 changes nothing"); + press(&mut a, 0, false, 5000.0); + press(&mut a, 0, false, 5100.0); + assert_eq!(tolerance(&a), 100.0, "00 keeps the 100% of the first 0"); +} + +#[test] +fn other_tools_digits_are_unchanged() { + for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] { + let mut a = app(tool); + press(&mut a, 3, false, 0.0); + assert!((a.session.tools.brush.opacity - 0.3).abs() < 1e-6, "{tool:?}"); + assert_eq!(tolerance(&a), 50.0, "{tool:?}"); + } + for tool in [Tool::Gradient, Tool::PaintBucket] { + let mut a = app(tool); + press(&mut a, 3, false, 0.0); + assert_eq!(a.ui.tool_options.fill_opacity, 30.0, "{tool:?}"); + assert_eq!(tolerance(&a), 50.0, "{tool:?}"); + } + let mut a = app(Tool::Move); + press(&mut a, 3, false, 0.0); + assert!((layer(&a).0 - 0.3).abs() < 1e-6); + assert_eq!(tolerance(&a), 50.0); + // The tools whose number is a strength, exposure or tolerance of their own still ignore the digits. + for tool in [Tool::ColorReplacement, Tool::Dodge, Tool::Smudge, Tool::MagicEraser] { + let mut a = app(tool); + let before = (a.session.tools.brush.clone(), layer(&a), a.ui.tool_options.clone()); + press(&mut a, 3, false, 0.0); + assert_eq!((a.session.tools.brush.clone(), layer(&a), a.ui.tool_options.clone()), before, "{tool:?}"); + } +} + +/// Through the whole app: number keys typed over the canvas (no widget focused). +#[test] +fn number_keys_set_the_tolerance_in_the_app() { + use egui_kittest::Harness; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app(Tool::BackgroundEraser) + }); + h.run_steps(4); + let key = |h: &mut Harness<'_, PhotocraftApp>, k: Key| { + h.event(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + key(&mut h, Key::Num3); + assert_eq!(tolerance(h.state()), 30.0); +} diff --git a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs new file mode 100644 index 0000000000..acf9d3de15 --- /dev/null +++ b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs @@ -0,0 +1,310 @@ +//! #11909: the Background Eraser's Tolerance takes whole numbers 1..100; a value typed past the +//! range becomes the closest one and, once the field is left, an alert with OK says so. +//! `paint.backgroundEraser` clamps `tolerance` to 1..100 too, and the options bar's controls carry +//! the reference application's tooltips. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::eraser_ui::TOLERANCE_RANGE_ALERT; +use crate::state::{DialogKind, Tool}; + +fn app_harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.ui.tool = Tool::BackgroundEraser; + app + }); + h.run_steps(5); + h +} + +fn bar(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +/// Types `text` into the Tolerance field, then Tabs out of it. +fn type_tolerance(h: &mut Harness<'static, PhotocraftApp>, text: &str) { + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +fn alert(h: &Harness<'static, PhotocraftApp>) -> Option { + h.state().ui.dialogs.iter().find(|d| d.kind == DialogKind::Error).and_then(|d| d.fields.get("message")).and_then(|m| m.as_str()).map(str::to_owned) +} + +fn tolerance(h: &Harness<'static, PhotocraftApp>) -> f32 { + h.state().ui.tool_options.bg_tolerance +} + +#[test] +fn tolerance_past_the_range_takes_the_closest_value_and_alerts() { + for (typed, want) in [("0", 1.0), ("150", 100.0), ("-5", 1.0)] { + let mut h = app_harness(); + type_tolerance(&mut h, typed); + assert_eq!(tolerance(&h), want, "{typed}"); + assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT), "{typed}"); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(alert(&h), None, "{typed}: OK closes the alert"); + assert_eq!(tolerance(&h), want, "{typed}: the clamped value is kept"); + } +} + +#[test] +fn the_alert_is_a_modal_the_control_channel_sees() { + let mut h = app_harness(); + type_tolerance(&mut h, "150"); + let dialogs = serde_json::to_value(&h.state().ui.dialogs).unwrap(); + assert!(dialogs.to_string().contains(TOLERANCE_RANGE_ALERT), "{dialogs}"); + assert!(h.query_by_label("OK").is_some(), "the alert has an OK button"); +} + +#[test] +fn tolerance_in_range_stores_a_whole_number_without_an_alert() { + let mut h = app_harness(); + type_tolerance(&mut h, "33.6"); + assert_eq!(tolerance(&h), 34.0); + assert_eq!(alert(&h), None); + type_tolerance(&mut h, "100"); + assert_eq!(tolerance(&h), 100.0); + assert_eq!(alert(&h), None, "the end of the range is in it"); + type_tolerance(&mut h, "abc"); + assert_eq!(tolerance(&h), 100.0, "not a number: the value stays"); + assert_eq!(alert(&h), None); +} + +#[test] +fn a_leading_zero_typed_key_by_key_keeps_the_text() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + for c in ["0", "5"] { + h.event(egui::Event::Text(c.into())); + h.run_steps(2); + } + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(tolerance(&h), 5.0, "0 then 5 is 05, not 15"); + assert_eq!(alert(&h), None, "05 is in range"); +} + +#[test] +fn a_tolerance_set_elsewhere_during_an_edit_wins_over_the_typed_text() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("150".into())); + h.run_steps(1); + // The bar goes away mid-edit and a preset sets the Tolerance meanwhile. + h.state_mut().ui.panels.options_bar = false; + h.run_steps(2); + h.state_mut().ui.tool_options.bg_tolerance = 30.0; + h.state_mut().ui.panels.options_bar = true; + h.run_steps(1); + // From the first frame back, the field shows the preset's value. + let shown = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .accesskit_node() + .numeric_value(); + assert_eq!(shown, Some(30.0), "the field shows the preset's value, not the typed 150"); + h.run_steps(2); + assert_eq!(tolerance(&h), 30.0, "the preset's value, not the typed 150"); +} + +#[test] +fn an_expression_committed_with_enter_past_the_range_alerts() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("50*4".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0); + assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT)); +} + +#[test] +fn a_restored_tolerance_out_of_range_shows_as_the_field_stores_it() { + for (saved, want) in [(0.0, 1.0), (33.6, 34.0), (f32::NAN, 50.0)] { + let mut h = app_harness(); + h.state_mut().ui.tool_options.bg_tolerance = saved; + h.run_steps(3); + assert_eq!(tolerance(&h), want, "{saved}"); + assert_eq!(alert(&h), None, "{saved}: no alert for a restored value"); + } +} + +#[test] +fn a_restored_fraction_reaches_the_stroke_as_a_whole_number() { + // The options bar need not have drawn: the stroke's params round it themselves. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for (saved, want) in [(23.6, 24.0), (0.0, 1.0), (f32::NAN, 50.0)] { + app.ui.tool_options.bg_tolerance = saved; + let p = crate::eraser_ui::background_eraser_params(&app, &[[1.0, 1.0, 1.0]]); + assert_eq!(p["tolerance"].as_f64(), Some(want), "{saved}"); + } +} + +#[test] +fn a_label_scrub_past_100_does_not_alert() { + let mut h = app_harness(); + type_tolerance(&mut h, "90"); + // The field keeps the keyboard while the caption is scrubbed from 90 past the end. + let field = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field"); + let rect = field.rect(); + field.click(); + h.run_steps(1); + // The caption sits just left of the field. + let from = egui::pos2(rect.left() - 25.0, rect.center().y); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + for k in 1..=6 { + h.event(egui::Event::PointerMoved(from + vec2(50.0 * k as f32, 0.0))); + h.run_steps(1); + } + let to = from + vec2(300.0, 0.0); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0, "the scrub reached the end"); + assert_eq!(alert(&h), None, "a scrub stops at 100 without an alert"); +} + +#[test] +fn an_edit_cut_off_by_hiding_the_bar_leaves_no_stale_alert() { + let mut h = app_harness(); + let field = |h: &Harness<'static, PhotocraftApp>| { + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .rect() + .center() + }; + // 150 typed, then the bar goes away before the field is left. + let at = field(&h); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("150".into())); + h.run_steps(1); + h.state_mut().ui.panels.options_bar = false; + h.run_steps(3); + h.state_mut().ui.panels.options_bar = true; + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0); + // Back in the field and out again without a change: nothing to say. + type_tolerance(&mut h, "100"); + assert_eq!(alert(&h), None, "no alert for an edit that was already cut off"); +} + +/// The accessible descriptions (tooltips) of the widgets of `role` named `label`. +fn descriptions(h: &Harness<'static, PhotocraftApp>, role: Option, label: &str) -> Vec { + let of = |n: egui_kittest::Node<'_>| n.accesskit_node().description().map(|d| d.to_string()); + match role { + Some(role) => h.query_all_by_role(role).filter(|n| n.accesskit_node().label().as_deref() == Some(label)).filter_map(of).collect(), + None => h.query_all_by_label(label).filter_map(of).collect(), + } +} + +#[test] +fn the_bar_has_the_reference_tooltips() { + use egui::accesskit::Role; + for (role, label, tip) in [ + (Role::Button, "Brush Preset picker", "Click to open the Brush Preset picker"), + (Role::ComboBox, "Limits:", "How far to let the erasing spread"), + (Role::SpinButton, "Tolerance:", "Erase range of similar colors"), + (Role::CheckBox, "Protect Foreground Color", "Do not erase the foreground swatch color"), + ] { + let mut h = bar(Tool::BackgroundEraser); + assert!(h.query_by_label(tip).is_none(), "{label}: no tooltip before the hover"); + let at = h.query_all_by_role(role).find(|n| n.accesskit_node().label().as_deref() == Some(label)).expect(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + assert!(h.query_by_label(tip).is_some(), "{label}: resting on it shows {tip:?}"); + } +} + +#[test] +fn the_other_tools_keep_their_brush_chip_tooltip() { + for tool in [Tool::Eraser, Tool::Pencil] { + let h = bar(tool); + let found = descriptions(&h, None, "Brush Preset picker"); + assert!(found.iter().any(|d| d == "Brush Preset picker"), "{tool:?}: {found:?}"); + } +} + +/// The alpha at `(x, y)` after a Background Eraser stroke over white and a square one level off +/// white, with `tolerance`. +fn erased_alpha(tolerance: serde_json::Value, (x, y): (i32, i32)) -> f32 { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 40})).unwrap(); + s.execute("select.rect", json!({"x": 30, "y": 0, "width": 34, "height": 40, "antiAlias": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#feffff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute( + "paint.backgroundEraser", + json!({"points": [[4, 20], [56, 20]], "size": 16, "hardness": 1, "sampling": "once", "limits": "discontiguous", "tolerance": tolerance}), + ) + .unwrap(); + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)[3] +} + +#[test] +fn the_command_clamps_tolerance_to_1_through_100() { + // Sampled white; the square is 1/255 off it: Tolerance 1 % reaches it, 0 % would not. + let near = (45, 20); + assert!(erased_alpha(json!(1), near) < 1.0, "1 % erases the near-white square"); + assert_eq!(erased_alpha(json!(0), near), erased_alpha(json!(1), near), "0 is taken as 1"); + assert_eq!(erased_alpha(json!(-20), near), erased_alpha(json!(1), near), "below the range is taken as 1"); + assert_eq!(erased_alpha(json!(150), (6, 20)), erased_alpha(json!(100), (6, 20)), "150 is taken as 100"); +} + +#[test] +fn the_params_doc_says_1_through_100() { + let spec = photocraft_engine::commands::find("paint.backgroundEraser").expect("registered"); + assert!(spec.params.contains(r#""tolerance":1..100=50"#), "{}", spec.params); +} diff --git a/crates/ui-egui/src/brush_behind_key_tests.rs b/crates/ui-egui/src/brush_behind_key_tests.rs new file mode 100644 index 0000000000..e7aef596ac --- /dev/null +++ b/crates/ui-egui/src/brush_behind_key_tests.rs @@ -0,0 +1,134 @@ +//! Shift+Alt+Q selects Behind for Brush and Pencil (#11894). + +use egui::{Context, Event, Key, Modifiers, RawInput}; +use photocraft_color::BlendMode; +use photocraft_engine::Session; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::brush_mode_menu::{self, Item}; +use crate::state::Tool; + +/// Create a test document for the selected tool, optionally with a transparent layer. +fn app(tool: Tool, transparent_layer: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + if transparent_layer { + app.run("layer.new.layer", json!({})).unwrap(); + } + app.ui.tool = tool; + app +} + +/// Send Shift+Alt+Q through the shortcut handler and assert that it consumes the key. +fn press_behind(app: &mut PhotocraftApp, ctx: &Context) { + let modifiers = Modifiers::SHIFT | Modifiers::ALT; + let events = vec![Event::ModifiersChanged(modifiers), Event::Key { key: Key::Q, physical_key: Some(Key::Q), pressed: true, repeat: false, modifiers }]; + ctx.begin_pass(RawInput { events, ..Default::default() }); + crate::shortcuts::handle(app, ctx); + assert!(!ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::Q, .. })))); + ctx.end_pass().textures_delta.clear(); +} + +/// Check whether the rendered options bar displays the Behind label. +fn options_show_behind(app: &mut PhotocraftApp, ctx: &Context) -> bool { + /// Find the Behind text label in a shape or its descendants. + fn has_behind(shape: &egui::Shape) -> bool { + match shape { + egui::Shape::Text(text) => text.galley.text() == "Behind", + egui::Shape::Vec(shapes) => shapes.iter().any(has_behind), + _ => false, + } + } + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 300.0))); + let mut out = ctx.run_ui(RawInput { screen_rect, ..Default::default() }, |ui| crate::panels::options_bar(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().any(|shape| has_behind(&shape.shape)) +} + +/// The key leaves Clear, uses one menu-equivalent command, and changes the displayed Mode. +#[test] +fn brush_and_pencil_select_behind_in_one_command() { + for tool in [Tool::Brush, Tool::Pencil] { + let mut app = app(tool, true); + app.ui.tool_options.brush_clear = true; + app.ui.tool_options.pencil_clear = true; + app.session.tools.brush.mode = BlendMode::Multiply; + let ctx = Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + assert!(brush_mode_menu::enabled(&app, Item::Behind)); + let (id, params) = brush_mode_menu::engine_call(&app, tool, Item::Behind).unwrap(); + assert_eq!((id, ¶ms), ("tools.setBrush", &json!({"brush": {"mode": "Normal", "behind": true}}))); + let before = app.session.journal.len(); + press_behind(&mut app, &ctx); + assert_eq!(&app.session.journal[before..], &[(id.to_owned(), params)]); + assert_eq!(app.ui.brush_tool, tool); + assert_eq!(app.ui.tool_options.brush_clear, tool != Tool::Brush); + assert_eq!(app.ui.tool_options.pencil_clear, tool != Tool::Pencil); + let brush = app.session.execute("brush.get", json!({})).unwrap(); + assert_eq!(brush["behind"], true); + assert_eq!(brush["mode"], "Normal"); + assert!(options_show_behind(&mut app, &ctx), "{tool:?}"); + } +} + +/// A shortcut on the start screen must leave both the live brush and its owning tool alone. +#[test] +fn brush_and_pencil_do_not_select_behind_without_a_document() { + for (tool, brush_tool) in [(Tool::Brush, Tool::Pencil), (Tool::Pencil, Tool::Brush)] { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + assert!(app.session.active().is_none()); + app.ui.tool = tool; + app.ui.brush_tool = brush_tool; + app.ui.tool_options.brush_clear = true; + app.ui.tool_options.pencil_clear = true; + app.session.tools.brush.mode = BlendMode::Multiply; + let brush = app.session.execute("brush.get", json!({})).unwrap(); + let before = app.session.journal.len(); + press_behind(&mut app, &Context::default()); + assert_eq!(app.session.journal.len(), before); + assert_eq!(app.session.execute("brush.get", json!({})).unwrap(), brush); + assert_eq!(app.ui.brush_tool, brush_tool); + assert!(app.ui.tool_options.brush_clear && app.ui.tool_options.pencil_clear); + } +} + +/// The same Background restriction used by the menu applies before the shortcut changes settings. +#[test] +fn background_does_not_accept_behind() { + for tool in [Tool::Brush, Tool::Pencil] { + let mut app = app(tool, false); + app.ui.tool_options.brush_clear = true; + app.ui.tool_options.pencil_clear = true; + let ctx = Context::default(); + assert!(!brush_mode_menu::enabled(&app, Item::Behind)); + let before = app.session.journal.len(); + let brush_tool = app.ui.brush_tool; + let brush = app.session.execute("brush.get", json!({})).unwrap(); + press_behind(&mut app, &ctx); + assert_eq!(app.session.journal.len(), before); + assert_eq!(app.session.execute("brush.get", json!({})).unwrap(), brush); + assert_eq!(app.ui.brush_tool, brush_tool); + assert!(app.ui.tool_options.brush_clear && app.ui.tool_options.pencil_clear); + } +} + +/// The consumed key stays a no-op for layers and for tools outside the measured scope. +#[test] +fn other_tools_keep_the_behind_key_as_a_no_op() { + for tool in [Tool::Move, Tool::CloneStamp, Tool::PatternStamp, Tool::HistoryBrush, Tool::Gradient, Tool::PaintBucket] { + let mut app = app(tool, true); + let ctx = Context::default(); + let before = app.session.journal.len(); + let brush_tool = app.ui.brush_tool; + let active = app.session.active().unwrap(); + let layer = active.active_layer.unwrap(); + let blend = active.doc.layer(layer).unwrap().blend; + press_behind(&mut app, &ctx); + assert_eq!(app.session.journal.len(), before, "{tool:?}"); + assert_eq!(app.ui.tool, tool); + assert_eq!(app.ui.brush_tool, brush_tool); + assert_eq!(app.session.active().unwrap().doc.layer(layer).unwrap().blend, blend); + assert!(!app.session.tools.brush.behind); + } +} diff --git a/crates/ui-egui/src/brush_opacity_minimum_tests.rs b/crates/ui-egui/src/brush_opacity_minimum_tests.rs new file mode 100644 index 0000000000..77f84a7b41 --- /dev/null +++ b/crates/ui-egui/src/brush_opacity_minimum_tests.rs @@ -0,0 +1,146 @@ +//! The Brush's Opacity starts at 1 %, as in Photoshop (#11812): its options-bar field takes whole +//! numbers from 1 to 100, the digit keys `00` set 100 %, and `tools.setBrush` floors opacity at 1 %. + +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::opacity_keys::press; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = tool; + app +} + +fn options_bar(tool: Tool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), theme); + ready = true; + return; + } + crate::panels::options_bar(app, ui); + }, + app(tool), + ); + h.run_steps(4); + h +} + +/// Types `text` into the options bar's Opacity field and presses Enter. +fn type_opacity(h: &mut Harness<'_, PhotocraftApp>, text: &str) -> f32 { + // "Opacity:" in the Photoshop-look themes, "Opacity" in the others. + let field = h + .query_all(egui_kittest::kittest::by().role(egui::accesskit::Role::SpinButton)) + .find(|n| n.accesskit_node().label().is_some_and(|l| l.trim_end_matches(':') == "Opacity")) + .unwrap_or_else(|| panic!("the {:?} Opacity field", h.state().ui.tool)) + .rect(); + h.hover_at(field.center()); + h.run(); + h.drag_at(field.center()); + h.run(); + h.drop_at(field.center()); + h.run_steps(2); + h.event(Event::Key { key: Key::A, physical_key: Some(Key::A), pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.run(); + h.event(Event::Text(text.into())); + h.run_steps(2); + h.event(Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + h.state().session.tools.brush.opacity +} + +/// In the Photoshop-look themes and the others (their options bars lay the Brush out apart); the +/// Pencil and Eraser fields take 1-100 whole numbers in Photoshop too. +#[test] +fn the_brush_opacity_field_takes_whole_numbers_from_1_to_100() { + use crate::theme::ThemeKind; + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] { + let mut h = options_bar(tool, theme); + for (typed, want) in [("0", 0.01), ("-10", 0.01), ("150", 1.0), ("45", 0.45), ("33.7", 0.34)] { + let got = type_opacity(&mut h, typed); + assert!((got - want).abs() < 1e-6, "{theme:?} {tool:?} typed {typed}: {got}, want {want}"); + } + } + } +} + +#[test] +fn double_zero_sets_the_brush_to_100_percent() { + let mut a = app(Tool::Brush); + a.run("tools.setBrush", json!({"brush": {"opacity": 0.3}})).unwrap(); + press(&mut a, 0, false, 0.0); + press(&mut a, 0, false, 100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); + // Other pairs are the exact value; single digits are tens; 0 alone is 100%. + for (digits, want) in [([0, 7], 0.07), ([4, 5], 0.45)] { + press(&mut a, digits[0], false, 10_000.0); + press(&mut a, digits[1], false, 10_100.0); + assert!((a.session.tools.brush.opacity - want).abs() < 1e-6, "{digits:?}"); + } + for (d, want) in [(3, 0.3), (9, 0.9), (0, 1.0), (1, 0.1)] { + press(&mut a, d, false, 20_000.0 + f64::from(d) * 10_000.0); + assert!((a.session.tools.brush.opacity - want).abs() < 1e-6, "{d}"); + } + // With build-up on, ⇧ digits set the opacity: `00` is 100% there too. + a.session.tools.brush.build_up = true; + press(&mut a, 2, true, 200_000.0); + press(&mut a, 0, true, 300_000.0); + press(&mut a, 0, true, 300_100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0); +} + +#[test] +fn double_zero_on_flow_keeps_its_one_percent() { + let mut a = app(Tool::Brush); + press(&mut a, 0, true, 0.0); + press(&mut a, 0, true, 100.0); + assert_eq!(a.session.tools.brush.flow, 0.01, "⇧00 is Flow's 1%, unchanged"); +} + +#[test] +fn set_brush_floors_opacity_at_one_percent() { + let mut a = app(Tool::Brush); + for params in [json!({"brush": {"opacity": 0}}), json!({"opacity": 0.0}), json!({"brush": {"opacity": 0.004}})] { + a.run("tools.setBrush", json!({"brush": {"opacity": 0.5}})).unwrap(); + a.run("tools.setBrush", params.clone()).unwrap(); + assert_eq!(a.session.tools.brush.opacity, 0.01, "{params}"); + } + a.run("tools.setBrush", json!({"brush": {"opacity": 0.37}})).unwrap(); + assert!((a.session.tools.brush.opacity - 0.37).abs() < 1e-6, "values above the floor are kept"); +} + +/// The Pencil's `00` is 100 % too (the Pencil has no Flow, so ⇧ digits are not involved). +#[test] +fn double_zero_sets_the_pencil_to_100_percent() { + let mut a = app(Tool::Pencil); + a.run("tools.setBrush", json!({"brush": {"opacity": 0.3}})).unwrap(); + press(&mut a, 0, false, 0.0); + press(&mut a, 0, false, 100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); +} + +/// An opacity set outside the field (a `tools.setBrush` fraction, an older saved 0) shows as the +/// whole percent in 1-100 the field can hold. +#[test] +fn the_opacity_field_shows_outside_values_as_whole_percents() { + let mut h = options_bar(Tool::Brush, crate::theme::ThemeKind::ProMedium); + for (stored, shown) in [(0.337, 34.0), (0.0, 1.0), (0.004, 1.0)] { + h.state_mut().session.tools.brush.opacity = stored; + h.run_steps(2); + let value = h + .query_all(egui_kittest::kittest::by().role(egui::accesskit::Role::SpinButton)) + .find(|n| n.accesskit_node().label().is_some_and(|l| l.trim_end_matches(':') == "Opacity")) + .and_then(|n| n.accesskit_node().numeric_value()); + assert_eq!(value, Some(shown), "stored {stored}"); + assert_eq!(h.state().session.tools.brush.opacity, stored, "showing it does not change it"); + } +} diff --git a/crates/ui-egui/src/brush_options_tooltips_tests.rs b/crates/ui-egui/src/brush_options_tooltips_tests.rs new file mode 100644 index 0000000000..b3372d06b0 --- /dev/null +++ b/crates/ui-egui/src/brush_options_tooltips_tests.rs @@ -0,0 +1,114 @@ +//! #11813: the Brush options-bar controls carry the reference application's tooltips (also their +//! accessible descriptions), the Eraser keeps today's texts, and the smoothing options popup has +//! a "Smoothing Options" title. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable, by}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; +use crate::theme::ThemeKind; + +fn bar(tool: Tool) -> Harness<'static, PhotocraftApp> { + bar_in(tool, ThemeKind::ProMedium) +} + +fn bar_in(tool: Tool, theme: ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + h +} + +/// The descriptions of the widgets labelled `label`, or, for the controls a caption names (Mode +/// and the fields), of every control of that kind. +fn descriptions(h: &Harness<'static, PhotocraftApp>, label: &str) -> Vec { + let of = |n: egui_kittest::Node<'_>| n.accesskit_node().description().map(|d| d.to_string()); + if label == "Mode" { + return h.query_all_by_role(egui::accesskit::Role::ComboBox).filter_map(of).collect(); + } + // The fields too are named by the caption before them. + if ["Opacity", "Flow", "Smoothing"].contains(&label) { + return h.query_all_by_role(egui::accesskit::Role::SpinButton).filter_map(of).collect(); + } + // The non-Pro pressure switches are unnamed nodes: every description in the bar. + if label.starts_with("Pressure for") { + return h.query_all(by().predicate(|n| n.description().is_some())).filter_map(of).collect(); + } + h.query_all_by_label(label).filter_map(of).collect() +} + +const BRUSH: [(&str, &str); 8] = [ + ("Brush Preset picker", "Click to open the Brush Preset picker"), + ("Mode", "Painting Mode"), + ("Opacity", "Set opacity for stroke"), + ("Always use pressure for opacity", "Always use Pressure for Opacity. When off, Brush Preset controls pressure"), + ("Flow", "Set flow rate for stroke"), + ("Smoothing", "Set smoothing for stroke\nUse higher values to reduce the shakiness of brush strokes"), + ("Always use pressure for size", "Always use Pressure for Size. When off, Brush Preset controls pressure"), + ("Set painting symmetry options", "Set symmetry options for Painting"), +]; + +#[test] +fn the_brush_controls_have_the_reference_tooltips() { + let h = bar(Tool::Brush); + for (label, tip) in BRUSH { + let found = descriptions(&h, label); + assert!(found.iter().any(|d| d == tip), "{label}: {found:?}"); + } +} + +#[test] +fn the_eraser_keeps_its_texts() { + let h = bar(Tool::Eraser); + for (label, tip) in BRUSH { + assert!(!descriptions(&h, label).iter().any(|d| d == tip), "{label} changed on the Eraser"); + } +} + +/// The non-Pro themes' Brush bar: the same texts where it has the same controls (its pressure +/// switches are named "Pressure for Size" and "Pressure for Opacity"). +const BRUSH_CLASSIC: [(&str, &str); 6] = [ + ("Mode", "Painting Mode"), + ("Opacity", "Set opacity for stroke"), + ("Flow", "Set flow rate for stroke"), + ("Smoothing", "Set smoothing for stroke\nUse higher values to reduce the shakiness of brush strokes"), + ("Pressure for Size", "Always use Pressure for Size. When off, Brush Preset controls pressure"), + ("Pressure for Opacity", "Always use Pressure for Opacity. When off, Brush Preset controls pressure"), +]; + +#[test] +fn the_brush_has_the_reference_tooltips_in_other_themes_too() { + let h = bar_in(Tool::Brush, ThemeKind::Classic); + for (label, tip) in BRUSH_CLASSIC { + let found = descriptions(&h, label); + assert!(found.iter().any(|d| d == tip), "{label}: {found:?}"); + } + let h = bar_in(Tool::Eraser, ThemeKind::Classic); + for (label, tip) in BRUSH_CLASSIC { + assert!(!descriptions(&h, label).iter().any(|d| d == tip), "{label} changed on the Eraser"); + } +} + +#[test] +fn the_erasers_smoothing_popup_keeps_no_title() { + let mut h = bar(Tool::Eraser); + h.get_by_label("Set additional smoothing options").click(); + h.run_steps(3); + assert!(h.query_by_label("Pulled String Mode").is_some()); + assert!(h.query_by_label("Smoothing Options").is_none()); +} + +#[test] +fn the_smoothing_options_popup_has_a_title() { + let mut h = bar(Tool::Brush); + h.get_by_label("Set additional smoothing options").click(); + h.run_steps(3); + assert!(h.query_by_label("Smoothing Options").is_some()); + assert!(h.query_by_label("Pulled String Mode").is_some()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ac471a9269..e5b0f62e08 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1988,9 +1988,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: None => paint_dots(ui, rect, app.session.prefs().interface.show_canvas_dots), } let border = app.session.prefs().interface.canvas_border; - // Default-mode Crop changes its camera during input. Defer its image until the new camera - // is known; other tools keep their existing paint order and texture/outline snapshots. + // Default-mode Crop changes its camera during input. Live painting must also process the + // current pointer samples before preparing the image, or every dab appears a frame late. + // Under a dialog the image keeps its place below the dialog's own overlays (Refine Edge). let crop_camera = primary && crate::crop_mode::moves_image(app); + let paint_after_input = crop_camera || (primary && app.ui.dialogs.is_empty() && strokes_live(app.ui.tool)); let paint_input = CanvasPaintInput { ctx: &ctx, rect, @@ -2008,7 +2010,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: target_xf, pixel_grid, }; - let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &display_view, xf, &paint_input)) }; + let painted = if paint_after_input { None } else { Some(paint_canvas(app, ui, &display_view, xf, &paint_input)) }; let img_rect = xf.doc_rect(doc.bounds()); // Under an open dialog the canvas widget is inert, but the image still pans and zooms. @@ -2029,7 +2031,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.stylus.update_for_frame(ui.ctx().cumulative_frame_nr(), &ui.input(|i| i.events.clone())); crate::stylus::Stylus::sync_eraser_tool(app); // A Magnetic Lasso border left behind by a tool or document switch is dropped. - crate::magnetic_lasso_ui::frame(app); + crate::magnetic_lasso_ui::frame(app, ui.input(|i| i.pointer.primary_down()), primary); // A Properties Levels/Curves eyedropper left behind by a tool or layer switch is dropped. crate::layer_picker::prune(app); // Held keys (hold_keys.rs): Space repositions a crop frame, marquee, lasso or shape being @@ -2176,12 +2178,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.request_repaint(); } } - let xf = if crop_camera { + let xf = if paint_after_input { + // This frame's navigation input (wheel pan, pinch zoom) shows in this frame too. + if view.zoom != render_zoom { + display_view.zoom = view.zoom; + } if view.center != target_xf.center { display_view.center = view.center; } display_view.rotation = view.rotation; - ViewXform { center: display_view.center, rotation: display_view.rotation, ..xf }.pixel_aligned_for(app, ppp) + ViewXform { zoom: display_view.zoom / ppp, center: display_view.center, rotation: display_view.rotation, ..xf }.pixel_aligned_for(app, ppp) } else { xf }; @@ -2279,6 +2285,9 @@ mod layer_edges_tests; #[cfg(test)] mod guide_ants_tests; +#[cfg(test)] +mod live_frame_tests; + #[cfg(test)] mod rotation_preview_isolation_tests { use super::*; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index b2e377e0ec..50059007ad 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -46,6 +46,15 @@ pub struct CanvasToolMenu { /// had when it opened. #[serde(default)] pub crop: Option>, + /// The Color Sampler menu's sampler: the one under the pointer when it opened (its index), + /// `None` off a sampler, where every row is greyed. + #[serde(default)] + pub sampler: Option, + /// Where that sampler was when the menu opened: the active document's index and the sampler's + /// position. Once a control command deletes, moves or renumbers samplers, or another document + /// becomes active, the index may name a different sampler, so the menu closes and Delete greys. + #[serde(default)] + pub sampler_at: Option<(usize, [f64; 2])>, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -303,6 +312,8 @@ pub fn open_crop(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; guide: false, spelling: None, crop: Some(users), + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -445,7 +456,50 @@ pub const PEN_MENU: &[Row] = &[ Some(("Disable Symmetry Path", "paint.symmetryDisable")), ]; -/// Tools whose plain canvas right-click offers selection actions. +/// The Color Sampler menu's Delete row: removes the sampler the menu opened on. +pub const SAMPLER_DELETE: &str = "view.colorSamplers.delete"; + +/// The Color Sampler's canvas menu (#11733), in the reference application's order: Delete, then +/// the readout modes for the sampler under the pointer. The readout rows are this menu's own ids +/// and stay greyed until samplers have their own readout mode. +pub const SAMPLER_MENU: &[Row] = &[ + Some(("Delete", SAMPLER_DELETE)), + None, + Some(("Actual Color", "colorSampler.readout.actual")), + Some(("Proof Color", "colorSampler.readout.proof")), + None, + Some(("Grayscale", "colorSampler.readout.gray")), + Some(("RGB Color", "colorSampler.readout.rgb")), + Some(("Web Color", "colorSampler.readout.web")), + Some(("HSB Color", "colorSampler.readout.hsb")), + Some(("HSL Color", "colorSampler.readout.hsl")), + Some(("CMYK Color", "colorSampler.readout.cmyk")), + Some(("Lab Color", "colorSampler.readout.lab")), + None, + Some(("Grayscale 16 bit", "colorSampler.readout.gray16")), + Some(("RGB 16 bit", "colorSampler.readout.rgb16")), + Some(("CMYK 16 bit", "colorSampler.readout.cmyk16")), + Some(("Lab 16 bit", "colorSampler.readout.lab16")), + None, + Some(("Total Ink", "colorSampler.readout.totalInk")), +]; + +/// Does the Color Sampler menu's index still name the sampler it opened on, in the same document? +/// `true` off a sampler, where there is nothing to go stale. +fn sampler_current(app: &PhotocraftApp, menu: &CanvasToolMenu) -> bool { + let Some(i) = menu.sampler else { return true }; + let now = app.session.active_index().zip(app.session.active().and_then(|d| d.color_samplers.get(i).copied())); + now.is_some() && now == menu.sampler_at +} + +/// Is `menu` the Color Sampler's own menu (not the pasteboard, guide or transform menu opened +/// with it)? +fn sampler_menu(menu: &CanvasToolMenu) -> bool { + menu.tool == Tool::ColorSampler && !menu.transform && !menu.pasteboard && !menu.guide && menu.crop.is_none() && menu.anchor.is_none() +} + +/// Tools whose plain canvas right-click opens a menu: the selection tools' actions, the path +/// menu and the Color Sampler's. pub fn applies(tool: Tool) -> bool { matches!( tool, @@ -461,6 +515,7 @@ pub fn applies(tool: Tool) -> bool { // Its brush is set from the options bar's chip; the canvas right-click is the // selection menu, as in Photoshop (#7268). | Tool::QuickSelection + | Tool::ColorSampler ) || path_menu(tool) } @@ -510,6 +565,8 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { TYPE_LAYER_MENU } else if path_menu(menu.tool) { PEN_MENU + } else if sampler_menu(menu) { + SAMPLER_MENU } else { selection_rows(menu.has_selection) } @@ -649,6 +706,8 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> guide: false, spelling: None, crop: None, + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -674,6 +733,9 @@ pub fn engine_call(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> _ => (command.into(), json!({})), }; } + if sampler_menu(menu) { + return (command.into(), menu.sampler.map_or(json!({}), |i| json!({"index": i}))); + } match pasteboard_choice(command) { Some(c) if menu.pasteboard => ("prefs.set".into(), json!({"values": {"interface.canvasColor": c.name()}})), _ if menu.pasteboard => ("prefs.set".into(), json!({})), @@ -727,6 +789,8 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: guide: false, spelling: None, crop: None, + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -758,6 +822,8 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui guide: true, spelling: None, crop: None, + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -810,6 +876,10 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) if menu.type_layer { return TYPE_LAYER_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command); } + // Off a sampler every row is greyed. The readout rows wait for per-sampler readout modes. + if sampler_menu(menu) { + return command == SAMPLER_DELETE && menu.sampler.is_some() && sampler_current(app, menu); + } if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } @@ -870,6 +940,8 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; + let sampler = if tool == Tool::ColorSampler { doc.and_then(|d| crate::analysis_ui::sampler_at(app, d)) } else { None }; + let sampler_at = sampler.and_then(|i| Some((app.session.active_index()?, *app.session.active()?.color_samplers.get(i)?))); app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, @@ -884,6 +956,8 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] guide: false, spelling, crop: None, + sampler, + sampler_at, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -911,6 +985,8 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; guide: false, spelling: None, crop: None, + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -964,6 +1040,9 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_type(app, ctx, command) } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) + } else if sampler_menu(&menu) { + let (id, params) = engine_call(app, &menu, command); + app.run(&id, params).map(|_| ()) } else if command == "select.toWorkPath" { // Photoshop's Make Work Path… asks for the tolerance first. spec_dialog(app, command, "Make Work Path", r##"{"tolerance":0.5..10=2}"##, serde_json::Map::new()); @@ -1153,6 +1232,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { || (menu.text && app.ui.text_edit.is_none()) || (menu.type_layer && app.ui.text_edit.is_some()) || (menu.crop.is_some() && !crate::crop_ui::pending(app)) + || (sampler_menu(&menu) && !sampler_current(app, &menu)) { app.ui.canvas_tool_menu = None; return; diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index e692c4fe07..e4f0336b1d 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -112,10 +112,10 @@ pub(super) fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, const RING_GREY: Color32 = Color32::from_gray(127); /// Tools on which holding ⌥ (Alt) switches to the Eyedropper: a click or drag sets the -/// foreground colour, as with the Eyedropper itself (#417). +/// foreground colour, as with the Eyedropper itself (#417, the Background Eraser #11906). pub(crate) fn alt_samples(tool: Tool, mods: egui::Modifiers) -> bool { // Control+Alt is the brush-resize drag (`brush_resize`), not sampling. - mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket) + mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket | Tool::BackgroundEraser) } /// Preferences › Cursors › Other Cursors asks for the precise crosshair, or Caps Lock swaps the @@ -289,3 +289,7 @@ pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool { #[cfg(test)] #[path = "../eyedropper_mask_sample_tests.rs"] mod eyedropper_mask_sample_tests; + +#[cfg(test)] +#[path = "../background_eraser_alt_click_tests.rs"] +mod background_eraser_alt_click_tests; diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 8be1a51c5d..fdc718be0f 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -86,6 +86,12 @@ impl Drag { } } + /// The document and target a vector drag was pressed on are still the current ones + /// ([`VectorGestureOwner`]); false for a drag that captured none. + pub(crate) fn vector_owner_valid(&self, app: &PhotocraftApp) -> bool { + self.vector_owner.as_ref().is_some_and(|owner| owner.valid(app)) + } + /// Reposition: move the start and every point so the last one lands on `to` (same size). pub fn shift_to(&mut self, to: [f64; 2]) { let last = self.points.last().map_or(self.start, |p| [p[0], p[1]]); diff --git a/crates/ui-egui/src/canvas/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs index 3a6ee54d97..54679f034d 100644 --- a/crates/ui-egui/src/canvas/filter_preview.rs +++ b/crates/ui-egui/src/canvas/filter_preview.rs @@ -58,8 +58,10 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } params = crate::select_mask_view::request_params(&d.fields, params); } + crate::actions::authorize_panel_preview(app, d.id, &d.fields, &cmd, ¶ms).ok()?; let st = app.session.documents().get(idx)?; - let k = filter_preview_factor(app, &st.doc, &cmd, ¶ms); + // Fade previews at full size: it blends one area, like OK does, and has no proxy (#8230). + let k = if cmd == FADE { 1 } else { filter_preview_factor(app, &st.doc, &cmd, ¶ms) }; let request = crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, @@ -111,19 +113,23 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); let color = app.session.color.settings.clone(); let task = request.clone(); + let fade = fade_job(app, &task); if let Err(error) = app.filter_preview_worker.start(request.clone(), ctx.clone(), move |cancel| { let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_for_target( - &doc, - task.active, - &task.command, - &task.params, - task.k, - Some(cancel), - task.target, - &documents, - &color, - ) + let result = match fade { + Some(fade) => fade.and_then(|f| f.render().ok()), + None => crate::filter_dialog::preview_document_for_target( + &doc, + task.active, + &task.command, + &task.params, + task.k, + Some(cancel), + task.target, + &documents, + &color, + ), + } .map(|doc| { let buffer = photocraft_compose::flatten(&doc); (std::sync::Arc::new(doc), buffer) @@ -154,17 +160,20 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let doc = app.session.documents().get(idx)?.doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); let t0 = crate::gpu_canvas::now_ms(); - let computed = crate::filter_dialog::preview_document_for_target( - &doc, - request.active, - &request.command, - &request.params, - request.k, - None, - request.target, - &documents, - &app.session.color.settings, - ) + let computed = match fade_job(app, &request) { + Some(fade) => fade.and_then(|f| f.render().ok()), + None => crate::filter_dialog::preview_document_for_target( + &doc, + request.active, + &request.command, + &request.params, + request.k, + None, + request.target, + &documents, + &app.session.color.settings, + ), + } .map(|doc| { let buffer = photocraft_compose::flatten(&doc); (std::sync::Arc::new(doc), buffer) @@ -176,6 +185,15 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e Some((preview.key.k, key, [result.size.width, result.size.height])) } +const FADE: &str = "edit.fade"; + +/// Edit › Fade blends back towards the pixels before the last step, which only the session's +/// history holds, so its preview is captured from the session here (cheap: the copy shares its +/// tiles) and blended where filter previews compute (#8230). `None` for every other command. +fn fade_job(app: &PhotocraftApp, request: &crate::filter_dialog::FilterPreviewKey) -> Option> { + (request.command == FADE).then(|| photocraft_engine::edit_menu_cmds::fade_preview(&app.session, &request.params).ok()) +} + /// Channel overlays must use the filter's current proxy too, including on the CPU canvas. /// The cache key changes with the completed request; unchanged frames reuse the overlay texture. pub(crate) fn filter_channel_preview(app: &mut PhotocraftApp, idx: usize, ctx: &egui::Context) -> Option<(std::sync::Arc, u64)> { diff --git a/crates/ui-egui/src/canvas/guide_select/tests.rs b/crates/ui-egui/src/canvas/guide_select/tests.rs index a591f47547..1038d3000a 100644 --- a/crates/ui-egui/src/canvas/guide_select/tests.rs +++ b/crates/ui-egui/src/canvas/guide_select/tests.rs @@ -230,6 +230,31 @@ fn edit_selected_guides_opens_new_guide_on_the_guide_and_applies_the_edit() { assert_eq!(selected(&app), Some((true, 1)), "the edited guide stays selected"); } +/// #10440: Edit Selected Guides' position takes arithmetic as New Guide's does, a leading bare +/// number in the ruler unit. +#[test] +fn edit_selected_guides_takes_arithmetic_in_the_position() { + let mut app = app(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); + click(&mut app, 50.0, 70.0); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let mut edited = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + // "30 + 10" with percent rulers is 40 % of the 200 px width. + edited.insert("position".into(), json!("30 + 10")); + assert_eq!(crate::new_guide_ui::params(&app, &edited)["position"], "30 % + 10"); + app.ui.dialogs.retain(|d| d.id != id); + crate::new_guide_ui::confirm(&mut app, &edited).unwrap(); + assert_eq!(guides(&app), (vec![80.0], vec![30.0])); + assert_eq!(selected(&app), Some((true, 0)), "the edited guide stays selected"); + // A unit on the first operand wins: 100 % of the width less 50 px. + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let mut edited = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + edited.insert("position".into(), json!("100% - 50px")); + app.ui.dialogs.retain(|d| d.id != id); + crate::new_guide_ui::confirm(&mut app, &edited).unwrap(); + assert_eq!(guides(&app), (vec![150.0], vec![30.0])); +} + #[test] fn editing_an_uncoloured_guide_keeps_the_preference_colour_unless_one_is_chosen() { let mut app = app(); diff --git a/crates/ui-egui/src/canvas/live_frame_tests.rs b/crates/ui-egui/src/canvas/live_frame_tests.rs new file mode 100644 index 0000000000..a5ccec19eb --- /dev/null +++ b/crates/ui-egui/src/canvas/live_frame_tests.rs @@ -0,0 +1,96 @@ +//! Live painting tools show this frame's pointer input in this frame (no extra frame of delay), +//! and the deferred image follows the animated camera. + +use super::*; +use serde_json::json; + +#[test] +fn live_brush_texture_includes_this_frames_press_and_moves() { + for tool in [Tool::Brush, Tool::MixerBrush] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 128, "height": 64, "background": "white"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0, "smoothing": {"amount": 0.0}}})).unwrap(); + app.ui.tool = tool; + app.sync_views(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 640.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + let p = h.state().last_canvas_rect.center(); + h.input_mut().events.push(egui::Event::PointerMoved(p)); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + let shown = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + let app = h.state_mut(); + let live = app.live_stroke.as_ref().expect("painting starts on press"); + let key = live.display_key(); + let doc = live.stroke.doc().clone(); + let (_, display) = canvas_display(app, &doc, None); + assert_eq!(app.canvases.values().next().unwrap().tex_preview_key, key ^ display, "{tool:?}: texture includes this frame's input"); + }; + shown(&mut h); + h.input_mut().events.push(egui::Event::PointerMoved(p + vec2(25.0, 0.0))); + h.run_steps(1); + shown(&mut h); + let end = p + vec2(25.0, 0.0); + h.input_mut().events.push(egui::Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().live_stroke.is_none()); + // The Brush keeps the shown pixels as its committed stroke; the Mixer Brush commits the + // pixels its live preview rendered (`gesture.rs`). + if tool == Tool::Brush { + assert!(h.state().committed_stroke.is_some(), "release reuses the shown pixels"); + } + let label = h.state().session.active().and_then(|st| st.history.undo_label()).map(str::to_owned); + assert_eq!(label.as_deref(), Some(if tool == Tool::MixerBrush { "Mixer Brush" } else { "Brush Tool" })); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + } +} + +#[test] +fn live_tools_paint_the_interpolated_camera_during_zoom_animation() { + for tool in [Tool::Brush, Tool::MixerBrush] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("prefs.set", json!({"values": {"general.animatedZoom": true}})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + let v = &mut app.ui.views[0]; + v.fit_pending = false; + v.zoom = 2.0; + v.center = [32.0, 32.0]; + v.doc_size = [64, 64]; + v.zoom_animating = true; + v.display_camera = Some((1.0, [32.0, 32.0])); + v.zoom_transition = Some(crate::state::ZoomTransition { from: (1.0, [32.0, 32.0]), target: (2.0, [32.0, 32.0]), started: 0.0 }); + let view = v.clone(); + let ctx = egui::Context::default(); + let tex = ensure_texture(&mut app, &ctx, 0, None).unwrap().0; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(500.0, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), time: Some(0.1), ..Default::default() }, |ui| { + canvas_view(&mut app, ui, 0, rect, view.clone(), true); + }); + out.textures_delta.clear(); + let mesh = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(mesh) if mesh.texture_id == tex => Some(mesh), + _ => None, + }) + .unwrap(); + let min = mesh.vertices.iter().map(|v| v.pos.x).fold(f32::INFINITY, f32::min); + let max = mesh.vertices.iter().map(|v| v.pos.x).fold(f32::NEG_INFINITY, f32::max); + let shown = app.ui.views[0].display_camera.unwrap().0; + assert!(shown > 1.0 && shown < 2.0, "{tool:?}: animation is between endpoints"); + assert!(((max - min) - 64.0 * shown).abs() < 0.01, "{tool:?}: image must use the animated camera, width {} at zoom {shown}", max - min); + } +} diff --git a/crates/ui-egui/src/canvas/move_ui.rs b/crates/ui-egui/src/canvas/move_ui.rs index 2a70bd6a7a..0743905f7f 100644 --- a/crates/ui-egui/src/canvas/move_ui.rs +++ b/crates/ui-egui/src/canvas/move_ui.rs @@ -383,7 +383,8 @@ pub(crate) fn finish(app: &mut PhotocraftApp, dx: f64, dy: f64) { return; } // `nest`: a layer dropped on another artboard joins it (Photoshop's auto-nesting). - let Ok(result) = app.run("layer.translate", json!({"dx": dx, "dy": dy, "selection": carries_selection(app), "nest": true})) else { + let Ok(result) = app.run("layer.translate", json!({"dx": dx, "dy": dy, "selection": carries_selection(app), "nest": app.ui.artboard_tool.auto_nest})) + else { return; }; // A layer that changed board is clipped by its new board, not the old one the preview showed, diff --git a/crates/ui-egui/src/canvas/paint_mouse.rs b/crates/ui-egui/src/canvas/paint_mouse.rs index fa644f35ab..2c16ce1fa1 100644 --- a/crates/ui-egui/src/canvas/paint_mouse.rs +++ b/crates/ui-egui/src/canvas/paint_mouse.rs @@ -67,10 +67,19 @@ fn session_fields(current: &BrushSettings, brush: &BrushSettings) -> BrushSettin BrushSettings { color: current.color, background: current.background, erase: current.erase, seed: current.seed, ..brush.clone() } } -/// A tool's brush as it starts out (Reset Tool, #2825): the default brush at Photoshop's -/// smoothing, with the session's own fields of `current`. -pub(crate) fn fresh_brush(current: &BrushSettings) -> BrushSettings { - session_fields(current, &BrushSettings { smoothing: first_smoothing(), ..BrushSettings::default() }) +/// The Brush tool's tip after Reset Tool, as the reference measures it (#11810): size, hardness +/// and spacing of a 13 px soft round at 25 % spacing. +const BRUSH_TOOL_TIP: (f32, f32, f32) = (13.0, 0.0, 0.25); + +/// `tool`'s brush as it starts out (Reset Tool, #2825): the default brush at Photoshop's +/// smoothing, with the session's own fields of `current`. The Brush tool's tip is +/// [`BRUSH_TOOL_TIP`]; the other tools keep the default tip. +pub(crate) fn fresh_brush(tool: Tool, current: &BrushSettings) -> BrushSettings { + let mut brush = BrushSettings { smoothing: first_smoothing(), ..BrushSettings::default() }; + if tool == Tool::Brush { + (brush.size, brush.hardness, brush.spacing) = BRUSH_TOOL_TIP; + } + session_fields(current, &brush) } /// Photoshop's 10 % stroke smoothing, what a tool's brush starts at. diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 989af47e1c..f1a8917b63 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -146,6 +146,7 @@ fn is_point_tool(t: Tool) -> bool { | Tool::EllipseShape | Tool::Triangle | Tool::Polygon + | Tool::Star | Tool::Line | Tool::Type | Tool::VerticalType @@ -334,7 +335,10 @@ pub(crate) fn snap_click(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers /// the pixel grid. fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { if app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid - && matches!(app.ui.tool, Tool::Pen | Tool::CurvaturePen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) + && matches!( + app.ui.tool, + Tool::Pen | Tool::CurvaturePen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line + ) { [p[0].round(), p[1].round()] } else { diff --git a/crates/ui-egui/src/canvas/snap_ui/tests.rs b/crates/ui-egui/src/canvas/snap_ui/tests.rs index b42264a188..bd809a30fc 100644 --- a/crates/ui-egui/src/canvas/snap_ui/tests.rs +++ b/crates/ui-egui/src/canvas/snap_ui/tests.rs @@ -947,7 +947,7 @@ fn alt_over_the_box_shows_the_reference_point_pointer() { for p in [[55.0, 52.0], [30.0, 30.0], [200.0, 200.0]] { assert_eq!(crate::transform_tool::cursor(&app, &xf, p, alt), Some(egui::CursorIcon::Crosshair), "{p:?}"); } - assert_eq!(crate::transform_tool::cursor(&app, &xf, [55.0, 52.0], egui::Modifiers::NONE), Some(egui::CursorIcon::Move)); + assert_eq!(crate::transform_tool::cursor(&app, &xf, [55.0, 52.0], egui::Modifiers::NONE), Some(egui::CursorIcon::Default)); assert_ne!(crate::transform_tool::cursor(&app, &xf, [40.0, 40.0], alt), Some(egui::CursorIcon::Crosshair), "a corner handle stays a handle"); } diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 7389104f6c..497de35430 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -18,13 +18,17 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::brush_mode_menu::show(app, ui.ctx()); } +/// Where each docked document is drawn under Window › Arrange: its index and the cell it gets, +/// at the border positions the user has dragged them to. `None` with no docked document, or when +/// the arrangement is the plain single canvas. With more documents than the layout has cells, the +/// shown run starts at [`PhotocraftApp::arrange_start`] and always includes the active one. pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option> { let documents = docked_document_indices(app); let n = documents.len(); if n == 0 { return None; } - let cells = crate::view_cmds::cells(&app.ui.view.arrange, rect, n)?; + let cells = crate::view_cmds::cells(&app.ui.view.arrange, rect, n, &app.ui.view.arrange_splits)?; // Keep stable tiles, expressing the remembered document index in the filtered docked list. let start = documents.partition_point(|&d| d < app.arrange_start).min(n.saturating_sub(cells.len())); let idx = app.session.active_index().and_then(|d| documents.iter().position(|&p| p == d)); @@ -109,19 +113,27 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(shown) = arranged_cells(app, rect) { app.arrange_start = shown.first().map_or(0, |(d, _)| *d); let t = crate::theme::Tokens::get(ui.ctx()); + // A press on a border grip drags the border (`drag_borders`); it activates no document. + // The grips are laid out after the canvases, so egui gives them the press (#10101). + let grips: Vec = border_grips(app, rect).into_iter().map(|(_, grip)| grip).collect(); for (d, cell) in shown { let cell = cell.shrink(1.0); let view = app.ui.views[d].clone(); let v = canvas_view(app, ui, d, cell, view, d == active_doc); if d != active_doc { app.ui.views[d] = v; - if main_focused && ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| cell.contains(p))) { + if main_focused + && ui.input(|i| { + i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| cell.contains(p) && !grips.iter().any(|g| g.contains(p))) + }) + { app.session.set_active(d); } } let stroke = if d == active_doc { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, Color32::from_black_alpha(160)) }; ui.painter().rect_stroke(cell, 0, stroke, egui::StrokeKind::Inside); } + drag_borders(app, ui, rect); return; } if idx != active_doc && main_focused && ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| rect.contains(p))) { @@ -131,6 +143,33 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { canvas_view(app, ui, idx, rect, view, app.session.active_index() == Some(idx)); } +/// Window › Arrange: the borders between the documents are dragged to give one more room, as in +/// Photoshop (#10101). Laid out after the canvases, so a grip over a cell's edge takes the press. +fn drag_borders(app: &mut PhotocraftApp, ui: &egui::Ui, rect: Rect) { + let n = docked_document_indices(app).len(); + let layout = app.ui.view.arrange.clone(); + for (border, grip) in border_grips(app, rect) { + let response = ui.interact(grip, ui.scope_id().with(("arrange-border", border.split)), Sense::drag()); + if response.hovered() || response.dragged() { + ui.ctx().set_cursor_icon(if border.vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + } + if response.dragged() + && let Some(p) = response.interact_pointer_pos() + { + crate::view_cmds::drag_border(&layout, rect, n, &mut app.ui.view.arrange_splits, border.split, p); + } + } +} + +/// The arrangement's borders, each with the strip either side of it that grabs it. +fn border_grips(app: &PhotocraftApp, rect: Rect) -> Vec<(crate::view_cmds::Border, Rect)> { + let n = docked_document_indices(app).len(); + crate::view_cmds::borders(&app.ui.view.arrange, rect, n, &app.ui.view.arrange_splits) + .into_iter() + .map(|b| (b, b.line.expand2(if b.vertical { egui::vec2(3.0, 0.0) } else { egui::vec2(0.0, 3.0) }))) + .collect() +} + /// The tab strip; returns where its document tabs are. pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let t = crate::theme::Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 661da5d277..a3b8186e8e 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3072,3 +3072,53 @@ fn torn_off_document_is_not_drawn_again_in_the_main_window() { assert_eq!(tabs, Some(vec![1]), "{kind:?}: only the docked document stays on the main tab strip"); } } + +/// #8230: Edit › Fade opens with a Preview checkbox, and the canvas shows the layer faded with the +/// dialog's opacity, at full size, on the background worker and the inline path alike. Cancel +/// leaves the document, its history and Fade itself as they were; OK applies what was shown. +#[test] +fn fade_dialog_previews_the_faded_layer_and_cancel_keeps_the_document() { + for background in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + app.run("edit.fill", json!({"color": "#000000"})).unwrap(); + app.background_jobs = background; + let ctx = egui::Context::default(); + let st = app.session.active().unwrap(); + let (layer, steps, revision) = (st.active_layer.unwrap(), st.history.past_len(), st.revision); + let id = crate::filter_dialog::open(&mut app, "edit.fade").expect("Fade has a dialog"); + let d = app.ui.dialog_mut(id).expect("the dialog"); + assert_eq!(d.fields.get("__preview"), Some(&json!(true)), "a Preview checkbox, on"); + d.fields.insert("opacity".into(), json!(25)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let (k, size) = loop { + if let Some((k, _, size)) = ensure_filter_preview(&mut app, 0, &ctx) + && app.filter_preview.as_ref().is_some_and(|p| p.key.params.get("opacity") == Some(&json!(25))) + { + break (k, size); + } + assert!(std::time::Instant::now() < deadline, "background jobs {background}: no preview"); + std::thread::sleep(std::time::Duration::from_millis(1)); + }; + assert_eq!((k, size), (1, [40, 30]), "background jobs {background}: full size"); + let shown = app.filter_preview.as_ref().and_then(|p| p.result.as_ref()).expect("a result").layer(layer).unwrap().surface().unwrap().pixel(3, 3); + assert!((shown[0] - 0.75).abs() < 0.01, "background jobs {background}: {shown:?}"); + + // Cancel: nothing changed, nothing previewed, and Fade is still there to use. + app.ui.close_dialog(id); + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none(), "background jobs {background}: no preview after Cancel"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(layer).unwrap().surface().unwrap().pixel(3, 3)[0], 0.0, "background jobs {background}: the layer is untouched"); + assert_eq!((st.history.past_len(), st.revision), (steps, revision), "background jobs {background}: no history step"); + assert!(app.session.is_enabled("edit.fade"), "background jobs {background}: Fade is still available"); + + // OK applies the faded layer as one step. + let id = crate::filter_dialog::open(&mut app, "edit.fade").unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(25)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let st = app.session.active().unwrap(); + let faded = st.doc.layer(layer).unwrap().surface().unwrap().pixel(3, 3)[0]; + assert!((faded - shown[0]).abs() < 1e-6, "background jobs {background}: OK applies the preview ({faded})"); + assert_eq!(st.history.past_len(), steps + 1); + } +} diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 97325bdb40..90e4e4fb43 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -1,6 +1,6 @@ use super::selection::floating_outline; use super::view_xform::ViewXform; -use super::{Color32, LayerContent, PhotocraftApp, Pos2, Rect, Stroke, Tool, Vec2, pos2, vec2}; +use super::{Color32, LayerContent, PhotocraftApp, Pos2, Rect, Stroke, Tool, Vec2, vec2}; /// View › Show › Layer Edges: the colour of the active layer's outline. pub(crate) const LAYER_EDGES: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb); @@ -75,50 +75,53 @@ pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Op Some(Rect::from_two_pos(xf.to_screen(b.x0 as f32, b.y0 as f32), xf.to_screen(b.x1 as f32, b.y1 as f32))) } -/// The Move tool shows transform handles even before a Free Transform session exists. -/// Use the same hit areas for pointer-down and hover, so a control never behaves like -/// resize/rotate while showing the ordinary four-arrow Move cursor (#2871). +/// Express the unopened screen rectangle in the same corner order as a transform quad. +fn controls_quad(r: Rect) -> [[f64; 2]; 4] { + [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| [f64::from(p.x), f64::from(p.y)]) +} + +/// Classify screen coordinates with the fixed screen-space tolerance, without a pivot. +fn controls_zone(r: Rect, p: Pos2) -> crate::transform_tool::Hit { + crate::state::TransformSession::box_hit(&controls_quad(r), [f64::from(p.x), f64::from(p.y)], crate::transform_tool::HANDLE_PX) +} + +/// Share the open session's screen-sized zones before the first interaction (#11097). pub(super) fn transform_controls_cursor(r: Rect, p: Pos2) -> Option { - let handles = [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()]; - let grab = crate::transform_tool::HANDLE_PX as f32; - for (i, handle) in handles.into_iter().enumerate() { - if handle.distance(p) <= grab { - return Some(match i { - 0 | 4 => egui::CursorIcon::ResizeNwSe, - 2 | 6 => egui::CursorIcon::ResizeNeSw, - 1 | 5 => egui::CursorIcon::ResizeVertical, - _ => egui::CursorIcon::ResizeHorizontal, - }); - } - } - // The rotation band: the pointer gives way to the curved rotate arrow (`transform_controls_turn`). - in_rotation_band(r, p).then_some(egui::CursorIcon::None) -} - -fn in_rotation_band(r: Rect, p: Pos2) -> bool { - r.expand(18.0).contains(p) && !r.expand(5.0).contains(p) -} - -/// In the rotation band just outside the Move tool's transform controls, the screen direction the -/// curved rotate arrow bends round: into the box along the nearest corner's bisector, as Free -/// Transform draws it (#4658). Before, the band showed a plain alias pointer, so nothing said a -/// drag there rotates. -pub(super) fn transform_controls_turn(r: Rect, p: Pos2) -> Option { - if !in_rotation_band(r, p) - || [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] - .into_iter() - .any(|h| h.distance(p) <= crate::transform_tool::HANDLE_PX as f32) - { + if !p.is_finite() { return None; } - let corner = pos2(if p.x < r.center().x { r.left() } else { r.right() }, if p.y < r.center().y { r.top() } else { r.bottom() }); - Some(vec2((r.center().x - corner.x).signum(), (r.center().y - corner.y).signum()).normalized() * 16.0) + use crate::transform_tool::Hit; + Some(match controls_zone(r, p) { + Hit::Corner(0 | 2) => egui::CursorIcon::ResizeNwSe, + Hit::Corner(_) => egui::CursorIcon::ResizeNeSw, + Hit::Edge(0 | 2) => egui::CursorIcon::ResizeVertical, + Hit::Edge(_) => egui::CursorIcon::ResizeHorizontal, + Hit::Outside => egui::CursorIcon::None, + Hit::Inside | Hit::Pivot => egui::CursorIcon::Default, + }) } -/// A visible handle starts scaling; the narrow band just outside the box starts rotation. -/// The interior stays the normal Move-tool drag target. +/// At any distance outside the controls, the same edge/corner direction as Free Transform. +pub(super) fn transform_controls_turn(r: Rect, p: Pos2) -> Option { + if controls_zone(r, p) != crate::transform_tool::Hit::Outside { + return None; + } + let k = crate::state::TransformSession::box_turn_direction(&controls_quad(r), [f64::from(p.x), f64::from(p.y)])?; + Some(vec2(k[0] as f32, k[1] as f32) * 16.0) +} + +/// Resize from the shared handle zones and keep Move's existing rotation press band. +/// Outside presses beyond that band still reach the Move tool, even where hover paints a turn arc. pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool { - transform_controls_cursor(r, p).is_some() + if !p.is_finite() { + return false; + } + use crate::transform_tool::Hit; + match controls_zone(r, p) { + Hit::Corner(_) | Hit::Edge(_) => true, + Hit::Outside => r.expand(18.0).contains(p) && !r.expand(5.0).contains(p), + Hit::Inside | Hit::Pivot => false, + } } /// The reference application's alert when a Move-tool transform control is dragged on a frame @@ -177,3 +180,7 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P painter.rect_stroke(h, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside); } } + +#[cfg(test)] +#[path = "../transform_hit_zone_tests.rs"] +mod hit_zone_tests; diff --git a/crates/ui-egui/src/canvas/transform_controls_tests.rs b/crates/ui-egui/src/canvas/transform_controls_tests.rs index ecfbfe056d..2bb92e0d8b 100644 --- a/crates/ui-egui/src/canvas/transform_controls_tests.rs +++ b/crates/ui-egui/src/canvas/transform_controls_tests.rs @@ -328,21 +328,20 @@ fn move_transform_controls_show_resize_and_rotate_cursors_before_first_interacti (r.center_bottom(), egui::CursorIcon::ResizeVertical), (r.right_center(), egui::CursorIcon::ResizeHorizontal), (r.left_center(), egui::CursorIcon::ResizeHorizontal), - // Between handles: the rotation band (a point above the top-centre handle is that handle), - // where the curved rotate arrow replaces the pointer (#4658). + // Just beyond the outward edge band, the curved rotate arrow replaces the pointer (#4658). (pos2(r.left() + r.width() * 0.25, r.top() - 12.0), egui::CursorIcon::None), ] { assert_eq!(transform_controls_cursor(r, p), Some(expected), "hover at {p:?}"); assert!(transform_controls_hit(r, p), "same region must start a transform"); } - assert_eq!(transform_controls_cursor(r, r.center()), None, "interior stays Move"); - assert_eq!(transform_controls_cursor(r, pos2(r.center().x, r.top() - 30.0)), None); - // The rotate arrow bends round the nearest corner, pointing into the box; handles and the - // interior have none. - let into = |p: Pos2| transform_controls_turn(r, p).map(|v| (v.x.signum(), v.y.signum())); - assert_eq!(into(pos2(r.left() + r.width() * 0.25, r.top() - 12.0)), Some((1.0, 1.0)), "top left"); - assert_eq!(into(pos2(r.right() + 12.0, r.bottom() - r.height() * 0.25)), Some((-1.0, -1.0)), "bottom right"); - assert_eq!(into(pos2(r.left() - 12.0, r.bottom() + 12.0)), Some((1.0, -1.0)), "beyond the bottom-left corner"); + assert_eq!(transform_controls_cursor(r, r.center()), Some(egui::CursorIcon::Default), "interior uses the default arrow"); + assert_eq!(transform_controls_cursor(r, pos2(r.center().x, r.top() - 30.0)), Some(egui::CursorIcon::None)); + // The rotate arrow points into the nearest edge or corner. Handles and the interior have none. + let into = |p: Pos2| transform_controls_turn(r, p); + assert_eq!(into(pos2(r.left() + r.width() * 0.25, r.top() - 12.0)), Some(vec2(0.0, 16.0)), "top edge"); + assert_eq!(into(pos2(r.right() + 12.0, r.bottom() - r.height() * 0.25)), Some(vec2(-16.0, 0.0)), "right edge"); + let corner = into(pos2(r.left() - 12.0, r.bottom() + 12.0)).unwrap(); + assert!(corner.x > 0.0 && corner.y < 0.0, "beyond the bottom-left corner"); assert_eq!(into(r.left_top()), None); assert_eq!(into(r.center()), None); @@ -351,7 +350,7 @@ fn move_transform_controls_show_resize_and_rotate_cursors_before_first_interacti let next = transform_controls_rect(&app, &xf).expect("new active shape controls"); assert_ne!(r, next); assert_eq!(transform_controls_cursor(next, next.right_bottom()), Some(egui::CursorIcon::ResizeNwSe)); - assert_eq!(transform_controls_cursor(next, next.center()), None); + assert_eq!(transform_controls_cursor(next, next.center()), Some(egui::CursorIcon::Default)); } #[test] diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index 3441e194ca..4d0af0d3a7 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -116,7 +116,12 @@ fn execute_command(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRe // params and never open a dialog (an agent would otherwise get a modal instead of a // result). `ui.menu.invoke` behaves like a menu click, so it may open the dialog. if req.method == "engine.execute" && photocraft_engine::commands::find(id).is_some() { - return run_waiting(app, wait, |app| app.run(id, params)); + return run_waiting(app, wait, |app| { + let previous = std::mem::replace(&mut app.programmatic_playback, true); + let result = app.run(id, params); + app.programmatic_playback = previous; + result + }); } run_waiting(app, wait, |app| crate::menus::invoke(app, ctx, id, params)) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 621903bd4c..14d20537fd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -311,6 +311,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::preset_group_prompt::owns(fields) => crate::preset_group_prompt::body(ui, fields), _ if crate::actions::owns_stop_message(fields) => crate::actions::stop_message_body(ui, fields), _ if crate::actions::owns_insert_stop(fields) => crate::actions::insert_stop_body(ui, fields), + _ if crate::actions::menu_item::owns(fields) => crate::actions::menu_item::body(ui, fields), _ if crate::actions::owns_new_set(fields) => crate::actions::new_set_body(ui, fields), _ if crate::actions::playback::owns(fields) => crate::actions::playback::body(ui, fields), _ if crate::new_layer_ui::owns(fields) => crate::new_layer_ui::body(ui, fields), @@ -320,7 +321,6 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::stack_dialog::owns(fields) => crate::stack_dialog::body(app, ui, fields, d.id), _ if crate::tool_reset::owns(fields) => crate::tool_reset::body(ui), _ if crate::crop_presets::owns(fields) => crate::crop_presets::body(app, ui, fields), - _ if crate::variables_ui::owns(fields) => crate::variables_ui::body(app, ui, fields), _ => return command_body_more(app, ui, d, fields, id), } outcome @@ -330,6 +330,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id) -> Option { let mut outcome: Option = None; match d.kind { + _ if crate::variables_ui::owns(fields) => crate::variables_ui::body(app, ui, fields), _ if merge_channels_ui::owns(fields) => merge_channels_ui::body(ui, fields), _ if crate::file_ui::owns(fields) => crate::file_ui::body(app, ui, fields, d.id), _ if crate::color_picker_ui::owns(fields) => { @@ -349,6 +350,7 @@ fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fie _ if crate::blur_pins::owns(fields) => crate::blur_pins::body(ui, fields), _ if fields.contains_key("__filter") => filter_body(app, ui, d, fields, id), _ if crate::new_guide_ui::owns(fields) => crate::new_guide_ui::body(ui, fields), + _ if crate::equalize_ui::owns(fields) => crate::equalize_ui::body(ui, fields), _ if fields.contains_key("__form") => { if fields.get("__command").and_then(Value::as_str) == Some("file.fileInfo") { let room = (ui.ctx().content_rect().height() - DIALOG_CHROME).max(120.0); @@ -885,6 +887,8 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::disk_change_prompt::owns, crate::actions::owns_stop_message, crate::actions::owns_insert_stop, + crate::actions::menu_item::owns, + crate::actions::record_again::owns, crate::actions::owns_new_set, crate::actions::playback::owns, crate::new_layer_ui::owns, @@ -898,6 +902,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::variables_ui::owns, crate::file_ui::owns, crate::new_guide_ui::owns, + crate::equalize_ui::owns, crate::color_picker_ui::owns, crate::color_range_ui::owns, crate::prefs_ui::owns, @@ -961,7 +966,13 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result Result edit, Err(e) => { app.filter_preview = None; @@ -1027,7 +1038,7 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result Result<(), String> { if crate::layer_style::color_picker::has_child(app, id) { return Err("confirm or cancel the Layer Style color picker first".into()); @@ -1052,6 +1063,9 @@ pub(crate) fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), if let Some(d) = app.ui.dialogs.iter().find(|d| d.id == id).filter(|d| crate::guide_layout_ui::owns(&d.fields)) { crate::guide_layout_ui::validate(app, &d.fields)?; } + if let Some(d) = app.ui.dialogs.iter().find(|d| d.id == id).filter(|d| crate::equalize_ui::owns(&d.fields)) { + crate::equalize_ui::validate(app, &d.fields)?; + } // Merge Channels with too few, repeated, closed or changed documents stays open (#11451). merge_channels_ui::preflight(app, id)?; Ok(()) @@ -1061,6 +1075,12 @@ pub(crate) fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), /// Confirm a command dialog through the first handler whose marker its fields carry; the prompts /// and small editors are here, the rest in `confirm_command_more`. fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result { + if crate::actions::record_again::owns(&d.fields) || crate::actions::record_again::owns_dialog(app, d.id) { + return crate::actions::record_again::confirm(app, d.id, &d.fields); + } + if crate::actions::menu_item::owns(&d.fields) || crate::actions::menu_item::owns_dialog(app, d.id) { + return crate::actions::menu_item::confirm(app, d.id, &d.fields); + } match d.kind { _ if crate::open_prompt::owns_memory(&d.fields) => crate::open_prompt::answer_memory(app, true), _ if crate::open_prompt::owns_damage(&d.fields) => crate::open_prompt::answer_damage(app, true), @@ -1157,6 +1177,8 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { crate::color_picker_ui::live_preview::end(app, *picker); } app.ui.dialogs.retain(|d| !pickers.contains(&d.id)); + crate::actions::record_again::cancel(app, id); + crate::actions::menu_item::cancel(app, id); crate::actions::stopped(app, &closed.fields); if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); @@ -1184,9 +1206,9 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { Ok(Value::Null) } -/// Open the parameter dialog of `command`: the adjustment editor for `image.adjustments.*`, the -/// schema dialog for filters, the Color Range dialog for `select.colorRange`, otherwise a bare -/// confirm dialog. +/// Open the parameter dialog of `command`: the Equalize choices when there is a selection, the +/// adjustment editor for `image.adjustments.*`, the schema dialog for filters, the Color Range +/// dialog for `select.colorRange`, otherwise a bare confirm dialog. pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) -> u64 { if command == "view.newGuideLayout" { return crate::guide_layout_ui::open(app); @@ -1194,6 +1216,9 @@ pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) if command == crate::stroke_ui::COMMAND { return crate::stroke_ui::open(app); } + if command == crate::equalize_ui::COMMAND && crate::equalize_ui::asks(app) { + return crate::equalize_ui::open(app); + } if crate::new_group_ui::fronts(command) { return crate::new_group_ui::open(app, command); } @@ -1241,6 +1266,7 @@ pub mod disk_change_prompt; pub mod distort_ui; pub mod duplicate_layer_ui; pub mod edit_history; +pub mod equalize_ui; pub mod export_dialog; pub mod file_ui; pub mod fill_ui; diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index f20ea8618e..f435bde1b4 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -17,6 +17,8 @@ use crate::tone::{self, HistSource}; const PREFIX: &str = "image.adjustments."; const CURVE_PICKER: &str = "__curvePicker"; const CURVE_PICKER_BASE: &str = "__curvePickerBase"; +/// Set once a Photo Filter dialog has drawn: its editor memory was refreshed for this opening. +const PHOTO_FILTER_FRESH: &str = "__photoFilterFresh"; fn picker(value: Option<&Value>) -> Option { use photocraft_engine::adjust_params::CurvesEyedropper; @@ -282,8 +284,33 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { + let id = open_neutral(app, command)?; + let kind = command.strip_prefix(PREFIX).unwrap_or_default(); + let kept: Vec<(String, Value)> = match app.session.prefs().dialogs.get(command) { + Some(last) => KEEPS_LAST + .iter() + .filter(|(k, _)| *k == kind) + .filter_map(|(_, key)| last.get(*key).filter(|v| v.is_boolean()).map(|v| ((*key).to_owned(), v.clone()))) + .collect(), + None => Vec::new(), + }; + if let Some(d) = app.ui.dialog_mut(id) { + d.fields.extend(kept); + } + Some(id) +} + +/// Opens the dialog for `command` with the kind's neutral settings. +pub fn open_neutral(app: &mut PhotocraftApp, command: &str) -> Option { let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; let spec = photocraft_engine::commands::find(command)?; let mode = curve_mode(app, kind); @@ -342,7 +369,7 @@ fn combined_lab_target(fields: &Map) -> bool { /// adjustment's shortcut, ⌥⌘L / ⌥⌘M, #8900), or at the neutral ones when there are none or they /// don't fit the current colour mode. pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { - let id = open(app, command)?; + let id = open_neutral(app, command)?; let kind = command.strip_prefix(PREFIX).unwrap_or_default(); let mode = curve_mode(app, kind); let Some(last) = app.session.prefs().dialogs.get(command).cloned() else { return Some(id) }; @@ -372,6 +399,22 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { Some(id) } +/// `params` of the adjustment `command` with the keys the dialog would otherwise fill in wrongly: +/// a recorded Photo Filter that names only its `filter` gets that preset's `color`, not the +/// dialog's neutral one. Every recorded value is kept as it was (a round trip through the engine +/// would rewrite them: float colours as hex, a pencil curve smoothed again). `None` when nothing +/// needs adding, or for params the engine rejects. +pub(crate) fn complete_params(app: &PhotocraftApp, command: &str, params: &Value) -> Option { + let kind = command.strip_prefix(PREFIX).filter(|k| *k == "photoFilter")?; + // A `null` colour reads as none, as the engine reads it. + let recorded = params.as_object().filter(|p| p.contains_key("filter") && p.get("color").is_none_or(Value::is_null))?; + let adjustment = photocraft_engine::adjust_params::from_params(kind, params, None, curve_mode(app, kind)).ok()?; + let color = photocraft_engine::adjust_params::to_params(&adjustment).get("color").cloned()?; + let mut done = recorded.clone(); + done.insert("color".into(), color); + Some(Value::Object(done)) +} + /// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a /// smart filter's settings is not a use of the dialog's own (as `filter_dialog::remember`). pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map) { @@ -430,6 +473,12 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map(mode)), Some(false), "Filter is chosen for the preset colour"); + // That opening is cancelled with Filter chosen; ⌥ brings the custom colour back with Color. + h.state_mut().ui.close_dialog(id); + h.run_steps(1); + let id = open_last(h.state_mut(), cmd).unwrap(); + h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["color"], json!("#123456")); + assert_eq!(h.ctx.data(|d| d.get_temp::(mode)), Some(true), "Color is chosen for the custom colour"); +} + +/// #9879 review: a recorded Photo Filter that names only its preset completes to that preset's +/// colour, so editing it as a smart filter doesn't swap in the dialog's neutral colour. Recorded +/// values are kept as they were, and nothing else is rewritten. +#[test] +fn recorded_photo_filter_settings_complete_to_their_preset_colour() { + let app = app_with_image(); + let pf = "image.adjustments.photoFilter"; + let done = complete_params(&app, pf, &json!({"filter": "cooling80", "density": 60, "extra": [1]})).unwrap(); + assert_eq!(done, json!({"filter": "cooling80", "density": 60, "extra": [1], "color": "#006dff"}), "only the colour is added"); + // A recorded colour (here a float triple the engine would rewrite as hex) is left alone. + assert!(complete_params(&app, pf, &json!({"filter": "cooling80", "color": [0.5, 0.0, 0.0]})).is_none()); + let null = complete_params(&app, pf, &json!({"filter": "cooling80", "color": null})).unwrap(); + assert_eq!(null["color"], json!("#006dff"), "a null colour completes from the preset, as the engine reads it"); + assert!(complete_params(&app, pf, &json!({"density": 60})).is_none(), "no preset to take a colour from"); + assert!(complete_params(&app, pf, &json!({"filter": "nope"})).is_none(), "rejected params"); + // Other adjustments keep their recorded values exactly (a pencil curve isn't smoothed again). + let curves = json!({"pencil": true, "smooth": 2, "rgb": [0, 255]}); + assert!(complete_params(&app, "image.adjustments.curves", &curves).is_none()); + assert!(complete_params(&app, "filter.blur.gaussian", &json!({"radius": 2})).is_none(), "not an adjustment"); +} + #[test] fn captured_filter_mask_curves_stay_gray_after_selecting_native_channels() { let mut app = app_with_image(); diff --git a/crates/ui-egui/src/dialogs/adjust_editors.rs b/crates/ui-egui/src/dialogs/adjust_editors.rs index 640991fdb0..c3062fdb7c 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors.rs @@ -31,6 +31,7 @@ mod levels; mod selective_color; mod session; +pub(crate) use basic::PHOTO_FILTER_MODE; #[cfg(test)] pub(crate) use basic::row_value; #[cfg(test)] @@ -75,6 +76,12 @@ pub fn has_editor(kind: &str) -> bool { KINDS.contains(&kind) } +/// Forget Photo Filter's Filter / Color choice in the editor memory `mem`, so the editor derives it +/// from the colour again: a choice changed and then cancelled must not outlive the dialog (#9879). +pub(crate) fn forget_photo_filter_mode(ctx: &egui::Context, mem: egui::Id) { + ctx.data_mut(|d| d.remove::(mem.with(PHOTO_FILTER_MODE))); +} + /// Kinds that draw a histogram behind their controls. pub fn needs_histogram(kind: &str) -> bool { matches!(kind, "levels" | "curves" | "threshold") diff --git a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs index 6a0e9694b6..e10d32652b 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs @@ -194,12 +194,16 @@ pub(super) fn black_white(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Ed // --------------------------------------------------------------------------------------------- // Photo Filter +/// The editor memory key of Photo Filter's Filter / Color choice (unset: Color for a colour that +/// is no preset). +pub(crate) const PHOTO_FILTER_MODE: &str = "pf-mode"; + pub(super) fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let mut e = Edit::default(); let mut c = rgb_of(v, "color", [236.0 / 255.0, 138.0 / 255.0, 0.0]); let hex = adjust_params::hex(c); let preset = PHOTO_FILTERS.iter().find(|f| adjust_params::hex(f.2.map(|x| f32::from(x) / 255.0)) == hex).map(|f| f.0); - let mode_id = cx.mem.with("pf-mode"); + let mode_id = cx.mem.with(PHOTO_FILTER_MODE); let mut use_color: bool = ui.data(|d| d.get_temp(mode_id)).unwrap_or(preset.is_none()); ui.horizontal(|ui| { if ui.radio(!use_color, tl!("Filter:")).clicked() { diff --git a/crates/ui-egui/src/dialogs/adjust_preview.rs b/crates/ui-egui/src/dialogs/adjust_preview.rs index 8238ddeb7b..49cecdc51b 100644 --- a/crates/ui-egui/src/dialogs/adjust_preview.rs +++ b/crates/ui-egui/src/dialogs/adjust_preview.rs @@ -191,7 +191,10 @@ fn dialog(app: &PhotocraftApp) -> Option<(String, Value, bool)> { } let kind = d.fields.get("__adjust")?.as_str()?.to_string(); let preview = d.fields.get("__preview").and_then(Value::as_bool).unwrap_or(true); - Some((kind, crate::filter_dialog::params_of(&d.fields), preview)) + let params = crate::filter_dialog::params_of(&d.fields); + let command = format!("image.adjustments.{kind}"); + crate::actions::authorize_panel_preview(app, d.id, &d.fields, &command, ¶ms).ok()?; + Some((kind, params, preview)) } /// Whether Image › Adjustments `kind` on document `idx` would edit a channel (the Channels panel's diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui.rs b/crates/ui-egui/src/dialogs/camera_raw_ui.rs index 1b71ef3306..c32240b4cb 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui.rs @@ -30,6 +30,7 @@ use crate::widgets; mod controls; mod convert; +mod filmstrip; mod menu; mod open; mod proxy; @@ -38,6 +39,7 @@ mod sidecar; pub use controls::show; #[cfg(test)] use convert::open_pixels; +pub use filmstrip::HeldRaw; pub use menu::menu; #[cfg(test)] use open::{Coverage, commit_open_raw, document_index, finish_redevelop, open_raw, replace_raw_document}; diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs index dee34b3cc9..d764ad987f 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs @@ -1,4 +1,4 @@ -use super::open::{Target, cancel, commit_open_raw, open_raw, raw_stage}; +use super::open::{Target, cancel, raw_stage}; use super::sidecar::{load_settings, report_file_error, save_settings}; use super::{ Align2, BANDS, CameraRaw, CameraRawDialog, Color32, ERect, FontId, PANEL_W, PhotocraftApp, Pos2, Sense, Stroke, Tokens, ToneZone, Value, Wheel, curve_lut, @@ -69,7 +69,7 @@ pub(super) fn commit(app: &mut PhotocraftApp) -> Result { Target::OpenRaw => { let raw = d.raw_open.clone().ok_or("no raw file is being opened")?; let (params, original) = (d.params.clone(), d.original.clone()); - commit_open_raw(app, ¶ms, &raw, Some(&original))? + super::filmstrip::open_all(app, ¶ms, &raw, &original)? } }; app.camera_raw = None; @@ -188,10 +188,11 @@ fn curve_editor(ui: &mut egui::Ui, p: &mut CameraRaw, dirty: &mut bool, state: & pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.camera_raw.is_none() - && let Some(raw) = app.pending_raw_open.take() - && let Err(e) = open_raw(app, ctx, raw) + && !app.raw_shelf.is_empty() + && let Err(e) = super::filmstrip::open_held(app, ctx, 0) { // The document stays open, developed with the defaults. + app.raw_shelf.remove(0); app.ui.status = e; app.ui.status_error = true; } @@ -241,6 +242,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let screen = ctx.content_rect(); let current_display = app.session.active().and_then(|st| app.session.color.canvas_display(&st.doc).ok()); let mut action: Option<&str> = None; + let mut show_held: Option = None; egui::Area::new(egui::Id::unique("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.camera_raw.as_mut() else { return }; history_keys(d, ctx); @@ -278,7 +280,16 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let animated_zoom = app.session.prefs().general.animated_zoom; let scope = &mut app.ui.camera_raw_scope; // Clip both pixels and scope overlays before painting the settings dock/footer. - let preview_body = ERect::from_min_max(body.min, pos2((body.right() - PANEL_W).max(body.left() + 1.0), body.bottom())); + // Several raws being opened: the filmstrip on the left. + let film_w = if d.target == Target::OpenRaw && app.raw_shelf.len() > 1 { super::filmstrip::FILM_W } else { 0.0 }; + if film_w > 0.0 { + let film = ERect::from_min_max(body.min, pos2(body.left() + film_w - 1.0, body.bottom())); + painter.rect_filled(film, 0.0, t.dock); + painter.line_segment([film.right_top(), film.right_bottom()], Stroke::new(1.0, t.separator)); + show_held = super::filmstrip::filmstrip(ui, film, &mut app.raw_shelf, d.raw_open.as_ref().map(|r| r.document), &t); + } + let preview_body = + ERect::from_min_max(pos2(body.left() + film_w, body.top()), pos2((body.right() - PANEL_W).max(body.left() + film_w + 1.0), body.bottom())); let view = ERect::from_min_max( preview_body.min + vec2(16.0, 16.0), pos2((preview_body.right() - 16.0).max(preview_body.left() + 17.0), (preview_body.bottom() - 48.0).max(preview_body.top() + 17.0)), @@ -476,6 +487,15 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { ) { action = Some(if role == widgets::ButtonRole::Default { "ok" } else { "cancel" }); } + // Done (open-time dialog): keep the settings in the raws' sidecars, open nothing. + if d.target == Target::OpenRaw + && fu + .add_sized([90.0, 28.0], egui::Button::new(tl!("Done"))) + .on_hover_text(tl!("Save the settings to each raw's sidecar without opening it")) + .clicked() + { + action = Some("done"); + } fu.add_space(12.0); widgets::checkbox(&mut fu, &mut d.show_before, tl!("Before (Y)")); fu.label(egui::RichText::new(format!("{:.0} ms", d.render_ms)).color(t.text_faint)); @@ -496,6 +516,12 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } }); crate::camera_raw_scope_ui::persist(app, ctx); + if let Some(i) = show_held + && let Err(e) = super::filmstrip::show_raw(app, ctx, i) + { + app.ui.status = e; + app.ui.status_error = true; + } if ctx.input(|i| i.key_pressed(egui::Key::Y)) && let Some(d) = app.camera_raw.as_mut() { @@ -511,6 +537,9 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } } Some("cancel") => cancel(app), + Some("done") => { + super::filmstrip::done(app); + } Some("save") => { let r = save_settings(app); report_file_error(app, r); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/convert.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/convert.rs index 908f01e1f1..313110eaca 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/convert.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/convert.rs @@ -48,24 +48,10 @@ pub(super) fn apply_raw_conversions(app: &mut PhotocraftApp, index: usize, d: &p Ok(()) } -pub(super) fn open_pixels( - app: &mut PhotocraftApp, - ctx: &egui::Context, - layer: LayerId, - surf: photocraft_raster::Surface, - params: CameraRaw, - coverage: Coverage, - target: Target, -) -> Result<(), String> { - let st = app.session.active().ok_or("no document")?; - let canvas = st.doc.bounds(); - let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); - let mut params = params; - if params.encoding_gamma == 0.0 { - params.encoding_gamma = photocraft_engine::lens_cmds::encoding_gamma(&st.doc); - } - // Use the engine filter domain, including pixels outside the canvas: spatial Camera Raw - // stages (vignette, grain, local contrast) must agree with the committed result. +/// What the preview develops, and its size: the engine filter domain, including pixels outside the +/// canvas, since spatial Camera Raw stages (vignette, grain, local contrast) must agree with the +/// committed result. +pub(super) fn preview_area(canvas: Rect, surf: &photocraft_raster::Surface) -> Result<(Rect, usize, usize), String> { let area = canvas.union(&surf.content_bounds()); let width = area.x1.checked_sub(area.x0).filter(|n| (1..=1_048_576).contains(n)); let height = area.y1.checked_sub(area.y0).filter(|n| (1..=1_048_576).contains(n)); @@ -76,6 +62,12 @@ pub(super) fn open_pixels( if w.checked_mul(h).is_none_or(|pixels| pixels > 512_000_000) { return Err("Camera Raw preview bounds are too large".into()); } + Ok((area, w, h)) +} + +/// `area` of `surf` (`w`×`h`) box-averaged down to at most [`PROXY_SIDE`] pixels on its long side: +/// the proxy, its size, and the side of the blocks averaged. +pub(super) fn box_proxy(surf: &photocraft_raster::Surface, area: Rect, w: usize, h: usize) -> (Vec<[f32; 4]>, usize, usize, usize) { let k = w.max(h).div_ceil(PROXY_SIDE).max(1); let (pw, ph) = (w.div_ceil(k), h.div_ceil(k)); let mut proxy = vec![[0.0f32; 4]; pw * ph]; @@ -98,6 +90,27 @@ pub(super) fn open_pixels( proxy[py * pw + x] = [a[0] / a[4], a[1] / a[4], a[2] / a[4], a[3] / a[4]]; } } + (proxy, pw, ph, k) +} + +pub(super) fn open_pixels( + app: &mut PhotocraftApp, + ctx: &egui::Context, + layer: LayerId, + surf: photocraft_raster::Surface, + params: CameraRaw, + coverage: Coverage, + target: Target, +) -> Result<(), String> { + let st = app.session.active().ok_or("no document")?; + let canvas = st.doc.bounds(); + let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); + let mut params = params; + if params.encoding_gamma == 0.0 { + params.encoding_gamma = photocraft_engine::lens_cmds::encoding_gamma(&st.doc); + } + let (area, w, h) = preview_area(canvas, &surf)?; + let (proxy, pw, ph, k) = box_proxy(&surf, area, w, h); // Coverage at the centre of each proxy block. let sample = |value: &dyn Fn(i32, i32) -> f32| { (0..ph) diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/filmstrip.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/filmstrip.rs new file mode 100644 index 0000000000..1e35869dbe --- /dev/null +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/filmstrip.rs @@ -0,0 +1,290 @@ +//! The open-time dialog's filmstrip: raws opened together wait side by side in one Camera Raw +//! dialog, as in Photoshop's. Each keeps its own settings and demosaic choice when another is +//! shown, Open develops every one and Cancel closes every one. + +use super::open::{Target, commit_open_raw, open_raw_with, return_on_failure}; +use super::sidecar::read_sidecar; +use super::{ + Align2, CameraRaw, CameraRawDialog, Color32, ERect, FontId, PhotocraftApp, RawOpen, Rect, Sense, Stroke, Tokens, Value, json, pos2, vec2, widgets, +}; + +/// The filmstrip column, shown when more than one raw is being opened. +pub(super) const FILM_W: f32 = 150.0; +/// Long side of a filmstrip thumbnail, in pixels. +const THUMB_SIDE: usize = 240; + +/// A raw waiting in the open-time dialog: opened (developed with the defaults) and reviewed +/// before Open keeps it. +pub struct HeldRaw { + pub raw: RawOpen, + /// Its settings and the settings it started from (its sidecar's, or the defaults) once it + /// has been shown and left for another (`None`: not edited yet). + pub(super) kept: Option<(CameraRaw, CameraRaw)>, + /// The filmstrip thumbnail until it is first drawn, then its texture. + pub(super) thumb: Option, + tex: Option, +} + +impl HeldRaw { + pub(super) fn new(app: &PhotocraftApp, raw: RawOpen) -> Self { + let thumb = super::open::document_index(app, raw.document).and_then(|i| thumbnail(app, i)); + HeldRaw { raw, kept: None, thumb, tex: None } + } +} + +/// A filmstrip thumbnail of document `index` (a just-developed raw: its first pixel layer). +fn thumbnail(app: &PhotocraftApp, index: usize) -> Option { + let doc = &app.session.documents().get(index)?.doc; + let surf = doc.layers.iter().find_map(|l| l.surface())?; + let area = doc.bounds(); + let (w, h) = (area.width() as usize, area.height() as usize); + if w == 0 || h == 0 { + return None; + } + let k = w.max(h).div_ceil(THUMB_SIDE).max(1); + let (tw, th) = (w.div_ceil(k), h.div_ceil(k)); + let mut acc = vec![[0.0f32; 5]; tw * th]; + let mut row = vec![[0.0f32; 4]; w]; + for y in (0..h).step_by(k.div_ceil(2).max(1)) { + surf.read_rgba_into(Rect::new(area.x0, area.y0 + y as i32, area.x1, area.y0 + y as i32 + 1), &mut row); + for (x, q) in row.iter().enumerate().step_by(k.div_ceil(2).max(1)) { + let a = &mut acc[(y / k) * tw + x / k]; + for c in 0..4 { + a[c] += q[c]; + } + a[4] += 1.0; + } + } + let px: Vec<[f32; 4]> = acc.iter().map(|a| if a[4] > 0.0 { [a[0] / a[4], a[1] / a[4], a[2] / a[4], a[3] / a[4]] } else { [0.0; 4] }).collect(); + let display = app.session.color.canvas_display(doc).ok()?; + Some(CameraRawDialog::image(&px, tw, th, &display)) +} + +/// `px` (`w`×`h`) box-averaged down to at most [`THUMB_SIDE`] pixels on its long side. +fn shrink(px: &[[f32; 4]], w: usize, h: usize) -> (Vec<[f32; 4]>, usize, usize) { + let k = w.max(h).div_ceil(THUMB_SIDE).max(1); + let (tw, th) = (w.div_ceil(k), h.div_ceil(k)); + let mut acc = vec![[0.0f32; 5]; tw * th]; + for (i, q) in px.iter().enumerate().take(w * h) { + if let Some(a) = acc.get_mut((i / w / k) * tw + (i % w) / k) { + for c in 0..4 { + a[c] += q[c]; + } + a[4] += 1.0; + } + } + (acc.iter().map(|a| if a[4] > 0.0 { [a[0] / a[4], a[1] / a[4], a[2] / a[4], a[3] / a[4]] } else { [0.0; 4] }).collect(), tw, th) +} + +/// Shows raw `i` of the filmstrip, with the settings it was left with (its sidecar's, the +/// Camera Raw Defaults' Auto, or the defaults the first time). +pub(super) fn open_held(app: &mut PhotocraftApp, ctx: &egui::Context, i: usize) -> Result<(), String> { + let held = app.raw_shelf.get(i).ok_or("no raw file at that place in the filmstrip")?; + let (raw, kept) = (held.raw.clone(), held.kept.clone()); + open_raw_with(app, ctx, raw, kept) +} + +/// Shows raw `to` of the filmstrip; the one being left keeps its settings, and its thumbnail +/// shows them. +pub(super) fn show_raw(app: &mut PhotocraftApp, ctx: &egui::Context, to: usize) -> Result<(), String> { + let Some(d) = app.camera_raw.as_ref() else { return Ok(()) }; + let current = d.raw_open.as_ref().map(|r| r.document); + if d.target != Target::OpenRaw || app.raw_shelf.get(to).is_none_or(|h| Some(h.raw.document) == current) { + return Ok(()); + } + keep_settings(app); + // On success this replaces the dialog. On failure the dialog stays on the raw being left, and + // the other keeps its place in the filmstrip. + let active = app.session.active_index(); + let shown = open_held(app, ctx, to); + if shown.is_err() + && let Some(i) = active + { + app.session.set_active(i); + } + shown +} + +/// Stores the dialog's settings (and demosaic choice) on its filmstrip raw, and refreshes the +/// raw's thumbnail from the preview. +pub(super) fn keep_settings(app: &mut PhotocraftApp) { + let Some(d) = app.camera_raw.as_ref() else { return }; + let Some(raw) = &d.raw_open else { return }; + let Some(held) = app.raw_shelf.iter_mut().find(|h| h.raw.document == raw.document) else { return }; + held.kept = Some((d.params.clone(), d.original.clone())); + held.raw.demosaic = raw.demosaic; + // The preview may not have caught up with the last edit: the thumbnail shows what was kept. + let frame = (d.dirty || d.render_pending()).then(|| d.proxy_job().run().px); + let developed = frame.as_deref().unwrap_or(&d.processed); + if !developed.is_empty() { + let (px, w, h) = shrink(developed, d.pw, d.ph); + held.thumb = Some(CameraRawDialog::image(&px, w, h, &d.display)); + held.tex = None; + } +} + +/// The settings Open develops a never-shown filmstrip raw with, and what they started from: +/// its sidecar's, else the Camera Raw Defaults' Auto when that is on, else the defaults. +pub(super) fn first_settings(app: &PhotocraftApp, raw: &RawOpen) -> (CameraRaw, Option) { + if let Some((loaded, _, _)) = raw.path.as_deref().and_then(read_sidecar) { + return (loaded.clone(), Some(loaded)); + } + let mut p = CameraRaw::default(); + // From the proxy the dialog would use, so showing a raw doesn't change what Auto gives it. + if app.session.prefs().raw_defaults.apply_auto_tone + && let Some(st) = super::open::document_index(app, raw.document).and_then(|i| app.session.documents().get(i)) + && let Some(surf) = st.doc.layers.iter().find_map(|l| l.surface()) + && let Ok((area, w, h)) = super::convert::preview_area(st.doc.bounds(), surf) + { + let (proxy, ..) = super::convert::box_proxy(surf, area, w, h); + photocraft_algo::camera_raw::auto_tone(&proxy, &mut p); + } + (p, None) +} + +/// Open with raws in the filmstrip: the shown one as the dialog has it, then every other with +/// its own settings. One reply for one raw, `{"opened": [...]}` for several. A raw leaves the +/// filmstrip once it has opened or its background develop has started: one that fails stays or +/// comes back there with its settings, so the dialog returns for it (or Cancel still closes it). +pub(super) fn open_all(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, original: &CameraRaw) -> Result { + keep_settings(app); + let others: Vec = app.raw_shelf.iter().map(|h| h.raw.document).filter(|id| *id != raw.document).collect(); + let first = commit_open_raw(app, params, raw, Some(original)); + if let Ok(v) = &first { + return_on_failure(app, v, original); + app.raw_shelf.retain(|h| h.raw.document != raw.document); + } + if others.is_empty() { + return first; + } + let mut opened = Vec::new(); + let mut failed = Vec::new(); + match first { + Ok(v) => opened.push(v), + Err(e) => failed.push(format!("{}: {e}", raw.name)), + } + for id in others { + let Some(held) = app.raw_shelf.iter().find(|h| h.raw.document == id) else { continue }; + let (held_raw, kept) = (held.raw.clone(), held.kept.clone()); + let (p, loaded) = match kept { + Some((p, original)) => (p, Some(original)), + None => first_settings(app, &held_raw), + }; + match commit_open_raw(app, &p, &held_raw, loaded.as_ref()) { + Ok(v) => { + return_on_failure(app, &v, loaded.as_ref().unwrap_or(&CameraRaw::default())); + opened.push(v); + app.raw_shelf.retain(|h| h.raw.document != id); + } + Err(e) => failed.push(format!("{}: {e}", held_raw.name)), + } + } + if !failed.is_empty() { + let e = failed.join("; "); + if opened.is_empty() { + return Err(e); + } + crate::notices::error(app, e); + } + Ok(json!({"opened": opened})) +} + +/// Done: saves each raw's settings to its sidecar, as Camera Raw's Done does, and opens none of +/// them (they close again). +pub(super) fn done(app: &mut PhotocraftApp) -> Value { + use super::sidecar::Stored; + keep_settings(app); + let Some(d) = app.camera_raw.as_ref() else { return Value::Null }; + let Some(raw) = d.raw_open.clone() else { return Value::Null }; + let mut raws = vec![(raw.clone(), d.params.clone())]; + for held in app.raw_shelf.iter().filter(|h| h.raw.document != raw.document) { + let p = match &held.kept { + Some((p, _)) => p.clone(), + None => first_settings(app, &held.raw).0, + }; + raws.push((held.raw.clone(), p)); + } + let stored: Vec = raws.iter().map(|(raw, p)| super::sidecar::store_sidecar(app, raw, p)).collect(); + let saved: Vec<&String> = stored.iter().filter_map(|s| if let Stored::Written(name) = s { Some(name) } else { None }).collect(); + // A failed write (already in a notice) keeps the dialog and every raw open, so no edit is lost. + if stored.contains(&Stored::Failed) { + app.ui.status = tl!("Some settings could not be saved; the raws stay open").to_string(); + app.ui.status_error = true; + return json!({"done": false, "saved": saved}); + } + app.ui.status = if !saved.is_empty() { + crate::i18n::fmt(tl!("Saved Camera Raw settings to {file}"), &[("file", &saved.iter().map(|s| s.as_str()).collect::>().join(", "))]) + } else if stored.iter().all(|s| *s == Stored::Defaults) { + tl!("Nothing to save: these raws are at their default settings").to_string() + } else { + tl!("No settings saved: these raws can't keep a sidecar here").to_string() + }; + app.ui.status_error = saved.is_empty() && stored.contains(&Stored::Skipped); + let saved = json!(saved); + app.camera_raw = None; + app.raw_shelf.clear(); + for (raw, _) in &raws { + if let Some(index) = super::open::document_index(app, raw.document) { + app.session.close(index); + } + } + app.sync_views(); + json!({"done": true, "saved": saved}) +} + +/// The filmstrip of raws being opened: a thumbnail and name each, the shown one outlined, a dot +/// on edited ones. Returns the one clicked. +pub(super) fn filmstrip(ui: &mut egui::Ui, rect: ERect, shelf: &mut [HeldRaw], current: Option, t: &Tokens) -> Option { + let mut clicked = None; + let mut film = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(6.0, 6.0)))); + film.set_clip_rect(rect.intersect(ui.clip_rect())); + egui::ScrollArea::vertical().id_salt("camera-raw-filmstrip").auto_shrink([false, false]).show(&mut film, |ui| { + ui.spacing_mut().item_spacing.y = 6.0; + let w = ui.available_width(); + for (i, held) in shelf.iter_mut().enumerate() { + if held.tex.is_none() + && let Some(img) = held.thumb.take() + { + held.tex = Some(ui.ctx().load_texture(format!("camera-raw-thumb-{}", held.raw.document.0), img, egui::TextureOptions::LINEAR)); + } + let (tile, response) = ui.allocate_exact_size(vec2(w, w * 0.75 + 18.0), Sense::click()); + let frame = ERect::from_min_size(tile.min, vec2(w, w * 0.75)); + let p = ui.painter(); + p.rect_filled(frame, 2.0, if response.hovered() { t.hover } else { t.canvas }); + if let Some(tex) = &held.tex { + let size = tex.size_vec2(); + let fit = ((frame.width() - 8.0) / size.x.max(1.0)).min((frame.height() - 8.0) / size.y.max(1.0)); + let r = ERect::from_center_size(frame.center(), size * fit); + widgets::checker(p, r, 6.0); + p.image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + if Some(held.raw.document) == current { + p.rect_stroke(frame, 2.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + } + if held.kept.as_ref().is_some_and(|(p, _)| !p.is_identity()) { + p.circle_filled(frame.right_top() + vec2(-7.0, 7.0), 3.0, t.text); + } + let name = ERect::from_min_max(pos2(tile.left(), frame.bottom()), tile.max); + let font = FontId::proportional(10.5); + let label = tail_fitting(p, &held.raw.name, font.clone(), name.width()); + p.with_clip_rect(name).text(name.center(), Align2::CENTER_CENTER, label, font, t.text_dim); + let response = response.on_hover_text(&held.raw.name); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, Some(held.raw.document) == current, &held.raw.name)); + if response.clicked() { + clicked = Some(i); + } + } + }); + clicked +} + +/// `name`, or "…" and as much of its end as fits in `width`: raws from one shoot differ at the +/// end of their names (the frame number). +fn tail_fitting(p: &egui::Painter, name: &str, font: FontId, width: f32) -> String { + let fits = |s: &str| p.layout_no_wrap(s.to_owned(), font.clone(), Color32::WHITE).size().x <= width; + if fits(name) { + return name.to_owned(); + } + let chars: Vec = name.chars().collect(); + (1..chars.len()).map(|start| format!("…{}", chars[start..].iter().collect::())).find(|s| fits(s)).unwrap_or_default() +} diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs index a66e172158..b8c75a94e1 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs @@ -149,7 +149,7 @@ fn queue_identity_targets_an_inactive_raw_without_changing_the_active_document() app.session.set_active(0); assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); queue_open_dialog(&mut app, raw.document, &raw.name, raw.path.as_deref(), raw.bytes.as_deref().map(Vec::as_slice), &[]).unwrap(); - let queued = app.pending_raw_open.as_ref().expect("the explicitly named RAW was queued"); + let queued = app.raw_shelf.first().map(|h| &h.raw).expect("the explicitly named RAW was queued"); assert_eq!(queued.document, raw.document); assert_eq!((&queued.name, &queued.path, &queued.bytes), (&raw.name, &raw.path, &raw.bytes)); assert_eq!(app.session.active().unwrap().doc.id, control.doc.id, "queueing does not activate the target"); @@ -160,12 +160,12 @@ fn queue_identity_targets_an_inactive_raw_without_changing_the_active_document() fn queue_identity_rejects_a_missing_document_without_replacing_the_pending_review() { let (mut app, raw, control) = identity_app(); queue_open_dialog(&mut app, raw.document, &raw.name, raw.path.as_deref(), raw.bytes.as_deref().map(Vec::as_slice), &[]).unwrap(); - let pending = app.pending_raw_open.as_ref().unwrap().clone(); + let pending = app.raw_shelf.first().map(|h| &h.raw).unwrap().clone(); app.session.set_active(0); let missing = photocraft_doc::DocId::fresh(); assert!(!app.session.documents().iter().any(|state| state.doc.id == missing)); assert!(queue_open_dialog(&mut app, missing, "missing.dng", Some("missing.dng"), Some(&[1, 2, 3]), &[]).is_err()); - let queued = app.pending_raw_open.as_ref().expect("the existing review is preserved"); + let queued = app.raw_shelf.first().map(|h| &h.raw).expect("the existing review is preserved"); assert_eq!(queued.document, pending.document); assert_eq!((&queued.name, &queued.path, &queued.bytes), (&pending.name, &pending.path, &pending.bytes)); assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/menu.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/menu.rs index ead69eba2b..8aed0e5664 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/menu.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/menu.rs @@ -35,15 +35,16 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val for (key, value) in fields { match key.as_str() { "set" | "scope" | "view" | "demosaic" => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" if value.is_boolean() => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" => { + "before" | "commit" | "cancel" | "done" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" if value.is_boolean() => {} + "before" | "commit" | "cancel" | "done" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" => { return Some(Err(format!("Camera Raw {key} must be a boolean"))); } _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), } } - if ui.get("commit").and_then(Value::as_bool) == Some(true) && ui.get("cancel").and_then(Value::as_bool) == Some(true) { - return Some(Err("Camera Raw cannot commit and cancel together".into())); + let ends = ["commit", "cancel", "done"].into_iter().filter(|k| ui.get(*k).and_then(Value::as_bool) == Some(true)).count(); + if ends > 1 { + return Some(Err("Camera Raw takes one of commit, cancel and done".into())); } for (key, run) in [("saveSettings", save_settings as fn(&mut PhotocraftApp) -> Result), ("loadSettings", load_settings)] { if ui.get(key).and_then(Value::as_bool) == Some(true) { @@ -72,6 +73,9 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti // Stage both filter and presentation updates. No texture, preference or dialog mutation // occurs until every part of the request has passed validation. let d = app.camera_raw.as_ref()?; + if ui.get("done").and_then(Value::as_bool) == Some(true) && d.target != super::open::Target::OpenRaw { + return Some(Err("Done is for raws being opened".into())); + } let mut next_params = d.params.clone(); // Auto (the Light panel's button): the tone sliders from the image's histogram. if ui.get("auto").and_then(Value::as_bool) == Some(true) { @@ -165,5 +169,8 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti if ui.get("commit").and_then(Value::as_bool) == Some(true) { return Some(commit(app)); } + if ui.get("done").and_then(Value::as_bool) == Some(true) { + return Some(Ok(super::filmstrip::done(app))); + } Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null))) } diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs index 8e3a9c9de6..dbae1e6ee7 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs @@ -45,7 +45,8 @@ pub fn without_develop_note(warnings: &[String]) -> Vec { } /// Queues the open-time dialog for the document whose import completed, independent of the -/// active tab. A delayed completion for a closed document must not review another photograph. +/// active tab: the dialog shows on the next frame, or adds it to its filmstrip when it is already +/// open. A delayed completion for a closed document must not review another photograph. /// `warnings` are the import's: its note says which demosaic method the open developed with. pub fn queue_open_dialog( app: &mut PhotocraftApp, @@ -61,22 +62,42 @@ pub fn queue_open_dialog( // The method the open actually used (`jobs::import_options` at the job's start), from its // note: the preference may have changed while a background open ran. let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); - app.pending_raw_open = Some(RawOpen { + if app.raw_shelf.iter().any(|h| h.raw.document == document) { + return Ok(()); + } + let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), developed_with: method, demosaic: method, - }); + }; + let held = super::filmstrip::HeldRaw::new(app, raw); + app.raw_shelf.push(held); Ok(()) } -/// Opens the queued open-time dialog on its document's (only) layer. +/// Opens the open-time dialog on a queued raw's (only) layer. +#[cfg(test)] pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Result<(), String> { + open_raw_with(app, ctx, raw, None) +} + +/// [`open_raw`], or with `kept` settings and the settings they started from (a filmstrip raw +/// shown again). +pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen, kept: Option<(CameraRaw, CameraRaw)>) -> Result<(), String> { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; + if let Some((params, original)) = kept { + open_pixels(app, ctx, layer, surf, params, Coverage::None, Target::OpenRaw)?; + if let Some(d) = app.camera_raw.as_mut() { + d.original = original; + } + attach_raw(app, raw); + return Ok(()); + } // The file's sidecar (`IMG_0001.xmp` beside it) fills the dialog, as Camera Raw's does; the // status bar names what it could not apply. let mut params = CameraRaw::default(); @@ -103,6 +124,12 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe photocraft_algo::camera_raw::auto_tone(&d.proxy, &mut d.params); d.dirty = true; } + attach_raw(app, raw); + Ok(()) +} + +/// Gives the open dialog its raw: the file name, and the camera tone curve it was developed with. +fn attach_raw(app: &mut PhotocraftApp, raw: RawOpen) { let curve = match (&raw.bytes, &raw.path) { (Some(b), _) => Some(photocraft_io::raw::tone_curve(b)), #[cfg(not(target_arch = "wasm32"))] @@ -117,7 +144,6 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe d.raw_curve = curve; d.raw_open = Some(raw); } - Ok(()) } pub(super) fn document_index(app: &PhotocraftApp, id: photocraft_doc::DocId) -> Option { @@ -153,6 +179,9 @@ pub struct Redevelop { filter_step: bool, /// The Camera Raw Defaults the open started with, for the conversions after the filter step. defaults: photocraft_engine::prefs::RawDefaults, + /// The dialog's Open started this, from these settings: if the re-develop fails, the raw goes + /// back to the filmstrip to be tried again or cancelled. + back_to_filmstrip: Option, } /// The sliders a raw open applies while developing, before the tone curve (as Camera Raw does). @@ -225,7 +254,7 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: } photocraft_engine::jobs::Started::Job(job) => { let defaults = app.session.prefs().raw_defaults.clone(); - app.raw_redevelop.push(Redevelop { job, params: params.clone(), raw: raw.clone(), filter_step: false, defaults }); + app.raw_redevelop.push(Redevelop { job, params: params.clone(), raw: raw.clone(), filter_step: false, defaults, back_to_filmstrip: None }); app.ui.status = label; app.ui.status_error = false; Ok(json!({"job": job.0, "pending": true})) @@ -233,6 +262,15 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: } } +/// `reply` is [`commit_open_raw`]'s from the dialog's Open: if it started a background re-develop +/// and that fails, the raw comes back to the filmstrip, its settings having started from `original`. +pub(super) fn return_on_failure(app: &mut PhotocraftApp, reply: &Value, original: &CameraRaw) { + let job = reply.get("job").and_then(Value::as_u64); + if let Some(pending) = app.raw_redevelop.iter_mut().find(|r| Some(r.job.0) == job && !r.filter_step) { + pending.back_to_filmstrip = Some(original.clone()); + } +} + /// Swap the default-developed document `doc_id` for the re-developed one, at the same tab /// position and with the same path. Returns `{document, docId, previous, warnings, wb}`: /// `document` is the tab index at apply time, `docId` is the replacement's stable identity. @@ -324,7 +362,7 @@ fn finish_open_raw( && let Some(job) = r.get("job").and_then(Value::as_u64) { let job = photocraft_engine::jobs::JobId(job); - app.raw_redevelop.push(Redevelop { job, params: rest.clone(), raw: raw.clone(), filter_step: true, defaults: d.clone() }); + app.raw_redevelop.push(Redevelop { job, params: rest.clone(), raw: raw.clone(), filter_step: true, defaults: d.clone(), back_to_filmstrip: None }); result["filter"] = r; mark_unmodified(app, index); return Ok(result); @@ -373,7 +411,7 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, /// Every k-th pixel of a surface's content, k chosen so the longer side has about `max_side` /// samples: enough for a histogram (Auto tone) without reading a whole raw. -fn sample_pixels(surf: &photocraft_raster::Surface, max_side: usize) -> Vec<[f32; 4]> { +pub(super) fn sample_pixels(surf: &photocraft_raster::Surface, max_side: usize) -> Vec<[f32; 4]> { let area = surf.content_bounds(); let (w, h) = (area.x1.saturating_sub(area.x0).max(0) as usize, area.y1.saturating_sub(area.y0).max(0) as usize); if w == 0 || h == 0 { @@ -435,7 +473,15 @@ pub fn on_redevelop_event(app: &mut PhotocraftApp, e: &photocraft_engine::jobs:: Err(err) => crate::notices::error(app, format!("{}: {err}", e.label)), }, photocraft_engine::jobs::JobOutcome::Failed(err) => { - // The document stays open, developed with the defaults. + // The document stays open, developed with the defaults; from the dialog, it goes back + // to the filmstrip with its settings, which may have no sidecar to keep them. + if let Some(original) = pending.back_to_filmstrip + && document_index(app, pending.raw.document).is_some() + { + let mut held = super::filmstrip::HeldRaw::new(app, pending.raw); + held.kept = Some((pending.params, original)); + app.raw_shelf.push(held); + } app.sync_views(); crate::notices::error(app, format!("{}: {err}", e.label)); } @@ -447,13 +493,18 @@ pub fn on_redevelop_event(app: &mut PhotocraftApp, e: &photocraft_engine::jobs:: true } -/// Cancel: closes the dialog; for the open-time dialog also the document it was opening. +/// Cancel: closes the dialog; for the open-time dialog also the documents it was opening (every +/// raw in its filmstrip). pub(super) fn cancel(app: &mut PhotocraftApp) { let Some(d) = app.camera_raw.take() else { return }; - if let (Target::OpenRaw, Some(raw)) = (d.target, &d.raw_open) - && let Some(index) = document_index(app, raw.document) - { - app.session.close(index); + if let (Target::OpenRaw, Some(raw)) = (d.target, &d.raw_open) { + let mut opening: Vec<_> = std::mem::take(&mut app.raw_shelf).into_iter().map(|h| h.raw.document).collect(); + opening.push(raw.document); + for id in opening { + if let Some(index) = document_index(app, id) { + app.session.close(index); + } + } app.sync_views(); app.ui.status = crate::i18n::fmt(tl!("Canceled opening {name}"), &[("name", &raw.name)]); app.ui.status_error = false; diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs index dfb03a13b9..9ac2f8c997 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs @@ -52,27 +52,49 @@ pub(super) fn write_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam if !app.session.prefs().raw_defaults.write_xmp_sidecars || loaded == Some(params) { return; } + store_sidecar(app, raw, params); +} + +/// What [`store_sidecar`] did. +#[derive(Debug, PartialEq)] +pub(super) enum Stored { + /// Written: the sidecar's name. + Written(String), + /// Nothing to keep: default settings and no sidecar of ours to update. + Defaults, + /// This raw can't keep a sidecar here: an automation request, a DNG or a raw without a path, + /// another application's sidecar, or no file writer. + Skipped, + /// The write failed (already reported in a notice). + Failed, +} + +/// Writes `params` to `raw`'s sidecar under [`write_sidecar`]'s rules, whatever the preference +/// (Done asks for it). +pub(super) fn store_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &CameraRaw) -> Stored { if app.automation_input || app.session.authorize.is_some() { log::info!("{}: sidecar not written for an automation request", raw.name); - return; + return Stored::Skipped; } - let Some(path) = raw.path.as_deref().filter(|p| !is_dng(p)) else { return }; + let Some(path) = raw.path.as_deref().filter(|p| !is_dng(p)) else { return Stored::Skipped }; let sidecar = sidecar_path(path); let name = crate::file_open::display_name(&sidecar); match existing_sidecar_is_ours(&sidecar) { Some(false) => { let kept = crate::i18n::fmt(tl!("Kept {name}: written by another application"), &[("name", &name)]); crate::notices::post(app, kept, Vec::new(), false, None); - return; + return Stored::Skipped; } - None if params.is_identity() => return, + None if params.is_identity() => return Stored::Defaults, _ => {} } let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes(); - let Some(write) = app.services.write.as_mut() else { return }; + let Some(write) = app.services.write.as_mut() else { return Stored::Skipped }; if let Err(e) = write(&sidecar, &bytes) { crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)])); + return Stored::Failed; } + Stored::Written(name) } /// `None` when there is no sidecar at `sidecar`, else whether PhotoCraft wrote it. One that diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs index bcae932f5e..9c817414bc 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -1,5 +1,10 @@ use super::*; +/// The first raw waiting for the open-time dialog, taken off its filmstrip. +fn take_queued(app: &mut PhotocraftApp) -> Option { + (!app.raw_shelf.is_empty()).then(|| app.raw_shelf.remove(0).raw) +} + /// #9550: a Color Mixer Hue track runs through the neighbouring bands' hues (Reds: magenta to /// orange, wrapping round), not from grey; Saturation and Luminance keep grey to the colour. #[test] @@ -358,7 +363,7 @@ fn raw_opens_in_camera_raw_and_open_redevelops() { // A raw has no embedded profile (ProPhoto RGB is the space it is developed into), so Ask // When Opening (on by default) has nothing to ask about. assert!(app.ui.dialogs.is_empty(), "no profile prompt for a raw"); - let raw = app.pending_raw_open.take().expect("a raw open queues the dialog"); + let raw = take_queued(&mut app).expect("a raw open queues the dialog"); open_raw(&mut app, &ctx, raw).unwrap(); let d = app.camera_raw.as_ref().unwrap(); assert_eq!(d.target, Target::OpenRaw); @@ -388,7 +393,7 @@ fn the_demosaic_method_is_chosen_at_open() { let ctx = egui::Context::default(); app.open_bytes("IMG_0002.dng", &dng).unwrap(); let ahd = pixels(&app); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let d = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "bilinear"}})).unwrap().unwrap(); assert_eq!(d["openingRaw"]["demosaic"], "bilinear"); @@ -405,7 +410,7 @@ fn the_demosaic_method_is_chosen_at_open() { app.run("prefs.set", json!({"path": "rawDefaults.demosaic", "value": "bilinear"})).unwrap(); app.open_bytes("IMG_0003.dng", &dng).unwrap(); assert_eq!(pixels(&app), bilinear, "developed with the preference's method"); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let d = app.camera_raw.as_ref().unwrap().describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview); assert_eq!(d["openingRaw"]["demosaic"], "bilinear"); @@ -419,11 +424,11 @@ fn the_demosaic_method_is_chosen_at_open() { // The preference may change while a background open runs: the dialog starts from the // method the open developed with (its note), not from the newer preference. let warnings = app.open_bytes("IMG_0004.dng", &dng).unwrap(); - app.pending_raw_open.take(); + take_queued(&mut app); app.run("prefs.set", json!({"path": "rawDefaults.demosaic", "value": "ahd"})).unwrap(); let document = app.session.active().unwrap().doc.id; queue_open_dialog(&mut app, document, "IMG_0004.dng", None, Some(&dng), &warnings).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); use photocraft_io::raw::Demosaic; assert_eq!((raw.developed_with, raw.demosaic), (Demosaic::Bilinear, Demosaic::Bilinear)); } @@ -433,7 +438,7 @@ fn shadows_of_a_raw_open_act_on_the_raw_data_and_the_preview_shows_it() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("IMG_0002.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); assert!(app.camera_raw.as_ref().unwrap().raw_curve.is_some(), "the preview knows how the raw was developed"); let mean = |px: &[[f32; 4]]| px.iter().map(|p| p[0] + p[1] + p[2]).sum::() / px.len() as f32; @@ -454,7 +459,7 @@ fn clarity_texture_and_dehaze_of_a_raw_open_act_on_the_raw_data() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("IMG_0003.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let before = mean_red(&app); let preview_before = app.camera_raw.as_ref().unwrap().processed.clone(); @@ -476,7 +481,7 @@ fn background_redevelop_keeps_the_tab_position_and_view() { app.open_bytes("mid.dng", &dng).unwrap(); app.run("file.new", json!({"width": 12, "height": 12})).unwrap(); app.sync_views(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); let old_id = raw.document; assert_eq!(document_index(&app, old_id), Some(1)); app.ui.views[1].zoom = 3.0; @@ -516,7 +521,7 @@ fn a_raw_opens_and_redevelops_at_the_raw_defaults_resolution() { app.run("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap(); app.open_bytes("IMG_0004.dng", &dng).unwrap(); assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); @@ -526,19 +531,201 @@ fn a_raw_opens_and_redevelops_at_the_raw_defaults_resolution() { assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0, "automation open"); } +/// Raws opened together share the dialog in a filmstrip, each with its own settings; Open +/// develops them all, Cancel closes them all. +#[test] +fn several_raws_share_the_dialog_and_keep_their_own_settings() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + app.open_bytes("three.dng", &dng).unwrap(); + assert_eq!(app.raw_shelf.len(), 3); + assert!(app.raw_shelf.iter().all(|h| h.thumb.is_some()), "each has a thumbnail"); + let ids: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + app.camera_raw.as_mut().unwrap().render(&ctx); + filmstrip::show_raw(&mut app, &ctx, 1).unwrap(); + let d = app.camera_raw.as_ref().unwrap(); + assert_eq!((d.raw_open.as_ref().unwrap().document, d.layer_name.as_str(), d.params.exposure), (ids[1], "two.dng", 0.0)); + assert_eq!(app.raw_shelf[0].kept.as_ref().map(|(p, _)| p.exposure), Some(1.0), "the first keeps its settings"); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 30}}})).unwrap().unwrap(); + filmstrip::show_raw(&mut app, &ctx, 0).unwrap(); + assert_eq!(app.camera_raw.as_ref().unwrap().params.exposure, 1.0, "back to the first, as it was left"); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(3), "{r}"); + assert!(app.raw_shelf.is_empty() && app.camera_raw.is_none()); + assert_eq!(app.session.documents().len(), 3, "the never-shown third opens too"); + assert!(app.session.documents().iter().all(|st| st.saved_revision == st.revision), "Open leaves them unmodified"); + // Cancel closes every raw in the filmstrip. + app.open_bytes("four.dng", &dng).unwrap(); + app.open_bytes("five.dng", &dng).unwrap(); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap(); + assert!(app.raw_shelf.is_empty()); + assert_eq!(app.session.documents().len(), 3); +} + +/// A raw whose Open fails stays in the filmstrip with its settings, and the dialog comes back +/// for it; the raws that opened leave it. +#[test] +fn a_raw_that_fails_to_open_stays_in_the_filmstrip_with_its_settings() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + app.open_bytes("three.dng", &dng).unwrap(); + let ids: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + // Two's file can no longer be developed, and it was left with its own settings. + let mut kept = CameraRaw { exposure: 0.5, ..Default::default() }; + kept.contrast = 20.0; + app.raw_shelf[1].raw.bytes = Some(std::sync::Arc::new(b"not a raw file".to_vec())); + app.raw_shelf[1].kept = Some((kept.clone(), CameraRaw::default())); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(2), "one and three open: {r}"); + let left: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + assert_eq!(left, vec![ids[1]], "only the failed raw stays in the filmstrip"); + assert_eq!(app.raw_shelf[0].kept.as_ref().map(|(p, _)| p), Some(&kept), "with its settings"); + // The dialog comes back for it with them; when every raw fails, the dialog and its + // filmstrip stay. + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + let shown = &app.camera_raw.as_ref().unwrap().params; + assert_eq!((shown.exposure, shown.contrast), (0.5, 20.0)); + app.open_bytes("four.dng", &dng).unwrap(); + app.raw_shelf[1].raw.bytes = Some(std::sync::Arc::new(b"not a raw file either".to_vec())); + app.raw_shelf[1].kept = Some((kept.clone(), CameraRaw::default())); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().is_err()); + assert_eq!(app.raw_shelf.len(), 2, "both raws stay in the filmstrip"); + assert!(app.camera_raw.is_some(), "and the dialog stays open"); + // Cancel still closes every raw in it. + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap(); + assert!(app.raw_shelf.is_empty()); + assert_eq!(app.session.documents().len(), 2, "one and three stay open"); +} + +/// A raw that can't be shown leaves things as they were: the dialog stays on the raw being left, +/// and the other keeps its place in the filmstrip with its settings. +#[test] +fn a_raw_that_cannot_be_shown_keeps_the_dialog_and_the_filmstrip() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + let ids: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + let kept = CameraRaw { exposure: 0.5, ..Default::default() }; + app.raw_shelf[1].kept = Some((kept.clone(), CameraRaw::default())); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + let two = document_index(&app, ids[1]).unwrap(); + app.session.close(two); + assert!(filmstrip::show_raw(&mut app, &ctx, 1).is_err()); + let d = app.camera_raw.as_ref().expect("the dialog stays"); + assert_eq!((d.raw_open.as_ref().unwrap().document, d.params.exposure), (ids[0], 1.0)); + assert_eq!(app.raw_shelf.len(), 2); + assert_eq!(app.raw_shelf[1].kept.as_ref().map(|(p, _)| p), Some(&kept)); +} + +/// Leaving a raw before its preview has caught up: its thumbnail shows the settings it keeps, not +/// the frame before them. +#[test] +fn a_raw_left_before_its_preview_lands_gets_a_thumbnail_of_its_settings() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.params.exposure = 2.0; + d.dirty = true; + filmstrip::keep_settings(&mut app); + let thumb = app.raw_shelf[0].thumb.clone().expect("a thumbnail"); + app.camera_raw.as_mut().unwrap().render(&ctx); + filmstrip::keep_settings(&mut app); + assert!(thumb == app.raw_shelf[0].thumb.clone().unwrap(), "the thumbnail matches a finished develop of the same settings"); +} + +/// Open returns once a background develop has started; if that develop then fails, the raw comes +/// back to the filmstrip with its settings, as one that failed at once stays there. +#[test] +fn a_raw_whose_background_develop_fails_comes_back_to_the_filmstrip() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + let ids: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + let (kept, from) = (CameraRaw { exposure: 0.5, contrast: 20.0, ..Default::default() }, CameraRaw { contrast: 5.0, ..Default::default() }); + app.raw_shelf[1].raw.bytes = Some(std::sync::Arc::new(b"not a raw file".to_vec())); + app.raw_shelf[1].kept = Some((kept.clone(), from.clone())); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + app.background_jobs = true; + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(2), "both started: {r}"); + assert!(app.raw_shelf.is_empty()); + for _ in 0..3000 { + crate::jobs_ui::tick(&mut app, &ctx); + if app.raw_redevelop.is_empty() && !app.session.has_jobs() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(app.raw_redevelop.is_empty() && !app.session.has_jobs(), "the jobs never finished"); + let left: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + assert_eq!(left, vec![ids[1]], "only the failed raw comes back"); + assert_eq!(app.raw_shelf[0].kept, Some((kept, from)), "with its settings"); + app.background_jobs = false; + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + assert_eq!(app.camera_raw.as_ref().unwrap().params.contrast, 20.0); +} + +/// Camera Raw Defaults' Auto reads the same pixels whether a raw was shown or not, so Open gives +/// it the same settings either way: a fine pattern that a sparse sample misreads shows it. +#[test] +fn auto_tone_of_a_never_shown_raw_matches_the_dialogs() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("prefs.set", json!({"path": "rawDefaults.applyAutoTone", "value": true})).unwrap(); + app.run("file.new", json!({"width": 1800, "height": 1200})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let st = app.session.active_mut().unwrap(); + let id = st.doc.layers.iter().find(|l| l.surface().is_some()).unwrap().id; + st.active_layer = Some(id); + let document = st.doc.id; + let Some(photocraft_doc::LayerContent::Raster(s)) = std::sync::Arc::make_mut(&mut st.doc).layer_mut(id).map(|l| &mut l.content) else { + panic!("a pixel layer") + }; + let format = s.format(); + let (dark, light) = (photocraft_raster::from_rgba(&format, [0.1, 0.1, 0.1, 1.0]), photocraft_raster::from_rgba(&format, [0.9, 0.9, 0.9, 1.0])); + for y in 0..1200 { + for x in 0..1800 { + s.write_pixel(x, y, if (x + y) % 2 == 0 { &dark } else { &light }); + } + } + let method = photocraft_io::raw::Demosaic::Ahd; + let raw = RawOpen { document, name: "pattern.dng".into(), path: None, bytes: None, developed_with: method, demosaic: method }; + let (unshown, _) = filmstrip::first_settings(&app, &raw); + open_raw(&mut app, &ctx, raw).unwrap(); + let shown = &app.camera_raw.as_ref().unwrap().params; + let tone = |p: &CameraRaw| [p.exposure, p.contrast, p.highlights, p.shadows, p.whites, p.blacks]; + assert_eq!(tone(&unshown), tone(shown)); +} + #[test] fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("a.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap(); assert_eq!(r["cancelled"], true); assert!(app.session.documents().is_empty(), "Cancel closes the raw being opened"); // Unchanged settings: Open keeps the default develop and adds no step. app.open_bytes("b.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); assert_eq!((r["document"].as_u64(), r["redeveloped"].as_bool(), r.get("filter")), (Some(0), Some(false), None), "{r}"); @@ -546,7 +733,7 @@ fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { // Preference off: raws open straight away. app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); app.open_bytes("c.dng", &dng).unwrap(); - assert!(app.pending_raw_open.is_none()); + assert!(app.raw_shelf.is_empty()); // A non-raw never queues the dialog; a closed document is an error, not a panic. assert!( open_raw( @@ -570,7 +757,7 @@ fn calibration_changes_the_preview_and_round_trips_through_xmp() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("IMG_0005.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let before = app.camera_raw.as_ref().unwrap().processed.clone(); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"shadowTint": 100, "calibrationHue": [100, 0, 0], "calibrationSat": [0, 0, -100]}}})) @@ -595,7 +782,7 @@ fn auto_sets_the_light_sliders_from_the_image() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("IMG_0006.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); assert!(app.camera_raw.as_ref().unwrap().params.is_identity()); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"auto": true}})).unwrap().unwrap(); @@ -605,7 +792,7 @@ fn auto_sets_the_light_sliders_from_the_image() { // The preference does the same when a raw opens without a sidecar. app.run("prefs.set", json!({"path": "rawDefaults.applyAutoTone", "value": true})).unwrap(); app.open_bytes("IMG_0007.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); assert_eq!(app.camera_raw.as_ref().unwrap().params, p, "Apply auto tone opens the dialog with Auto's settings"); } @@ -635,7 +822,7 @@ fn a_sidecar_fills_the_dialog_and_open_writes_it_back() { })); let ctx = egui::Context::default(); app.open_bytes("IMG_0008.cr2", &dng).unwrap(); - let mut raw = app.pending_raw_open.take().unwrap(); + let mut raw = take_queued(&mut app).unwrap(); raw.path = Some(raw_path.clone()); open_raw(&mut app, &ctx, raw).unwrap(); let d = app.camera_raw.as_ref().unwrap(); @@ -657,14 +844,14 @@ fn a_sidecar_fills_the_dialog_and_open_writes_it_back() { let dng_path = dir.join("IMG_0009.dng").to_string_lossy().into_owned(); std::fs::write(&dng_path, &dng).unwrap(); app.open_bytes("IMG_0009.dng", &dng).unwrap(); - let mut raw = app.pending_raw_open.take().unwrap(); + let mut raw = take_queued(&mut app).unwrap(); raw.path = Some(dng_path); open_raw(&mut app, &ctx, raw).unwrap(); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); assert!(written.lock().unwrap().is_empty(), "no sidecar for a DNG"); app.run("prefs.set", json!({"path": "rawDefaults.writeXmpSidecars", "value": false})).unwrap(); app.open_bytes("IMG_0008.cr2", &dng).unwrap(); - let mut raw = app.pending_raw_open.take().unwrap(); + let mut raw = take_queued(&mut app).unwrap(); raw.path = Some(raw_path.clone()); open_raw(&mut app, &ctx, raw).unwrap(); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 30}, "commit": true}})).unwrap().unwrap(); @@ -673,7 +860,7 @@ fn a_sidecar_fills_the_dialog_and_open_writes_it_back() { app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); let before = app.session.documents().len(); app.open_file(&raw_path, &dng).unwrap(); - assert!(app.pending_raw_open.is_none()); + assert!(app.raw_shelf.is_empty()); assert_eq!(app.session.documents().len(), before + 1); let st = app.session.active().unwrap(); assert_eq!(st.saved_revision, st.revision, "opening with a sidecar leaves the document unmodified"); @@ -686,7 +873,7 @@ fn camera_raw_defaults_apply_when_a_raw_opens() { let (mut app, dng) = raw_app(); app.run("prefs.set", json!({"values": {"rawDefaults.openInCameraRaw": false, "rawDefaults.colorSpace": "srgb", "rawDefaults.bitDepth": "8", "rawDefaults.resolution": 240.0, "rawDefaults.sharpenFor": "screen", "rawDefaults.openAsSmartObject": true}})).unwrap(); app.open_bytes("IMG_0010.dng", &dng).unwrap(); - assert!(app.pending_raw_open.is_none()); + assert!(app.raw_shelf.is_empty()); let st = app.session.active().unwrap(); assert_eq!(st.doc.depth, photocraft_color::SampleType::U8, "Bit depth 8"); assert_eq!(st.doc.resolution_dpi, 240.0, "Resolution"); @@ -700,7 +887,7 @@ fn camera_raw_defaults_apply_when_a_raw_opens() { .unwrap(); let ctx = egui::Context::default(); app.open_bytes("IMG_0011.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); assert_eq!(r["defaults"], json!(["sharpenFor", "colorSpace"]), "{r}"); @@ -756,7 +943,7 @@ fn raw_with_sidecar(tag: &str, sidecar: Option<&str>) -> (PhotocraftApp, Vec /// Opens the raw at `raw_path` in the open-time dialog, as File › Open does. fn open_dialog_at(app: &mut PhotocraftApp, ctx: &egui::Context, dng: &[u8], raw_path: &str) { app.open_bytes("IMG_0100.cr2", dng).unwrap(); - let mut raw = app.pending_raw_open.take().unwrap(); + let mut raw = take_queued(app).unwrap(); raw.path = Some(raw_path.to_string()); open_raw(app, ctx, raw).unwrap(); } @@ -808,7 +995,7 @@ fn opening_without_the_dialog_does_not_rewrite_the_sidecar() { let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("nodialog", Some(LIGHTROOM_SIDECAR)); app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); app.open_file(&raw_path, &dng).unwrap(); - assert!(app.pending_raw_open.is_none()); + assert!(app.raw_shelf.is_empty()); assert_eq!(app.session.documents().len(), 1); assert!(written.lock().unwrap().is_empty(), "just opening never writes the sidecar"); assert_eq!(std::fs::read_to_string(sidecar_path(&raw_path)).unwrap(), LIGHTROOM_SIDECAR); @@ -855,7 +1042,7 @@ fn a_sidecar_write_error_stays_reported() { fn open_with_settings(app: &mut PhotocraftApp, ctx: &egui::Context, dng: &[u8], name: &str) { app.open_bytes(name, dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(app).unwrap(); open_raw(app, ctx, raw).unwrap(); menu(app, ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 0.5, "contrast": 60, "saturation": 40, "vibrance": 30}}})).unwrap().unwrap(); } @@ -1022,3 +1209,60 @@ fn save_and_load_settings_round_trip_through_xmp_files() { frame(&mut app); assert_eq!(app.camera_raw.as_ref().unwrap().params.clarity, 25.0); } + +/// Done keeps the settings in the raw's sidecar and opens nothing, +/// even with the preference that writes sidecars on Open turned off. +#[test] +fn done_saves_the_sidecar_and_opens_nothing() { + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("done", None); + let ctx = egui::Context::default(); + app.run("prefs.set", json!({"path": "rawDefaults.writeXmpSidecars", "value": false})).unwrap(); + let before = app.session.documents().len(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + let set = json!({"contrast": 20, "clarity": 15}); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": set}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true}})).unwrap().unwrap(); + assert_eq!(r["saved"], json!(["IMG_0100.xmp"]), "{r}"); + assert!(app.camera_raw.is_none() && app.raw_shelf.is_empty()); + assert_eq!(app.session.documents().len(), before, "Done opens nothing"); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 1); + let mut back = CameraRaw::default(); + photocraft_io::camera_raw_xmp::load(&mut back, &w[0].1).unwrap(); + assert_eq!((back.contrast, back.clarity), (20.0, 15.0)); + // Done is only for raws being opened, and checked before anything else in the request + // changes the dialog. + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true}})).unwrap().is_err()); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap(); + let before = app.camera_raw.as_ref().unwrap().params.clone(); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 40}, "done": true}})).unwrap().is_err()); + assert_eq!(app.camera_raw.as_ref().unwrap().params, before, "a refused Done changes nothing"); + for other in ["commit", "cancel"] { + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true, other: true}})).unwrap().is_err(), "{other}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A sidecar that can't be written keeps the dialog and the raw open with their settings; +/// default settings are nothing to save, not an error. +#[test] +fn done_keeps_the_raws_open_when_a_sidecar_write_fails() { + let (mut app, dng, dir, raw_path, _) = raw_with_sidecar("done-fails", None); + let ctx = egui::Context::default(); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Err("read-only volume".to_string()))); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + let docs = app.session.documents().len(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 20}}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true}})).unwrap().unwrap(); + assert_eq!(r["done"], false, "{r}"); + assert_eq!(app.camera_raw.as_ref().map(|d| d.params.contrast), Some(20.0), "the edit is still there"); + assert_eq!(app.session.documents().len(), docs, "the raw stays open"); + assert!(app.ui.status_error); + // At the defaults there is nothing to write: Done closes, without an error. + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 0}}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true}})).unwrap().unwrap(); + assert_eq!((r["done"].clone(), r["saved"].clone()), (json!(true), json!([])), "{r}"); + assert!(app.camera_raw.is_none() && !app.ui.status_error, "{}", app.ui.status); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/dialogs/color_picker_ui.rs b/crates/ui-egui/src/dialogs/color_picker_ui.rs index b3f655be45..d59bf082c3 100644 --- a/crates/ui-egui/src/dialogs/color_picker_ui.rs +++ b/crates/ui-egui/src/dialogs/color_picker_ui.rs @@ -993,6 +993,10 @@ fn confirm_target(app: &mut PhotocraftApp, f: &Map) -> Result) -> bool { + fields.contains_key(MARK) +} + +/// Whether Equalize asks first: the active document has a selection and Equalize can run. +pub fn asks(app: &PhotocraftApp) -> bool { + app.session.active().is_some_and(|st| st.doc.selection.is_some()) && app.session.is_enabled(COMMAND) +} + +pub fn open(app: &mut PhotocraftApp) -> u64 { + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("Equalize")); + let st = app.session.active(); + f.insert("__document".into(), st.map(|s| json!(s.doc.id)).unwrap_or(Value::Null)); + f.insert("__layer".into(), st.and_then(|s| s.active_layer).map(|l| json!(l.0)).unwrap_or(Value::Null)); + f.insert("__selection".into(), st.and_then(selection_fingerprint).map(Value::String).unwrap_or(Value::Null)); + f.insert(KEY.into(), json!(true)); + app.ui.open_dialog(DialogKind::Command, f) +} + +/// A hash of the selection's stored tiles and its default value, so OK can tell whether the +/// selection changed after the dialog opened. Tiles that share one allocation (uniform tiles, +/// copy-on-write clones) are hashed once. +fn selection_fingerprint(st: &photocraft_engine::DocState) -> Option { + let sel = st.doc.selection.as_ref()?; + let mut h = DefaultHasher::new(); + // A point far outside every tile reads the default value. + sel.sample_channel(i32::MIN / 2, i32::MIN / 2, 0).to_bits().hash(&mut h); + let mut seen: HashMap = HashMap::new(); + for (c, t) in sel.tiles() { + let tile = *seen.entry(Arc::as_ptr(t) as usize).or_insert_with(|| { + let mut th = DefaultHasher::new(); + t.bytes().hash(&mut th); + th.finish() + }); + (c.tx, c.ty, tile).hash(&mut h); + } + Some(format!("{:016x}", h.finish())) +} + +/// OK runs only while the document, layer and selection the dialog was opened for are still the +/// active ones; otherwise the dialog stays open. +pub fn validate(app: &PhotocraftApp, f: &Map) -> Result<(), String> { + let st = app.session.active().ok_or("no document")?; + if f.get("__document") != Some(&json!(st.doc.id)) { + return Err("the Equalize dialog belongs to another document".into()); + } + if st.doc.selection.is_none() { + return Err("the selection was cleared while the Equalize dialog was open".into()); + } + if f.get("__layer") != Some(&st.active_layer.map(|l| json!(l.0)).unwrap_or(Value::Null)) { + return Err("another layer was selected while the Equalize dialog was open".into()); + } + if f.get("__selection") != Some(&selection_fingerprint(st).map(Value::String).unwrap_or(Value::Null)) { + return Err("the selection changed while the Equalize dialog was open".into()); + } + Ok(()) +} + +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + let mut only = f.get(KEY).and_then(Value::as_bool).unwrap_or(true); + ui.radio_value(&mut only, true, tl!("Equalize Selected Area Only")); + ui.radio_value(&mut only, false, tl!("Equalize Entire Image Based On Selected Area")); + f.insert(KEY.into(), json!(only)); +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/equalize_ui/tests.rs b/crates/ui-egui/src/dialogs/equalize_ui/tests.rs new file mode 100644 index 0000000000..db6924c4a4 --- /dev/null +++ b/crates/ui-egui/src/dialogs/equalize_ui/tests.rs @@ -0,0 +1,173 @@ +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use super::*; +use crate::menus::invoke as menu; + +/// A 40 × 30 layer: the top half at 0.4 gray, the bottom half at 0.5, with the left half selected +/// when `select`. +fn setup(select: bool) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 30})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 15})).unwrap(); + app.run("edit.fill", json!({"color": [0.4, 0.4, 0.4]})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 15, "width": 40, "height": 15})).unwrap(); + app.run("edit.fill", json!({"color": [0.5, 0.5, 0.5]})).unwrap(); + if select { + app.run("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + } else { + app.run("select.deselect", json!({})).unwrap(); + } + (app, egui::Context::default()) +} + +fn gray(app: &PhotocraftApp, x: i32, y: i32) -> f32 { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[0] +} + +#[test] +fn command_dialog_with_a_selection_opens_equalize_choices() { + let (mut app, _) = setup(true); + let id = crate::dialogs::open_command_dialog(&mut app, COMMAND, "Equalize"); + let fields = &app.ui.dialog_mut(id).unwrap().fields; + assert!(owns(fields)); + assert_eq!(fields[KEY], json!(true)); + + let (mut app, _) = setup(false); + let id = crate::dialogs::open_command_dialog(&mut app, COMMAND, "Equalize"); + assert!(!owns(&app.ui.dialog_mut(id).unwrap().fields)); +} + +#[test] +fn equalize_marker_is_a_route_marker() { + assert!(crate::dialogs::is_route_marker(MARK)); +} + +#[test] +fn equalize_with_a_selection_asks_and_defaults_to_the_selected_area_only() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(d).unwrap().fields; + assert!(owns(f)); + assert_eq!(f[KEY], json!(true)); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01, "nothing changes before OK"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert!(gray(&app, 1, 1) < 0.05 && gray(&app, 1, 20) > 0.95, "the selected area is equalized"); + assert!((gray(&app, 30, 1) - 0.4).abs() < 0.01 && (gray(&app, 30, 20) - 0.5).abs() < 0.01, "the rest is unchanged"); +} + +#[test] +fn entire_image_based_on_the_selected_area_maps_the_whole_layer() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(d).unwrap().fields.insert(KEY.into(), json!(false)); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert!(gray(&app, 30, 1) < 0.05 && gray(&app, 30, 20) > 0.95, "outside the selection too"); +} + +/// OK on a dialog opened for one document, while another document is active, equalizes nothing and +/// leaves the dialog open. +#[test] +fn ok_refuses_while_another_document_is_active() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.run("file.new", json!({"width": 40, "height": 30})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": [0.4, 0.4, 0.4]})).unwrap(); + assert!(crate::dialogs::confirm(&mut app, d).is_err()); + assert!(app.ui.dialogs.iter().any(|x| x.id == d), "the dialog stays open"); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01, "the other document is unchanged"); + assert!(app.session.set_active(0)); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01, "nothing ran on the dialog's document either"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert!(gray(&app, 1, 1) < 0.05, "OK runs once its document is active again"); +} + +/// OK on a dialog whose selection was cleared while it was open equalizes nothing and leaves the +/// dialog open, so the choice is never applied to the whole layer. +#[test] +fn ok_refuses_after_the_selection_is_cleared() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.run("select.deselect", json!({})).unwrap(); + assert!(crate::dialogs::confirm(&mut app, d).is_err()); + assert!(app.ui.dialogs.iter().any(|x| x.id == d), "the dialog stays open"); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01 && (gray(&app, 30, 20) - 0.5).abs() < 0.01, "the layer is unchanged"); +} + +/// OK after another layer became active equalizes nothing and leaves the dialog open; it runs once +/// the layer the dialog was opened for is active again. +#[test] +fn ok_refuses_after_the_active_layer_changes() { + let (mut app, ctx) = setup(true); + let layer = app.session.active().unwrap().active_layer.unwrap(); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": [0.4, 0.4, 0.4]})).unwrap(); + assert!(crate::dialogs::confirm(&mut app, d).is_err()); + assert!(app.ui.dialogs.iter().any(|x| x.id == d), "the dialog stays open"); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01, "the new layer is unchanged"); + app.run("layer.select", json!({"layer": layer.0})).unwrap(); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01, "nothing ran on the dialog's layer either"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert!(gray(&app, 1, 1) < 0.05 && gray(&app, 1, 20) > 0.95, "OK runs once its layer is active again"); +} + +/// OK after the selection changed equalizes nothing and leaves the dialog open; it runs once the +/// selection is the one the dialog was opened with again. +#[test] +fn ok_refuses_after_the_selection_changes() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.run("select.rect", json!({"x": 20, "y": 0, "width": 20, "height": 30})).unwrap(); + assert!(crate::dialogs::confirm(&mut app, d).is_err()); + assert!(app.ui.dialogs.iter().any(|x| x.id == d), "the dialog stays open"); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01 && (gray(&app, 30, 1) - 0.4).abs() < 0.01, "the layer is unchanged"); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert!(gray(&app, 1, 1) < 0.05 && (gray(&app, 30, 1) - 0.4).abs() < 0.01, "OK runs on the selection it was opened with"); +} + +#[test] +fn cancel_leaves_the_layer_unchanged() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + crate::dialogs::cancel(&mut app, d).unwrap(); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01 && (gray(&app, 1, 20) - 0.5).abs() < 0.01); +} + +#[test] +fn without_a_selection_or_with_params_equalize_runs_at_once() { + let (mut app, ctx) = setup(false); + let r = menu(&mut app, &ctx, COMMAND, json!({})).unwrap(); + assert!(r.get("dialog").is_none() && app.ui.dialogs.is_empty(), "{r}"); + assert!(gray(&app, 30, 1) < 0.05 && gray(&app, 30, 20) > 0.95); + let (mut scripted, ctx) = setup(true); + menu(&mut scripted, &ctx, COMMAND, json!({"selectedAreaOnly": false})).unwrap(); + assert!(scripted.ui.dialogs.is_empty()); + assert!(gray(&scripted, 30, 1) < 0.05, "a script's `selectedAreaOnly` runs without the dialog"); +} + +#[test] +fn the_dialog_shows_both_choices() { + let (mut app, _) = setup(true); + open(&mut app); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::dialogs::show(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + assert!(h.query_by_label("Equalize Selected Area Only").is_some()); + h.get_by_label("Equalize Entire Image Based On Selected Area").click(); + h.run_steps(2); + assert_eq!(h.state().ui.dialogs[0].fields[KEY], json!(false)); + assert!(h.query_by_label("OK").is_some() && h.query_by_label("Cancel").is_some()); +} diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 540c1bb3b9..5923fb4d2d 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -322,6 +322,8 @@ pub const PREVIEWED: &[&str] = &[ // The preview shows the image through the chosen profile, as Photoshop's Preview does. "edit.assignProfile", "edit.convertToProfile", + // Previewed from the session's history (`canvas::filter_preview`), at full size (#8230). + "edit.fade", ]; pub fn has_dialog(command: &str) -> bool { diff --git a/crates/ui-egui/src/dialogs/new_guide_ui.rs b/crates/ui-egui/src/dialogs/new_guide_ui.rs index 794a126da4..e9a3406b86 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui.rs @@ -82,6 +82,34 @@ fn with_unit(value: &str, unit: Unit) -> String { format!("{value} {}", unit.suffix()) } +/// `typed` with `unit` after its first number when that number has none ("300 / 2" with pixel +/// rulers → "300 px / 2"); anything else as typed. Nor when a length later in its term makes it +/// a plain factor ("2 / 2 * 50 px": the term is in px already). +fn unit_on_first_number(typed: &str, unit: Unit) -> String { + let end = typed + .char_indices() + .find(|&(i, c)| !(c.is_ascii_digit() || matches!(c, '.' | ',') || (i == 0 && matches!(c, '-' | '+')))) + .map_or(typed.len(), |(i, _)| i); + let (number, rest) = typed.split_at(end); + let bare = !number.is_empty() && rest.trim_start().starts_with(['+', '-', '*', '/']) && !factor_has_unit(rest); + if bare { format!("{} {}{rest}", number, unit.suffix()) } else { typed.to_string() } +} + +/// Whether an operand multiplied or divided in at the start of `rest` (up to the first `+` or `-` +/// between operands) carries a unit, as the engine splits operands. +fn factor_has_unit(mut rest: &str) -> bool { + while let Some(operand) = rest.trim_start().strip_prefix(['*', '/']).map(str::trim_start) { + // The operand runs to the next operator; a sign at its start is its own. + let end = operand.char_indices().find(|&(i, c)| i > 0 && matches!(c, '+' | '-' | '*' | '/')).map_or(operand.len(), |(i, _)| i); + let (operand, after) = operand.split_at(end); + if Unit::parse_length(operand).is_some_and(|(_, u)| u.is_some()) { + return true; + } + rest = after; + } + false +} + /// `px` (document pixels) in the ruler unit, as the field shows it: "14.11 cm". Percent is of the /// active document's width for a vertical guide and its height for a horizontal one. fn in_ruler_unit(app: &PhotocraftApp, px: f64, vertical: bool) -> String { @@ -212,10 +240,14 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { /// the engine to read (or refuse, keeping the dialog open). Custom… sends its "#rrggbb". pub fn params(app: &PhotocraftApp, f: &Map) -> Value { let orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal"); - let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim(); - let position = match Unit::parse_length(typed) { - Some((_, None)) => with_unit(typed, ruler_unit(app)), - _ => typed.to_string(), + // A Unicode minus (U+2212) is a minus, as the engine reads it. + let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim().replace('−', "-"); + let position = match Unit::parse_length(&typed) { + Some((_, None)) => with_unit(&typed, ruler_unit(app)), + Some(_) => typed, + // Arithmetic (#10440): a first number without a unit is in the ruler unit, as in + // Photoshop ("300 / 2" is 150 px with pixel rulers); the engine evaluates the rest. + None => unit_on_first_number(&typed, ruler_unit(app)), }; let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); let color = if color == CUSTOM { custom_color(f) } else { color.to_string() }; @@ -301,3 +333,29 @@ pub fn edit_preview<'a>(app: &'a PhotocraftApp, doc: &photocraft_doc::Document) #[cfg(test)] mod tests; + +#[cfg(test)] +mod arithmetic_tests { + use super::{Unit, unit_on_first_number}; + + /// #10440: a first number without a unit is in the ruler unit, the rest goes to the engine. + #[test] + fn a_leading_bare_number_gets_the_ruler_unit() { + assert_eq!(unit_on_first_number("300 / 2", Unit::Pixels), "300 px / 2"); + assert_eq!(unit_on_first_number("3+1in", Unit::Centimeters), "3 cm+1in"); + assert_eq!(unit_on_first_number("100% / 3", Unit::Pixels), "100% / 3"); + assert_eq!(unit_on_first_number("abc", Unit::Pixels), "abc"); + } + + /// A bare number multiplied or divided within a term that has a length is a plain factor: + /// the length is the term's unit, so the ruler unit isn't added (it would make a length + /// times a length). A term of bare numbers still gets it. + #[test] + fn a_leading_factor_of_a_length_gets_no_ruler_unit() { + for typed in ["2 * 50 px", "2 / 2 * 50 px", "2*50px+10", "4 / 2 * 1in - 3", "2 * -1cm"] { + assert_eq!(unit_on_first_number(typed, Unit::Centimeters), typed); + } + assert_eq!(unit_on_first_number("2 * 3 + 1in", Unit::Centimeters), "2 cm * 3 + 1in"); + assert_eq!(unit_on_first_number("6 / 2 / 3 - 1", Unit::Centimeters), "6 cm / 2 / 3 - 1"); + } +} diff --git a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs index c0c732f0f7..5571641527 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs @@ -435,3 +435,29 @@ fn refused_edit_shows_the_error_and_typed_fields_in_the_dialog() { assert!(h.query_by_label("guide edits are denied").is_none()); assert_eq!(guides(h.state()).1, vec![10.0, 20.0]); } + +/// #10440: a leading bare number gets the ruler unit after a Unicode minus (U+2212) as after "-", +/// so "5.08 − 2.54" with centimetre rulers is 2.54 cm, not 2.54 px. +#[test] +fn the_ruler_unit_goes_on_a_bare_number_before_a_unicode_minus() { + let (mut app, ctx) = app("cm"); + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", "5.08 − 2.54"); + crate::dialogs::confirm(&mut app, d).unwrap(); + let (_, vertical) = guides(&app); + assert!(vertical.len() == 1 && (vertical[0] - 100.0).abs() < 1e-6, "{vertical:?}"); +} + +/// #10440: a leading bare number that multiplies or divides a length is a plain factor, so it +/// doesn't take the ruler unit (CodeRabbit: "2 / 2 * 50 px" became "2 cm / 2 * 50 px"). +#[test] +fn a_leading_factor_of_a_length_is_a_plain_number() { + let (mut app, ctx) = app("cm"); + for (typed, px) in [("2 * 50 px", 100.0), ("2 / 2 * 1in", 100.0), ("2 * 2.54 - 2.54", 100.0)] { + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", typed); + crate::dialogs::confirm(&mut app, d).unwrap_or_else(|e| panic!("{typed}: {e}")); + let landed = *guides(&app).1.last().unwrap(); + assert!((landed - px).abs() < 1e-6, "{typed}: {landed}"); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b688c6d4a..b0de64ffff 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -216,7 +216,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 34] = [ +pub static LANGUAGES: [LangInfo; 35] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -284,6 +284,8 @@ pub static LANGUAGES: [LangInfo; 34] = [ LangInfo { code: "ro", name: "Română", source: catalog!("ro"), plural: plural_romanian, complete_menus: true, catalog: OnceLock::new() }, // Tamil; `ta-IN`, `ta-LK` and `ta_IN.UTF-8` resolve here. CLDR plural: one|other. LangInfo { code: "ta", name: "தமிழ்", source: catalog!("ta"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Georgian: nouns stay singular after a numeral (`შრე: 5`, `5 შრე`), so one form serves every count. + LangInfo { code: "ka", name: "ქართული", source: catalog!("ka"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 43cb270c47..1cfced87ac 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3610,3 +3610,10 @@ curves Smooth تنعيم Show layer bounds on hover إظهار حدود الطبقة عند التمرير Expand layer group on click توسيع مجموعة الطبقات عند النقر Show hover bounds from Layers إظهار حدود التمرير من الطبقات +# Artboard tool options bar (#11385) + Distribute Widths توزيع العروض + Distribute Heights توزيع الارتفاعات + Auto-nest Layers تداخل الطبقات تلقائيًا + Auto-size Canvas تحديد حجم مساحة الرسم تلقائيًا + Keep Relative Position During Layer Reordering الإبقاء على الموضع النسبي عند إعادة ترتيب الطبقات + Shrinkwrap Canvas on Save تقليص مساحة الرسم إلى المحتوى عند الحفظ diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv index 850a7a2625..c228ba73d0 100644 --- a/crates/ui-egui/src/i18n/az.tsv +++ b/crates/ui-egui/src/i18n/az.tsv @@ -4626,3 +4626,10 @@ smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG redakt Show layer bounds on hover Üzərinə gətirəndə qat sərhədlərini göstərin Expand layer group on click Klikləyəndə qat qrupunu genişləndirin Show hover bounds from Layers Üzərinə gətirmə sərhədlərini Qatlardan göstərin +# Artboard tool options bar (#11385) + Distribute Widths Enləri yaymaq + Distribute Heights Hündürlükləri yaymaq + Auto-nest Layers Qatları avtomatik yuvalayın + Auto-size Canvas Kətanın ölçüsünü avtomatik təyin edin + Keep Relative Position During Layer Reordering Qatları yenidən sıralayarkən nisbi mövqeyi saxlayın + Shrinkwrap Canvas on Save Saxlayarkən kətanı məzmuna sıxın diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 75daa428f5..3a9cdb8137 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4640,3 +4640,10 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Prikaži granice sloja pri prelasku miša Expand layer group on click Proširi grupu slojeva klikom Show hover bounds from Layers Prikaži granice pri prelasku iz panela Slojevi +# Artboard tool options bar (#11385) + Distribute Widths Rasporedi širine + Distribute Heights Rasporedi visine + Auto-nest Layers Automatski ugnijezdi slojeve + Auto-size Canvas Automatski promijeni veličinu platna + Keep Relative Position During Layer Reordering Zadrži relativni položaj pri promjeni redoslijeda slojeva + Shrinkwrap Canvas on Save Smanji platno na sadržaj pri sačuvanju diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bc029d6d55..eb8d5dc546 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -410,6 +410,8 @@ Match Color… Přizpůsobit barvy… Replace Color… Nahradit barvu… Equalize Vyrovnat + Equalize Selected Area Only Vyrovnat pouze výběr + Equalize Entire Image Based On Selected Area Vyrovnat celý obraz podle výběru Auto Tone Automatický tón Auto Contrast Automatický kontrast Auto Color Automatické barvy @@ -4354,3 +4356,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Zobrazit hranice vrstvy při najetí Expand layer group on click Rozbalit skupinu vrstev kliknutím Show hover bounds from Layers Zobrazit hranice při najetí z panelu Vrstvy + Saved Camera Raw settings to {file} Nastavení Camera Raw uložena do {file} + Save the settings to each raw's sidecar without opening it Uložit nastavení do souboru sidecar každého snímku raw bez jeho otevření + No settings saved: these raws can't keep a sidecar here Nic neuloženo: tyto snímky raw zde nemohou mít soubor sidecar + Some settings could not be saved; the raws stay open Některá nastavení nelze uložit; snímky raw zůstávají otevřené + Nothing to save: these raws are at their default settings Není co ukládat: tyto snímky raw mají výchozí nastavení +# Artboard tool options bar (#11385) + Distribute Widths Rozmístit šířky + Distribute Heights Rozmístit výšky + Auto-nest Layers Automaticky vnořovat vrstvy + Auto-size Canvas Automaticky měnit velikost plátna + Keep Relative Position During Layer Reordering Zachovat relativní polohu při změně pořadí vrstev + Shrinkwrap Canvas on Save Při uložení zmenšit plátno na obsah diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 53d40c5b34..cf9105b865 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4822,3 +4822,10 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet Show layer bounds on hover Vis lagets grænser ved svæv Expand layer group on click Udvid lagergruppe ved klik Show hover bounds from Layers Vis svævegrænser fra Lag +# Artboard tool options bar (#11385) + Distribute Widths Fordel bredder + Distribute Heights Fordel højder + Auto-nest Layers Indlejr lag automatisk + Auto-size Canvas Tilpas lærredsstørrelse automatisk + Keep Relative Position During Layer Reordering Bevar relativ placering ved omorganisering af lag + Shrinkwrap Canvas on Save Beskær lærredet til indholdet ved lagring diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 94554a9b30..6a970a2c7a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -381,6 +381,8 @@ Match Color… Farbabgleich… Replace Color… Farbe ersetzen… Equalize Angleichen + Equalize Selected Area Only Nur ausgewählten Bereich angleichen + Equalize Entire Image Based On Selected Area Gesamtes Bild anhand des ausgewählten Bereichs angleichen Auto Tone Automatische Tonwertkorrektur Auto Contrast Automatischer Kontrast Auto Color Automatische Farbe @@ -3649,3 +3651,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Ebenenbegrenzung beim Darüberfahren anzeigen Expand layer group on click Ebenengruppe per Klick erweitern Show hover bounds from Layers Begrenzung beim Darüberfahren aus Ebenen anzeigen + Saved Camera Raw settings to {file} Camera-Raw-Einstellungen in {file} gespeichert + Save the settings to each raw's sidecar without opening it Die Einstellungen in der Filialdatei jeder Raw-Datei speichern, ohne sie zu öffnen + No settings saved: these raws can't keep a sidecar here Nichts gespeichert: Diese Raw-Dateien können hier keine Filialdatei haben + Some settings could not be saved; the raws stay open Einige Einstellungen konnten nicht gespeichert werden; die Raw-Dateien bleiben geöffnet + Nothing to save: these raws are at their default settings Nichts zu speichern: Diese Raw-Dateien haben ihre Standardeinstellungen +# Artboard tool options bar (#11385) + Distribute Widths Breiten verteilen + Distribute Heights Höhen verteilen + Auto-nest Layers Ebenen automatisch verschachteln + Auto-size Canvas Zeichenflächengröße automatisch anpassen + Keep Relative Position During Layer Reordering Relative Position beim Neuanordnen von Ebenen beibehalten + Shrinkwrap Canvas on Save Zeichenfläche beim Speichern auf den Inhalt verkleinern diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e16bc5aeab..af6353f692 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -767,6 +767,8 @@ Channel Mixer Μείκτης καναλιών Desaturate Αφαίρεση κορεσμού Equalize Εξισορρόπηση + Equalize Selected Area Only Εξισορρόπηση μόνο της επιλεγμένης περιοχής + Equalize Entire Image Based On Selected Area Εξισορρόπηση ολόκληρης της εικόνας βάσει της επιλεγμένης περιοχής Auto Tone Αυτόματη διόρθωση τόνων Auto Contrast Αυτόματη αντίθεση Auto Color Αυτόματη διόρθωση χρωμάτων @@ -4313,3 +4315,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Εμφάνιση ορίων στρώσης στο πέρασμα του δείκτη Expand layer group on click Ανάπτυξη ομάδας στρώσεων με κλικ Show hover bounds from Layers Εμφάνιση ορίων στο πέρασμα από τις Στρώσεις + Saved Camera Raw settings to {file} Οι ρυθμίσεις Camera Raw αποθηκεύτηκαν στο {file} + Save the settings to each raw's sidecar without opening it Αποθήκευση των ρυθμίσεων στο συνοδευτικό αρχείο κάθε raw χωρίς άνοιγμα + No settings saved: these raws can't keep a sidecar here Δεν αποθηκεύτηκε τίποτα: αυτά τα raw δεν μπορούν να έχουν συνοδευτικό αρχείο εδώ + Some settings could not be saved; the raws stay open Ορισμένες ρυθμίσεις δεν ήταν δυνατό να αποθηκευτούν· τα raw παραμένουν ανοιχτά + Nothing to save: these raws are at their default settings Τίποτα για αποθήκευση: αυτά τα raw έχουν τις προεπιλεγμένες ρυθμίσεις +# Artboard tool options bar (#11385) + Distribute Widths Κατανομή πλατών + Distribute Heights Κατανομή υψών + Auto-nest Layers Αυτόματη ένθεση επιπέδων + Auto-size Canvas Αυτόματη αλλαγή μεγέθους καμβά + Keep Relative Position During Layer Reordering Διατήρηση σχετικής θέσης κατά την αναδιάταξη επιπέδων + Shrinkwrap Canvas on Save Σμίκρυνση καμβά στο περιεχόμενο κατά την αποθήκευση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4980641c2c..e9b758a917 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -347,6 +347,8 @@ Match Color… Igualar color… Replace Color… Reemplazar color… Equalize Ecualizar + Equalize Selected Area Only Ecualizar solo la selección + Equalize Entire Image Based On Selected Area Ecualizar toda la imagen según la selección Auto Tone Tono automático Auto Contrast Contraste automático Auto Color Color automático @@ -4513,3 +4515,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Mostrar límites de capa al pasar el puntero Expand layer group on click Expandir grupo de capas al hacer clic Show hover bounds from Layers Mostrar límites al pasar el puntero desde Capas + Saved Camera Raw settings to {file} Ajustes de Camera Raw guardados en {file} + Save the settings to each raw's sidecar without opening it Guardar los ajustes en el archivo sidecar de cada raw sin abrirlo + No settings saved: these raws can't keep a sidecar here No se guardó nada: estos raw no pueden tener un archivo sidecar aquí + Some settings could not be saved; the raws stay open No se pudieron guardar algunos ajustes; los raw siguen abiertos + Nothing to save: these raws are at their default settings Nada que guardar: estos raw tienen los ajustes predeterminados +# Artboard tool options bar (#11385) + Distribute Widths Distribuir anchos + Distribute Heights Distribuir alturas + Auto-nest Layers Anidar capas automáticamente + Auto-size Canvas Ajustar tamaño de lienzo automáticamente + Keep Relative Position During Layer Reordering Mantener la posición relativa al reordenar capas + Shrinkwrap Canvas on Save Reducir el lienzo al contenido al guardar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c68f8a9a40..eb99627d4f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4823,3 +4823,10 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen Show layer bounds on hover Näytä tason rajat osoitettaessa Expand layer group on click Laajenna tasoryhmä napsautuksella Show hover bounds from Layers Näytä rajat osoitettaessa Tasot-paneelista +# Artboard tool options bar (#11385) + Distribute Widths Jaa leveydet tasaisesti + Distribute Heights Jaa korkeudet tasaisesti + Auto-nest Layers Sisäkkäistä tasot automaattisesti + Auto-size Canvas Muuta kankaan kokoa automaattisesti + Keep Relative Position During Layer Reordering Säilytä suhteellinen sijainti tasojen järjestystä muutettaessa + Shrinkwrap Canvas on Save Pienennä kangas sisältöön tallennettaessa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e6a2c9960d..c0a008b3e4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -372,6 +372,8 @@ Match Color… Faire correspondre la couleur… Replace Color… Remplacer la couleur… Equalize Égaliser + Equalize Selected Area Only Égaliser uniquement la sélection + Equalize Entire Image Based On Selected Area Égaliser toute l'image d'après la sélection Auto Tone Tonalité automatique Auto Contrast Contraste automatique Auto Color Couleur automatique @@ -4636,3 +4638,15 @@ shape Appearance Apparence Show layer bounds on hover Afficher les limites du calque au survol Expand layer group on click Développer le groupe de calques au clic Show hover bounds from Layers Afficher les limites au survol depuis Calques + Saved Camera Raw settings to {file} Réglages Camera Raw enregistrés dans {file} + Save the settings to each raw's sidecar without opening it Enregistrer les réglages dans le fichier annexe de chaque raw sans l’ouvrir + No settings saved: these raws can't keep a sidecar here Rien n’a été enregistré : ces raw ne peuvent pas avoir de fichier annexe ici + Some settings could not be saved; the raws stay open Certains réglages n’ont pas pu être enregistrés ; les raw restent ouverts + Nothing to save: these raws are at their default settings Rien à enregistrer : ces raw ont leurs réglages par défaut +# Artboard tool options bar (#11385) + Distribute Widths Répartir les largeurs + Distribute Heights Répartir les hauteurs + Auto-nest Layers Imbriquer les calques automatiquement + Auto-size Canvas Redimensionner le canevas automatiquement + Keep Relative Position During Layer Reordering Conserver la position relative lors de la réorganisation des calques + Shrinkwrap Canvas on Save Réduire le canevas au contenu à l'enregistrement diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1ec4cc97e6..2e6854129a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -684,3 +684,10 @@ cameraRaw Yellow पीला Show layer bounds on hover होवर पर लेयर की सीमाएँ दिखाएँ Expand layer group on click क्लिक पर लेयर समूह विस्तृत करें Show hover bounds from Layers लेयर्स से होवर सीमाएँ दिखाएँ +# Artboard tool options bar (#11385) + Distribute Widths चौड़ाइयाँ वितरित करें + Distribute Heights ऊंचाइयाँ वितरित करें + Auto-nest Layers लेयर्स को स्वचालित रूप से नेस्ट करें + Auto-size Canvas कैनवास का आकार स्वचालित रूप से बदलें + Keep Relative Position During Layer Reordering लेयर्स का क्रम बदलते समय सापेक्ष स्थिति बनाए रखें + Shrinkwrap Canvas on Save सहेजते समय कैनवास को सामग्री के अनुसार छोटा करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 859b84e69e..ced7e54f43 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -194,6 +194,8 @@ Edit Szerkesztés Enhanced Controls… Bővített vezérlők… Equalize Kiegyenlítés + Equalize Entire Image Based On Selected Area A teljes kép kiegyenlítése a kijelölt terület alapján + Equalize Selected Area Only Csak a kijelölt terület kiegyenlítése Exit Kilépés Export As… Exportálás másként… Export Preferences… Beállítások exportálása… @@ -4520,3 +4522,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Réteghatárok megjelenítése rámutatáskor Expand layer group on click Rétegcsoport kibontása kattintásra Show hover bounds from Layers Határok megjelenítése a Rétegek panelről rámutatáskor + Saved Camera Raw settings to {file} Camera Raw-beállítások mentve ide: {file} + Save the settings to each raw's sidecar without opening it A beállítások mentése minden raw kísérőfájljába megnyitás nélkül + No settings saved: these raws can't keep a sidecar here Nincs mentve semmi: ezek a raw fájlok itt nem kaphatnak kísérőfájlt + Some settings could not be saved; the raws stay open Néhány beállítást nem sikerült menteni; a raw fájlok nyitva maradnak + Nothing to save: these raws are at their default settings Nincs mit menteni: ezek a raw fájlok alapértelmezett beállításokon vannak +# Artboard tool options bar (#11385) + Distribute Widths Szélességek elosztása + Distribute Heights Magasságok elosztása + Auto-nest Layers Rétegek automatikus beágyazása + Auto-size Canvas Vászonméret automatikus módosítása + Keep Relative Position During Layer Reordering Relatív pozíció megtartása a rétegek átrendezésekor + Shrinkwrap Canvas on Save Vászon összehúzása a tartalomra mentéskor diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 84eeff3fd5..4ba6f52758 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -365,6 +365,8 @@ Match Color… Cocokkan Warna… Replace Color… Ganti Warna… Equalize Ekualisasi + Equalize Selected Area Only Ekualisasi seleksi saja + Equalize Entire Image Based On Selected Area Ekualisasi seluruh gambar berdasarkan seleksi Auto Tone Auto Tone Auto Contrast Auto Kontras Auto Color Auto Warna @@ -3636,3 +3638,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Tampilkan batas layer saat kursor di atasnya Expand layer group on click Perluas grup layer saat diklik Show hover bounds from Layers Tampilkan batas saat kursor di atas dari Layer + Saved Camera Raw settings to {file} Pengaturan Camera Raw disimpan ke {file} + Save the settings to each raw's sidecar without opening it Simpan pengaturan ke file sidecar setiap raw tanpa membukanya + No settings saved: these raws can't keep a sidecar here Tidak ada yang disimpan: raw ini tidak dapat memiliki file sidecar di sini + Some settings could not be saved; the raws stay open Beberapa pengaturan tidak dapat disimpan; raw tetap terbuka + Nothing to save: these raws are at their default settings Tidak ada yang perlu disimpan: raw ini memakai pengaturan default +# Artboard tool options bar (#11385) + Distribute Widths Distribusi lebar + Distribute Heights Distribusi tinggi + Auto-nest Layers Sarangkan layer secara otomatis + Auto-size Canvas Ubah ukuran kanvas otomatis + Keep Relative Position During Layer Reordering Pertahankan posisi relatif saat mengurutkan ulang layer + Shrinkwrap Canvas on Save Kecilkan kanvas sesuai konten saat menyimpan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f6bbeffd30..1d671feece 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -393,6 +393,8 @@ Match Color… Corrispondenza colore… Replace Color… Sostituisci colore… Equalize Equalizza + Equalize Selected Area Only Equalizza solo l'area selezionata + Equalize Entire Image Based On Selected Area Equalizza l'intera immagine in base all'area selezionata Auto Tone Tono automatico Auto Contrast Contrasto automatico Auto Color Colore automatico @@ -4712,3 +4714,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Mostra i limiti del livello al passaggio del mouse Expand layer group on click Espandi il gruppo di livelli al clic Show hover bounds from Layers Mostra i limiti al passaggio dal pannello Livelli + Saved Camera Raw settings to {file} Impostazioni Camera Raw salvate in {file} + Save the settings to each raw's sidecar without opening it Salva le impostazioni nel file sidecar di ogni raw senza aprirlo + No settings saved: these raws can't keep a sidecar here Nulla salvato: questi raw non possono avere un file sidecar qui + Some settings could not be saved; the raws stay open Alcune impostazioni non sono state salvate; i raw restano aperti + Nothing to save: these raws are at their default settings Niente da salvare: questi raw hanno le impostazioni predefinite +# Artboard tool options bar (#11385) + Distribute Widths Distribuisci larghezze + Distribute Heights Distribuisci altezze + Auto-nest Layers Annida livelli automaticamente + Auto-size Canvas Ridimensiona quadro automaticamente + Keep Relative Position During Layer Reordering Mantieni la posizione relativa durante il riordino dei livelli + Shrinkwrap Canvas on Save Riduci il quadro al contenuto al salvataggio diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 063562f436..c464084b38 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -403,6 +403,8 @@ Match Color… カラーを一致… Replace Color… 色を置き換え… Equalize 平均化 + Equalize Selected Area Only 選択範囲のみ平均化 + Equalize Entire Image Based On Selected Area 選択範囲に基づいて画像全体を平均化 Auto Tone 自動トーン補正 Auto Contrast 自動コントラスト Auto Color 自動カラー補正 @@ -4284,3 +4286,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover ホバー時にレイヤーの境界を表示 Expand layer group on click クリックでレイヤーグループを展開 Show hover bounds from Layers レイヤーパネルからのホバー境界を表示 + Saved Camera Raw settings to {file} Camera Raw の設定を {file} に保存しました + Save the settings to each raw's sidecar without opening it 各 Raw のサイドカーファイルに設定を保存し、開かずに閉じます + No settings saved: these raws can't keep a sidecar here 保存されませんでした:これらの Raw にはここでサイドカーファイルを作成できません + Some settings could not be saved; the raws stay open 一部の設定を保存できませんでした。Raw は開いたままです + Nothing to save: these raws are at their default settings 保存するものはありません:これらの Raw は初期設定のままです +# Artboard tool options bar (#11385) + Distribute Widths 幅を分布 + Distribute Heights 高さを分布 + Auto-nest Layers レイヤーを自動でネスト + Auto-size Canvas カンバスサイズを自動調整 + Keep Relative Position During Layer Reordering レイヤーの並べ替え中も相対位置を保持 + Shrinkwrap Canvas on Save 保存時にカンバスを内容に合わせて縮小 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv new file mode 100644 index 0000000000..02e5270e11 --- /dev/null +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -0,0 +1,4719 @@ +# Georgian (ka) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); Georgian has one form: +# nouns stay singular after a numeral (`შრე: {n}`) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Georgian computing vocabulary (შრე, ნიღაბი, მონიშნული, ფუნჯი ...). No proprietary localisation +# resource, string table or product UI was consulted or copied; add entries the same way. +# Commands are verbal nouns (შენახვა, გახსნა); Georgian has no letter case, so nothing is +# capitalised (never Mtavruli). Product and technology names (PhotoCraft, OpenType, CMYK, Lab ...) +# stay as they are; the trailing "…" of dialog commands is kept. +# Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. + + Rows სტრიქონები + Number რაოდენობა + Margin ველები + Top: ზემოთ: + Left: მარცხნივ: + Bottom: ქვემოთ: + Right: მარჯვნივ: + Target: სამიზნე: + Selected Artboards მონიშნული სამუშაო დაფები + Center Columns სვეტების ცენტრირება + Clear Existing Guides არსებული დამხმარე ხაზების წაშლა + Invalid guide layout დამხმარე ხაზების არასწორი განლაგება + Leave empty for automatic size ავტომატური ზომისთვის დატოვეთ ცარიელი + 5 Columns, 3 Rows 5 სვეტი, 3 სტრიქონი + 8 Columns 8 სვეტი + 12 Columns 12 სვეტი + + File ფაილი + Edit რედაქტირება + Image გამოსახულება + Layer შრე + Type ტექსტი + Select მონიშვნა + Selected მონიშნული + Scope მოქმედების არე + Top Left ზედა მარცხენა + Top Right ზედა მარჯვენა + Bottom Left ქვედა მარცხენა + Bottom Right ქვედა მარჯვენა + Filter ფილტრი + View ხედი + Window ფანჯარა + Help დახმარება + (coming soon) (მალე) + New… ახალი… + Open… გახსნა… + Open As… გახსნა, როგორც… + Open EXR Parts… EXR ნაწილების გახსნა… + Add beauty precomp from the AOVs საბაზო კომპოზიციის (beauty) დამატება AOV-ებიდან + Both ორივე + Left მარცხნივ + Right მარჯვნივ + Open Recent ბოლოს გახსნილი ფაილების გახსნა + Clear Recent Files ბოლოს გახსნილი ფაილების სიის გასუფთავება + Remove from Recent ბოლოს გახსნილებიდან ამოშლა + Remove {name} from Recent {name}-ის ამოშლა ბოლოს გახსნილებიდან + Close დახურვა + Minimize ჩაკეცვა + Maximize გაშლა + Restore აღდგენა + Close All ყველას დახურვა + Close Others სხვების დახურვა + Save შენახვა + Save As… შენახვა როგორც… + Save a Copy… ასლის შენახვა… + Revert წინა ვერსიაზე დაბრუნება + Export ექსპორტი + Quick Export as PNG სწრაფი ექსპორტი PNG ფორმატში + Export As… ექსპორტი სხვა ფორმატით… + Export Preferences… ექსპორტის პარამეტრები… + Save for Web (Legacy)… ვებისთვის შენახვა (მოძველებული)… + Artboards to Files… სამუშაო დაფები ფაილებად… + Artboards to PDF… სამუშაო დაფები PDF-ად… + Layers to Files… შრეები ფაილებად… + Layer Comps to Files… შრეების კომპოზიციები ფაილებად… + Color Lookup Tables… ფერების ცხრილები (Color Lookup)… + Data Sets as Files… მონაცემთა ნაკრებები ფაილებად… + Paths to Illustrator… ტრაექტორიები Illustrator-ში… + Render Video… ვიდეოს რენდერი… + Generate გენერაცია + Image Assets გამოსახულების რესურსები + Place Embedded… ჩადგმულის განთავსება… + Place Linked… დაკავშირებულის განთავსება… + Package… შეფუთვა… + Automate ავტომატიზაცია + Batch… პაკეტური დამუშავება… + Create Droplet… დროპლეტის შექმნა… + Crop and Straighten Photos ფოტოების ჩამოჭრა და გასწორება + Contact Sheet II… საკონტაქტო ფურცელი II… + Conditional Mode Change… რეჟიმის პირობითი შეცვლა… + Fit Image… გამოსახულების ჩატევა… + Lens Correction… ობიექტივის კორექცია… + Merge to HDR Pro… HDR Pro-ში შერწყმა… + Photomerge… Photomerge… + Scripts სკრიპტები + Image Processor… გამოსახულებების დამმუშავებელი… + Delete All Empty Layers ყველა ცარიელი შრის წაშლა + Flatten All Layer Effects შრეების ყველა ეფექტის რასტრირება + Flatten All Masks ყველა ნიღბის გამოყენება + Script Events Manager… სკრიპტის მოვლენების მართვა… + Import იმპორტი + Browse… არჩევა… + Reveal in Finder Finder-ში ჩვენება + Show in Explorer Explorer-ში ჩვენება + Show in Folder საქაღალდეში ჩვენება + File Info… ფაილის ინფორმაცია… + Print… დაბეჭდვა… + Print One Copy ერთი ასლის დაბეჭდვა + Exit გასვლა + Undo დაბრუნება + Redo გამეორება + Toggle Last State ბოლო მდგომარეობის გადართვა + Fade… მილევა… + Cut ამოჭრა + Copy კოპირება + Copy Merged შერწყმულის კოპირება + Paste ჩასმა + Paste Special სპეციალური ჩასმა + Paste in Place ადგილზე ჩასმა + Paste Into შიგნით ჩასმა + Paste Outside გარეთ ჩასმა + Paste Lorem Ipsum Lorem Ipsum-ის ჩასმა + Clear გასუფთავება + Search… ძიება… + Check Spelling… მართლწერის შემოწმება… + Find and Replace Text… ტექსტის ძებნა და ჩანაცვლება… + Fill… შევსება… + Stroke… კონტური… + Content-Aware Fill… შიგთავსის გათვალისწინებით შევსება… + Delete and Fill Selection მონიშნულის წაშლა და შევსება + Content-Aware Scale შიგთავსის გათვალისწინებით მასშტაბირება + Puppet Warp მარიონეტული დეფორმაცია + Perspective Warp პერსპექტიული დეფორმაცია + Free Transform თავისუფალი გარდაქმნა + Transform გარდაქმნა + Again ხელახლა + Scale მასშტაბირება + Rotate შემობრუნება + Skew დახრა + Distort დამახინჯება + Perspective პერსპექტივა + Warp დეფორმაცია + Flip Horizontal ჰორიზონტალურად არეკვლა + Flip Vertical ვერტიკალურად არეკვლა + Rotate 180° 180°-ით შემობრუნება + Rotate 90° Clockwise 90°-ით შემობრუნება საათის ისრის მიმართულებით + Rotate 90° Counter Clockwise 90°-ით შემობრუნება საათის ისრის საწინააღმდეგოდ + 180° 180° + 90° Clockwise 90° საათის ისრის მიმართულებით + 90° Counter Clockwise 90° საათის ისრის საწინააღმდეგოდ + Arbitrary… ნებისმიერი… + Auto-Align Layers… შრეების ავტოსწორება… + Auto-Blend Layers… შრეების ავტოშერევა… + Define Brush Preset… ფუნჯის პრესეტის განსაზღვრა… + Define Pattern… პატერნის განსაზღვრა… + Define Custom Shape… მომხმარებლის ფიგურის განსაზღვრა… + Purge გაწმენდა + Clipboard გაცვლის ბუფერი + Histories ისტორიები + Video Cache ვიდეოს ქეში + All ყველა + Presets პრესეტები + Preset Manager… პრესეტების მართვა… + Migrate Presets პრესეტების მიგრაცია + Export/Import Presets… პრესეტების ექსპორტი/იმპორტი… + Color Settings… ფერის პარამეტრები… + Assign Profile… პროფილის მინიჭება… + Convert to Profile… პროფილში გარდაქმნა… + Keyboard Shortcuts… კლავიატურის მალსახმობები… + Menus… მენიუები… + Toolbar… ხელსაწყოთა ზოლი… + Preferences პარამეტრები + General… ზოგადი… + Interface… ინტერფეისი… + Workspace… სამუშაო სივრცე… + Tools… ხელსაწყოები… + History Log… ისტორიის ჟურნალი… + File Handling… ფაილებთან მუშაობა… + Export… ექსპორტი… + Performance… წარმადობა… + Scratch Disks… დროებითი დისკები… + Cursors… კურსორები… + Transparency & Gamut… გამჭვირვალობა და ფერთა დიაპაზონი… + Units & Rulers… ერთეულები და სახაზავები… + Guides, Grid & Slices… დამხმარე ხაზები, ბადე და ნაჭრები… + Plug-ins… პლაგინები… + Type… ტექსტი… + Enhanced Controls… გაუმჯობესებული მართვის ელემენტები… + Camera Raw… Camera Raw… + Raw Defaults… Raw-ის ნაგულისხმევი პარამეტრები… + Integrations… ინტეგრაციები… + AI Integrations… AI ინტეგრაციები… + Install Plug-in… პლაგინის დაყენება… + Language Options ენის პარამეტრები + Settings… პარამეტრები… + Mode რეჟიმი + Bitmap ბიტური + Grayscale ნაცრისფერი ტონები + Duotone ორტონიანი + Indexed Color ინდექსირებული ფერი + RGB Color RGB ფერი + CMYK Color CMYK ფერი + Lab Color Lab ფერი + Multichannel მრავალარხიანი + 8 Bits/Channel 8 ბიტი/არხი + 16 Bits/Channel 16 ბიტი/არხი + 32 Bits/Channel 32 ბიტი/არხი + Color Table… ფერების ცხრილი… + Bitmap… ბიტური… + Duotone… ორტონიანი… + Indexed Color… ინდექსირებული ფერი… + Adjustments კორექცია + Brightness/Contrast… სიკაშკაშე/კონტრასტი… + Levels… დონეები… + Curves… მრუდები… + Exposure… ექსპოზიცია… + Vibrance… სიხალასე… + Hue/Saturation… ფერის ტონი/გაჯერება… + Color Balance… ფერთა ბალანსი… + Black & White… შავ-თეთრი… + Photo Filter… ფოტოფილტრი… + Channel Mixer… არხების მიქშერი… + Color Lookup… ფერების ცხრილი (Color Lookup)… + Invert ინვერსია + Posterize… პოსტერიზაცია… + Threshold… ზღვარი… + Gradient Map… გრადიენტული რუკა… + Selective Color… შერჩევითი ფერი… + Shadows/Highlights… ჩრდილები/ღია ტონები… + HDR Toning… HDR ტონირება… + Desaturate გაუფერულება + Match Color… ფერის შეთანხმება… + Replace Color… ფერის ჩანაცვლება… + Equalize გათანაბრება + Auto Tone ავტოტონი + Auto Contrast ავტოკონტრასტი + Auto Color ავტოფერი + Image Size… გამოსახულების ზომა… + Canvas Size… ტილოს ზომა… + Image Rotation გამოსახულების შემობრუნება + Flip Canvas Horizontal ტილოს ჰორიზონტალურად არეკვლა + Flip Canvas Vertical ტილოს ვერტიკალურად არეკვლა + Crop ჩამოჭრა + Trim… შეკვეცა… + Reveal All ყველაფრის გამოჩენა + Duplicate… დუბლირება… + Apply Image… გამოსახულების გამოყენება… + Calculations… გამოთვლები… + Variables ცვლადები + Define… განსაზღვრა… + Data Sets… მონაცემთა ნაკრებები… + Apply Data Set… მონაცემთა ნაკრების გამოყენება… + Variable Data Sets… ცვლადი მონაცემების ნაკრებები… + Trap… ტრეპინგი… + Analysis ანალიზი + Set Measurement Scale… საზომი მასშტაბის დაყენება… + Select Data Points… მონაცემთა წერტილების არჩევა… + Record Measurements გაზომვების ჩაწერა + Ruler Tool სახაზავის ხელსაწყო + Count Tool დათვლის ხელსაწყო + Place Scale Marker… მასშტაბის მარკერის განთავსება… + Custom… მომხმარებლის… + New ახალი + Layer… შრე… + Group… ჯგუფი… + Group from Layers… ჯგუფი შრეებიდან… + Artboard… სამუშაო დაფა… + Artboard from Group… სამუშაო დაფა ჯგუფიდან… + Artboard from Layers… სამუშაო დაფა შრეებიდან… + Layer via Copy შრე კოპირებით + Layer via Cut შრე ამოჭრით + Layer From Background… შრე ფონიდან… + Layer from Background… შრე ფონიდან… + Background From Layer ფონი შრიდან + New Shape Layer ახალი ფიგურის შრე + New Type Layer ახალი ტექსტის შრე + New Fill Layer ახალი შევსების შრე + New Adjustment Layer ახალი მაკორექტირებელი შრე + New Layer Based Slice ახალი შრეზე დაფუძნებული ნაჭერი + New Smart Object via Copy ახალი ჭკვიანი ობიექტი კოპირებით + New Blank Video Layer ახალი ცარიელი ვიდეოშრე + New Video Layer from File… ახალი ვიდეოშრე ფაილიდან… + Duplicate Layer… შრის დუბლირება… + Delete Layer შრის წაშლა + Delete წაშლა + Rename Layer შრის სახელის შეცვლა + Create Layer შრის შექმნა + Layer Style შრის სტილი + Blending Options… შერევის პარამეტრები… + Bevel & Emboss… ფასკა და რელიეფი… + Inner Shadow… შიდა ჩრდილი… + Inner Glow… შიდა ნათება… + Satin… ატლასი… + Color Overlay… ფერის გადაფარვა… + Gradient Overlay… გრადიენტის გადაფარვა… + Pattern Overlay… პატერნის გადაფარვა… + Outer Glow… გარე ნათება… + Drop Shadow… ჩრდილი… + Copy Layer Style შრის სტილის კოპირება + Paste Layer Style შრის სტილის ჩასმა + Clear Layer Style შრის სტილის გასუფთავება + Global Light… გლობალური განათება… + Hide All Effects ყველა ეფექტის დამალვა + Scale Effects… ეფექტების მასშტაბირება… + Smart Filter ჭკვიანი ფილტრი + Convert for Smart Filters ჭკვიანი ფილტრებისთვის გარდაქმნა + Clear Smart Filters ჭკვიანი ფილტრების გასუფთავება + Disable Smart Filters ჭკვიანი ფილტრების გამორთვა + Delete Filter Mask ფილტრის ნიღბის წაშლა + Disable Filter Mask ფილტრის ნიღბის გამორთვა + Solid Color… მყარი ფერი… + Gradient… გრადიენტი… + Pattern… პატერნი… + Layer Mask შრის ნიღაბი + Vector Mask ვექტორული ნიღაბი + Reveal Selection მონიშნულის გამოჩენა + Hide Selection მონიშნულის დამალვა + Hide All ყველას დამალვა + From Transparency გამჭვირვალობიდან + Delete Vector Mask ვექტორული ნიღბის წაშლა + Enable Layer Mask შრის ნიღბის ჩართვა + Disable Layer Mask შრის ნიღბის გამორთვა + Enable Vector Mask ვექტორული ნიღბის ჩართვა + Link Layer Mask შრის ნიღბის დაკავშირება + Unlink Layer Mask შრის ნიღბის კავშირის გაწყვეტა + Link Vector Mask ვექტორული ნიღბის დაკავშირება + Add Vector Mask ვექტორული ნიღბის დამატება + Vector Mask from Current Path ვექტორული ნიღაბი მიმდინარე ტრაექტორიიდან + Create Clipping Mask მომჭრელი ნიღბის შექმნა + Release Clipping Mask მომჭრელი ნიღბის მოხსნა + Smart Objects ჭკვიანი ობიექტები + Smart Object ჭკვიანი ობიექტი + Convert to Smart Object ჭკვიან ობიექტად გარდაქმნა + Convert to Embedded ჩადგმულად გარდაქმნა + Convert to Layers შრეებად გარდაქმნა + Convert to Linked… დაკავშირებულად გარდაქმნა… + Edit Contents შიგთავსის რედაქტირება + Replace Contents… შიგთავსის ჩანაცვლება… + Relink to File… ფაილთან ხელახლა დაკავშირება… + Export Contents… შიგთავსის ექსპორტი… + Update Modified Content შეცვლილი შიგთავსის განახლება + Update All Modified Content ყველა შეცვლილი შიგთავსის განახლება + Stack Mode დასტის რეჟიმი + Entropy ენტროპია + Kurtosis ექსცესი + Maximum მაქსიმუმი + Mean საშუალო + Median მედიანა + Minimum მინიმუმი + Range დიაპაზონი + Skewness ასიმეტრია + Standard Deviation სტანდარტული გადახრა + Summation ჯამი + Variance დისპერსია + Video Layers ვიდეოშრეები + Interpret Footage… მასალის ინტერპრეტაცია… + Replace Footage… მასალის ჩანაცვლება… + Reload Frame კადრის ხელახლა ჩატვირთვა + Show Altered Video შეცვლილი ვიდეოს ჩვენება + Restore Frame კადრის აღდგენა + Restore All Frames ყველა კადრის აღდგენა + Insert Blank Frame ცარიელი კადრის ჩასმა + Delete Frame კადრის წაშლა + Duplicate Frame კადრის დუბლირება + Frame from Layers კადრი შრეებიდან + Video Frames to Layers… ვიდეოკადრები შრეებად… + Rasterize რასტრირება + Rasterize Type ტექსტის რასტრირება + Rasterize Type Layer ტექსტის შრის რასტრირება + Rasterize Shape ფიგურის რასტრირება + New Guide Layout… დამხმარე ხაზების ახალი განლაგება… + New Guides From Shape ახალი დამხმარე ხაზები ფიგურიდან + Lock Layers… შრეების ჩაკეტვა… + Link Layers შრეების დაკავშირება + Select Linked Layers დაკავშირებული შრეების მონიშვნა + Merge Layers შრეების შერწყმა + Merge Down ქვედასთან შერწყმა + Merge Visible ხილულების შერწყმა + Stamp Visible ხილულის ანაბეჭდი + Stamp Down ქვედაზე ანაბეჭდი + Flatten Image ერთ შრედ გაერთიანება + Matting კიდის ფონის მოცილება + Color Decontaminate… ფერის გაწმენდა… + Remove Black Matte შავი შემოსაზღვრის მოცილება + Remove White Matte თეთრი შემოსაზღვრის მოცილება + Defringe… კიდის შარავანდის მოცილება… + Group Layers შრეების დაჯგუფება + Ungroup Layers შრეების დაჯგუფების მოხსნა + Hide Layers შრეების დამალვა + Arrange დალაგება + Bring to Front წინა პლანზე გადმოტანა + Bring Forward წინ გადმოტანა + Send Backward უკან გადატანა + Send to Back უკანა პლანზე გადატანა + Reverse შებრუნება + Combine Shapes ფიგურების შეერთება + Unite Shapes ფიგურების გაერთიანება + Subtract Front Shape წინა ფიგურის გამოკლება + Intersect Shape Areas ფიგურების გადაკვეთის არე + Exclude Overlapping Shapes გადაფარული არეების გამორიცხვა + Merge Shape Components ფიგურის კომპონენტების შერწყმა + Align სწორება + Top Edges ზედა კიდეები + Vertical Centers ვერტიკალური ცენტრები + Bottom Edges ქვედა კიდეები + Left Edges მარცხენა კიდეები + Horizontal Centers ჰორიზონტალური ცენტრები + Right Edges მარჯვენა კიდეები + Distribute განაწილება + Vertically ვერტიკალურად + Horizontally ჰორიზონტალურად + Layer Edges შრის კიდეები + Isolate Layers შრეების იზოლირება + Layer Content Options… შრის შიგთავსის პარამეტრები… + Fill Content შევსების შიგთავსი + Deselect Layers შრეების მონიშვნის მოხსნა + Find Layers შრეების ძებნა + Layer Comps შრეების კომპოზიციები + Layers შრეები + All Layers ყველა შრე + Hidden Layers დამალული შრეები + Mask All Objects ყველა ობიექტის ნიღბით დაფარვა + Subject საგანი + Sky ცა + Panels პანელები + Character Panel სიმბოლოს პანელი + Paragraph Panel აბზაცის პანელი + Character Styles Panel სიმბოლოს სტილების პანელი + Paragraph Styles Panel აბზაცის სტილების პანელი + Glyphs Panel გლიფების პანელი + Anti-Alias დაგლუვება + Sharp მკვეთრი + Crisp მკაფიო + Strong ძლიერი + Smooth გლუვი + Windows LCD Windows LCD + Windows Windows + Orientation ორიენტაცია + Horizontal ჰორიზონტალური + Vertical ვერტიკალური + OpenType OpenType + Standard Ligatures სტანდარტული ლიგატურები + Contextual Alternates კონტექსტური ალტერნატივები + Discretionary Ligatures დამატებითი ლიგატურები + Swash მოხატული ასოები (swash) + Oldstyle ძველი სტილის ციფრები + Stylistic Alternates სტილისტური ალტერნატივები + Titling Alternates სათაურის ალტერნატივები + Ornaments ორნამენტები + Ordinals რიგობითი რიცხვები + Fractions წილადები + Create Work Path სამუშაო ტრაექტორიის შექმნა + Convert to Shape ფიგურად გარდაქმნა + Warp Text… ტექსტის დეფორმაცია… + Convert to Point Text წერტილოვან ტექსტად გარდაქმნა + Convert to Paragraph Text აბზაცის ტექსტად გარდაქმნა + Default Features ნაგულისხმევი შესაძლებლობები + East Asian Features აღმოსავლეთაზიური შესაძლებლობები + Middle Eastern Features ახლოაღმოსავლური შესაძლებლობები + Middle Eastern & South Asian Composer აწყობა ახლო აღმოსავლეთისა და სამხრეთ აზიის დამწერლობებისთვის + Update All Text Layers ყველა ტექსტური შრის განახლება + Replace All Missing Fonts ყველა ნაკლული შრიფტის ჩანაცვლება + Resolve Missing Fonts… ნაკლული შრიფტების მოგვარება… + Font Preview Size შრიფტის წინასწარი ნახვის ზომა + None არცერთი + Small პატარა + Medium საშუალო + Large დიდი + Extra Large ძალიან დიდი + Huge უზარმაზარი + Load Default Type Styles ტექსტის ნაგულისხმევი სტილების ჩატვირთვა + Save Default Type Styles ტექსტის ნაგულისხმევი სტილების შენახვა + Deselect მონიშვნის მოხსნა + Reselect ხელახლა მონიშვნა + Inverse შებრუნება + Inverse Selection მონიშვნის შებრუნება + Similar მსგავსი + Color Range… ფერების დიაპაზონი… + Focus Area… ფოკუსის არე… + Select and Mask… მონიშვნა და ნიღაბი… +# Select and Mask dialog labels + Smart Radius ჭკვიანი რადიუსი + Shift Edge კიდის წანაცვლება + Decontaminate ფერის გაწმენდა + Refine Edge კიდის დაზუსტება + Modify შეცვლა + Border… საზღვარი… + Smooth… დაგლუვება… + Expand… გაფართოება… + Contract… შევიწროება… + Feather… კიდეების შერბილება… + Grow ზრდა + Transform Selection მონიშნულის გარდაქმნა + Edit in Quick Mask Mode სწრაფი ნიღბის რეჟიმში რედაქტირება + Load Selection… მონიშნულის ჩატვირთვა… + Save Selection… მონიშნულის შენახვა… + Match All ყველას შესაბამისობა + Last Filter ბოლო ფილტრი + Filter Gallery… ფილტრების გალერეა… + Adaptive Wide Angle… ადაპტიური ფართოკუთხა… + Camera Raw Filter… Camera Raw ფილტრი… + Liquify… გათხევადება… + Vanishing Point… გაქრობის წერტილი… + Blur გაბუნდოვნება + Blur Gallery გაბუნდოვნების გალერეა + Field Blur… ველის გაბუნდოვნება… + Iris Blur… დიაფრაგმის გაბუნდოვნება… + Tilt-Shift… დახრა-წანაცვლება… + Path Blur… ტრაექტორიის გასწვრივ გაბუნდოვნება… + Spin Blur… ბრუნვითი გაბუნდოვნება… + Blur More მეტად გაბუნდოვნება + Box Blur… ბლოკური გაბუნდოვნება… + Gaussian Blur… გაუსის გაბუნდოვნება… + Lens Blur… ლინზის ბუნდოვნება… + Motion Blur… მოძრაობის გაბუნდოვნება… + Radial Blur… რადიალური გაბუნდოვნება… + Shape Blur… ფიგურით გაბუნდოვნება… + Smart Blur… ჭკვიანი გაბუნდოვნება… + Surface Blur… ზედაპირის გაბუნდოვნება… + Average გასაშუალოება + Displace… წანაცვლება… + Pinch… შეკუმშვა… + Polar Coordinates… პოლარული კოორდინატები… + Ripple… ტალღოვნება… + Shear… დახრა… + Spherize… სფერიზება… + Twirl… დაგრეხა… + Wave… ტალღა… + ZigZag… ზიგზაგი… + Noise ხმაური + Add Noise… ხმაურის დამატება… + Despeckle ლაქების მოცილება + Dust & Scratches… მტვერი და ნაკაწრები… + Median… მედიანა… + Reduce Noise… ხმაურის შემცირება… + Pixelate პიქსელიზაცია + Color Halftone… ფერადი ნახევარტონი… + Crystallize… კრისტალიზაცია… + Facet წახნაგი + Fragment ფრაგმენტი + Mezzotint… მეცოტინტა… + Mosaic… მოზაიკა… + Pointillize… პუანტილიზმი… + Render რენდერი + Clouds ღრუბლები + Difference Clouds სხვაობის ღრუბლები + Fibers… ბოჭკოები… + Flame… ალი… + Lens Flare… ობიექტივის ანარეკლი… + Lighting Effects… განათების ეფექტები… + Relight… ხელახალი განათება… + Picture Frame… სურათის ჩარჩო… + Tree… ხე… + Sharpen გამკვეთრება + Sharpen Edges კიდეების გამკვეთრება + Sharpen More მეტად გამკვეთრება + Smart Sharpen… ჭკვიანი გამკვეთრება… + Unsharp Mask… არამკვეთრი ნიღაბი… + High Pass… მაღალსიხშირული ფილტრი… + Stylize სტილიზაცია + Diffuse… დიფუზია… + Emboss… რელიეფი… + Extrude… ამოწევა… + Find Edges კიდეების პოვნა + Oil Paint… ზეთის საღებავი… + Solarize სოლარიზაცია + Tiles… ფილები… + Trace Contour… კონტურის გავლება… + Wind… ქარი… + Video ვიდეო + De-Interlace… სტრიქონგამოტოვების მოცილება… + NTSC Colors NTSC ფერები + Other სხვა + Maximum… მაქსიმუმი… + Minimum… მინიმუმი… + Offset… წანაცვლება… + Plug-ins პლაგინები + Proof Setup საცდელი ჩვენების პარამეტრები + Proof Colors ფერების საცდელი ჩვენება + Gamut Warning ფერთა გამიდან გასვლის გაფრთხილება + Working CMYK სამუშაო CMYK + Working Cyan Plate სამუშაო ცისფერი ფირფიტა + Working Magenta Plate სამუშაო მეწამული ფირფიტა + Working Yellow Plate სამუშაო ყვითელი ფირფიტა + Working Black Plate სამუშაო შავი ფირფიტა + Working CMY Plate სამუშაო CMY ფირფიტა + Legacy Macintosh RGB მოძველებული Macintosh RGB + Internet Standard RGB ინტერნეტის სტანდარტული RGB + Monitor RGB მონიტორის RGB + Color Blindness — Protanopia-type დალტონიზმი — პროტანოპიის ტიპი + Color Blindness — Deuteranopia-type დალტონიზმი — დეიტერანოპიის ტიპი + Zoom In გადიდება + Zoom Out დაპატარავება + Fit on Screen ეკრანში ჩატევა + Fit Layer(s) on Screen შრე(ებ)ის ეკრანში ჩატევა + Fit Artboard on Screen სამუშაო დაფის ეკრანში ჩატევა + 100% 100% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size საბეჭდი ზომა + Pixel Aspect Ratio პიქსელის გვერდების თანაფარდობა + Pixel Aspect Ratio Correction პიქსელის გვერდების თანაფარდობის კორექცია + Custom Pixel Aspect Ratio… პიქსელის გვერდების მომხმარებლის თანაფარდობა… + Square კვადრატული + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC ფართოეკრანიანი (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL ფართოეკრანიანი (1,46) + Anamorphic 2:1 (2) ანამორფული 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + 32-bit Preview Options… 32-ბიტიანი გადახედვის პარამეტრები… + Screen Mode ეკრანის რეჟიმი + Standard Screen Mode ეკრანის სტანდარტული რეჟიმი + Full Screen Mode With Menu Bar სრულეკრანიანი რეჟიმი მენიუს ზოლით + Full Screen Mode სრულეკრანიანი რეჟიმი + Cycle Screen Mode ეკრანის რეჟიმების მორიგეობით გადართვა + Show ჩვენება + Extras დამატებითი ელემენტები + Selection Edges მონიშნულის კიდეები + Target Path სამიზნე ტრაექტორია + Current Path მიმდინარე ტრაექტორია + Grid ბადე + Guides დამხმარე ხაზები + Canvas Guides ტილოს დამხმარე ხაზები + Artboard Guides სამუშაო დაფის დამხმარე ხაზები + Count დათვლა + Smart Guides ჭკვიანი დამხმარე ხაზები + Slices ნაჭრები + Notes შენიშვნები + Pixel Grid პიქსელური ბადე + Pixel Art პიქსელ-არტი + Pixel Art Preview პიქსელ-არტის გადახედვა + Pattern Preview პატერნის გადახედვა + Brush Preview ფუნჯის გადახედვა + Mesh დეფორმაციის ბადე + Edit Pins სამაგრების რედაქტირება + Document Bounds დოკუმენტის საზღვრები + Show Extras Options… დამატებითი ელემენტების ჩვენების პარამეტრები… + Rulers სახაზავები + Snap მიბმა + Snap To მიბმის ობიექტები + Lock Guides დამხმარე ხაზების ჩაკეტვა + Clear Guides დამხმარე ხაზების გასუფთავება + Clear Selected Artboard Guides მონიშნული სამუშაო დაფის დამხმარე ხაზების წაშლა + Clear Canvas Guides ტილოს დამხმარე ხაზების წაშლა + New Guide… ახალი დამხმარე ხაზი… + Lock Slices ნაჭრების ჩაკეტვა + Clear Slices ნაჭრების წაშლა + Match Zoom მასშტაბის შეთანხმება + Match Location მდებარეობის შეთანხმება + Match Rotation შემობრუნების შეთანხმება + Cascade კასკადი + Tile ფილებად + Tile All Horizontally ყველას ჰორიზონტალურად განლაგება + Tile All Vertically ყველას ვერტიკალურად განლაგება + 2-up Horizontal 2 ჰორიზონტალურად + 2-up Vertical 2 ვერტიკალურად + 3-up Horizontal 3 ჰორიზონტალურად + 3-up Stacked 3 დასტად + 3-up Vertical 3 ვერტიკალურად + 4-up 4 ფანჯარა + 6-up 6 ფანჯარა + Consolidate All to Tabs ყველას ჩანართებად გაერთიანება + Float in Window ცალკე ფანჯარაში გამოტანა + Float All in Windows ყველას მცურავ ფანჯრებში + New Window for Document დოკუმენტის ახალი ფანჯარა + Workspace სამუშაო სივრცე + Essentials ძირითადი + Graphic and Web გრაფიკა და ვები + Painting ხატვა + Photography ფოტოგრაფია + New Workspace… ახალი სამუშაო სივრცე… + Delete Workspace… სამუშაო სივრცის წაშლა… + Reset Workspace სამუშაო სივრცის საწყისზე დაბრუნება + Reset Tool ხელსაწყოს ჩამოყრა + Reset All Tools ყველა ხელსაწყოს ჩამოყრა + Reset all tools to their default settings? ჩამოიყაროს ყველა ხელსაწყო ნაგულისხმევ პარამეტრებზე? + Lock Workspace სამუშაო სივრცის ჩაკეტვა + Theme თემა + Pro Theme Pro თემა + Pro Medium Gray Theme Pro საშუალო ნაცრისფერი თემა + Pro Light Gray Theme Pro ღია ნაცრისფერი თემა + Pro Light Theme Pro ღია თემა + Studio Theme Studio თემა + Studio Light Theme Studio ღია თემა + Adwaita Light Theme Adwaita ღია თემა + Adwaita Dark Theme Adwaita ბნელი თემა + Classic Theme კლასიკური თემა + Solarized dark Solarized ბნელი + Solarized Dark Theme Solarized ბნელი თემა + Breeze dark Breeze ბნელი + Breeze Dark Theme Breeze ბნელი თემა + Breeze light Breeze ღია + Breeze Light Theme Breeze ღია თემა + Next Theme შემდეგი თემა + Actions მოქმედებები + Brush Settings ფუნჯის პარამეტრები + Brushes ფუნჯები + Channels არხები + Character სიმბოლო + Character Styles სიმბოლოს სტილები + Clone Source კლონირების წყარო + Color ფერი + Glyphs გლიფები + Gradients გრადიენტები + Histogram ჰისტოგრამა + History ისტორია + Info ინფორმაცია + Measurement Log გაზომვების ჟურნალი + Modifier Keys მოდიფიკატორი კლავიშები + Navigator ნავიგატორი + Options პარამეტრები + Paragraph აბზაცი + Paragraph Styles აბზაცის სტილები + Paths ტრაექტორიები + Patterns პატერნები + Properties თვისებები + Shapes ფიგურები + Styles სტილები + Swatches ფერის ნიმუშები + Timeline დროის ხაზი + Tool Presets ხელსაწყოს პრესეტები + Tools ხელსაწყოები + About PhotoCraft PhotoCraft-ის შესახებ + PhotoCraft Website PhotoCraft-ის ვებსაიტი + PhotoCraft on GitHub PhotoCraft GitHub-ზე + ArtCraft Website ArtCraft-ის ვებსაიტი + Join the ArtCraft Discord… შემოუერთდით ArtCraft-ის Discord-ს… + Report an Issue… ხარვეზის შეტყობინება… + WIA Support… WIA მხარდაჭერა… + Apply გამოყენება + HSB/HSL HSB/HSL + Invert… ინვერსია… + Load Files into Stack… ფაილების დასტაში ჩატვირთვა… + Motion მოძრაობა + Notes… შენიშვნები… + Other… სხვა… + Remove Warp Split დეფორმაციის გაყოფის მოცილება + Shape ფიგურა + Split Warp Crosswise დეფორმაციის ჯვარედინად გაყოფა + Split Warp Horizontally დეფორმაციის ჰორიზონტალურად გაყოფა + Split Warp Vertically დეფორმაციის ვერტიკალურად გაყოფა + Statistics… სტატისტიკა… + Refine Strokes დაზუსტების შტრიხები + Edits ცვლილებები +@plural {n} item|{n} items {n} ელემენტი + Auto ავტომატური + (print to the printer) (პრინტერზე ბეჭდვა) + + Add variable + ცვლადის დამატება + 30 frames @ 30 fps 30 კადრი @ 30 fps + 3D LUT File 3D LUT ფაილი + Absolute აბსოლუტური + Absolute Colorimetric აბსოლუტური კოლორიმეტრიული + Action მოქმედება + Adaptive ადაპტური + Adaptive Wide Angle isn't open ადაპტიური ფართოკუთხა არ არის გახსნილი + Add დამატება + Add Layer Mask შრის ნიღბის დამატება + Add Stop გადასვლის წერტილის დამატება + Add a layer style შრის სტილის დამატება + Add a mask (from the selection; {key} inverts) ნიღბის დამატება (მონიშნულიდან; {key} აბრუნებს) + Add an adjustment კორექციის დამატება + Add disk დისკის დამატება + Add space after paragraph ინტერვალის დამატება აბზაცის შემდეგ + Add space before paragraph ინტერვალის დამატება აბზაცის წინ + Add to Sample ნიმუშში დამატება + Add to selection ({key}) მონიშნულში დამატება ({key}) + Adjust for Zoom მასშტაბის გათვალისწინება + Adjustment visible კორექცია ხილულია + Adobe RGB (1998) compatible Adobe RGB (1998)-თან თავსებადი + Align and Distribute სწორება და განაწილება + Align and distribute სწორება და განაწილება + Align horizontal centers ჰორიზონტალური ცენტრების სწორება + Align left edges მარცხენა კიდეების სწორება + Align right edges მარჯვენა კიდეების სწორება + Aligned გასწორებული + All Caps მთავრული + All Slices ყველა ნაჭერი + All User Slices მომხმარებლის ყველა ნაჭერი + All shortcuts reset to PhotoCraft defaults. ყველა მალსახმობი დაბრუნდა PhotoCraft-ის ნაგულისხმევზე. + Alt Alt + Always use pressure for opacity გაუმჭვირვალობისთვის ყოველთვის დაწოლის გამოყენება + Always use pressure for size ზომისთვის ყოველთვის დაწოლის გამოყენება + Amount რაოდენობა + Anchor: მიმაგრება: + Angle კუთხე + Angle Gradient კუთხური გრადიენტი + Angle Jitter კუთხის ცვალებადობა + Answer the unsaved-changes prompt first ჯერ უპასუხეთ შეუნახავი ცვლილებების შეკითხვას + Anti-alias დაგლუვება + Appearance იერსახე + Appearance (Layer Style) იერსახე (შრის სტილი) + Apply Layer Mask შრის ნიღბის გამოყენება + Apply Per Tip თითოეულ წვერზე გამოყენება + Apply next selected layer comp შემდეგი მონიშნული შრეების კომპოზიციის გამოყენება + Apply previous selected layer comp წინა მონიშნული შრეების კომპოზიციის გამოყენება + Artboard სამუშაო დაფა + Artboard Background Color სამუშაო დაფის ფონის ფერი + Assign working space სამუშაო სივრცის მინიჭება + Author: ავტორი: + Auto Hide ავტომატური დამალვა + Auto-Select: ავტომონიშვნა: + Automatically level near-horizontal line segments თითქმის ჰორიზონტალური მონაკვეთების ავტომატური გასწორება + Automatically straighten and level ავტომატური გასწორება და დონეზე მოყვანა + Automatically straighten near-vertical line segments თითქმის ვერტიკალური მონაკვეთების ავტომატური გასწორება + Background ფონი + Background Color ფონის ფერი + Background Contents ფონის შიგთავსი + Balance ბალანსი + Baseline საბაზისო ხაზი + Baseline shift საბაზისო ხაზის წანაცვლება + Basic საბაზო + Before (Y) მანამდე (Y) + Begin recording ჩაწერის დაწყება + Bend: მოხრა: + Bicubic ბიკუბური + Bicubic (smooth gradients) ბიკუბური (გლუვი გრადიენტებისთვის) + Automatic ავტომატური + Bicubic Smoother (enlargement) ბიკუბური, უფრო გლუვი (გადიდებისთვის) + Bicubic Sharper (reduction) ბიკუბური, უფრო მკვეთრი (შემცირებისთვის) + Bilinear ბიწრფივი + Black შავი + Black & White შავ-თეთრი + Black Point Compensation შავი წერტილის კომპენსაცია + Black, White შავი, თეთრი + Blacks შავები + Blending შერევა + Blending Options შერევის პარამეტრები + Blue ლურჯი + Blue, Red, Yellow ლურჯი, წითელი, ყვითელი + Both Axes ორივე ღერძი + Bottom ქვემოთ + Brightness სიკაშკაშე + Brightness Jitter სიკაშკაშის ცვალებადობა + Brightness/Contrast სიკაშკაშე/კონტრასტი + Bring the selected pin forward მონიშნული სამაგრის წინ გადმოტანა + Brush Preset picker ფუნჯის პრესეტის ამრჩევი + Brush Reconstruct Options ფუნჯით აღდგენის პარამეტრები + Brush Tool Options ფუნჯის ხელსაწყოს პარამეტრები + Cache Level: ქეშის დონე: + Camera Raw isn't open Camera Raw არ არის გახსნილი + Cancel გაუქმება + Cancel (Esc) გაუქმება (Esc) + Cancel any current edits (Esc) მიმდინარე ცვლილებების გაუქმება (Esc) + Cancel current crop operation (Esc) მიმდინარე ჩამოჭრის გაუქმება (Esc) + Cancel transform (Esc) გარდაქმნის გაუქმება (Esc) + Cancel warp (Esc) დეფორმაციის გაუქმება (Esc) + Canvas ტილო + Canvas extension color: ტილოს გაფართოების ფერი: + Capture ჩაწერა + Capture the visibility, position and style of every layer with + below, then switch between versions. + ღილაკით ქვემოთ ჩაწერეთ ყოველი შრის ხილვადობა, პოზიცია და სტილი, შემდეგ კი ვერსიებს შორის გადართეთ. + Catch-up on Stroke End მონასმის ბოლოს დაწევა + Center ცენტრში + Center Crop Marks ცენტრის ჭრის ნიშნები + Center text ტექსტის ცენტრირება + Change ჩანაცვლება + Change All ყველას ჩანაცვლება + Change Screen Mode (F) ეკრანის რეჟიმის შეცვლა (F) + Change To: ჩანაცვლება: + Channel Mixer არხების მიქშერი + Channel: არხი: + Check All Layers ყველა შრის შემოწმება + Check Spelling მართლწერის შემოწმება + Clarity სიცხადე + Clear All ყველას გასუფთავება + Clear Override გადაფარვის მოხსნა + Clear Samples ნიმუშების გასუფთავება + Click sets foreground · right-click sets background დაწკაპუნება აყენებს წინა პლანის ფერს · მარჯვენა დაწკაპუნება — ფონის ფერს + Click the first point or press {key} to close · Esc cancels დასახურად დაწკაპეთ პირველ წერტილზე ან დააჭირეთ {key}-ს · Esc აუქმებს + Click to add a point · drag off or {key}-click or Delete to remove დაწკაპეთ წერტილის დასამატებლად · მოსაშორებლად გადაათრიეთ გარეთ, {key}-დაწკაპეთ ან დააჭირეთ Delete-ს + Click to apply, {key}-click to clear overrides, double-click for Style Options დაწკაპეთ გამოსაყენებლად, {key}-დაწკაპუნება ხსნის გადაფარვებს, ორმაგი დაწკაპუნება — სტილის პარამეტრები + Click to edit the gradient დაწკაპეთ გრადიენტის რედაქტირებისთვის + Click to restore the current colour დაწკაპეთ მიმდინარე ფერის აღსადგენად + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out დაწკაპეთ გასადიდებლად · {key}-დაწკაპუნება — დასაპატარავებლად · მარჯვნივ/მარცხნივ გადათრევა — გადიდება/დაპატარავება + Click with the Note tool to add a note. შენიშვნის დასამატებლად დაწკაპეთ შენიშვნის ხელსაწყოთი. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · პირველ წერტილზე დაწკაპუნება: დახურვა · {key} დასრულება · Esc გაუქმება + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · {alt}-გადათრევა: სახელურის გაწყვეტა · პირველ წერტილზე დაწკაპუნება ან გადათრევა: დახურვა · {key} დასრულება · Esc გაუქმება + Clip to layer below ქვედა შრეში ჩაჭრა + Clipped ჩაჭრილი + Close Style Options სტილის პარამეტრების დახურვა + Cmd Cmd + Coated CMYK ცარცული ქაღალდის CMYK + Color & Swatches ფერი და ფერის ნიმუშები + Color Balance ფერთა ბალანსი + Color Detail ფერის დეტალები + Color Grading ფერის კორექცია (grading) + Color Lookup ფერების ცხრილი (Color Lookup) + Color Management ფერების მართვა + Color Mixer ფერების მიქშერი + Color Mode ფერის რეჟიმი + Color Priority ფერის პრიორიტეტი + Color Range preview ფერების დიაპაზონის გადახედვა + Color: ფერი: + Colorize გაფერადება + Colors ფერები + Commit Perspective Warp ({key}) პერსპექტიული დეფორმაციის დადასტურება ({key}) + Commit Puppet Warp ({key}) მარიონეტული დეფორმაციის დადასტურება ({key}) + Commit any current edits ({key}) მიმდინარე ცვლილებების დადასტურება ({key}) + Commit current crop operation ({key}) მიმდინარე ჩამოჭრის დადასტურება ({key}) + Commit transform ({key}) გარდაქმნის დადასტურება ({key}) + Commit warp ({key}) დეფორმაციის დადასტურება ({key}) + Constant მუდმივი + Constrain proportions პროპორციების შენარჩუნება + Contact Sheet II საკონტაქტო ფურცელი II + Content-Aware შიგთავსის გათვალისწინებით + Contiguous მომიჯნავე + Contrast კონტრასტი + Control: მართვა: + Convert to sRGB sRGB-ად გარდაქმნა + Copies ასლები + Copper სპილენძი + Copyright საავტორო უფლება + Copyright and Contact Info საავტორო უფლება და საკონტაქტო ინფორმაცია + Corner Crop Marks კუთხის ჭრის ნიშნები + Corner radius კუთხის რადიუსი + Correction კორექცია + Count Jitter რაოდენობის ცვალებადობა + Create შექმნა + Create Droplet დროპლეტის შექმნა + Create Texture ტექსტურის შექმნა + Create Video Timeline ვიდეოს დროის ხაზის შექმნა + Create a new count group ახალი დათვლის ჯგუფის შექმნა + Create a new document or open an existing file. შექმენით ახალი დოკუმენტი ან გახსენით არსებული ფაილი. + Create a new group ახალი ჯგუფის შექმნა + Create a new layer ახალი შრის შექმნა + Create new action ახალი მოქმედების შექმნა + Create new channel ახალი არხის შექმნა + Create new document from current state ახალი დოკუმენტის შექმნა მიმდინარე მდგომარეობიდან + Create new fill or adjustment layer ახალი შევსების ან მაკორექტირებელი შრის შექმნა + Create new gradient ახალი გრადიენტის შექმნა + Create new group ახალი ჯგუფის შექმნა + Create new layer comp შრეების ახალი კომპოზიციის შექმნა + Create new pattern from the selection ახალი პატერნის შექმნა მონიშნულიდან + Create new shape from the current path ახალი ფიგურის შექმნა მიმდინარე ტრაექტორიიდან + Create new snapshot ახალი სნეპშოტის შექმნა + Create new style (from the selected type) ახალი სტილის შექმნა (მონიშნული ტექსტიდან) + Create new style from the selected layer ახალი სტილის შექმნა მონიშნული შრიდან + Create new tool preset ხელსაწყოს ახალი პრესეტის შექმნა + Crop Factor ჩამოჭრის კოეფიციენტი + Ctrl Ctrl + Current & Below მიმდინარე და ქვედა + Current Layer მიმდინარე შრე + Current Size მიმდინარე ზომა + Current Tool Only მხოლოდ მიმდინარე ხელსაწყო + Curve მრუდი + Curves მრუდები + Custom მორგებული + Custom Shapes მორგებული ფიგურები + Cyan ცისფერი + Cyan · Red ცისფერი · წითელი + Darken ჩამუქება + Darks მუქი ტონები + Data Sets მონაცემთა ნაკრებები + Default ნაგულისხმევი + Default colours (D) ნაგულისხმევი ფერები (D) + Default printer ნაგულისხმევი პრინტერი + Default restored. ნაგულისხმევი აღდგენილია. + Define განსაზღვრა + Define at least one variable first. ჯერ განსაზღვრეთ ერთი ცვლადი მაინც. + Dehaze ბურუსის მოცილება + Delete Cropped Pixels ჩამოჭრილი პიქსელების წაშლა + Delete Group ჯგუფის წაშლა + Delete Layer Mask შრის ნიღბის წაშლა + Delete Layers შრეების წაშლა + Delete Shortcut მალსახმობის წაშლა + Delete Timeline დროის ხაზის წაშლა + Delete Tool Preset ხელსაწყოს პრესეტის წაშლა + Delete count group დათვლის ჯგუფის წაშლა + Delete current channel მიმდინარე არხის წაშლა + Delete current state მიმდინარე მდგომარეობის წაშლა + Delete effect layer ეფექტის შრის წაშლა + +# Layers-panel trash confirmation + Delete layer ‘{name}’? წაიშალოს შრე ‘{name}’? + Delete {count} layers? {names} წაიშალოს შრეები ({count})? {names} + Delete layer? წაიშალოს შრე? + The document is closed დოკუმენტი დახურულია + The document or selected layers changed. Please request deletion again. დოკუმენტი ან მონიშნული შრეები შეიცვალა. გთხოვთ, წაშლა ხელახლა მოითხოვოთ. + Missing delete command წაშლის ბრძანება აკლია + Invalid delete command წაშლის ბრძანება არასწორია + Layer deleted, but confirmation preference could not be saved: {error} შრე წაიშალა, მაგრამ დადასტურების პარამეტრი ვერ შეინახა: {error} + + Delete layer შრის წაშლა + Delete layer comp შრეების კომპოზიციის წაშლა + Delete note შენიშვნის წაშლა + Delete selected measurements მონიშნული გაზომვების წაშლა + Delete style სტილის წაშლა + Delete tool preset ხელსაწყოს პრესეტის წაშლა + Density სიმკვრივე + Density: სიმკვრივე: + Depth სიღრმე + Depth Jitter სიღრმის ცვალებადობა + Description აღწერა + Deselect All ყველა მონიშვნის მოხსნა + Detail დეტალი + Diamond Gradient ბრილიანტისებრი გრადიენტი + Difference სხვაობა + Diffusion დიფუზია + Discard the embedded profile (don't color manage) ჩაშენებული პროფილის უგულებელყოფა (ფერების მართვის გარეშე) + Discontiguous არამომიჯნავე + Discord Discord + Dismiss დახურვა + Display P3 Display P3 + Display Text ტექსტის ჩვენება + Dither დიზერინგი + Divide… გაყოფა… + Document დოკუმენტი + Don't Save არ შეინახო + No არა + Yes დიახ + Done მზადაა + Dots წერტილები + Drag a rectangle around the object ობიექტის გარშემო მართკუთხედი გაავლეთ + Drag to move the active shape's path or the Work Path გადაათრიეთ აქტიური ფიგურის ტრაექტორიის ან სამუშაო ტრაექტორიის გადასაადგილებლად + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath დაწკაპუნება: საყრდენი წერტილის მონიშვნა · გადათრევა: საყრდენი წერტილის, სახელურის ან სეგმენტის გადაადგილება · {shift}-დაწკაპუნება: დამატება · {alt}-დაწკაპუნება: მთელი ქვეტრაექტორია + Draw with the Pen tool (P) or make a work path from a selection. დახაზეთ კალმით (P) ან შექმენით სამუშაო ტრაექტორია მონიშნულიდან. + Drop an image or PSD anywhere to open it. გასახსნელად ჩააგდეთ გამოსახულება ან PSD ნებისმიერ ადგილას. + Duplicate Layers… შრეების დუბლირება… + Duplicate style სტილის დუბლირება + Edit Toolbar… ხელსაწყოთა ზოლის რედაქტირება… + Edit in Quick Mask Mode (Q) რედაქტირება სწრაფი ნიღბის რეჟიმში (Q) + Edit in Standard Mode (Q) რედაქტირება სტანდარტულ რეჟიმში (Q) + Effects ეფექტები + Embed Color Profile ფერის პროფილის ჩაშენება + Embedded Profile Mismatch ჩაშენებული პროფილის შეუსაბამობა + Enable airbrush-style build-up effects აეროგრაფის სტილის დაგროვების ეფექტის ჩართვა + Enhance Edge კიდის გაძლიერება + Entire Font მთელი შრიფტი + Exact ზუსტი + Expansion: გაფართოება: + Export Presets ექსპორტის პრესეტები + Export selected measurements (CSV) მონიშნული გაზომვების ექსპორტი (CSV) + Exposure ექსპოზიცია + Exposure and Gamma ექსპოზიცია და გამა + Exposure: ექსპოზიცია: + Eyedropper პიპეტი + Factor: კოეფიციენტი: + Faux Bold ხელოვნური მუქი + Faux Italic ხელოვნური დახრილი + Feather კიდეების შერბილება + Fewer Points ნაკლები წერტილი + File Settings ფაილის პარამეტრები + Fill შევსება + Fill: შევსება: + Filter for adjustment layers მაკორექტირებელი შრეების ფილტრი + Filter for pixel layers პიქსელური შრეების ფილტრი + Filter for shape layers ფიგურის შრეების ფილტრი + Filter for smart objects ჭკვიანი ობიექტების ფილტრი + Filter for type layers ტექსტური შრეების ფილტრი + Filter: ფილტრი: + Finger Painting თითით ხატვა + Fisheye თევზის თვალი + Fit მორგება + Fit Screen ეკრანში ჩატევა + Fill Screen ეკრანის შევსება + Fit in View ხედში ჩატევა + Fixed ფიქსირებული + Fixed Ratio ფიქსირებული პროპორცია + Fixed Size ფიქსირებული ზომა + Flip არეკვლა + Flip X X-ით არეკვლა + Flip X Jitter X-ით არეკვლის ცვალებადობა + Flip Y Y-ით არეკვლა + Flip Y Jitter Y-ით არეკვლის ცვალებადობა + Flow ნაკადი + Flow Jitter ნაკადის ცვალებადობა + Focal Length ფოკუსური მანძილი + Font შრიფტი + Font Size: შრიფტის ზომა: + Font size შრიფტის ზომა + Foreground წინა პლანი + Foreground to Background წინა პლანიდან ფონისკენ + Foreground/Background Jitter წინა პლანის/ფონის ცვალებადობა + Format ფორმატი + Frame from Layers… კადრი შრეებიდან… + Free თავისუფალი + Full Spherical სრული სფერული + Fuzziness: გაბნეულობა: + GPU GPU + Gamma Correction გამა-კორექცია + Gamma: გამა: + Global გლობალური + Glow Color ნათების ფერი + Gradient Map გრადიენტული რუკა + Grain მარცვლოვნება + Gray ნაცრისფერი + Gray Gamma 2.2 Gray Gamma 2.2 + Green მწვანე + Group ჯგუფი + Hardness სიხისტე + Heart გული + Height სიმაღლე + Height: სიმაღლე: + Hide Auto Slices ავტომატური ნაჭრების დამალვა + Hide Properties თვისებების დამალვა + Highlight Compression ღია ტონების შეკუმშვა + Highlight Priority ღია ტონების პრიორიტეტი + Highlights ღია ტონები + Home საწყისი ეკრანი + Horizontal scale ჰორიზონტალური მასშტაბი + Hue ფერის ტონი + Hue Jitter ფერის ტონის ცვალებადობა + Hue/Saturation ფერის ტონი/გაჯერება + Hyphenate სიტყვების გადატანა + Ignore უგულებელყოფა + Ignore All ყველას გამოტოვება + Image Size გამოსახულების ზომა + Import Brushes… ფუნჯების იმპორტი… + Import brushes (.abr) ფუნჯების იმპორტი (.abr) + Import Presets პრესეტების იმპორტი + Imported იმპორტირებული + Indent First პირველი სტრიქონის შეწევა + Indent Left მარცხენა შეწევა + Indent Right მარჯვენა შეწევა + Indent first line პირველი სტრიქონის შეწევა + Indent left margin მარცხენა ველის შეწევა + Indent right margin მარჯვენა ველის შეწევა + Input: შემავალი: + Inside შიგნით + Interlaced ინტერლეისი + Interpolation: ინტერპოლაცია: + Intersect with selection ({key}) მონიშნულთან გადაკვეთა ({key}) + Invert All ყველაფრის ინვერსია + Invert has no settings. ინვერსიას პარამეტრები არ აქვს. + Italic დახრილი + Join us on Discord შემოგვიერთდით Discord-ზე + Justify all სრული ორმხრივი სწორება + Justify last centered ორმხრივი სწორება, ბოლო სტრიქონი ცენტრში + Justify last left ორმხრივი სწორება, ბოლო სტრიქონი მარცხნივ + Justify last right ორმხრივი სწორება, ბოლო სტრიქონი მარჯვნივ + Kerning კერნინგი + Keyboard Shortcuts კლავიატურის მალსახმობები + Keyboard Shortcuts and Menus კლავიატურის მალსახმობები და მენიუები + Kind სახეობა + Label Size წარწერის ზომა + Labels წარწერები + Lanczos (sharp) Lanczos (მკვეთრი) + Landscape ჰორიზონტალური + Last Document State დოკუმენტის ბოლო მდგომარეობა + Layout განლაგება + Leading სტრიქონთა დაშორება + Leading (set to the font size × auto-leading when Auto) სტრიქონთა დაშორება (ავტომატურ რეჟიმში უდრის შრიფტის ზომას × ავტომატური დაშორება) + Leave as is (don't color manage) დატოვება როგორც არის (ფერების მართვის გარეშე) + Left align text ტექსტის მარცხნივ სწორება + Length: სიგრძე: + Level დონე + Levels დონეები + Lighten გაღიავება + Lightness სიღიავე + Lights ნათელი ტონები + Limits: ზღვრები: + Linear Gradient წრფივი გრადიენტი + Link layers შრეების დაკავშირება + Link width and height სიგანისა და სიმაღლის დაკავშირება + Liquify is not open გათხევადება არ არის გახსნილი + Load ჩატვირთვა + Load 3D LUT… 3D LUT-ის ჩატვირთვა… + Load channel as selection არხის ჩატვირთვა მონიშნულად + Load… ჩატვირთვა… + Localized Color Clusters ფერთა ლოკალური კლასტერები + Lock ჩაკეტვა + Lock all ყველაფრის ჩაკეტვა + Lock image pixels გამოსახულების პიქსელების ჩაკეტვა + Lock layer შრის ჩაკეტვა + Lock position პოზიციის ჩაკეტვა + Lock transparent pixels გამჭვირვალე პიქსელების ჩაკეტვა + Lock: ჩაკეტვა: + Logical Length: ლოგიკური სიგრძე: + Logical Units: ლოგიკური ერთეულები: + Luminance სიკაშკაშე + Luminance Detail სიკაშკაშის დეტალი + Magenta მეწამული + Magenta · Green მეწამული · მწვანე + Maintain aspect ratio თანაფარდობის შენარჩუნება + Marker Size მარკერის ზომა + Mask All ყველაფრის დაფარვა ნიღბით + Mask Options ნიღბის პარამეტრები + Masking ნიღბვა + Master საერთო + Matte მატი + Median: მედიანა: + Menus მენიუები + Mesh Size ბადის ზომა + Metadata მეტამონაცემები + Metrics მეტრიკა + Midpoint შუა წერტილი + Midtones შუალედური ტონები + Midtones from: შუალედური ტონები, საწყისი: + Midtones to: შუალედური ტონები, საბოლოო: + Minimum Depth მინიმალური სიღრმე + Minimum Diameter მინიმალური დიამეტრი + Minimum Roundness მინიმალური სიმრგვალე + Mode: რეჟიმი: + Monochrome მონოქრომული + More Points მეტი წერტილი + Move effect layer down ეფექტის შრის ჩამოწევა + Move effect layer up ეფექტის შრის აწევა + Move to გადატანა + Name სახელი + Name: სახელი: + Nearest Neighbor უახლოესი მეზობელი + Nearest Neighbor (hard edges) უახლოესი მეზობელი (მკვეთრი კიდეები) + New Size ახალი ზომა + New document… ახალი დოკუმენტი… + New effect layer ახალი ეფექტის შრე + New selection ახალი მონიშვნა + Next frame შემდეგი კადრი + Next note შემდეგი შენიშვნა + No Color ფერის გარეშე + No Color Management ფერების მართვის გარეშე + No Dither დიზერინგის გარეშე + No author ავტორის გარეშე + No character style სიმბოლოს სტილის გარეშე + No document დოკუმენტი არ არის + No matching commands შესაბამისი ბრძანება ვერ მოიძებნა + No presets for the current tool. მიმდინარე ხელსაწყოს პრესეტები არ არის. + No presets of this type. ამ ტიპის პრესეტები არ არის. + No properties თვისებები არ არის + No tool presets. ხელსაწყოს პრესეტები არ არის. + Noise Reduction ხმაურის შემცირება + Normal ჩვეულებრივი + Not in Dictionary: ლექსიკონში არ არის: + OK OK + Offset წანაცვლება + Only Web Colors მხოლოდ ვებ-ფერები + Opacity გაუმჭვირვალობა + Opacity Jitter გაუმჭვირვალობის ცვალებადობა + Opacity: გაუმჭვირვალობა: + Open a document to define variables. ცვლადების განსასაზღვრად გახსენით დოკუმენტი. + Open a document to select a colour range. ფერების დიაპაზონის მოსანიშნად გახსენით დოკუმენტი. + Open a document to use the timeline. დროის ხაზის გამოსაყენებლად გახსენით დოკუმენტი. + Optical ოპტიკური + Optimized ოპტიმიზებული + Option-click to define a source point to clone from Option-დაწკაპუნებით განსაზღვრეთ კლონირების საწყისი წერტილი + Original ორიგინალი + Output Channel: გამომავალი არხი: + Output Levels: გამომავალი დონეები: + Output: გამომავალი: + Outside გარეთ + Overlay Color გადაფარვის ფერი + Overlay Opacity გადაფარვის გაუმჭვირვალობა + Overlay: Rule of Thirds გადაფარვა: მესამედების წესი + Override Pressure დაწოლის უგულებელყოფა + Override Rotation შემობრუნების უგულებელყოფა + Override Tilt დახრის უგულებელყოფა + PRESET DETAILS პრესეტის დეტალები + PSD PSD + Paint Overlay საღებავით გადაფარვა + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. პანელების მდებარეობა ინახება სამუშაო სივრცეში. კლავიატურის მალსახმობები, მენიუები და ხელსაწყოთა ზოლი არასავალდებულოა. + Paper ქაღალდი + Path ტრაექტორია + Pattern პატერნი + Pattern Size პატერნის ზომა + Pause პაუზა + Percent პროცენტი + Perceptual აღქმითი + Perspective Warp is not active პერსპექტიული დეფორმაცია აქტიური არ არის + Perspective Warp: draw planes along the image's perspective, then switch to Warp პერსპექტიული დეფორმაცია: დახაზეთ სიბრტყეები გამოსახულების პერსპექტივის გასწვრივ, შემდეგ გადაერთეთ დეფორმაციაზე + Photo Filter ფოტოფილტრი + PhotoCraft PhotoCraft + PhotoCraft Manages Colors ფერებს მართავს PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — ღია კოდის ნატიური გამოსახულების რედაქტორი, დაწერილი Rust-ზე. + Pin Depth: სამაგრის სიღრმე: + Pixel Length: სიგრძე პიქსელებში: + Pixels/Centimeter პიქსელი/სანტიმეტრი + Pixels/Inch პიქსელი/დუიმი + Pixels: პიქსელები: + Play დაკვრა + Play selection მონიშნულის დაკვრა + Portrait ვერტიკალური + Position პოზიცია + Position and Size პოზიცია და ზომა + Posterize პოსტერიზაცია + Preserve Details დეტალების შენარჩუნება + Preserve Luminosity სიკაშკაშის შენარჩუნება + Preset პრესეტი + Preset Type პრესეტის ტიპი + Preset: პრესეტი: + Presets: პრესეტები: + Press keys… დააჭირეთ კლავიშებს… + Pressure დაწოლა + Pressure for Opacity დაწოლა გაუმჭვირვალობისთვის + Pressure for Size დაწოლა ზომისთვის + Prevent auto-nesting in and out of Artboards and Frames სამუშაო დაფებსა და ჩარჩოებში ავტომატური ჩასმისა და ამოღების აკრძალვა + Preview წინასწარი ნახვა + Previous frame წინა კადრი + Previous note წინა შენიშვნა + Print დაბეჭდვა + Printer პრინტერი + Printer Manages Colors ფერებს მართავს პრინტერი + Printer Setup პრინტერის პარამეტრები + Printing Marks საბეჭდი ნიშნები + Progressive პროგრესული + Promote დაწინაურება + Protect Detail დეტალის დაცვა + Protect Foreground Color წინა პლანის ფერის დაცვა + Protect Tones ტონების დაცვა + Proximity Match სიახლოვით შესაბამისობა + Pulled String Mode დაჭიმული ძაფის რეჟიმი + Puppet Warp is not active მარიონეტული დეფორმაცია აქტიური არ არის + Purity სისუფთავე + Quality ხარისხი + Quick Actions სწრაფი მოქმედებები + Quick Export As PNG სწრაფი ექსპორტი PNG-ად + Radial Gradient რადიალური გრადიენტი + Radius რადიუსი + Radius: რადიუსი: + Range: დიაპაზონი: + Rate სიხშირე + Recently Used ბოლოს გამოყენებული + Reconstruct რეკონსტრუქცია + Record ● a sequence of edits, then play ▶ it on any document. ჩაწერეთ ● რედაქტირებების თანმიმდევრობა, შემდეგ დაუკარით ▶ ნებისმიერ დოკუმენტზე. + Red წითელი + Redefine style by current selection სტილის ხელახლა განსაზღვრა მიმდინარე მონიშნულით + Refine დაზუსტება + Reflected Gradient არეკლილი გრადიენტი + Registration Marks შეთავსების ნიშნები + Regular ჩვეულებრივი + Relative ფარდობითი + Relative Colorimetric ფარდობითი კოლორიმეტრიული + Remove მოშორება + Remove All Pins ყველა სამაგრის მოშორება + Remove Background ფონის მოშორება + Remove Planes სიბრტყეების მოშორება + Remove Tool მოშორების ხელსაწყო + Remove stop გადასვლის წერტილის წაშლა + Remove variable ცვლადის მოშორება + Rename სახელის შეცვლა + Rename Group… ჯგუფის სახელის შეცვლა… + Rename Layer… შრის სახელის შეცვლა… + Rename Tool Preset… ხელსაწყოს პრესეტის სახელის შეცვლა… + Rename… სახელის შეცვლა… + Resample ხელახალი დისკრეტიზაცია + Reset All to Defaults ყველაფრის ნაგულისხმევზე დაბრუნება + Import Shortcuts… მალსახმობების იმპორტი… + Load a keyboard shortcut set saved as .kys .kys ფაილად შენახული კლავიატურის მალსახმობების ნაკრების ჩატვირთვა + Imported {n} shortcuts from {file}. Click OK to keep them. {file}-დან იმპორტირებულია მალსახმობი: {n}. მათ შესანარჩუნებლად დაწკაპეთ OK-ზე. + {n} commands are not in PhotoCraft. PhotoCraft-ში არ არის ბრძანება: {n}. + Keyboard shortcuts imported კლავიატურის მალსახმობები იმპორტირებულია + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. აქ გამოიყენება თქვენი კლავიატურის მალსახმობების ნაკრები {set}: ნაგულისხმევისგან განსხვავდება მალსახმობი: {n}. მათ აჩვენებს რედაქტირება › კლავიატურის მალსახმობები; ყველაფრის ნაგულისხმევზე დაბრუნება ამას გააუქმებს. + Import keyboard shortcuts? მოხდეს კლავიატურის მალსახმობების იმპორტი? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft-მა ამ კომპიუტერზე იპოვა თქვენი კლავიატურის მალსახმობების ნაკრები {set} სხვა გამოსახულების რედაქტორიდან: PhotoCraft-ისგან განსხვავდება მისი მალსახმობი: {n}. მოხდეს იმპორტი? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. რედაქტირება › კლავიატურის მალსახმობები › ყველაფრის ნაგულისხმევზე დაბრუნება აუქმებს იმპორტს. + Don't Import იმპორტის გარეშე + The keyboard shortcut set was not imported: {error} კლავიატურის მალსახმობების ნაკრების იმპორტი ვერ მოხერხდა: {error} + Reset Section სექციის საწყისზე დაბრუნება + Reset Transform გარდაქმნის საწყისზე დაბრუნება + Reset to defaults ნაგულისხმევზე დაბრუნება + Resolution გარჩევადობა + Resolution: გარჩევადობა: + Restore All ყველაფრის აღდგენა + Restore Defaults ნაგულისხმევის აღდგენა + Restrictive (Web) შეზღუდული (ვები) + Right align text ტექსტის მარჯვნივ სწორება + Rigid ხისტი + Rotate: შემობრუნება: + Rotation შემობრუნება + Roughness უხეშობა + Roundness სიმრგვალე + Roundness Jitter სიმრგვალის ცვალებადობა + Rulers & Grids სახაზავები და ბადეები + Sample All Layers ყველა შრიდან ნიმუშის აღება + Sample: ნიმუში: + Sampling: Background Swatch ნიმუშის აღება: ფონის ფერის ნიმუში + Sampling: Continuous ნიმუშის აღება: უწყვეტი + Sampling: Once ნიმუშის აღება: ერთხელ + Saturate გაჯერება + Saturation გაჯერება + Saturation Jitter გაჯერების ცვალებადობა + Save HTML and Images HTML-ისა და გამოსახულებების შენახვა + Save as PDF PDF-ად შენახვა + Save selection as channel მონიშნულის არხად შენახვა + Save… შენახვა… + Scale to Fit Media მედიაში ჩატევით მასშტაბირება + Scatter გაბნევა + Scratch disks დროებითი დისკები + Search ძიება + Search commands ბრძანებების ძიება + Search commands, tools and panels… ბრძანებების, ხელსაწყოებისა და პანელების ძიება… + Search fonts შრიფტების ძიება + Select All ყველაფრის მონიშვნა + Select Subject ობიექტის მონიშვნა + Select a type layer or click into text to insert glyphs გლიფების ჩასასმელად მონიშნეთ ტექსტური შრე ან დაწკაპეთ ტექსტში + Select text to apply a character style სიმბოლოს სტილის გამოსაყენებლად მონიშნეთ ტექსტი + Select: მონიშვნა: + Selections მონიშნულები + Selective შერჩევითი + Selective Color შერჩევითი ფერი + Send the selected pin backward მონიშნული სამაგრის უკან გადატანა + Set additional smoothing options დაგლუვების დამატებითი პარამეტრები + Set background color ფონის ფერის დაყენება + Set foreground color წინა პლანის ფერის დაყენება + Set painting symmetry options ხატვის სიმეტრიის პარამეტრები + Symmetry Off სიმეტრია გამორთულია + Set shape fill type ფიგურის შევსების ტიპის დაყენება + Set shape stroke type ფიგურის კონტურის ტიპის დაყენება + Set the text color ტექსტის ფერის დაყენება + Shadows ჩრდილები + Shape: ფორმა: + Sharpening გამკვეთრება + Shift Shift + Shortcut removed. მალსახმობი მოშორებულია. + Show All Menu Items მენიუს ყველა პუნქტის ჩვენება + Show Backdrop ფონის ჩვენება + Show Guides დამხმარე ხაზების ჩვენება + Show Mask ნიღბის ჩვენება + Show Mesh ბადის ჩვენება + Show Overlay გადაფარვის ჩვენება + Show Transform Controls გარდაქმნის მართვის ელემენტების ჩვენება + Show or hide the Notes panel შენიშვნების პანელის ჩვენება ან დამალვა + Show: ჩვენება: + Sides გვერდები + Sides: გვერდები: + Size ზომა + Size Jitter ზომის ცვალებადობა + Size: ზომა: + Slice Options… ნაჭრის პარამეტრები… + Slices From Guides ნაჭრები დამხმარე ხაზებიდან + Slices are locked (View › Lock Slices) ნაჭრები ჩაკეტილია (ხედი › ნაჭრების ჩაკეტვა) + Small Caps პატარა მთავრული + Smoothing დაგლუვება + Space After ინტერვალი შემდეგ + Space Before ინტერვალი წინ + Spacing ინტერვალი + Spell check complete. მართლწერის შემოწმება დასრულდა. + Split: გაყოფა: + Star ratio ვარსკვლავის თანაფარდობა + Stop playing/recording დაკვრის/ჩაწერის შეჩერება + Straighten გასწორება + Straighten Layer შრის გასწორება + Strength: სიძლიერე: + Strikethrough გადახაზული + Stroke კონტური + Stroke Catch-up მონასმის დაწევა + Stroke: კონტური: + Location: მდებარეობა: + Style სტილი + Style Options სტილის პარამეტრები + Style Options… სტილის პარამეტრები… + Style: Normal სტილი: ჩვეულებრივი + Subtract from Sample ნიმუშს გამოკლება + Subtract from selection ({key}) მონიშნულს გამოკლება ({key}) + Suggestions: შეთავაზებები: + Swap colours (X) ფერების გაცვლა (X) + Swaps height and width სიმაღლისა და სიგანის გაცვლა + Switch between free transform and warp modes თავისუფალი გარდაქმნისა და დეფორმაციის რეჟიმებს შორის გადართვა + Switch theme თემის გადართვა + Temperature ტემპერატურა + Tetrahedral ტეტრაედრული + Text Position: ტექსტის პოზიცია: + Texture ტექსტურა + Texture Each Tip ტექსტურა თითოეულ წვერზე + Threshold ზღვარი + Threshold Level ზღვრის დონე + Thumbnail size (smallest: list) ესკიზის ზომა (უმცირესი: სია) + Tilt X X-დახრა + Tilt Y Y-დახრა + Tint ტონირება + Toggle count group visibility დათვლის ჯგუფის ხილვადობის გადართვა + Toggle text orientation ტექსტის ორიენტაციის გადართვა + Tolerance დაშვება + Tolerance: დაშვება: + Tone: ტონი: + Toolbar ხელსაწყოთა ზოლი + Top ზემოთ + Total: სულ: + Tracking ტრეკინგი + Tracking (1/1000 em) ტრეკინგი (1/1000 em) + Transparency გამჭვირვალობა + Transparent გამჭვირვალე + Trilinear სამწრფივი + Type Styles ტექსტის სტილები + Type: ტიპი: + Underline ხაზგასმული + Untitled უსათაურო + Untitled-1 უსათაურო-1 + Update layer comp (visibility, position and appearance) შრეების კომპოზიციის განახლება (ხილვადობა, პოზიცია და იერსახე) + Use Default ნაგულისხმევის გამოყენება + Use Legacy ძველი ვერსიის გამოყენება + Use Measurement Scale საზომი მასშტაბის გამოყენება + Variables bind a layer's visibility, text or pixels to a named data slot. ცვლადები შრის ხილვადობას, ტექსტს ან პიქსელებს აკავშირებს მონაცემთა დასახელებულ ველთან. + Vertical scale ვერტიკალური მასშტაბი + Vibrance სიხალასე + View Options ხედის პარამეტრები + Vignetting ვინიეტირება + Violet, Orange იისფერი, ნარინჯისფერი + Visibility ხილვადობა + Visible ხილული + Warp: დეფორმაცია: + Web Snap ვებ-ფერებზე მიბმა + Weight სისქე + Weight: სისქე: + White თეთრი + White Balance: As Shot თეთრის ბალანსი: როგორც გადაღებულია + Whites თეთრები + Width სიგანე + Slant დახრა + Width: სიგანე: + Work Path სამუშაო ტრაექტორია + Yellow ყვითელი + Yellow · Blue ყვითელი · ლურჯი + Zoom მასშტაბი + Zoom in გადიდება + Zoom out დაპატარავება + command or shortcut ბრძანება ან მალსახმობი + current მიმდინარე + image file path გამოსახულების ფაილის მისამართი + modified შეცვლილი + name სახელი + new ახალი + {name} options {name}: პარამეტრები + ↑↓ navigate {key} run esc close ↑↓ ნავიგაცია {key} გაშვება esc დახურვა + Version {version} ვერსია {version} + New Document ახალი დოკუმენტი + New Document With Mask ახალი დოკუმენტი ნიღბით + New Layer With Mask ახალი შრე ნიღბით + Preset Name პრესეტის სახელი + No saved presets yet. შენახული პრესეტები ჯერ არ არის. + Error შეცდომა + Command ბრძანება + Adjustment layers have nothing to transform მაკორექტირებელ შრეებში გარდასაქმნელი არაფერია + Altitude სიმაღლე + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua ზღვისფერი + Art & Illustration ხელოვნება და ილუსტრაცია + ArtCraft ArtCraft + Background Eraser Tool ფონის საშლელის ხელსაწყო + Bevel & Emboss ფასკა და რელიეფი + Blend Mode შერევის რეჟიმი + Blues ლურჯები + Blur Tool გაბუნდოვნების ხელსაწყო + Bold მუქი + Brush ფუნჯი + Block ბლოკი + Brush Pose ფუნჯის პოზა + Brush Tip Shape ფუნჯის წვერის ფორმა + Brush Tool ფუნჯის ხელსაწყო + Build-up აეროგრაფი + Burn Tool გამამუქებლის ხელსაწყო + Centimeters სანტიმეტრი + Choke შევიწროება + Classic კლასიკური + Click to add text დაწკაპეთ ტექსტის დასამატებლად + Click to fill similar colours დაწკაპეთ მსგავსი ფერების შესავსებად + Clone Stamp Tool კლონირების შტამპის ხელსაწყო + Color Burn ფერით ჩამუქება + Color Dodge ფერით გაღიავება + Color Dynamics ფერის დინამიკა + Color Overlay ფერის გადაფარვა + Comic Book კომიქსი + Conform მორგება + Content-Aware Fill needs a selection on a pixel layer შიგთავსის გათვალისწინებით შევსებას სჭირდება მონიშნული პიქსელურ შრეზე + Could not transform: the layer is empty გარდაქმნა ვერ მოხერხდა: შრე ცარიელია + Could not use Perspective Warp: the layer is empty პერსპექტიული დეფორმაცია ვერ გამოიყენება: შრე ცარიელია + Could not use Puppet Warp: no opaque pixels მარიონეტული დეფორმაცია ვერ გამოიყენება: გაუმჭვირვალე პიქსელები არ არის + Could not use Puppet Warp: the layer is empty მარიონეტული დეფორმაცია ვერ გამოიყენება: შრე ცარიელია + Crop Tool ჩამოჭრის ხელსაწყო + Crosswise განივად + Current Tool მიმდინარე ხელსაწყო + Custom Shape Tool მორგებული ფიგურის ხელსაწყო + Cyans ცისფრები + Default PhotoCraft Size ნაგულისხმევი ზომა + Delete Workspace სამუშაო სივრცის წაშლა + Diamond ბრილიანტი + Dimensions: ზომები: + Direction მიმართულება + Distance მანძილი + Document Dimensions დოკუმენტის ზომები + Document Profile დოკუმენტის პროფილი + Document Sizes დოკუმენტის ზომები + Dodge Tool გამაღიავებლის ხელსაწყო + Down ქვემოთ + Drag to draw a gradient გადაათრიეთ გრადიენტის დასახაზად + Drag to move the active layer გადაათრიეთ აქტიური შრის გადასაადგილებლად + Drag to pan · hold Space with any tool გადაათრიეთ ხედის გადასაწევად · ან ნებისმიერ ხელსაწყოსთან დააჭირეთ Space-ს + Drop Shadow ჩრდილი + Dual Brush ორმაგი ფუნჯი + Edge კიდე + Efficiency ეფექტიანობა + Efficiency: 100% ეფექტიანობა: 100% + Ellipse Tool ელიფსის ხელსაწყო + Elliptical Marquee Tool ელიფსური მონიშვნის ხელსაწყო + Single Row Marquee Tool ერთი სტრიქონის მონიშვნის ხელსაწყო + Single Column Marquee Tool ერთი სვეტის მონიშვნის ხელსაწყო + Emboss რელიეფი + Eraser საშლელი + Eraser Tool საშლელის ხელსაწყო + ExtraBold ძალიან მუქი + ExtraLight ძალიან ღია + Eyedropper Tool პიპეტის ხელსაწყო + Fade მილევა + Fill Opacity შევსების გაუმჭვირვალობა + Film & Video კინო და ვიდეო + From საწყისი + General ზოგადი + GitHub GitHub + Gradient Overlay გრადიენტის გადაფარვა + Gradient Tool გრადიენტის ხელსაწყო + Greens მწვანეები + Hand Tool ხელის ხელსაწყო + Hard მკვეთრი + Hard Mix მკვეთრი შერევა + Healing Brush Tool აღმდგენი ფუნჯის ხელსაწყო + Highlights from: ღია ტონები, საწყისი: + History Brush Tool ისტორიის ფუნჯის ხელსაწყო + Horizontal Type Tool ჰორიზონტალური ტექსტის ხელსაწყო + Horizontal Type Mask Tool ჰორიზონტალური ტექსტური ნიღბის ხელსაწყო + Vertical Type Mask Tool ვერტიკალური ტექსტური ნიღბის ხელსაწყო + Image Size: გამოსახულების ზომა: + Inches დუიმი + Indexed ინდექსირებული + Initial Direction საწყისი მიმართულება + Inner Bevel შიდა ფასკა + Inner Glow შიდა ნათება + Inner Shadow შიდა ჩრდილი + Lab Lab + Landscape, 10 x 8 ჰორიზონტალური, 10 x 8 + Landscape, 6 x 4 ჰორიზონტალური, 6 x 4 + Landscape, 7 x 5 ჰორიზონტალური, 7 x 5 + Lasso ლასო + Lasso Tool ლასოს ხელსაწყო + Layer Count შრეების რაოდენობა + Layer Knocks Out Drop Shadow შრე ჩრდილს ამოკვეთს + Legal Legal + Letter Letter + Light ღია + Line Tool ხაზის ხელსაწყო + Linear წრფივი + Linear Burn წრფივი ჩამუქება + Linear Height წრფივი სიმაღლე + Link with Layer შრესთან დაკავშირება + Lorem Ipsum Lorem Ipsum + Magentas მეწამულები + Magic Eraser Tool ჯადოსნური საშლელის ხელსაწყო + Magic Wand Tool ჯადოსნური ჯოხის ხელსაწყო + Marquee მონიშვნა + Mean: საშუალო: + Measurement Scale საზომი მასშტაბი + Measurement Scale Marker საზომი მასშტაბის მარკერი + Millimeters მილიმეტრი + Mobile მობილური + Move Tool გადაადგილების ხელსაწყო + Could not use the move tool because the layer is locked. გადაადგილების ხელსაწყო ვერ გამოიყენება, რადგან შრე ჩაკეტილია. + Could not complete the Free Transform command because the layer is locked. თავისუფალი გარდაქმნის ბრძანება ვერ შესრულდა, რადგან შრე ჩაკეტილია. + Could not transform because the layer is locked. გარდაქმნა ვერ მოხერხდა, რადგან შრე ჩაკეტილია. + Multiply გამრავლება + My Brushes ჩემი ფუნჯები + Neutrals ნეიტრალურები + New Workspace ახალი სამუშაო სივრცე + Note Tool შენიშვნის ხელსაწყო + Object Selection Tool ობიექტის მონიშვნის ხელსაწყო + Off გამორთული + Open გახსნა + Orange ნარინჯისფერი + Original Ratio საწყისი პროპორცია + Out Of Gamut ფერთა დიაპაზონს გარეთ + Outer Bevel გარე ფასკა + Outer Glow გარე ნათება + Overlay გადაფარვა + Paint Bucket Tool საღებავის ვედროს ხელსაწყო + Paints from the document's opening state ხატავს დოკუმენტის გახსნისდროინდელი მდგომარეობიდან + Path Selection Tool ტრაექტორიის მონიშვნის ხელსაწყო + Direct Selection Tool პირდაპირი მონიშვნის ხელსაწყო + Add Anchor Point Tool საყრდენი წერტილის დამატების ხელსაწყო + Delete Anchor Point Tool საყრდენი წერტილის წაშლის ხელსაწყო + Click a segment to add an anchor point · {alt}-click an anchor to delete it საყრდენი წერტილის დასამატებლად დაწკაპეთ სეგმენტზე · წასაშლელად — {alt}-დაწკაპუნება საყრდენ წერტილზე + Click an anchor point to delete it · {alt}-click a segment to add one წასაშლელად დაწკაპეთ საყრდენ წერტილზე · დასამატებლად — {alt}-დაწკაპუნება სეგმენტზე + Pattern Overlay პატერნის გადაფარვა + Pen Pressure კალმის დაწოლა + Pen Tilt კალმის დახრა + Pen Tool კალმის ხელსაწყო + Convert Point Tool წერტილის გარდაქმნის ხელსაწყო + Photo ფოტო + PhotoCraft website PhotoCraft-ის ვებსაიტი + Picas პიკა + Pillow Emboss ბალიშისებრი რელიეფი + Pixel Replacement პიქსელების ჩანაცვლება + Pixels პიქსელი + Points პუნქტი + Polygon Tool მრავალკუთხედის ხელსაწყო + Polygonal Lasso Tool მრავალკუთხა ლასოს ხელსაწყო + Portrait, 4 x 6 ვერტიკალური, 4 x 6 + Portrait, 5 x 7 ვერტიკალური, 5 x 7 + PostScript (72 points/inch) PostScript (72 პუნქტი/დუიმი) + Postcard ღია ბარათი + Poster პოსტერი + Pro (Dark) Pro (ბნელი) + Pro (Light) Pro (ღია) + Pro (Light Gray) Pro (ღია ნაცრისფერი) + Pro (Medium Gray) Pro (საშუალო ნაცრისფერი) + Protect Texture ტექსტურის დაცვა + Purple იისფერი + Quick Mask სწრაფი ნიღაბი + Quick Selection Tool სწრაფი მონიშვნის ხელსაწყო + Radial რადიალური + Rasterize Layer შრის რასტრირება + Rasterize Layers შრეების რასტრირება + Ratio პროპორცია + Recent ბოლოდროინდელი + Rectangle Tool მართკუთხედის ხელსაწყო + Rectangular Marquee Tool მართკუთხა მონიშვნის ხელსაწყო + Reds წითლები + Reflected არეკლილი + Sample: foreground colour ნიმუში: წინა პლანის ფერი + Sampled Colors ნიმუშად აღებული ფერები + Satin ატლასი + Save Pixel Aspect Ratio პიქსელის გვერდების თანაფარდობის შენახვა + Scattering გაბნევა + Scratch Sizes დროებითი დისკის ზომები + Select Data Points მონაცემთა წერტილების მონიშვნა + Selection მონიშნული + SemiBold ნახევრად მუქი + Shadows up to: ჩრდილები, ზედა ზღვარი: + Shape Dynamics ფორმის დინამიკა + Sharpen Tool გამკვეთრების ხელსაწყო + Show Extras Options დამატებითი ელემენტების პარამეტრების ჩვენება + Slice Select Tool ნაჭრის მონიშვნის ხელსაწყო + Slice Tool ნაჭრის ხელსაწყო + Smart Filters ჭკვიანი ფილტრები + Smudge Tool გადღაბნის ხელსაწყო + Soft რბილი + Soften დარბილება + Source წყარო + Source: set წყარო: დაყენებულია + Sponge Tool ღრუბლის ხელსაწყო + Spot Healing Brush Tool წერტილოვანი აღმდგენი ფუნჯის ხელსაწყო + Spread გავრცობა + Square, 12 x 12 კვადრატი, 12 x 12 + Square, 5 x 5 კვადრატი, 5 x 5 + Std Dev: სტანდ. გადახრა: + Studio (Dark) Studio (ბნელი) + Studio (Light) Studio (ღია) + Stylus Wheel სტილუსის ბორბალი + Subtract გამოკლება + Tabloid Tabloid + Text Replacement ტექსტის ჩანაცვლება + Thin თხელი + Traditional (72.27 points/inch) ტრადიციული (72.27 პუნქტი/დუიმი) + Transfer გადაცემა + Triangle Tool სამკუთხედის ხელსაწყო + Unsaved changes შეუნახავი ცვლილებები + Uses the foreground and background colours. იყენებს წინა პლანისა და ფონის ფერებს. + Web ვები + Web Large ვები, დიდი + Web Minimum ვები, მინიმალური + Web Most Common ვები, ყველაზე გავრცელებული + WebP (lossless) WebP (უდანაკარგო) + Wet Edges სველი კიდეები + Yellows ყვითლები + Zoom Tool მასშტაბის ხელსაწყო + {name} Properties {name}: თვისებები + Revert “{name}” to the last saved version? Your changes will be lost. დაუბრუნდეს „{name}“ ბოლოს შენახულ ვერსიას? თქვენი ცვლილებები დაიკარგება. + Do you want to save the changes you made to “{name}” before quitting? გსურთ, გასვლამდე შეინახოთ „{name}“-ში შეტანილი ცვლილებები? + Do you want to save the changes you made to “{name}” before closing? გსურთ, დახურვამდე შეინახოთ „{name}“-ში შეტანილი ცვლილებები? + Doc: {flat}/{layered} დოკ.: {flat}/{layered} + Scratch: {size} დროებითი: {size} + Untagged {mode} {mode} ტეგის გარეშე + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch გარჩევადობა: {dpi} პიქსელი/დუიმი + Doc: {w} × {h} px დოკ.: {w} × {h} px + Frame {current} / {total} კადრი {current} / {total} + Count: {n} რაოდენობა: {n} + BLANK DOCUMENT PRESETS ({n}) ცარიელი დოკუმენტის პრესეტები ({n}) + Constraints ({n}) შეზღუდვები ({n}) + Align {what} სწორება: {what} +@plural {n} Layer|{n} Layers შრე: {n} +@plural {n} layer|{n} layers შრე: {n} +@plural Group · {n} layer|Group · {n} layers ჯგუფი · შრე: {n} + Additional plugins folder პლაგინების დამატებითი საქაღალდე + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control აგენტის მიერ მართვის დაშვება + Allow scripts to connect სკრიპტების დაკავშირების დაშვება + Always ყოველთვის + Always create smart objects when placing განთავსებისას ყოველთვის ჭკვიანი ობიექტების შექმნა + Animated zoom ანიმირებული მასშტაბირება + Apply auto tone ავტოტონის გამოყენება + Ask კითხვა + Ask before saving layered tiff შრეებიანი TIFF-ის შენახვამდე კითხვა + Auto collapse icon panels ხატულების პანელების ავტომატური ჩაკეცვა + Auto show hidden panels დამალული პანელების ავტომატური ჩვენება + Auto show home screen საწყისი ეკრანის ავტომატური ჩვენება + Autosave ავტოშენახვა + Autosave minutes ავტოშენახვის ინტერვალი (წუთი) + Beep when done დასრულებისას ხმოვანი სიგნალი + Bicubic automatic ბიკუბური, ავტომატური + Bicubic sharper ბიკუბური, უფრო მკვეთრი + Bicubic smoother ბიკუბური, უფრო გლუვი + Bit depth ბიტური სიღრმე + Brush preview color ფუნჯის წინასწარი ნახვის ფერი + Cache levels ქეშის დონეები + Cache tile size ქეშის ფილის ზომა + Camera Raw Defaults Camera Raw-ის ნაგულისხმევი პარამეტრები + Canvas border ტილოს საზღვარი + Show canvas dots ტილოს წერტილების ჩვენება + Canvas color ტილოს ფერი + Canvas custom color ტილოს მორგებული ფერი + Color picker ფერის ამრჩევი + Color space ფერთა სივრცე + Column width სვეტის სიგანე + Concise მოკლე + Control port მართვის პორტი + Convert to srgb sRGB-ად გარდაქმნა + Cursors კურსორები + Custom dark მორგებული ბნელი + Custom light მორგებული ღია + Dark ბნელი + Dark gray მუქი ნაცრისფერი + Dashed lines წყვეტილი ხაზები + Destination დანიშნულება + Detailed დეტალური + Disks დისკები + Display p3 Display P3 + Double click layer mask launches select and mask შრის ნიღაბზე ორმაგი დაწკაპუნება ხსნის „მონიშვნა და ნიღაბს“ + Drop shadow ჩრდილი + Dynamic color sliders ფერის დინამიკური სლაიდერები + East asian აღმოსავლეთაზიური + Effect cache mb ეფექტების ქეში (მბ) + Enable flick panning ინერციული გადაწევის ჩართვა + Enable floating document window docking მცურავი დოკუმენტის ფანჯრების მიმაგრების ჩართვა + Enable narrow options bar ვიწრო პარამეტრების ზოლის ჩართვა + Enabled ჩართულია + Enhanced Controls გაუმჯობესებული მართვის ელემენტები + Export clipboard გაცვლის ბუფერის ექსპორტი + Extra large ძალიან დიდი + File Handling ფაილებთან მუშაობა + File path ფაილის მისამართი + Fill new type layers with placeholder ახალი ტექსტური შრეების შევსება ჩანაცვლების ტექსტით + Font preview შრიფტის წინასწარი ნახვა + Full size tip სრული ზომის წვერი + Gamut warning color ფერთა გამიდან გასვლის გაფრთხილების ფერი + Gamut warning opacity ფერთა გამიდან გასვლის გაფრთხილების გაუმჭვირვალობა + Enable Generator გენერატორის ჩართვა + Gif GIF + Glossy პრიალა + Grid color ბადის ფერი + Grid colors ბადის ფერები + Grid size ბადის ზომა + Grid style ბადის სტილი + Grid unit ბადის ერთეული + Gridline every ბადის ხაზი ყოველ + Guide color დამხმარე ხაზის ფერი + Guide style დამხმარე ხაზის სტილი + Guides, Grid & Slices დამხმარე ხაზები, ბადე და ნაჭრები + Gutter შუალედი + History Log ისტორიის ჟურნალი + History states ისტორიის მდგომარეობები + Ignore exif profile tag EXIF პროფილის ტეგის უგულებელყოფა + Image interpolation გამოსახულების ინტერპოლაცია + Image previews გამოსახულების მინიატურები + Integrations ინტეგრაციები + Interface ინტერფეისი + Jpeg quality JPEG-ის ხარისხი + Jpg JPG + Language ენა + Large tabs დიდი ჩანართები + Legacy compositing ძველი კომპოზიტინგი + Light gray ღია ნაცრისფერი + Line ხაზი + Lines ხაზები + Lossless უდანაკარგო + Lowercase extension გაფართოება პატარა ასოებით + Maximize psd compatibility PSD-თავსებადობის მაქსიმიზაცია + Medium gray საშუალო ნაცრისფერი + Memory usage mb მეხსიერების გამოყენება (მბ) + Low resolution previews დაბალი გარჩევადობის მინიატურები + Missing glyph protection დაკარგული გლიფებისგან დაცვა + Modern თანამედროვე + Nearest neighbor უახლოესი მეზობელი + Never არასოდეს + Normal tip ჩვეულებრივი წვერი + Open as smart object ჭკვიან ობიექტად გახსნა + Open in camera raw Camera Raw-ში გახსნა + Open documents as tabs დოკუმენტების ჩანართებად გახსნა + Overscroll საზღვრებს მიღმა გადახვევა + Performance წარმადობა + Png PNG + Point size პუნქტის ზომა + Precise ზუსტი + Preserve details დეტალების შენარჩუნება + Print resolution ბეჭდვის გარჩევადობა + Pro Pro + Pro light Pro ღია + Pro light gray Pro ღია ნაცრისფერი + Pro medium Pro საშუალო + Pro photo ProPhoto + Quick export format სწრაფი ექსპორტის ფორმატი + Quick export location სწრაფი ექსპორტის მდებარეობა + Recent file count ბოლოს გახსნილი ფაილების რაოდენობა + Recent files ბოლოს გახსნილი ფაილები + Recent fonts ბოლოდროინდელი შრიფტები + Recover on launch გაშვებისას აღდგენა + Remember workspace changes სამუშაო სივრცის ცვლილებების დამახსოვრება + Resize image during place განთავსებისას გამოსახულების ზომის შეცვლა + Rotate view with trackpad ხედის შემობრუნება სენსორული პანელით + Same folder იგივე საქაღალდე + Save in background ფონურ რეჟიმში შენახვა + Scratch Disks დროებითი დისკები + Screen ეკრანი + Screen resolution ეკრანის გარჩევადობა + Scrubby slider acceleration მცოცავი სლაიდერის აჩქარება + Sessions only მხოლოდ სესიები + Sharpen for გამკვეთრება: + Show channels in color არხების ფერად ჩვენება + Show crosshair in brush tip ფუნჯის წვერში ჯვრის ჩვენება + Show extension panels გაფართოების პანელების ჩვენება + Show font names in english შრიფტების სახელების ინგლისურად ჩვენება + Show menu colors მენიუს ფერების ჩვენება + Show only crosshair while painting ხატვისას მხოლოდ ჯვრის ჩვენება + Show slice numbers ნაჭრების ნომრების ჩვენება + Show tooltips მინიშნებების ჩვენება + Show highlight on rollover მიტანისას გამოკვეთის ჩვენება + Show transformation values გარდაქმნის მნიშვნელობების ჩვენება + Slice color ნაჭრის ფერი + Smart guide color ჭკვიანი დამხმარე ხაზების ფერი + Smart quotes ჭკვიანი ბრჭყალები + Snap vector tools and transforms to pixel grid ვექტორული ხელსაწყოებისა და გარდაქმნების პიქსელურ ბადეზე მიბმა + Srgb sRGB + Standard სტანდარტული + Studio Studio + Studio light Studio ღია + Adwaita Adwaita + Adwaita dark Adwaita ბნელი + Subdivisions ქვედანაყოფები + System სისტემა + Text engine ტექსტის ძრავა + Text file ტექსტური ფაილი + Tiny ძალიან პატარა + Touch gestures სენსორული ჟესტები + Transparency & Gamut გამჭვირვალობა და ფერთა დიაპაზონი + Type units ტექსტის ერთეულები + UI font size ინტერფეისის შრიფტის ზომა + UI scale ინტერფეისის მასშტაბი + Units & Rulers ერთეულები და სახაზავები + Use additional plugins folder პლაგინების დამატებითი საქაღალდის გამოყენება + Use esc to commit დადასტურება Esc-ით + Use gpu GPU-ის გამოყენება + Use legacy free transform ძველი თავისუფალი გარდაქმნის გამოყენება + Use shift key for tool switch ხელსაწყოს გადართვა Shift კლავიშით + Vary round brush hardness on hud მრგვალი ფუნჯის სიხისტის ცვლა HUD-ზე + Webp WebP + World ready მსოფლიო (World-Ready) + Zoom clicked point to center დაწკაპუნებული წერტილის ცენტრში მოქცევა მასშტაბირებისას + Zoom resizes windows მასშტაბირება ფანჯრების ზომას ცვლის + Zoom with scroll wheel მასშტაბირება გორგოლაჭით + Zoom with trackpad pinch მასშტაბირება სენსორულ პანელზე თითების მოჭერით + Pass Through გატარება + Dissolve გაფანტვა + Darker Color უფრო მუქი ფერი + Linear Dodge (Add) წრფივი გაღიავება (დამატება) + Lighter Color უფრო ღია ფერი + Soft Light რბილი სინათლე + Hard Light მკვეთრი სინათლე + Vivid Light კაშკაშა სინათლე + Linear Light წრფივი სინათლე + Pin Light წერტილოვანი სინათლე + Exclusion გამორიცხვა + Divide გაყოფა + Luminosity ნათება +@plural {n} sample|{n} samples {n} ნიმუში + {added}, {removed} subtracted {added}, გამოკლებულია {removed} + Adjustment Preview კორექციის გადახედვა + Airbrush: keeps depositing paint while the pointer rests. აეროგრაფი: საღებავის დატანა გრძელდება, სანამ მაჩვენებელი ერთ ადგილზე დგას. + Angle: კუთხე: + Blend If შერევა, თუ + Close Tab Group ჩანართების ჯგუფის დახურვა + Collapse ჩაკეცვა + Collapse All Groups ყველა ჯგუფის ჩაკეცვა + Collapse Panel Group პანელების ჯგუფის ჩაკეცვა + Collapse group ({key}-click: all groups) ჯგუფის ჩაკეცვა ({key}+დაწკაპუნება: ყველა ჯგუფი) + Create new brush from the current settings ახალი ფუნჯის შექმნა მიმდინარე პარამეტრებით + Create new brush from these settings ახალი ფუნჯის შექმნა ამ პარამეტრებით + Update the current brush with these settings მიმდინარე ფუნჯის განახლება ამ პარამეტრებით + Delete brush ფუნჯის წაშლა + Drag the arrow to set the angle, the dots to set the roundness გადაათრიეთ ისარი კუთხის დასაყენებლად, წერტილები — სიმრგვალისთვის + Expand გაშლა + Expand Panel Group პანელების ჯგუფის გაშლა + Expand group ({key}-click: all groups) ჯგუფის გაშლა ({key}+დაწკაპუნება: ყველა ჯგუფი) + Fade steps მილევის ბიჯები + Hide the layer's effects ({key}-click: all layers) შრის ეფექტების დამალვა ({key}+დაწკაპუნება: ყველა შრე) + Import Brushes… (.abr) ფუნჯების იმპორტი… (.abr) + Move Group Down ჯგუფის ჩამოწევა + Move Group Up ჯგუფის აწევა + Reset the brush to the defaults ფუნჯის ნაგულისხმევ პარამეტრებზე დაბრუნება + Restore original size საწყისი ზომის აღდგენა + Roundness: სიმრგვალე: + Search Brushes ფუნჯების ძიება + Show the layer's effects ({key}-click: all layers) შრის ეფექტების ჩვენება ({key}+დაწკაპუნება: ყველა შრე) + Brush picker ფუნჯის ამრჩევი + Erase წაშლა + Right click with painting tools მარჯვენა დაწკაპუნება სახატავი ხელსაწყოებით + System Info… სისტემის ინფორმაცია… + System Info სისტემის ინფორმაცია + More panels სხვა პანელები + More documents სხვა დოკუმენტები + These settings aren't available in PhotoCraft yet. ეს პარამეტრები PhotoCraft-ში ჯერ ხელმისაწვდომი არ არის. + Graphics გრაფიკა + Reset GPU Backend GPU-ის ბექენდის ჩამოყრა + Applies at next launch. ძალაში შედის შემდეგი გაშვებისას. + Click to sample the foreground colour · {key}-click for background დაწკაპეთ წინა პლანის ფერის ნიმუშის ასაღებად · {key}+დაწკაპუნება — ფონისთვის + {key}-click to set the source {key}+დაწკაპუნება წყაროს დასაყენებლად + {key} to subtract {key} — გამოსაკლებად + Clone source {n} ({key}-click with Clone Stamp to set) კლონირების წყარო {n} (დასაყენებლად {key}+დაწკაპუნება კლონირების შტამპით) + Source: not set ({key}-click with Clone Stamp) წყარო: არ არის დაყენებული ({key}+დაწკაპუნება კლონირების შტამპით) + Pixel Layer პიქსელური შრე + Type Layer ტექსტური შრე + Shape Layer ფიგურის შრე + Group Layer ჯგუფის შრე + Fill Layer შევსების შრე + Smart Object Layer ჭკვიანი ობიექტის შრე + Create Warped Text დეფორმირებული ტექსტის შექმნა + Rasterize Smart Object ჭკვიანი ობიექტის რასტრირება + Rasterize Fill Content შევსების შიგთავსის რასტრირება + Rasterize Layer Style შრის სტილის რასტრირება + Unlock: picking a preset replaces these settings განბლოკვა: პრესეტის არჩევა ამ პარამეტრებს ჩაანაცვლებს + Lock: keep these settings when picking another preset ჩაკეტვა: ამ პარამეტრების შენარჩუნება სხვა პრესეტის არჩევისას + Diameter: დიამეტრი: + Hardness: სიხისტე: + No brush presets ფუნჯის პრესეტები არ არის + Toggle the Brush Settings panel (F5) ფუნჯის პარამეტრების პანელის ჩართვა/გამორთვა (F5) + Create new brush preset from the current settings ფუნჯის ახალი პრესეტის შექმნა მიმდინარე პარამეტრებით + Brush Settings… (F5): every option of this brush ფუნჯის პარამეტრები… (F5): ამ ფუნჯის ყველა პარამეტრი + Rename Brush… ფუნჯის სახელის შეცვლა… + Delete Brush ფუნჯის წაშლა + New Brush Preset… ფუნჯის ახალი პრესეტი… + Brush Preset Options ფუნჯის პრესეტის პარამეტრები + Group name ჯგუფის სახელი + Brush name ფუნჯის სახელი + Brush Name ფუნჯის სახელი + Brush Stroke ფუნჯის მონასმი + Off: the speed of the pointer sets the spacing გამორთული: ინტერვალს მაჩვენებლის სიჩქარე განსაზღვრავს + Tilt Scale დახრის მასშტაბი + Brush Projection ფუნჯის პროექცია + Apply the pen's tilt and rotation to the tip shape კალმის დახრისა და შემობრუნების გამოყენება წვერის ფორმაზე + Mixer Brush შემრევი ფუნჯი + Wet სინოტივე + Mix მიქსი + Wetness Jitter სინოტივის ცვალებადობა + Mix Jitter მიქსის ცვალებადობა + Show the layer mask as an overlay შრის ნიღბის გადაფარვად ჩვენება + Toggle visibility ხილვადობის ჩართვა/გამორთვა + Unlink the mask from the layer ნიღბის შრისგან გათიშვა + Link the mask to the layer ნიღბის შრესთან დაკავშირება + Gradient გრადიენტი + Gradient Options გრადიენტის პარამეტრები + Align with layer შრესთან სწორება + Reset Alignment სწორების ჩამოყრა + Centre the gradient (offset 0, 0) გრადიენტის ცენტრირება (წანაცვლება 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor გრადიენტის რედაქტორი + Gradient picker გრადიენტის ამრჩევი + Gradient stops გრადიენტის გადასვლის წერტილები + Save As Preset პრესეტად შენახვა + The gradient the Gradient tool paints with გრადიენტი, რომლითაც გრადიენტის ხელსაწყო ხატავს + Adds the current gradient to the Gradients panel მიმდინარე გრადიენტის დამატება გრადიენტების პანელში + The current gradient (pick one in Window › Gradients) მიმდინარე გრადიენტი (აირჩიეთ ფანჯარა › გრადიენტები) + Select a type layer to edit its character and paragraph settings. სიმბოლოსა და აბზაცის პარამეტრების შესაცვლელად მონიშნეთ ტექსტური შრე. + Text color ტექსტის ფერი + Type Options ტექსტის პარამეტრები + Anti-aliasing method დაგლუვების მეთოდი + Horizontal type ჰორიზონტალური ტექსტი + Vertical type ვერტიკალური ტექსტი + Fill path with foreground color ტრაექტორიის წინა პლანის ფერით შევსება + Stroke path with brush ტრაექტორიის კონტური ფუნჯით + Load path as a selection ტრაექტორიის მონიშნულად ჩატვირთვა + Make work path from selection მონიშნულიდან სამუშაო ტრაექტორიის შექმნა + Add vector mask ვექტორული ნიღბის დამატება + Create new path ახალი ტრაექტორიის შექმნა + Delete path ტრაექტორიის წაშლა + Delete vector mask ვექტორული ნიღბის წაშლა + CPU (no GPU acceleration) CPU (GPU-აჩქარების გარეშე) + DirectX 12 DirectX 12 + GPU backend GPU ბექენდი + Linux display server Linux-ის ეკრანის სერვერი + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure პლანშეტის დაწოლის გამოყენება + Vulkan Vulkan + Alt-click to define a source point to clone from Alt+დაწკაპუნებით მიუთითეთ კლონირების საწყისი წერტილი + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. მონიშნულის შესაქმნელად ტრაექტორია არ არის: დახაზეთ კალმით ან მონიშნეთ ტრაექტორიების პანელში. + Cancel opening გახსნის გაუქმება + Cancelled opening {name} {name}-ის გახსნა გაუქმდა + Developing {name} მუშავდება {name} + Cancelled {label} გაუქმდა: {label} + Esc to cancel Esc — გასაუქმებლად + Opened {name} გაიხსნა {name} + Opening {name} იხსნება {name} + Saved {name} შენახულია {name} + Saving {name} ინახება {name} + A save to {name} is still running; try again when it finishes {name}-ში შენახვა ჯერ კიდევ მიმდინარეობს; სცადეთ მისი დასრულების შემდეგ + {label} is writing the file and can no longer be cancelled {label} ფაილს წერს და მისი გაუქმება აღარ შეიძლება + Opening {name}… იხსნება {name}… + Opening… იხსნება… + Reading… იკითხება… + Working… მუშავდება… +@id select.all ყველაფრის მონიშვნა +@id edit.purge.undo დაბრუნების ისტორია + Delete Smart Filters ჭკვიანი ფილტრების წაშლა + Enable Smart Filters ჭკვიანი ფილტრების ჩართვა + New Layer… ახალი შრე… + Delete Layer… შრის წაშლა… + Color Range ფერების დიაპაზონი + Select Similar Colors მსგავსი ფერების მონიშვნა + New Selection ახალი მონიშვნა + Adaptive Wide Angle ადაპტიური ფართოკუთხა + Chalk & Charcoal ცარცი და ნახშირი + Charcoal ნახშირი + Chrome ქრომი + Conté Crayon კონტეს ფანქარი + Graphic Pen გრაფიკული კალამი + Halftone Pattern ნახევარტონური პატერნი + Note Paper ქაღალდი ჩანაწერებისთვის + Photocopy ფოტოასლი + Plaster თაბაშირი + Reticulation რეტიკულაცია + Stamp შტამპი + Torn Edges დახეული კიდეები + Water Paper სველი ქაღალდი + Canvas rotation ტილოს შემობრუნება + Extensions გაფართოებები + Libraries ბიბლიოთეკები + 3D 3D + Toolbars ხელსაწყოთა ზოლები + Move გადაადგილება + Wand ჯოხი + Pencil ფანქარი + Clone Stamp კლონირების შტამპი + Healing აღდგენა + Dodge გამაღიავებელი + Burn გამამუქებელი + Pen კალამი + Hand ხელი + Sampler სინჯის ამღები + Note tool შენიშვნის ხელსაწყო + Quick Selection სწრაფი მონიშვნა + Magnetic Lasso მაგნიტური ლასო + History Brush ისტორიის ფუნჯი + Art History Brush მხატვრული ისტორიის ფუნჯი + Pattern Stamp პატერნის შტამპი + Spot Healing წერტილოვანი აღდგენა + Red Eye წითელი თვალები + Patch საკერებელი + Content-Aware Move შიგთავსის გათვალისწინებით გადაადგილება + Rotate View ხედის შემობრუნება + Perspective Crop პერსპექტიული ჩამოჭრა + Color Replacement ფერის ჩანაცვლება + Dodge/Burn გამაღიავებელი/გამამუქებელი + Dynamics დინამიკა + Tilt დახრა + Reset ჩამოყრა + Defaults restored. ნაგულისხმევი პარამეტრები აღდგენილია. + Stop შეწყვეტა + Create new set ახალი ნაკრების შექმნა + Technology Previews ტექნოლოგიების წინასწარი ვერსიები + New Group ახალი ჯგუფი + Layer Comp შრეების კომპოზიცია + Create new layer ახალი შრის შექმნა + Group · {n} layer ჯგუფი · {n} შრე + General Blend ზოგადი შერევა + Advanced Blending გაფართოებული შერევა + RGB RGB + Alpha ალფა + Spot Channel სპოტ-ფერის არხი + Working Gray Plate სამუშაო რუხი ფირფიტა + HSL/Grayscale HSL/ნაცრისფერი ტონები + Tone Curve ტონალური მრუდი + Split Toning გაყოფილი ტონირება + Lens Corrections ობიექტივის კორექციები + Camera Calibration კამერის კალიბრაცია + Snapshots სნეპშოტები + Recovery აღდგენა + Fill Light შევსების სინათლე + Convert to Grayscale ნაცრისფერ ტონებად გარდაქმნა + Sharpness სიმკვეთრე + Color Noise Reduction ფერის ხმაურის შემცირება + Luminance Smoothness სიკაშკაშის სიგლუვე + Make მწარმოებელი + Model მოდელი + Lens ობიექტივი + F F + ISO ISO + Focal Length35mm ფოკუსური მანძილი (35 მმ-ის ეკვივალენტი) + Aspect Ratio თანაფარდობა + Time დრო + Artist ავტორი + Enter description… შეიყვანეთ აღწერა… + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard ინდუსტრიული სტანდარტი + Uncoated CMYK CMYK უცარცო ქაღალდისთვის + Newspaper გაზეთი + Working Spaces სამუშაო სივრცეები + Color Management Policies ფერების მართვის წესები + Detect Faces სახეების ამოცნობა + Localize Color Clusters ფერთა კლასტერების ლოკალიზება + Duplicated selection name მონიშნულის სახელი დუბლირებულია + Operation ოპერაცია + New channel only მხოლოდ ახალი არხი + Masked Areas ნიღბიანი არეები + Selected Areas მონიშნული არეები + Spot Color სპოტ-ფერი + Color indicates ფერი აღნიშნავს + Create Snapshot სნეპშოტის შექმნა + Delete Snapshot სნეპშოტის წაშლა + New Snapshot ახალი სნეპშოტი + Snapshot 1 სნეპშოტი 1 + Step Forward ნაბიჯით წინ + Step Backward ნაბიჯით უკან + Nonlinear History არაწრფივი ისტორია + Set New Snapshot as Default ახალი სნეპშოტის ნაგულისხმევად დაყენება + Automatically Create First Snapshot პირველი სნეპშოტის ავტომატურად შექმნა + Automatically Create New Snapshot When Saving შენახვისას ახალი სნეპშოტის ავტომატურად შექმნა + Show New Snapshot Dialog by Default ახალი სნეპშოტის დიალოგის ნაგულისხმევად ჩვენება + Clear History ისტორიის გასუფთავება + Panel Options… პანელის პარამეტრები… + View menu ხედის მენიუ + Keep view in sync ხედის სინქრონიზაცია + {zoom}% {zoom}% + Mouse Controls თაგვის მართვა + Initial Direction of Stroke მონასმის საწყისი მიმართულება + Direction of Stroke მონასმის მიმართულება + Humidity ტენიანობა + Auto Clean Brush ფუნჯის ავტომატური გაწმენდა + Spacing: ინტერვალი: + Brush Tip ფუნჯის წვერი + New Layer from Brush ახალი შრე ფუნჯიდან + New Brush ახალი ფუნჯი + New Tool Preset ხელსაწყოს ახალი პრესეტი + Auto-Leading ავტომატური სტრიქონთა დაშორება + Scale Horizontally ჰორიზონტალური მასშტაბირება + Scale Vertically ვერტიკალური მასშტაბირება + Baseline Shift საბაზისო ხაზის წანაცვლება + Mac LCD Mac LCD + Mac Sharp Mac მკვეთრი + Mac Crisp Mac მკაფიო + Mac Strong Mac ძლიერი + Mac Smooth Mac გლუვი + Superscript ზედა ინდექსი + Subscript ქვედა ინდექსი + Ligature ლიგატურა + Alternative ალტერნატიული + Roman რომაული + Bold Italic მუქი დახრილი + Ultra Light ულტრა ღია + Extra Light ძალიან ღია + Semi Bold ნახევრად მუქი + Extra Bold ძალიან მუქი + Ultra Bold ულტრა მუქი + Condensed შევიწროებული + First Line Indent პირველი სტრიქონის შეწევა + Hanging Punctuation ჩამოკიდებული პუნქტუაცია + Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია + Avoid Widows/Orphans ობოლი სტრიქონების თავიდან აცილება + Compose აწყობა + Adobe Single-line Composer Adobe-ის სტრიქონის მიხედვით აწყობა + Adobe Every-line Composer Adobe-ის აბზაცის მიხედვით აწყობა + Justification სწორება + Word Spacing სიტყვებს შორის ინტერვალი + Letter Spacing ასოებს შორის ინტერვალი + Glyph Scaling გლიფების მასშტაბირება + Auto-leading % ავტომატური სტრიქონთა დაშორება, % + Single Word Justification ერთი სიტყვის ორმხრივი სწორება + Alignment სწორება + Justify ორმხრივი სწორება + Glyph გლიფი + Alternates for Current Selection ალტერნატივები მიმდინარე მონიშნულისთვის + Digits ციფრები + Symbols სიმბოლოები + Punctuation პუნქტუაცია + Path: ტრაექტორია: + Path to Illustrator… ტრაექტორია Illustrator-ისთვის… + Make Work Path სამუშაო ტრაექტორიის შექმნა + Make Selection მონიშნულის შექმნა + Fill Path ტრაექტორიის შევსება + Stroke Path ტრაექტორიის კონტური + Clipping Path ჩაჭრის ტრაექტორია + New Path ახალი ტრაექტორია + Duplicate Path ტრაექტორიის დუბლირება + Delete Path ტრაექტორიის წაშლა + Auto-Hide ავტომატური დამალვა + W W + H H + X X + Y Y + Frame Sync კადრების სინქრონიზაცია + Quick Sync სწრაფი სინქრონიზაცია + Audio აუდიო + Frame Duration კადრის ხანგრძლივობა + Loop ციკლი + Split at Playhead გაყოფა დაკვრის მაჩვენებელთან + Flatten Frames Into Clips კადრების კლიპებად გაერთიანება + Flatten Frames Into Layers კადრების შრეებად გაერთიანება + Convert Frames > Video Layers კადრების გარდაქმნა > ვიდეოშრეები + New Video Layer ახალი ვიდეოშრე + New Video Group ახალი ვიდეოჯგუფი + Limit Playback დაკვრის შეზღუდვა + Enable Timeline Quick Config დროის ხაზის სწრაფი კონფიგურაციის ჩართვა + Set Start Timecode… საწყისი დროის კოდის დაყენება… + Timecode დროის კოდი + 30 fps 30 კადრი/წმ + 25 fps 25 კადრი/წმ + 24 fps 24 კადრი/წმ + 15 fps 15 კადრი/წმ + 10 fps 10 კადრი/წმ + Current Time მიმდინარე დრო + Out გამოსვლა + In შესვლა + Duration ხანგრძლივობა + Repeat გამეორება + Play Once ერთხელ დაკვრა + Info Options… ინფორმაციის პარამეტრები… + First Color Readout ფერის პირველი ანათვალი + Second Color Readout ფერის მეორე ანათვალი + Actual Color ფაქტობრივი ფერი + Grayscale Slider ნაცრისფერი ტონების სლაიდერი + Web Color ვებფერი + HSB Color HSB ფერი + Total Ink საღებავის ჯამი + Opacity Mask გაუმჭვირვალობის ნიღაბი + Ruler სახაზავი + Document Size დოკუმენტის ზომა + Timing დროის აღრიცხვა + Status Information მდგომარეობის ინფორმაცია + Saved Multichannel შენახული მრავალარხიანი + Mouse Coordinates თაგვის კოორდინატები + Ruler Units სახაზავის ერთეულები + Hide დამალვა + New Layer Comp შრეების ახალი კომპოზიცია + Duplicate Layer Comp შრეების კომპოზიციის დუბლირება + Rename Layer Comp შრეების კომპოზიციის სახელის შეცვლა + Apply Layer Comp შრეების კომპოზიციის გამოყენება + Update Layer Comp შრეების კომპოზიციის განახლება + Restore last viewed comp ბოლოს ნანახი კომპოზიციის აღდგენა + Method მეთოდი + Select All Layers ყველა შრის მონიშვნა + Keep proportions პროპორციების შენარჩუნება + Interpolation ინტერპოლაცია + Author ავტორი + Caption წარწერა + Caption Credits წარწერის ავტორები + Keywords საკვანძო სიტყვები + Copyright Info URL საავტორო უფლებების ინფორმაციის URL + Copyright Status საავტორო უფლებების სტატუსი + Copyrighted დაცულია საავტორო უფლებით + Public Domain საზოგადოებრივი საკუთრება + Unknown უცნობი + Credit ავტორობა + Transmission Reference გადაცემის მითითება + Urgency სასწრაფოობა + High მაღალი + Low დაბალი + Category კატეგორია + Supplemental Categories დამატებითი კატეგორიები + Object Name / Title ობიექტის სახელი / სათაური + Date Created შექმნის თარიღი + City ქალაქი + Location მდებარეობა + Province / State რეგიონი / შტატი + Country ქვეყანა + Byline ავტორის ხელმოწერა + Byline Title ავტორის თანამდებობა + Phone ტელეფონი + Email ელფოსტა + URLs URL-ები + Last Modified ბოლო ცვლილება + EXIF EXIF + GPS GPS + Camera Data კამერის მონაცემები + Camera Model კამერის მოდელი + Camera Make კამერის მწარმოებელი + EXIF Version EXIF-ის ვერსია + Exposure Program ექსპოზიციის პროგრამა + Aperture დიაფრაგმა + Shutter Speed ჩამკეტის სიჩქარე + Metering Mode გაზომვის რეჟიმი + Light Source სინათლის წყარო + Flash განათება (Flash) + Lens Info ობიექტივის ინფორმაცია + ISO Speed ISO მგრძნობიარობა + Subject Distance მანძილი ობიექტამდე + DateTime Original ორიგინალის თარიღი და დრო + Create Date შექმნის თარიღი + Modify Date ცვლილების თარიღი + Software პროგრამა + Raw Data დაუმუშავებელი მონაცემები + Advanced დამატებითი + Import… იმპორტი… + PDF Presentation… PDF პრეზენტაცია… + Create Multiple Picture Packages რამდენიმე ფოტოპაკეტის შექმნა + Picture Package… ფოტოპაკეტი… + Web Photo Gallery… ვებფოტოგალერეა… + Choose… არჩევა… + Select Folder… საქაღალდის არჩევა… + Override Action "Save" Commands მოქმედების „შენახვის“ ბრძანებების უგულებელყოფა + File Naming ფაილების დასახელება + Compatibility თავსებადობა + Mac OS Mac OS + Unix Unix + Include ICC Profile ICC პროფილის ჩართვა + Error File შეცდომების ფაილი + Stop For Errors შეცდომისას შეჩერება + Log Errors To File შეცდომების ფაილში ჩაწერა + Errors შეცდომები + Settings პარამეტრები + Settings: პარამეტრები: + Working Space სამუშაო სივრცე + RGB: RGB: + CMYK: CMYK: + Gray: რუხი: + Spot: სპოტ-ფერი: + Advanced Controls დამატებითი მართვის ელემენტები + Desaturate Monitor Colors By მონიტორის ფერების გაუფერულება: + Blend RGB Colors Using Gamma 1.0 RGB ფერების შერევა გამით 1.0 + Profile Mismatches: Ask When Opening პროფილების შეუსაბამობა: გახსნისას შეკითხვა + Ask When Pasting ჩასმისას შეკითხვა + Missing Profiles: Ask When Opening აკლია პროფილები: გახსნისას შეკითხვა + Embed profiles პროფილების ჩაშენება + Ask Before Saving Saved Embedded შენახვამდე შეკითხვა ჩაშენებული პროფილის შესახებ + Coerce to sRGB sRGB-ზე იძულებით გადაყვანა + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Graphic and web გრაფიკა და ვები + Minimalistic მინიმალისტური + Expand All Groups ყველა ჯგუფის ჩამოშლა + Photo (2 × 3 in) ფოტო (2 × 3 დუიმი) + Photo (4 × 6 in) ფოტო (4 × 6 დუიმი) + Photo (5 × 7 in) ფოტო (5 × 7 დუიმი) + Photo (8 × 10 in) ფოტო (8 × 10 დუიმი) + Recent Items ბოლოდროინდელი ელემენტები + Save Preset… პრესეტის შენახვა… + Delete Preset… პრესეტის წაშლა… + Preset Name: პრესეტის სახელი: + Width and Height სიგანე და სიმაღლე + Color Profile ფერის პროფილი + Units ერთეულები + Columns სვეტები + Create new count group ახალი დათვლის ჯგუფის შექმნა + Adjustment Layer მაკორექტირებელი შრე + Pencil Tool ფანქრის ხელსაწყო + Pixel პიქსელი + Adjustment კორექცია + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract გადაათრიეთ (ლასო) ან დაწკაპეთ წერტილებზე (მრავალკუთხა) · {add} დამატება · {sub} გამოკლება + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels გადაათრიეთ ჩამოჭრის ჩარჩო · შიგნით გადათრევით გადაადგილება · კიდეებით ზომის შეცვლა ({ratio} თანაფარდობა, {centre} ცენტრიდან) · Space — გადაადგილება ხაზვისას · ისრები — მცირე წანაცვლება · X — ორიენტაციის შეცვლა · {commit} — დადასტურება · Esc — გაუქმება + {mode} Color · {bits} bit {mode} ფერი · {bits} ბიტი + Drag to move the layer გადაათრიეთ შრის გადასაადგილებლად + Scrubby Zoom მასშტაბირება გადათრევით + Auto Erase ავტომატური წაშლა + Color Adaptation ფერის ადაპტაცია + Preserve Transparency გამჭვირვალობის შენარჩუნება + New Style… ახალი სტილი… + shortcut მალსახმობი + Go to Layer შრეზე გადასვლა + Saved შენახულია + After (X) შემდეგ (X) + Current მიმდინარე + Reference point location საყრდენი წერტილის მდებარეობა + Toggle reference point საყრდენი წერტილის ჩართვა/გამორთვა + Arc რკალი + Arch თაღი + Bulge ამობურცვა + Shell Lower ქვედა ნიჟარა + Shell Upper ზედა ნიჟარა + Flag დროშა + Fish თევზი + Wave ტალღა + Rise აწევა + Inflate გაბერვა + Twist გრეხა + Squeeze შეკუმშვა + Add Pins სამაგრების დამატება + Delete Pins სამაგრების წაშლა + Rotate Auto ავტომატური შემობრუნება + Rotate Fixed ფიქსირებული შემობრუნება + Create Plane სიბრტყის შექმნა + Edit Plane სიბრტყის რედაქტირება + Select Plane სიბრტყის მონიშვნა + Measure გაზომვა + Heal აღდგენა + Heal Off აღდგენა გამორთულია + Heal On აღდგენა ჩართულია + Heal Auto ავტომატური აღდგენა + Blend შერევა + Brush Size ფუნჯის ზომა + Heal Color აღდგენის ფერი + Mask ნიღაბი + Channel არხი + Rectangle მართკუთხედი + Result შედეგი + New Channel ახალი არხი + Save Selection მონიშნულის შენახვა + Load Selection მონიშნულის ჩატვირთვა + Duplicate დუბლირება + Fast სწრაფი + Accurate ზუსტი + Add to დამატება: + Subtract from გამოკლება: + Intersect with გადაკვეთა: + Capture Comp კომპოზიციის ჩაწერა + No Layer Comps შრეების კომპოზიციები არ არის + Adobe PDF Presets… Adobe PDF-ის პრესეტები… + 3D… 3D… + Technology Previews… ტექნოლოგიების წინასწარი ვერსიები… + Background from Layer ფონი შრიდან + Add a mask (from the selection; ⌥ inverts) ნიღბის დამატება (მონიშნულიდან; ⌥ შებრუნებს) + Add to selection (⇧) მონიშნულზე დამატება (⇧) + Airbrush: keeps painting while the pointer rests. აეროგრაფი: ხატვა გრძელდება, სანამ მაჩვენებელი ერთ ადგილზე დგას. + Click the first point or press ↵ to close · Esc cancels დაწკაპეთ პირველ წერტილზე ან დააჭირეთ ↵-ს დასახურად · Esc — გაუქმება + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove დაწკაპეთ წერტილის დასამატებლად · მოსაშორებლად გადაათრიეთ გარეთ, ⌘/Ctrl+დაწკაპუნება ან Delete + Click to apply, ⌥-click to clear overrides, double-click for Style Options დაწკაპეთ გამოსაყენებლად, ⌥+დაწკაპუნება — გადაფარვების მოხსნა, ორმაგი დაწკაპუნება — სტილის პარამეტრები + Click to zoom in · ⌥-click to zoom out დაწკაპეთ გასადიდებლად · ⌥+დაწკაპუნება — დასაპატარავებლად + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · პირველ წერტილზე დაწკაპუნება: დახურვა · ↩ დასრულება · Esc გაუქმება + Commit Perspective Warp (↩) პერსპექტიული დეფორმაციის დადასტურება (↩) + Commit Puppet Warp (↩) მარიონეტული დეფორმაციის დადასტურება (↩) + Commit any current edits (⌘↩) მიმდინარე ცვლილებების დადასტურება (⌘↩) + Commit current crop operation (↵) მიმდინარე ჩამოჭრის დადასტურება (↵) + Commit transform (↩) გარდაქმნის დადასტურება (↩) + Commit warp (↩) დეფორმაციის დადასტურება (↩) + Create a new layer (⇧⌘N) ახალი შრის შექმნა (⇧⌘N) + Custom pattern მორგებული პატერნი + General Brushes ზოგადი ფუნჯები + Grid (⌘') ბადე (⌘') + Intersect with selection (⇧⌥) მონიშნულთან გადაკვეთა (⇧⌥) + New document… ⌘N ახალი დოკუმენტი… ⌘N + Open… ⌘O გახსნა… ⌘O + Rulers (⌘R) სახაზავები (⌘R) + Search commands (⌘K) ბრძანებების ძიება (⌘K) + Show Guides (⌘;) დამხმარე ხაზების ჩვენება (⌘;) + Subtract from selection (⌥) მონიშნულიდან გამოკლება (⌥) + Uses the foreground and background colors. იყენებს წინა პლანისა და ფონის ფერებს. + ↑↓ navigate ↵ run esc close ↑↓ ნავიგაცია ↵ გაშვება esc დახურვა + Adds grain to the soft edges of the brush tip. ფუნჯის წვერის რბილ კიდეებს მარცვლოვნებას მატებს. + Keeps the current texture when switching presets. პრესეტების გადართვისას მიმდინარე ტექსტურას ინარჩუნებს. + Watercolour look: lighter interior, darker rims. აკვარელის ეფექტი: ღია შიგნით, მუქი კიდეები. + Collapse group (⌥-click: all groups) ჯგუფის ჩაკეცვა (⌥+დაწკაპუნება: ყველა ჯგუფი) + Expand group (⌥-click: all groups) ჯგუფის გაშლა (⌥+დაწკაპუნება: ყველა ჯგუფი) + Hide the layer's effects (⌥-click: all layers) შრის ეფექტების დამალვა (⌥+დაწკაპუნება: ყველა შრე) + Show the layer's effects (⌥-click: all layers) შრის ეფექტების ჩვენება (⌥+დაწკაპუნება: ყველა შრე) + New from Clipboard ახალი გაცვლის ბუფერიდან + Free Transform a Copy ასლის თავისუფალი გარდაქმნა + Transform Again on a Copy ასლზე გარდაქმნის გამეორება + Select Layer Above ზედა შრის მონიშვნა + Select Layer Below ქვედა შრის მონიშვნა + Select Top Layer უზედაესი შრის მონიშვნა + Select Bottom Layer უქვედაესი შრის მონიშვნა + Add Layer Above to Selection ზედა შრის მონიშნულზე დამატება + Add Layer Below to Selection ქვედა შრის მონიშნულზე დამატება + 8 bit 8 ბიტი + Integer მთელი რიცხვი + 16 bit 16 ბიტი + 32 bit (float) 32 ბიტი (მცურავი) + Floating point მცურავი წერტილი + Use Global Light გლობალური განათების გამოყენება + Native file drag-and-drop is unavailable ფაილების სისტემური გადათრევა მიუწვდომელია + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. ფაილების სისტემური გადათრევა Wayland-ზე ჯერ მხარდაჭერილი არ არის. გამოიყენეთ ფაილი › გახსნა, ან დააკოპირეთ სურათი ფაილების მენეჯერში და ჩასვით {paste}-ით. + To drop files, start PhotoCraft under XWayland: `{command}` ფაილების ჩასაგდებად გაუშვით PhotoCraft XWayland-ში: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. ან დააყენეთ პარამეტრები › წარმადობა › Linux-ის ეკრანის სერვერი X11-ზე: მაშინ PhotoCraft ყოველთვის XWayland-ში გაეშვება. + Edit › Paste რედაქტირება › ჩასმა + Use File › Open, or paste a copied image with {paste}. გამოიყენეთ ფაილი › გახსნა, ან ჩასვით დაკოპირებული სურათი {paste}-ით. + Mixer Brush Tool შემრევი ფუნჯის ხელსაწყო + Color Replacement Tool ფერის ჩანაცვლების ხელსაწყო + Invalid {mode} profile {mode} პროფილი არასწორია + Unnamed {mode} profile უსახელო {mode} პროფილი + Show bounding box when dragging layer შრის გადათრევისას შემომსაზღვრელი ჩარჩოს ჩვენება + System title bar სისტემური სათაურის ზოლი + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) ლასოთი გაყინვა: გადათრევით არე იყინება, Alt+გადათრევით — დნება (L) + Rendering Mode რენდერის რეჟიმი + Automatic (recommended) ავტომატური (რეკომენდებული) + CPU / Compatibility CPU / თავსებადობა + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. ავტომატური რეჟიმი იყენებს GPU-აჩქარებას, როცა ის ხელმისაწვდომია, ხოლო შეცდომისას CPU-რენდერზე გადადის. + Switched to CPU rendering გადართულია CPU-რენდერზე + GPU acceleration could not continue. Your documents are unchanged. GPU-აჩქარების გაგრძელება ვერ მოხერხდა. თქვენი დოკუმენტები უცვლელია. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft იყენებს CPU-ზე მომუშავე გამოსახულების კომპოზიტორს. ფანჯარამ შეიძლება მაინც გამოიყენოს თქვენი ვიდეოადაპტერი. + Retry GPU requires a restart. Save your work first. GPU-ის ხელახლა ცდას გადატვირთვა სჭირდება. ჯერ შეინახეთ ნამუშევარი. + Keep Using CPU CPU-ის გამოყენების გაგრძელება + Retry GPU GPU-ის ხელახლა ცდა + GPU Backend GPU ბექენდი + Details დეტალები + Rendering mode რენდერის რეჟიმი + Shadow Clipping Warning (U) ჩრდილების მოკვეთის გაფრთხილება (U) + Highlight Clipping Warning (O) ღია ტონების მოკვეთის გაფრთხილება (O) + Show Lab Color Readouts Lab ფერის მნიშვნელობების ჩვენება + Show Vectorscope ვექტორსკოპის ჩვენება + Show Red at 3 o'clock წითლის ჩვენება 3 საათის მიმართულებით + Show Skin Tone Indicator კანის ტონის მაჩვენებლის ჩვენება + Tone Histogram ტონების ჰისტოგრამა + Show Selected Region მონიშნული რეგიონის ჩვენება +cameraRaw Light სინათლე + Camera Raw Filter ({layer}) Camera Raw ფილტრი ({layer}) + Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Lights ნათელი ტონები +cameraRaw Darks მუქი ტონები +cameraRaw Blacks შავები +cameraRaw Amount რაოდენობა +cameraRaw Tint ტონირება +cameraRaw Masking ნიღბვა +cameraRaw Blending შერევა + Patch Tool საკერებლის ხელსაწყო + Patch: საკერებელი: + Lasso around an area, then drag the selection შემოხაზეთ არე ლასოთი, შემდეგ გადაათრიეთ მონიშნული + Now drag the selection onto another area ახლა გადაათრიეთ მონიშნული სხვა არეზე + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: ბადე: + Edit Type ტექსტის რედაქტირება +@id edit.stroke კონტური… +@id edit.transform.scale მასშტაბირება +cameraRaw Red წითელი +cameraRaw Orange ნარინჯისფერი +cameraRaw Yellow ყვითელი +cameraRaw Green მწვანე +cameraRaw Aqua ზღვისფერი +cameraRaw Blue ლურჯი +cameraRaw Purple იისფერი +cameraRaw Magenta მეწამული +cameraRaw Luminance სიკაშკაშე +cameraRaw Temperature ტემპერატურა +cameraRaw Color ფერი +cameraRaw Vibrance სიხალასე +cameraRaw Color Grading ფერის კორექცია (grading) +cameraRaw Global გლობალური +cameraRaw Hue ფერის ტონი +cameraRaw Luminance Detail სიკაშკაშის დეტალები +cameraRaw Color Detail ფერის დეტალები +cameraRaw Roughness უხეშობა +cameraRaw Whites თეთრები + No patterns პატერნები არ არის + Enable {name} {name}-ის ჩართვა + Hold Alt while drawing for straight segments სწორი მონაკვეთებისთვის ხაზვისას დააჭირეთ Alt-ს + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts გადაათრიეთ ან დაწკაპეთ მრავალკუთხედის წერტილებზე · სწორი მონაკვეთებისთვის ლასოს დროს დააჭირეთ Alt-ს · {add} დამატება · {sub} ხაზვამდე — გამოკლება + Extend გაფართოება + Structure: სტრუქტურა: + Content-Aware Move Tool შიგთავსის გათვალისწინებით გადაადგილების ხელსაწყო + TIFF Options TIFF-ის პარამეტრები + Targa Options Targa-ს პარამეტრები + 24 bits/pixel 24 ბიტი/პიქსელი + 32 bits/pixel 32 ბიტი/პიქსელი + Including layers will increase file size. შრეების ჩართვა ფაილის ზომას გაზრდის. + Discard Layers and Save a Copy შრეების უგულებელყოფა და ასლის შენახვა + Don't show again აღარ აჩვენო + Save failed: {error} შენახვა ვერ მოხერხდა: {error} + Save cancelled შენახვა გაუქმდა + +# Layer Style dialog: style presets and per-effect defaults + Style {name} saved სტილი {name} შენახულია + Make Default ნაგულისხმევად დაყენება + Reset to Default ნაგულისხმევზე დაბრუნება + Couldn't place {name}: {error} {name}-ის ჩასმა ვერ მოხერხდა: {error} + Full-resolution preview unavailable სრული გარჩევადობის გადახედვა მიუწვდომელია + Magnetic Lasso Tool მაგნიტური ლასოს ხელსაწყო + Frequency სიხშირე + Use tablet pressure to change pen width კალმის სისქის შეცვლა პლანშეტის დაწოლით + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About პროგრამის შესახებ + Contributors მონაწილეები + Models მოდელები + Sort სორტირება + Username მომხმარებლის სახელი + Display name საჩვენებელი სახელი + Real name ნამდვილი სახელი + Reverse the order რიგის შებრუნება + Grab bag სხვადასხვა + Table ცხრილი + Name (A–Z) სახელი (ა–ჰ) + Merged PRs გაერთიანებული PR-ები + Commits კომიტები + Lines added დამატებული სტრიქონები + Lines deleted წაშლილი სტრიქონები + Line delta სტრიქონების სხვაობა + Binary assets added დამატებული ბინარული ფაილები + Binary assets removed წაშლილი ბინარული ფაილები + First commit პირველი კომიტი + First პირველი + Last commit ბოლო კომიტი + Last ბოლო + Contributors: {contributors} · Commits: {commits} მონაწილეები: {contributors} · კომიტები: {commits} + No contributor data was built into this copy. ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული. + No model credits were built into this copy. ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული. + Company კომპანია + Version ვერსია + % of all commits ყველა კომიტის % + Lines +/− სტრიქონები +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, კომიტი: {commits}, სტრიქონი: +{added} / −{deleted} (Δ {delta}), ბინარული ფაილი: +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch ახალი ფერის ნიმუშის შექმნა + New Swatch… ფერის ახალი ნიმუში… + New Swatch Group… ფერის ნიმუშების ახალი ჯგუფი… + Import Swatches… ფერის ნიმუშების იმპორტი… + Replace Swatches… ფერის ნიმუშების ჩანაცვლება… + Export Swatches… ფერის ნიმუშების ექსპორტი… + Export Swatches for Exchange… ფერის ნიმუშების ექსპორტი გასაცვლელად… + Reset Swatches ფერის ნიმუშების ჩამოყრა + Add to Swatches ფერის ნიმუშებში დამატება +# Layer Style > Blending Options (Advanced Blending) + Blend If: შერევა, თუ: + Channels: არხები: + Knockout ამოგდება + Apply At Canvas Bounds ტილოს საზღვრებზე გამოყენება + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) ფერის ამრჩევი (ტექსტის ფერი) + Color Picker ({name}) ფერის ამრჩევი ({name}) + Color Picker (Fill Color) ფერის ამრჩევი (შევსების ფერი) + Fill color შევსების ფერი + Color Picker (Stroke Color) ფერის ამრჩევი (კონტურის ფერი) + Stroke color კონტურის ფერი + Pick screen color ეკრანის ფერის აღება + Sample a pixel anywhere on the screen პიქსელის ნიმუშის აღება ეკრანის ნებისმიერი ადგილიდან + Click to pick · Esc to cancel დაწკაპეთ ასაღებად · Esc — გაუქმება + Pick a screen pixel or press Esc to cancel აიღეთ ეკრანის პიქსელი ან დააჭირეთ Esc-ს გასაუქმებლად + Scroll to zoom გადახვევით მასშტაბირება + Show/Hide All Panels ყველა პანელის ჩვენება/დამალვა + Show/Hide Panels პანელების ჩვენება/დამალვა + Could not open გახსნა ვერ მოხერხდა + {path}: the path is not valid Unicode; rename the file and open it again. {path}: გზა არ არის სწორი Unicode; გადაარქვით ფაილს სახელი და ხელახლა გახსენით. + +# Fill dialog, layer style contours and Image Size + Contents: შიგთავსი: + Custom Pattern: მორგებული პატერნი: + Foreground Color წინა პლანის ფერი + Color… ფერი… + 50% Gray 50% ნაცრისფერი + {size} (was {old}) {size} (იყო {old}) + Cone კონუსი + Cone (Inverted) კონუსი (შებრუნებული) + Domed გუმბათოვანი + Domed (Inverted) გუმბათოვანი (შებრუნებული) + Diagonal (Descending) დიაგონალური (დაღმავალი) + Contour Editor კონტურის რედაქტორი + Anti-aliased დაგლუვებული + Corner კუთხე + Gloss Contour ბზინვარების კონტური + Contour კონტური + Cone - Inverted კონუსი - შებრუნებული + Cove - Deep ღარი - ღრმა + Cove - Shallow ღარი - ზედაპირული + Gaussian გაუსის + Half Round ნახევარწრიული + Ring რგოლი + Ring - Double რგოლი - ორმაგი + Rolling Slope - Descending ტალღოვანი ფერდობი - დაღმავალი + Rounded Steps მომრგვალებული საფეხურები + Sawtooth 1 ხერხკბილა 1 + + Red Eye Tool წითელი თვალების ხელსაწყო + Pupil Size გუგის ზომა + Darken Amount ჩამუქების რაოდენობა + Click a red pupil to neutralize it დაწკაპეთ წითელ გუგაზე მის გასანეიტრალებლად + open source ღია კოდი + …and {n} more …და კიდევ {n} + + Eyedroppers: პიპეტები: + Set Black Point შავი წერტილის დაყენება + Set Neutral Gray Point ნეიტრალური ნაცრისფერი წერტილის დაყენება + Set White Point თეთრი წერტილის დაყენება + Unavailable in this color mode მიუწვდომელია ამ ფერის რეჟიმში + Selection Preview: მონიშნულის გადახედვა: + Black Matte შავი ფონი + White Matte თეთრი ფონი + +# Layer colour labels. + Seafoam ზღვის ქაფი + Indigo ინდიგო + Fuchsia ფუქსია + Violet იისფერი + Large document: redrawing on the CPU დიდი დოკუმენტი: გადახატვა CPU-ზე + This redraw took {seconds} s on the CPU. ამ გადახატვას CPU-ზე {seconds} წმ დასჭირდა. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. ცვლილებები, რომლებიც მთელ დოკუმენტს ცვლის, მაგალითად შრის შერევის რეჟიმი, გაუმჭვირვალობა ან ხილვადობა, მთელ დოკუმენტს ხელახლა ხატავს. + The GPU compositor wasn't used: {reason} GPU-კომპოზიტორი არ იქნა გამოყენებული: {reason} +fontWeight Thin თხელი +fontWeight ExtraLight ძალიან ღია +fontWeight Light ღია +fontWeight Regular ჩვეულებრივი +fontWeight Medium საშუალო +fontWeight SemiBold ნახევრად მუქი +fontWeight Bold მუქი +fontWeight ExtraBold ძალიან მუქი +fontWeight Black შავი +fontWeight Italic დახრილი + +# Pattern Stamp tool + Pattern Stamp Tool პატერნის შტამპის ხელსაწყო + Impressionist იმპრესიონისტული + Rotate View Tool ხედის შემობრუნების ხელსაწყო + Reset View ხედის ჩამოყრა + Drag around the centre to rotate the view · Shift constrains to 15° გადაათრიეთ ცენტრის გარშემო ხედის შესაბრუნებლად · Shift — 15°-იანი ბიჯით + +# Painting symmetry presets. + Dual Axis ორი ღერძი + Diagonal დიაგონალური + Transform Symmetry სიმეტრიის გარდაქმნა + +# Eyedropper Sample Size and Sample (#1649). + Point Sample წერტილოვანი ნიმუში + 3 by 3 Average საშუალო 3×3 + 5 by 5 Average საშუალო 5×5 + 11 by 11 Average საშუალო 11×11 + 31 by 31 Average საშუალო 31×31 + 51 by 51 Average საშუალო 51×51 + 101 by 101 Average საშუალო 101×101 + Sample Size: ნიმუშის ზომა: + All Layers No Adjustments ყველა შრე, კორექციის გარეშე + Current & Below No Adjustments მიმდინარე და ქვედა, კორექციის გარეშე + Show Sampling Ring ნიმუშის აღების რგოლის ჩვენება + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour ბეჭდვისთვის ფერთა დიაპაზონს გარეთაა: დაწკაპეთ უახლოესი დასაბეჭდი ფერისთვის + Not a web colour: click for the nearest web colour ვებფერი არ არის: დაწკაპეთ უახლოესი ვებფერისთვის + Color to Alpha… ფერიდან ალფაში… + Transparency Threshold გამჭვირვალობის ზღვარი + Opacity Threshold გაუმჭვირვალობის ზღვარი + PhotoCraft — Screen Color Picker PhotoCraft — ეკრანის ფერის ამრჩევი + + Rasterize svg groups deeper than SVG ჯგუფების რასტრირება სიღრმიდან + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 ამ ზღვარს გამორთავს; 100 დონეზე ღრმად ჩადგმული SVG ჯგუფები ყოველთვის რასტრირდება. + Rule of Thirds მესამედების წესი + Triangle სამკუთხედი + Golden Ratio ოქროს კვეთა + Golden Spiral ოქროს სპირალი + Auto Show Overlay გადაფარვის ავტომატური ჩვენება + Always Show Overlay გადაფარვის ყოველთვის ჩვენება + Never Show Overlay გადაფარვის არასოდეს ჩვენება + Cycle Overlay გადაფარვების გადართვა + Cycle Orientation ორიენტაციის გადართვა + Set the overlay options for the Crop tool ჩამოჭრის ხელსაწყოს გადაფარვის პარამეტრები + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-დაწკაპუნება სამუშაო მაგიდამ მიიღო, რომელიც Alt-გადათრევით ფანჯრებს ამოძრავებს. ფანჯრის გადაადგილების კლავიშად Super დააყენეთ, ან Alt დააფიქსირეთ: ფანჯარა › მოდიფიკატორი კლავიშები. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) ფერის ამრჩევი (შრის სტილის ფერი) + Auto Hide Notifications შეტყობინებების ავტომატური დამალვა + Notification Duration (seconds) შეტყობინების ხანგრძლივობა (წამი) + Appearance Mode იერსახის რეჟიმი + Dark Theme ბნელი თემა + Light Theme ღია თემა + Pro Medium Pro საშუალო + Studio Light Studio ღია + Next Appearance Mode შემდეგი იერსახის რეჟიმი + Sync with system სისტემასთან სინქრონიზაცია + Active აქტიური + Color Picker (Solid Color) ფერის ამრჩევი (მყარი ფერი) + Straighten the image by drawing a line on it გამოსახულების გასწორება მასზე ხაზის გავლებით + Set additional Crop options ჩამოჭრის დამატებითი პარამეტრები + Show Cropped Area ჩამოჭრილი არის ჩვენება + Enable Crop Shield ჩამოჭრის ფარის ჩართვა + Match Canvas ტილოს მიხედვით + Auto Adjust Opacity გაუმჭვირვალობის ავტომატური მორგება + Crop Shield color ჩამოჭრის ფარის ფერი + Color Picker (Crop Shield Color) ფერის ამრჩევი (ჩამოჭრის ფარის ფერი) + From Center ცენტრიდან + Link radii რადიუსების დაკავშირება + Star Ratio: ვარსკვლავის თანაფარდობა: + Create Rectangle მართკუთხედის შექმნა + Create Ellipse ელიფსის შექმნა + Create Triangle სამკუთხედის შექმნა + Create Polygon მრავალკუთხედის შექმნა + Pressure curve დაწოლის მრუდი + Click to add a point, right-click to remove დაწკაპეთ წერტილის დასამატებლად, მარჯვენა დაწკაპუნებით — წასაშლელად + Some brush presets could not be loaded ფუნჯის ზოგიერთი პრესეტი ვერ ჩაიტვირთა + Retry saving presets პრესეტების შენახვის ხელახლა ცდა + Brush preset changes are not saved: {error} ფუნჯის პრესეტების ცვლილებები არ ინახება: {error} + Keep this tab open to retain your brushes. ფუნჯების შესანარჩუნებლად ეს ჩანართი ღია დატოვეთ. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. ბრაუზერის საცავი მიუწვდომელია; ფუნჯები მხოლოდ ამ სეანსში შეინახება. მათ შესანარჩუნებლად ეს ჩანართი ღია დატოვეთ. + Saving brush presets… Keep this tab open until saving finishes. ფუნჯის პრესეტები ინახება… ეს ჩანართი ღია დატოვეთ შენახვის დასრულებამდე. + Reflect არეკვლა + Glow ნათება + Negation უარყოფა + XOR XOR + Color Burn (Paint.NET) ფერით ჩამუქება (Paint.NET) + Color Dodge (Paint.NET) ფერით გაღიავება (Paint.NET) + Stroke Options კონტურის პარამეტრები + Solid უწყვეტი + Dashed ტირეებიანი + Dotted პუნქტირი + Caps ბოლოები + Butt / Flat ბლაგვი / ბრტყელი + Corners კუთხეები + Miter მახვილი + Round მომრგვალებული + Bevel წაკვეთილი + Miter limit მახვილის ზღვარი + Dashed line ტირეებიანი ხაზი + Dash ტირე + Gap შუალედი + Dash / gap lengths × stroke width ტირის / შუალედის სიგრძეები × კონტურის სისქე + Add pair წყვილის დამატება + Remove pair წყვილის წაშლა + Dash offset ტირის წანაცვლება + Open paths use centered strokes ღია ტრაექტორიების კონტური ცენტრირებულია + Use finite lengths and a positive pattern total გამოიყენეთ სასრული სიგრძეები და ნიმუშის დადებითი ჯამური სიგრძე + No subject found — try Object Selection or the Lasso ობიექტი ვერ მოიძებნა — სცადეთ ობიექტის მონიშვნა ან ლასო + Quick Selection found nothing there სწრაფმა მონიშვნამ იქ ვერაფერი იპოვა + Object Selection found nothing there — try a tighter rectangle ობიექტის მონიშვნამ იქ ვერაფერი იპოვა — სცადეთ უფრო ვიწრო მართკუთხედი + No subject found — try a rectangle around the object ობიექტი ვერ მოიძებნა — სცადეთ ობიექტის გარშემო მართკუთხედი + Click the first point or press {key} to close · {del} removes a point · Esc cancels დაწკაპეთ პირველ წერტილზე ან დააჭირეთ {key}-ს დასახურად · {del} წერტილს შლის · Esc აუქმებს + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close დაწკაპეთ, შემდეგ იმოძრავეთ კიდის გასწვრივ · დაწკაპეთ წერტილის დასამაგრებლად · {alt}-დაწკაპუნება — სწორი მონაკვეთი · ორმაგი დაწკაპუნება — დახურვა + Follows only edges this close to the pointer ([ and ] change it) მიჰყვება მხოლოდ მაჩვენებელთან ამდენად ახლოს მყოფ კიდეებს ([ და ] ცვლის) + Higher values follow only edges that contrast sharply with their surroundings მაღალი მნიშვნელობები მხოლოდ გარემოსთან მკვეთრად კონტრასტულ კიდეებს მიჰყვება + How often fastening points are placed by themselves რა სიხშირით ისმება დამაგრების წერტილები ავტომატურად + Appearance Settings… იერსახის პარამეტრები… + Add to favorites რჩეულებში დამატება + Remove from favorites რჩეულებიდან ამოშლა +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make მწარმოებელი +cameraData Model მოდელი +cameraData Date Taken გადაღების თარიღი +cameraData Exposure ექსპოზიცია +cameraData F-Stop დიაფრაგმა +cameraData ISO Speed ISO მგრძნობიარობა +cameraData Focal Length ფოკუსური მანძილი +cameraData Focal Length (35 mm) ფოკუსური მანძილი (35 მმ) +cameraData Lens ობიექტივი +# Shared panel docking + Activate Panel პანელის გააქტიურება + Change Panel Group პანელების ჯგუფის შეცვლა + Change Panel Layout პანელების განლაგების შეცვლა + Close Panel პანელის დახურვა + Dock Panel პანელის მიმაგრება + Float Panel მცურავი პანელი + Move Panel პანელის გადაადგილება + Group with ერთად დაჯგუფება + Panel menu პანელის მენიუ + Resize panel window პანელის ფანჯრის ზომის შეცვლა + Resize panels პანელების ზომის შეცვლა + Use Classic Mode კლასიკური რეჟიმის გამოყენება + Auto Center Preview გადახედვის ავტომატური ცენტრირება + Next Document შემდეგი დოკუმენტი + Previous Document წინა დოკუმენტი + W x H x Resolution სიგ. x სიმ. x გარჩევადობა + Front Image წინა გამოსახულება + Frame Tool ჩარჩოს ხელსაწყო + Create a new rectangular frame ახალი მართკუთხა ჩარჩოს შექმნა + Create a new elliptical frame ახალი ელიფსური ჩარჩოს შექმნა + Include in playback დაკვრაში ჩართვა + Perspective Crop Tool პერსპექტიული ჩამოჭრის ხელსაწყო + Show Grid ბადის ჩვენება + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels გადაათრიეთ ჩარჩო ან დაწკაპეთ ოთხ კუთხეზე · კუთხეები დახრილი ფიგურის კიდეებზე გადაათრიეთ · {commit} ადასტურებს · Esc აუქმებს + Artboard Tool სამუშაო დაფის ხელსაწყო + Make Portrait ვერტიკალურად + Make Landscape ჰორიზონტალურად + Add New Artboard ახალი სამუშაო დაფის დამატება + Freeform Pen Tool თავისუფალი ფორმის კალმის ხელსაწყო + Curvature Pen Tool მრუდის კალმის ხელსაწყო + Curve Fit: მრუდის მორგება: + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish დაწკაპუნება: გლუვი წერტილი · ორმაგი დაწკაპუნება: კუთხე · წერტილის გადათრევა: გადაადგილება · პირველ წერტილზე დაწკაპუნება: დახურვა · {key} დასრულება + New Crop Preset… ჩამოჭრის ახალი პრესეტი… + Delete Crop Preset… ჩამოჭრის პრესეტის წაშლა… + Select the crop presets to delete: აირჩიეთ წასაშლელი ჩამოჭრის პრესეტები: + Select the crop presets to delete აირჩიეთ წასაშლელი ჩამოჭრის პრესეტები + Enter a name for the crop preset შეიყვანეთ ჩამოჭრის პრესეტის სახელი + There is no crop size or ratio to save შესანახი ჩამოჭრის ზომა ან თანაფარდობა არ არის + Scale: მასშტაბი: + Snap to Origin სათავესთან მიბმა + Search settings… პარამეტრების ძიება… + No matching settings შესაბამისი პარამეტრები არ არის +# Edit › Content-Aware Fill workspace + Sampling Brush Tool ნიმუშის აღების ფუნჯის ხელსაწყო + Sampling Area Overlay ნიმუშის აღების არის გადაფარვა + Show Sampling Area ნიმუშის აღების არის ჩვენება + Indicates აღნიშნავს + Sampling Area ნიმუშის აღების არე + Excluded Area გამორიცხული არე + Sampling Area Options ნიმუშის აღების არის პარამეტრები + Rectangular მართკუთხა + Fill Settings შევსების პარამეტრები + Rotation Adaptation ბრუნვის ადაპტაცია + Very High ძალიან მაღალი + Full სრული + Mirror არეკვლა + Output Settings გამოტანის პარამეტრები + Output To გამოტანის ადგილი + Loading fonts… შრიფტები იტვირთება… +# Select and Mask view modes (#1754) + Onion Skin ხახვის ფენები + Marching Ants მოსიარულე ჭიანჭველები + On Black შავ ფონზე + On White თეთრ ფონზე + On Layers შრეებზე + Show Edge კიდის ჩვენება + Show Original ორიგინალის ჩვენება + F cycles the views; hold X to see the image F ხედებს ცვლის; X-ის დაჭერით გამოსახულება გამოჩნდება +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) კიდის დაზუსტების ფუნჯი (R) + Expand Detection Area აღმოჩენის არის გაფართოება + Restore Original Edge საწყისი კიდის აღდგენა + Paint over hair or fur on the image; Alt paints the other mode გამოსახულებაზე თმა ან ბეწვი გადაღებეთ; Alt მეორე რეჟიმით ხატავს + Clear Brush Strokes ფუნჯის მონასმების გასუფთავება + Show rulers სახაზავების ჩვენება + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove დაწკაპეთ განსათავსებლად · Alt-დაწკაპუნება ან ტილოს გარეთ გადათრევა — წაშლა + No document open დოკუმენტი გახსნილი არ არის + {n} of {max} sample points სინჯის წერტილი: {n} / {max} +layerLabel No Color ფერის გარეშე + Preferences… პარამეტრები… + Actual Pixels რეალური პიქსელები + Clipping Mask მომჭრელი ნიღაბი + Tool Options ხელსაწყოების პარამეტრები + Color Picker ფერის ამრჩევი + Color Swatches ფერის ნიმუშები + Linear Dodge წრფივი გაღიავება + Preview Image გამოსახულების გადახედვა + Save As შენახვა როგორც + Remove All ყველას წაშლა + Unlock განბლოკვა + Enable ჩართვა + Disable გამორთვა + On ჩართული + Clear Selection მონიშნულის გასუფთავება + Invert Selection მონიშვნის შებრუნება + Select Inverse მონიშვნის შებრუნება + Contract შევიწროება + Sample Size ნიმუშის ზომა + Strength სიძლიერე + Gamma გამა + Input შემავალი + Output გამომავალი + Input Levels შემავალი დონეები + Output Levels გამომავალი დონეები + Layer Styles შრის სტილები + Blending Mode შერევის რეჟიმი + Fill Color შევსების ფერი + Stroke Color კონტურის ფერი + Stroke Width კონტურის სისქე + Shadow ჩრდილი + Adjustment Layers მაკორექტირებელი შრეები + Text Layer ტექსტური შრე + Documents დოკუმენტები + File Name ფაილის სახელი + File Format ფაილის ფორმატი + Compression შეკუმშვა + Artboards სამუშაო დაფები + Snap to Grid ბადეზე მიბმა + Full Screen მთელ ეკრანზე + Fullscreen მთელ ეკრანზე + Workspaces სამუშაო სივრცეები + Show All ყველას ჩვენება + Hidden დამალული + New Layer ახალი შრე + Duplicate Layer შრის დუბლირება + Ungroup დაჯგუფების მოხსნა + Lock Transparent Pixels გამჭვირვალე პიქსელების ჩაკეტვა + Lock Image Pixels გამოსახულების პიქსელების ჩაკეტვა + Lock Position პოზიციის ჩაკეტვა + Lock All ყველაფრის ჩაკეტვა + Show Rulers სახაზავების ჩვენება + Guide Layout… დამხმარე ხაზების განლაგება… + Page Setup… გვერდის პარამეტრები… + Reset Preferences პარამეტრების ჩამოყრა + Documentation დოკუმენტაცია + Check for Updates განახლებების შემოწმება + Loading… იტვირთება… + Processing… მუშავდება… + Saving… ინახება… + Exporting… ექსპორტირდება… + Please wait… გთხოვთ, მოიცადოთ… + No selection მონიშნული არაფერია + No layers შრეები არ არის + No recent files ბოლოს გახსნილი ფაილები არ არის + Unsupported file format ფაილის ფორმატი მხარდაჭერილი არ არის + Failed to open file ფაილის გახსნა ვერ მოხერხდა + Failed to save file ფაილის შენახვა ვერ მოხერხდა + Operation canceled ოპერაცია გაუქმდა + Changes have not been saved. ცვლილებები შენახული არ არის. + Are you sure? დარწმუნებული ხართ? + Discard Changes ცვლილებების უარყოფა + Recent Files ბოლოს გახსნილი ფაილები + Open File ფაილის გახსნა + Save File ფაილის შენახვა + Save Changes ცვლილებების შენახვა + Rendering… რენდერდება… + Importing… იმპორტირდება… + Ready მზადაა + Failed ვერ მოხერხდა + Warning გაფრთხილება + Information ინფორმაცია + Retry ხელახლა ცდა + Continue გაგრძელება + Skip გამოტოვება + Resume განახლება + Reset All ყველაფრის ჩამოყრა + Defaults ნაგულისხმევი + Apply to All ყველაზე გამოყენება + Confirm დადასტურება + Finish დასრულება + Back უკან + Next შემდეგი + Previous წინა + More მეტი + Less ნაკლები + Manual ხელით + Disabled გამორთული + Locked ჩაკეტილი + Unlocked განბლოკილი + Delete… წაშლა… + Create… შექმნა… + Edit… რედაქტირება… + Change… შეცვლა… + Transformation გარდაქმნა + Scaling მასშტაბირება + Canvas Size ტილოს ზომა + Percentage პროცენტული + Degrees გრადუსი + Middle შუა + Diameter დიამეტრი + Intensity ინტენსივობა + Sensitivity მგრძნობელობა + Softness სირბილე + Feather Radius კიდეების შერბილების რადიუსი + Library ბიბლიოთეკა + Layer Properties შრის თვისებები + Groups ჯგუფები + Masks ნიღბები + Add Mask ნიღბის დამატება + Delete Mask ნიღბის წაშლა + Invert Mask ნიღბის ინვერსია + Enable Mask ნიღბის ჩართვა + Disable Mask ნიღბის გამორთვა + Apply Mask ნიღბის გამოყენება + Import Settings პარამეტრების იმპორტი + Export Settings პარამეტრების ექსპორტი + Shortcut მალსახმობი + Shortcuts მალსახმობები + Keyboard კლავიატურა + Mouse მაუსი + Tablet პლანშეტი + Display ეკრანი + Panel პანელი + Hide Grid ბადის დამალვა + Fit to Window ფანჯარაში ჩატევა + Live Preview ცოცხალი გადახედვა + Before მანამდე + After შემდეგ + Compare შედარება + File Size ფაილის ზომა + Filename ფაილის სახელი + Folder საქაღალდე + Open Folder საქაღალდის გახსნა + Choose Folder საქაღალდის არჩევა + Browse არჩევა + Find ძებნა + Replace ჩანაცვლება + Date თარიღი + Learn More დაწვრილებით + Report a Bug შეცდომის შეტყობინება + Updates განახლებები + Update განახლება + Restart Required საჭიროა გადატვირთვა + Restart გადატვირთვა + Quit გასვლა + Quit PhotoCraft PhotoCraft-იდან გასვლა + Close Window ფანჯრის დახურვა + New Window ახალი ფანჯარა + Open Image გამოსახულების გახსნა + Save Image გამოსახულების შენახვა + Export Image გამოსახულების ექსპორტი + Import Image გამოსახულების იმპორტი + Export As ექსპორტი სხვა ფორმატით + Export to ექსპორტი ფორმატში + Export Options ექსპორტის პარამეტრები + Import Options იმპორტის პარამეტრები + No Selection მონიშნული არაფერია + No Document დოკუმენტი არ არის + No Image გამოსახულება არ არის + No Layers შრეები არ არის + No Results შედეგები არ არის + No Results Found შედეგები ვერ მოიძებნა + Nothing to Undo დასაბრუნებელი არაფერია + Nothing to Redo ხელახლა შესასრულებელი არაფერია + Changes Saved ცვლილებები შენახულია + Unsaved Changes შეუნახავი ცვლილებები + Save before closing? შევინახო დახურვამდე? + Are you sure you want to quit? ნამდვილად გსურთ გასვლა? + The file could not be opened. ფაილის გახსნა ვერ მოხერხდა. + The file could not be saved. ფაილის შენახვა ვერ მოხერხდა. + Unsupported format ფორმატი მხარდაჭერილი არ არის + Out of memory მეხსიერება არ კმარა + Permission denied წვდომა აკრძალულია + File not found ფაილი ვერ მოიძებნა + Operation failed ოპერაცია ვერ შესრულდა + Operation completed ოპერაცია დასრულდა + Operation cancelled ოპერაცია გაუქმდა + Sharpen Amount გამკვეთრების ოდენობა + Sharpen Radius გამკვეთრების რადიუსი + Sharpen Detail გამკვეთრების დეტალი + Sharpen Masking გამკვეთრების ნიღბვა + Noise Luminance ხმაურის სიკაშკაშე + Noise Luminance Detail ხმაურის სიკაშკაშის დეტალი + Noise Color ხმაურის ფერი + Noise Color Detail ხმაურის ფერის დეტალი + Grain Amount მარცვლოვნების ოდენობა + Grain Size მარცვლის ზომა + Grain Roughness მარცვლის უხეშობა + Vignette Roundness ვინიეტის სიმრგვალე + Vignette Feather ვინიეტის კიდეების შერბილება + Vignette Highlights ვინიეტის ღია ტონები + Vignette Style ვინიეტის სტილი + Planes სიბრტყეები + Plane სიბრტყე + Shadow Amount ჩრდილის ოდენობა + Shadow Tone ჩრდილის ტონი + Shadow Radius ჩრდილის რადიუსი + Highlight Amount ღია ტონების ოდენობა + Highlight Tone ღია ტონების ტონი + Highlight Radius ღია ტონების რადიუსი + Midtone შუალედური ტონი + Black Clip შავის მოკვეთა + White Clip თეთრის მოკვეთა + Fuzziness გაბნეულობა + Source Layer წყარო შრე + Neutralize ნეიტრალიზება + Use Selection In Source წყაროში მონიშნულის გამოყენება + Use Selection In Target სამიზნეში მონიშნულის გამოყენება + Flame Type ალის ტიპი + Randomize Length სიგრძის შემთხვევითობა + Interval ინტერვალი + Adjust Interval For Loops ინტერვალის მორგება ციკლებისთვის + Use Custom Color საკუთარი ფერის გამოყენება + Flame Lines ალის ხაზები + Flame Bottom Alignment ალის ქვედა სწორება + Flame Style ალის სტილი + Flame Shape ალის ფორმა + Randomize Shapes ფორმების შემთხვევითობა + Frame ჩარჩო + Arrangement განლაგება + Thickness სისქე + Vine Color ვაზის ფერი + Flower Color ყვავილის ფერი + Leaf Color ფოთლის ფერი + Base Tree Type ხის ძირითადი ტიპი + Light Direction სინათლის მიმართულება + Leaves Amount ფოთლების რაოდენობა + Leaves Size ფოთლების ზომა + Branches Height ტოტების სიმაღლე + Branches Thickness ტოტების სისქე + Default Leaves ნაგულისხმევი ფოთლები + Leaves Color ფოთლების ფერი + Custom Branch Color ტოტების საკუთარი ფერი + Branches Color ტოტების ფერი + Flat Shading ბრტყელი დაჩრდილვა + Date and Time თარიღი და დრო + Scale Units მასშტაბის ერთეულები + Scale Factor მასშტაბის კოეფიციენტი + Area ფართობი + Perimeter პერიმეტრი + Circularity წრიულობა + Gray Value (Minimum) რუხი მნიშვნელობა (მინიმუმი) + Gray Value (Maximum) რუხი მნიშვნელობა (მაქსიმუმი) + Gray Value (Mean) რუხი მნიშვნელობა (საშუალო) + Gray Value (Median) რუხი მნიშვნელობა (მედიანა) + Integrated Density ინტეგრალური სიმკვრივე + Length სიგრძე + In Black შემავალი შავი + In White შემავალი თეთრი + Out Black გამომავალი შავი + Out White გამომავალი თეთრი + Tint Color ტონირების ფერი + Stops გადასვლის წერტილები + Center X ცენტრი X + Center Y ცენტრი Y + Distribution განაწილება + Monochromatic მონოქრომული + Cell Size უჯრის ზომა + Generators გენერატორები + Wavelength Min ტალღის სიგრძე მინ. + Wavelength Max ტალღის სიგრძე მაქს. + Amplitude Min ამპლიტუდა მინ. + Amplitude Max ამპლიტუდა მაქს. + Undefined Areas განუსაზღვრელი არეები + Preserve შენარჩუნება + Max Radius მაქს. რადიუსი + Depth Mode სიღრმის რეჟიმი + Solid Front Faces უწყვეტი წინა წახნაგები + Mask Incomplete Blocks არასრული ბლოკების დაფარვა + Stylization სტილიზაცია + Cleanliness სისუფთავე + Bristle Detail ჯაგრის დეტალი + Lighting განათება + Shine ბზინვარება + Max Offset მაქს. წანაცვლება + Map Document რუკის დოკუმენტი + Map Path რუკის ფაილი + Map Layer რუკის შრე + Ridges ქედები + Light Type სინათლის ტიპი + Light X სინათლე X + Light Y სინათლე Y + Light Z სინათლე Z + Target X სამიზნე X + Target Y სამიზნე Y + Hotspot ცხელი წერტილი + Elevation სიმაღლე + Ambient გარემოს განათება + Warmth სითბო + Gloss სიპრიალე + Metallic მეტალიკური + Ambience გარემო + White Is High თეთრი მაღალია + Reduce Color Noise ფერადი ხმაურის შემცირება + Sharpen Details დეტალების გამკვეთრება + Remove Jpeg Artifact JPEG არტეფაქტის მოცილება + Blade Curvature ფრთების სიმრუდე + Depth Map სიღრმის რუკა + Focal Distance ფოკუსური მანძილი + Focus ფოკუსი + Transition გადასვლა + Radius X რადიუსი X + Radius Y რადიუსი Y + Pins სამაგრები + Blur Angle გაბუნდოვნების კუთხე + Speed სიჩქარე + Taper წაწვეტება + Start X დასაწყისი X + Start Y დასაწყისი Y + End X დასასრული X + End Y დასასრული Y + Kernel ბირთვი + Input Mode შეყვანის რეჟიმი + Row Order სტრიქონების რიგი + Eliminate აღმოფხვრა + Create By შექმნის ხერხი + List სია + Pencil Width ფანქრის სისქე + Stroke Pressure შტრიხის დაწოლა + Paper Brightness ქაღალდის სიკაშკაშე + Number Of Levels დონეების რაოდენობა + Edge Simplicity კიდის სიმარტივე + Edge Fidelity კიდის სიზუსტე + Brush Detail ფუნჯის დეტალი + Highlight Area ნათელი არე + Glow Size ნათების ზომა + Glow Brightness ნათების სიკაშკაშე + Brush Type ფუნჯის ტიპი + Stroke Size შტრიხის ზომა + Stroke Detail შტრიხის დეტალი + Highlight Strength ღია ტონების სიძლიერე + Smoothness სიგლუვე + Edge Thickness კიდის სისქე + Edge Intensity კიდის ინტენსივობა + Posterization პოსტერიზაცია + Stroke Length შტრიხის სიგრძე + Relief რელიეფი + Definition სიცხადე + Texture Coverage ტექსტურის დაფარვა + Shadow Intensity ჩრდილის ინტენსივობა + Edge Width კიდის სიგანე + Edge Brightness კიდის სიკაშკაშე + Direction Balance მიმართულების ბალანსი + Black Intensity შავის ინტენსივობა + White Intensity თეთრის ინტენსივობა + Dark Intensity მუქის ინტენსივობა + Custom Pixel Aspect Ratio პიქსელის საკუთარი თანაფარდობა + 32-bit Preview Options 32-ბიტიანი გადახედვის პარამეტრები + New Guide ახალი დამხმარე ხაზი + Join the ArtCraft Discord შემოუერთდით ArtCraft-ის Discord-ს + Report an Issue ხარვეზის შეტყობინება + Load Files into Stack ფაილების დასტაში ჩატვირთვა + Statistics სტატისტიკა + Duplicate Layers შრეების დუბლირება + Edit Toolbar ხელსაწყოთა ზოლის რედაქტირება + Import Brushes ფუნჯების იმპორტი + Load 3D LUT 3D LUT-ის ჩატვირთვა + New document ახალი დოკუმენტი + Press keys დააჭირეთ კლავიშებს + Rename Group ჯგუფის სახელის შეცვლა + Rename Tool Preset ხელსაწყოს პრესეტის სახელის შეცვლა + Search commands, tools and panels ბრძანებების, ხელსაწყოებისა და პანელების ძიება + Slice Options ნაჭრის პარამეტრები + Rename Brush ფუნჯის სახელის შეცვლა + Opening იხსნება + Reading იკითხება + Working მუშავდება + Accented Edges აქცენტირებული კიდეები + Angled Strokes დახრილი შტრიხები + Anisotropic ანიზოტროპული + Around Center ცენტრის გარშემო + Artistic მხატვრული + Bamboo ბამბუკი + Bas Relief ბარელიეფი + Berries კენკრა + Blast აფეთქება + Bleach Bypass გაუთეთრებელი დამუშავება + Blocks ბლოკები + Brick აგური + Brush Strokes ფუნჯის შტრიხები + Burlap ტომრის ქსოვილი + Candle Light სანთლის შუქი + Circle წრე + Clumped შეჯგუფებული + Coarse Dots მსხვილი წერტილები + Colored Pencil ფერადი ფანქარი + Contrasty კონტრასტული + Cool ცივი + Craquelure კრაკელიური + Cross ჯვარი + Cross Process ჯვარედინი დამუშავება + Crosshatch ჯვარედინი დაშტრიხვა + Cutout აპლიკაცია + Daisies გვირილები + Dark Rough მუქი უხეში + Dark Strokes მუქი შტრიხები + Darken Only მხოლოდ ჩამუქება + Day For Night დღე ღამედ + Deep Blue მუქი ლურჯი + Deep Emerald მუქი ზურმუხტისფერი + Deep Red მუქი წითელი + Deep Yellow მუქი ყვითელი + Dot წერტილი + Double Line ორმაგი ხაზი + Draft მონახაზი + Dry Brush მშრალი ფუნჯი + Duplication დუბლირება + Edge Extension კიდის გაგრძელება + Edge Only მხოლოდ კიდე + Enlarged გადიდებული + Even Fields ლუწი ველები + Faded Film გახუნებული ფირი + Film Grain ფირის მარცვლოვნება + Fine წვრილი + Fine Dots წვრილი წერტილები + Flat ბრტყელი + Fresco ფრესკა + From Left მარცხნიდან + From Right მარჯვნიდან + Frosted მქრქალი + Generic ზოგადი + Glass მინა + Glowing Edges მანათობელი კიდეები + Grainy Dots მარცვლოვანი წერტილები + Hearts გულები + Heptagon შვიდკუთხედი + Hexagon ექვსკუთხედი + Horizontal Only მხოლოდ ჰორიზონტალური + Infinite უსასრულო + Ink Outlines მელნის კონტურები + Ivy სურო + Left Diagonal მარცხენა დიაგონალი + Level Based დონეზე დაფუძნებული + Light Rough ღია უხეში + Lighten Only მხოლოდ გაღიავება + Long Lines გრძელი ხაზები + Long Strokes გრძელი შტრიხები + Lower ქვედა + Medium Dots საშუალო წერტილები + Medium Lines საშუალო ხაზები + Medium Strokes საშუალო შტრიხები + Mono Contrast მონოკონტრასტი + Mosaic Tiles მოზაიკის ფილები + Multiple Flames Along Path მრავალი ალი ტრაექტორიის გასწვრივ + Multiple Flames One Direction მრავალი ალი ერთი მიმართულებით + Multiple Flames Path Directions მრავალი ალი ტრაექტორიის მიმართულებებით + Multiple Flames Various Length მრავალი ალი სხვადასხვა სიგრძით + Octagon რვაკუთხედი + Odd Fields კენტი ველები + One Flame Along Path ერთი ალი ტრაექტორიის გასწვრივ + Out From Center ცენტრიდან გარეთ + Oval ოვალი + Overlay Edge კიდის გადაფარვა + Palette Knife პალიტრის დანა + Parallel პარალელური + Patchwork ნაჭრების მოზაიკა + Pentagon ხუთკუთხედი + Point წერტილი + Pointed წვეტიანი + Pond Ripples ტბორის ტალღები + Pyramids პირამიდები + Random შემთხვევითი + Right Diagonal მარჯვენა დიაგონალი + Rope თოკი + Roses ვარდები + Sandstone ქვიშაქვა + Scallops ნიჟარები + Sepia სეპია + Short Lines მოკლე ხაზები + Short Strokes მოკლე შტრიხები + Simple მარტივი + Light Intensity სინათლის ინტენსივობა + Spray Radius შესხურების რადიუსი + Stroke Direction შტრიხის მიმართულება + Graininess მარცვლოვნება + Glow Amount ნათების ოდენობა + Clear Amount სისუფთავის ოდენობა + Distortion დეფორმაცია + Ripple Size ტალღის ზომა + Ripple Magnitude ტალღის სიდიდე + Charcoal Area ნახშირის არე + Chalk Area ცარცის არე + Charcoal Thickness ნახშირის სისქე + Light Dark Balance ღია-მუქის ბალანსი + Foreground Level წინა პლანის დონე + Background Level ფონის დონე + Pattern Type პატერნის ტიპი + Image Balance გამოსახულების ბალანსი + Darkness სიმუქე + Fiber Length ბოჭკოს სიგრძე + Crack Spacing ბზარებს შორის მანძილი + Crack Depth ბზარის სიღრმე + Crack Brightness ბზარის სიკაშკაშე + Grain Type მარცვლის ტიპი + Tile Size ფილის ზომა + Grout Width ნაკერის სიგანე + Lighten Grout ნაკერის გაღიავება + Square Size კვადრატის ზომა + Border Thickness საზღვრის სისქე + Correct Distortion დეფორმაციის კორექცია + Correct Vignette ვინიეტის კორექცია + Correct C A ქრომატული აბერაციის კორექცია + Auto Scale ავტომატური მასშტაბირება + Red Cyan წითელი/ცისფერი + Blue Yellow ლურჯი/ყვითელი + Vignette Amount ვინიეტის ოდენობა + Vignette Midpoint ვინიეტის შუა წერტილი + Constraints შეზღუდვები + Curve Highlights მრუდის ღია ტონები + Curve Lights მრუდის ნათელი ტონები + Curve Darks მრუდის მუქი ტონები + Curve Shadows მრუდის ჩრდილები + Curve Splits მრუდის გამყოფები + Point Curve წერტილოვანი მრუდი + Point Curve Red წერტილოვანი მრუდი, წითელი + Point Curve Green წერტილოვანი მრუდი, მწვანე + Point Curve Blue წერტილოვანი მრუდი, ლურჯი + Hsl Hue HSL ფერის ტონი + Hsl Sat HSL გაჯერება + Hsl Lum HSL სიკაშკაშე + Grade Shadows გრადირება: ჩრდილები + Grade Midtones გრადირება: შუალედური ტონები + Grade Highlights გრადირება: ღია ტონები + Grade Global გრადირება: გლობალური + Grade Blending გრადირება: შერევა + Grade Balance გრადირება: ბალანსი + Sine სინუსი + Sketch ესკიზი + Snowflakes ფიფქები + Sparkle ბრჭყვიალი + Spatter შხეფები + Speckle ლაქები + Spin ბრუნვა + Sprinkles მოფრქვეული ნაწილაკები + Squareness კვადრატულობა + Stagger ჭადრაკული წანაცვლება + Stained Glass ვიტრაჟი + Star ვარსკვლავი + Stars ვარსკვლავები + Stippled პუნქტირებული + Stretch გაწელვა + Teal Orange ზღვისფერი/ნარინჯისფერი + Tiny Lens პატარა ლინზა + To Center ცენტრისკენ + Unaltered უცვლელი + Underpainting ქვედა ფენის ხატვა + Underwater წყალქვეშა + Uniform ერთგვაროვანი + Upper ზედა + Vertical Only მხოლოდ ვერტიკალური + Vine With Flowers ვაზი ყვავილებით + Vine With Leaves ვაზი ფოთლებით + Violent მძვინვარე + Warm თბილი + Watercolor აკვარელი + Waves ტალღები + Wide Blurry ფართო ბუნდოვანი + Wide Sharp ფართო მკვეთრი + Wrap შემოხვევა + Zigzag ზიგზაგი + Wetness სინოტივე + AI Integrations AI ინტეგრაციები + Add Noise ხმაურის დამატება + Apply Data Set მონაცემთა ნაკრების გამოყენება + Apply Image გამოსახულების გამოყენება + Arbitrary ნებისმიერი + Artboard from Group სამუშაო დაფა ჯგუფიდან + Artboard from Layers სამუშაო დაფა შრეებიდან + Artboards to Files სამუშაო დაფები ფაილებად + Artboards to PDF სამუშაო დაფები PDF-ად + Assign Profile პროფილის მინიჭება + At მდებარეობა + Auto-Align Layers შრეების ავტომატური სწორება + Auto-Blend Layers შრეების ავტომატური შერევა + Batch პაკეტური დამუშავება + Border საზღვარი + Box Blur კვადრატული გაბუნდოვნება + Calculations გამოთვლები + Camera Raw Camera Raw + Camera Raw Filter Camera Raw ფილტრი + Channel1 არხი1 + Channel2 არხი2 + Channel3 არხი3 + Channel4 არხი4 + Clone კლონირება + Color Decontaminate ფერის გაწმენდა + Color Halftone ფერადი ნახევარტონი + Color Lookup Tables ფერების ცხრილები (Color Lookup) + Color Settings ფერის პარამეტრები + Color Table ფერთა ცხრილი + Conditional Mode Change პირობითი რეჟიმის შეცვლა + Content-Aware Fill შიგთავსის გათვალისწინებით შევსება + Convert to Linked დაკავშირებულად გარდაქმნა + Convert to Profile პროფილში გარდაქმნა + Crystallize კრისტალიზაცია + Data მონაცემები + Data Sets as Files მონაცემთა ნაკრებები ფაილებად + De-Interlace სტრიქონგამოტოვების მოცილება + Define Brush Preset ფუნჯის პრესეტის განსაზღვრა + Define Custom Shape საკუთარი ფიგურის განსაზღვრა + Define Pattern პატერნის განსაზღვრა + Defringe კიდის შარავანდის მოცილება + Diffuse დიფუზია + Diffuse Glow გაბნეული ნათება + Displace წანაცვლება + Do you want to save you made to “{name}” before quitting? გსურთ „{name}“-ში შეტანილი ცვლილებების შენახვა გასვლამდე? + Dust & Scratches მტვერი და ნაკაწრები + Export Contents შიგთავსის ექსპორტი + Export Preferences ექსპორტის პარამეტრები + Export/Import Presets პრესეტების ექსპორტი/იმპორტი + Extrude ამოწევა + Fibers ბოჭკოები + Field Blur ველის გაბუნდოვნება + File Info ფაილის ინფორმაცია + Filter Gallery ფილტრების გალერეა + Find and Replace Text ტექსტის ძებნა და ჩანაცვლება + Fit Image გამოსახულების ჩატევა + Flame ალი + Focus Area ფოკუსის არე + Gaussian Blur გაუსის გაბუნდოვნება + Global Light გლობალური განათება + Group from Layers ჯგუფი შრეებიდან + HDR Toning HDR ტონირება + High Pass მაღალსიხშირული ფილტრი + Highlight ღია ტონი + Image Processor გამოსახულებების დამმუშავებელი + Install Plug-in პლაგინის დაყენება + Interpret Footage ვიდეომასალის ინტერპრეტაცია + Iris Blur ირისის გაბუნდოვნება + Jag დაკბილულობა + Label იარლიყი + Layer Comps to Files შრეების კომპოზიციები ფაილებად + Layer Content Options შრის შიგთავსის პარამეტრები + Layer From Background შრე ფონიდან + Layer from Background შრე ფონიდან + Layers to Files შრეები ფაილებად + Legacy მოძველებული + Lens Blur ლინზის ბუნდოვნება + Lens Correction ობიექტივის კორექცია + Lens Flare ობიექტივის ანარეკლი + Lighting Effects განათების ეფექტები + Liquify გათხევადება + Lock Layers შრეების ჩაკეტვა + Lum სიკ. + Match Color ფერის შეთანხმება + Merge to HDR Pro HDR Pro-ში შერწყმა + Mezzotint მეცოტინტო + Mosaic მოზაიკა + Motion Blur მოძრაობის გაბუნდოვნება + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. ფაილების ჩაშენებული გადათრევა-ჩაგდება Wayland-ზე ჯერ მხარდაჭერილი არ არის. გამოიყენეთ ფაილი › გახსნა, ან გაუშვით PhotoCraft XWayland-ში: `WAYLAND_DISPLAY= photocraft`. + Neon Glow ნეონის ნათება + New Guide Layout დამხმარე ხაზების ახალი განლაგება + New Video Layer from File ახალი ვიდეოშრე ფაილიდან + Ocean Ripple ოკეანის ტალღები + Oil Paint ზეთის საღებავი + Open As გახსნა როგორც + Package შეფუთვა + Paint Daubs საღებავის ლაქები + Path Blur ტრაექტორიის გასწვრივ გაბუნდოვნება + Paths to Illustrator ტრაექტორიები Illustrator-ში + Photomerge Photomerge + Picture Frame სურათის ჩარჩო + Pinch მოჭყლეტა + Place Embedded ჩაშენებულის განთავსება + Place Linked დაკავშირებულის განთავსება + Place Scale Marker მასშტაბის მარკერის განთავსება + Plastic Wrap პლასტიკური შეფუთვა + Pointillize პუანტილიზმი + Polar Coordinates პოლარული კოორდინატები + Polar To Rectangular პოლარულიდან მართკუთხაში + Poster Edges პოსტერის კიდეები + Preset Manager პრესეტების მართვა + Profile პროფილი + Radial Blur რადიალური გაბუნდოვნება + Raw Defaults Raw ნაგულისხმევი პარამეტრები + Rectangular To Polar მართკუთხიდან პოლარულში + Reduce Noise ხმაურის შემცირება + Relink to File ფაილთან ხელახლა დაკავშირება + Render Video ვიდეოს რენდერი + Replace Color ფერის ჩანაცვლება + Replace Contents შიგთავსის ჩანაცვლება + Replace Footage ვიდეომასალის ჩანაცვლება + Resolve Missing Fonts დაკარგული შრიფტების აღდგენა + Ripple ტალღა + Rough Pastels უხეში პასტელი + Sat გაჯ. + Save a Copy ასლის შენახვა + Save for Web (Legacy) ვებისთვის შენახვა (მოძველებული) + Scale Effects ეფექტების მასშტაბირება + Script Events Manager სკრიპტის მოვლენების მართვა + Search menus მენიუების ძიება + Seed საწყისი რიცხვი + Select and Mask მონიშვნა და ნიღაბი + Set Measurement Scale საზომი მასშტაბის დაყენება + Shadows/Highlights ჩრდილები/ღია ტონები + Shape Blur ფიგურით გაბუნდოვნება + Shear დახრა + Smart Blur ჭკვიანი გაბუნდოვნება + Smart Sharpen ჭკვიანი გამკვეთრება + Smudge Stick გამღაბნელი ჩხირი + Solid Color მყარი ფერი + Spherize სფერიზება + Spin Blur ბრუნვითი გაბუნდოვნება + Spot პროჟექტორი + Sprayed Strokes შესხურებული შტრიხები + Strokes შტრიხები + Sumi-e სუმი-ე + Surface Blur ზედაპირის გაბუნდოვნება + Texturizer ტექსტურირება + Tiles ფილები + Tilt-Shift დახრა-წანაცვლება + Tool ხელსაწყო + Trace Contour კონტურის მოხაზვა + Trap ტრეპინგი + Tree ხე + Trim შეკვეცა + Turbulent ტურბულენტობა + Twirl გრიგალი + Unsharp Mask არამკვეთრი ნიღაბი + Use File › Open to open an image. გამოსახულების გასახსნელად გამოიყენეთ ფაილი › გახსნა. + Vanishing Point გაქრობის წერტილი + Variable Data Sets ცვლადი მონაცემთა ნაკრებები + Video Frames to Layers ვიდეოკადრები შრეებად + WIA Support WIA მხარდაჭერა + Warp Text ტექსტის დეფორმაცია + Wind ქარი + ZigZag ზიგზაგი + Vertical Type Tool ვერტიკალური ტექსტის ხელსაწყო + About {app} {app}-ის შესახებ + Hide {app} {app}-ის დამალვა + Quit {app} {app}-იდან გასვლა + Services სერვისები + Hide Others სხვების დამალვა + Lut LUT + Cooling L B B გამაციებელი LBB + Cooling80 გამაციებელი 80 + Cooling82 გამაციებელი 82 + Hsb HSB + Hsl HSL + Movie Prime ფიქსირებული კინოობიექტივი + Prime105 ფიქსირებული 105 მმ + Prime35 ფიქსირებული 35 მმ + Rgb RGB + Sponge ღრუბელი + Warming L B A გამათბობელი LBA + Warming81 გამათბობელი 81 + Warming85 გამათბობელი 85 +@id filter.sharpen.sharpen გამკვეთრება + Good კარგი + Best საუკეთესო + Adjustment Properties კორექციის თვისებები + Default Foreground and Background Colors ნაგულისხმევი წინა პლანისა და ფონის ფერები + Move Layer შრის გადაადგილება + Rectangular Selection მართკუთხა მონიშვნა + Reorder Layer შრის გადალაგება + Select Layer შრის მონიშვნა + Set Colors ფერების დაყენება + Switch Foreground and Background Colors წინა პლანისა და ფონის ფერების შენაცვლება + Target სამიზნე +# Print dialog (#2697) + Emulsion Down ემულსია ქვემოთ + Functions ფუნქციები + Print Resolution: ბეჭდვის გარჩევადობა: + +# Added upstream (sync 2026-10-13) + Merge Group ჯგუფის შერწყმა + Manage submitted jobs in the system print queue სისტემის ბეჭდვის რიგში გაგზავნილი დავალებების მართვა + Enable animations ანიმაციების ჩართვა + Saving {percent} ინახება: {percent} + Transform On Drop გარდაქმნა ჩაგდებისას + Scale or rotate the moved area, then press {key} გადაადგილებული არის მასშტაბირება ან შემობრუნება, შემდეგ დააჭირეთ {key}-ს + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close გადათრევა: თავისუფალი ხატვა · {alt}-დაწკაპუნება: სწორი მონაკვეთი · გაშვება საწყის წერტილზე: დახურვა + Amount: რაოდენობა: + Protect: დაცვა: + Protect skin tones კანის ტონების დაცვა + Edit points to modify the curve მრუდის შესაცვლელად წერტილების რედაქტირება + Draw to modify the curve მრუდის შესაცვლელად ხატვა + Smooth the curve values მრუდის მნიშვნელობების გაგლუვება + Drag to draw the curve · {key}-click draws a straight line გადაათრიეთ მრუდის დასახატად · {key}-დაწკაპუნება ხაზავს სწორ ხაზს + Grid Size: ბადის ზომა: + Vanishing Point: click four corners to create a plane გაქრობის წერტილი: სიბრტყის შესაქმნელად დაწკაპეთ ოთხ კუთხეზე + Vanishing Point is not active გაქრობის წერტილი არ არის აქტიური + Apply Vanishing Point ({key}) გაქრობის წერტილის გამოყენება ({key}) + Fix the red plane first: its corners must form a convex quad ჯერ გაასწორეთ წითელი სიბრტყე: მისი კუთხეები ამოზნექილ ოთხკუთხედს უნდა ქმნიდეს + Use Previous Layer to Create Clipping Mask წინა შრის გამოყენება მომჭრელი ნიღბის შესაქმნელად + Fill with {mode}-neutral color (white) შევსება {mode}-ისთვის ნეიტრალური ფერით (თეთრი) + Fill with {mode}-neutral color (black) შევსება {mode}-ისთვის ნეიტრალური ფერით (შავი) + Fill with {mode}-neutral color (50% gray) შევსება {mode}-ისთვის ნეიტრალური ფერით (50% ნაცრისფერი) + (No neutral color exists for {mode} mode.) ({mode} რეჟიმისთვის ნეიტრალური ფერი არ არსებობს.) + The layer or its mask changed. Drag the mask onto the Trash again. შრე ან მისი ნიღაბი შეიცვალა. კვლავ გადაათრიეთ ნიღაბი ნაგვის ყუთზე. + Light Blue ღია ლურჯი + Light Red ღია წითელი + Medium Blue საშუალო ლურჯი + Light Gray ღია ნაცრისფერი + Remove after each stroke ყოველი შტრიხის შემდეგ მოშორება + Apply the removal ({key}) მოშორების გამოყენება ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) მოსაშორებელი შტრიხი: {n} (Enter — გამოყენება, Esc — გაუქმება) + Simulate Pressure დაწოლის იმიტაცია + Stroke Path… ტრაექტორიის კონტური… + Background Eraser ფონის საშლელი + Healing Brush აღმდგენი ფუნჯი + Spot Healing Brush წერტილოვანი აღმდგენი ფუნჯი + Smudge გადღაბნა +curves Smooth გაგლუვება + Apply mask to layer before removing? გამოვიყენო ნიღაბი შრეზე მოშორებამდე? + Delete the vector mask? წავშალო ვექტორული ნიღაბი? +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} ფერის ნიმუში: {n} ექსპორტირებულია {file}-ში +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group}-დან იმპორტირებულია ფუნჯი: {n} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}-დან იმპორტირებულია ფერის ნიმუში: {n} +@plural Played {n} step|Played {n} steps შესრულებულია ნაბიჯი: {n} +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk განახლდა დისკზე შეცვლილი დაკავშირებული ჭკვიანი ობიექტი: {n} +@plural Saved for Web: {n} file|Saved for Web: {n} files შენახულია ვებისთვის: ფაილი: {n} +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. ამ კომპოზიციის მიერ ჩაწერილი შრე წაიშალა: {n}. განაახლეთ კომპოზიცია ან გაასუფთავეთ გაფრთხილება. +@plural {n} plane|{n} planes სიბრტყე: {n} +@plural {n} preset|{n} presets პრესეტი: {n} +@plural {n} sample point removed|{n} sample points removed მოშორებულია სინჯის წერტილი: {n} +@plural {n} slice|{n} slices ნაჭერი: {n} +@plural {n} step|{n} steps ნაბიჯი: {n} +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (ფუნჯის პრესეტის დამატებითი გაფრთხილება: +{n}) +@plural {what} with {n} warning|{what} with {n} warnings {what}, გაფრთხილება: {n} + Duplicate Channel არხის დუბლირება + Delete Channel არხის წაშლა + Rename Channel… არხის სახელის შეცვლა… + Merge Spot Channel სპოტ-ფერის არხის შერწყმა + Convert to Alpha Channel ალფა-არხად გარდაქმნა + Exit Quick Mask სწრაფი ნიღბიდან გამოსვლა + New Channel… ახალი არხი… + New Spot Channel… ახალი სპოტ-ფერის არხი… + Split Channels არხების დაყოფა + Merge Channels… არხების შერწყმა… + Color Indicates Masked Areas ფერი აღნიშნავს ნიღბიან არეებს + Color Indicates Selected Areas ფერი აღნიშნავს მონიშნულ არეებს + Up ზემოთ + To საბოლოო + Make Work Path… სამუშაო ტრაექტორიის შექმნა… + Create Vector Mask ვექტორული ნიღბის შექმნა + Make Selection… მონიშნულის შექმნა… + Fill Path… ტრაექტორიის შევსება… + Clipping Path… ჩაჭრის ტრაექტორია… + Free Transform Path ტრაექტორიის თავისუფალი გარდაქმნა + Unite Shapes at Overlap ფიგურების გაერთიანება გადაფარვის არეში + Subtract Shapes at Overlap ფიგურების გამოკლება გადაფარვის არეში + Copy Fill შევსების კოპირება + Copy Complete Stroke სრული კონტურის კოპირება + Paste Fill შევსების ჩასმა + Paste Complete Stroke სრული კონტურის ჩასმა + Make Symmetry Path სიმეტრიის ტრაექტორიის შექმნა + Disable Symmetry Path სიმეტრიის ტრაექტორიის გამორთვა + Dark Gray მუქი ნაცრისფერი + Medium Gray საშუალო ნაცრისფერი + Select Custom Color… მორგებული ფერის არჩევა… + Color Picker (Custom Canvas Color) ფერის ამრჩევი (ტილოს მორგებული ფერი) + ✓ Color Indicates Masked Areas ✓ ფერი აღნიშნავს ნიღბიან არეებს + ✓ Color Indicates Selected Areas ✓ ფერი აღნიშნავს მონიშნულ არეებს + Shallow ზედაპირული + Deep ღრმა + Blend Interior Effects as Group შიდა ეფექტების შერევა ჯგუფად + Blend Clipped Layers as Group ჩაჭრილი შრეების შერევა ჯგუფად + Transparency Shapes Layer გამჭვირვალობა განსაზღვრავს შრის ფორმას + Layer Mask Hides Effects შრის ნიღაბი მალავს ეფექტებს + Vector Mask Hides Effects ვექტორული ნიღაბი მალავს ეფექტებს + General Blending ზოგადი შერევა + This Layer: ეს შრე: + Underlying Layer: ქვედა შრე: + Nothing to paste: the clipboard holds no image ჩასასმელი არაფერია: გაცვლის ბუფერში გამოსახულება არ არის + Preferences saved პარამეტრები შენახულია + Invalid panel layout; restored the default dock პანელების განლაგება არასწორია; აღდგა ნაგულისხმევი დოკი + Preferences were reset: {error} პარამეტრები ჩამოიყარა: {error} + Recovered {name} აღდგენილია {name} + Couldn't save preferences: {error}. Retrying… პარამეტრების შენახვა ვერ მოხერხდა: {error}. ხელახლა ცდა… + Autosave failed: {error} ავტოშენახვა ვერ მოხერხდა: {error} + Exported presets to {path} პრესეტები ექსპორტირებულია: {path} + Ran script {name} სკრიპტი შესრულდა: {name} + Sent to the printer ({spooler}) გაიგზავნა პრინტერზე ({spooler}) + Printed to {path} დაიბეჭდა: {path} + Exported {path} ({size}) ექსპორტირებულია: {path} ({size}) + Exported {path} ექსპორტირებულია: {path} + Added swatch {name} დაემატა ფერის ნიმუში: {name} + Step {step} ({id}) failed: {error} ნაბიჯი {step} ({id}) ვერ შესრულდა: {error} + Could not rename the action: {error} მოქმედების სახელის შეცვლა ვერ მოხერხდა: {error} + Action playback failed: {error} მოქმედების შესრულება ვერ მოხერხდა: {error} + Clone source set at {x}, {y} კლონირების წყარო დაყენდა: {x}, {y} + Color management: {error} ფერების მართვა: {error} + Opened {url} გაიხსნა: {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes მორგებული ფიგურა „{name}“ არ არსებობს: აირჩიეთ პარამეტრების ზოლში ან ფანჯარა › ფიგურები + Saved a copy as {path} ასლი შენახულია როგორც {path} + Exported contents to {path} შიგთავსი ექსპორტირებულია: {path} + {label} is not available: {reason} {label} მიუწვდომელია: {reason} + Saved {path}; image assets: {n} in {dir} შენახულია {path}; გამოსახულების რესურსები: {n}, {dir}-ში + {message} (notes: {n}; {warning}) {message} (შენიშვნა: {n}; {warning}) + Preferences can't be saved პარამეტრების შენახვა შეუძლებელია + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft კვლავ ცდილობს; სანამ შენახვა არ მოხერხდება, პარამეტრების ცვლილებები დახურვისას დაიკარგება. + GPU retry scheduled GPU-ს ხელახალი ცდა დაგეგმილია + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. შეინახეთ ნამუშევარი და გადატვირთეთ PhotoCraft GPU-აჩქარების ხელახლა საცდელად. ამ სესიაში CPU-რენდერინგი აქტიური რჩება. + GPU device was lost; using the CPU renderer. GPU მოწყობილობა დაიკარგა; გამოიყენება CPU-რენდერერი. + GPU error; using the CPU renderer. GPU-ს შეცდომა; გამოიყენება CPU-რენდერერი. + Couldn't open {name}: {error} {name}-ის გახსნა ვერ მოხერხდა: {error} + Toggle the Brush Settings panel ფუნჯის პარამეტრების პანელის ჩართვა/გამორთვა + New Brush Preset ფუნჯის ახალი პრესეტი + Grid view ბადის ხედი + List view სიის ხედი + {verb} {mask} {layer} {verb}: {mask}, {layer} + Unlink კავშირის გაწყვეტა + Link დაკავშირება + layer mask შრის ნიღაბი + vector mask ვექტორული ნიღაბი + Commit transform გარდაქმნის დადასტურება + Cancel transform გარდაქმნის გაუქმება + Scroll menu up მენიუს ზემოთ გადახვევა + Scroll menu down მენიუს ქვემოთ გადახვევა + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? გასაგრძელებლად ეს ტექსტის შრე უნდა დარასტრირდეს. მისი ტექსტი აღარ იქნება რედაქტირებადი. დავარასტრირო ტექსტი? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? გასაგრძელებლად ეს ფიგურის შრე უნდა დარასტრირდეს. ის ფიგურად აღარ იქნება რედაქტირებადი. დავარასტრირო ფიგურა? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? გასაგრძელებლად ეს ჭკვიანი ობიექტი უნდა დარასტრირდეს. მისი შიგთავსი აღარ იქნება რედაქტირებადი. დავარასტრირო ჭკვიანი ობიექტი? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? გასაგრძელებლად ეს შევსების შრე უნდა დარასტრირდეს. მისი შევსება აღარ იქნება რედაქტირებადი. დავარასტრირო შრე? + This layer must be rasterized before proceeding. გასაგრძელებლად ეს შრე უნდა დარასტრირდეს. + Color Picker (Background Color) ფერის ამრჩევი (ფონის ფერი) + Color Picker (Foreground Color) ფერის ამრჩევი (წინა პლანის ფერი) + Adwaita (Light) Adwaita (ღია) + Adwaita (Dark) Adwaita (ბნელი) + Breeze Light Breeze ღია + Breeze Dark Breeze ბნელი + Solarized Dark Solarized ბნელი + Open Recent is unavailable on the web ბოლოს გახსნილი ფაილების გახსნა ვებში მიუწვდომელია + Graphics adapter: {adapter} გრაფიკული ადაპტერი: {adapter} + Backend: {backend} ({type}) ბექენდი: {backend} ({type}) + Driver: {driver} დრაივერი: {driver} + GPU backend preference: {backend} GPU ბექენდის არჩევანი: {backend} + Selected at launch: {backend} არჩეულია გაშვებისას: {backend} + Image compositor: {processor} გამოსახულების კომპოზიტორი: {processor} + Fallback: {reason} სარეზერვო ვარიანტი: {reason} + This session: {reason} ეს სესია: {reason} + Window renderer: {renderer} ფანჯრის რენდერერი: {renderer} + Software adapter პროგრამული ადაპტერი + Graphics adapter გრაფიკული ადაპტერი + unknown უცნობი + unknown type უცნობი ტიპი + auto ავტომატური + Platform: {os} {arch} პლატფორმა: {os} {arch} + previous reading kept; the last re-read failed: {reason} შენარჩუნებულია წინა მონაცემები; ბოლო ხელახალი წაკითხვა ვერ მოხერხდა: {reason} + Monitor profile: {profile} მონიტორის პროფილი: {profile} + Display profiles: {reason} ეკრანის პროფილები: {reason} + Monitor profile in use on {display}: {profile} {display}-ზე გამოყენებული მონიტორის პროფილი: {profile} + Monitor profile in use: {profile} გამოყენებული მონიტორის პროფილი: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. ტილო ნაჩვენებია როგორც sRGB. აირჩიეთ მონიტორის პროფილი: რედაქტირება › ფერის პარამეტრები. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. ტილო {display}-ზე ნაჩვენებია როგორც sRGB. აირჩიეთ მონიტორის პროფილი: რედაქტირება › ფერის პარამეტრები. + Display profile not used ეკრანის პროფილი არ გამოიყენება + Monitor profile on {display} (main window): {profile} მონიტორის პროფილი {display}-ზე (მთავარი ფანჯარა): {profile} + Monitor profile on {display}: {profile} მონიტორის პროფილი {display}-ზე: {profile} + manual ხელით + fallback სარეზერვო + The document does not have an embedded colour profile. Working space: {working}. დოკუმენტს არ აქვს ჩაშენებული ფერის პროფილი. სამუშაო სივრცე: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} დოკუმენტს აქვს ჩაშენებული ფერის პროფილი, რომელიც სამუშაო სივრცეს არ შეესაბამება.\nჩაშენებული: {embedded}\nსამუშაო: {working} + Importing {what} from {file}… {file}-დან {what}-ის იმპორტი… + brushes ფუნჯები + gradients გრადიენტები +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {file}-დან იმპორტირებულია ფუნჯი: {n} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {file}-დან იმპორტირებულია გრადიენტი: {n} + Data Set: {name} ({index}/{total}) მონაცემთა ნაკრები: {name} ({index}/{total}) + (none) (არცერთი) +navigator Zoom მასშტაბი + Apply {option}: {state} {option}-ის გამოყენება: {state} + on ჩართული + off გამორთული + This adjustment ({key}) is kept as imported and can't be edited yet. ეს კორექცია ({key}) შენარჩუნებულია იმპორტირებული სახით და ჯერ ვერ რედაქტირდება. + Join the ArtCraft Discord ({url}) შემოუერთდით ArtCraft-ის Discord-ს ({url}) + {index} of {total} {index} / {total} + {shortcut} assigned. {shortcut} მინიჭებულია. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} უკვე გამოიყენება ბრძანებისთვის {commands} და OK-ზე დაწკაპუნებისას იქიდან მოიხსნება. + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) კერნინგი: მეტრიკა, ოპტიკური ან მნიშვნელობა em-ის 1/1000-ში (Alt+←/→ კურსორთან) + Basic Latin საბაზო ლათინური + Latin 1 ლათინური 1 + Latin Extended-A ლათინური გაფართოებული-A + Latin Extended-B ლათინური გაფართოებული-B + Greek ბერძნული + Cyrillic კირილიცა + Numbers რიცხვები + Currency ვალუტა + Math Symbols მათემატიკური სიმბოლოები + Arrows ისრები + Save Path ტრაექტორიის შენახვა +cameraRaw Highlights ღია ტონები +cameraRaw Shadows ჩრდილები + Temporary დროებითი + none არცერთი + red წითელი + orange ნარინჯისფერი + yellow ყვითელი + green მწვანე + blue ლურჯი + violet იისფერი + gray ნაცრისფერი + Working Rgb სამუშაო RGB + Working Cmyk სამუშაო CMYK + Working Gray სამუშაო ნაცრისფერი + Policy Rgb RGB-ის წესი + Policy Cmyk CMYK-ის წესი + Policy Gray ნაცრისფერის წესი + Convert გარდაქმნა + Ask On Mismatch შეუსაბამობისას შეკითხვა + Ask On Paste ჩასმისას შეკითხვა + Ask On Missing არარსებობისას შეკითხვა + Intent განზრახვა + Bpc შავი წერტილის კომპენსაცია + Blend Text Gamma ტექსტის ფერების შერევა გამით + Monitor Profile მონიტორის პროფილი + Display-p3 Display P3 + Adobe-rgb-compat Adobe RGB-თავსებადი + Prophoto-compat ProPhoto-თავსებადი + Linear-srgb წრფივი sRGB + Rec2020 Rec. 2020 + Coated-cmyk ცარცული ქაღალდის CMYK + Sgray sGray + Gray-gamma-2.2 Gray Gamma 2.2 +cameraRaw Effects ეფექტები +cameraRaw Exposure ექსპოზიცია +cameraRaw Contrast კონტრასტი +cameraRaw Texture ტექსტურა +cameraRaw Clarity სიცხადე +cameraRaw Dehaze ბურუსის მოცილება +cameraRaw Saturation გაჯერება +cameraRaw Curve მრუდი +cameraRaw Color Mixer ფერების მიქშერი +cameraRaw Midtones შუალედური ტონები +cameraRaw Balance ბალანსი +cameraRaw Detail დეტალი +cameraRaw Radius რადიუსი +cameraRaw Feather კიდეების შერბილება +cameraRaw Midpoint შუა წერტილი +cameraRaw Roundness სიმრგვალე +cameraRaw Size ზომა +document Orientation ორიენტაცია + Arc Lower ქვედა რკალი + Arc Upper ზედა რკალი + Forward Warp წინ დეფორმაცია + Twirl Clockwise საათის ისრის მიმართულებით დაგრეხა + Twirl Counterclockwise საათის ისრის საწინააღმდეგოდ დაგრეხა + Pucker შეჭმუხვნა + Bloat გაბერვა + Push Left მარცხნივ გაწევა + Freeze Mask ნიღბით გაყინვა + Thaw Mask ნიღბის გალღობა + Freeze Lasso ლასოთი გაყინვა + Reconstruct All ყველაფრის რეკონსტრუქცია + Mask None ნიღბის გარეშე +colorRange Invert ინვერსია +layers Fill შევსება +layers Fill: შევსება: +shape Appearance იერსახე + Export As: {n} layers ექსპორტი სხვა ფორმატით: შრე: {n} + Exported {n} files ექსპორტირებულია ფაილი: {n} +# Added upstream (sync 2 2026-10-13) + A brush preset with this name already exists ფუნჯის პრესეტი ამ სახელით უკვე არსებობს + Action playback stopped მოქმედების შესრულება შეწყდა + Add Mask to Selection ნიღბის მონიშნულში დამატება + Add Open Files გახსნილი ფაილების დამატება + Add to Dictionary ლექსიკონში დამატება + All shortcuts reset to Photoshop defaults. ყველა მალსახმობი დაბრუნდა Photoshop-ის ნაგულისხმევზე. + Allow Continue გაგრძელების დაშვება + Attempt to Automatically Align Source Images წყაროს გამოსახულებების ავტომატური სწორების მცდელობა + Auto Add/Delete ავტომატური დამატება/წაშლა + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. ბეჭდვის პარამეტრებში… დამატებით ნუ ჩართავთ „სარკისებურ ასახვას“: ორი ასახვა ერთმანეთს აბათილებს. + Print Settings… ბეჭდვის პარამეტრები… + Remember to turn off the printer's own colour management in Print Settings… არ დაგავიწყდეთ თავად პრინტერის ფერების მართვის გამორთვა ბეჭდვის პარამეტრებში… + Sent to {printer} გაიგზავნა პრინტერზე: {printer} + set in Print Settings… განისაზღვრება ბეჭდვის პარამეტრებში… + Based On საფუძველი + Bend მოხრა + Capture Brush Size in Preset ფუნჯის ზომის პრესეტში შენახვა + Channel Color არხის ფერი + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. აირჩიეთ ფორმატი, რომელიც მას შეინარჩუნებს, მაგალითად Photoshop (PSD), ან შეინახეთ ასლი. + Choose a gradient file (.grd). აირჩიეთ გრადიენტის ფაილი (.grd). + Clear Brush Controls ფუნჯის პარამეტრების გასუფთავება + Color Indicates: ფერი აღნიშნავს: + Copy Texture to Other Tools ტექსტურის სხვა ხელსაწყოებში კოპირება + Create Smart Object after Loading Layers შრეების ჩატვირთვის შემდეგ ჭკვიანი ობიექტის შექმნა + Default Photoshop Size Photoshop-ის ნაგულისხმევი ზომა + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels გადაათრიეთ ჩამოჭრის ჩარჩო · შიგნით გადათრევით გადაადგილება · კიდეებით ზომის შეცვლა ({ratio} თანაფარდობა, {centre} ცენტრიდან) · Space — გადაადგილება ხაზვისას · {commit} — დადასტურება · Esc — გაუქმება + Drag to pan · Scroll to zoom · Double-click to fit გადათრევა: გადაადგილება · გადახვევა: მასშტაბი · ორმაგი დაწკაპუნება: ჩატევა + Drag: draw freehand · Release on the start point: close გადათრევა: თავისუფალი ხატვა · გაშვება საწყის წერტილზე: დახურვა + Enter a name for the brush preset შეიყვანეთ ფუნჯის პრესეტის სახელი + Files ფაილები + Gradient Preset Options გრადიენტის პრესეტის პარამეტრები + Horizontal Distortion ჰორიზონტალური დეფორმაცია + ICO is limited to 256 × 256 pixels ICO შეზღუდულია 256 × 256 პიქსელით + Import .GRD .GRD-ის იმპორტი + Import Photoshop brushes (.abr) Photoshop-ის ფუნჯების იმპორტი (.abr) + Include Color ფერის ჩართვა + Include Tool Settings ხელსაწყოს პარამეტრების ჩართვა + Ink Characteristics მელნის მახასიათებლები + Insert Stop… შეჩერების ჩასმა… + Intersect Mask with Selection ნიღბის მონიშნულთან გადაკვეთა + Load Actions… მოქმედებების ჩატვირთვა… + Load Settings… პარამეტრების ჩატვირთვა… + Load settings from a Camera Raw .xmp file პარამეტრების ჩატვირთვა Camera Raw-ის .xmp ფაილიდან + Loaded Camera Raw settings from {file} Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან (მხარდაუჭერელი: {n}) + Mask Channel ნიღბის არხი + Message: შეტყობინება: + No Spelling Suggestions მართლწერის შეთავაზებები არ არის + No object found there — drag a rectangle around it იქ ობიექტი ვერ მოიძებნა — გადაათრიეთ მის გარშემო მართკუთხედი + Object Finder ობიექტების მაძიებელი + Paper Height ქაღალდის სიმაღლე + Paper Width ქაღალდის სიგანე + Pick Color From Image ფერის აღება გამოსახულებიდან + R g b RGB + Reset All Locked Settings ყველა ჩაკეტილი პარამეტრის ჩამოყრა + Save Actions… მოქმედებების შენახვა… + Save Settings… პარამეტრების შენახვა… + Save Set… ნაკრების შენახვა… + Save these settings as a Camera Raw .xmp file ამ პარამეტრების შენახვა Camera Raw-ის .xmp ფაილად + Select an action first ჯერ აირჩიეთ მოქმედება + Set horizontal skew ჰორიზონტალური დახრის დაყენება + Set vertical skew ვერტიკალური დახრის დაყენება + Show dialog during playback დიალოგის ჩვენება შესრულებისას + Soft Dark Theme რბილი ბნელი თემა + Soft Light Theme რბილი ღია თემა + Soft dark რბილი ბნელი + Soft light რბილი ღია + Solidity: სიმკვრივე: + Sort By Name სახელის მიხედვით დალაგება + Source Channel წყაროს არხი + Source Files წყაროს ფაილები + Source Invert წყაროს ინვერსია + Source1 Channel წყარო 1-ის არხი + Source1 Invert წყარო 1-ის ინვერსია + Source2 Channel წყარო 2-ის არხი + Source2 Invert წყარო 2-ის ინვერსია + Step {step} is waiting for its dialog ნაბიჯი {step} თავის დიალოგს ელოდება + Subtract Mask from Selection ნიღბის მონიშნულიდან გამოკლება + Use: გამოყენება: + Vertical Distortion ვერტიკალური დეფორმაცია + {message} ({note}) {message} ({note}) + {name} (Photoshop) {name} (Photoshop) + {name} can't keep everything in this document {name} ვერ შეინარჩუნებს ამ დოკუმენტის ყველაფერს +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {file}-დან იმპორტირებულია პატერნი: {n} +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file}-დან ჩაიტვირთა მოქმედება: {n} +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file}-ში შეინახა მოქმედება: {n} +@plural {n} action|{n} actions მოქმედება: {n} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet ნაბიჯი, რომელსაც PhotoCraft-ში ჯერ შესატყვისი არ აქვს: {n} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out გამოტოვებული ნაბიჯი, რომელსაც Photoshop-ში შესატყვისი არ აქვს: {n} +liquify Rate სიჩქარე + +# Sync 2026-10-14 (strings added upstream) + Choose Stack Mode: აირჩიეთ დასტის რეჟიმი: + No pin selected სამაგრი არ არის მონიშნული + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. სამაგრის დასამატებლად დაწკაპეთ გამოსახულებაზე; სამაგრის გადასაადგილებლად გადაათრიეთ ის, გაბუნდოვნების შესაცვლელად კი — მისი რგოლი. Delete წაშლის მონიშნულ სამაგრს. + The document “{name}” does not have an embedded {mode} profile. დოკუმენტს „{name}“ არ აქვს ჩაშენებული {mode} პროფილი. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. დოკუმენტს „{name}“ აქვს ჩაშენებული ფერის პროფილი, რომელიც მიმდინარე {mode} სამუშაო სივრცეს არ შეესაბამება. + Working: {working} სამუშაო: {working} + Custom background color ფონის მორგებული ფერი + Redrawing გადახატვა + Starting serial საწყისი რიგითი ნომერი + Presentation ჩვენება +@id file.close დახურვა +@id layer.layerMask.apply გამოყენება +@id edit.clear გასუფთავება +layerLabel Red წითელი +layerLabel Magenta მეწამული + Export As: {name} ექსპორტი სხვა ფორმატით: {name} + Color Sampler Tool ფერის სინჯის აღების ხელსაწყო + Proof Setup… საცდელი ჩვენების პარამეტრები… + Toggle Layer Mask Overlay შრის ნიღბის გადაფარვის გადართვა + Increase Type Size ტექსტის ზომის გაზრდა + Decrease Type Size ტექსტის ზომის შემცირება + Increase Type Size by 10 ტექსტის ზომის 10-ით გაზრდა + Decrease Type Size by 10 ტექსტის ზომის 10-ით შემცირება + Make Selection from Path ტრაექტორიიდან მონიშნულის შექმნა + Target Composite Channel კომპოზიტური არხის არჩევა + Target Channel 3 მე-3 არხის არჩევა + Target Channel 4 მე-4 არხის არჩევა + Target Channel 5 მე-5 არხის არჩევა + Target Channel 6 მე-6 არხის არჩევა + Target Channel 7 მე-7 არხის არჩევა + Target Channel 8 მე-8 არხის არჩევა + Target Channel 9 მე-9 არხის არჩევა + Fill with Foreground Color წინა პლანის ფერით შევსება + Fill with Background Color ფონის ფერით შევსება + Fill with Foreground Color, Preserve Transparency წინა პლანის ფერით შევსება, გამჭვირვალობის შენარჩუნებით + Fill with Background Color, Preserve Transparency ფონის ფერით შევსება, გამჭვირვალობის შენარჩუნებით + Decrease Brush Size ფუნჯის ზომის შემცირება + Increase Brush Size ფუნჯის ზომის გაზრდა + Decrease Brush Hardness ფუნჯის სიხისტის შემცირება + Increase Brush Hardness ფუნჯის სიხისტის გაზრდა + Hand Tool (hold) ხელის ხელსაწყო (დაჭერით) + Zoom In (hold, drag to scrub) გადიდება (დაჭერით; გადათრევა — მასშტაბის მდორე ცვლა) + Zoom Out (hold) დაპატარავება (დაჭერით) + Erase with Brush or Pencil (hold) ფუნჯით ან ფანქრით წაშლა (დაჭერით) + Author Title ავტორის თანამდებობა + Copyright Url საავტორო უფლებების URL + Title სათაური + Working-cmyk სამუშაო CMYK + Simulate Paper ქაღალდის იმიტაცია + Warming Filter (85) გამათბობელი ფილტრი (85) + Warming Filter (LBA) გამათბობელი ფილტრი (LBA) + Warming Filter (81) გამათბობელი ფილტრი (81) + Cooling Filter (80) გამაციებელი ფილტრი (80) + Cooling Filter (LBB) გამაციებელი ფილტრი (LBB) + Cooling Filter (82) გამაციებელი ფილტრი (82) + Cw საათის ისრის მიმართულებით + Ccw საათის ისრის საწინააღმდეგოდ + Nearest უახლოესი მეზობელი + R G B RGB + Pattern Fill… პატერნით შევსება… + Any ნებისმიერი + Cmyk CMYK + Dont Enlarge გადიდების გარეშე + Change Find ჩანაცვლება და ძებნა + Case Sensitive რეგისტრის გათვალისწინებით + Whole Word მთლიანი სიტყვა + Forward წინ + Checkerboard ჭადრაკის დაფა + browser preset storage timed out ბრაუზერის პრესეტების საცავმა დროულად არ უპასუხა + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად, კვადრატი — კუთხეების მოსამრგვალებლად, შიგნით მდებარე წერტილები კი — გაბუნდოვნების დაწყების ადგილის დასაყენებლად. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. გადაათრიეთ სამაგრი ზოლის გადასაადგილებლად, ან მისი რგოლი — გაბუნდოვნების შესაცვლელად. გადაათრიეთ უწყვეტი ხაზები მკვეთრი ზოლის გასაფართოებლად, წყვეტილი ხაზები — გარდამავალი არის დასაყენებლად, წერტილები კი — შესაბრუნებლად. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად; რგოლი გაბუნდოვნების კუთხეს აყენებს. + Add Shortcut მალსახმობის დამატება + Clip thumbnails to layer bounds ესკიზების შრის საზღვრებით მოჭრა + Clip Thumbnails to Layer Bounds ესკიზების შრის საზღვრებით მოჭრა + Import Brushes… (.abr or image) ფუნჯების იმპორტი… (.abr ან გამოსახულება) + Import Brush from Image… ფუნჯის იმპორტი გამოსახულებიდან… + Bundle Arrow-Key Nudges ისრებით წანაცვლებების გაერთიანება + Nudge Bundle Pause (ms) გაერთიანებული წანაცვლების პაუზა (მწმ) + Response curve რეაქციის მრუდი + Space ჰარი + W: სიგ.: + A: კუთხე: + Show home recent files ბოლო ფაილების ჩვენება საწყის ეკრანზე + Background (Default) ფონი (ნაგულისხმევი) + Include All Subfolders ყველა ქვესაქაღალდის ჩართვა + Use the embedded profile ({profile}) ჩაშენებული პროფილის გამოყენება ({profile}) + Convert to the working space ({profile}) სამუშაო სივრცეში გარდაქმნა ({profile}) + Remember this choice for future images ამ არჩევანის დამახსოვრება მომავალი გამოსახულებებისთვის + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. შეკითხვის ხელახლა ჩასართავად გამოიყენეთ „შეუსაბამობისას შეკითხვა“: რედაქტირება › ფერის პარამეტრები. + Button Mode ღილაკების რეჟიმი + Button Mode On ღილაკების რეჟიმი ჩართულია + Darken Interface ინტერფეისის გამუქება + Lighten Interface ინტერფეისის გაღიავება + Technique ტექნიკა + Chisel Hard მკვეთრი კვეთა + Chisel Soft რბილი კვეთა + Open color-less EXR as ACEScg ფერის სივრცის გარეშე EXR-ის გახსნა ACEScg-ად + Edit Selected Guides მონიშნული დამხმარე ხაზების რედაქტირება + Clear Selected Guides მონიშნული დამხმარე ხაზების გასუფთავება + Delete this set ამ ნაკრების წაშლა + New Set… ახალი ნაკრები… + PhotoCraft Defaults PhotoCraft-ის ნაგულისხმევი + Save a list of these shortcuts as a web page ამ მალსახმობების სიის ვებგვერდად შენახვა + Save the changes to this set ამ ნაკრებში ცვლილებების შენახვა + Save these shortcuts as a .kys file ამ მალსახმობების .kys ფაილად შენახვა + Save these shortcuts as a new set ამ მალსახმობების ახალ ნაკრებად შენახვა + Set deleted. ნაკრები წაიშალა. + Set saved. ნაკრები შენახულია. + Set: ნაკრები: + Summarize… შეჯამება… + There are too many sets: delete one first. ნაკრები ძალიან ბევრია: ჯერ ერთი წაშალეთ. + set name ნაკრების სახელი + {set} (modified) {set} (შეცვლილი) + Accept and Go to Conflict მიღება და კონფლიქტზე გადასვლა + Changes undone. ცვლილებები დაბრუნდა. + Choose a new shortcut for this command, or leave it without one. აირჩიეთ ამ ბრძანების ახალი მალსახმობი ან დატოვეთ ის მალსახმობის გარეშე. + Put this command's shortcuts back as they were ამ ბრძანების მალსახმობების წინანდელ მდგომარეობაში დაბრუნება + Take the shortcut from the other command and select it მალსახმობის მეორე ბრძანებისთვის წართმევა და იმ ბრძანების მონიშვნა + Undo Changes ცვლილებების დაბრუნება + {shortcut} is reserved by the system and can't be assigned. {shortcut} სისტემისთვისაა დაჯავშნული და მისი მინიჭება შეუძლებელია. + Low-Latency Drawing (vsync off, applies at next launch) დაბალი დაყოვნებით ხატვა (vsync გამორთულია, ძალაში შედის შემდეგი გაშვებისას) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. თავისუფალი გარდაქმნის გამოყენება ჯერ არ დასრულებულა; სცადეთ ხელახლა, როცა დასრულდება. + The transform couldn't be applied. გარდაქმნის გამოყენება ვერ მოხერხდა. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. მის შრეებს GPU-ის მეხსიერებიდან სჭირდება {needed}; ლიმიტი მათ უტოვებს {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. პარამეტრები › წარმადობა › GPU-ის მეხსიერება დააყენეთ იმ მოცულობაზე, რასაც თქვენი ვიდეობარათი იტევს, მაქსიმუმ მისი მეხსიერების 3/8-ზე. ავტომატური მნიშვნელობა მიჰყვება მეხსიერების გამოყენებას, ამ კომპიუტერის მეხსიერების მეოთხედისა და ბარათის მეხსიერების 3/8-ის ფარგლებში, ხოლო თუ ბარათის მეხსიერება ცნობილია, დოკუმენტს მის ხარჯზე არ ითვლის. სანამ შრეები არ ეტევა, სტატუსის ზოლში ჩანს „GPU-ის მეხსიერება სავსეა“. + GPU Memory Full GPU-ის მეხსიერება სავსეა + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. შრეებს GPU-ის მეხსიერებიდან სჭირდება {needed}, დასაშვებია კი {budget}, ამიტომ ცვლილებები, რომლებიც მთელ დოკუმენტს ხელახლა ხატავს, ამას CPU-ზე აკეთებს. დაწკაპეთ GPU-ის მეხსიერების დასაყენებლად. + GPU memory budget: {budget} GPU-ის მეხსიერების ლიმიტი: {budget} + This document's layers: {needed} of {budget} for layers ამ დოკუმენტის შრეები: {needed} / {budget} (შრეებისთვის) + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU ამ დოკუმენტის შრეებს სჭირდება {needed}, დასაშვებია კი {budget}: მთელი დოკუმენტის გადახატვა CPU-ს იყენებს + GPU Memory (MB, 0 = Automatic) GPU-ის მეხსიერება (მბ, 0 = ავტომატური) + {name} was changed by another app after it was opened or saved here. {name} სხვა აპლიკაციამ შეცვალა მას შემდეგ, რაც აქ გაიხსნა ან შეინახა. + Replace it with this document, or cancel and use File › Save As to keep both. ჩაანაცვლეთ ის ამ დოკუმენტით, ან გააუქმეთ და ორივეს შესანარჩუნებლად გამოიყენეთ ფაილი › შენახვა როგორც. + Pasting from other apps is unavailable სხვა აპლიკაციებიდან ჩასმა მიუწვდომელია + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft ამ სესიაში სისტემის გაცვლის ბუფერთან წვდომას ვერ ახერხებს. Wayland-ზე Flatpak-ის იზოლირებული გარემო მალავს პროტოკოლს, რომლითაც სხვა აპლიკაციების გამოსახულებები იკითხება. + To paste from other apps, start PhotoCraft under XWayland: `{command}` სხვა აპლიკაციებიდან ჩასასმელად გაუშვით PhotoCraft XWayland-ში: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here ჩასასმელი არაფერია: PhotoCraft აქ სისტემის გაცვლის ბუფერს ვერ კითხულობს + Show file name in title ფაილის სახელის ჩვენება სათაურში + Memory Usage მეხსიერების გამოყენება + Per-document RAM limit (MB) RAM-ის ლიმიტი თითო დოკუმენტზე (მბ) + Installed RAM: {ram} MB; this limit: {percent}% კომპიუტერის RAM: {ram} მბ; ეს ლიმიტი: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. ზღუდავს თითოეული ღია დოკუმენტის პიქსელებსა და ისტორიას ერთად. ლიმიტის გადაჭარბებისას დაბრუნების უძველესი მდგომარეობები იშლება. + Applies immediately when saved. Memory is not reserved in advance. შენახვისთანავე მოქმედებს. მეხსიერება წინასწარ არ რეზერვდება. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. როცა GPU-ის მეხსიერება ავტომატურზეა დაყენებული, ეს GPU-ზე შრეების ლიმიტსაც ზღუდავს. ის არ აღემატება კომპიუტერის RAM-ის მეოთხედს და, თუ ცნობილია, ვიდეობარათის მეხსიერების სამ მერვედს; მინიმუმი 512 მბ-ია. ამ ზღვრებს ზემოთ ამ ლიმიტის გაზრდა GPU-ის ლიმიტს არ ზრდის. + Use გამოყენება + In Front წინ + Behind უკან + Overprint ზედდაბეჭდვა + Current Brush Load ფუნჯზე აღებული საღებავი + Load Brush ფუნჯზე საღებავის აღება + Clean Brush ფუნჯის გაწმენდა + Load the brush after each stroke ყოველი შტრიხის შემდეგ ფუნჯზე საღებავის აღება + Clean the brush after each stroke ყოველი შტრიხის შემდეგ ფუნჯის გაწმენდა + Animation Options ანიმაციის პარამეტრები + Entire Document მთელი დოკუმენტი + Filter Options ფილტრის პარამეტრები + Layer Bounds შრის საზღვრები + Layers Panel Options შრეების პანელის პარამეტრები + New Artboard… ახალი სამუშაო დაფა… + New Group… ახალი ჯგუფი… + Prevent Auto-Nesting ავტომატური ჩადგმის აკრძალვა + Show Layers შრეების ჩვენება + Thumbnail Contents ესკიზის შიგთავსი + Thumbnail Size ესკიზის ზომა + Use Default Masks on Fill Layers შევსების შრეებზე ნაგულისხმევი ნიღბების გამოყენება + Expand New Effects ახალი ეფექტების გაშლა + Add “copy” to Copied Layers and Groups დაკოპირებულ შრეებსა და ჯგუფებზე „copy“-ის დამატება + Preview on canvas when reordering layers შრეების გადალაგებისას ტილოზე წინასწარი ნახვა + Show layer mask badges შრის ნიღბის ნიშნების ჩვენება + Blending Options ({filter}) შერევის პარამეტრები ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) შრის ეფექტებისა და ჭკვიანი ფილტრების დამალვა ({key}+დაწკაპუნება: ყველა შრე) + Show the layer's effects and Smart Filters ({key}-click: all layers) შრის ეფექტებისა და ჭკვიანი ფილტრების ჩვენება ({key}+დაწკაპუნება: ყველა შრე) + Wait for the current job to finish. დაელოდეთ მიმდინარე დავალების დასრულებას. + The document or Smart Filter changed. Reopen its dialog. დოკუმენტი ან ჭკვიანი ფილტრი შეიცვალა. ხელახლა გახსენით მისი დიალოგი. + Opacity must be between 0% and 100%. გაუმჭვირვალობა უნდა იყოს 0%-სა და 100%-ს შორის. + Dissolve is approximated: it blends without Photoshop's dither. „გაფანტვა“ მიახლოებითია: შერევა Photoshop-ის დიზერინგის გარეშე ხდება. + New Snapshot… ახალი სნეპშოტი… + History Options… ისტორიის პარამეტრები… +# History panel › History Options… (#2966) + Allow Non-Linear History არაწრფივი ისტორიის დაშვება + Make Layer Visibility Changes Undoable შრის ხილვადობის ცვლილებებისთვის დაბრუნების ჩართვა + Paints from the History panel's source state ხატავს ისტორიის პანელის წყარო მდგომარეობიდან + Sets the source for the history brush ადგენს ისტორიის ფუნჯის წყაროს + Bicubic Automatic ბიკუბური, ავტომატური + Bicubic Smoother ბიკუბური, უფრო გლუვი + Bicubic Sharper ბიკუბური, უფრო მკვეთრი + 50% Threshold 50%-იანი ზღვარი + Pattern Dither პატერნული დიზერინგი + Diffusion Dither დიფუზიური დიზერინგი + Halftone Screen ნახევარტონური რასტრი + Custom Pattern მორგებული პატერნი + Frequency: სიხშირე: + Lines/Inch ხაზი/დუიმი + Lines/Centimeter ხაზი/სანტიმეტრი + Ellipse ელიფსი + DDS Options DDS-ის პარამეტრები + Generate Mipmaps მიპმაპების გენერაცია + There is no color table to save. შესანახი ფერთა ცხრილი არ არის. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file}-ში შეინახა ფერი: {n} + GPU adapter GPU ადაპტერი + +# Strings added upstream (sync 2026-10-22) + Mask Options… ნიღბის პარამეტრები… + Layer Mask Display Options… შრის ნიღბის ჩვენების პარამეტრები… + Create warped text დეფორმირებული ტექსტის შექმნა + Override Tilt X X-დახრის უგულებელყოფა + Override Tilt Y Y-დახრის უგულებელყოფა + Rename Path… ტრაექტორიის სახელის შეცვლა… + LUT folder LUT-ების საქაღალდე + Retry GPU tries the graphics processor again now. „GPU-ის ხელახლა ცდა“ გრაფიკული პროცესორის გამოყენებას ახლავე ხელახლა სცდის. + GPU acceleration resumed. GPU-აჩქარება აღდგა. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. შეზღუდვის დასამატებლად გამოსახულებაზე გაავლეთ; Shift — ჰორიზონტალური/ვერტიკალური; შეზღუდვის შესაცვლელად ან წასაშლელად — მასზე მარჯვენა დაწკაპუნება. + JPEG Options JPEG-ის პარამეტრები + Image Options გამოსახულების პარამეტრები + Format Options ფორმატის პარამეტრები + Baseline ("Standard") საბაზისო („სტანდარტული“) + Baseline Optimized საბაზისო ოპტიმიზებული + Copyright Notice საავტორო უფლების შენიშვნა + Document Title დოკუმენტის სათაური + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels აქტიურ შრეს აქ პიქსელები არ აქვს — ჩართეთ „ყველა შრიდან ნიმუშის აღება“ ან აირჩიეთ შრე, რომელსაც პიქსელები აქვს + Load Contours… კონტურების ჩატვირთვა… + Replace Contours… კონტურების ჩანაცვლება… + Save Contours… კონტურების შენახვა… + Delete Contour კონტურის წაშლა + Loaded contours from {file} {file}-დან ჩაიტვირთა კონტურები + Saved contours to {file} {file}-ში შეინახა კონტურები + New Action… ახალი მოქმედება… + Start Recording ჩაწერის დაწყება + Stop Recording ჩაწერის შეჩერება + Highlight Mode ღია ტონების რეჟიმი + Highlight Color ღია ტონების ფერი + Highlight Opacity ღია ტონების გაუმჭვირვალობა + Shadow Mode ჩრდილის რეჟიმი + Shadow Color ჩრდილის ფერი + Shadow Opacity ჩრდილის გაუმჭვირვალობა + Do not append "copy" to filename when saving a copy ასლის შენახვისას ფაილის სახელს „copy“ არ დაემატოს + Graphics memory: {size} გრაფიკული მეხსიერება: {size} + {path}: the path is not valid Unicode; save under another name. {path}: გზა არ არის სწორი Unicode; შეინახეთ სხვა სახელით. + Demosaic დემოზაიკა + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. როგორ ივსება კამერის ფერადი ფილტრის მოზაიკა. გამოიყენება ფაილის გახსნისას; წინასწარი ნახვა აჩვენებს მეთოდს, რომლითაც ფაილი გაიხსნა. + AHD (adaptive homogeneity) AHD (ადაპტური ერთგვაროვნება) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: ყველაზე სუფთა კიდეები და ყველაზე ნაკლები ფერადი შარავანდი, ყველაზე ნელი. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. გრადიენტით კორექტირებული ინტერპოლაცია: მკვეთრი და სწრაფი, წვრილ დეტალებზე მცირე ფერადი შარავანდით. + The fastest and the softest, with colour fringing on fine detail. ყველაზე სწრაფი და ყველაზე რბილი, წვრილ დეტალებზე ფერადი შარავანდით. + “{name}” already exists. Replace it? „{name}“ უკვე არსებობს. ჩავანაცვლოთ? + The name in the save dialog was “{name}”. შენახვის დიალოგში სახელი იყო „{name}“. + Move Up აწევა + Move Down ჩამოწევა + Previous Brush წინა ფუნჯი + Next Brush შემდეგი ფუნჯი + First Brush პირველი ფუნჯი + Last Brush ბოლო ფუნჯი + Preview size გადახედვის ზომა + Confirm or cancel the open action step dialog first ჯერ დაადასტურეთ ან გააუქმეთ მოქმედების ნაბიჯის გახსნილი დიალოგი + Pro Medium Gray Pro საშუალო ნაცრისფერი + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro ღია ნაცრისფერი + Pro Light Pro ღია + Resize Windows to Fit ფანჯრების ზომის მორგება + EXR EXR + Automatic paragraph direction აბზაცის ავტომატური მიმართულება + Left-to-right paragraph direction აბზაცის მიმართულება მარცხნიდან მარჯვნივ + Right-to-left paragraph direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ + Not rendered: this adjustment ({key}) is kept as imported. არ არის დარენდერებული: ეს კორექცია ({key}) შენარჩუნებულია იმპორტირებული სახით. + Print preview image ამობეჭდვამდე ნახვის გამოსახულება + +# Strings added upstream (sync 2026-10-27) + 105% 105% + 110% 110% + 115% 115% + 120% 120% + From: საიდან: + Full Document მთელი დოკუმენტი + Merged Layers შერწყმული შრეები + Damaged Photoshop document დაზიანებული Photoshop-ის დოკუმენტი + The document “{name}” may be damaged. დოკუმენტი „{name}“ შეიძლება დაზიანებული იყოს. + Some layers, pixels or masks could not be read. Do you want to continue opening it? ზოგიერთი შრის, პიქსელის ან ნიღბის წაკითხვა ვერ მოხერხდა. გსურთ გახსნის გაგრძელება? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. თუ გააგრძელებთ და ორიგინალს გადააწერთ, წაუკითხავი მონაცემები შეიძლება სამუდამოდ დაიკარგოს. ორიგინალის შესანარჩუნებლად შეინახეთ ასლი. + Alt-click a plane to set the clone source სიბრტყეზე Alt-დაწკაპუნებით მიუთითეთ კლონირების წყარო + Too many clone strokes: apply Vanishing Point first კლონირების შტრიხები ზედმეტად ბევრია: ჯერ გამოიყენეთ გაქრობის წერტილი + Calibration კალიბრაცია + Red Primary წითელი ძირითადი + Green Primary მწვანე ძირითადი + Blue Primary ლურჯი ძირითადი + Write xmp sidecars XMP თანმხლები ფაილების ჩაწერა + Set the tone sliders from the image ტონის სლაიდერების დაყენება გამოსახულების მიხედვით + Settings from {name} პარამეტრები {name}-დან + Settings from {name}; {count} not supported: {names} პარამეტრები {name}-დან; მხარდაუჭერელი ({count}): {names} + Could not write {name}: {error} {name}-ის ჩაწერა ვერ მოხერხდა: {error} + Kept {name}: written by another application {name} შენარჩუნებულია: ჩაწერილია სხვა აპლიკაციის მიერ + Shadow Tint ჩრდილების ტონირება + Check for Updates… განახლებების შემოწმება… + Open the release page to choose an installer for your system. გახსენით გამოშვების გვერდი და აირჩიეთ თქვენი სისტემის ინსტალატორი. + Automatically Check for Updates განახლებების ავტომატური შემოწმება + Import Styles… სტილების იმპორტი… + Export Selected Styles… მონიშნული სტილების ექსპორტი… + Export Styles… სტილების ექსპორტი… + {file} is not a style library (.asl) {file} სტილების ბიბლიოთეკა არ არის (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file}-დან იმპორტირებულია სტილი: {n} +@plural Exported {n} style to {file}|Exported {n} styles to {file} სტილი: {n} ექსპორტირებულია {file}-ში + Hue Cube ფერის ტონის კუბი + Brightness Cube სიკაშკაშის კუბი + Color Wheel ფერთა წრე + RGB Sliders RGB სლაიდერები + HSB Sliders HSB სლაიდერები + CMYK Sliders CMYK სლაიდერები + Lab Sliders Lab სლაიდერები + Web Color Sliders ვებფერის სლაიდერები + Copy Color as HTML ფერის კოპირება HTML კოდად + Copy Color's Hex Code ფერის Hex კოდის კოპირება + RGB Spectrum RGB სპექტრი + CMYK Spectrum CMYK სპექტრი + Grayscale Ramp ნაცრისფერი ტონების შკალა + Current Colors მიმდინარე ფერები + Make Ramp Web Safe ვებისთვის უსაფრთხო შკალა + Art History Brush Tool მხატვრული ისტორიის ფუნჯის ხელსაწყო + Tight Short მჭიდრო მოკლე + Tight Medium მჭიდრო საშუალო + Tight Long მჭიდრო გრძელი + Loose Medium თავისუფალი საშუალო + Loose Long თავისუფალი გრძელი + Dab ლაქა + Tight Curl მჭიდრო ხვია + Loose Curl თავისუფალი ხვია + Stylized dabs from a history state სტილიზებული ლაქები ისტორიის მდგომარეობიდან + New Group from Layers… ახალი ჯგუფი შრეებიდან… + Show Favorite Fonts რჩეული შრიფტების ჩვენება + No favorite fonts yet: click the star beside a font to add it. რჩეული შრიფტები ჯერ არ არის: დასამატებლად დაწკაპეთ ვარსკვლავზე შრიფტის გვერდით. + Favorite fonts რჩეული შრიფტები + Add to Favorites რჩეულებში დამატება + Remove from Favorites რჩეულებიდან ამოშლა + Show All Fonts ყველა შრიფტის ჩვენება + All Classes ყველა კლასი + Blackletter გოთიკური + Decorative დეკორატიული + Font preview: შრიფტის ნიმუში: + Handwritten ხელნაწერი + Hide Font Classes შრიფტის კლასების დამალვა + Hide Styles სტილების დამალვა + Monospace მონოსიგანიანი + No fonts match these filters. ამ ფილტრებს არცერთი შრიფტი არ შეესაბამება. + No similar fonts: the current font doesn't say what it looks like. მსგავსი შრიფტები არ არის: მიმდინარე შრიფტი არ აღწერს, როგორ გამოიყურება. + OpenType font OpenType შრიფტი + Sans Serif უსერიფო + Script კალიგრაფიული + Serif სერიფიანი + Show Font Classes შრიფტის კლასების ჩვენება + Show Similar Fonts მსგავსი შრიფტების ჩვენება + Show Styles სტილების ჩვენება + Show Variable Fonts ცვლადი შრიფტების ჩვენება + Slab Serif სლაბ-სერიფი + TrueType font TrueType შრიფტი + Variable font ცვლადი შრიფტი + Font size presets შრიფტის ზომის პრესეტები + Leading presets სტრიქონთა დაშორების პრესეტები + Tracking presets ტრეკინგის პრესეტები +# Select and Mask workspace + Color Aware ფერის გათვალისწინებით + Decontaminate Colors ფერების გაწმენდა + Edge Detection კიდის აღმოჩენა + Global Refinements გლობალური დაზუსტებები + High Quality Preview მაღალი ხარისხის წინასწარი ნახვა + Object Aware ობიექტის გათვალისწინებით + Real-Time Refinement დაზუსტება რეალურ დროში + Refine Edge Brush Tool კიდის დაზუსტების ფუნჯის ხელსაწყო + Refine Hair თმის დაზუსტება + Refine Mode დაზუსტების რეჟიმი + Remember Settings პარამეტრების დამახსოვრება + View Mode ხედის რეჟიმი + Object Aware needs a segmentation model, which isn't available yet „ობიექტის გათვალისწინებით“ რეჟიმს სჭირდება სეგმენტაციის მოდელი, რომელიც ჯერ მიუწვდომელია + 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 „მონიშვნა და ნიღაბი“ დაიხურა: მონიშნულია სხვა შრე + Path operations ტრაექტორიის ოპერაციები + Drag to move the selected path გადაათრიეთ მონიშნული ტრაექტორიის გადასაადგილებლად + Move effect up ეფექტის აწევა + Move effect down ეფექტის ჩამოწევა + Delete effect ეფექტის წაშლა + Current Layer: მიმდინარე შრე: + The path was kept as the Work Path ტრაექტორია შენარჩუნდა სამუშაო ტრაექტორიად + The path was discarded ტრაექტორია გაუქმდა + Select Layer Under Cursor კურსორის ქვეშ არსებული შრის მონიშვნა + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. გადაათრიეთ გამოსახულებაზე შეზღუდვის დასამატებლად; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად დაწკაპეთ მარჯვენა ღილაკით. + CPU Rendering CPU-რენდერინგი + Calculating… გამოითვლება… + Click to refresh histogram with uncached data დაწკაპეთ ჰისტოგრამის ქეშის გარეშე მონაცემებით განსაახლებლად + Count: რაოდენობა: + Drawn on the CPU: {reason}. Click for System Info. დახატულია CPU-ზე: {reason}. დაწკაპეთ სისტემის ინფორმაციის სანახავად. + Level: დონე: + Percentile: პროცენტილი: + Show Channels in Color არხების ფერად ჩვენება + Show Statistics სტატისტიკის ჩვენება + Source: წყარო: + Uncached Refresh განახლება ქეშის გარეშე + Convert to Shape… ფიგურად გარდაქმნა… + Delete Hidden Layers დამალული შრეების წაშლა + Fit Layer on Screen შრის ეკრანში ჩატევა + Flow: ნაკადი: + Refine Edge… კიდის დაზუსტება… + Reset Essentials ძირითადის საწყისზე დაბრუნება + Rounded Rectangle Tool მომრგვალებული მართკუთხედის ხელსაწყო + Status Bar სტატუსის ზოლი + Tile Horizontally ჰორიზონტალურად განლაგება + Tile Vertically ვერტიკალურად განლაგება + Edit Brush… ფუნჯის რედაქტირება… +@plural {n} document|{n} documents {n} დოკუმენტი +@plural {n} pixel|{n} pixels {n} პიქსელი +cameraRaw Before (Y) მანამდე (Y) +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw ფილტრი ({layer}) +cameraRaw Camera Raw isn't open Camera Raw არ არის გახსნილი +cameraRaw Cancel გაუქმება +cameraRaw Cancelled opening {name} {name}-ის გახსნა გაუქმდა +cameraRaw Cancelled {label} გაუქმდა: {label} +cameraRaw Color Priority ფერის პრიორიტეტი +cameraRaw Developing {name} მუშავდება {name} +cameraRaw Full-resolution preview unavailable სრული გარჩევადობის გადახედვა მიუწვდომელია +cameraRaw Grain მარცვლოვნება +cameraRaw Highlight Clipping Warning (O) ღია ტონების მოკვეთის გაფრთხილება (O) +cameraRaw Highlight Priority ღია ტონების პრიორიტეტი +cameraRaw Load Settings… პარამეტრების ჩატვირთვა… +cameraRaw Load settings from a Camera Raw .xmp file პარამეტრების ჩატვირთვა Camera Raw-ის .xmp ფაილიდან +cameraRaw Loaded Camera Raw settings from {file} Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან +cameraRaw Noise Reduction ხმაურის შემცირება +cameraRaw OK OK +cameraRaw Open გახსნა +cameraRaw Opened {name} გაიხსნა {name} +cameraRaw Paint Overlay საღებავით გადაფარვა +cameraRaw Save Settings… პარამეტრების შენახვა… +cameraRaw Save these settings as a Camera Raw .xmp file ამ პარამეტრების შენახვა Camera Raw-ის .xmp ფაილად +cameraRaw Shadow Clipping Warning (U) ჩრდილების მოკვეთის გაფრთხილება (U) +cameraRaw Sharpening გამკვეთრება +cameraRaw Show Lab Color Readouts Lab ფერის მნიშვნელობების ჩვენება +cameraRaw Show Red at 3 o'clock წითლის ჩვენება 3 საათის მიმართულებით +cameraRaw Show Selected Region მონიშნული რეგიონის ჩვენება +cameraRaw Show Skin Tone Indicator კანის ტონის მაჩვენებლის ჩვენება +cameraRaw Show Vectorscope ვექტორსკოპის ჩვენება +cameraRaw Tone Histogram ტონების ჰისტოგრამა +cameraRaw Vignetting ვინიეტირება +cameraRaw White Balance: As Shot თეთრის ბალანსი: როგორც გადაღებულია + No matching fonts შესაბამისი შრიფტები არ არის + +# Sync 2026-11-03: Load Actions summary, SVG Smart Objects, zoom field, Shape Fill/Stroke popover +@plural {n} action file loaded|{n} action files loaded ჩატვირთულია მოქმედებების ფაილი: {n} + {set} from {file} replaced the one from {earlier} {file}-დან {set}-მა ჩაანაცვლა {earlier}-დან ჩატვირთული +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} ვერ ჩაიტვირთა ფაილი: {n} — {errors} +smartSvg Choose executable… შესრულებადი ფაილის არჩევა… +smartSvg Choose the application used to edit this SVG: აირჩიეთ აპლიკაცია, რომლითაც ეს SVG დარედაქტირდება: +smartSvg Edit SVG Smart Object SVG ჭკვიანი ობიექტის რედაქტირება +smartSvg Other application სხვა აპლიკაცია +smartSvg PhotoCraft (internal editor) PhotoCraft (ჩაშენებული რედაქტორი) +smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG-რედაქტორი გაიხსნა. ამ ჭკვიანი ობიექტის განსაახლებლად შეინახეთ SVG. +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. შეინახეთ გახსნილ SVG ფაილში. ყოველი შენახული ცვლილება PhotoCraft-ში ერთ დაბრუნების ნაბიჯად იქცევა. +smartSvg Stop syncing this Smart Object ამ ჭკვიანი ობიექტის სინქრონიზაციის შეწყვეტა +smartSvg Updates wait while another document or an editing dialog is active. განახლება დაელოდება, სანამ სხვა დოკუმენტი ან რედაქტირების დიალოგი აქტიურია. +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS შიგთავსი SVG-ის სახით დარედაქტირდება. შენახვა ჩაანაცვლებს ჩაშენებულ წყაროს; ფორმატისთვის დამახასიათებელი შესაძლებლობები შეიძლება შეიცვალოს. + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. არასწორი რიცხვითი მნიშვნელობა. საჭიროა მთელი რიცხვი {min}%-დან {max}%-მდე. ჩასმულია წინა მნიშვნელობა. + Recently Used Colors ბოლოს გამოყენებული ფერები + Stop Color გადასვლის წერტილის ფერი + Shapes can't use patterns yet ფიგურებს პატერნების გამოყენება ჯერ არ შეუძლიათ + Memory usage percent მეხსიერების გამოყენება (%) + Save Full-Size Composite in .pcraft Files სრული ზომის გაერთიანებული გამოსახულების შენახვა .pcraft ფაილებში + Scratch Size Limit (MB, 0 = No Limit) დროებითი დისკის ზომის ლიმიტი (მბ, 0 = ლიმიტის გარეშე) +# Sync 2026-11-07: strings added upstream + Clear Recent File List ბოლოს გახსნილი ფაილების სიის გასუფთავება + New Document… ახალი დოკუმენტი… + Open Document… დოკუმენტის გახსნა… + Consolidate All to Here ყველას აქ გაერთიანება + Move to New Window ახალ ფანჯარაში გადატანა + Reveal in Explorer Explorer-ში ჩვენება + Plugins… პლაგინები… + Embed Linked დაკავშირებულის ჩაშენება + Embed All Linked ყველა დაკავშირებულის ჩაშენება + Plugins პლაგინები + Working CMY Plates სამუშაო CMY ფირფიტები + Legacy Macintosh RGB (Gamma 1.8) მოძველებული Macintosh RGB (გამა 1.8) + Internet Standard RGB (sRGB) ინტერნეტის სტანდარტული RGB (sRGB) + Contextual Task Bar კონტექსტური ამოცანების ზოლი + Hide bar ზოლის დამალვა + Reset bar position ზოლის პოზიციის ჩამოყრა + Pin bar position ზოლის პოზიციის ჩამაგრება + Move bar ზოლის გადაადგილება + More options მეტი პარამეტრი + Source: {name} წყარო: {name} + No Thumbnails ესკიზების გარეშე + Small Thumbnails პატარა ესკიზები + Medium Thumbnails საშუალო ესკიზები + Large Thumbnails დიდი ესკიზები + Clip Thumbnails to Document Bounds ესკიზების დოკუმენტის საზღვრებით მოჭრა + Make Shape ფიგურის შექმნა + Background (default) ფონი (ნაგულისხმევი) + Transparent (default) გამჭვირვალე (ნაგულისხმევი) + The History Brush source was deleted: set a new source in the History panel ისტორიის ფუნჯის წყარო წაიშალა: მიუთითეთ ახალი წყარო ისტორიის პანელში + The GPU compositor is still being prepared GPU-კომპოზიტორი ჯერ კიდევ მზადდება + Message შეტყობინება + Playback Options… შესრულების პარამეტრები… + Playback Options შესრულების პარამეტრები + Accelerated დაჩქარებული + Step by Step ნაბიჯ-ნაბიჯ + Pause For: პაუზა: + Pause For პაუზა + seconds წამი + Create a single history state ისტორიის ერთი მდგომარეობის შექმნა + Generator enabled გენერატორი ჩართულია + On your computer თქვენს კომპიუტერზე + New file ახალი ფაილი + Learn სწავლა + Website ვებსაიტი + A frame supports affine transforms; select its image for perspective transforms ჩარჩო აფინურ გარდაქმნებს უჭერს მხარს; პერსპექტიული გარდაქმნისთვის მონიშნეთ მისი გამოსახულება + A frame supports affine transforms; select its image to warp it ჩარჩო აფინურ გარდაქმნებს უჭერს მხარს; დეფორმაციისთვის მონიშნეთ მისი გამოსახულება + Convert to Frame… ჩარჩოდ გარდაქმნა… + Create a new custom frame ახალი მორგებული ჩარჩოს შექმნა + Create a new polygonal frame ახალი მრავალკუთხა ჩარჩოს შექმნა + Create a new triangular frame ახალი სამკუთხა ჩარჩოს შექმნა + Fit Content to Frame შიგთავსის ჩარჩოზე მორგება + Fit Frame to Content ჩარჩოს შიგთავსზე მორგება + Frame coordinates must be finite and within 600000 pixels ჩარჩოს კოორდინატები სასრული უნდა იყოს და 600000 პიქსელს არ უნდა აღემატებოდეს + Import Image… გამოსახულების იმპორტი… + Invalid frame transform ჩარჩოს არასწორი გარდაქმნა + Invalid nested file drop ფაილის ჩაგდების არასწორი მოთხოვნა + New Frame ახალი ჩარჩო + Replace Image… გამოსახულების ჩანაცვლება… + The frame is locked ჩარჩო ჩაკეტილია + The frame is unavailable ჩარჩო მიუწვდომელია + Channel Options… არხის პარამეტრები… + Specify Channels: არხების მითითება: + Not enough grayscale documents for this mode. ამ რეჟიმისთვის ნაცრისფერი ტონების დოკუმენტები საკმარისი არ არის. + Pick a different document for each channel. თითოეული არხისთვის სხვა დოკუმენტი აირჩიეთ. + A picked document is no longer open. ერთ-ერთი არჩეული დოკუმენტი აღარ არის გახსნილი. + A picked document is no longer a grayscale document of the same size. ერთ-ერთი არჩეული დოკუმენტი აღარ არის იმავე ზომის ნაცრისფერი ტონების დოკუმენტი. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bb59a34ccc..4d4815caf2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -369,6 +369,8 @@ Match Color… 색상 일치… Replace Color… 색상 대체… Equalize 균일화 + Equalize Selected Area Only 선택 영역만 균일화 + Equalize Entire Image Based On Selected Area 선택 영역을 기준으로 전체 이미지 균일화 Auto Tone 자동 톤 Auto Contrast 자동 대비 Auto Color 자동 색상 @@ -4186,3 +4188,15 @@ shape Appearance 화면 모양 Show layer bounds on hover 마우스를 올리면 레이어 경계 표시 Expand layer group on click 클릭하면 레이어 그룹 펼치기 Show hover bounds from Layers 레이어 패널에서 마우스를 올릴 때 경계 표시 + Saved Camera Raw settings to {file} Camera Raw 설정을 {file}에 저장했습니다 + Save the settings to each raw's sidecar without opening it 각 Raw의 사이드카 파일에 설정을 저장하고 열지 않습니다 + No settings saved: these raws can't keep a sidecar here 저장된 항목 없음: 이 Raw 파일은 여기에 사이드카 파일을 둘 수 없습니다 + Some settings could not be saved; the raws stay open 일부 설정을 저장할 수 없습니다. Raw 파일은 열린 상태로 유지됩니다 + Nothing to save: these raws are at their default settings 저장할 항목 없음: 이 Raw 파일은 기본 설정입니다 +# Artboard tool options bar (#11385) + Distribute Widths 너비 분포 + Distribute Heights 높이 분포 + Auto-nest Layers 레이어 자동 중첩 + Auto-size Canvas 캔버스 크기 자동 조정 + Keep Relative Position During Layer Reordering 레이어 순서 변경 시 상대 위치 유지 + Shrinkwrap Canvas on Save 저장 시 캔버스를 내용에 맞게 축소 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index b9575105ec..83643360f3 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -4822,3 +4822,10 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert Show layer bounds on hover Vis lagets grenser ved hovring Expand layer group on click Utvid lagergruppe ved klikk Show hover bounds from Layers Vis hovringsgrenser fra Lag +# Artboard tool options bar (#11385) + Distribute Widths Fordel bredder + Distribute Heights Fordel høyder + Auto-nest Layers Neste lag automatisk + Auto-size Canvas Endre lerretstørrelse automatisk + Keep Relative Position During Layer Reordering Behold relativ posisjon ved omordning av lag + Shrinkwrap Canvas on Save Krymp lerretet til innholdet ved lagring diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 27516f61f1..07088b2d3e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -335,6 +335,8 @@ Match Color… Kleur afstemmen… Replace Color… Kleur vervangen… Equalize Egaliseren + Equalize Selected Area Only Alleen geselecteerd gebied egaliseren + Equalize Entire Image Based On Selected Area Hele afbeelding egaliseren op basis van geselecteerd gebied Auto Tone Automatische tooncorrectie Auto Contrast Automatisch contrast Auto Color Automatische kleur @@ -4367,3 +4369,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Laaggrenzen tonen bij aanwijzen Expand layer group on click Laaggroep uitvouwen bij klikken Show hover bounds from Layers Aanwijsgrenzen tonen vanuit Lagen + Saved Camera Raw settings to {file} Camera Raw-instellingen opgeslagen in {file} + Save the settings to each raw's sidecar without opening it De instellingen in het sidecarbestand van elke raw opslaan zonder die te openen + No settings saved: these raws can't keep a sidecar here Niets opgeslagen: deze raws kunnen hier geen sidecarbestand krijgen + Some settings could not be saved; the raws stay open Sommige instellingen konden niet worden opgeslagen; de raws blijven open + Nothing to save: these raws are at their default settings Niets op te slaan: deze raws staan op hun standaardinstellingen +# Artboard tool options bar (#11385) + Distribute Widths Breedtes verdelen + Distribute Heights Hoogtes verdelen + Auto-nest Layers Lagen automatisch nesten + Auto-size Canvas Canvasgrootte automatisch aanpassen + Keep Relative Position During Layer Reordering Relatieve positie behouden bij het wijzigen van de laagvolgorde + Shrinkwrap Canvas on Save Canvas bij opslaan verkleinen tot de inhoud diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7afa589754..b6dd0211f0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -378,6 +378,8 @@ Match Color… Dopasuj kolor… Replace Color… Zamień kolor… Equalize Wyrównaj + Equalize Selected Area Only Wyrównaj tylko zaznaczony obszar + Equalize Entire Image Based On Selected Area Wyrównaj cały obraz na podstawie zaznaczonego obszaru Auto Tone Automatyczne tony Auto Contrast Automatyczny kontrast Auto Color Automatyczny kolor @@ -4231,3 +4233,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Pokaż granice warstwy po najechaniu Expand layer group on click Rozwiń grupę warstw po kliknięciu Show hover bounds from Layers Pokaż granice po najechaniu z panelu Warstwy + Saved Camera Raw settings to {file} Zapisano ustawienia Camera Raw w {file} + Save the settings to each raw's sidecar without opening it Zapisz ustawienia w pliku sidecar każdego pliku raw bez otwierania go + No settings saved: these raws can't keep a sidecar here Nic nie zapisano: te pliki raw nie mogą tu mieć pliku sidecar + Some settings could not be saved; the raws stay open Nie udało się zapisać niektórych ustawień; pliki raw pozostają otwarte + Nothing to save: these raws are at their default settings Nic do zapisania: te pliki raw mają ustawienia domyślne +# Artboard tool options bar (#11385) + Distribute Widths Rozmieść szerokości równomiernie + Distribute Heights Rozmieść wysokości równomiernie + Auto-nest Layers Automatycznie zagnieżdżaj warstwy + Auto-size Canvas Automatycznie zmieniaj rozmiar płótna + Keep Relative Position During Layer Reordering Zachowaj położenie względne przy zmianie kolejności warstw + Shrinkwrap Canvas on Save Przytnij płótno do zawartości przy zapisie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8ca8043bc3..15fdbbdbeb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -368,6 +368,8 @@ Match Color… Corresponder cores… Replace Color… Substituir cor… Equalize Equalizar + Equalize Selected Area Only Equalizar somente a área selecionada + Equalize Entire Image Based On Selected Area Equalizar a imagem inteira com base na área selecionada Auto Tone Tom automático Auto Contrast Contraste automático Auto Color Cor automática @@ -4527,3 +4529,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Mostrar limites da camada ao passar o cursor Expand layer group on click Expandir grupo de camadas ao clicar Show hover bounds from Layers Mostrar limites ao passar o cursor em Camadas + Saved Camera Raw settings to {file} Configurações do Camera Raw salvas em {file} + Save the settings to each raw's sidecar without opening it Salvar as configurações no arquivo sidecar de cada raw sem abri-lo + No settings saved: these raws can't keep a sidecar here Nada foi salvo: estes raw não podem ter um arquivo sidecar aqui + Some settings could not be saved; the raws stay open Não foi possível salvar algumas configurações; os raw continuam abertos + Nothing to save: these raws are at their default settings Nada para salvar: estes raw estão nas configurações padrão +# Artboard tool options bar (#11385) + Distribute Widths Distribuir larguras + Distribute Heights Distribuir alturas + Auto-nest Layers Aninhar camadas automaticamente + Auto-size Canvas Ajustar tamanho da tela automaticamente + Keep Relative Position During Layer Reordering Manter a posição relativa ao reordenar camadas + Shrinkwrap Canvas on Save Reduzir a tela ao conteúdo ao salvar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ae0ed525ea..65575eaf9a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -996,6 +996,8 @@ Match Color… Подбор цвета… Replace Color… Замена цвета… Equalize Выравнивание + Equalize Selected Area Only Выровнять только выделенную область + Equalize Entire Image Based On Selected Area Выровнять всё изображение по выделенной области HDR Toning… HDR-тонирование… Shadows/Highlights… Тени/Света… Amount Эффект @@ -4798,3 +4800,15 @@ shape Appearance Оформление Show layer bounds on hover Показывать границы слоя при наведении Expand layer group on click Раскрывать группу слоёв по щелчку Show hover bounds from Layers Показывать границы при наведении из панели «Слои» + Saved Camera Raw settings to {file} Настройки Camera Raw сохранены в {file} + Save the settings to each raw's sidecar without opening it Сохранить настройки в сопроводительный файл каждого raw, не открывая его + No settings saved: these raws can't keep a sidecar here Ничего не сохранено: у этих raw здесь не может быть сопроводительного файла + Some settings could not be saved; the raws stay open Не удалось сохранить некоторые настройки; raw остаются открытыми + Nothing to save: these raws are at their default settings Нечего сохранять: у этих raw настройки по умолчанию +# Artboard tool options bar (#11385) + Distribute Widths Распределить ширину + Distribute Heights Распределить высоту + Auto-nest Layers Автоматически вкладывать слои + Auto-size Canvas Автоматически менять размер холста + Keep Relative Position During Layer Reordering Сохранять относительное положение при изменении порядка слоев + Shrinkwrap Canvas on Save Уменьшать холст до содержимого при сохранении diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 173c385818..b2c1d42cab 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -394,6 +394,8 @@ Match Color… Uskladi boje… Replace Color… Zameni boju… Equalize Izjednači + Equalize Selected Area Only Izjednači samo selekciju + Equalize Entire Image Based On Selected Area Izjednači celu sliku prema selekciji Auto Tone Automatski ton Auto Contrast Automatski kontrast Auto Color Automatske boje @@ -4679,3 +4681,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Prikaži granice sloja pri prelasku miša Expand layer group on click Proširi grupu slojeva klikom Show hover bounds from Layers Prikaži granice pri prelasku iz panela Slojevi + Saved Camera Raw settings to {file} Подешавања Camera Raw сачувана у {file} + Save the settings to each raw's sidecar without opening it Sačuvaj podešavanja u prateću datoteku svakog raw snimka bez otvaranja + No settings saved: these raws can't keep a sidecar here Ništa nije sačuvano: ovi raw snimci ovde ne mogu imati prateću datoteku + Some settings could not be saved; the raws stay open Neka podešavanja nije moguće sačuvati; raw snimci ostaju otvoreni + Nothing to save: these raws are at their default settings Nema šta da se sačuva: ovi raw snimci imaju podrazumevana podešavanja +# Artboard tool options bar (#11385) + Distribute Widths Raspodeli širine + Distribute Heights Raspodeli visine + Auto-nest Layers Automatski ugnezdi slojeve + Auto-size Canvas Automatski promeni veličinu platna + Keep Relative Position During Layer Reordering Zadrži relativni položaj pri promeni redosleda slojeva + Shrinkwrap Canvas on Save Smanji platno na sadržaj pri čuvanju diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 364bcb1fa1..2157f234de 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4820,3 +4820,10 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen Show layer bounds on hover Visa lagrets gränser vid hovring Expand layer group on click Expandera lagergrupp vid klick Show hover bounds from Layers Visa hovringsgränser från Lager +# Artboard tool options bar (#11385) + Distribute Widths Fördela bredder + Distribute Heights Fördela höjder + Auto-nest Layers Kapsla in lager automatiskt + Auto-size Canvas Ändra storlek på arbetsytan automatiskt + Keep Relative Position During Layer Reordering Behåll relativ position vid ändring av lagerordning + Shrinkwrap Canvas on Save Krymp arbetsytan till innehållet vid sparning diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index b2bef186e9..b660029cdb 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -172,6 +172,7 @@ mod tests { (vec!["nb-NO", "en-US"], "nb"), (vec!["nn_NO.UTF-8", "en-US"], "nb"), (vec!["is-IS", "no-NO"], "nb"), + (vec!["ka-GE", "ru-RU"], "ka"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 54241cf172..36c0c6e3ae 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -7,6 +7,7 @@ const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered"); const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered"); const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered"); const HI: fn() -> Lang = || Lang::from_code("hi").expect("hi registered"); +const KA: fn() -> Lang = || Lang::from_code("ka").expect("ka registered"); #[test] fn hindi_has_the_reveal_and_camera_raw_rows() { @@ -28,6 +29,20 @@ fn hindi_tags_resolve() { assert_eq!(Lang::from_pref("HI"), HI()); } +#[test] +fn georgian_tags_resolve() { + for tag in ["ka", "ka-GE", "ka_GE", "ka_GE.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(KA()), "{tag}"); + } + assert_eq!(KA().name(), "ქართული"); + assert_eq!(Lang::from_pref("ka"), KA()); + assert_eq!(Lang::from_pref("KA"), KA()); + assert_eq!(tr(KA(), "Layer"), "შრე"); + assert_eq!(tr(KA(), "File"), "ფაილი"); + assert_eq!(trn(KA(), 1, "{n} layer", "{n} layers"), "შრე: 1"); + assert_eq!(trn(KA(), 3, "{n} layer", "{n} layers"), "შრე: 3"); +} + #[test] fn indonesian_tags_resolve() { for tag in ["id", "id-ID", "id_ID", "id_ID.UTF-8"] { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0120da1700..ee2567d21e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -302,6 +302,8 @@ Match Color… Rengi Eşleştir… Replace Color… Rengi Değiştir… Equalize Eşitle + Equalize Selected Area Only Yalnızca Seçili Alanı Eşitle + Equalize Entire Image Based On Selected Area Tüm Görüntüyü Seçili Alana Göre Eşitle Auto Tone Otomatik Ton Auto Contrast Otomatik Kontrast Auto Color Otomatik Renk @@ -4344,3 +4346,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Üzerine gelince katman sınırlarını göster Expand layer group on click Tıklayınca katman grubunu genişlet Show hover bounds from Layers Katmanlar'dan üzerine gelme sınırlarını göster + Saved Camera Raw settings to {file} Camera Raw ayarları {file} dosyasına kaydedildi + Save the settings to each raw's sidecar without opening it Ayarları her raw dosyasının yan dosyasına kaydet, açmadan + No settings saved: these raws can't keep a sidecar here Hiçbir şey kaydedilmedi: bu raw dosyaları burada yan dosyaya sahip olamaz + Some settings could not be saved; the raws stay open Bazı ayarlar kaydedilemedi; raw dosyaları açık kalıyor + Nothing to save: these raws are at their default settings Kaydedilecek bir şey yok: bu raw dosyaları varsayılan ayarlarda +# Artboard tool options bar (#11385) + Distribute Widths Genişlikleri Dağıt + Distribute Heights Yükseklikleri Dağıt + Auto-nest Layers Katmanları Otomatik İç İçe Yerleştir + Auto-size Canvas Tuvali Otomatik Boyutlandır + Keep Relative Position During Layer Reordering Katmanları Yeniden Sıralarken Göreli Konumu Koru + Shrinkwrap Canvas on Save Kaydederken Tuvali İçeriğe Göre Küçült diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b3ba8887f7..8035a7f224 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -967,6 +967,8 @@ Match Color… Узгодити колір… Replace Color… Замінити колір… Equalize Вирівняти гістограму + Equalize Selected Area Only Вирівняти лише виділену область + Equalize Entire Image Based On Selected Area Вирівняти все зображення за виділеною областю HDR Toning… Тонування HDR… Shadows/Highlights… Тіні/світлі ділянки… Amount Величина @@ -4324,3 +4326,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Показувати межі шару під час наведення Expand layer group on click Розгортати групу шарів за клацанням Show hover bounds from Layers Показувати межі під час наведення з панелі «Шари» + Saved Camera Raw settings to {file} Налаштування Camera Raw збережено в {file} + Save the settings to each raw's sidecar without opening it Зберегти налаштування в супровідний файл кожного raw, не відкриваючи його + No settings saved: these raws can't keep a sidecar here Нічого не збережено: ці raw тут не можуть мати супровідного файлу + Some settings could not be saved; the raws stay open Не вдалося зберегти деякі налаштування; raw залишаються відкритими + Nothing to save: these raws are at their default settings Нічого зберігати: ці raw мають типові налаштування +# Artboard tool options bar (#11385) + Distribute Widths Розподілити ширину + Distribute Heights Розподілити висоту + Auto-nest Layers Автоматично вкладати шари + Auto-size Canvas Автоматично змінювати розмір полотна + Keep Relative Position During Layer Reordering Зберігати відносне положення під час зміни порядку шарів + Shrinkwrap Canvas on Save Зменшувати полотно до вмісту під час збереження diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c1e35f2880..f1e4723709 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -411,6 +411,8 @@ Enable Vector Mask Bật mặt nạ vector Entropy Entropy Equalize Cân bằng histogram + Equalize Entire Image Based On Selected Area Cân bằng histogram toàn bộ hình ảnh theo vùng chọn + Equalize Selected Area Only Chỉ cân bằng histogram vùng chọn Exclude Overlapping Shapes Loại phần chồng hình Subtract Shapes at Overlap Loại phần chồng hình Export Contents… Xuất nội dung… @@ -4118,3 +4120,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Hiện ranh giới lớp khi di chuột Expand layer group on click Mở rộng nhóm lớp khi nhấp Show hover bounds from Layers Hiện ranh giới khi di chuột từ bảng Lớp + Saved Camera Raw settings to {file} Đã lưu cài đặt Camera Raw vào {file} + Save the settings to each raw's sidecar without opening it Lưu thiết lập vào tệp sidecar của từng ảnh raw mà không mở + No settings saved: these raws can't keep a sidecar here Không lưu được gì: các ảnh raw này không thể có tệp sidecar ở đây + Some settings could not be saved; the raws stay open Không thể lưu một số cài đặt; các ảnh raw vẫn mở + Nothing to save: these raws are at their default settings Không có gì để lưu: các ảnh raw này đang ở cài đặt mặc định +# Artboard tool options bar (#11385) + Distribute Widths Phân bố chiều rộng + Distribute Heights Phân bố chiều cao + Auto-nest Layers Tự động lồng các lớp + Auto-size Canvas Tự động đổi kích thước vùng vẽ + Keep Relative Position During Layer Reordering Giữ vị trí tương đối khi sắp xếp lại lớp + Shrinkwrap Canvas on Save Thu nhỏ vùng vẽ theo nội dung khi lưu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d34dd8047a..28c6119f66 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -385,6 +385,8 @@ Match Color… 匹配颜色… Replace Color… 替换颜色… Equalize 色调均化 + Equalize Selected Area Only 仅色调均化选区 + Equalize Entire Image Based On Selected Area 基于选区色调均化整个图像 Auto Tone 自动色调 Auto Contrast 自动对比度 Auto Color 自动颜色 @@ -3305,3 +3307,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover 悬停时显示图层边界 Expand layer group on click 单击时展开图层组 Show hover bounds from Layers 显示来自图层面板的悬停边界 + Saved Camera Raw settings to {file} 已将 Camera Raw 设置保存到 {file} + Save the settings to each raw's sidecar without opening it 将设置保存到每个 Raw 的附属文件,不打开它 + No settings saved: these raws can't keep a sidecar here 未保存任何内容:这些 Raw 在此处无法拥有附属文件 + Some settings could not be saved; the raws stay open 部分设置无法保存;Raw 保持打开 + Nothing to save: these raws are at their default settings 无需保存:这些 Raw 均为默认设置 +# Artboard tool options bar (#11385) + Distribute Widths 分布宽度 + Distribute Heights 分布高度 + Auto-nest Layers 自动嵌套图层 + Auto-size Canvas 自动调整画布大小 + Keep Relative Position During Layer Reordering 重新排列图层时保持相对位置 + Shrinkwrap Canvas on Save 保存时将画布缩至内容大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ec03483d41..8d2c5697f1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -384,6 +384,8 @@ Match Color… 符合顏色… Replace Color… 取代顏色… Equalize 均勻分佈 + Equalize Selected Area Only 僅均勻分佈選取範圍 + Equalize Entire Image Based On Selected Area 依據選取範圍均勻分佈整個影像 Auto Tone 自動色調 Auto Contrast 自動對比 Auto Color 自動色彩 @@ -3262,3 +3264,15 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover 滑過時顯示圖層邊界 Expand layer group on click 按一下時展開圖層群組 Show hover bounds from Layers 顯示來自圖層面板的滑過邊界 + Saved Camera Raw settings to {file} 已將 Camera Raw 設定儲存到 {file} + Save the settings to each raw's sidecar without opening it 將設定儲存到每個 Raw 的附屬檔案,不開啟它 + No settings saved: these raws can't keep a sidecar here 未儲存任何內容:這些 Raw 在此處無法擁有附屬檔案 + Some settings could not be saved; the raws stay open 部分設定無法儲存;Raw 保持開啟 + Nothing to save: these raws are at their default settings 無需儲存:這些 Raw 均為預設設定 +# Artboard tool options bar (#11385) + Distribute Widths 均分寬度 + Distribute Heights 均分高度 + Auto-nest Layers 自動巢狀圖層 + Auto-size Canvas 自動調整版面尺寸 + Keep Relative Position During Layer Reordering 重新排列圖層時保持相對位置 + Shrinkwrap Canvas on Save 儲存時將版面縮至內容大小 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c102064a7f..0a40f7b034 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -179,6 +179,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::EllipseShape => "circle", Tool::Triangle => "triangle", Tool::Polygon => "pentagon", + Tool::Star => "star", Tool::Line => "slash", Tool::CustomShape => "cloud", } diff --git a/crates/ui-egui/src/input/blend_keys.rs b/crates/ui-egui/src/input/blend_keys.rs index 5a126268b7..fb3d5d47b5 100644 --- a/crates/ui-egui/src/input/blend_keys.rs +++ b/crates/ui-egui/src/input/blend_keys.rs @@ -32,8 +32,7 @@ pub enum Pick { Mode(BlendMode), /// Painting tools only: paint transparency. Clear, - /// Painting tools only: paint behind the layer's pixels. Not implemented yet, so ⇧⌥Q is taken - /// (it must not reach another binding) and does nothing. + /// Brush and Pencil: paint behind the layer's pixels. Behind, } @@ -265,7 +264,23 @@ pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { let want = match pick { Pick::Mode(m) => Some(m), Pick::Clear => None, - Pick::Behind => return, + Pick::Behind => { + let item = crate::brush_mode_menu::Item::Behind; + let tool = app.ui.tool; + if app.session.active().is_none() || !crate::brush_mode_menu::enabled(app, item) { + return; + } + let Some((id, params)) = crate::brush_mode_menu::engine_call(app, tool, item) else { return }; + crate::paint_mouse::sync_tool_brush(app); + if app.run(id, params).is_ok() { + match tool { + Tool::Brush => app.ui.tool_options.brush_clear = false, + Tool::Pencil => app.ui.tool_options.pencil_clear = false, + _ => {} + } + } + return; + } }; match target(app.ui.tool) { Target::Tool(modes) if modes.contains(&want) => set_tool_mode(app, want), @@ -280,3 +295,7 @@ pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "../brush_behind_key_tests.rs"] +mod brush_behind_key_tests; diff --git a/crates/ui-egui/src/input/opacity_keys.rs b/crates/ui-egui/src/input/opacity_keys.rs index 7f02c0c77b..55044a26f1 100644 --- a/crates/ui-egui/src/input/opacity_keys.rs +++ b/crates/ui-egui/src/input/opacity_keys.rs @@ -1,8 +1,9 @@ //! Number keys set opacity (#352): 1 = 10% … 9 = 90%, 0 = 100%; two digits -//! typed quickly set an exact value (4 then 5 = 45%, 0 then 0 = 0%). With the Brush, Pencil or +//! typed quickly set an exact value (4 then 5 = 45%; 0 then 0 = 100% for the Brush and the Pencil, #11812). With the Brush, Pencil or //! Eraser they set the brush's Opacity (⇧: Flow; swapped while airbrush build-up is on); with the Gradient or Paint Bucket their //! Opacity; with tools that have no opacity of their own (Move, the selection tools, Type…) the -//! active layer's Opacity (⇧: Fill). Tools whose number is a strength or exposure are left alone. +//! active layer's Opacity (⇧: Fill); with the Background Eraser its Tolerance (#11907). Tools whose +//! number is a strength or exposure are left alone. use egui::{Event, Key}; use serde_json::json; @@ -25,6 +26,8 @@ enum Target { }, /// The Gradient / Paint Bucket opacity option. ToolOpacity, + /// The Background Eraser's Tolerance option. + BgTolerance, /// The active layer's opacity (⇧: fill). Layer, None, @@ -35,6 +38,7 @@ fn target(tool: Tool) -> Target { Tool::Brush | Tool::Eraser | Tool::CloneStamp | Tool::HistoryBrush => Target::Brush { flow: true }, Tool::Pencil => Target::Brush { flow: false }, Tool::Gradient | Tool::PaintBucket => Target::ToolOpacity, + Tool::BackgroundEraser => Target::BgTolerance, // Their options-bar number is a strength, exposure or tolerance, not this opacity. t if t.is_brushlike() || t == Tool::MagicEraser => Target::None, _ => Target::Layer, @@ -107,9 +111,13 @@ fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { let shift = shift ^ (build_up && matches!(target(tool), Target::Brush { flow: true })); match (target(app.ui.tool), shift) { (Target::Brush { .. }, false) => { - // Clone Stamp's and History Brush's Opacity fields (and their strokes) start at 1%, so - // `00` sets the 1% they actually paint with rather than showing a 0% they can't do. - let v = if matches!(tool, Tool::CloneStamp | Tool::HistoryBrush) { v.max(0.01) } else { v }; + // The painting tools' Opacity fields (and `tools.setBrush`) start at 1%, so `00` sets the + // 1% they actually paint with rather than a 0% they can't do. The Brush's and the + // Pencil's `00` is 100%, as in Photoshop (#11812); only `00` gives 0 here. + let v = match tool { + Tool::Brush | Tool::Pencil if v <= 0.0 => 1.0, + _ => v.max(0.01), + }; let _ = app.run("tools.setBrush", json!({ "brush": { "opacity": v } })); } // The options bar's Flow field starts at 1%. @@ -117,6 +125,8 @@ fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { let _ = app.run("tools.setBrush", json!({ "brush": { "flow": v.max(0.01) } })); } (Target::ToolOpacity, false) => app.ui.tool_options.fill_opacity = percent, + // The Tolerance field starts at 1%; `00` (0%) keeps today's value rather than guessing. + (Target::BgTolerance, false) if percent >= 1.0 => app.ui.tool_options.bg_tolerance = percent, (Target::Layer, _) => { // The locked Background has no opacity or fill. let Some(st) = app.session.active() else { return }; @@ -163,7 +173,7 @@ mod tests { press(&mut a, 0, false, 9000.0); assert_eq!(a.session.tools.brush.opacity, 1.0, "0 = 100%"); press(&mut a, 0, false, 9100.0); - assert_eq!(a.session.tools.brush.opacity, 0.0, "00 = 0%"); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); press(&mut a, 2, true, 20000.0); assert!((a.session.tools.brush.flow - 0.2).abs() < 1e-6); press(&mut a, 0, true, 30000.0); @@ -224,10 +234,6 @@ mod tests { press(&mut a, 0, false, 100.0); assert!((a.session.tools.brush.opacity - 0.01).abs() < 1e-6, "{tool:?}"); } - let mut a = app(Tool::Brush); - press(&mut a, 0, false, 0.0); - press(&mut a, 0, false, 100.0); - assert!(a.session.tools.brush.opacity.abs() < 1e-6, "the Brush itself still goes to 0%"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 81b981fa55..47d6d7740f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -150,6 +150,7 @@ pub use dialogs::disk_change_prompt; pub use dialogs::distort_ui; pub use dialogs::duplicate_layer_ui; pub use dialogs::edit_history; +pub use dialogs::equalize_ui; pub use dialogs::export_dialog; pub use dialogs::file_ui; pub use dialogs::fill_ui; @@ -562,6 +563,8 @@ pub struct PhotocraftApp { /// makes shortcut- and dialog-triggered commands pass the same policy as /// direct control calls. pub(crate) automation_input: bool, + /// The direct control-command route keeps Actions playback synchronous and dialog-free. + pub(crate) programmatic_playback: bool, /// How many `run_command` calls are on the stack. Only the outermost one writes the frame's /// command stage: a nested run (`file.save` → `layer.smartObjects.saveContents`, /// `actions.play` → its steps) is already inside the outer call's time. @@ -603,9 +606,9 @@ pub struct PhotocraftApp { pub(crate) gradient: gradient_ui::LiveGradient, /// Filter › Camera Raw Filter dialog (camera_raw_ui). pub(crate) camera_raw: Option, - /// A raw file just opened interactively, waiting for the open-time Camera Raw dialog (shown - /// on the next frame, which has the egui context). - pub(crate) pending_raw_open: Option, + /// Raws just opened interactively, waiting in the open-time Camera Raw dialog (shown on the + /// next frame, which has the egui context); several share it in a filmstrip. + pub(crate) raw_shelf: Vec, /// The open-time re-develops (or their Camera Raw steps) running in the background, one per /// raw being opened. pub(crate) raw_redevelop: Vec, @@ -726,6 +729,8 @@ pub struct PhotocraftApp { pub(crate) prompt_order: std::collections::VecDeque, /// A brush or gradient file waiting on the group-name prompt (see `preset_group_prompt`). pub(crate) held_preset_import: Option, + pub(crate) held_record_again: Option, + pub(crate) held_insert_menu: Option, /// Set once the user has agreed to quit, so the resulting close request goes through. pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). @@ -770,6 +775,10 @@ impl SyntheticTarget { #[cfg(test)] mod artboard_add_mode_tests; #[cfg(test)] +mod background_eraser_tolerance_field_tests; +#[cfg(test)] +mod brush_options_tooltips_tests; +#[cfg(test)] mod brush_picker_escape_tests; #[cfg(test)] mod shape_bounds_box_tests; @@ -855,6 +864,12 @@ mod transform_type_tests; #[cfg(test)] mod transform_reference_point_tests; +#[cfg(test)] +mod transform_relative_position_tests; + +#[cfg(test)] +mod star_tool_tests; + #[cfg(test)] mod transform_several_tests; @@ -869,6 +884,9 @@ mod save_identity_tests; #[cfg(test)] mod move_auto_select_tests; +#[cfg(test)] +mod move_target_default_tests; + #[cfg(test)] mod move_history_names_tests; #[cfg(test)] @@ -991,6 +1009,10 @@ mod menu_item_labels_tests; #[cfg(test)] mod pen_path_ownership_tests; +#[cfg(test)] +mod artboard_options_bar_tests; +#[cfg(test)] +mod brush_opacity_minimum_tests; #[cfg(test)] mod doc_tab_menu_tests; #[cfg(test)] @@ -1001,3 +1023,6 @@ mod preferences_submenu_tests; mod selection_move_step_tests; #[cfg(test)] mod view_guides_submenu_tests; + +#[cfg(test)] +mod background_eraser_digit_keys_tests; diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 2691897f81..3838a70bcf 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -348,6 +348,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Smart Objects"], "Update All Modified Content", None, "layer.smartObjects.updateAllModifiedContent"), (&["Layer", "Smart Objects"], "---", None, "---"), (&["Layer", "Smart Objects"], "Edit Contents", None, "layer.smartObjects.editContents"), + (&["Layer", "Smart Objects"], "Reset Transform", None, "layer.smartObjects.resetTransform"), (&["Layer", "Smart Objects"], "Relink to File…", None, "layer.smartObjects.relinkToFile"), (&["Layer", "Smart Objects"], "Replace Contents…", None, "layer.smartObjects.replaceContents"), (&["Layer", "Smart Objects"], "Export Contents…", None, "layer.smartObjects.exportContents"), diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 6b6a3c373d..9e2da7e511 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -208,10 +208,16 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, // ⌘↩ / Ctrl+Enter (#306): load the path selected in the Paths panel (or the one being // drawn) as a selection. "path.toSelection" if params.get("name").is_none() => crate::vector_ui::path_to_selection(app, params.clone()), - // ⌘⇧↩ / ⌘⌥↩ load the same path, adding it to or subtracting it from the selection. - "path.addToSelection" | "path.subtractFromSelection" if params.get("name").is_none() => { + // ⌘⇧↩ / ⌘⌥↩ / ⇧⌥⌘↩ load the same path, adding it to, subtracting it from or intersecting + // it with the selection. + "path.addToSelection" | "path.subtractFromSelection" | "path.intersectWithSelection" if params.get("name").is_none() => { let mut p = params.as_object().cloned().unwrap_or_default(); - p.insert("mode".into(), json!(if id == "path.addToSelection" { "add" } else { "subtract" })); + let mode = match id { + "path.addToSelection" => "add", + "path.subtractFromSelection" => "subtract", + _ => "intersect", + }; + p.insert("mode".into(), json!(mode)); crate::vector_ui::path_to_selection(app, Value::Object(p)) } // Layer › Rename Layer from a menu starts in-place renaming in the Layers panel. @@ -451,6 +457,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: let dialog = crate::channel_options_ui::open_new_spot(app).ok_or("no document is open")?; Ok(json!({"dialog": dialog})) } + // Select › Find Layers: the Layers panel's Name filter (#10884). + "select.findLayers" => crate::layer_filter_ui::find::invoke(app, ctx, params), // Photoshop's "Select and Mask…" is the engine's select.refineEdge. // Select › Color Range… from the menu: the dialog (with params: the engine directly). "select.colorRange" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::color_range_ui::open(app)})), @@ -687,6 +695,10 @@ fn invoke_view_command(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p Some(crate::sizing::open(app, sz).map(|dialog| json!({"dialog": dialog})).ok_or_else(|| "no document".to_string())) } "view.newGuide" if params.as_object().is_none_or(|object| object.is_empty()) => Some(Ok(json!({"dialog": crate::new_guide_ui::open(app)}))), + // Image › Adjustments › Equalize with a selection asks what to equalize, as in Photoshop. + crate::equalize_ui::COMMAND if params.as_object().is_none_or(|object| object.is_empty()) && crate::equalize_ui::asks(app) => { + Some(Ok(json!({"dialog": crate::equalize_ui::open(app)}))) + } fl if crate::filter_dialog::has_dialog(fl) && params.as_object().is_none_or(|object| object.is_empty()) => { Some(Ok(json!({"dialog": crate::filter_dialog::open(app, fl)}))) } diff --git a/crates/ui-egui/src/menus/view_cmds.rs b/crates/ui-egui/src/menus/view_cmds.rs index bbe985ab6e..6194417a22 100644 --- a/crates/ui-egui/src/menus/view_cmds.rs +++ b/crates/ui-egui/src/menus/view_cmds.rs @@ -18,7 +18,7 @@ mod dialogs; mod dispatch; mod options; -pub use arrange::cells; +pub use arrange::{Border, borders, cells, drag_border}; pub(crate) use arrange::{document_is_floating, float_window}; pub(crate) use dialogs::write_copy; #[cfg(test)] @@ -106,6 +106,9 @@ pub struct ViewOptions { pub pixel_art_preview: bool, /// Window › Arrange layout of the document area: "tabs" or a tile/n-up layout id. pub arrange: String, + /// Where the borders between arranged documents were dragged (#10101): fractions of the + /// area, column borders then row borders; empty (or not fitting the layout) is even cells. + pub arrange_splits: Vec, /// Type › Font Preview Size: "small" | "medium" | "large" | "extraLarge" | "huge". pub font_preview_size: String, /// Type › Language Options: "defaultFeatures" | "eastAsianFeatures" | "middleEasternFeatures", @@ -138,6 +141,7 @@ impl Default for ViewOptions { pattern_preview: false, pixel_art_preview: false, arrange: "tabs".into(), + arrange_splits: Vec::new(), font_preview_size: "medium".into(), language_features: "defaultFeatures".into(), middle_eastern_composer: false, diff --git a/crates/ui-egui/src/menus/view_cmds/arrange.rs b/crates/ui-egui/src/menus/view_cmds/arrange.rs index 3b81474605..514c6e79b3 100644 --- a/crates/ui-egui/src/menus/view_cmds/arrange.rs +++ b/crates/ui-egui/src/menus/view_cmds/arrange.rs @@ -48,9 +48,14 @@ pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize, f wid } +/// The Window › Arrange items: a layout (2-up, 4-up, Tiled…), which starts from even cells and +/// refits every document, or one of the window commands beside them — Consolidate All to Tabs, +/// New Window, Float in Window, Float All in Windows, Match Zoom and the rest. pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result { if LAYOUTS.contains(&k) { app.ui.view.arrange = k.to_string(); + // A new arrangement starts from even cells. + app.ui.view.arrange_splits.clear(); // Each document fits its new cell. app.ui.views.iter_mut().for_each(|v| v.fit_pending = true); return Ok(json!({"arrange": k})); @@ -137,43 +142,164 @@ pub(crate) fn document_is_floating(app: &PhotocraftApp, doc: usize) -> bool { app.ui.windows.iter().any(|w| w.open && w.floating && w.document == doc) } -/// Screen rectangles for `n` documents in an arrangement (`None` = tabs). The active document -/// always gets a cell; n-up layouts show that many documents starting from the active one. -pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option> { - use craft_ui::layout::{SplitAxis, SplitSize, split_rect}; - - if n < 2 || !LAYOUTS.contains(&layout) { - return None; - } - let grid = |cols: usize, rows: usize, count: usize| -> Vec { - let (w, h) = (rect.width() / cols as f32, rect.height() / rows as f32); - (0..count.min(cols * rows)) - .map(|i| egui::Rect::from_min_size(rect.min + egui::vec2((i % cols) as f32 * w, (i / cols) as f32 * h), egui::vec2(w, h))) - .collect() - }; - Some(match layout { - "tileAllVertically" => grid(n, 1, n), - "tileAllHorizontally" => grid(1, n, n), - "twoUpVertical" => { - let panes = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![panes.first, panes.second] - } - "twoUpHorizontal" => { - let panes = split_rect(rect, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![panes.first, panes.second] - } - "threeUpVertical" => grid(3, 1, 3), - "threeUpHorizontal" => grid(1, 3, 3), - "threeUpStacked" => { - let columns = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?; - let rows = split_rect(columns.second, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![columns.first, rows.first, rows.second] - } - "fourUp" => grid(2, 2, 4), - "sixUp" => grid(3, 2, 6), - _ => { - let cols = (n as f32).sqrt().ceil() as usize; - grid(cols, n.div_ceil(cols), n) +/// Screen rectangles for `n` documents in an arrangement (`None` = tabs), with the borders +/// where they were left (`splits`, [`ViewOptions::arrange_splits`](super::ViewOptions)). The +/// active document always gets a cell; n-up layouts show that many documents starting from the +/// active one. +pub fn cells(layout: &str, rect: egui::Rect, n: usize, splits: &[f32]) -> Option> { + let shape = Shape::of(layout, n)?; + let (xs, ys) = shape.lines(rect, splits); + Some(match shape { + Shape::Grid { cols, rows, count } => (0..count.min(cols * rows)) + .filter_map(|i| { + let (c, r) = (i % cols, i / cols); + Some(egui::Rect::from_min_max(egui::pos2(*xs.get(c)?, *ys.get(r)?), egui::pos2(*xs.get(c + 1)?, *ys.get(r + 1)?))) + }) + .collect(), + // One tall cell on the left; the right column is cut in two. + Shape::Stacked => { + let (x, y) = (xs.get(1).copied().unwrap_or(rect.center().x), ys.get(1).copied().unwrap_or(rect.center().y)); + vec![ + egui::Rect::from_min_max(rect.min, egui::pos2(x, rect.max.y)), + egui::Rect::from_min_max(egui::pos2(x, rect.min.y), egui::pos2(rect.max.x, y)), + egui::Rect::from_min_max(egui::pos2(x, y), rect.max), + ] } }) } + +/// A border between arranged documents that can be dragged, as in Photoshop (#10101). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Border { + /// Which of the arrangement's splits it is: the index for [`drag_border`]. + pub split: usize, + /// It runs top to bottom (and moves left and right). + pub vertical: bool, + /// Its line on screen: zero wide (or tall) across the cells it divides. + pub line: egui::Rect, +} + +/// The borders of an arrangement, as laid out by [`cells`]. +pub fn borders(layout: &str, rect: egui::Rect, n: usize, splits: &[f32]) -> Vec { + let Some(shape) = Shape::of(layout, n) else { return Vec::new() }; + let (xs, ys) = shape.lines(rect, splits); + let inner = |v: &[f32]| v.get(1..v.len().saturating_sub(1)).unwrap_or_default().to_vec(); + let (inner_x, inner_y) = (inner(&xs), inner(&ys)); + let vertical = inner_x.iter().enumerate().map(|(i, &x)| Border { + split: i, + vertical: true, + line: egui::Rect::from_min_max(egui::pos2(x, rect.min.y), egui::pos2(x, rect.max.y)), + }); + // 3-up Stacked's row border spans only the right column. + let left = match shape { + Shape::Stacked => inner_x.first().copied().unwrap_or(rect.min.x), + Shape::Grid { .. } => rect.min.x, + }; + let horizontal = inner_y.iter().enumerate().map(|(i, &y)| Border { + split: inner_x.len() + i, + vertical: false, + line: egui::Rect::from_min_max(egui::pos2(left, y), egui::pos2(rect.max.x, y)), + }); + vertical.chain(horizontal).collect() +} + +/// Move border `split` of the arrangement to `pointer` (screen points), keeping every cell at +/// least [`MIN_CELL`] points across where the area allows. `splits` is rewritten in full, so a +/// stale or damaged list starts again from even cells. +pub fn drag_border(layout: &str, rect: egui::Rect, n: usize, splits: &mut Vec, split: usize, pointer: egui::Pos2) { + let Some(shape) = Shape::of(layout, n) else { return }; + let (cols, rows) = shape.counts(); + let mut fractions = shape.fractions(splits); + // The borders on the dragged one's axis are `fractions[first..first + count]`. + let (axis_start, axis_len, first, count, pos) = if split < cols - 1 { + (rect.min.x, rect.width(), 0, cols - 1, pointer.x) + } else if split < (cols - 1) + (rows - 1) { + (rect.min.y, rect.height(), cols - 1, rows - 1, pointer.y) + } else { + return; + }; + if !(axis_len > 0.0 && pos.is_finite()) { + return; + } + let own = fractions.get(first..first + count).map(<[f32]>::to_vec).unwrap_or_default(); + let k = split - first; + // [`MIN_CELL`] in fractions of this axis, whatever the number of cells: capping it by the + // even spacing would let a cell fall under the minimum on a long axis (#10101 review). + let gap = MIN_CELL / axis_len; + let lo = if k == 0 { 0.0 } else { own.get(k - 1).copied().unwrap_or(0.0) } + gap; + let hi = own.get(k + 1).copied().unwrap_or(1.0) - gap; + // The neighbours leave no room for a cell of the minimum width: the border stays where it is, + // rather than taking a value out of order that would invalidate the whole list. + if lo > hi { + return; + } + if let Some(f) = fractions.get_mut(split) { + *f = ((pos - axis_start) / axis_len).clamp(lo, hi); + } + *splits = fractions; +} + +/// The narrowest a cell is dragged to, in points. +pub const MIN_CELL: f32 = 80.0; + +/// An arrangement as columns and rows of cells. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Shape { + /// `cols` × `rows` even cells, the first `count` of them used. + Grid { cols: usize, rows: usize, count: usize }, + /// 3-up Stacked: two columns, the right one in two rows. + Stacked, +} + +impl Shape { + fn of(layout: &str, n: usize) -> Option { + if n < 2 || !LAYOUTS.contains(&layout) { + return None; + } + let grid = |cols: usize, rows: usize, count: usize| Shape::Grid { cols, rows, count }; + Some(match layout { + "tileAllVertically" => grid(n, 1, n), + "tileAllHorizontally" => grid(1, n, n), + "twoUpVertical" => grid(2, 1, 2), + "twoUpHorizontal" => grid(1, 2, 2), + "threeUpVertical" => grid(3, 1, 3), + "threeUpHorizontal" => grid(1, 3, 3), + "threeUpStacked" => Shape::Stacked, + "fourUp" => grid(2, 2, 4), + "sixUp" => grid(3, 2, 6), + _ => { + let cols = (n as f32).sqrt().ceil() as usize; + grid(cols, n.div_ceil(cols), n) + } + }) + } + + fn counts(self) -> (usize, usize) { + match self { + Shape::Grid { cols, rows, .. } => (cols, rows), + Shape::Stacked => (2, 2), + } + } + + /// The split positions as fractions: the column borders, then the row borders. `splits` + /// is used only when it fits this shape (as many values, each axis increasing within 0–1); + /// otherwise the cells are even. + fn fractions(self, splits: &[f32]) -> Vec { + let (cols, rows) = self.counts(); + let even = |k: usize| (1..k).map(move |i| i as f32 / k as f32); + let ordered = |v: &[f32]| v.iter().all(|f| f.is_finite() && *f > 0.0 && *f < 1.0) && v.windows(2).all(|w| matches!(w, [a, b] if a < b)); + let fit = splits.len() == (cols - 1) + (rows - 1) && splits.get(..cols - 1).is_some_and(ordered) && splits.get(cols - 1..).is_some_and(ordered); + if fit { splits.to_vec() } else { even(cols).chain(even(rows)).collect() } + } + + /// Every column line's x and every row line's y on screen, the area's edges included. + fn lines(self, rect: egui::Rect, splits: &[f32]) -> (Vec, Vec) { + let (cols, _) = self.counts(); + let fractions = self.fractions(splits); + let (fx, fy) = fractions.split_at((cols - 1).min(fractions.len())); + let at = |start: f32, len: f32, inner: &[f32]| { + std::iter::once(start).chain(inner.iter().map(|f| start + len * f)).chain(std::iter::once(start + len)).collect::>() + }; + (at(rect.min.x, rect.width(), fx), at(rect.min.y, rect.height(), fy)) + } +} diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index c292065045..67c823b5c1 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -183,14 +183,14 @@ fn print_size_uses_the_screen_resolution_preference() { #[test] fn arrange_layouts_floating_windows_and_matching() { let (mut app, ctx) = app_with(3); - assert!(cells("tabs", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).is_none()); + assert!(cells("tabs", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).is_none()); menu(&mut app, &ctx, "window.arrange.threeUpStacked", json!({})).unwrap(); assert_eq!(checked(&app, "window.arrange.threeUpStacked"), Some(true)); - let c = cells(&app.ui.view.arrange, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).unwrap(); + let c = cells(&app.ui.view.arrange, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).unwrap(); assert_eq!(c.len(), 3); assert_eq!(c[0].height(), 600.0); assert_eq!(c[1].height(), 300.0); - let six = cells("sixUp", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).unwrap(); + let six = cells("sixUp", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).unwrap(); assert_eq!((six.len(), six[1].min.x), (6, 300.0)); menu(&mut app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap(); assert_eq!(app.ui.windows.len(), 3); @@ -232,13 +232,109 @@ fn match_zoom_clamps_to_each_documents_zoom_range() { fn two_up_and_stacked_cells_keep_their_existing_boundaries() { let rect = egui::Rect::from_min_max(egui::pos2(40.0, 75.0), egui::pos2(940.0, 675.0)); let region = |left, top, right, bottom| egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); - assert_eq!(cells("twoUpVertical", rect, 2).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 675.0)]); - assert_eq!(cells("twoUpHorizontal", rect, 2).unwrap(), vec![region(40.0, 75.0, 940.0, 375.0), region(40.0, 375.0, 940.0, 675.0)]); + assert_eq!(cells("twoUpVertical", rect, 2, &[]).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 675.0)]); + assert_eq!(cells("twoUpHorizontal", rect, 2, &[]).unwrap(), vec![region(40.0, 75.0, 940.0, 375.0), region(40.0, 375.0, 940.0, 675.0)]); assert_eq!( - cells("threeUpStacked", rect, 3).unwrap(), + cells("threeUpStacked", rect, 3, &[]).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 375.0), region(490.0, 375.0, 940.0, 675.0)] ); - assert!(cells("twoUpVertical", rect, 1).is_none()); + assert!(cells("twoUpVertical", rect, 1, &[]).is_none()); +} + +#[test] +fn dragged_arrange_borders_resize_the_cells_beside_them() { + // #10101: in 2-up and the other arrangements the border between documents drags, as in + // Photoshop, and the documents on either side take the new sizes. + use super::arrange::{Border, MIN_CELL}; + let near = |a: &[egui::Rect], b: &[egui::Rect]| { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.min - b.min).length() < 0.01 && (a.max - b.max).length() < 0.01) + }; + let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(900.0, 600.0)); + let region = |left, top, right, bottom| egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + let mut splits = Vec::new(); + let b = borders("twoUpVertical", rect, 2, &splits); + assert_eq!(b, vec![Border { split: 0, vertical: true, line: region(450.0, 0.0, 450.0, 600.0) }]); + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(300.0, 250.0)); + assert!(near(&cells("twoUpVertical", rect, 2, &splits).unwrap(), &[region(0.0, 0.0, 300.0, 600.0), region(300.0, 0.0, 900.0, 600.0)])); + assert!((borders("twoUpVertical", rect, 2, &splits)[0].line.min.x - 300.0).abs() < 0.01); + // No cell is dragged narrower than MIN_CELL, nor past the area. + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(-500.0, 0.0)); + assert!((cells("twoUpVertical", rect, 2, &splits).unwrap()[0].width() - MIN_CELL).abs() < 0.01); + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(899.0, 0.0)); + assert!((cells("twoUpVertical", rect, 2, &splits).unwrap()[1].width() - MIN_CELL).abs() < 0.01); + + // 4-up: a column border and a row border, each across the whole area. + let mut splits = Vec::new(); + let b = borders("fourUp", rect, 4, &splits); + assert_eq!(b.iter().map(|b| (b.split, b.vertical)).collect::>(), vec![(0, true), (1, false)]); + drag_border("fourUp", rect, 4, &mut splits, 1, egui::pos2(10.0, 450.0)); + let c = cells("fourUp", rect, 4, &splits).unwrap(); + assert!(near(&[c[0], c[3]], &[region(0.0, 0.0, 450.0, 450.0), region(450.0, 450.0, 900.0, 600.0)])); + + // 3-up Stacked: the row border is only across the right column, and follows the column. + let mut splits = Vec::new(); + drag_border("threeUpStacked", rect, 3, &mut splits, 0, egui::pos2(600.0, 0.0)); + drag_border("threeUpStacked", rect, 3, &mut splits, 1, egui::pos2(700.0, 200.0)); + assert!(near( + &cells("threeUpStacked", rect, 3, &splits).unwrap(), + &[region(0.0, 0.0, 600.0, 600.0), region(600.0, 0.0, 900.0, 200.0), region(600.0, 200.0, 900.0, 600.0)] + )); + assert!(near(&[borders("threeUpStacked", rect, 3, &splits)[1].line], &[region(600.0, 200.0, 900.0, 200.0)])); + + // Tiled: a border stops short of its neighbours, so the cells keep their order. + let mut splits = Vec::new(); + drag_border("threeUpVertical", rect, 3, &mut splits, 0, egui::pos2(800.0, 0.0)); + let c = cells("threeUpVertical", rect, 3, &splits).unwrap(); + assert!((c[0].max.x - (600.0 - MIN_CELL)).abs() < 0.01 && (c[1].width() - MIN_CELL).abs() < 0.01); + + // #10101 review: the limit is MIN_CELL itself, not the even spacing. On a 300 pt axis a + // 2-up cell may still be dragged down to 80 pt, where capping the gap at 0.5/(n+1) would + // have stopped it at 75. + let narrow = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(300.0, 200.0)); + let mut splits = Vec::new(); + drag_border("twoUpVertical", narrow, 2, &mut splits, 0, egui::pos2(-100.0, 0.0)); + assert!((cells("twoUpVertical", narrow, 2, &splits).unwrap()[0].width() - MIN_CELL).abs() < 0.01); + + // And when the neighbours leave no room for a cell of that width, the border stays put and + // the list keeps its order rather than taking a value from the wrong side of them. + let tight = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(200.0, 200.0)); + let mut splits = vec![0.45, 0.55]; + drag_border("threeUpVertical", tight, 3, &mut splits, 1, egui::pos2(10.0, 0.0)); + assert_eq!(splits, vec![0.45, 0.55], "no room: unchanged"); + let mut splits = vec![0.45, 0.55]; + drag_border("threeUpVertical", tight, 3, &mut splits, 0, egui::pos2(190.0, 0.0)); + assert_eq!(splits, vec![0.45, 0.55], "and from the other side too"); +} + +#[test] +fn damaged_or_stale_arrange_splits_lay_out_even_cells() { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)); + let even = cells("threeUpVertical", rect, 3, &[]).unwrap(); + // From a settings file or another layout: wrong count, out of order, outside 0–1, NaN. + for bad in [vec![0.5], vec![0.6, 0.3], vec![0.0, 0.5], vec![0.3, 1.5], vec![f32::NAN, 0.7], vec![0.2, 0.4, 0.6]] { + assert_eq!(cells("threeUpVertical", rect, 3, &bad).unwrap(), even, "{bad:?}"); + assert_eq!(borders("threeUpVertical", rect, 3, &bad).len(), 2); + // A drag starts again from even cells. + let mut splits = bad.clone(); + drag_border("threeUpVertical", rect, 3, &mut splits, 1, egui::pos2(700.0, 0.0)); + assert_eq!(cells("threeUpVertical", rect, 3, &splits).unwrap()[0], even[0], "{bad:?}"); + } + // A split that isn't the layout's, or a pointer off the numbers, changes nothing. + let mut splits = vec![0.25, 0.5]; + drag_border("threeUpVertical", rect, 3, &mut splits, 2, egui::pos2(700.0, 0.0)); + drag_border("threeUpVertical", rect, 3, &mut splits, 0, egui::pos2(f32::NAN, 0.0)); + drag_border("threeUpVertical", egui::Rect::NOTHING, 3, &mut splits, 0, egui::pos2(10.0, 0.0)); + assert_eq!(splits, vec![0.25, 0.5]); + assert!(borders("tabs", rect, 3, &[]).is_empty()); +} + +#[test] +fn choosing_an_arrangement_starts_from_even_cells() { + let (mut app, ctx) = app_with(2); + menu(&mut app, &ctx, "window.arrange.twoUpVertical", json!({})).unwrap(); + app.ui.view.arrange_splits = vec![0.3]; + menu(&mut app, &ctx, "window.arrange.twoUpHorizontal", json!({})).unwrap(); + assert!(app.ui.view.arrange_splits.is_empty()); } #[test] @@ -1014,6 +1110,50 @@ fn arranged_documents_keep_their_tiles() { assert_eq!(docs(&mut app, 1), vec![0, 1]); } +/// #10101: dragging the border between two arranged documents moves it. Grabbed inside the +/// other document's edge, it doesn't make that document the active one. +#[test] +fn dragging_the_border_between_arranged_documents_resizes_them() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "twoUpVertical".into(); + app.ui.tool = crate::state::Tool::Brush; + // The right-hand document is active; the grab below is inside the left one's edge. + app.session.set_active(1); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let area = h.state().last_canvas_rect; + let n = h.state().session.documents().len(); + let border = borders("twoUpVertical", area, n, &[])[0].line; + // On the grip, 2 pt inside the left document (its cell ends 1 pt short of the border). + let from = border.center() - egui::vec2(2.0, 0.0); + let to = from - egui::vec2(200.0, 0.0); + let ev = |h: &mut Harness<'_, PhotocraftApp>, e: egui::Event| h.input_mut().events.push(e); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + ev(&mut h, egui::Event::PointerMoved(from)); + h.run(); + ev(&mut h, button(from, true)); + h.run(); + for step in 1..=10 { + ev(&mut h, egui::Event::PointerMoved(from + (to - from) * (step as f32 / 10.0))); + h.run(); + } + ev(&mut h, button(to, false)); + h.run_steps(2); + let cells = cells("twoUpVertical", area, n, &h.state().ui.view.arrange_splits).unwrap(); + assert!((cells[0].max.x - to.x).abs() < 1.0, "the border follows the pointer: {cells:?}, pointer at {}", to.x); + assert_eq!(h.state().session.active_index(), Some(1), "grabbing the border activates no document"); +} + /// #6017: rulers showed in the active tile only; the other tile had none and no room for them. #[test] fn every_arranged_tile_has_rulers() { @@ -1108,3 +1248,52 @@ fn full_screen_modes_need_a_document() { leave_full_screen_without_document(&mut app, &ctx); assert_eq!(app.ui.view.screen_mode, "standard"); } + +/// #10101 review: the grip overlaps the *active* document's edge too, and a press there must +/// drag the border without painting. The cells the canvases are actually drawn in follow. +#[test] +fn grabbing_the_border_inside_the_active_document_drags_it_without_painting() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "twoUpVertical".into(); + app.ui.tool = crate::state::Tool::Brush; + // The right-hand document is active, and the grab below is inside *its* edge. + app.session.set_active(1); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let area = h.state().last_canvas_rect; + let n = h.state().session.documents().len(); + let border = borders("twoUpVertical", area, n, &[])[0].line; + let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().map_or(0, |d| d.history.entries().len()); + let before = steps(&h); + // 2 pt into the right-hand (active) document, where its canvas would otherwise take the press. + let from = border.center() + egui::vec2(2.0, 0.0); + let to = from + egui::vec2(150.0, 0.0); + let ev = |h: &mut Harness<'_, PhotocraftApp>, e: egui::Event| h.input_mut().events.push(e); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + ev(&mut h, egui::Event::PointerMoved(from)); + h.run(); + ev(&mut h, button(from, true)); + h.run(); + for step in 1..=10 { + ev(&mut h, egui::Event::PointerMoved(from + (to - from) * (step as f32 / 10.0))); + h.run(); + } + ev(&mut h, button(to, false)); + h.run_steps(2); + assert_eq!(steps(&h), before, "the press dragged the border and painted nothing"); + assert_eq!(h.state().session.active_index(), Some(1), "the active document stays active"); + // And the cells the documents are drawn in follow the border, not just the stored fractions. + let shown = crate::canvas::arranged_cells(h.state(), area).expect("two documents are arranged"); + assert!((shown[0].1.max.x - to.x).abs() < 2.0, "the left cell ends at the border: {:?}, pointer at {}", shown[0].1, to.x); + assert!((shown[1].1.min.x - to.x).abs() < 2.0, "and the right one starts there: {:?}", shown[1].1); +} diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 568a3e6aa5..68cc7453c7 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -51,10 +51,10 @@ fn drag(app: &mut PhotocraftApp, from: [f64; 2], by: [f64; 2], mods: egui::Modif } #[test] -fn auto_select_is_on_by_default_with_the_group_target() { +fn auto_select_is_on_by_default_with_the_layer_target() { let o = ToolOptions::default(); assert!(o.move_auto_select, "Photoshop ships with Auto-Select checked"); - assert_eq!(o.move_target, "group", "Photoshop defaults Auto-Select to Group (#4056)"); + assert_eq!(o.move_target, "layer", "Reset Tool in Photoshop gives Auto-Select: Layer (#11380)"); // A saved tool preset that predates the option gets the default too. let mut v = serde_json::to_value(&o).unwrap(); v.as_object_mut().unwrap().remove("move_auto_select"); diff --git a/crates/ui-egui/src/move_target_default_tests.rs b/crates/ui-egui/src/move_target_default_tests.rs new file mode 100644 index 0000000000..b22b649ecd --- /dev/null +++ b/crates/ui-egui/src/move_target_default_tests.rs @@ -0,0 +1,75 @@ +//! The Move tool's Auto-Select target (#11380): fresh options and Tool › Reset Tool give Layer, +//! as the reference application's Reset Tool does; the dropdown lists Group, then Layer; a saved +//! choice is kept. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{Tool, ToolOptions}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app +} + +#[test] +fn fresh_options_and_reset_tool_give_auto_select_on_with_the_layer_target() { + let o = ToolOptions::default(); + assert!(o.move_auto_select); + assert_eq!(o.move_target, "layer"); + let mut app = app(); + app.ui.tool_options.move_target = "group".into(); + app.ui.tool_options.move_auto_select = false; + crate::menus::invoke(&mut app, &egui::Context::default(), crate::tools::tool_reset::RESET, json!({})).unwrap(); + assert!(app.ui.tool_options.move_auto_select); + assert_eq!(app.ui.tool_options.move_target, "layer", "Reset Tool gives Layer"); +} + +#[test] +fn a_saved_choice_is_kept_and_only_a_missing_key_gets_layer() { + let mut v = serde_json::to_value(ToolOptions::default()).unwrap(); + v["move_target"] = json!("group"); + let back: ToolOptions = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(back.move_target, "group", "a saved Group stays Group"); + v.as_object_mut().unwrap().remove("move_target"); + let back: ToolOptions = serde_json::from_value(v).unwrap(); + assert_eq!(back.move_target, "layer"); +} + +#[test] +fn the_target_dropdown_lists_group_then_layer() { + let mut h = Harness::builder().with_size(vec2(1600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let combo = h + .get_all_by_role(Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some("Layer") || n.accesskit_node().label().as_deref() == Some("Layer")) + .expect("the Auto-Select target dropdown shows Layer"); + let below = combo.rect().max.y; + combo.click(); + h.run_steps(4); + let item_y = |label: &str| { + h.get_all_by_label(label) + .map(|n| n.rect()) + .filter(|r| r.min.y >= below - 0.5) + .map(|r| r.min.y) + .next() + .unwrap_or_else(|| panic!("a {label} entry in the open list")) + }; + assert!(item_y("Group") < item_y("Layer"), "Group is listed above Layer"); +} diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index ea549885d7..73675c83f6 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -345,6 +345,73 @@ fn stop_step(app: &mut PhotocraftApp, action: &Action, i: usize, p: &Value) -> C Called::Paused(json!({"step": i, "id": id, "dialog": dialog})) } +/// Let the engine handle programmatic playback and stateless items. Interactive Play opens +/// the settings editor and retains its command, document and receiver until confirmation. +fn pause_menu_item(app: &mut PhotocraftApp, action: &str, from: usize, id: &str, params: &Value) -> Result, String> { + use photocraft_engine::atn_cmds::{MENU_ITEM, menu_item_command, menu_item_needs_settings}; + + if id != MENU_ITEM || !playback::is_interactive(app) { + return Ok(None); + } + let command = menu_item_command(params).map_err(|error| error.to_string())?; + if !menu_item_needs_settings(command) { + return Ok(None); + } + let defaults = json!({}); + authorize_owned(app, command, &defaults, app.session.authorize, app.automation_input)?; + app.session.precheck(command, &defaults).map_err(|error| error.to_string())?; + let receiver = app.session.active().and_then(|st| st.active_layer); + let doc = app.session.active().map(|st| st.doc.id); + let dialog = open_step_editor(app, command)?; + let token = app.paused_playbacks.pause(action.to_owned(), from, app.session.authorize); + if let Some(d) = app.ui.dialog_mut(dialog) { + d.fields.insert(RESUME.into(), json!(token)); + } + if let Some(p) = app.paused_playbacks.paused.get_mut(&token) { + p.menu_editor = Some((dialog, command.to_owned())); + p.filter_receiver = Some(receiver); + p.doc = doc; + } + Ok(Some(dialog)) +} + +/// Route playback and Record Again to the same settings editor used by the command. +fn open_step_editor(app: &mut PhotocraftApp, command: &str) -> Result { + if crate::sizing::is_sizing(command) { + crate::sizing::open(app, command).ok_or_else(|| tl!("No document.").into()) + } else if crate::filter_dialog::has_dialog(command) || crate::adjust_dialog::has_dialog(command) { + Ok(crate::dialogs::open_command_dialog(app, command, &label_of(command))) + } else { + crate::filter_dialog::open(app, command).ok_or_else(|| tl!("The step has no command editor.").into()) + } +} + +/// Open the recorded step's editor and retain the receiver for confirmation. +fn pause_recorded_step(app: &mut PhotocraftApp, action: &Action, i: usize, id: &str, p: &Value) -> Value { + let label = label_of(id); + let active_layer = app.session.active().and_then(|st| st.active_layer); + let dialog = crate::dialogs::open_command_dialog(app, id, &label); + let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + let mut receiver = None; + if let Some(d) = app.ui.dialog_mut(dialog) { + if let Some(o) = p.as_object() { + // `__` keys are the dialog's own markers (`params_of` drops them), not step values. + d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); + } + d.fields.insert(RESUME.into(), json!(token)); + // Kept for every implicit step, also one opened on a document target: the dialog + // may switch it to a layer-owned one, and `run_step_dialog` checks the receiver + // for the target it confirms. + if p.get("layer").is_none_or(Value::is_null) { + receiver = Some(active_layer); + } + } + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.filter_receiver = receiver; + } + json!({"step": i, "id": id, "dialog": dialog}) +} + /// `suspended` is the recording a paused part of the same call took ([`PausedPlayback::record`]); it comes /// back, with that named call journaled, only when the whole call ends. `inner` are the called /// actions a resumed playback replays first, outermost first, each `(action, from)`: the first is @@ -373,6 +440,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option, }) .or(suspended); let mut ran = 0u64; + let interactive = playback::is_interactive(app); let (mut failed, mut paused, mut stopped) = (None, None, None); let ended = match replay_inner(app, &action.name, from, inner) { // The call was step `from - 1`: a paced Play waits after it as after any other step. @@ -402,42 +470,37 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option, } // A Stop shows its message or ends the playback (`stop_step`). Agents keep the headless // semantics of the engine command. - if id == photocraft_engine::atn_cmds::STOP_MESSAGE && !app.automation_input { + if id == photocraft_engine::atn_cmds::STOP_MESSAGE && interactive { end_at(stop_step(app, &action, i, p), &mut ran, &mut failed, &mut paused, &mut stopped); break; } + match pause_menu_item(app, &action.name, i + 1, id, p) { + Ok(Some(dialog)) => { + paused = Some(json!({"step": i, "id": id, "dialog": dialog})); + break; + } + Err(error) => { + failed = Some(json!({"step": i, "id": id, "error": error})); + break; + } + Ok(None) => {} + } // The dialog toggle: show the step's dialog with its recorded values and continue // from the next step when it is confirmed (`resume`). Agents play headlessly. - if action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id)) { - let label = label_of(id); - let active_layer = app.session.active().and_then(|st| st.active_layer); - let dialog = crate::dialogs::open_command_dialog(app, id, &label); - let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); - let mut receiver = None; - if let Some(d) = app.ui.dialog_mut(dialog) { - if let Some(o) = p.as_object() { - // `__` keys are the dialog's own markers (`params_of` drops them), not step values. - d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); - } - d.fields.insert(RESUME.into(), json!(token)); - // Kept for every implicit step, also one opened on a document target: the dialog - // may switch it to a layer-owned one, and `run_step_dialog` checks the receiver - // for the target it confirms. - if p.get("layer").is_none_or(Value::is_null) { - receiver = Some(active_layer); - } - } - if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { - paused.filter_receiver = receiver; - } - paused = Some(json!({"step": i, "id": id, "dialog": dialog})); + if action.dialogs.contains(&i) + && interactive + && (crate::filter_dialog::has_dialog(id) + || crate::adjust_dialog::has_dialog(id) + || (id == crate::equalize_ui::COMMAND && crate::equalize_ui::asks(app))) + { + paused = Some(pause_recorded_step(app, &action, i, id, p)); break; } // Check Spelling's dialog opens with Check All Layers as recorded; Done continues the action. // While the dialog is open (for another action or by hand), this step fails and the open // one keeps its place and ignored words. // A correction (`action` set) opens no dialog and runs below. - if action.dialogs.contains(&i) && !app.automation_input && id == "edit.checkSpelling" && p.get("action").is_none() { + if action.dialogs.contains(&i) && interactive && id == "edit.checkSpelling" && p.get("action").is_none() { if let Some(open) = app.ui.type_panels.spell.as_ref() { let error = if open.resume.is_some() { "Check Spelling is already open for another action" } else { "Check Spelling is already open" }; failed = Some(json!({"step": i, "id": id, "error": error})); @@ -681,6 +744,8 @@ fn step_label(id: &str, params: &Value) -> String { let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); let branch = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("–").to_string(); format!("{}: {cond} → {} / {}", label_of(id), branch("then"), branch("else")) + } else if id == photocraft_engine::atn_cmds::MENU_ITEM { + photocraft_engine::atn_cmds::menu_item_command(params).map_or_else(|_| label_of(id), label_of) } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { @@ -1040,7 +1105,9 @@ fn suspended(app: &PhotocraftApp) -> bool { /// action could go away or change place). The engine checks these against `recording`, which /// is `None` then. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!(id, "actions.record" | "actions.delete" | "actions.move") && suspended(app) { + if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate" | "actions.recordAgain" | "actions.allowToolRecording") + && suspended(app) + { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } Ok(()) @@ -1060,6 +1127,22 @@ pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map photocraft_engine::Result<()>; +/// Deferred Actions dialogs retain their caller's gate and also honor current permissions. +fn authorize_owned(app: &PhotocraftApp, id: &str, params: &Value, gate: Option, automated: bool) -> Result<(), String> { + if let Some(gate) = gate { + gate(id, params).map_err(|e| e.to_string())?; + } + if let Some(current) = app.session.authorize { + current(id, params).map_err(|e| e.to_string())?; + } + if (automated || app.automation_input || app.session.authorize.is_some()) + && let Some(check) = app.services.automation_command.as_ref() + { + check(id, params)?; + } + Ok(()) +} + /// A function-pointer dispatch gate cannot carry two different policies into nested jobs. /// Changed gates and distinct simultaneous Session/service gates both end this continuation; /// hosts that need both policies must supply one composed gate at both boundaries. @@ -1097,6 +1180,8 @@ pub(crate) struct PausedPlayback { /// An implicit filter/adjustment step resolves this active layer at the pause. The outer Option marks /// that binding even when there was no active layer; explicit-layer and other dialogs omit it. filter_receiver: Option>, + /// Descriptor-free menu playback owns this settings dialog and the concrete command it opened. + menu_editor: Option<(u64, String)>, /// The actions that called this one through Play Action steps, nearest first, each with the /// step it continues from: confirming the dialog finishes `action`, then each of them in turn /// (at most [`MAX_CALLERS`]). @@ -1150,6 +1235,7 @@ impl PausedPlaybacks { ran: 0, doc: None, filter_receiver: None, + menu_editor: None, callers: Vec::new(), allow_continue: false, timed: false, @@ -1171,6 +1257,37 @@ fn document_target(params: &Value) -> bool { params.get("target").is_some_and(|target| target.as_str() == Some("quickMask") || target.get("channel").and_then(Value::as_u64).is_some()) } +/// Before a preview or confirmation evaluates settings, check the actual editor and its permissions. +pub(crate) fn authorize_panel_preview( + app: &PhotocraftApp, + dialog: u64, + fields: &serde_json::Map, + command: &str, + params: &Value, +) -> Result<(), String> { + record_again::authorize_preview(app, dialog, command, params)?; + if let Some((token, paused)) = app.paused_playbacks.paused.iter().find(|(_, p)| p.menu_editor.as_ref().is_some_and(|(id, _)| *id == dialog)) { + if fields.get(RESUME).and_then(Value::as_u64) != Some(*token) { + return Err("the menu item dialog changed".into()); + } + check_menu_editor(app, paused, command, params)?; + authorize_owned(app, command, params, paused.gate, paused.gate.is_some())?; + } + Ok(()) +} + +/// Check that preview and confirmation still target the command and receiver that opened the editor. +fn check_menu_editor(app: &PhotocraftApp, paused: &PausedPlayback, command: &str, params: &Value) -> Result<(), String> { + if let Some((_, original)) = &paused.menu_editor + && (original != command + || app.session.active().map(|st| st.doc.id) != paused.doc + || (!document_target(params) && paused.filter_receiver != Some(app.session.active().and_then(|st| st.active_layer)))) + { + return Err("the menu item command or its document changed".into()); + } + Ok(()) +} + /// Admit the final dialog command before resuming history or starting a worker. The captured /// policy judges the edited parameters; a current live gate still governs dispatch and nested /// work. With no live gate, reinstall the captured gate for job admission and rendering. @@ -1178,6 +1295,7 @@ pub(crate) fn run_step_dialog(app: &mut PhotocraftApp, fields: &serde_json::Map< let Some(token) = fields.get(RESUME) else { return app.run(command, params) }; let token = token.as_u64().ok_or("this dialog doesn't belong to a paused action")?; let paused = app.paused_playbacks.paused.get(&token).ok_or("this dialog doesn't belong to a paused action")?; + check_menu_editor(app, paused, command, ¶ms)?; if let Some(receiver) = paused.filter_receiver && !document_target(¶ms) && app.session.active().and_then(|st| st.active_layer) != receiver @@ -1418,6 +1536,14 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.button(tl!("New Set…")).clicked() { act = Some("newSet"); } + let can_duplicate_set = + !recording && app.ui.actions.selected_set.as_ref().is_some_and(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set)); + if ui.add_enabled(can_delete || can_duplicate_set, egui::Button::new(tl!("Duplicate"))).clicked() { + act = Some("duplicate"); + } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { + act = Some("delete"); + } if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() { act = Some("play"); } @@ -1428,18 +1554,30 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if ui.button(tl!("Start Recording")).clicked() { act = Some("record"); } - if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { - act = Some("delete"); + if ui.add_enabled(selected && app.ui.actions.selected_step.is_some() && !recording, egui::Button::new(tl!("Record Again"))).clicked() { + act = Some("recordAgain"); } ui.separator(); + if ui.add_enabled(selected, egui::Button::new(tl!("Insert Menu Item…"))).clicked() { + act = Some("insertMenu"); + } if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { act = Some("stop"); } + let path_selected = + app.ui.selected_path.as_deref().is_some_and(|name| photocraft_engine::actions_edit_cmds::selected_recording_path(&app.session, name).is_ok()); + if ui.add_enabled(selected && path_selected, egui::Button::new(tl!("Insert Path"))).clicked() { + act = Some("insertPath"); + } ui.separator(); // Enabled with nothing selected, as in the reference application; not while a paced Play waits. if ui.add_enabled(!playback::pending(app), egui::Button::new(tl!("Playback Options…"))).clicked() { act = Some("playbackOptions"); } + let mut tools = app.session.actions.record_tools; + if crate::widgets::checkbox(ui, &mut tools, tl!("Allow Tool Recording")).changed() { + act = Some("toolRecording"); + } ui.separator(); if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); @@ -1474,6 +1612,14 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { delete_selection(app); Ok(Value::Null) } + "duplicate" => duplicate_selection(app), + "recordAgain" => record_again::open(app), + "toolRecording" => app.run("actions.allowToolRecording", json!({"enabled": tools})), + "insertPath" => app.run( + "actions.insertPath", + json!({"action": app.ui.actions.selected, "at": app.ui.actions.selected_step.map(|i| i.saturating_add(1)), "name": app.ui.selected_path}), + ), + "insertMenu" => menu_item::open(app), "load" => load_dialog(app), "newSet" => new_set_dialog(app), "stop" => insert_stop_dialog(app), @@ -1537,6 +1683,47 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) { } } +/// Keep the selection and disclosure rows on the new copy after the engine edits the list. +fn duplicate_selection(app: &mut PhotocraftApp) -> Result { + if app.ui.actions.selected.is_none() + && let Some(set) = app.ui.actions.selected_set.clone() + { + let count = app.session.actions.list.len(); + let result = app.run("actions.duplicate", json!({"set": set}))?; + if let (Some(at), Some(added)) = (result["index"].as_u64(), result["actions"].as_u64()) + && let (Ok(at), Ok(added)) = (usize::try_from(at), usize::try_from(added)) + && at <= count + { + app.ui.actions.expanded.resize(count, false); + app.ui.actions.expanded.splice(at..at, std::iter::repeat_n(false, added)); + } + app.ui.actions.selected_set = result["set"].as_str().map(str::to_owned); + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + return Ok(result); + } + let action = app.ui.actions.selected.ok_or_else(|| tl!("Select an action first").to_string())?; + let step = app.ui.actions.selected_step; + let count = app.session.actions.list.len(); + let expanded = app.ui.actions.expanded.get(action).copied().unwrap_or(false); + let result = app.run("actions.duplicate", json!({"action": action, "step": step}))?; + let index = result["index"].as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("the copy has no action index")?; + app.ui.actions.selected = Some(index); + app.ui.actions.selected_step = result.get("step").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + if step.is_none() { + app.ui.actions.expanded.resize(count, false); + if index <= app.ui.actions.expanded.len() { + app.ui.actions.expanded.insert(index, expanded); + } + } else { + app.ui.actions.expanded.resize(count, false); + if let Some(expanded) = app.ui.actions.expanded.get_mut(index) { + *expanded = true; + } + } + Ok(result) +} + fn delete_selection(app: &mut PhotocraftApp) { let Some(action) = app.ui.actions.selected else { return }; let step = app.ui.actions.selected_step; @@ -2094,7 +2281,9 @@ fn draw_step_controls(ui: &mut egui::Ui, rect: Rect, step: usize, label: &str, c (check.on_hover_text(tl!("Include in playback")), dialog.on_hover_text(tl!("Show dialog during playback"))) } +pub(crate) mod menu_item; pub(crate) mod playback; +pub(crate) mod record_again; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/actions/menu_item.rs b/crates/ui-egui/src/panels/actions/menu_item.rs new file mode 100644 index 0000000000..d18a5edeb3 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/menu_item.rs @@ -0,0 +1,87 @@ +//! Choose an existing mapped menu event without borrowing current command settings. +use crate::{PhotocraftApp, state::DialogKind}; +use serde_json::{Map, Value, json}; +const COMMAND: &str = "actions.insertMenuItem"; +const MARK: &str = "__insertMenuItem"; + +/// Keep a chooser tied to its original action, library and caller. +pub(crate) struct Held { + dialog: u64, + revision: u64, + target: Value, + gate: Option, + automated: bool, +} + +/// Register the chooser with the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Whether this dialog id still owns a deferred panel operation. +pub(crate) fn owns_dialog(app: &PhotocraftApp, dialog: u64) -> bool { + app.held_insert_menu.as_ref().is_some_and(|held| held.dialog == dialog) +} + +/// Open without recording a step or sampling the selected command's current settings. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + if app.held_insert_menu.is_some() { + return Err(tl!("Confirm or cancel the pending menu item first.").into()); + } + let action = app.ui.actions.selected.filter(|i| *i < app.session.actions.list.len()).ok_or(tl!("Select an action first."))?; + let target = json!({"action":action,"at":app.ui.actions.selected_step.map(|i|i.saturating_add(1))}); + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let automated = app.automation_input || gate.is_some(); + let fields = json!({MARK:true,"__command":COMMAND,"__label":"Insert Menu Item","id":"image.imageSize"}); + let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + app.held_insert_menu = Some(Held { dialog, revision: app.session.actions.rev, target, gate, automated }); + Ok(json!({"dialog":dialog})) +} + +/// List only menu identities the existing ATN event table can encode unambiguously. +pub(crate) fn body(ui: &mut egui::Ui, fields: &mut Map) { + ui.label(tl!("Choose a menu command. Playback asks for its settings when needed.")); + let mut id = fields.get("id").and_then(Value::as_str).unwrap_or("image.imageSize").to_string(); + craft_ui::ComboBox::from_id_salt("actions-insert-menu").selected_text(super::label_of(&id)).show_ui(ui, |ui| { + for command in photocraft_engine::atn_cmds::menu_item_commands() { + ui.selectable_value(&mut id, command.to_string(), super::label_of(command)); + } + }); + fields.insert("id".into(), json!(id)); +} + +/// Consent inserts only into the captured action and uses both retained and current authority. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_insert_menu.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No menu item belongs to this chooser.").into()); + } + let held = app.held_insert_menu.take().ok_or(tl!("No menu item is waiting."))?; + if held.revision != app.session.actions.rev || fields.get("__command").and_then(Value::as_str) != Some(COMMAND) { + return Err(tl!("The action or insertion command changed. Request Insert Menu Item again.").into()); + } + let mut params = held.target; + let action = params["action"].as_u64().and_then(|index| usize::try_from(index).ok()); + params["id"] = fields.get("id").cloned().unwrap_or(Value::Null); + super::authorize_owned(app, COMMAND, ¶ms, held.gate, held.automated)?; + let saved = app.session.authorize; + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Insert Menu Item again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, params); + app.session.authorize = saved; + let result = result?; + app.ui.actions.selected = action; + app.ui.actions.selected_set = None; + app.ui.actions.selected_step = result.get("step").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + Ok(result) +} + +/// Only its own Cancel may release the chooser's target. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_insert_menu.as_ref().is_some_and(|h| h.dialog == dialog) { + app.held_insert_menu = None; + } +} diff --git a/crates/ui-egui/src/panels/actions/playback.rs b/crates/ui-egui/src/panels/actions/playback.rs index da4401ebdb..340906a060 100644 --- a/crates/ui-egui/src/panels/actions/playback.rs +++ b/crates/ui-egui/src/panels/actions/playback.rs @@ -5,8 +5,7 @@ //! action like a step dialog does (a [`super::PausedPlayback`] token holding the suspended history and //! recording), and [`tick`] resumes it once the wait is over, so the steps share one history state //! and the canvas redraws between them. Esc, the panel's Stop button, an edit of the action list or a -//! new recording end it there, keeping the steps already applied. Agents (`automation_input`) and -//! headless playback never wait. +//! new recording end it there, keeping the steps already applied. Programmatic playback never waits. use serde_json::{Map, Value, json}; use web_time::{Duration, Instant}; @@ -34,10 +33,15 @@ pub(crate) struct Timer { rev: u64, } +/// Native Play and control-channel menu clicks may open editors and wait between steps. +pub(super) fn is_interactive(app: &PhotocraftApp) -> bool { + !app.automation_input && !app.programmatic_playback +} + /// How long a person's Play waits after a step, or `None` when it goes straight on (Accelerated, /// and always for agents). pub(super) fn step_pause(app: &PhotocraftApp) -> Option { - if app.automation_input { + if !is_interactive(app) { return None; } let a = &app.session.prefs().actions; diff --git a/crates/ui-egui/src/panels/actions/record_again.rs b/crates/ui-egui/src/panels/actions/record_again.rs new file mode 100644 index 0000000000..d5eda23863 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/record_again.rs @@ -0,0 +1,133 @@ +//! One editor owns one original step; previews and confirmation retain its authority. +use photocraft_engine::{ + actions_edit_cmds, + doc::{DocId, LayerId}, +}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; + +const COMMAND: &str = "actions.recordAgain"; +const MARK: &str = "__recordAgain"; + +/// Selection, document and authority are private rather than editable dialog parameters. +pub(crate) struct Held { + dialog: u64, + selection: Value, + command: String, + document: Option<(DocId, u64, Option)>, + gate: Option, + automated: bool, +} + +/// The marker uses the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Whether this dialog id still owns a deferred panel operation. +pub(crate) fn owns_dialog(app: &PhotocraftApp, dialog: u64) -> bool { + app.held_record_again.as_ref().is_some_and(|held| held.dialog == dialog) +} + +/// Apply captured and current permissions to the actual operation and final settings. +fn authorize(app: &PhotocraftApp, held: &Held, id: &str, params: &Value) -> Result<(), String> { + super::authorize_owned(app, id, params, held.gate, held.automated) +} + +/// Refuse stale selection or a different document before preview or confirmation can edit it. +fn unchanged(app: &PhotocraftApp, held: &Held) -> Result<(), String> { + super::suspended_guard(app, COMMAND)?; + if held.selection.get("revision").and_then(Value::as_u64) != Some(app.session.actions.rev) + || app.session.actions.recording.is_some() + || app.session.actions.playing > 0 + { + return Err(tl!("The actions changed. Request Record Again again.").into()); + } + if app.session.active().map(|s| (s.doc.id, s.revision, s.active_layer)) != held.document { + return Err(tl!("The document changed. Request Record Again again.").into()); + } + Ok(()) +} + +/// Open the existing command editor and seed its controls from this step's recorded values. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + if app.held_record_again.is_some() { + return Err(tl!("Confirm or cancel the pending Record Again editor first.").into()); + } + let action = app.ui.actions.selected.ok_or(tl!("Select an action step first."))?; + let step = app.ui.actions.selected_step.ok_or(tl!("Select an action step first."))?; + let selection = json!({"action": action, "step": step, "revision": app.session.actions.rev}); + let target = actions_edit_cmds::record_again_target(&app.session, &selection).map_err(|e| e.to_string())?; + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let mut held = Held { + dialog: 0, + selection, + command: target.command, + document: app.session.active().map(|s| (s.doc.id, s.revision, s.active_layer)), + gate, + automated: app.automation_input || gate.is_some(), + }; + unchanged(app, &held)?; + authorize(app, &held, COMMAND, &held.selection)?; + authorize(app, &held, &held.command, &target.params)?; + let resolved = photocraft_engine::actions_cmds::playback_params(&app.session, &held.command, &target.params).map_err(|e| e.to_string())?; + app.session.precheck(&held.command, &resolved).map_err(|e| e.to_string())?; + let dialog = super::open_step_editor(app, &held.command)?; + let fields = &mut app.ui.dialog_mut(dialog).ok_or(tl!("The step editor could not be opened."))?.fields; + if let Some(params) = target.params.as_object() { + fields.extend(params.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); + } + fields.insert(MARK.into(), json!(true)); + held.dialog = dialog; + app.held_record_again = Some(held); + Ok(json!({"dialog": dialog})) +} + +/// Guard both actual preview routes before parsing LUT files or evaluating a command. +pub(crate) fn authorize_preview(app: &PhotocraftApp, dialog: u64, command: &str, params: &Value) -> Result<(), String> { + let Some(held) = app.held_record_again.as_ref().filter(|h| h.dialog == dialog) else { return Ok(()) }; + unchanged(app, held)?; + if command != held.command { + return Err(tl!("The Record Again command changed.").into()); + } + authorize(app, held, command, params) +} + +/// Apply through the registered engine command, retaining flags and replacing only after success. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_record_again.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No recorded step belongs to this editor.").into()); + } + let held = app.held_record_again.take().ok_or(tl!("No step is waiting."))?; + unchanged(app, &held)?; + if fields.get("__command").and_then(Value::as_str) != Some(held.command.as_str()) + || fields.get("__adjust").and_then(Value::as_str).is_some_and(|kind| format!("image.adjustments.{kind}") != held.command) + { + return Err(tl!("The Record Again command changed.").into()); + } + let params = crate::filter_dialog::params_of(fields); + let mut request = held.selection.clone(); + request["params"] = params.clone(); + authorize(app, &held, COMMAND, &request)?; + authorize(app, &held, &held.command, ¶ms)?; + let saved = app.session.authorize; + // Different policy functions must not replace the original caller's nested-command gate. + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Record Again again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, request); + app.session.authorize = saved; + app.filter_preview = None; + result +} + +/// A different dialog cannot discard this editor's selection or authority. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_record_again.as_ref().is_some_and(|held| held.dialog == dialog) { + app.held_record_again = None; + } +} diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index e87d38b8b4..d9711a4fc2 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -2036,6 +2036,37 @@ fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { assert!(v.get("paused").is_none()); } +#[test] +fn equalize_step_with_a_selection_opens_its_choices() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 8})).unwrap(); + app.run("edit.fill", json!({"color": [0.4, 0.4, 0.4]})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 8, "width": 16, "height": 8})).unwrap(); + app.run("edit.fill", json!({"color": [0.5, 0.5, 0.5]})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); + let mut action = Action::new("Equalize", vec![(crate::equalize_ui::COMMAND.into(), json!({}))]); + action.dialogs.insert(0); + app.session.actions.list.push(action); + let gray = |app: &PhotocraftApp, x: i32, y: i32| { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[0] + }; + + let result = play(&mut app, &json!({"action": "Equalize"})).unwrap(); + let id = result["paused"]["dialog"].as_u64().unwrap(); + let fields = &app.ui.dialog_mut(id).unwrap().fields; + assert!(crate::equalize_ui::owns(fields)); + assert_eq!(fields["selectedAreaOnly"], true); + assert!((gray(&app, 12, 1) - 0.4).abs() < 0.01, "nothing changes before OK"); + + // OK with the entire image chosen maps the pixels outside the selection too. + app.ui.dialog_mut(id).unwrap().fields.insert("selectedAreaOnly".into(), json!(false)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(gray(&app, 12, 1) < 0.05 && gray(&app, 12, 12) > 0.95, "outside the selection too"); +} + /// An 8×6 document, a Child action whose first step (a blur) shows its dialog and whose /// second step adds a "Suffix" layer, and recording into Owner started. fn recording_with_dialog_child() -> PhotocraftApp { diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index ee83682dcd..ddd45f38ba 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -36,21 +36,27 @@ pub(super) fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraf } /// Options-bar Smoothing % (the brush's stroke smoothing; the live stroke and the commit use it). -pub(super) fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) { +pub(super) fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) -> egui::Response { let mut sm = (b.smoothing.amount * 100.0).round(); - if widgets::value_field_named(ui, tl!("Smoothing"), &mut sm, 0.0..=100.0, "%", width).changed() { + let r = widgets::value_field_named(ui, tl!("Smoothing"), &mut sm, 0.0..=100.0, "%", width); + if r.changed() { b.smoothing.enabled = true; b.smoothing.amount = (sm / 100.0).clamp(0.0, 1.0); } + r } /// Options-bar gear beside Smoothing: Photoshop's smoothing options popup, edited on the brush /// like the Smoothing % (so they stay per tool and reach the live stroke and the commit). -pub(super) fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings) { +/// `brush` adds the Brush's "Smoothing Options" title (#11813); the Eraser keeps its popup as it was. +pub(super) fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, brush: bool) { let resp = icons::button(ui, "settings", 24.0, false, tl!("Set additional smoothing options")); let resp = crate::brush_picker::named(resp, "Set additional smoothing options"); egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let s = &mut b.smoothing; + if brush { + ui.label(egui::RichText::new(tl!("Smoothing Options")).strong()); + } widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode")); widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up")); widgets::checkbox(ui, &mut s.catch_up_on_end, tl!("Catch-up on Stroke End")); @@ -119,7 +125,13 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Brush Preset picker"))); - if resp.on_hover_text(tl!("Brush Preset picker")).clicked() { + // The Brush's and the Background Eraser's own text (#11813, #11909); the other tools keep theirs. + let tip = if matches!(state.tool, crate::state::Tool::Brush | crate::state::Tool::BackgroundEraser) { + tl!("Click to open the Brush Preset picker") + } else { + tl!("Brush Preset picker") + }; + if resp.on_hover_text(tip).clicked() { if state.brush_picker.is_some() { crate::brush_picker::close(state); } else { diff --git a/crates/ui-egui/src/panels/brush_panel/tests.rs b/crates/ui-egui/src/panels/brush_panel/tests.rs index e0e15cb4f5..164443600c 100644 --- a/crates/ui-egui/src/panels/brush_panel/tests.rs +++ b/crates/ui-egui/src/panels/brush_panel/tests.rs @@ -1876,3 +1876,48 @@ fn checkboxes_show_a_pressed_state_from_their_label() { assert!(*h.state(), "the release checks it"); assert_eq!(fill(&h), Some(widgets::check_box_fill(true, false, &t))); } + +#[test] +fn tip_geometry_edits_and_arrow_drag_route_only_changed_fields_through_the_brush_command() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + fn centre(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(c) if c.radius == 40.0 => Some(c.center), + egui::Shape::Vec(shapes) => shapes.iter().find_map(centre), + _ => None, + } + } + let mut h = harness(0, 0); + h.state_mut().run("tools.setBrush", json!({"brush": {"angle": -23.0, "roundness": 0.473, "hardness": 0.65, "spacing": 0.31, "flipX": true}})).unwrap(); + h.run_steps(3); + let mut expected = h.state().session.tools.brush.clone(); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected.angle = -22.0; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"angle": -22.0_f32})); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected.roundness = 0.48; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"roundness": 0.48_f32})); + let c = h.output().shapes.iter().find_map(|shape| centre(&shape.shape)).unwrap(); + let a = expected.angle.to_radians(); + h.drag_at(c + vec2(a.cos(), -a.sin()) * 35.0); + h.run_steps(1); + h.hover_at(c + vec2(0.0, -35.0)); + h.run_steps(2); + h.drop_at(c + vec2(0.0, -35.0)); + h.run_steps(2); + expected.angle = 90.0; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"angle": 90.0_f32})); + assert_eq!(h.state().session.journal.len(), journal + 3, "one command per field edit and arrow gesture"); +} diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index da96b7474a..7026f83352 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -325,26 +325,31 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], }); ui.add_space(4.0); ui.horizontal(|ui| { - ellipse_widget(ui, &mut b.angle, &mut b.roundness); - ui.add_space(8.0); - ui.vertical(|ui| { + // Reserve the widget's column so both numeric rows share the same right edge. + let fields_width = (ui.available_width() - 84.0 - ui.spacing().item_spacing.x).max(0.0); + ui.allocate_ui_with_layout(vec2(fields_width, 84.0), egui::Layout::top_down(egui::Align::Max), |ui| { ui.add_space(10.0); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); - let mut a = b.angle; - if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() { - b.angle = a.round().clamp(-180.0, 180.0); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut a = b.angle; + if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() { + b.angle = a.round().clamp(-180.0, 180.0); + } + ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); + }); }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); - let mut r = (b.roundness * 100.0).round(); - if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).accessible_name(tl!("Roundness:")).changed() { - b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut r = (b.roundness * 100.0).round(); + if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).accessible_name(tl!("Roundness:")).changed() { + b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); + } + ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); + }); }); }); + ellipse_widget(ui, &mut b.angle, &mut b.roundness); }); ui.add_space(6.0); ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| { diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index b9a221f04e..abed333e02 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -25,8 +25,12 @@ fn minimum_brush() -> BrushSettings { } fn section_harness(section: usize, b: BrushSettings) -> Harness<'static, BrushSettings> { + section_harness_width(section, 340.0, b) +} + +fn section_harness_width(section: usize, width: f32, b: BrushSettings) -> Harness<'static, BrushSettings> { let mut ready = false; - let mut h = Harness::builder().with_size(vec2(340.0, 1100.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(width, 1100.0)).build_ui_state( move |ui, b: &mut BrushSettings| { if !ready { crate::PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); @@ -335,3 +339,100 @@ fn ellipse_widget_drags_angle_and_roundness() { let (_, r) = ellipse_drag(EllipseGrab::Roundness, vec2(500.0, 0.0), 90.0, 0.2); assert_eq!(r, 1.0); } + +fn tip_geometry_center(h: &Harness<'static, BrushSettings>) -> egui::Pos2 { + fn centre(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(c) if c.radius == 40.0 => Some(c.center), + egui::Shape::Vec(shapes) => shapes.iter().find_map(centre), + _ => None, + } + } + h.output().shapes.iter().find_map(|shape| centre(&shape.shape)).unwrap() +} + +#[test] +fn tip_geometry_fields_precede_the_widget_and_labels_align_in_all_themes() { + for kind in theme::ThemeKind::ALL { + for width in [280.0, 340.0] { + let b = BrushSettings { angle: -23.0, roundness: 0.473, ..Default::default() }; + let mut h = section_harness_width(0, width, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let centre = tip_geometry_center(&h); + let angle = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap().rect(); + let roundness = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap().rect(); + let angle_label = caption_rect(&h, "Angle:"); + let roundness_label = caption_rect(&h, "Roundness:"); + assert!(angle.right() + 2.0 < centre.x - 40.0 && roundness.right() + 2.0 < centre.x - 40.0, "{kind:?}, {width}: fields belong left of the widget"); + assert!((angle.left() - roundness.left()).abs() < 0.5, "{kind:?}, {width}: one numeric column"); + assert!((angle_label.right() - roundness_label.right()).abs() < 0.5, "{kind:?}, {width}: labels align to the right"); + assert!((angle_label.center().y - angle.center().y).abs() < 2.0 && (roundness_label.center().y - roundness.center().y).abs() < 2.0); + assert_eq!(h.state(), &b, "layout does not round imported values"); + } + } +} + +#[test] +fn tip_geometry_numeric_edits_keep_units_limits_and_other_settings() { + let mut b = BrushSettings { angle: -23.0, roundness: 0.473, hardness: 0.65, spacing: 0.31, flip_x: true, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + let angle = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap(); + angle.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + b.angle = -22.0; + assert_eq!(h.state(), &b); + let roundness = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap(); + roundness.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + b.roundness = 0.48; + assert_eq!(h.state(), &b, "percentage edits retain degrees and the rest of the brush"); + for (angle, key) in [(-180.0, egui::Key::ArrowDown), (180.0, egui::Key::ArrowUp)] { + b.angle = angle; + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(f64::from(angle))).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "angle stays within -180..180 degrees"); + } + for (value, key) in [(1.0, egui::Key::ArrowDown), (100.0, egui::Key::ArrowUp)] { + b.roundness = value / 100.0; + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(f64::from(value))).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "roundness stays within 1..100 percent"); + } +} + +#[test] +fn tip_geometry_pointer_drags_keep_angle_and_roundness_independent() { + let mut b = BrushSettings { roundness: 0.5, flip_x: true, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + let c = tip_geometry_center(&h); + drag(&mut h, c + vec2(35.0, 0.0), c + vec2(0.0, -35.0)); + b.angle = 90.0; + assert_eq!(h.state(), &b, "the arrow changes only the angle"); + let handle = c + roundness_handles(b.angle, b.roundness)[0]; + // Acquire the dot before moving beyond its hit radius. + h.hover_at(handle); + h.run_steps(2); + h.drag_at(handle); + h.run_steps(1); + h.hover_at(handle + vec2(-6.5, 0.0)); + h.run_steps(2); + h.hover_at(c + vec2(-ELLIPSE_R * 0.8, 0.0)); + h.run_steps(2); + h.drop_at(c + vec2(-ELLIPSE_R * 0.8, 0.0)); + h.run_steps(2); + b.roundness = 0.8; + assert!((h.state().roundness - b.roundness).abs() < 1e-5, "a real handle drag keeps percentage units"); + b.roundness = h.state().roundness; + assert_eq!(h.state(), &b, "the dot changes only the roundness"); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index 1c439872a3..ba7898bbec 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -323,10 +323,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } } - FilterBy::Name => { - let width = ui.available_width().max(48.0); - widgets::text_field(ui, ui.scope_id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); - } + FilterBy::Name => find::name_field(app, ui), FilterBy::Effect => effect::controls(app, ui), FilterBy::Mode => { // The 27 layer modes, without Pass Through: a group set to Pass Through is never @@ -369,6 +366,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } pub mod effect; +pub mod find; mod group; pub mod selection; @@ -380,6 +378,8 @@ pub mod attribute; #[cfg(test)] mod attribute_tests; #[cfg(test)] +mod find_tests; +#[cfg(test)] mod selection_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find.rs b/crates/ui-egui/src/panels/layer_filter_ui/find.rs new file mode 100644 index 0000000000..ee437906cf --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/find.rs @@ -0,0 +1,80 @@ +//! #10884: Select › Find Layers (⌥⇧⌘F) filters the Layers panel by Name, as in Photoshop: it shows +//! the Layers panel, switches the filter to Name and puts the caret in its field with the text +//! selected, so typing replaces it. The layer selection is not changed. With a `name` param +//! (scripts and agents) it sets the Name text to it instead of focusing the field. + +use serde_json::{Value, json}; + +use super::FilterBy; +use crate::{PhotocraftApp, widgets}; + +/// The egui memory flag: focus the Name field the next time it is drawn. +fn focus_flag() -> egui::Id { + egui::Id::unique("layer-filter-find-focus") +} + +/// Run `select.findLayers`: the engine checks `params` and lists the layers the Name text matches; +/// then the Name filter is applied. Without a `name` the text is kept, and the Layers panel is +/// shown with the field focused. +pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> Result { + let given = params.get("name").is_some_and(|n| !n.is_null()); + // `run` keeps the Name text when no `name` is given ([`params_for_run`]) and applies the + // reply's Name filter ([`apply`]). + let reply = app.run("select.findLayers", params)?; + if !given { + // The panel docking layout (when on) shows the Layers panel itself, else the dock group. + if app.ui.docking.is_some() { + if let Some(shown) = crate::panel_docking::command(app, "window.panel.activate", &json!({"panel": "layers"})) { + shown?; + } + } else { + *crate::dock::Group::Layers.tab_mut(&mut app.ui.dock_tabs) = 0; + crate::dock::reveal(app, crate::dock::Group::Layers); + } + // A hidden filter row (Filter Options › Hide) stays hidden, as in Photoshop, and showing it + // later doesn't put the caret in the field: only a drawn row takes the focus. + if app.ui.layers_panel_options.show_filters { + ctx.data_mut(|d| d.insert_temp(focus_flag(), true)); + } + } + Ok(reply) +} + +/// The params `select.findLayers` runs with: without a `name` the panel's Name text is kept, so the +/// reply lists the layers the panel lists however the command runs (the menu, the shortcut, +/// `engine.execute` or an action step). +pub(crate) fn params_for_run(app: &PhotocraftApp, params: Value) -> Value { + if params.get("name").is_some_and(|n| !n.is_null()) { + return params; + } + let mut params = if params.is_object() { params } else { json!({}) }; + params["name"] = json!(app.ui.layer_filter_name); + params +} + +/// Apply the Name filter in a `select.findLayers` reply to the Layers panel: switch to Name and set +/// the text when the reply has one. [`PhotocraftApp::run`] calls it after every Find Layers. +pub(crate) fn apply(app: &mut PhotocraftApp, reply: &Value) { + app.ui.layer_filter_by = FilterBy::Name; + if let Some(name) = reply["layerFilter"]["name"].as_str() { + app.ui.layer_filter_name = name.to_string(); + } +} + +/// The Name filter's text field, taking the focus with its text selected after [`invoke`]. The +/// focus waits for a frame without a pointer press or release: egui drops the focus at the end of +/// a frame in which the pointer was clicked elsewhere, as it is when the menu item is clicked. +pub fn name_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let id = ui.scope_id().with("layer-filter-name"); + let pending = ui.ctx().data(|d| d.get_temp::(focus_flag())).unwrap_or(false); + let clicking = ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released() || i.pointer.any_down()); + if pending && clicking { + ui.ctx().request_repaint(); + } else if pending { + ui.ctx().data_mut(|d| d.remove_temp::(focus_flag())); + ui.memory_mut(|m| m.request_focus(id)); + widgets::field_tab::select_all(ui.ctx(), id, &app.ui.layer_filter_name); + } + let width = ui.available_width().max(48.0); + widgets::text_field(ui, id, &mut app.ui.layer_filter_name, tl!("Search"), width, false); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs new file mode 100644 index 0000000000..8a7a00d854 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs @@ -0,0 +1,192 @@ +//! #10884: Select › Find Layers opens the Layers panel's Name filter, against the cases measured in +//! Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; + +/// "LinkA", "Other" and "LinkB" over the Background, "Other" selected. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + for name in ["LinkA", "Other", "LinkB"] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + } + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + let other = id(&h, "Other"); + h.state_mut().session.execute("layer.select", json!({"layer": other.0})).unwrap(); + h.run_steps(4); + h +} + +fn id(h: &Harness<'_, PhotocraftApp>, name: &str) -> LayerId { + h.state() + .session + .active() + .unwrap() + .doc + .walk() + .into_iter() + .find(|(_, _, l)| l.name == name) + .map(|(_, _, l)| l.id) + .unwrap_or_else(|| panic!("no layer {name}")) +} + +fn selected(h: &Harness<'_, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().selected_layers() +} + +/// The names the panel's filter lists, top to bottom. +fn listed(h: &Harness<'_, PhotocraftApp>) -> Vec { + let f = Filter::of_app(h.state()); + let doc = &h.state().session.active().unwrap().doc; + crate::layer_tree_ui::display_rows(doc, f.active()).into_iter().filter(|(_, l)| f.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn find(h: &mut Harness<'_, PhotocraftApp>, params: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + let reply = crate::menus::invoke(h.state_mut(), &ctx, "select.findLayers", params).unwrap(); + h.run_steps(4); + reply +} + +#[test] +fn the_menu_item_shows_the_layers_panel_and_focuses_the_name_field() { + let mut h = harness(); + h.state_mut().ui.panels.layers = false; + h.state_mut().ui.layer_filter_name = "oth".into(); + h.run_steps(4); + let before = selected(&h); + find(&mut h, json!({})); + assert_eq!(selected(&h), before, "Find Layers does not change the layer selection"); + assert!(h.state().ui.panels.layers, "the Layers panel is shown"); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); + // The existing text is selected, so typing replaces it. + h.event(egui::Event::Text("lin".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "lin"); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [], "the typed filter hides \"Other\", which is deselected (#10879); no match is selected"); +} + +#[test] +fn a_name_param_sets_the_filter_text_without_changing_the_selection() { + let mut h = harness(); + let link_a = id(&h, "LinkA"); + h.state_mut().session.execute("layer.select", json!({"layer": link_a.0})).unwrap(); + h.run_steps(4); + let reply = find(&mut h, json!({"name": "link"})); + assert_eq!((h.state().ui.layer_filter_by, h.state().ui.layer_filter_name.as_str()), (FilterBy::Name, "link")); + assert_eq!(reply["selected"], json!([id(&h, "LinkA").0, id(&h, "LinkB").0]), "the reply lists the matching layers"); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [link_a], "LinkA stays selected; LinkB, also listed, isn't selected"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "a script does not take the keyboard focus"); +} + +#[test] +fn the_shortcut_runs_find_layers() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT, egui::Key::F); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); +} + +#[test] +fn clicking_the_menu_item_leaves_the_caret_in_the_name_field() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + h.get_by_label("Select").click(); + h.run_steps(4); + h.get_by_label("Find Layers Ctrl+Alt+Shift+F").click(); + h.run_steps(6); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + h.event(egui::Event::Text("lin".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "lin", "typing goes to the Name field"); +} + +#[test] +fn the_shortcut_shows_a_layers_tab_closed_from_the_window_menu() { + let mut h = harness(); + use egui_kittest::kittest::Queryable; + h.get_by_label("Window").click(); + h.run_steps(4); + h.get_by_label("✔ Layers F7").click(); + h.run_steps(4); + assert!(!h.state().ui.panels.layers, "Window › Layers closed the panel"); + h.state_mut().ui.layer_filter_name = "lin".into(); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT, egui::Key::F); + h.run_steps(6); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + h.event(egui::Event::Text("other".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "other", "the panel is back, focused, with its text selected"); +} + +#[test] +fn the_menu_item_shows_a_layers_panel_closed_in_the_docking_layout() { + let mut h = harness(); + h.state_mut().run("window.panel.move", json!({"panel": "swatches", "anchor": "layers", "zone": "top"})).unwrap(); + h.state_mut().run("window.panel.close", json!({"panel": "layers"})).unwrap(); + h.run_steps(4); + let shown = |h: &Harness<'_, PhotocraftApp>| h.state().ui.docking.as_ref().is_some_and(|w| w.layout.contains(&"layers".to_string())); + assert!(!shown(&h), "the Layers panel is closed"); + h.state_mut().ui.layer_filter_name = "lin".into(); + find(&mut h, json!({})); + assert!(shown(&h), "Find Layers shows the Layers panel again"); + h.event(egui::Event::Text("other".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "other", "the Name field has the focus with its text selected"); +} + +#[test] +fn a_hidden_filter_row_stays_hidden_and_does_not_take_the_focus_later() { + // Measured in Photoshop: with Filter Options › Hide, Find Layers switches to Name but leaves + // the row hidden, and showing the row afterwards does not put the caret in the field. + let mut h = harness(); + h.state_mut().ui.layers_panel_options.show_filters = false; + h.state_mut().ui.layer_filter_name = "lin".into(); + h.run_steps(4); + find(&mut h, json!({})); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(!h.state().ui.layers_panel_options.show_filters, "the filter row stays hidden"); + h.state_mut().ui.layers_panel_options.show_filters = true; + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "showing the row later does not focus the Name field"); + assert_eq!(h.state().ui.layer_filter_name, "lin"); +} + +#[test] +fn engine_execute_without_a_name_lists_what_the_panel_lists() { + let mut h = harness(); + h.state_mut().ui.layer_filter_name = "link".into(); + let reply = h.state_mut().run("select.findLayers", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "link", "the Name text is kept"); + assert_eq!(reply["selected"], json!([id(&h, "LinkA").0, id(&h, "LinkB").0]), "the reply lists the layers the kept text matches"); + assert_eq!(reply["layerFilter"], json!({"by": "name", "name": "link"})); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); +} + +#[test] +fn engine_execute_sets_the_panel_filter_too() { + // `engine.execute` runs engine commands through `PhotocraftApp::run`, not `menus::invoke`. + let mut h = harness(); + h.state_mut().ui.layer_filter_by = FilterBy::Kind; + h.state_mut().run("select.findLayers", json!({"name": "link"})).unwrap(); + h.run_steps(4); + assert_eq!((h.state().ui.layer_filter_by, h.state().ui.layer_filter_name.as_str()), (FilterBy::Name, "link")); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [], "the filter hides \"Other\" (#10879) and selects none of the matches"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "a script does not take the keyboard focus"); +} diff --git a/crates/ui-egui/src/panels/layer_menu_ui.rs b/crates/ui-egui/src/panels/layer_menu_ui.rs index 8986746a19..6158ba8997 100644 --- a/crates/ui-egui/src/panels/layer_menu_ui.rs +++ b/crates/ui-egui/src/panels/layer_menu_ui.rs @@ -84,6 +84,7 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { LayerContent::Shape(_) => v.push(Some(("Rasterize Layer", "layer.rasterize.shape"))), LayerContent::Smart(_) => { v.push(Some((tl!("Edit Contents"), "layer.smartObjects.editContents"))); + v.push(Some((tl!("Reset Transform"), "layer.smartObjects.resetTransform"))); v.push(Some((tl!("Replace Contents…"), "layer.smartObjects.replaceContents"))); v.push(Some((tl!("Relink to File…"), "layer.smartObjects.relinkToFile"))); v.push(Some((tl!("Convert to Layers"), "layer.smartObjects.convertToLayers"))); diff --git a/crates/ui-egui/src/panels/layer_menu_ui/tests.rs b/crates/ui-egui/src/panels/layer_menu_ui/tests.rs index 33d759909f..11c29e58ba 100644 --- a/crates/ui-egui/src/panels/layer_menu_ui/tests.rs +++ b/crates/ui-egui/src/panels/layer_menu_ui/tests.rs @@ -271,8 +271,15 @@ fn edit_contents_is_available_only_for_smart_objects() { let ids: Vec<_> = entries(&layer(&s), false, false).into_iter().flatten().map(|e| e.1).collect(); let edit = ids.iter().position(|id| *id == "layer.smartObjects.editContents").expect("Edit Contents entry"); assert_eq!( - ids[edit + 1..edit + 5], - ["layer.smartObjects.replaceContents", "layer.smartObjects.relinkToFile", "layer.smartObjects.convertToLayers", "layer.rasterize.smartObject"] + ids[edit + 1..edit + 6], + [ + "layer.smartObjects.resetTransform", + "layer.smartObjects.replaceContents", + "layer.smartObjects.relinkToFile", + "layer.smartObjects.convertToLayers", + "layer.rasterize.smartObject" + ], + "Reset Transform follows Edit Contents, as in the reference flyout" ); } @@ -308,12 +315,14 @@ fn multi_selection_menu_rasterizes_the_selection() { let smart = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); let ids = |multi| entries(&smart, multi, false).into_iter().flatten().map(|e| e.1).collect::>(); assert!(ids(false).contains(&"layer.smartObjects.editContents"), "a single smart object keeps its items"); + assert!(ids(false).contains(&"layer.smartObjects.resetTransform"), "and its Reset Transform"); let multi = ids(true); assert!(multi.contains(&"layer.rasterize.layer")); for single in [ "layer.rasterize.smartObject", "layer.rasterize.shape", "layer.smartObjects.editContents", + "layer.smartObjects.resetTransform", "layer.smartObjects.replaceContents", "layer.smartObjects.relinkToFile", "layer.smartObjects.convertToLayers", diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index d3574f3980..df45e61d01 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -158,6 +158,9 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, Tool::Brush | Tool::Eraser if t.pro => { // A Block-mode Eraser has a fixed size, opacity and flow (Photoshop, #2770). let block = crate::eraser_ui::block_mode(app, app.ui.tool); + // The Brush's own tooltips (#11813); the Eraser keeps its texts. + let brush = app.ui.tool == Tool::Brush; + let tip = |r: egui::Response, text: &str| if brush { r.on_hover_text(text) } else { r }; ui.add_enabled_ui(!block, |ui| { brush_preset_chip(ui, b, &mut app.ui); crate::brush_picker::settings_toggle(app, ui); @@ -170,26 +173,33 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, opt_label(ui, tl!("Mode")); let held = app.held_erase; paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held); + // The dropdown's button, by the id its combo box takes from "brush-mode". + if let Some(mode) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("brush-mode"))) { + tip(mode, tl!("Painting Mode")); + } } ui.add_enabled_ui(!block, |ui| { - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); - if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() { + // A whole number from 1 to 100, as in Photoshop: 0 paints nothing (#11812). + tip(whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, 62.0), tl!("Set opacity for stroke")); + let pressure = pressure_button(ui, b.pressure_opacity, tl!("Always use pressure for opacity"), brush); + if tip(pressure, tl!("Always use Pressure for Opacity. When off, Brush Preset controls pressure")).clicked() { b.pressure_opacity = !b.pressure_opacity; } }); // A Pencil- or Block-mode Eraser is always full flow, without build-up (Photoshop). ui.add_enabled_ui(!block && !crate::eraser_ui::pencil_mode(app, app.ui.tool), |ui| { - percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0); + tip(percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0), tl!("Set flow rate for stroke")); let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects")); if crate::brush_picker::named(airbrush, "Enable airbrush-style build-up effects").clicked() { b.build_up = !b.build_up; } }); opt_label(ui, tl!("Smoothing")); - smoothing_field(ui, b, 58.0); - smoothing_options(ui, b); + tip(smoothing_field(ui, b, 58.0), tl!("Set smoothing for stroke\nUse higher values to reduce the shakiness of brush strokes")); + smoothing_options(ui, b, brush); widgets::vline(ui, 22.0); - if icons::button(ui, "circle-dot", 24.0, b.pressure_size, tl!("Always use pressure for size")).clicked() { + let pressure = pressure_button(ui, b.pressure_size, tl!("Always use pressure for size"), brush); + if tip(pressure, tl!("Always use Pressure for Size. When off, Brush Preset controls pressure")).clicked() { b.pressure_size = !b.pressure_size; } } @@ -206,7 +216,7 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, opt_label(ui, tl!("Mode")); let held = app.held_erase; paint_mode_dropdown(ui, "pencil-mode", b, &mut app.ui.tool_options.pencil_clear, held); - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, if t.pro { 62.0 } else { 66.0 }); + whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, if t.pro { 62.0 } else { 66.0 }); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 }); widgets::vline(ui, 22.0); @@ -223,23 +233,37 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, widgets::value_field(ui, &mut b.size, 1.0..=photocraft_engine::paint::MAX_BRUSH_SIZE, "px", 76.0); }); widgets::vline(ui, 22.0); + // The Brush's own tooltips here too (#11813); the Eraser keeps its texts. + let brush = app.ui.tool == Tool::Brush; + let tip = |r: egui::Response, text: &str| if brush { r.on_hover_text(text) } else { r }; if app.ui.tool == Tool::Eraser { crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); } else { opt_label(ui, tl!("Mode")); let held = app.held_erase; paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held); + if let Some(mode) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("brush-mode"))) { + tip(mode, tl!("Painting Mode")); + } } // A Pencil-mode Eraser is always hard and at full flow (Photoshop). let fixed = block || crate::eraser_ui::pencil_mode(app, app.ui.tool); ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0)); - ui.add_enabled_ui(!block, |ui| percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0)); - ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)); + let opacity = ui.add_enabled_ui(!block, |ui| whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, 66.0)).inner; + tip(opacity, tl!("Set opacity for stroke")); + let flow = ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)).inner; + tip(flow, tl!("Set flow rate for stroke")); opt_label(ui, tl!("Smoothing")); - smoothing_field(ui, b, 66.0); + tip(smoothing_field(ui, b, 66.0), tl!("Set smoothing for stroke\nUse higher values to reduce the shakiness of brush strokes")); widgets::vline(ui, 22.0); - widgets::toggle(ui, &mut b.pressure_size, tl!("Pressure for Size")); - widgets::toggle(ui, &mut b.pressure_opacity, tl!("Pressure for Opacity")); + tip( + widgets::toggle(ui, &mut b.pressure_size, tl!("Pressure for Size")), + tl!("Always use Pressure for Size. When off, Brush Preset controls pressure"), + ); + tip( + widgets::toggle(ui, &mut b.pressure_opacity, tl!("Pressure for Opacity")), + tl!("Always use Pressure for Opacity. When off, Brush Preset controls pressure"), + ); } Tool::MixerBrush => { brush_preset_chip(ui, b, &mut app.ui); @@ -611,7 +635,7 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, "move-target", tl!("Auto-Select:"), &mut o.move_target, - &[("layer".to_string(), tl!("Layer")), ("group".to_string(), tl!("Group"))], + &[("group".to_string(), tl!("Group")), ("layer".to_string(), tl!("Layer"))], 76.0, ); widgets::vline(ui, 22.0); @@ -796,12 +820,32 @@ fn fixed_size_field(ui: &mut egui::Ui, px: &mut f32, units: widgets::FieldUnits, picked } -fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { +fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) -> egui::Response { opt_label(ui, label); let mut percent = *value * 100.0; - if widgets::value_field_named(ui, label, &mut percent, range, "%", width).changed() { + let r = widgets::value_field_named(ui, label, &mut percent, range, "%", width); + if r.changed() { *value = percent / 100.0; } + r +} + +/// A pressure toggle named `name` (for screen readers and tests); its tooltip is `name`, unless +/// `own_tip` leaves the tooltip to the caller (the Brush's own texts, #11813). +fn pressure_button(ui: &mut egui::Ui, on: bool, name: &str, own_tip: bool) -> egui::Response { + icons::button_with_icon_size_tip(ui, "circle-dot", 24.0, (24.0f32 * 0.52).round(), on, name, !own_tip) +} + +/// [`percent_field`] that keeps whole percents (`33.7` becomes 34 %). A value set elsewhere (a +/// `tools.setBrush` fraction, an older saved 0) shows as the whole percent in range. +fn whole_percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) -> egui::Response { + opt_label(ui, label); + let mut percent = (*value * 100.0).round().max(*range.start()).min(*range.end()); + let r = widgets::value_field_named(ui, label, &mut percent, range.clone(), "%", width); + if r.changed() { + *value = percent.round().max(*range.start()).min(*range.end()) / 100.0; + } + r } fn hint(ui: &mut egui::Ui, s: &str) { diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index 34258ab909..56234d8f51 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -496,7 +496,8 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: return; } }; - let Some(id) = crate::adjust_dialog::open(app, &f.command).or_else(|| { + // A smart filter's dialog shows the filter's own settings, never the last-used ones. + let Some(id) = crate::adjust_dialog::open_neutral(app, &f.command).or_else(|| { let id = crate::filter_dialog::has_dialog(&f.command).then(|| crate::filter_dialog::open(app, &f.command)).flatten()?; // The filter dialog opens on the user's last-used values; a recorded filter that lacks a // key must show (and save) the schema default instead. @@ -507,10 +508,13 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: }) else { return; }; + // A recorded Photo Filter that names only its preset gets the preset's colour, which the + // dialog would otherwise fill in with its neutral one; recorded values stay as they were. + let params = crate::adjust_dialog::complete_params(app, &f.command, &f.params).unwrap_or_else(|| f.params.clone()); let mode = app.session.active().map(|st| st.doc.mode); if let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("__smartFilter".into(), target); - if let Value::Object(p) = &f.params { + if let Value::Object(p) = ¶ms { for (k, v) in p { if !k.starts_with("__") { d.fields.insert(k.clone(), v.clone()); diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index 12f85de2c1..da8500f535 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -30,7 +30,7 @@ pub(crate) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint], &[Tool::Type, Tool::VerticalType, Tool::VerticalTypeMask, Tool::HorizontalTypeMask], &[Tool::PathSelection, Tool::DirectSelection], - &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape], + &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Star, Tool::Line, Tool::CustomShape], ], &[&[Tool::Hand, Tool::RotateView], &[Tool::Zoom]], ]; diff --git a/crates/ui-egui/src/patch_sample_all_default_tests.rs b/crates/ui-egui/src/patch_sample_all_default_tests.rs new file mode 100644 index 0000000000..6a78f6111a --- /dev/null +++ b/crates/ui-egui/src/patch_sample_all_default_tests.rs @@ -0,0 +1,64 @@ +//! The Patch tool's Content-Aware Sample All Layers starts on (#12125): a fresh start and Reset +//! Tool give the Patch tool Sample All Layers on while other tools stay off, and saved settings +//! keep their stored value. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{SampleAllLayers, Tool, ToolOptions}; + +fn app() -> PhotocraftApp { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + PhotocraftApp::new(s, crate::Services::default()) +} + +#[test] +fn patch_sample_all_layers_starts_on_and_other_tools_start_off() { + let defaults = SampleAllLayers::default(); + assert!(defaults.patch); + assert!(defaults.get(Tool::Patch)); + for tool in Tool::ALL { + if tool != Tool::Patch { + assert!(!defaults.get(tool), "{tool:?} should default to Sample All Layers off"); + } + } + + let app = app(); + assert!(app.ui.tool_options.sample_all.patch); +} + +#[test] +fn reset_tool_restores_patch_sample_all_layers_to_on() { + let mut app = app(); + let ctx = egui::Context::default(); + app.ui.tool = Tool::Patch; + app.ui.tool_options.sample_all.patch = false; + app.ui.tool_options.sample_all.magic_wand = true; + + crate::menus::invoke(&mut app, &ctx, super::RESET, json!({})).unwrap(); + assert!(app.ui.tool_options.sample_all.patch, "Reset Tool restores Patch Sample All Layers to on"); + assert!(app.ui.tool_options.sample_all.magic_wand, "other tools keep their Sample All Layers setting"); +} + +#[test] +fn saved_settings_keep_stored_patch_sample_all_layers_value() { + let saved_off: ToolOptions = serde_json::from_value(json!({ + "sample_all": { + "patch": false, + "magic_wand": true + } + })) + .unwrap(); + assert!(!saved_off.sample_all.patch); + assert!(saved_off.sample_all.magic_wand); + + let missing_patch: ToolOptions = serde_json::from_value(json!({ + "sample_all": { + "magic_wand": true + } + })) + .unwrap(); + assert!(missing_patch.sample_all.patch); + assert!(missing_patch.sample_all.magic_wand); +} diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 4b5cf04d61..aefc2bb3c7 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -210,6 +210,181 @@ fn the_pen_carries_on_an_open_path_from_either_end() { assert_eq!((work.subpaths.len(), work.subpaths[1].closed, work.subpaths[1].knots.len()), (2, true, 3)); } +/// #10726: after another tool, the Curvature Pen carries an open path on as in Photoshop: a click on +/// its endpoint selects it, and the next click continues the path from there. The subpath is +/// extended in one History step, its segments keep their shape, and the new anchors take smooth +/// curvature handles. +#[test] +fn the_curvature_pen_carries_on_an_open_path() { + let mut app = app(Tool::Pen); + click(&mut app, 20.0, 20.0); + // A curve: the middle anchor's handles dragged out further than the Curvature Pen would set. + tool_event(&mut app, ToolEvent::Down { x: 80.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 130.0, y: 10.0 }, Modifiers::NONE); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let before = work_path(&app).subpaths[0].knots.clone(); + assert_ne!(before[1].out_ctrl, before[1].anchor, "the middle anchor has handles"); + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + let at = steps(&app); + app.ui.tool = Tool::CurvaturePen; + click(&mut app, 80.0, 80.0); + assert!(app.ui.pen.is_none(), "the click selects the endpoint, it draws nothing"); + click(&mut app, 140.0, 120.0); + assert!(app.ui.pen.as_ref().is_some_and(|p| p.curvature && p.knots.len() == 4), "the next click carries the path on"); + click(&mut app, 200.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.len(), 1, "the same subpath, extended"); + let knots = &work.subpaths[0].knots; + let anchors: Vec<[f64; 2]> = knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + assert_eq!(anchors, [[20.0, 20.0], [80.0, 20.0], [80.0, 80.0], [140.0, 120.0], [200.0, 80.0]]); + assert_eq!(steps(&app), at + 1, "one History step"); + // The Pen's straight segments keep their shape: the first knots are as they were, and the + // endpoint keeps its incoming handle. + assert_eq!((knots[0], knots[1]), (before[0], before[1])); + assert_eq!((knots[2].anchor, knots[2].in_ctrl), (before[2].anchor, before[2].in_ctrl)); + // The new anchor in the middle is smooth: its handles lie either side of it. + let mid = &knots[3]; + assert!(mid.in_ctrl != mid.anchor && mid.out_ctrl != mid.anchor, "{mid:?}"); + assert!((mid.in_ctrl.x - mid.anchor.x) * (mid.out_ctrl.x - mid.anchor.x) < 0.0, "handles on either side: {mid:?}"); + // While it draws on, the Curvature Pen moves only the new anchors: a press on an old corner + // adds an anchor there. + click(&mut app, 200.0, 80.0); + click(&mut app, 260.0, 40.0); + click(&mut app, 80.0, 20.0); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(7), "a new anchor, the old one untouched"); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots[1][0]), Some([80.0, 20.0])); +} + +/// The work path set to `subpaths`, in one step. +fn set_work_path(app: &mut PhotocraftApp, subpaths: Vec) { + app.session + .edit("path", |doc, _| { + doc.work_path = Some(Path::new(subpaths)); + Ok(()) + }) + .unwrap(); +} + +/// Direct Selection's selection: anchor `[subpath, knot]` of `target`, alone. +fn select_anchor(app: &mut PhotocraftApp, target: crate::direct_select::PathRef, anchor: [usize; 2]) { + app.ui.direct_selection.target = Some(target); + app.ui.direct_selection.anchors = vec![anchor]; + // Made now, on this document: not a stale selection the next press drops. + app.ui.direct_selection.owner = crate::direct_select::Owner::current(app); +} + +/// CodeRabbit on #10744: two open subpaths end on the same point, and the Curvature Pen carries on +/// the one whose endpoint is selected, not the first one ending there. +#[test] +fn the_curvature_pen_carries_on_the_selected_subpath_of_two_that_end_together() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::CurvaturePen); + set_work_path(&mut app, vec![Subpath::polyline(&[(20.0, 20.0), (80.0, 80.0)]), Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [1, 1]); + click(&mut app, 140.0, 150.0); + assert_eq!(app.ui.pen.as_ref().and_then(|p| p.resumes.as_ref()).map(|r| r.index), Some(1), "the selected subpath"); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.iter().map(|s| s.knots.len()).collect::>(), [2, 3], "the second subpath grew"); +} + +/// Photoshop: an open Curvature Pen arc carried on from its endpoint goes on as a new Curvature +/// run from that endpoint. The old segments keep their shape, the endpoint keeps its incoming +/// handle, and its outgoing handle and the new knots are those of a path drawn from the endpoint +/// on, so the join is a cusp where both arcs meet, not bent smooth. +#[test] +fn a_carried_on_endpoint_starts_a_new_curvature_run() { + let draw = |points: &[(f64, f64)]| { + let mut app = app(Tool::CurvaturePen); + for &(x, y) in points { + click(&mut app, x, y); + } + assert!(crate::vector_ui::pen_commit(&mut app, false), "the commit goes through"); + app + }; + let fresh = work_path(&draw(&[(400.0, 300.0), (550.0, 150.0), (700.0, 300.0)])).subpaths[0].knots.clone(); + let mut app = draw(&[(100.0, 300.0), (250.0, 150.0), (400.0, 300.0)]); + let before = work_path(&app).subpaths[0].knots.clone(); + click(&mut app, 400.0, 300.0); + assert!(app.ui.pen.is_none(), "the click selects the endpoint, it draws nothing"); + click(&mut app, 550.0, 150.0); + click(&mut app, 700.0, 300.0); + assert!(crate::vector_ui::pen_commit(&mut app, false), "the commit goes through"); + let work = work_path(&app); + assert_eq!(work.subpaths.len(), 1, "the same subpath, extended"); + let knots = &work.subpaths[0].knots; + assert_eq!(knots.len(), 5, "carried on by the new anchors"); + assert_eq!((knots[0], knots[1]), (before[0], before[1]), "the old segments keep their shape"); + assert_eq!((knots[2].anchor, knots[2].in_ctrl), (before[2].anchor, before[2].in_ctrl), "the endpoint keeps its incoming handle"); + assert_eq!(knots[2].out_ctrl, fresh[0].out_ctrl, "the endpoint goes on as a new run's first knot: {knots:?}"); + assert_eq!((knots[3], knots[4]), (fresh[1], fresh[2]), "the new knots are those of a new run"); + // Both arcs meet at the endpoint from above: a cusp, as in Photoshop. + let k = &knots[2]; + assert!(k.in_ctrl.y < k.anchor.y && k.out_ctrl.y < k.anchor.y, "{k:?}"); +} + +/// CodeRabbit on #10744: a curved path carried on from its first endpoint runs backwards, each +/// knot's handles swapped, and that endpoint keeps the handle it had towards the path. +#[test] +fn a_curved_path_carries_on_from_its_first_endpoint_with_its_handles_swapped() { + use photocraft_doc::vector::{Knot, Subpath}; + use photocraft_geom::Point; + let mut app = app(Tool::CurvaturePen); + let start = Knot::smooth(Point::new(20.0, 20.0), Point::new(10.0, 30.0), Point::new(30.0, 10.0)); + let middle = Knot::smooth(Point::new(50.0, 60.0), Point::new(40.0, 50.0), Point::new(60.0, 70.0)); + set_work_path(&mut app, vec![Subpath { closed: false, knots: vec![start, middle, Knot::corner(80.0, 80.0)], op: Default::default() }]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 0]); + click(&mut app, 0.0, 100.0); + let pen = app.ui.pen.as_ref().expect("the path is carried on"); + assert_eq!(pen.resumes.as_ref().map(|r| r.index), Some(0)); + let anchors: Vec<[f64; 2]> = pen.knots.iter().map(|k| k[0]).collect(); + assert_eq!(anchors, [[80.0, 80.0], [50.0, 60.0], [20.0, 20.0], [0.0, 100.0]], "backwards, then the new anchor"); + assert_eq!((pen.knots[1][1], pen.knots[1][2]), ([60.0, 70.0], [40.0, 50.0]), "the curved middle knot's handles are swapped"); + assert_eq!(pen.knots[2][1], [30.0, 10.0], "the endpoint's handle towards the path: its old outgoing one"); +} + +/// CodeRabbit on #10744: a double-click on a knot of the path carried on (not the endpoint it +/// carries on from) changes nothing: those knots keep their handles. +#[test] +fn toggling_a_carried_knot_changes_nothing() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::CurvaturePen); + set_work_path(&mut app, vec![Subpath::polyline(&[(20.0, 20.0), (80.0, 20.0), (80.0, 80.0)])]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 2]); + click(&mut app, 140.0, 120.0); + let before = app.ui.pen.as_ref().map(|p| (p.unlinked.clone(), p.knots.clone())); + crate::vector_ui::curvature_toggle(&mut app, 80.0, 20.0); + assert_eq!(app.ui.pen.as_ref().map(|p| (p.unlinked.clone(), p.knots.clone())), before, "the old corner stays as it was, handles and all"); +} + +/// CodeRabbit on #10744: in Path mode the Pen carries on the work path, so an endpoint selected +/// on a shape layer, even one on the same point as the work path's, is not carried on: the +/// press starts a new path rather than carrying on the work path from there. +#[test] +fn an_endpoint_on_another_path_than_the_pen_draws_on_starts_a_new_one() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let shape = app.session.active().and_then(|st| st.active_layer).expect("a shape layer").0; + app.ui.tool_options.vector_mode = "path".into(); + // The Paths panel on no path: the Pen draws on the work path, not the layer's. + app.ui.selected_path = None; + set_work_path(&mut app, vec![Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); + // The tool first: switching tools drops a Direct Selection selection. + app.ui.tool = Tool::CurvaturePen; + select_anchor(&mut app, crate::direct_select::PathRef::Layer(shape), [0, 1]); + assert!(crate::direct_select::selected_open_end(&app).is_some(), "the shape's endpoint is selected"); + click(&mut app, 140.0, 150.0); + let pen = app.ui.pen.as_ref().expect("a new path"); + assert!(pen.resumes.is_none() && pen.knots.len() == 1, "{pen:?}"); +} + /// #6593 review: carrying on a subpath keeps its path operation, and a draft whose subpath changed /// meanwhile refuses without overwriting newer work. The historical test name is retained /// for reviewed-head provenance; captured-revision validation supersedes its stale fallback. diff --git a/crates/ui-egui/src/prefs/prefs_ui.rs b/crates/ui-egui/src/prefs/prefs_ui.rs index 36cd3e74c5..127812585a 100644 --- a/crates/ui-egui/src/prefs/prefs_ui.rs +++ b/crates/ui-egui/src/prefs/prefs_ui.rs @@ -53,6 +53,8 @@ pub struct Runtime { /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, pub(crate) selected_tool: Option, + pub(crate) tool_colors: Option<[[[f32; 4]; 2]; 2]>, + pub(crate) tool_cmyk_picks: crate::tool_memory::CmykPicks, pub(crate) group_tools: Option>, /// The tool owning the session brush and the other tools' brushes, as last remembered. pub(crate) tool_brushes: Option, diff --git a/crates/ui-egui/src/prefs/prefs_ui/save.rs b/crates/ui-egui/src/prefs/prefs_ui/save.rs index e3917ba9cd..d236af92b4 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/save.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/save.rs @@ -116,8 +116,16 @@ fn write_prefs(app: &mut PhotocraftApp, ours: &Value) -> Result<(), String> { let stored = app.services.load_prefs.as_mut().and_then(|load| load()).and_then(|t| serde_json::from_str::(&t).ok()); let text = match (stored, &app.prefs_rt.saved_value) { (Some(mut theirs @ Value::Object(_)), Some(base)) => { - let base = &without_stale_migration(base, &theirs); - merge_changes(base, ours, &mut theirs); + let mut base = without_stale_migration(base, &theirs); + // Older files have no toolColors object. Diff the first saved swatches against + // their actual defaults so an unchanged field cannot overwrite another window's edit. + if matches!(base.get("toolColors"), None | Some(Value::Null)) + && ours.get("toolColors").is_some_and(Value::is_object) + && let Some(fields) = base.as_object_mut() + { + fields.insert("toolColors".into(), crate::tool_memory::default_colors()); + } + merge_changes(&base, ours, &mut theirs); serde_json::to_string_pretty(&theirs) } _ => serde_json::to_string_pretty(ours), diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index 3d62afaa43..7064c8a286 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -1432,7 +1432,7 @@ fn remembered_tool_brushes_survive_damage() { } /// #6212: Reset Tool forgets that tool's remembered options and brush; Reset All Tools -/// forgets every tool's. +/// forgets every tool's: each painting tool keeps only its own starting brush (#11810). #[test] fn reset_tools_clear_the_remembered_options_and_brushes() { use crate::state::Tool; @@ -1471,10 +1471,12 @@ fn reset_tools_clear_the_remembered_options_and_brushes() { tick(&mut app, &ctx); let saved = stored(&store); assert!(saved["toolOptions"].is_null(), "{saved}"); - assert!(saved["toolBrushes"]["brushes"].as_object().is_none_or(|m| m.is_empty()), "{saved}"); + let starting = + |brushes: &[(Tool, photocraft_engine::BrushSettings)]| brushes.iter().all(|(t, b)| *b == crate::paint_mouse::fresh_brush(*t, b) && b.size != 77.0); + assert!(starting(&app.ui.tool_brushes), "{saved}"); let (app2, _) = app_with_saved(store.lock().unwrap().clone()); assert_eq!(app2.ui.tool_options, crate::state::ToolOptions::default()); - assert!(app2.ui.tool_brushes.is_empty()); + assert!(!app2.ui.tool_brushes.is_empty() && starting(&app2.ui.tool_brushes), "remembered across a restart"); } #[test] @@ -2153,6 +2155,203 @@ fn edit_dialogs_open() { let d = crate::menus::invoke(&mut app, &ctx, "edit.presets.presetManager", json!({})).unwrap()["dialog"].as_u64().unwrap(); assert!(crate::dialogs::confirm(&mut app, d).is_ok()); } + +#[test] +fn tool_colors_first_saves_keep_other_instances_separate_edits() { + for initial in + [None, Some("{}".into()), Some(json!({"toolColors": null}).to_string()), Some(json!({"toolColors": crate::tool_memory::default_colors()}).to_string())] + { + for background_first in [false, true] { + let store = Arc::new(Mutex::new(initial.clone())); + let (mut foreground, mut background) = (app_on(&store, Arc::default()), app_on(&store, Arc::default())); + let fg = [0.25, 0.5, 0.75, 1.0]; + let bg = [0.75, 0.5, 0.25, 1.0]; + foreground.run("tools.setColors", json!({"foreground": fg})).unwrap(); + background.run("tools.setColors", json!({"background": bg})).unwrap(); + let (first, second) = if background_first { (&mut background, &mut foreground) } else { (&mut foreground, &mut background) }; + crate::tool_memory::save_on_exit(first).unwrap(); + crate::tool_memory::save_on_exit(second).unwrap(); + // Further saves must still keep the other window's independent field. + save_preferences(first).unwrap(); + save_preferences(second).unwrap(); + let restored = app_on(&store, Arc::default()); + assert_eq!(restored.session.tools.foreground, fg); + assert_eq!(restored.session.tools.background, bg); + assert_eq!(restored.session.tools.other_colors, photocraft_engine::ToolState::MASK_COLORS); + } + } +} + +#[test] +fn tool_colors_survive_restart_before_the_first_frame() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let fg = [0.2, 0.4, 0.6, 0.8]; + let bg = [0.9, 0.7, 0.3, 1.0]; + app.run("tools.setColors", json!({"foreground": fg, "background": bg})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, fg); + assert_eq!(restored.session.tools.background, bg); + let rev = app.session.prefs.rev(); + crate::tool_memory::persist(&mut app, &ctx); + assert_eq!(app.session.prefs.rev(), rev, "unchanged colours do not rewrite preferences"); +} + +#[test] +fn tool_colors_survive_restart_after_rejecting_an_invalid_preset() { + for bad_channel in [json!(1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] { + let (mut app, store) = app_with_store(); + let color = [0.2, 0.4, 0.6, 0.8]; + app.run("tools.setColors", json!({"foreground": color})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + app.run( + "tool.presets.new", + json!({ + "name": "Invalid", "tool": "move", "options": {"foreground": [bad_channel, 0.4, 0.6, 1.0]} + }), + ) + .unwrap(); + assert!(app.run("tool.presets.select", json!({"preset": "Invalid"})).is_err()); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + assert_eq!(stored(&store)["toolColors"]["foreground"], json!(color)); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, color); + } +} + +#[test] +fn tool_colors_preserve_finite_hdr_components() { + let (mut app, store) = app_with_store(); + let fg = [1.5, -0.1, 0.6, 1.0]; + app.run("tools.setColors", json!({"foreground": fg})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, fg); +} + +#[test] +fn tool_colors_restore_cmyk_inks_for_picker_and_fill_after_swapping_and_exiting_on_a_mask() { + let (mut app, store) = app_with_store(); + let cyan = [1.0, 0.0, 0.0, 0.0]; + let magenta = [0.0, 1.0, 0.0, 0.0]; + let fg = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + let bg = photocraft_color::convert::cmyk_to_rgb(magenta).map(|c| c.clamp(0.0, 1.0)); + app.run( + "tools.setColors", + json!({ + "foreground": fg, "foregroundCmyk": cyan, "background": bg, "backgroundCmyk": magenta + }), + ) + .unwrap(); + app.run("tools.swapColors", json!({})).unwrap(); + app.session.tools.target_mask(true); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.picked_cmyk(fg), Some(cyan)); + assert_eq!(restored.session.tools.picked_cmyk(bg), Some(magenta)); + for (target, expected) in [("foreground", magenta), ("background", cyan)] { + crate::color_picker_ui::open(&mut restored, target); + assert_eq!(restored.ui.dialogs.last().unwrap().fields["__cmyk"], json!(expected)); + } + restored.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + restored.run("image.mode.cmyk", json!({})).unwrap(); + for (command, expected) in [("edit.fillForeground", magenta), ("edit.fillBackground", cyan)] { + restored.run(command, json!({})).unwrap(); + let st = restored.session.active().unwrap(); + let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let pixel = surface.read_region(photocraft_geom::Rect::new(0, 0, 1, 1)); + assert!(pixel.iter().take(4).zip(expected).all(|(a, b)| (a - b).abs() < 0.01), "{command}: {pixel:?}"); + } +} + +#[test] +fn tool_colors_save_changed_cmyk_inks_even_when_rgba_is_unchanged() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let rgb = [0.2, 0.4, 0.6]; + app.run("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": [1, 0, 0, 0]})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let rev = app.session.prefs.rev(); + let inks = [0.1, 0.2, 0.3, 0.4]; + app.run("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": inks})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + assert!(app.session.prefs.rev() > rev); + tick(&mut app, &ctx); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.picked_cmyk(rgb), Some(inks)); +} + +#[test] +fn tool_colors_restore_only_bounded_valid_cmyk_picks() { + let limit = photocraft_engine::ToolState::CMYK_PICKS; + let mut picks: Vec = (0..limit + 3).map(|i| json!([[i as f32 / 16.0, 0.2, 0.3], [0.1, 0.2, 0.3, 0.4]])).collect(); + picks.extend([json!("bad"), json!([[0, 0, 0], [1, 0, 0]]), json!([[0, 0, 0], [2, 0, 0, 0]]), json!([[1e100, 0, 0], [1, 0, 0, 0]])]); + let (app, _) = app_with_saved(Some( + json!({ + "toolColors": {"foreground": [0.2, 0.4, 0.6, 1.0], "cmykPicks": picks} + }) + .to_string(), + )); + assert_eq!(app.session.tools.foreground, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(app.session.tools.cmyk_picks.len(), limit); + assert_eq!(app.session.tools.picked_cmyk([0.0, 0.2, 0.3]), None); + assert_eq!(app.session.tools.picked_cmyk([(limit + 2) as f32 / 16.0, 0.2, 0.3]), Some([0.1, 0.2, 0.3, 0.4])); +} + +#[test] +fn tool_colors_flush_swaps_and_defaults_on_exit_without_another_frame() { + let (mut app, store) = app_with_store(); + app.run("tools.setColors", json!({"foreground": "#336699", "background": "#cc8844"})).unwrap(); + app.run("tools.swapColors", json!({})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, restored_store) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, app.session.tools.foreground); + assert_eq!(restored.session.tools.background, app.session.tools.background); + restored.run("tools.defaultColors", json!({})).unwrap(); + crate::tool_memory::save_on_exit(&mut restored).unwrap(); + let (defaults, _) = app_with_saved(restored_store.lock().unwrap().clone()); + assert_eq!(defaults.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(defaults.session.tools.background, [1.0; 4]); +} + +#[test] +fn tool_colors_keep_pixel_and_mask_pairs_when_exiting_on_a_mask() { + let (mut app, store) = app_with_store(); + app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0]; + app.session.tools.background = [0.9, 0.7, 0.3, 1.0]; + let pixels = [app.session.tools.foreground, app.session.tools.background]; + app.session.tools.target_mask(true); + app.session.tools.foreground = [0.5, 0.5, 0.5, 1.0]; + let masks = [app.session.tools.foreground, app.session.tools.background]; + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!([restored.session.tools.foreground, restored.session.tools.background], pixels); + restored.session.tools.target_mask(true); + assert_eq!([restored.session.tools.foreground, restored.session.tools.background], masks); +} + +#[test] +fn tool_colors_ignore_invalid_swatches_without_losing_valid_preferences() { + for bad in [json!(null), json!("red"), json!([1, 0, 0]), json!([1e100, 0, 0, 1]), json!([0, "bad", 0, 1])] { + let (app, _) = app_with_saved(Some( + json!({ + "toolColors": {"foreground": bad, "background": [0.2, 0.4, 0.6, 1.0]}, + "toolOptions": {"feather": 12.0} + }) + .to_string(), + )); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(app.session.tools.background, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(app.ui.tool_options.feather, 12.0); + } + let (app, _) = app_with_saved(Some(json!({"toolOptions": {"feather": 12.0}}).to_string())); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(app.session.tools.background, [1.0; 4]); +} + #[test] fn reset_section_on_type_keeps_the_favourite_fonts() { // The favourites are the font menu's library, not a Type setting. diff --git a/crates/ui-egui/src/smart_objects_menu_tests.rs b/crates/ui-egui/src/smart_objects_menu_tests.rs index dd6d7947a6..91addfd6b3 100644 --- a/crates/ui-egui/src/smart_objects_menu_tests.rs +++ b/crates/ui-egui/src/smart_objects_menu_tests.rs @@ -36,6 +36,7 @@ fn the_rows_follow_the_reference_order() { "Update All Modified Content", "---", "Edit Contents", + "Reset Transform", "Relink to File…", "Replace Contents…", "Export Contents…", diff --git a/crates/ui-egui/src/star_tool_tests.rs b/crates/ui-egui/src/star_tool_tests.rs new file mode 100644 index 0000000000..b83bb15d83 --- /dev/null +++ b/crates/ui-egui/src/star_tool_tests.rs @@ -0,0 +1,115 @@ +//! The Star Tool (#11377): in the shape slot between Polygon and Line, on U, and a drag makes a +//! live star shape layer with PhotoCraft's star defaults (5 points, star ratio 0.5). + +use egui::{Key, Modifiers}; +use photocraft_doc::{Color, Fill, LayerContent, LiveShape, ShapeLayer}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::panels::toolbar::TOOL_SECTIONS; +use crate::state::Tool; + +const SHAPES: [Tool; 7] = [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Star, Tool::Line, Tool::CustomShape]; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app +} + +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: (from[0] + to[0]) / 2.0, y: (from[1] + to[1]) / 2.0, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); +} + +fn shape_layer(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + match &st.doc.layer(st.active_layer?)?.content { + LayerContent::Shape(s) => Some(s.clone()), + _ => None, + } +} + +fn live_shape(app: &PhotocraftApp) -> Option { + shape_layer(app)?.live +} + +#[test] +fn star_sits_in_the_shape_slot_after_polygon() { + let slot = TOOL_SECTIONS.iter().flat_map(|s| s.iter()).find(|slot| slot.contains(&Tool::Star)).copied(); + assert_eq!(slot, Some(&SHAPES[..])); + let pos = |t: Tool| Tool::ALL.iter().position(|x| *x == t); + assert_eq!(pos(Tool::Star), pos(Tool::Polygon).map(|p| p + 1), "Tool::ALL keeps the slot order"); +} + +#[test] +fn star_is_a_shape_tool_on_u_with_its_label_icon_and_name() { + assert!(crate::tools::vector_ui::is_shape_tool(Tool::Star)); + assert_eq!(Tool::Star.key(), 'U'); + assert_eq!(Tool::Star.label(), "Star Tool"); + assert_eq!(Tool::from_name("Star Tool"), Some(Tool::Star)); + assert_eq!(Tool::from_name("star"), Some(Tool::Star)); + assert_eq!(crate::icons::tool_icon(Tool::Star), "star"); +} + +#[test] +fn shift_u_steps_from_polygon_to_star_to_line() { + let mut app = app(); + app.ui.tool = Tool::Polygon; + press(&mut app, Key::U, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Star); + press(&mut app, Key::U, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Line); +} + +#[test] +fn a_drag_makes_a_live_star_shape_layer_with_one_undo_step() { + let mut app = app(); + app.ui.tool = Tool::Star; + app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + app.ui.tool_options.shape_fill = true; + app.ui.tool_options.stroke_width = 5.0; + let before = app.session.active().unwrap().history.past_len(); + drag(&mut app, [20.0, 30.0], [120.0, 90.0], Modifiers::NONE); + match live_shape(&app) { + Some(LiveShape::Polygon { rect, sides, star_ratio, .. }) => { + assert_eq!((sides, star_ratio), (5, 0.5)); + assert_eq!(rect, [20.0, 30.0, 100.0, 60.0]); + } + other => panic!("a live star shape, got {other:?}"), + } + let shape = shape_layer(&app).expect("a star shape layer"); + let solid = |r, g, b| Fill::Solid(Color::rgba(r, g, b, 1.0)); + assert_eq!(shape.fill, Some(solid(1.0, 0.0, 0.0)), "filled with the foreground colour"); + let stroke = shape.stroke.expect("the star keeps the tool's stroke"); + assert_eq!((stroke.paint, stroke.width), (solid(0.0, 0.0, 1.0), 5.0), "stroked with the background colour at the tool's width"); + assert_eq!(app.session.active().unwrap().history.past_len(), before + 1, "one history step"); + app.run("edit.undo", json!({})).unwrap(); + assert!(live_shape(&app).is_none(), "undo takes the star away"); +} + +#[test] +fn shift_and_alt_constrain_and_centre_the_star_like_the_polygon_tool() { + let mut app = app(); + app.ui.tool = Tool::Star; + drag(&mut app, [100.0, 100.0], [140.0, 120.0], Modifiers::SHIFT | Modifiers::ALT); + match live_shape(&app) { + Some(LiveShape::Polygon { rect, .. }) => assert_eq!(rect, [60.0, 60.0, 80.0, 80.0], "a square box drawn from the centre"), + other => panic!("a live star shape, got {other:?}"), + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6a6f1aeca0..5b79331819 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -9,6 +9,9 @@ mod tool; mod tool_options; mod transform; +#[cfg(test)] +mod type_default_size_tests; + pub use color_panel::{ColorPanelMode, ColorPanelState, ColorRamp, DockTabs}; pub use panel_state::{ CameraRawPreviewState, CameraRawScopeState, CurvesEditorState, LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog, LayersPanelOptions, @@ -766,7 +769,7 @@ mod tests { assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 68); + assert_eq!(Tool::ALL.len(), 69); } fn vector_tool_names_parse() { diff --git a/crates/ui-egui/src/state/tool.rs b/crates/ui-egui/src/state/tool.rs index 8dda7ce231..66aa7d53f6 100644 --- a/crates/ui-egui/src/state/tool.rs +++ b/crates/ui-egui/src/state/tool.rs @@ -71,6 +71,8 @@ pub enum Tool { EllipseShape, Triangle, Polygon, + /// The shape flyout's Star Tool (#11377): a live polygon shape with a star ratio. + Star, Line, CustomShape, Slice, @@ -79,7 +81,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 68] = [ + pub const ALL: [Tool; 69] = [ Tool::Move, Tool::Artboard, Tool::RectMarquee, @@ -143,6 +145,7 @@ impl Tool { Tool::EllipseShape, Tool::Triangle, Tool::Polygon, + Tool::Star, Tool::Line, Tool::CustomShape, Tool::Slice, @@ -218,6 +221,7 @@ impl Tool { Tool::EllipseShape => "Ellipse Tool", Tool::Triangle => "Triangle Tool", Tool::Polygon => "Polygon Tool", + Tool::Star => "Star Tool", Tool::Line => "Line Tool", Tool::CustomShape => "Custom Shape Tool", } @@ -288,7 +292,7 @@ impl Tool { // In the Pen flyout, but P never reaches them. Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => '', Tool::PathSelection | Tool::DirectSelection => 'A', - Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => 'U', + Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line | Tool::CustomShape => 'U', } } /// Glyph drawn in the toolbar (vector icons come later). diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index 228d8bac38..e5db1af422 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -145,8 +145,8 @@ pub struct ToolOptions { #[serde(default = "one")] pub slice_height: f32, /// Move tool: Auto-Select (with "layer" or "group" target) and Show Transform Controls. - /// Auto-Select is on with the "group" target by default, as in current Photoshop; - /// ⌘/Ctrl-click inverts it. + /// Auto-Select is on with the "layer" target by default, as Photoshop's Reset Tool leaves it + /// (measured for #11380; #4056 had asked for "group"); ⌘/Ctrl-click inverts it. #[serde(default = "yes")] pub move_auto_select: bool, #[serde(default = "default_move_target")] @@ -168,6 +168,10 @@ pub struct ToolOptions { /// transforms still turn and scale about it (#11098). #[serde(default)] pub transform_reference_point: bool, + /// Transform options bar: X and Y show the reference point's offset from where the box started + /// instead of its position (Photoshop's relative positioning, off by default, #11679). + #[serde(default)] + pub transform_relative_position: bool, /// Last interpolation selected in the Transform options bar; unset follows General's default. #[serde(default)] pub transform_interpolation: Option, @@ -292,7 +296,7 @@ fn default_crop_resolution_unit() -> String { } fn default_move_target() -> String { - "group".into() + "layer".into() } fn default_pen_path_op() -> String { @@ -349,10 +353,10 @@ fn default_eyedropper_sample() -> String { } /// Each tool's Sample All Layers (the Paint Bucket calls it All Layers). Photoshop keeps one per -/// tool, all off by default: turning it on for the Magic Wand must not leave Blur or Smudge -/// copying the composite onto the active layer (#5524). The Mixer Brush keeps its own with the -/// brush (`MixerSettings`). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +/// tool, all off by default except the Patch tool's Content-Aware Sample All Layers (#5524, +/// #12125): turning it on for the Magic Wand must not leave Blur or Smudge copying the composite +/// onto the active layer. The Mixer Brush keeps its own with the brush (`MixerSettings`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct SampleAllLayers { pub magic_wand: bool, @@ -369,6 +373,25 @@ pub struct SampleAllLayers { pub smudge: bool, } +impl Default for SampleAllLayers { + fn default() -> Self { + Self { + magic_wand: false, + quick_selection: false, + object_selection: false, + paint_bucket: false, + magic_eraser: false, + spot_healing: false, + patch: true, + content_aware_move: false, + remove: false, + blur: false, + sharpen: false, + smudge: false, + } + } +} + impl SampleAllLayers { /// `tool`'s setting; false for a tool without the option. pub fn get(mut self, tool: Tool) -> bool { @@ -417,7 +440,7 @@ impl Default for ToolOptions { bucket_clear: false, type_font: "Inter".into(), type_style: "Regular".into(), - type_size: 48.0, + type_size: 50.0, type_aa: "sharp".into(), type_align: "left".into(), type_style_defaults: serde_json::Map::new(), @@ -478,6 +501,7 @@ impl Default for ToolOptions { move_expand_group_on_click: true, move_show_layers_hover_bounds: true, transform_reference_point: false, + transform_relative_position: false, transform_interpolation: None, crop_ratio: String::new(), crop_custom_ratio_width: 0.0, diff --git a/crates/ui-egui/src/state/type_default_size_tests.rs b/crates/ui-egui/src/state/type_default_size_tests.rs new file mode 100644 index 0000000000..79b62d862b --- /dev/null +++ b/crates/ui-egui/src/state/type_default_size_tests.rs @@ -0,0 +1,89 @@ +//! BUG-TYPE-TOOL-DEFAULT-FONT-SIZE (#12051): the Type tool starts at 50 pt. +//! +//! Measured in the reference application: after Reset All Tools the Type tool's size is 50 pt, +//! the same in an 800x600 document at 72 ppi, a 3000x2000 document at 300 ppi and a 100x100 +//! document at 72 ppi, and the scripted tool options report `size=50` in points. A size saved by +//! an older version (which shipped 48 pt) is a remembered choice, not a default, and stays. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{Tool, ToolOptions}; + +/// The size in points the scripted tool options report: `ui.inspect`'s `toolOptions` is +/// `ToolOptions` serialized (`control/inspect.rs`), and its serde name is `type_size`. +fn scripted_size(o: &ToolOptions) -> f64 { + let v = serde_json::to_value(o).unwrap_or_default(); + v["type_size"].as_f64().unwrap_or_default() +} + +/// An app with one document of the given size and resolution. +fn app_with_document(width: u32, height: u32, resolution: f64) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": width, "height": height, "resolution": resolution})).unwrap(); + app.sync_views(); + app +} + +#[test] +fn a_fresh_install_starts_the_type_tool_at_50_pt() { + assert_eq!(ToolOptions::default().type_size, 50.0, "the shipped default is 50 pt, not 48 pt"); + assert_eq!(scripted_size(&ToolOptions::default()), 50.0, "the scripted tool options report size=50"); +} + +/// Reset Tool (the options bar's far-left menu) puts the field back to the default, and the +/// default is 50 pt for every flavour of the Type tool. +#[test] +fn reset_tool_gives_50_pt_for_every_type_tool() { + for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + let mut o = ToolOptions { type_size: 12.0, ..ToolOptions::default() }; + crate::tool_reset::reset_options(&mut o, tool); + assert_eq!(o.type_size, 50.0, "{tool:?} › Reset Tool"); + } +} + +#[test] +fn reset_tool_and_reset_all_tools_give_50_pt_in_the_running_app() { + let ctx = egui::Context::default(); + for tool in [Tool::Type, Tool::VerticalType] { + let mut app = app_with_document(800, 600, 72.0); + app.ui.tool = tool; + app.ui.tool_options.type_size = 12.0; + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET, json!({})).unwrap(); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{tool:?} › Reset Tool"); + assert_eq!(scripted_size(&app.ui.tool_options), 50.0, "{tool:?} › Reset Tool, scripted"); + + app.ui.tool_options.type_size = 12.0; + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET_ALL, json!({"confirmed": true})).unwrap(); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{tool:?} › Reset All Tools"); + } +} + +/// The default is a constant of the tool, not a function of the document: the measured reference +/// values are 800x600 at 72 ppi, 3000x2000 at 300 ppi and 100x100 at 72 ppi. +#[test] +fn the_default_size_does_not_depend_on_the_document() { + for (width, height, dpi) in [(800u32, 600u32, 72.0f64), (3000, 2000, 300.0), (100, 100, 72.0)] { + let app = app_with_document(width, height, dpi); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{width}x{height} at {dpi} ppi"); + assert_eq!(scripted_size(&app.ui.tool_options), 50.0, "{width}x{height} at {dpi} ppi: scripted tool options"); + } +} + +/// A tool option saved by an older version is a remembered choice: a saved 48 stays 48. +#[test] +fn a_size_saved_by_an_older_version_keeps_its_value() { + let mut restarted = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + restarted.session.prefs.edit(|p| p.tool_options = json!({ "type_size": 48.0 })); + crate::tool_memory::restore(&mut restarted); + assert_eq!(restarted.ui.tool_options.type_size, 48.0, "a saved 48 pt is not overwritten by the new default"); +} + +/// The default is also the fallback for a damaged remembered value (`tool_memory::sanitize`, +/// the second site #12051 names), so it follows it to 50 pt. +#[test] +fn a_damaged_saved_size_falls_back_to_50_pt() { + let mut o = ToolOptions { type_size: f32::NAN, ..ToolOptions::default() }; + crate::tool_memory::sanitize(&mut o); + assert_eq!(o.type_size, 50.0, "a damaged value takes the default"); +} diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index b70974d0b2..6b31332d50 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -1,4 +1,4 @@ -//! Selected tool and tool options remembered between launches, as in Photoshop (#2814). +//! Selected tool, colours and tool options remembered between launches, as in Photoshop (#2814). //! //! `Preferences::tool_options` holds the [`ToolOptions`] fields that differ from their defaults, //! so a default changed in a later release still reaches users who never touched that option. @@ -25,6 +25,9 @@ use crate::state::{Tool, ToolOptions}; /// The tool whose brush the session brush is, and the other painting tools' brushes. pub(crate) type ToolBrushes = (Tool, Vec<(Tool, BrushSettings)>); +/// The sRGB stand-ins and exact inks of recently picked CMYK colours, oldest first. +pub(crate) type CmykPicks = Vec<([f32; 3], [f32; 4])>; + /// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; @@ -85,6 +88,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.request_repaint(); } } + persist_colors(app, ctx); persist_group_tools(app, ctx, tool); persist_brushes(app); if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { @@ -97,6 +101,83 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Keep pixel and mask swatches separately, even when the app exits with a mask targeted. +fn colors(app: &PhotocraftApp) -> [[[f32; 4]; 2]; 2] { + let t = &app.session.tools; + let current = [t.foreground, t.background]; + if t.mask_targeted { [t.other_colors, current] } else { [current, t.other_colors] } +} + +fn persist_colors(app: &mut PhotocraftApp, ctx: &egui::Context) { + let now = colors(app); + if app.prefs_rt.tool_colors == Some(now) && app.prefs_rt.tool_cmyk_picks == app.session.tools.cmyk_picks { + return; + } + app.prefs_rt.tool_colors = Some(now); + app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); + let saved = color_value(now, &app.prefs_rt.tool_cmyk_picks); + app.session.prefs.edit(|p| p.tool_colors = saved); + // Panels and shortcuts can change colours after prefs_ui::tick already ran this frame. + ctx.request_repaint(); +} + +fn color_value(pairs: [[[f32; 4]; 2]; 2], cmyk_picks: &CmykPicks) -> Value { + let [[foreground, background], [mask_foreground, mask_background]] = pairs; + json!({ + "foreground": foreground, "background": background, + "maskForeground": mask_foreground, "maskBackground": mask_background, + "cmykPicks": cmyk_picks, + }) +} + +/// A missing legacy value represents these defaults, not an indivisible null leaf. +pub(crate) fn default_colors() -> Value { + let tools = photocraft_engine::ToolState::default(); + color_value([[tools.foreground, tools.background], tools.other_colors], &tools.cmyk_picks) +} + +fn restore_colors(app: &mut PhotocraftApp) { + let saved = &app.session.prefs().tool_colors; + let mut pairs = colors(app); + let [[foreground, background], [mask_foreground, mask_background]] = &mut pairs; + for (key, color) in [("foreground", foreground), ("background", background), ("maskForeground", mask_foreground), ("maskBackground", mask_background)] { + if let Some(v) = saved.get(key) { + match serde_json::from_value::<[f32; 4]>(v.clone()) { + Ok(c) if c.iter().all(|x| x.is_finite()) => *color = c, + _ => log::warn!("Preferences: ignoring the remembered `{key}` colour"), + } + } + } + // Parse only a bounded number of valid entries, newest first, before mutating the session. + // `remember_cmyk` deduplicates entries and enforces the same bound as during a live session. + let picks: Option = saved.get("cmykPicks").and_then(Value::as_array).map(|entries| { + entries + .iter() + .rev() + .filter_map(|v| match serde_json::from_value::<([f32; 3], [f32; 4])>(v.clone()) { + Ok((rgb, inks)) if rgb.iter().all(|x| x.is_finite()) && inks.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x)) => Some((rgb, inks)), + _ => { + log::warn!("Preferences: ignoring a malformed remembered CMYK colour"); + None + } + }) + .take(photocraft_engine::ToolState::CMYK_PICKS) + .collect() + }); + let [pixels, mask] = pairs; + let (current, other) = if app.session.tools.mask_targeted { (mask, pixels) } else { (pixels, mask) }; + [app.session.tools.foreground, app.session.tools.background] = current; + app.session.tools.other_colors = other; + if let Some(picks) = picks { + app.session.tools.cmyk_picks.clear(); + for (rgb, inks) in picks.into_iter().rev() { + app.session.tools.remember_cmyk(rgb, inks); + } + } + app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); + app.prefs_rt.tool_colors = Some(pairs); +} + /// Remember the tools used last when the list changes, whether or not the selected tool did: a /// tool picked during a drag that ends on the tool it began with changes only the list. fn persist_group_tools(app: &mut PhotocraftApp, ctx: &egui::Context, tool: Tool) { @@ -189,7 +270,7 @@ fn restore_brushes(app: &mut PhotocraftApp) { app.prefs_rt.tool_brushes = Some((owner, app.ui.tool_brushes.clone())); } -/// Flush the final choice on normal exit, even if no further frame ran after the tool changed. +/// Flush the final tool state on normal exit, even if no further frame ran after a change. /// The brush first, as in a frame, so the saved brush owner matches the saved brush. pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { let ctx = egui::Context::default(); @@ -247,6 +328,7 @@ pub fn restore(app: &mut PhotocraftApp) { log::warn!("Preferences: the remembered tool options are not an object; using the defaults"); } app.prefs_rt.tool_options = Some(app.ui.tool_options.clone()); + restore_colors(app); restore_brushes(app); } diff --git a/crates/ui-egui/src/tools/align_bar.rs b/crates/ui-egui/src/tools/align_bar.rs index 51ac7ba4c1..927236903b 100644 --- a/crates/ui-egui/src/tools/align_bar.rs +++ b/crates/ui-egui/src/tools/align_bar.rs @@ -13,7 +13,7 @@ use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; /// (command id, icon, label). The labels are the Layer menu's, already translated. -const ALIGN: [(&str, &str, &str); 6] = [ +pub(crate) const ALIGN: [(&str, &str, &str); 6] = [ ("layer.align.leftEdges", "align-edge-left", "Left Edges"), ("layer.align.horizontalCenters", "align-edge-hcenter", "Horizontal Centers"), ("layer.align.rightEdges", "align-edge-right", "Right Edges"), diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index ed113909a6..a3b9826acb 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -29,9 +29,13 @@ pub enum Drag { Count(usize, usize, [f64; 2]), /// Moving note `index`: grab offset from its position. Note(usize, [f64; 2]), - /// Moving Color Sampler point `index` from where the press landed (a click, not a drag, does - /// nothing; dragging off the canvas removes the point). + /// Moving Color Sampler point `index`; the second field is where the pointer is now, where its + /// marker is drawn (#11729). The release moves the point there (a click on it too); releasing + /// off the canvas removes it. Sampler(usize, [f64; 2]), + /// A new Color Sampler point following the pointer, placed where the drag ends if that is on + /// the canvas (#11729). + SamplerNew([f64; 2]), } /// View state of the analysis tools and panels. @@ -347,6 +351,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } } } + // As in Photoshop (#11729): the press only starts a drag and the release decides. A new + // point follows the pointer and lands where the drag ends; a press on a point drags it + // there (a click on it moves it to the clicked pixel); Escape cancels either drag. (Tool::ColorSampler, ToolEvent::Down { x, y, .. }) => { let p = [x, y]; match nearest_sampler(&samplers, p, tol) { @@ -354,34 +361,28 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> Some(i) if mods.alt => { let _ = app.run("view.colorSamplers.delete", json!({"index": i})); } - // A plain click on a point starts a move (committed on release). Some(i) => app.ui.analysis.drag = Some(Drag::Sampler(i, p)), - // ⌥-click on empty space does nothing; a plain click inside places a new point. - None if mods.alt => {} - None if (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y) => { - let r = app.run("view.colorSamplers.add", json!({"x": x, "y": y})); - status_on_err(app, r); - } - // A click outside the canvas places nothing, as in Photoshop. - None => {} + // Off a point (⌥ or not, on the pasteboard too) a new point starts following the pointer. + None => app.ui.analysis.drag = Some(Drag::SamplerNew(p)), + } + } + (Tool::ColorSampler, ToolEvent::Move { x, y, .. }) => { + if let Some(Drag::Sampler(_, at) | Drag::SamplerNew(at)) = &mut app.ui.analysis.drag { + *at = [x, y]; } } (Tool::ColorSampler, ToolEvent::Up { x, y }) => { - if let Some(Drag::Sampler(i, from)) = app.ui.analysis.drag.take() { - if dist(from, [x, y]) <= 0.5 { - return true; // a click on a point, not a drag: nothing to do - } - // Dropped inside the canvas: move it. Dropped outside: remove just that point. - let inside = (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y); - let r = if inside { - app.run("view.colorSamplers.move", json!({"index": i, "x": x, "y": y})) - } else { - app.run("view.colorSamplers.delete", json!({"index": i})) - }; - status_on_err(app, r); - } + let inside = (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y); + let r = match app.ui.analysis.drag.take() { + // Released inside the canvas: move it there. Outside: remove just that point. + Some(Drag::Sampler(i, _)) if inside => app.run("view.colorSamplers.move", json!({"index": i, "x": x, "y": y})), + Some(Drag::Sampler(i, _)) => app.run("view.colorSamplers.delete", json!({"index": i})), + // A new point is placed where the drag ends; ending outside the canvas places none. + Some(Drag::SamplerNew(_)) if inside => app.run("view.colorSamplers.add", json!({"x": x, "y": y})), + _ => return true, + }; + status_on_err(app, r); } - (Tool::ColorSampler, ToolEvent::Move { .. }) => {} _ => {} } true @@ -392,6 +393,18 @@ fn nearest_sampler(points: &[[f64; 2]], at: [f64; 2], tol: f64) -> Option points.iter().enumerate().filter(|(_, q)| dist(**q, at) <= tol).min_by(|a, b| dist(*a.1, at).total_cmp(&dist(*b.1, at))).map(|(i, _)| i) } +/// The Color Sampler point under document point `at` on the active document, by the same hit test +/// a click uses, so the right-click menu acts on the sampler a click would grab. +pub(crate) fn sampler_at(app: &PhotocraftApp, at: [f64; 2]) -> Option { + if !at.iter().all(|v| v.is_finite()) { + return None; + } + nearest_sampler(&app.session.active()?.color_samplers, at, tolerance(app)) +} + +#[cfg(test)] +mod sampler_menu_tests; + /// Show a command error in the status bar without treating the sample-limit message as a fault. fn status_on_err(app: &mut PhotocraftApp, r: Result) { if let Err(e) = r { @@ -478,7 +491,15 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } // Color Sampler points (#1046): persistent numbered markers, drawn whatever the active tool is. - for (i, p) in app.session.active().map(|d| d.color_samplers.clone()).unwrap_or_default().iter().enumerate() { + // While one is dragged its marker follows the pointer, and a new one shows the next number + // there (#11729), unless the limit is reached and the release cannot place it. + let mut samplers = app.session.active().map(|d| d.color_samplers.clone()).unwrap_or_default(); + match app.ui.analysis.drag { + Some(Drag::Sampler(i, at)) if app.ui.tool == Tool::ColorSampler && i < samplers.len() => samplers[i] = at, + Some(Drag::SamplerNew(at)) if app.ui.tool == Tool::ColorSampler && samplers.len() < photocraft_engine::sampler_cmds::MAX_SAMPLERS => samplers.push(at), + _ => {} + } + for (i, p) in samplers.iter().enumerate() { let at = xf.to_screen(p[0] as f32, p[1] as f32); // A small target reticle, double-stroked so it reads on any pixel. for (w, c) in [(3.0, Color32::from_black_alpha(170)), (1.0, Color32::WHITE)] { @@ -998,6 +1019,9 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { #[cfg(test)] pub(crate) mod tests; +#[cfg(test)] +mod sampler_drag_tests; + /// Marks the "Delete this note?" alert (#11788). const DELETE_NOTE_MARK: &str = "__deleteNote"; /// Its "Don't show again", kept with the other dialog preferences. @@ -1073,6 +1097,16 @@ pub fn confirm_delete_note(app: &mut PhotocraftApp, fields: &Map) /// layer (Delete Layer) or the pixels (Edit › Clear). Text fields keep the keys. pub fn note_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { use crate::shortcut_dispatch::Focus; + // Escape during a Color Sampler drag cancels it: no new point, or the dragged one stays (#11729). + // A tool picked mid-drag gets the release, so the drag is over: drop it and leave Escape alone. + if matches!(app.ui.analysis.drag, Some(Drag::Sampler(..) | Drag::SamplerNew(_))) { + if app.ui.tool != Tool::ColorSampler { + app.ui.analysis.drag = None; + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + app.ui.analysis.drag = None; + return true; + } + } let Some(index) = app.ui.analysis.note_selected else { return false }; if app.ui.tool != Tool::Note || app.ui.text_edit.is_some() || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) { return false; diff --git a/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs b/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs new file mode 100644 index 0000000000..4c66548ddc --- /dev/null +++ b/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs @@ -0,0 +1,246 @@ +//! #11729: Color Sampler drag gestures, as in Photoshop. A new point follows the pointer and lands +//! where the drag ends, a press on a point drags it (a click on it moves it to the clicked pixel), +//! ⌥-click off a point adds one, and Escape cancels either drag. + +use super::*; +use egui::Modifiers; + +const NONE: Modifiers = Modifiers::NONE; +const ALT: Modifiers = Modifiers { alt: true, ctrl: false, shift: false, mac_cmd: false, command: false }; + +/// A 200 × 100 document with the Color Sampler tool. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::ColorSampler; + app +} + +fn down(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + crate::canvas::tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); +} + +fn mv(app: &mut PhotocraftApp, x: f64, y: f64) { + crate::canvas::tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, NONE); +} + +fn up(app: &mut PhotocraftApp, x: f64, y: f64) { + crate::canvas::tool_event(app, ToolEvent::Up { x, y }, NONE); +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + down(app, from[0], from[1], NONE); + mv(app, (from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0); + mv(app, to[0], to[1]); + up(app, to[0], to[1]); +} + +fn samplers(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.session.active().unwrap().color_samplers.clone() +} + +/// The pixel each point sits on (the stored position floors to it). +fn pixels(app: &PhotocraftApp) -> Vec<[i64; 2]> { + samplers(app).iter().map(|p| [p[0].floor() as i64, p[1].floor() as i64]).collect() +} + +/// Escape through the app's shortcut handling, as the canvas gets it. +fn escape(app: &mut PhotocraftApp) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![ + egui::Event::ModifiersChanged(NONE), + egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: NONE }, + ], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +#[test] +fn a_new_point_follows_the_pointer_and_lands_where_the_drag_ends() { + let mut app = app(); + down(&mut app, 120.0, 40.0, NONE); + assert!(samplers(&app).is_empty(), "nothing is placed on the press"); + mv(&mut app, 135.0, 50.0); + assert_eq!(app.ui.analysis.drag, Some(Drag::SamplerNew([135.0, 50.0])), "the marker follows the pointer"); + mv(&mut app, 150.0, 60.0); + up(&mut app, 150.0, 60.0); + assert_eq!(pixels(&app), vec![[150, 60]]); + assert_eq!(app.ui.analysis.drag, None); +} + +#[test] +fn a_drag_ending_off_the_canvas_places_nothing_and_one_from_the_pasteboard_places_one() { + let mut app = app(); + drag(&mut app, [150.0, 50.0], [230.0, 50.0]); + assert!(samplers(&app).is_empty(), "ended on the pasteboard"); + drag(&mut app, [-20.0, 50.0], [30.0, 40.0]); + assert_eq!(pixels(&app), vec![[30, 40]], "started on the pasteboard, ended on the canvas"); +} + +#[test] +fn a_plain_click_off_a_point_still_places_one_at_that_pixel() { + let mut app = app(); + down(&mut app, 12.4, 7.9, NONE); + up(&mut app, 12.4, 7.9); + assert_eq!(pixels(&app), vec![[12, 7]]); + // A click on the pasteboard places nothing. + down(&mut app, -5.0, 7.0, NONE); + up(&mut app, -5.0, 7.0); + assert_eq!(samplers(&app).len(), 1); +} + +#[test] +fn dragging_a_point_moves_its_marker_live_and_the_point_on_release() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 100, "y": 20})).unwrap(); + let before = samplers(&app); + down(&mut app, before[0][0], before[0][1], NONE); + mv(&mut app, 70.0, 30.0); + assert_eq!(app.ui.analysis.drag, Some(Drag::Sampler(0, [70.0, 30.0])), "the marker follows the pointer"); + assert_eq!(samplers(&app), before, "the point itself moves on release"); + mv(&mut app, 80.0, 20.0); + up(&mut app, 80.0, 20.0); + assert_eq!(pixels(&app), vec![[80, 20], [100, 20]], "#1 moved, #2 untouched, numbering kept"); +} + +#[test] +fn a_click_on_a_point_two_pixels_off_moves_it_to_the_clicked_pixel() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let p = samplers(&app)[0]; + down(&mut app, p[0] + 2.0, p[1] + 1.0, NONE); + up(&mut app, p[0] + 2.0, p[1] + 1.0); + assert_eq!(pixels(&app), vec![[52, 51]]); +} + +#[test] +fn dragging_a_point_off_the_document_still_deletes_it() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 100, "y": 20})).unwrap(); + let p = samplers(&app)[0]; + drag(&mut app, p, [-30.0, 50.0]); + assert_eq!(pixels(&app), vec![[100, 20]]); +} + +#[test] +fn alt_click_off_a_point_adds_one_and_on_a_point_deletes_it() { + let mut app = app(); + down(&mut app, 60.0, 30.0, ALT); + up(&mut app, 60.0, 30.0); + assert_eq!(pixels(&app), vec![[60, 30]], "⌥-click off a point adds one, like a plain click"); + let p = samplers(&app)[0]; + down(&mut app, p[0], p[1], ALT); + up(&mut app, p[0], p[1]); + assert!(samplers(&app).is_empty(), "⌥-click on a point deletes it"); +} + +#[test] +fn escape_cancels_either_drag_and_leaves_the_list_unchanged() { + // A new point: Escape mid-drag, then the release places nothing. + let mut app = app(); + down(&mut app, 40.0, 40.0, NONE); + mv(&mut app, 60.0, 50.0); + escape(&mut app); + assert_eq!(app.ui.analysis.drag, None); + up(&mut app, 60.0, 50.0); + assert!(samplers(&app).is_empty(), "no new point"); + // An existing point stays where it was. + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let before = samplers(&app); + down(&mut app, before[0][0], before[0][1], NONE); + mv(&mut app, 90.0, 10.0); + escape(&mut app); + up(&mut app, 90.0, 10.0); + assert_eq!(samplers(&app), before, "the dragged point stays put"); +} + +#[test] +fn the_dragged_and_the_new_marker_are_drawn_at_the_pointer() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let ctx = egui::Context::default(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 100.0)), + zoom: 1.0, + center: [100.0, 50.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + // The marker numbers the overlay paints, with where they are. + let numbers_at = |app: &PhotocraftApp| -> Vec<(String, egui::Pos2)> { + let mut o = ctx.run_ui(Default::default(), |ui| draw_overlay(app, ui.painter(), &xf)); + o.textures_delta.clear(); + o.shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_owned(), t.pos)), + _ => None, + }) + .collect() + }; + let p = samplers(&app)[0]; + down(&mut app, p[0], p[1], NONE); + mv(&mut app, 120.0, 30.0); + let at = xf.to_screen(120.0, 30.0); + let shown = numbers_at(&app); + assert!(shown.iter().any(|(t, q)| t == "1" && (q.x - at.x).abs() < 20.0 && (q.y - at.y).abs() < 20.0), "#1 follows the pointer: {shown:?}"); + up(&mut app, 120.0, 30.0); + down(&mut app, 20.0, 80.0, NONE); + mv(&mut app, 30.0, 70.0); + let at = xf.to_screen(30.0, 70.0); + let shown = numbers_at(&app); + assert!(shown.iter().any(|(t, q)| t == "2" && (q.x - at.x).abs() < 20.0 && (q.y - at.y).abs() < 20.0), "the new #2 follows the pointer: {shown:?}"); +} + +#[test] +fn at_the_ten_point_limit_a_drag_off_a_point_previews_no_new_marker() { + let mut app = app(); + for i in 0..10 { + app.run("view.colorSamplers.add", json!({"x": 10 + 15 * i, "y": 10})).unwrap(); + } + let ctx = egui::Context::default(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 100.0)), + zoom: 1.0, + center: [100.0, 50.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + down(&mut app, 50.0, 70.0, NONE); + mv(&mut app, 60.0, 80.0); + let mut o = ctx.run_ui(Default::default(), |ui| draw_overlay(&app, ui.painter(), &xf)); + o.textures_delta.clear(); + let numbers: Vec = o + .shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_owned()), + _ => None, + }) + .collect(); + assert!(!numbers.iter().any(|t| t == "11"), "no 11th marker that the release cannot place: {numbers:?}"); + up(&mut app, 60.0, 80.0); + assert_eq!(samplers(&app).len(), 10); +} + +#[test] +fn a_sampler_drag_whose_release_went_to_another_tool_leaves_escape_alone() { + let mut app = app(); + app.run("select.all", json!({})).unwrap(); + down(&mut app, 40.0, 40.0, NONE); + mv(&mut app, 60.0, 50.0); + // A tool letter mid-drag: the release goes to the new tool. + crate::tool_memory::select(&mut app, Tool::Hand); + up(&mut app, 60.0, 50.0); + escape(&mut app); + assert_eq!(app.ui.analysis.drag, None, "the stale drag is dropped"); + assert!(app.session.active().unwrap().doc.selection.is_none(), "Escape reached Deselect, not the stale sampler drag"); +} diff --git a/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs new file mode 100644 index 0000000000..9388a31f68 --- /dev/null +++ b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs @@ -0,0 +1,217 @@ +//! #11733: the Color Sampler's right-click menu on the canvas. + +use egui::{Event, Modifiers, PointerButton, vec2}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +use crate::canvas_tool_menu::{SAMPLER_DELETE, SAMPLER_MENU}; +use crate::control::{ControlRequest, Outcome, handle}; +use crate::{PhotocraftApp, Tool}; + +/// A 200 x 100 document with the Color Sampler and samplers at (20, 20) and (120, 60). +fn app() -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 20, "y": 20})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 120, "y": 60})).unwrap(); + app.ui.tool = Tool::ColorSampler; + (app, egui::Context::default()) +} + +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (req, _rx) = ControlRequest::new(method, params); + let Outcome::Done(v) = handle(app, ctx, &req) else { panic!("{method} replies at once") }; + v +} + +/// An agent's right-click at document point (x, y), as `ui.pointer` sends it. +fn right_click(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64) { + let r = control(app, ctx, "ui.pointer", json!({"button": "secondary", "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]})); + assert_eq!(r["ok"], json!(true), "{r}"); +} + +fn entries(app: &mut PhotocraftApp, ctx: &egui::Context) -> Vec { + let r = control(app, ctx, "ui.inspect", json!({})); + r["result"]["canvasToolMenu"]["entries"].as_array().cloned().unwrap_or_else(|| panic!("no canvasToolMenu: {r}")) +} + +fn samplers(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.session.active().unwrap().color_samplers.clone() +} + +/// The reference application's rows, in order, with `|` for a separator. +const EXPECTED: &[&str] = &[ + "Delete", + "|", + "Actual Color", + "Proof Color", + "|", + "Grayscale", + "RGB Color", + "Web Color", + "HSB Color", + "HSL Color", + "CMYK Color", + "Lab Color", + "|", + "Grayscale 16 bit", + "RGB 16 bit", + "CMYK 16 bit", + "Lab 16 bit", + "|", + "Total Ink", +]; + +fn labels(entries: &[Value]) -> Vec { + entries.iter().map(|e| if e["separator"] == json!(true) { "|".to_string() } else { e["label"].as_str().unwrap().to_string() }).collect() +} + +#[test] +fn right_click_on_a_sampler_lists_the_menu_in_order_with_delete_enabled() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 20.5, 20.5); + let menu = app.ui.canvas_tool_menu.clone().expect("the Color Sampler menu opens"); + assert_eq!(menu.tool, Tool::ColorSampler); + assert_eq!(menu.sampler, Some(0)); + let entries = entries(&mut app, &ctx); + assert_eq!(labels(&entries), EXPECTED); + let enabled: Vec<&str> = entries.iter().filter(|e| e["enabled"] == json!(true)).filter_map(|e| e["id"].as_str()).collect(); + // The readout rows wait for per-sampler readout modes (#11735) and stay greyed until then. + assert_eq!(enabled, [SAMPLER_DELETE]); + for (_, id) in SAMPLER_MENU.iter().flatten() { + assert!(!crate::canvas_tool_menu::mode_checked(&app, &menu, id), "{id} shows no checkmark"); + } + assert_eq!(samplers(&app).len(), 2, "the right-click places no sampler"); +} + +#[test] +fn right_click_off_a_sampler_greys_every_row() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 70.0, 40.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the menu opens off a sampler too"); + assert_eq!(menu.sampler, None); + let entries = entries(&mut app, &ctx); + assert_eq!(labels(&entries), EXPECTED); + assert!(entries.iter().all(|e| e["enabled"] != json!(true)), "every row greyed: {entries:?}"); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(false), "a greyed Delete does nothing: {r}"); + assert_eq!(samplers(&app).len(), 2, "nothing deleted, no sampler placed"); +} + +#[test] +fn delete_removes_the_sampler_under_the_pointer_and_renumbers_the_rest() { + let (mut app, ctx) = app(); + app.run("view.colorSamplers.add", json!({"x": 180, "y": 90})).unwrap(); + right_click(&mut app, &ctx, 120.0, 60.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(samplers(&app), [[20.0, 20.0], [180.0, 90.0]], "#3 became #2"); + assert!(app.ui.canvas_tool_menu.is_none(), "choosing closes the menu"); + let listed = app.run("view.colorSamplers.list", json!({})).unwrap(); + assert_eq!(listed["samplers"][1]["number"], json!(2)); +} + +#[test] +fn delete_never_removes_another_sampler_after_the_list_changes_under_the_menu() { + let (mut app, ctx) = app(); + app.run("view.colorSamplers.add", json!({"x": 180, "y": 90})).unwrap(); + right_click(&mut app, &ctx, 120.0, 60.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + // An agent deletes #1 while the menu is open: index 1 now names the old #3. + app.run("view.colorSamplers.delete", json!({"index": 0})).unwrap(); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(false), "Delete greys out: {r}"); + assert_eq!(samplers(&app), [[120.0, 60.0], [180.0, 90.0]], "no other sampler is deleted"); + // The menu itself closes on the next frame. + right_click(&mut app, &ctx, 120.0, 60.0); + app.run("view.colorSamplers.move", json!({"index": 0, "x": 30, "y": 30})).unwrap(); + ctx.run_ui(Default::default(), |ui| crate::canvas_tool_menu::show(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.ui.canvas_tool_menu.is_none(), "a moved sampler closes the menu"); + // Another document with a sampler at the same index and spot: still not this menu's sampler. + right_click(&mut app, &ctx, 180.0, 90.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + for (x, y) in [(20, 20), (180, 90)] { + app.run("view.colorSamplers.add", json!({"x": x, "y": y})).unwrap(); + } + let menu = app.ui.canvas_tool_menu.clone().expect("the menu is still open"); + assert!(!crate::canvas_tool_menu::entry_enabled(&app, &menu, SAMPLER_DELETE), "another document's sampler"); +} + +#[test] +fn readout_rows_do_nothing_until_samplers_have_readout_modes() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 20.0, 20.0); + let before = app.session.active().unwrap().revision; + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": "colorSampler.readout.hsl"})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert_eq!(app.session.active().unwrap().revision, before); + assert_eq!(samplers(&app).len(), 2); +} + +#[test] +fn a_stale_sampler_or_odd_point_fails_gracefully() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 120.0, 60.0); + // The sampler went away while the menu was open: Delete greys out instead of removing another. + app.run("view.colorSamplers.clear", json!({})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap(); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(!crate::canvas_tool_menu::entry_enabled(&app, &menu, SAMPLER_DELETE)); + crate::canvas_tool_menu::choose(&mut app, &ctx, SAMPLER_DELETE); + assert_eq!(samplers(&app), [[5.0, 5.0]]); + for p in [[f64::NAN, 5.0], [f64::INFINITY, 5.0], [5.0, f64::NEG_INFINITY]] { + assert_eq!(super::sampler_at(&app, p), None); + } + // No document: the menu opens greyed, nothing panics. + let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + empty.ui.tool = Tool::ColorSampler; + assert_eq!(super::sampler_at(&empty, [1.0, 1.0]), None); + if crate::canvas_tool_menu::open_at(&mut empty, &ctx, Tool::ColorSampler, [10.0, 10.0], Some([1.0, 1.0])) { + let menu = empty.ui.canvas_tool_menu.clone().unwrap(); + assert!(SAMPLER_MENU.iter().flatten().all(|(_, id)| !crate::canvas_tool_menu::entry_enabled(&empty, &menu, id))); + crate::canvas_tool_menu::choose(&mut empty, &ctx, SAMPLER_DELETE); + } +} + +#[test] +fn other_tools_keep_their_menus() { + let (mut app, ctx) = app(); + app.ui.tool = Tool::RectMarquee; + right_click(&mut app, &ctx, 20.0, 20.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the selection menu"); + assert_eq!(crate::canvas_tool_menu::rows(&menu), crate::canvas_tool_menu::NO_SELECTION_MENU); + app.ui.canvas_tool_menu = None; + app.ui.tool = Tool::Pen; + right_click(&mut app, &ctx, 20.0, 20.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the path menu"); + assert_eq!(crate::canvas_tool_menu::rows(&menu), crate::canvas_tool_menu::PEN_MENU); + app.ui.canvas_tool_menu = None; + // Outside the image the Color Sampler keeps the pasteboard menu. + app.ui.tool = Tool::ColorSampler; + right_click(&mut app, &ctx, 250.0, 40.0); + assert!(app.ui.canvas_tool_menu.as_ref().is_some_and(|m| m.pasteboard)); +} + +/// A real right-click on the canvas over sampler #2 opens the menu on it. +#[test] +fn canvas_right_click_opens_the_menu_on_the_sampler() { + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app().0 + }); + h.run_steps(5); + let xf = crate::canvas::ViewXform::active(h.state()).unwrap(); + let at = xf.to_screen(120.5, 60.5); + h.event(Event::PointerMoved(at)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + let menu = h.state().ui.canvas_tool_menu.clone().expect("the Color Sampler menu opens"); + assert_eq!((menu.tool, menu.sampler), (Tool::ColorSampler, Some(1))); + assert_eq!(h.state().session.active().unwrap().color_samplers.len(), 2, "no sampler was placed or moved"); +} diff --git a/crates/ui-egui/src/tools/artboard_tool.rs b/crates/ui-egui/src/tools/artboard_tool.rs index 80e8d4779a..36909acb71 100644 --- a/crates/ui-egui/src/tools/artboard_tool.rs +++ b/crates/ui-egui/src/tools/artboard_tool.rs @@ -20,6 +20,7 @@ use crate::state::Tool; use crate::theme::Tokens; mod add_mode; +pub(crate) mod bar; /// How far from an edge (screen points) a press still grabs its handle. const HANDLE_PX: f64 = 6.0; @@ -33,14 +34,47 @@ const PLUS_RADIUS_PX: f64 = 8.0; /// direction from the board's centre. const SIDES: [(&str, [f64; 2]); 4] = [("top", [0.0, -1.0]), ("right", [1.0, 0.0]), ("bottom", [0.0, 1.0]), ("left", [-1.0, 0.0])]; -/// Artboard tool state (serde: readable through the control channel). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +/// Artboard tool state (serde: readable through the control channel). The fields after `drag` are +/// the tool's options (#11385), which Reset Tool puts back to [`ArtboardToolUi::default`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ArtboardToolUi { /// Add New Artboard's placing mode: a click on empty canvas puts a board there (#11386). pub adding: bool, #[serde(skip)] pub drag: Option, + /// The bar's size preset (empty: Custom) and, with no board selected, the size it shows. + pub preset: String, + pub width: u32, + pub height: u32, + /// The background of a board the tool draws: `white`, `black`, `transparent` or `custom`. + pub background: String, + /// `#rrggbb` of the `custom` background. + pub background_color: String, + /// The gear menu's four entries. + pub auto_nest: bool, + pub auto_size_canvas: bool, + pub keep_relative_position: bool, + pub shrinkwrap_on_save: bool, +} + +impl Default for ArtboardToolUi { + fn default() -> Self { + let (preset, width, height) = bar::DEFAULT_SIZE; + Self { + adding: false, + drag: None, + preset: preset.to_string(), + width, + height, + background: "white".to_string(), + background_color: "#ffffff".to_string(), + auto_nest: true, + auto_size_canvas: true, + keep_relative_position: true, + shrinkwrap_on_save: true, + } + } } /// A gesture in progress. Board rects are `[x0, y0, x1, y1]` in document pixels. @@ -241,7 +275,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> if r[2] <= r[0] || r[3] <= r[1] { return true; } - app.run("layer.new.artboard", json!({"rect": xywh(r), "takeBackground": true})) + let mut p = bar::with_background(app, json!({"rect": xywh(r)})); + p["takeBackground"] = json!(true); + app.run("layer.new.artboard", p) } ArtboardDrag::Move { id, rect, .. } if r != rect => app.run("layer.artboard.set", json!({"layer": id.0, "x": r[0], "y": r[1]})), // Resizing leaves the contents where they are, as in Photoshop. @@ -304,45 +340,20 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } -/// The Artboard tool's options bar: the selected board's size preset, W/H, Make Portrait / Make -/// Landscape, and Add New Artboard. +/// The Artboard tool's options bar: the size preset, W/H, Make Portrait / Make Landscape, the +/// background, Add New Artboard, the Align panel and the gear menu (the new controls are in `bar`). pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool { if tool != Tool::Artboard { add_mode::leave(app); return false; } - let t = Tokens::get(ui.ctx()); let board = active_board(app); - let preset = board.and_then(|(id, _)| app.session.active()?.doc.layer(id)?.artboard().map(|a| a.preset.clone())); - let mut edit: Option = None; - ui.add_enabled_ui(board.is_some(), |ui| { - let (id, r) = board.unwrap_or((LayerId(0), [0, 0, 0, 0])); - ui.label(egui::RichText::new(tl!("Size")).color(t.text_dim)); - let mut preset = preset.unwrap_or_default(); - let mut opts: Vec<(String, &str)> = vec![(String::new(), tl!("Custom"))]; - opts.extend(photocraft_engine::artboard_cmds::PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n))); - if crate::widgets::dropdown(ui, "artboard-tool-preset", tl!("Preset"), &mut preset, &opts, 150.0) && !preset.is_empty() { - edit = Some(json!({"layer": id.0, "preset": preset})); - } - let (mut w, mut h) = ((r[2] - r[0]) as f32, (r[3] - r[1]) as f32); - ui.label(egui::RichText::new(tl!("W")).color(t.text_dim)); - let cw = crate::widgets::value_field(ui, &mut w, 1.0..=300_000.0, "px", 64.0).accessible_name(tl!("Width")).changed(); - ui.label(egui::RichText::new(tl!("H")).color(t.text_dim)); - let ch = crate::widgets::value_field(ui, &mut h, 1.0..=300_000.0, "px", 64.0).accessible_name(tl!("Height")).changed(); - if cw || ch { - edit = Some(json!({"layer": id.0, "width": w.round(), "height": h.round(), "coalesce": format!("artboard-tool-wh-{}", id.0)})); - } - if crate::widgets::secondary_button(ui, tl!("Make Portrait"), 0.0).clicked() && w > h { - edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); - } - if crate::widgets::secondary_button(ui, tl!("Make Landscape"), 0.0).clicked() && h > w { - edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); - } - }); + let edit = bar::size_group(app, ui, board); if let Some(p) = edit { let r = app.run("layer.artboard.set", p); report(app, r); } + bar::background(app, ui); crate::widgets::vline(ui, 22.0); let has_doc = app.session.active().is_some(); // Drawn as the primary button while the placing mode is on. @@ -359,6 +370,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo if button.clicked() { add_mode::toggle(app); } + bar::align_and_gear(app, ui); true } diff --git a/crates/ui-egui/src/tools/artboard_tool/add_mode.rs b/crates/ui-egui/src/tools/artboard_tool/add_mode.rs index c46d6d737d..10df5eaaa5 100644 --- a/crates/ui-egui/src/tools/artboard_tool/add_mode.rs +++ b/crates/ui-egui/src/tools/artboard_tool/add_mode.rs @@ -5,7 +5,6 @@ use serde_json::{Value, json}; -use super::active_board; use crate::PhotocraftApp; use crate::state::Tool; @@ -24,23 +23,21 @@ pub(super) fn leave(app: &mut PhotocraftApp) { app.ui.artboard_tool.adding = false; } -/// The size the options bar shows: the selected board's, else the canvas size (the engine's -/// fallback for a board with no size). -fn bar_size(app: &PhotocraftApp) -> Option<[i64; 2]> { - if let Some((_, r)) = active_board(app) { - return Some([r[2].saturating_sub(r[0]), r[3].saturating_sub(r[1])]); - } - let size = app.session.active()?.doc.size; - Some([i64::from(size.width), i64::from(size.height)]) -} - /// A click at document point `p` on empty canvas: one `layer.new.artboard` with the bar's size and /// its top-left corner at the click, rounded to whole pixels. None when the mode is off. pub(super) fn click(app: &mut PhotocraftApp, p: [f64; 2]) -> Option> { if !active(app) { return None; } - let [w, h] = bar_size(app)?; + app.session.active()?; + let [w, h] = super::bar::bar_size(app); let rect = [p[0].round() as i64, p[1].round() as i64, w, h]; - Some(app.run("layer.new.artboard", json!({"rect": rect}))) + let mut params = json!({"rect": rect}); + // With no board selected the size is the tool option's, so the board also keeps its preset + // name (the engine stores it and, with an explicit `rect`, the rect still decides the size). + let preset = &app.ui.artboard_tool.preset; + if !preset.is_empty() && super::active_board(app).is_none() { + params["preset"] = json!(preset); + } + Some(app.run("layer.new.artboard", super::bar::with_background(app, params))) } diff --git a/crates/ui-egui/src/tools/artboard_tool/bar.rs b/crates/ui-egui/src/tools/artboard_tool/bar.rs new file mode 100644 index 0000000000..e42199577e --- /dev/null +++ b/crates/ui-egui/src/tools/artboard_tool/bar.rs @@ -0,0 +1,343 @@ +//! The Artboard tool's options bar controls (#11385): the size group (preset, W, H, Make Portrait, +//! Make Landscape) that edits the selected board, or the tool's own options when no board is +//! selected; the background swatch and dropdown for the boards the tool draws; the Align panel; and +//! the gear menu. The options live in [`ArtboardToolUi`](super::ArtboardToolUi). + +use egui::Color32; +use photocraft_doc::{ArtboardBackground, Layer, LayerId}; +use photocraft_engine::artboard_cmds::PRESETS; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{PhotocraftApp, icons}; + +/// The size preset a fresh bar (and Reset Tool) shows, with its width and height. +pub const DEFAULT_SIZE: (&str, u32, u32) = ("iPhone 8/7/6", 750, 1334); + +/// The largest board side the bar accepts (the engine's limit). +const MAX_SIDE: f32 = 300_000.0; + +/// The Align panel: three groups of entries. An entry is a label and the `layer.align.*` command +/// it runs; Distribute Widths / Heights have none yet (their behaviour is not measured), so they +/// show disabled. +pub const ALIGN_PANEL: [&[(&str, Option<&str>)]; 3] = [ + &[ + ("Left Edges", Some("layer.align.leftEdges")), + ("Horizontal Centers", Some("layer.align.horizontalCenters")), + ("Right Edges", Some("layer.align.rightEdges")), + ], + &[ + ("Top Edges", Some("layer.align.topEdges")), + ("Vertical Centers", Some("layer.align.verticalCenters")), + ("Bottom Edges", Some("layer.align.bottomEdges")), + ], + &[("Distribute Widths", None), ("Distribute Heights", None)], +]; + +/// The gear menu's entries, in order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Gear { + AutoNest, + AutoSizeCanvas, + KeepRelativePosition, + ShrinkwrapOnSave, +} + +/// Each gear entry and its label. +pub const GEAR: [(Gear, &str); 4] = [ + (Gear::AutoNest, "Auto-nest Layers"), + (Gear::AutoSizeCanvas, "Auto-size Canvas"), + (Gear::KeepRelativePosition, "Keep Relative Position During Layer Reordering"), + (Gear::ShrinkwrapOnSave, "Shrinkwrap Canvas on Save"), +]; + +impl Gear { + /// Is the entry checked? + pub fn checked(self, o: &super::ArtboardToolUi) -> bool { + match self { + Gear::AutoNest => o.auto_nest, + Gear::AutoSizeCanvas => o.auto_size_canvas, + Gear::KeepRelativePosition => o.keep_relative_position, + Gear::ShrinkwrapOnSave => o.shrinkwrap_on_save, + } + } + + /// Only Auto-nest Layers does something yet (the Move tool's auto-nesting, #10512); the others + /// are drawn checked and disabled until their behaviour is measured. + pub fn enabled(self) -> bool { + self == Gear::AutoNest + } +} + +/// "The bar's size": the selected board's size, else the tool option's. `[width, height]` in pixels. +pub fn bar_size(app: &PhotocraftApp) -> [i64; 2] { + match super::active_board(app) { + Some((_, r)) => [r[2].saturating_sub(r[0]), r[3].saturating_sub(r[1])], + None => [i64::from(app.ui.artboard_tool.width), i64::from(app.ui.artboard_tool.height)], + } +} + +/// `params` for `layer.new.artboard` plus the bar's background: `background`, and `color` for Other…. +pub fn with_background(app: &PhotocraftApp, mut params: Value) -> Value { + let o = &app.ui.artboard_tool; + params["background"] = json!(o.background); + if o.background == "custom" { + params["color"] = json!(o.background_color); + } + params +} + +/// Choose size preset `name` as the tool option: its size, no board involved. Unknown names and +/// Custom (empty) change nothing. +pub fn set_preset(app: &mut PhotocraftApp, name: &str) { + if let Some((n, w, h)) = PRESETS.iter().find(|(n, _, _)| *n == name) { + let o = &mut app.ui.artboard_tool; + (o.preset, o.width, o.height) = (n.to_string(), *w, *h); + } +} + +/// Set the tool option size to `w` x `h` (clamped to 1..=300000); the preset becomes Custom, or +/// stays when the size is still the preset's. +pub fn set_size(app: &mut PhotocraftApp, w: f32, h: f32) { + let clamp = |v: f32| if v.is_finite() { v.round().clamp(1.0, MAX_SIDE) as u32 } else { 1 }; + let (w, h) = (clamp(w), clamp(h)); + let o = &mut app.ui.artboard_tool; + (o.width, o.height) = (w, h); + if !PRESETS.iter().any(|(n, pw, ph)| *n == o.preset && (*pw, *ph) == (w, h)) { + o.preset.clear(); + } +} + +/// The size preset the bar shows for board `id`: the name stored on the board ("" = Custom). +pub fn board_preset(app: &PhotocraftApp, id: LayerId) -> String { + app.session.active().and_then(|st| st.doc.layer(id)).and_then(Layer::artboard).map(|a| a.preset.clone()).unwrap_or_default() +} + +/// The selected boards: each selected layer's artboard, once, in document order. +fn selected_boards(app: &PhotocraftApp) -> Vec { + let Some(st) = app.session.active() else { return Vec::new() }; + let mut ids: Vec = Vec::new(); + for l in st.selected_layers() { + if let Some(b) = st.doc.artboard_of(l) + && !ids.contains(&b) + { + ids.push(b); + } + } + ids +} + +/// The background the bar shows as `(name, #rrggbb)`: the first selected board's, else the tool +/// option's. The colour is the custom one (or the option's remembered one for the named ones). +pub fn shown_background(app: &PhotocraftApp) -> (String, String) { + let o = &app.ui.artboard_tool; + let first = selected_boards(app).first().and_then(|id| app.session.active()?.doc.layer(*id)?.artboard().map(|a| a.background)); + match first { + Some(ArtboardBackground::Custom(c)) => { + let [r, g, b, _] = c.to_rgba8(); + ("custom".to_string(), format!("#{r:02x}{g:02x}{b:02x}")) + } + Some(b) => (b.name().to_string(), o.background_color.clone()), + None => (o.background.clone(), o.background_color.clone()), + } +} + +/// Choose background `name` (`white`, `black`, `transparent` or `custom` with `color`, `#rrggbb`). +/// It is the tool option for the next drawn board, and with boards selected also the background of +/// every selected board in one `layer.artboard.set` step ("Edit Artboard" / "Edit Artboards"). +pub fn set_background(app: &mut PhotocraftApp, name: &str, color: &str) -> Result { + let custom = name == "custom"; + if custom { + crate::color_picker_ui::parse_hex(color).ok_or("not a colour")?; + } + let mut boards = selected_boards(app); + // A board whose custom colour already has this RGB keeps its own value: the swatch shows it + // as 8-bit RGB, so OK in the picker without a change must not flatten its alpha or precision. + if custom && let Some(want) = crate::color_picker_ui::parse_hex(color) { + let hex = |c: [f32; 3]| c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let st = app.session.active(); + boards.retain(|id| match st.and_then(|st| st.doc.layer(*id)?.artboard().map(|a| a.background)) { + Some(ArtboardBackground::Custom(c)) => { + let [r, g, b, _] = c.to_rgba8(); + [r, g, b] != hex(want) + } + _ => true, + }); + } + let mut out = json!({}); + if !boards.is_empty() { + let mut params = json!({"layers": boards.iter().map(|b| b.0).collect::>(), "background": name}); + if custom { + params["color"] = json!(color); + } + out = app.run("layer.artboard.set", params)?; + } + let o = &mut app.ui.artboard_tool; + o.background = name.to_string(); + if custom { + o.background_color = color.to_string(); + } + Ok(out) +} + +/// OK in the Color Picker opened by Other…: the custom background is `color` (`#rrggbb`), for the +/// selected boards (if any) and the next drawn one. +pub fn set_custom_background(app: &mut PhotocraftApp, color: &str) -> Result { + set_background(app, "custom", color)?; + Ok(json!({"color": color})) +} + +/// [`set_custom_background`] for OK in the picker. `unchanged` (the result is the colour the picker +/// opened with, after any Only Web Colors snap) leaves every selected board alone, with no command +/// and no history step: only the tool option for the next board follows. Otherwise boards that +/// already have the picked RGB are skipped one by one ([`set_background`]). +pub fn set_custom_background_from_picker(app: &mut PhotocraftApp, color: &str, unchanged: bool) -> Result { + if !unchanged { + return set_custom_background(app, color); + } + crate::color_picker_ui::parse_hex(color).ok_or("not a colour")?; + let o = &mut app.ui.artboard_tool; + o.background = "custom".to_string(); + o.background_color = color.to_string(); + Ok(json!({"color": color})) +} + +/// Run Align panel entry `index` of group `group` on the selected boards. None for an entry that +/// has no command yet. +pub fn run_align(app: &mut PhotocraftApp, group: usize, index: usize) -> Option> { + let cmd = ALIGN_PANEL.get(group)?.get(index)?.1?; + Some(app.run(cmd, json!({}))) +} + +/// The swatch colour of a background (`name`, with `hex` for custom). +fn background_color(name: &str, hex: &str) -> Color32 { + match name { + "black" => Color32::BLACK, + "transparent" => Color32::TRANSPARENT, + "custom" => crate::color_picker_ui::parse_hex(hex).map_or(Color32::WHITE, rgb32), + _ => Color32::WHITE, + } +} + +fn rgb32(c: [f32; 3]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// Opens the Color Picker for the custom background. +fn pick_background(app: &mut PhotocraftApp) { + let rgb = crate::color_picker_ui::parse_hex(&shown_background(app).1).unwrap_or([1.0; 3]); + let title = crate::color_picker_ui::title_for(tl!("Color")); + crate::color_picker_ui::open_for_target(app, "artboardBackground", &title, rgb); +} + +/// Size, W, H, Make Portrait and Make Landscape. With a board selected they show and edit the +/// board (the returned `layer.artboard.set` params); with none, Size, W and H edit the tool's own +/// options and nothing is returned. +pub fn size_group(app: &mut PhotocraftApp, ui: &mut egui::Ui, board: Option<(LayerId, [i64; 4])>) -> Option { + let t = Tokens::get(ui.ctx()); + let mut edit: Option = None; + let (mut preset, mut w, mut h) = match board { + Some((id, r)) => (board_preset(app, id), (r[2] - r[0]) as f32, (r[3] - r[1]) as f32), + None => (app.ui.artboard_tool.preset.clone(), app.ui.artboard_tool.width as f32, app.ui.artboard_tool.height as f32), + }; + let id = board.map_or(LayerId(0), |b| b.0); + ui.label(egui::RichText::new(tl!("Size")).color(t.text_dim)); + let mut opts: Vec<(String, &str)> = vec![(String::new(), tl!("Custom"))]; + opts.extend(PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n))); + if crate::widgets::dropdown(ui, "artboard-tool-preset", tl!("Preset"), &mut preset, &opts, 150.0) && !preset.is_empty() { + if board.is_some() { + edit = Some(json!({"layer": id.0, "preset": preset})); + } else { + set_preset(app, &preset); + } + } + ui.label(egui::RichText::new(tl!("W")).color(t.text_dim)); + let cw = crate::widgets::value_field(ui, &mut w, 1.0..=MAX_SIDE, "px", 64.0).accessible_name(tl!("Width")).changed(); + ui.label(egui::RichText::new(tl!("H")).color(t.text_dim)); + let ch = crate::widgets::value_field(ui, &mut h, 1.0..=MAX_SIDE, "px", 64.0).accessible_name(tl!("Height")).changed(); + if cw || ch { + if board.is_some() { + edit = Some(json!({"layer": id.0, "width": w.round(), "height": h.round(), "coalesce": format!("artboard-tool-wh-{}", id.0)})); + } else { + set_size(app, w, h); + } + } + ui.add_enabled_ui(board.is_some(), |ui| { + if crate::widgets::secondary_button(ui, tl!("Make Portrait"), 0.0).clicked() && w > h { + edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); + } + if crate::widgets::secondary_button(ui, tl!("Make Landscape"), 0.0).clicked() && h > w { + edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); + } + }); + edit +} + +/// The background swatch and its dropdown (White, Black, Transparent, Other…). With boards selected +/// they show the first selected board's background and a change recolours every selected board in +/// one history step (`layer.artboard.set`; "Edit Artboard" for one, "Edit Artboards" for several); +/// either way it is also the tool option, the background of the next drawn board. With no board +/// selected it edits the tool option only. +pub fn background(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let (name, hex) = shown_background(app); + if crate::widgets::color_swatch_button(ui, background_color(&name, &hex), tl!("Artboard Background Color")).clicked() { + pick_background(app); + } + let mut bg = name; + let opts = [ + ("white".to_string(), tl!("White")), + ("black".to_string(), tl!("Black")), + ("transparent".to_string(), tl!("Transparent")), + ("custom".to_string(), tl!("Other…")), + ]; + if crate::widgets::dropdown(ui, "artboard-tool-bg", tl!("Background"), &mut bg, &opts, 120.0) { + if bg == "custom" { + pick_background(app); + } else if let Err(e) = set_background(app, &bg, &hex) { + app.ui.status = e; + app.ui.status_error = true; + } + } +} + +/// The Align button with its panel, and the gear button with its menu. +pub fn align_and_gear(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + crate::widgets::vline(ui, 22.0); + let align = icons::button_with_icon_size(ui, "align-edge-left", 28.0, 20.0, false, tl!("Align")); + egui::Popup::menu(&align).show(|ui| { + ui.set_min_width(200.0); + for (g, group) in ALIGN_PANEL.iter().enumerate() { + if g > 0 { + ui.separator(); + } + for (i, (label, cmd)) in group.iter().enumerate() { + let on = cmd.is_some_and(|c| app.session.is_enabled(c)); + let icon = match cmd { + Some(c) => crate::align_bar::ALIGN.iter().find(|a| a.0 == *c).map_or("align-edge-left", |a| a.1), + None if *label == "Distribute Widths" => "distribute-horizontal", + None => "distribute-vertical", + }; + let tint = if on { t.icon } else { t.text_dim }; + let button = egui::Button::image_and_text(icons::image(icon, 16.0, tint), tl!(label)); + if ui.add_enabled(on, button).clicked() { + if let Some(Err(e)) = run_align(app, g, i) { + app.ui.status = e; + app.ui.status_error = true; + } + ui.close(); + } + } + } + }); + let gear = icons::button_with_icon_size(ui, "settings", 28.0, 20.0, false, tl!("Options")); + egui::Popup::menu(&gear).show(|ui| { + for (item, label) in GEAR { + let mut on = item.checked(&app.ui.artboard_tool); + if ui.add_enabled(item.enabled(), egui::Checkbox::new(&mut on, tl!(label))).changed() && item == Gear::AutoNest { + app.ui.artboard_tool.auto_nest = on; + } + } + }); +} diff --git a/crates/ui-egui/src/tools/corner_radius.rs b/crates/ui-egui/src/tools/corner_radius.rs index 6358ffd139..f5ec3192e9 100644 --- a/crates/ui-egui/src/tools/corner_radius.rs +++ b/crates/ui-egui/src/tools/corner_radius.rs @@ -150,7 +150,7 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && match app.ui.tool { - Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon => true, + Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon | Tool::Star => true, Tool::Move => app.ui.tool_options.move_show_transform, _ => false, } diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index d652ebf9d4..f09f52f29b 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -130,19 +130,19 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { /// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic /// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's, /// also over the untouched default frame the tool shows when picked or after a commit (#6536, -/// #9731). +/// #9731). ⇧H and ⇧P are always the tool keys. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.default_frame || app.crop.drag.is_some() { return false; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::H)) { + if ctx.input_mut(|i| consume_unshifted(i, Key::H)) { let s = &mut app.ui.tool_options.crop_shield; if !s.classic_mode { s.show_cropped_area = !s.show_cropped_area; } return true; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::P)) { + if ctx.input_mut(|i| consume_unshifted(i, Key::P)) { let s = &mut app.ui.tool_options.crop_shield; s.classic_mode = !s.classic_mode; // The view follows at once, about its own centre: default mode turns it with the frame, @@ -153,6 +153,16 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } +/// `consume_key(Modifiers::NONE, key)` without the presses that hold ⇧ or ⌥: egui ignores an +/// extra ⇧ and ⌥ there, but ⇧+letter is the tool key and Alt+letter opens the menu with that +/// mnemonic (Alt+H Help, Alt+I Image). Reads each event's own modifiers, which synthetic +/// (control channel) keys carry without changing the frame's. +pub(crate) fn consume_unshifted(i: &mut egui::InputState, key: Key) -> bool { + let before = i.events.len(); + i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && !modifiers.shift && !modifiers.alt && modifiers.matches_logically(Modifiers::NONE))); + i.events.len() != before +} + /// The options bar's gear button and its menu. Returns true when Custom… asks for the Color /// Picker ([`pick_custom_color`], which needs the app). pub fn options_button(s: &mut CropShield, ui: &mut egui::Ui) -> bool { @@ -387,6 +397,69 @@ mod tests { assert!(app.ui.tool_options.crop_shield.show_cropped_area); } + /// A crop in progress: a frame changed from the default one, so [`keys`] gets past its + /// early returns (#9731). + fn crop_in_progress_app() -> PhotocraftApp { + let mut app = crop_app(); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; + app + } + + /// ⇧H and ⇧P are the Hand and Pen tool keys with a crop in progress, where plain H and P are + /// the crop box's: egui's `consume_key` ignores an extra ⇧, so the plain-key checks must refuse + /// it. Over the untouched default frame they are the tool keys too. + #[test] + fn shift_h_and_shift_p_switch_tools_instead_of_crop_options() { + for in_progress in [true, false] { + for (key, tool) in [(Key::H, Tool::Hand), (Key::P, Tool::Pen)] { + let mut app = if in_progress { crop_in_progress_app() } else { crop_app() }; + assert_eq!(app.crop.default_frame, !in_progress); + assert!(app.session.prefs().tools.use_shift_key_for_tool_switch); + let before = app.ui.tool_options.crop_shield.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). + i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, tool, "{key:?}, crop in progress: {in_progress}"); + assert_eq!(app.ui.tool_options.crop_shield, before, "{key:?} leaves Show Cropped Area and Classic Mode alone"); + } + } + } + + /// The same plain H and P with a crop in progress are still the crop box's keys, so the test + /// above really reaches the key checks. + #[test] + fn plain_p_toggles_classic_mode_while_a_crop_is_in_progress() { + let mut app = crop_in_progress_app(); + let classic = app.ui.tool_options.crop_shield.classic_mode; + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.events.push(egui::Event::Key { key: Key::P, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE })); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Crop, "P doesn't switch to the Pen tool"); + assert_eq!(app.ui.tool_options.crop_shield.classic_mode, !classic); + } + + /// Alt+H, Alt+P and Alt+I are not the crop box's H, P and I (Show Cropped Area, Classic Mode, + /// Front Image): egui's `consume_key` ignores an extra ⌥, so the plain-key checks must refuse + /// it and leave the press for later handlers. + #[test] + fn alt_letters_are_not_the_crop_box_keys() { + for key in [Key::H, Key::P, Key::I] { + let mut app = crop_in_progress_app(); + let (shield, ratio) = (app.ui.tool_options.crop_shield.clone(), app.ui.tool_options.crop_ratio.clone()); + let ctx = egui::Context::default(); + let alt = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }; + ctx.input_mut(|i| i.events.push(alt.clone())); + assert!(!keys(&mut app, &ctx) && !crate::crop_size::keys(&mut app, &ctx), "{key:?}"); + assert_eq!(app.ui.tool_options.crop_shield, shield, "{key:?}"); + assert_eq!(app.ui.tool_options.crop_ratio, ratio, "{key:?}"); + assert!(ctx.input(|i| i.events.contains(&alt)), "{key:?} is left for later handlers"); + } + } + #[test] fn the_color_picker_sets_the_custom_colour() { let mut app = crop_app(); diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index 3709ec6767..68b0714190 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -15,7 +15,7 @@ //! with the Crop tool, [`keys`]) fills them with the active document's size in px and its //! resolution; Clear empties them; ⇄ and X swap W and H. -use egui::{Modifiers, Stroke, StrokeKind, vec2}; +use egui::{Stroke, StrokeKind, vec2}; use photocraft_engine::prefs::Unit; use serde_json::{Value, json}; @@ -271,12 +271,12 @@ pub fn chosen(app: &mut PhotocraftApp) { } /// I with the Crop tool (no text field focused, which the caller checks): Front Image. Returns -/// true when the key was used; otherwise I stays the Eyedropper tool key. +/// true when the key was used; otherwise I (and always ⇧I) stays the Eyedropper tool key. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.crop.drag.is_some() || app.session.active().is_none() { return false; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::I)) { + if ctx.input_mut(|i| crate::crop_shield::consume_unshifted(i, egui::Key::I)) { front_image(app); crate::crop_ui::apply_ratio(app); return true; @@ -341,6 +341,7 @@ pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) -> bool { #[cfg(test)] mod tests { use super::*; + use egui::Modifiers; use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; #[test] @@ -536,6 +537,32 @@ mod tests { assert!(!keys(&mut app2, &ctx)); } + /// ⇧I is the Eyedropper group's key even with the Crop tool, not Front Image: with no frame + /// yet, over the untouched default frame and with a crop in progress. + #[test] + fn shift_i_with_the_crop_tool_switches_to_the_eyedropper() { + for frame in ["none", "default", "in progress"] { + let mut app = app(SampleType::U8); + if frame != "none" { + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "{frame}"); + } + if frame == "in progress" { + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; + } + let ratio = app.ui.tool_options.crop_ratio.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). + i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Eyedropper, "{frame}"); + assert_eq!(app.ui.tool_options.crop_ratio, ratio, "{frame}"); + } + } + /// Committing a W x H x Resolution crop resamples the frame's area to exactly W x H px and sets /// the resolution, in one undo step, at 8 and 16 bits. #[test] diff --git a/crates/ui-egui/src/tools/direct_select.rs b/crates/ui-egui/src/tools/direct_select.rs index 86df553ddc..ba8ab3b8ac 100644 --- a/crates/ui-egui/src/tools/direct_select.rs +++ b/crates/ui-egui/src/tools/direct_select.rs @@ -241,6 +241,18 @@ fn converting(tool: Tool, mods: Modifiers, pen_idle: bool) -> bool { !mods.command && (tool == Tool::ConvertPoint || (tool == Tool::Pen && mods.alt && pen_idle)) } +/// The selected anchor when it is the only one and an endpoint of an open subpath of the active +/// path: where the Curvature Pen carries the path on from (#10726), as Photoshop does after a +/// click, or ⌘/Ctrl-click, on the last point. The path, the subpath's index and the knot's. +pub(crate) fn selected_open_end(app: &PhotocraftApp) -> Option<(PathRef, usize, usize)> { + let ds = &app.ui.direct_selection; + let ([[s, k]], Some(target)) = (ds.anchors.as_slice(), ds.target) else { return None }; + let (_, path) = active_paths(app).into_iter().find(|(r, _)| *r == target)?; + let sub = path.subpaths.get(*s).filter(|sub| !sub.closed)?; + sub.knots.get(*k).filter(|_| *k == 0 || *k + 1 == sub.knots.len())?; + Some((target, *s, *k)) +} + /// Is the Curvature Pen idle (no path being drawn, no ⌘/Ctrl)? Then it edits the active path in /// place, as in Photoshop: a press on one of its anchors drags it, with no modifier. fn curvature_idle(app: &PhotocraftApp, mods: Modifiers) -> bool { diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index ac896eeb0b..3c5b997cb3 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -53,7 +53,7 @@ pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, - "tolerance": o.bg_tolerance, + "tolerance": stored_tolerance(o.bg_tolerance), "protectForegroundColor": o.bg_protect_fg, "seed": crate::retouch_ui::stroke_seed(points), }) @@ -125,11 +125,79 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ("findEdges".to_string(), tl!("Find Edges")), ]; widgets::dropdown(ui, "bg-eraser-limits", tl!("Limits:"), &mut o.bg_limits, &limits, 110.0); + // The dropdown's button, by the id its combo box takes from "bg-eraser-limits". + if let Some(limits) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("bg-eraser-limits"))) { + limits.on_hover_text(tl!("How far to let the erasing spread")); + } opt(ui, tl!("Tolerance:")); - widgets::value_field(ui, &mut o.bg_tolerance, 0.0..=100.0, "%", 62.0); - widgets::checkbox(ui, &mut o.bg_protect_fg, tl!("Protect Foreground Color")); + if tolerance_field(ui, &mut o.bg_tolerance) { + open_range_alert(app); + } + let o = &mut app.ui.tool_options; + widgets::checkbox(ui, &mut o.bg_protect_fg, tl!("Protect Foreground Color")).on_hover_text(tl!("Do not erase the foreground swatch color")); } _ => return false, } true } + +/// The alert for a Background Eraser Tolerance typed out of range (#11909). +pub const TOLERANCE_RANGE_ALERT: &str = "An integer between 1% and 100% is required. Closest value inserted."; + +/// The Background Eraser's Tolerance: whole numbers in 1..=100, as the reference application takes +/// them (#11909). A typed fraction becomes the nearest whole number, a value past the range the +/// closest one. Returns true when the field is left with a value typed out of range, so the +/// caller shows [`TOLERANCE_RANGE_ALERT`]. +fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { + // A restored setting (an older 0, a fraction) shows and erases as the field would store it. + *value = stored_tolerance(*value); + // While the field has the keyboard it gets back what was typed, not the stored value, so + // its text is left alone mid-entry (`0` on the way to `05` must not turn into `1`). + let draft_id = ui.make_persistent_id(egui::IdSalt::new("bg-eraser-tolerance-typed")); + // The field's own range is wide, so the typed value reaches here unclamped. + // A draft only stands while the setting is still the one it stored: a value set elsewhere + // meanwhile (a tool preset while the bar was hidden) wins. + let draft = ui.ctx().data(|d| d.get_temp::<(f32, f32)>(draft_id)); + let mut typed = draft.filter(|(_, stored)| *stored == *value).map_or(*value, |(typed, _)| typed); + let r = widgets::value_field(ui, &mut typed, -1.0e9..=1.0e9, "%", 62.0).on_hover_text(tl!("Erase range of similar colors")); + let out_id = r.id.with("out-of-range"); + // An edit the field never got to finish (the options bar was hidden while it had the + // keyboard) has nothing left to say once the field is entered again. + if r.gained_focus() { + ui.ctx().data_mut(|d| d.remove_temp::(out_id)); + } + if r.changed() && typed.is_finite() { + *value = typed.round().clamp(1.0, 100.0); + // Only what was typed alerts, also an expression committed as the field is left + // (`50*4` and Enter); a scrub past the ends (the field's or its caption's, even while + // the field has the keyboard) just stops there. + let typing = (r.has_focus() || r.lost_focus()) && ui.ctx().dragged_id().is_none(); + let out = typing && !(1.0..=100.0).contains(&typed); + ui.ctx().data_mut(|d| d.insert_temp(out_id, out)); + if typing && r.has_focus() { + ui.ctx().data_mut(|d| d.insert_temp(draft_id, (typed, *value))); + } else { + ui.ctx().data_mut(|d| d.remove_temp::<(f32, f32)>(draft_id)); + } + } + if !r.has_focus() { + ui.ctx().data_mut(|d| d.remove_temp::<(f32, f32)>(draft_id)); + } + r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false) +} + +/// A Tolerance as the field stores it: a whole number in 1..=100, the default 50 for a value +/// that is no number. Strokes use it too, so a restored setting erases the same before and after +/// the options bar shows it. +fn stored_tolerance(v: f32) -> f32 { + if v.is_finite() { v.round().clamp(1.0, 100.0) } else { 50.0 } +} + +/// Shows [`TOLERANCE_RANGE_ALERT`]: a modal with OK, once even if more than one pass asks. +fn open_range_alert(app: &mut PhotocraftApp) { + let message = tl!(TOLERANCE_RANGE_ALERT).to_owned(); + let shown = app.ui.dialogs.iter().any(|d| d.kind == crate::state::DialogKind::Error && d.fields.get("message").and_then(|m| m.as_str()) == Some(&message)); + if !shown { + app.ui.open_dialog(crate::state::DialogKind::Error, json!({ "message": message }).as_object().cloned().unwrap_or_default()); + } +} diff --git a/crates/ui-egui/src/tools/magnetic_lasso_ui.rs b/crates/ui-egui/src/tools/magnetic_lasso_ui.rs index 28ef343212..f669f61368 100644 --- a/crates/ui-egui/src/tools/magnetic_lasso_ui.rs +++ b/crates/ui-egui/src/tools/magnetic_lasso_ui.rs @@ -6,8 +6,11 @@ //! often at a higher Frequency. A click fastens a point; ⌥-click draws a straight segment and //! ⌥-drag a freehand one. ⌫ or Delete removes the last fastening point. Clicking the first point, //! a double-click or ↩ closes the border along the edges (⌥ with a straight segment); Esc cancels. -//! [ and ] narrow or widen the detection width by 1 px. As in Photoshop, the border follows the -//! active layer's pixels (the composite for a layer without pixels of its own). +//! [ and ] narrow or widen the detection width by 1 px. The border follows the edges of the visible +//! composite, whichever layer is active (hidden layers add none); documents that are not RGB keep +//! following the active layer's own pixels. In a non-RGB document an active group or adjustment +//! layer has no pixel surface of its own, so the tool follows the composite then instead of that +//! layer's pixels (`pixel_layer` and `read` in `photocraft_engine::magnetic_cmds`). //! //! The border closes through `select.magneticLasso` with `"trace": false`: the selection is exactly //! the outline drawn, in one history step, and an action replays that outline. @@ -55,6 +58,8 @@ pub struct Runtime { source: Option, /// Pointer moves were batched (`PhotocraftApp::defer_live_stroke`): trace once at the end. pending: bool, + /// The mouse button is down (pressed in this tool, not yet released): points fasten closer. + held: bool, } /// Detection settings from the options bar, for an event with pen `pressure`. @@ -68,12 +73,26 @@ fn settings(app: &PhotocraftApp, pressure: f32) -> Settings { Settings::new(width, o.magnetic_contrast / 100.0) } -/// How far the border runs before a point fastens by itself (document px): 8 screen points at -/// Frequency 100, 98 at 0, 47 at the default 57. -fn spacing(app: &PhotocraftApp) -> f64 { +/// How far the border runs before a point fastens by itself (document px), as the reference +/// application measures it (#11687). The stored spacing is `floor(40 - 0.35 x Frequency)`: 40 at +/// Frequency 0, 20 at the default 57, 5 at 100. +/// +/// - Button held: the stored value in document px, whatever the zoom (so on screen it grows with +/// the zoom: 40 px at 200 %, 60 at 300 %). Below 100 % the reference is uneven (about 8 to 10 +/// screen px at Frequency 57) and no rule was pinned; the stored document value is kept. +/// - Button up: `4 x stored + 4` screen points at 100 % and above (164, 84 and 24), and half of +/// that below 100 %, at any zoom, so it is divided by the zoom into document px. +fn spacing(app: &PhotocraftApp, held: bool) -> f64 { let f = app.ui.tool_options.magnetic_frequency; let f = if f.is_finite() { f64::from(f.clamp(0.0, 100.0)) } else { 57.0 }; - (8.0 + (100.0 - f) * 0.9) / f64::from(app.point_zoom().max(0.01)) + let stored = (40.0 - 0.35 * f).floor(); + if held { + return stored; + } + let zoom = f64::from(app.point_zoom().max(0.01)); + let screen = 4.0 * stored + 4.0; + let screen = if zoom < 1.0 { screen / 2.0 } else { screen }; + screen / zoom } /// Within this distance of the first point (document px), a click closes the border. @@ -104,14 +123,24 @@ fn trace(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], guide: &[[f64; 2 /// Pointer events while the Magnetic Lasso is the tool (it takes them all). pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + // A release ends the held-button spacing whoever takes it (the temporary Move included). + if matches!(ev, ToolEvent::Up { .. }) { + app.magnetic.held = false; + } // ⌘ held makes it the Move tool (`hold_keys::cmd_moves`): nothing to take then. if app.active_tool() != Tool::MagneticLasso { return false; } match ev { - ToolEvent::Down { x, y, pressure } if x.is_finite() && y.is_finite() => down(app, [x, y], pressure, mods), + ToolEvent::Down { x, y, pressure } if x.is_finite() && y.is_finite() => { + down(app, [x, y], pressure, mods); + app.magnetic.held = true; + } ToolEvent::Move { x, y, pressure } if x.is_finite() && y.is_finite() => moved(app, [x, y], pressure), - ToolEvent::Up { .. } => end_freehand(app), + ToolEvent::Up { .. } => { + app.magnetic.held = false; + end_freehand(app); + } _ => {} } true @@ -175,7 +204,7 @@ fn moved(app: &mut PhotocraftApp, p: [f64; 2], pressure: f32) { return; } let s = settings(app, pressure); - let step = (spacing(app) / 2.0).max(1.0); + let step = (spacing(app, app.magnetic.held) / 2.0).max(1.0); let last = app.ui.magnetic.trail.last().copied().unwrap_or(p); // A long jump (a fast flick, automation) is walked in steps, so points keep fastening along it. let n = ((dist(last, p) / step).ceil() as usize).clamp(1, 256); @@ -217,7 +246,7 @@ fn extend_trail(app: &mut PhotocraftApp, q: [f64; 2], s: Settings) { fn follow(app: &mut PhotocraftApp, q: [f64; 2], s: Settings, fasten: bool) { extend_trail(app, q, s); let target = snap(app, q, s); - let every = spacing(app); + let every = spacing(app, app.magnetic.held); for _ in 0..64 { let Some(&from) = app.ui.magnetic.path.last() else { return }; let guide = app.ui.magnetic.trail.clone(); @@ -365,8 +394,15 @@ pub fn replaces_selection(app: &PhotocraftApp) -> bool { app.ui.magnetic.active() && app.ui.magnetic.mode == "replace" } -/// Once a frame: a border left behind by a tool or document switch is dropped. -pub fn frame(app: &mut PhotocraftApp) { +/// Once a frame of a document canvas (`primary_down`: its primary pointer button is down; `active_canvas`: +/// it is the canvas of the active document, the one that owns a press in progress): a border left behind by a tool or document switch is dropped. +pub fn frame(app: &mut PhotocraftApp, primary_down: bool, active_canvas: bool) { + // The button is up: the held-button spacing ends even when the release went to the temporary + // Move (Ctrl pressed during the drag) and never reached this tool. Only the active canvas + // says so: another window's button being up says nothing about the drag in this one. + if active_canvas && !primary_down { + app.magnetic.held = false; + } let m = &app.ui.magnetic; if m.active() && (app.ui.tool != Tool::MagneticLasso || app.session.active().map(|d| d.doc.id.0) != Some(m.doc)) { cancel(app); @@ -375,7 +411,7 @@ pub fn frame(app: &mut PhotocraftApp) { /// Keys while the Magnetic Lasso is the tool: ↩ closes the border along the edges (⌥↩ straight), /// Esc cancels it, ⌫ / Delete remove the last fastening point, and [ / ] change the detection width -/// by 1 px. Returns whether a key was taken. +/// by 1 px (Shift+[ / Shift+] jump to 1 / 256 px). Returns whether a key was taken. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::MagneticLasso { return false; @@ -399,6 +435,17 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { return true; } } + // Shift+[ / Shift+] jump to the limits; they come first because the plain keys match with Shift held too. + let w = &mut app.ui.tool_options.magnetic_width; + // On a US layout the backend may report Shift+[ as the logical `{` key (egui's `OpenCurlyBracket`) with Shift set; a bare `{` (Programmer Dvorak) is left alone. + if pressed(Modifiers::SHIFT, Key::OpenBracket) || pressed(Modifiers::SHIFT, Key::OpenCurlyBracket) { + *w = 1.0; + return true; + } + if pressed(Modifiers::SHIFT, Key::CloseBracket) || pressed(Modifiers::SHIFT, Key::CloseCurlyBracket) { + *w = 256.0; + return true; + } let step = if pressed(Modifiers::NONE, Key::OpenBracket) { -1.0 } else if pressed(Modifiers::NONE, Key::CloseBracket) { @@ -406,7 +453,6 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } else { return false; }; - let w = &mut app.ui.tool_options.magnetic_width; *w = if w.is_finite() { (w.round() + step).clamp(1.0, 256.0) } else { 10.0 }; true } diff --git a/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs b/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs index 11d5137621..b5e41a5e1b 100644 --- a/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs +++ b/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs @@ -25,6 +25,8 @@ fn app() -> PhotocraftApp { }) .unwrap(); app.ui.tool = Tool::MagneticLasso; + // Frequency 100 (a point about every 24 px with the button up, #11687): the 80 x 70 square is small. + app.ui.tool_options.magnetic_frequency = 100.0; app } @@ -77,7 +79,7 @@ fn tracing_around_a_shape_selects_it() { assert!(on_square(m.path[0], 0.01), "the first point snaps onto the edge: {:?}", m.path[0]); hover_along(&mut app, &AROUND[..5]); let m = &app.ui.magnetic; - // Points fastened by themselves on the way (about every 47 px at Frequency 57). + // Points fastened by themselves on the way (about every 24 px at Frequency 100). assert!(m.anchors.len() >= 5, "{:?}", m.anchors); for p in m.path.iter().chain(&m.live) { assert!(on_square(*p, 1.0), "{p:?} is off the square's edge"); @@ -199,16 +201,16 @@ fn agents_see_the_border_in_progress() { fn switching_tools_or_documents_drops_the_border() { let mut app = app(); click(&mut app, 52.0, 37.0, Modifiers::NONE); - frame(&mut app); + frame(&mut app, true, true); assert!(app.ui.magnetic.active()); app.ui.tool = Tool::Lasso; - frame(&mut app); + frame(&mut app, true, true); assert!(!app.ui.magnetic.active()); app.ui.tool = Tool::MagneticLasso; click(&mut app, 52.0, 37.0, Modifiers::NONE); app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); app.sync_views(); - frame(&mut app); + frame(&mut app, true, true); assert!(!app.ui.magnetic.active()); } @@ -244,6 +246,41 @@ fn width_follows_pen_pressure_only_when_asked_and_only_with_a_pen() { assert_eq!(settings(&app, f32::NAN).width, 21.0); } +/// Starts a border at the top edge of the square, presses the button, then lets `release` end the +/// press; walks along the edge in 2 px steps and returns how many fastening points the border has. +fn points_after_walk(release: impl FnOnce(&mut PhotocraftApp)) -> usize { + let mut a = app(); + tool_event(&mut a, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + release(&mut a); + for x in (52..=128).step_by(2) { + tool_event(&mut a, ToolEvent::Move { x: f64::from(x), y: 40.0, pressure: 1.0 }, Modifiers::NONE); + } + a.ui.magnetic.anchors.len() +} + +#[test] +fn the_button_up_spacing_applies_after_a_release_the_temporary_move_took() { + // Baseline: an ordinary release, then the button-up spacing (~24 px at Frequency 100). + let plain = points_after_walk(|a| tool_event(a, ToolEvent::Up { x: 50.0, y: 40.0 }, Modifiers::NONE)); + assert!(plain <= 6, "button up: {plain} points"); + // The release arrives while the temporary Move (Ctrl held) is the active tool. + let moved = points_after_walk(|a| { + a.tool_override = Some(Tool::Move); + tool_event(a, ToolEvent::Up { x: 50.0, y: 40.0 }, Modifiers::COMMAND); + a.tool_override = None; + }); + assert!(moved <= 6, "after a release taken by the Move: {moved} points"); + // The canvas frame also clears it: the button is up, whoever saw the release. + let framed = points_after_walk(|a| frame(a, false, true)); + assert!(framed <= 6, "after a frame with the button up: {framed} points"); + // Another (inactive) canvas with its own button up leaves a live drag alone. + let other = points_after_walk(|a| frame(a, false, false)); + assert!(other > 10, "an inactive canvas's frame must not end the hold: {other} points"); + // A frame with the button still down keeps the held-button spacing. + let held = points_after_walk(|a| frame(a, true, true)); + assert!(held > 10, "button held: {held} points"); +} + #[test] fn hostile_input_never_panics() { let mut app = app(); diff --git a/crates/ui-egui/src/tools/path_components.rs b/crates/ui-egui/src/tools/path_components.rs index 33aef52d8d..7fd6fd55f7 100644 --- a/crates/ui-egui/src/tools/path_components.rs +++ b/crates/ui-egui/src/tools/path_components.rs @@ -166,16 +166,27 @@ pub(crate) fn press(app: &mut PhotocraftApp, at: [f64; 2], shift: bool) { /// components, one History step. `None` with none or all of them selected: the whole path moves /// instead, which keeps a live rectangle or ellipse live. pub(crate) fn move_selected(app: &mut PhotocraftApp, d: [f64; 2]) -> Option> { - let (r, path, heads) = selected(app)?; - if target(app).map(|(t, _)| t) != Some(r) || heads.len() >= path.components().len() { - return None; - } + let (r, _) = moving_selected(app)?; let mut params = r.params(app); params["anchors"] = json!(app.ui.direct_selection.anchors); params["move"] = json!(d); Some(run_selected_command(app, "path.moveAnchors", params)) } +/// The path [`move_selected`] moves components of, with some but not all of them selected. +fn moving_selected(app: &PhotocraftApp) -> Option<(PathRef, Path)> { + let (r, path, heads) = selected(app)?; + (target(app).map(|(t, _)| t) == Some(r) && heads.len() < path.components().len()).then_some((r, path)) +} + +/// The path [`move_selected`] leaves after a drag by `d`, for the drag's live preview: the +/// selected components moved with their handles, as `path.moveAnchors` moves them. +pub(crate) fn moved_selected(app: &PhotocraftApp, d: [f64; 2]) -> Option<(PathRef, Path)> { + let (r, mut path) = moving_selected(app)?; + photocraft_vector::edit::move_anchors(&mut path, &app.ui.direct_selection.anchors, d).ok()?; + Some((r, path)) +} + /// Do `+` and `-` pick a Path Operation with `tool`? The tools whose options bar has the menu. fn has_keys(tool: Tool) -> bool { matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) diff --git a/crates/ui-egui/src/tools/shape_bounds_box.rs b/crates/ui-egui/src/tools/shape_bounds_box.rs index ee137bc541..d56508bf96 100644 --- a/crates/ui-egui/src/tools/shape_bounds_box.rs +++ b/crates/ui-egui/src/tools/shape_bounds_box.rs @@ -62,7 +62,7 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && app.ui.view.shows(app.ui.view.show.target_path) - && matches!(app.ui.tool, Tool::PathSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) + && matches!(app.ui.tool, Tool::PathSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line) } /// The selected shape layers and the box round their paths. `None` unless at least one layer is diff --git a/crates/ui-egui/src/tools/symmetry_ui.rs b/crates/ui-egui/src/tools/symmetry_ui.rs index 95e6b61c61..53413abb72 100644 --- a/crates/ui-egui/src/tools/symmetry_ui.rs +++ b/crates/ui-egui/src/tools/symmetry_ui.rs @@ -167,7 +167,10 @@ fn mode_button(ui: &mut egui::Ui, mode: SymmetryMode, checked: bool) -> egui::Re } pub fn menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let current = app.session.active().and_then(|s| s.symmetry.clone()); - let resp = icons::button_with_icon_size(ui, "painting-symmetry", 24.0, 14.0, current.is_some(), tl!("Set painting symmetry options")); + // The Brush has its own text (#11813); the name stays for screen readers and the other tools. + let brush = app.ui.tool == crate::state::Tool::Brush; + let resp = icons::button_with_icon_size_tip(ui, "painting-symmetry", 24.0, 14.0, current.is_some(), tl!("Set painting symmetry options"), !brush); + let resp = if brush { resp.on_hover_text(tl!("Set symmetry options for Painting")) } else { resp }; let resp = crate::brush_picker::named(resp, "Set painting symmetry options"); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(220.0); diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 6d8fac5bbd..148599b50c 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -13,8 +13,9 @@ //! Smudge). Resetting one of those tools resets the shared field too. Sample All Layers is kept per //! tool, as in Photoshop, so resetting a tool resets only its own. //! [`reset_options`] lists the fields of each tool. The selection tools also go back to New -//! Selection, and the painting tools (the ones with a brush in the options bar) get the default -//! brush, keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have +//! Selection, and the painting tools (the ones with a brush in the options bar) get their starting +//! brush (`paint_mouse::fresh_brush`: the Brush tool's 13 px soft round, the default brush for the +//! others), keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have //! nothing to reset. use serde_json::{Map, Value, json}; @@ -73,6 +74,9 @@ fn reset_tool(app: &mut PhotocraftApp, tool: Tool) { crate::frame_tool::cancel(app); app.ui.frame_tool = Default::default(); } + if tool == Tool::Artboard { + app.ui.artboard_tool = Default::default(); + } reset_options(&mut app.ui.tool_options, tool); // Patch and Content-Aware Move lasso in the shared selection mode their bars show (#11024). if is_selection_tool(tool) || matches!(tool, Tool::Patch | Tool::ContentAwareMove) { @@ -91,13 +95,22 @@ fn reset_all(app: &mut PhotocraftApp) { crate::frame_tool::cancel(app); app.ui.frame_tool = Default::default(); app.ui.tool_options = ToolOptions::default(); + app.ui.artboard_tool = Default::default(); app.ui.selection_mode = 0; - app.ui.tool_brushes.clear(); reset_session_brush(app); + // Every other painting tool gets its own starting brush. Left without one, it would take the + // live brush when picked (`sync_tool_brush`), and the tools' starting tips differ (#11810). + let (live, current) = (app.ui.brush_tool, app.session.tools.brush.clone()); + app.ui.tool_brushes = Tool::ALL + .iter() + .filter(|t| **t != live && crate::paint_mouse::has_brush_picker(**t)) + .map(|t| (*t, crate::paint_mouse::fresh_brush(*t, ¤t))) + .collect(); } +/// The live brush is `app.ui.brush_tool`'s: give it that tool's starting brush. fn reset_session_brush(app: &mut PhotocraftApp) { - app.session.tools.brush = crate::paint_mouse::fresh_brush(&app.session.tools.brush); + app.session.tools.brush = crate::paint_mouse::fresh_brush(app.ui.brush_tool, &app.session.tools.brush); app.session.tools.brush_gesture = None; app.session.tools.current_preset = None; app.session.tools.brush_changed(); @@ -146,10 +159,12 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { /// The serialized name of `tool`'s own Sample All Layers inside `sample_all`, if it has one: a /// tool preset stores that one alone, not the other tools' (#5524, #7356). pub fn sample_all_key(tool: Tool) -> Option { - let mut probe = crate::state::SampleAllLayers::default(); - *probe.slot(tool)? = true; - let Ok(Value::Object(m)) = serde_json::to_value(probe) else { return None }; - m.into_iter().find(|(_, v)| v.as_bool() == Some(true)).map(|(k, _)| k) + let base = crate::state::SampleAllLayers::default(); + let mut probe = base; + let slot = probe.slot(tool)?; + *slot = !*slot; + let (Ok(Value::Object(b)), Ok(Value::Object(m))) = (serde_json::to_value(base), serde_json::to_value(probe)) else { return None }; + m.into_iter().find(|(k, v)| b.get(k) != Some(v)).map(|(k, _)| k) } /// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and @@ -171,7 +186,8 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { move_expand_group_on_click, move_show_layers_hover_bounds, transform_interpolation, - transform_reference_point + transform_reference_point, + transform_relative_position ), Tool::RectMarquee | Tool::EllipseMarquee => reset!(feather, anti_alias, marquee_style, marquee_width, marquee_height, marquee_unit), // The single-pixel marquees are never anti-aliased: Feather is their only option. @@ -243,7 +259,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { arrow_length, arrow_concavity, ), - Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => { + Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::CustomShape => { reset!(shape_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } Tool::ColorSampler @@ -356,3 +372,7 @@ mod tests { assert_eq!((keys.len(), distinct.len()), (12, 12), "one key per tool with the option"); } } + +#[cfg(test)] +#[path = "../patch_sample_all_default_tests.rs"] +mod patch_sample_all_default_tests; diff --git a/crates/ui-egui/src/tools/transform_fx.rs b/crates/ui-egui/src/tools/transform_fx.rs index 0ad888536d..5be2428c85 100644 --- a/crates/ui-egui/src/tools/transform_fx.rs +++ b/crates/ui-egui/src/tools/transform_fx.rs @@ -106,7 +106,7 @@ pub fn work_layer(layer: &Layer, scale: f64) -> Layer { l.vector_mask = None; l.fill_cache = None; l.blend = BlendMode::Normal; - photocraft_engine::image_cmds::scale_effects(&mut l.effects, scale as f32); + photocraft_engine::image_cmds::scale_effects_preview(&mut l.effects, scale as f32); l.effects.reference = l.effects.reference.map(|(x, y)| (x * scale, y * scale)); l } @@ -468,6 +468,27 @@ mod tests { l } + fn pattern_stroke_scale(l: &Layer) -> Option { + l.effects.items.iter().find_map(|e| match e { + Effect::Stroke(StrokeFx { paint: FxPaint::Pattern { scale, .. }, .. }) => Some(*scale), + _ => None, + }) + } + + /// The preview draws a pattern at the work scale exactly: the dialog's 1..1000 % range is for + /// stored styles, so a 2 % pattern at a quarter of the size is 0.5 %, and an imported 2000 % + /// one stays 2000 % at full size, as the committed style draws them. + #[test] + fn work_layer_scales_patterns_past_the_dialog_range() { + let with_pattern = |scale: f32| { + let Effect::Stroke(mut st) = stroke(3.0) else { panic!("a stroke") }; + st.paint = FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale }; + styled(vec![Effect::Stroke(st)]) + }; + assert_eq!(pattern_stroke_scale(&work_layer(&with_pattern(0.02), 0.25)), Some(0.005)); + assert_eq!(pattern_stroke_scale(&work_layer(&with_pattern(20.0), 1.0)), Some(20.0)); + } + fn doc() -> Document { Document::new("t", photocraft_geom::Size::new(200, 200), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8) } diff --git a/crates/ui-egui/src/tools/transform_tool/draw.rs b/crates/ui-egui/src/tools/transform_tool/draw.rs index 544e7a8359..8205381f60 100644 --- a/crates/ui-egui/src/tools/transform_tool/draw.rs +++ b/crates/ui-egui/src/tools/transform_tool/draw.rs @@ -1,11 +1,11 @@ use super::begin::warps_type; use super::drag_modes::{HandleIndicator, handle_indicator}; use super::options_bar::{quad_scale, readout}; -use super::pointer::{Hit, handle_at, rotate_dir, rotation_degrees}; +use super::pointer::{Hit, handle_at, rotation_degrees}; use super::warp::{on_section_edge, quick_split_kind, single_patch}; use super::{ - BezierMesh, Color32, DocId, HANDLE_PX, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp, - WarpDrag, WarpStyle, corners, pos2, vec2, + BezierMesh, Color32, DocId, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp, WarpDrag, + WarpStyle, corners, pos2, vec2, }; /// The distort or skew pointer at screen point `at`, where [`super::cursor`] hid the system @@ -224,14 +224,6 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], Stroke::new(1.0, Color32::WHITE)); } if let Some(at) = painter.ctx().pointer_hover_pos().filter(|p| xf.rect.contains(*p)) { - let p = xf.to_doc(at); - let tol = HANDLE_PX / f64::from(xf.zoom.max(0.01)); - if let Some(k) = rotate_dir(t, Some(pv), p, tol) { - // Through the view, so the arc bends round the corner as it shows under a turned or - // flipped view. - let toward = xf.to_screen((p[0] + k[0] * 16.0) as f32, (p[1] + k[1] * 16.0) as f32) - at; - crate::crop_ui::draw_turn_cursor(painter.ctx(), at, toward); - } // Preferences › Tools › Show Transformation Values off hides it, as in Photoshop (#204). if let Some(r) = live_readout(app).filter(|_| app.session.prefs().tools.show_transformation_values) { draw_live_readout(app, painter.ctx(), at, r); 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 19b46634e6..e9ee695815 100644 --- a/crates/ui-egui/src/tools/transform_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/transform_tool/options_bar.rs @@ -270,6 +270,87 @@ fn reference_point_toggle(ui: &mut egui::Ui, on: &mut bool) -> bool { *on } +/// Where the reference point was when the session opened: the point of the original box that the +/// box's transform has carried to the reference point now. A box that only scales or turns about +/// its reference point leaves it in place; a moved box carries it along. +fn reference_point_origin(rect: [f64; 4], quad: [[f64; 2]; 4], pivot: [f64; 2]) -> [f64; 2] { + if let Some(h) = Homography::rect_to_quad(rect, quad).and_then(|h| h.inverse()) { + let (x, y) = h.apply(pivot[0], pivot[1]); + if x.is_finite() && y.is_finite() { + return [x, y]; + } + } + // A flattened box has no inverse. Fit the map's linear part about the box's centre and use + // its pseudo-inverse: the collapsed direction adds nothing, so a box squashed about its + // reference point reads no offset and one squashed toward an edge reads how far it moved. + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let centre = [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0]; + let mid = [quad.iter().map(|q| q[0]).sum::() / 4.0, quad.iter().map(|q| q[1]).sum::() / 4.0]; + let d = [pivot[0] - mid[0], pivot[1] - mid[1]]; + if !(w.abs() > f64::EPSILON && h.abs() > f64::EPSILON) { + return [centre[0] + d[0], centre[1] + d[1]]; + } + // Columns: where a unit step along the box's width and height goes. + let ex = [(quad[1][0] - quad[0][0] + quad[2][0] - quad[3][0]) / (2.0 * w), (quad[1][1] - quad[0][1] + quad[2][1] - quad[3][1]) / (2.0 * w)]; + let ey = [(quad[3][0] - quad[0][0] + quad[2][0] - quad[1][0]) / (2.0 * h), (quad[3][1] - quad[0][1] + quad[2][1] - quad[1][1]) / (2.0 * h)]; + let det = ex[0] * ey[1] - ey[0] * ex[1]; + let norm2 = ex[0] * ex[0] + ex[1] * ex[1] + ey[0] * ey[0] + ey[1] * ey[1]; + let back = if det.abs() > 1e-9 * norm2.max(1e-12) { + [(ey[1] * d[0] - ey[0] * d[1]) / det, (ex[0] * d[1] - ex[1] * d[0]) / det] + } else if norm2 > 1e-12 { + // Rank one: the pseudo-inverse is the transpose over the squared norm. + [(ex[0] * d[0] + ex[1] * d[1]) / norm2, (ey[0] * d[0] + ey[1] * d[1]) / norm2] + } else { + [0.0, 0.0] + }; + let o = [centre[0] + back[0], centre[1] + back[1]]; + if o[0].is_finite() && o[1].is_finite() { o } else { pivot } +} + +fn shown_at(rect: [f64; 4], quad: [[f64; 2]; 4], pivot: [f64; 2], relative: bool) -> [f64; 2] { + if relative { + let o = reference_point_origin(rect, quad, pivot); + [pivot[0] - o[0], pivot[1] - o[1]] + } else { + pivot + } +} + +/// X and Y as the options bar shows them: the reference point's position, or with relative +/// positioning on (#11679) its offset from where it was when the session opened. +pub(crate) fn position_shown(t: &TransformSession, relative: bool) -> [f64; 2] { + shown_at(t.rect, t.quad, t.pivot, relative) +} + +/// Moves the box (and its reference point) so X and Y read `xy` (see [`position_shown`]). +pub(crate) fn set_position(s: &mut TransformSession, relative: bool, xy: [f64; 2]) { + let now = position_shown(s, relative); + let (dx, dy) = (xy[0] - now[0], xy[1] - now[1]); + s.quad = s.quad.map(|q| [q[0] + dx, q[1] + dy]); + s.pivot = [s.pivot[0] + dx, s.pivot[1] + dy]; +} + +/// Use Relative Positioning for Reference Point: the triangle button Photoshop puts between X and +/// Y (#11679). On, X and Y are the reference point's offset from where the box started. +fn relative_position_toggle(ui: &mut egui::Ui, on: &mut bool) { + let tk = crate::theme::Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click()); + if resp.clicked() { + *on = !*on; + } + let painter = ui.painter(); + if *on || resp.hovered() { + painter.rect_filled(rect, 3.0, if *on { tk.accent.gamma_multiply(0.35) } else { tk.card_border.gamma_multiply(0.5) }); + } + let c = rect.center(); + let tri = vec![egui::pos2(c.x, c.y - 4.5), egui::pos2(c.x + 5.0, c.y + 4.0), egui::pos2(c.x - 5.0, c.y + 4.0)]; + let color = if *on { tk.accent } else { tk.text_dim }; + painter.add(egui::Shape::closed_line(tri, egui::Stroke::new(1.2, color))); + let label = tl!("Use relative positioning for reference point"); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, *on, label)); + resp.on_hover_text(label); +} + fn anchor_grid_widget(ui: &mut egui::Ui, current_anchor: Option, enabled: bool) -> Option { let size = egui::vec2(16.0, 16.0); // Greyed and inert while Toggle Reference Point is off, as in Photoshop (#11098). @@ -314,18 +395,23 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t pivot = new_p; } ui.add_space(4.0); - let mut px = pivot[0] as f32; - let mut py = pivot[1] as f32; + let relative = app.ui.tool_options.transform_relative_position; + let shown = shown_at(t.rect, t.quad, pivot, relative); + let mut px = shown[0] as f32; + let mut py = shown[1] as f32; lbl(ui, "X:"); let rx = crate::widgets::value_field(ui, &mut px, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0).accessible_name("X"); + relative_position_toggle(ui, &mut app.ui.tool_options.transform_relative_position); lbl(ui, "Y:"); let ry = crate::widgets::value_field(ui, &mut py, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0).accessible_name("Y"); if (rx.changed() || ry.changed()) && let Some(s) = app.ui.transform.as_mut() { - let (dx, dy) = (px as f64 - s.pivot[0], py as f64 - s.pivot[1]); - s.quad = s.quad.map(|q| [q[0] + dx, q[1] + dy]); - s.pivot = [px as f64, py as f64]; + // An axis the user didn't edit keeps its full-precision value: the field's f32 would + // nudge the box by its rounding. + let x = if rx.changed() { f64::from(px) } else { shown[0] }; + let y = if ry.changed() { f64::from(py) } else { shown[1] }; + set_position(s, relative, [x, y]); } crate::widgets::vline(ui, 22.0); // W and H in the unit picked from their right-click menu: the scale in percent, or the box's diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 2cd5ba41a8..2f4d1b973f 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -3,8 +3,7 @@ use super::commit::{commit, committing, moves_type}; use super::drag_modes::{apply_drag, apply_mode_drag, drag_mods, handle_indicator, mode_allows, mode_mods}; use super::warp::{quick_split_kind, split_cursor, surface_at, warp_hit, warp_pointer}; use super::{ - CursorIcon, HANDLE_PX, Homography, LayerContent, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform, WarpStyle, - handle_tolerance, + CursorIcon, HANDLE_PX, Homography, LayerContent, PhotocraftApp, ToolEvent, TransformMode, TransformSession, ViewXform, WarpStyle, handle_tolerance, }; /// Which part of the box a document point hits. @@ -17,8 +16,106 @@ pub(crate) enum Hit { Outside, } -/// The nearest handle within `tol` (on a small box their grab areas overlap), else inside or -/// outside the box. +/// Screen-sized corner and outward edge bands, shared with Move's unopened controls. +impl TransformSession { + /// Classify a document point with screen-sized corner and outward edge zones. + /// On and outward from an edge, the nearest corner or midpoint wins, with the corner + /// reach capped along the edge. Inside and beyond the edge ends use the corner radius. + pub(crate) fn box_hit(quad: &[[f64; 2]; 4], p: [f64; 2], tol: f64) -> Hit { + // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. + let corner_radius = tol * (8.0 / HANDLE_PX); + let edge_width = tol * (11.0 / HANDLE_PX); + // Canvas coordinates round through f32. Treat only sub-pixel projection noise as + // the line itself; a point one screen pixel inside still belongs to the interior. + let line_epsilon = tol.abs() * (1e-3 / HANDLE_PX); + let distance = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); + let interior = inside(quad, p); + let edge = (0..4) + .filter_map(|i| { + let (u, q) = edge_projection(quad, i, p)?; + let d = distance(q); + (d <= edge_width && (!interior || d <= line_epsilon)).then_some((i, u, d)) + }) + .min_by(|a, b| a.2.total_cmp(&b.2)); + if let Some((i, u, _)) = edge + && (0.0..=1.0).contains(&u) + { + let next = (i + 1) % 4; + let (a, b) = (quad[i], quad[next]); + let length = (b[0] - a[0]).hypot(b[1] - a[1]); + let quarter = length * 0.25; + let tie_epsilon = line_epsilon.min(quarter * 0.5); + // Projected distances rank the handles along this edge. At the measured + // quarter-point ties, prefer the earlier handle in edge order. + let from_start = u * length; + let from_end = (1.0 - u) * length; + if from_start <= quarter + tie_epsilon && from_start <= edge_width + line_epsilon { + return Hit::Corner(i); + } + if from_end < quarter - tie_epsilon && from_end <= edge_width + line_epsilon { + return Hit::Corner(next); + } + return Hit::Edge(i); + } + if let Some((i, _)) = quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) { + return Hit::Corner(i); + } + match edge { + Some((i, _, _)) => Hit::Edge(i), + None if interior => Hit::Inside, + None => Hit::Outside, + } + } + + /// Into the nearest edge or corner: eight orientations for an upright rectangle, following + /// the actual quad when it is rotated, flipped or distorted. + pub(crate) fn box_turn_direction(quad: &[[f64; 2]; 4], p: [f64; 2]) -> Option<[f64; 2]> { + if p.iter().chain(quad.iter().flatten()).any(|v| !v.is_finite()) { + return None; + } + let (i, u, _) = (0..4) + .filter_map(|i| { + let (u, q) = edge_projection(quad, i, p)?; + Some((i, u, (p[0] - q[0]).hypot(p[1] - q[1]))) + }) + .min_by(|a, b| a.2.total_cmp(&b.2))?; + let unit = |v: [f64; 2]| { + let len = v[0].hypot(v[1]); + (len.is_finite() && len > 1e-9).then(|| [v[0] / len, v[1] / len]) + }; + let toward = if u <= 0.0 || u >= 1.0 { + let i = if u >= 1.0 { (i + 1) % 4 } else { i }; + let direction = |j: usize| unit([quad[j][0] - quad[i][0], quad[j][1] - quad[i][1]]); + let (a, b) = (direction((i + 1) % 4)?, direction((i + 3) % 4)?); + [a[0] + b[0], a[1] + b[1]] + } else { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + let normal = [a[1] - b[1], b[0] - a[0]]; + // Winding orients every edge, even when a concave quad's mean corner position + // lies on the wrong side of this edge. Translate before taking the area. + let cross = |i: usize, j: usize| (quad[i][0] - quad[0][0]) * (quad[j][1] - quad[0][1]) - (quad[i][1] - quad[0][1]) * (quad[j][0] - quad[0][0]); + let sign = (cross(1, 2) + cross(2, 3)).signum(); + normal.map(|v| v * sign) + }; + unit(toward) + } +} + +/// The unclamped projection parameter and the nearest point on the segment. Degenerate +/// edges have no band or normal; the other edges and corner handles remain usable. +fn edge_projection(quad: &[[f64; 2]; 4], i: usize, p: [f64; 2]) -> Option<(f64, [f64; 2])> { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + let d = [b[0] - a[0], b[1] - a[1]]; + let len2 = d[0] * d[0] + d[1] * d[1]; + if !len2.is_finite() || len2 <= 1e-18 { + return None; + } + let u = ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / len2; + let on_segment = u.clamp(0.0, 1.0); + Some((u, [a[0] + on_segment * d[0], a[1] + on_segment * d[1]])) +} + +/// The shared box zones, retaining the nearest pivot's existing grab priority on small boxes. pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit { hit_with(t, p, tol, true) } @@ -35,22 +132,26 @@ fn hit_with(t: &TransformSession, p: [f64; 2], tol: f64, pivot: bool) -> Hit { let (a, b) = (t.quad[i], t.quad[(i + 1) % 4]); [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0] }; + let h = TransformSession::box_hit(&t.quad, p, tol); + let handle_distance = match h { + Hit::Corner(i) => d(t.quad[i]), + Hit::Edge(i) => d(mid(i)), + _ => f64::INFINITY, + }; + if pivot { + // A shown reference point wins ties with handles, including when it sits on one. + let distance = d(t.pivot); + return if distance < tol && distance <= handle_distance { Hit::Pivot } else { h }; + } // With the reference point hidden, the box centre is a move target in its place, so a press // at the centre of a box smaller than two grab areas moves it instead of grabbing an edge. let centre = [t.quad.iter().map(|q| q[0]).sum::() / 4.0, t.quad.iter().map(|q| q[1]).sum::() / 4.0]; - // A shown reference point comes first: where it sits on a handle (the options bar's locator - // or its pull to the box's points put it there), a press drags the point, not the handle, as - // in the reference application. - let (first, last) = if pivot { (Some((t.pivot, Hit::Pivot)), None) } else { (None, Some((centre, Hit::Inside))) }; - let handles = first.into_iter().chain((0..4).map(|i| (t.quad[i], Hit::Corner(i)))).chain((0..4).map(|i| (mid(i), Hit::Edge(i)))).chain(last); - let nearest = handles.map(|(q, h)| (d(q), h)).filter(|(dist, _)| *dist < tol).min_by(|a, b| a.0.total_cmp(&b.0)); - match nearest { - Some((_, h)) => h, - None if inside(&t.quad, p) => Hit::Inside, - None => Hit::Outside, - } + let distance = d(centre); + if distance < tol && distance < handle_distance { Hit::Inside } else { h } } +/// Even-odd quad membership, independent of winding and valid for concave boxes. +/// Handles and edge-rounding tolerance are classified separately by the caller. fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool { let mut c = false; for i in 0..4 { @@ -268,16 +369,12 @@ pub(super) fn carry_pivot(quad0: [[f64; 2]; 4], quad: [[f64; 2]; 4], pivot0: [f6 if x.is_finite() && y.is_finite() { [x, y] } else { pivot0 } } -/// True just outside a corner: rotate handle feedback lives here, not over the entire -/// empty canvas. The actual rotation gesture continues to work farther away. -pub(super) fn near_rotate_corner(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { +/// Rotation feedback uses the same hit as the gesture, including the held hit while dragging. +pub(super) fn near_rotate_corner(t: &TransformSession, h: Hit) -> bool { t.warp.is_none() - && t.mode != TransformMode::Distort - && { - let h = hit(t, p, tol); - h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_))) - } - && t.quad.iter().any(|q| (p[0] - q[0]).hypot(p[1] - q[1]) <= tol * 3.0) + && t.content_aware.is_none() + && !matches!(t.mode, TransformMode::Distort | TransformMode::Skew) + && (h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_)))) } /// Clockwise degrees in canvas coordinates (positive Y points down). @@ -288,40 +385,18 @@ pub(super) fn rotation_degrees(quad: [[f64; 2]; 4]) -> f64 { } /// Where the rotate cursor's arc bends at document point `p`, or `None` outside the rotate zone: -/// just outside a corner, or anywhere while a rotate drag is on. It points into the box along the -/// bisector of the nearest corner, so each corner gets its own arrow and they turn with the box. +/// anywhere outside, or while a rotate drag is on. It follows the nearest edge or corner. pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> { - rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app)) + let t = app.ui.transform.as_ref()?; + rotate_dir(t, handle_at(app, t, p, handle_tolerance(app)), p) } -pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> { - // Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow. - if !(p[0].is_finite() && p[1].is_finite()) - || t.warp.is_some() - || t.content_aware.is_some() - || matches!(t.mode, TransformMode::Distort | TransformMode::Skew) - { +/// Follow the nearest edge or corner during a rotation hover or drag. +fn rotate_dir(t: &TransformSession, h: Hit, p: [f64; 2]) -> Option<[f64; 2]> { + if !near_rotate_corner(t, h) { return None; } - // During a drag the arrow shows only while it rotates: not when a handle that scales, skews - // or distorts strays past a corner (#5405). - match pv.and_then(|pv| pv.gesture) { - Some(g) if g.hit == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(g.hit, Hit::Corner(_) | Hit::Edge(_))) => {} - Some(_) => return None, - None if !near_rotate_corner(t, p, tol) => return None, - None => {} - } - let dist = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); - let i = (0..4).min_by(|&a, &b| dist(t.quad[a]).total_cmp(&dist(t.quad[b])))?; - let unit = |to: [f64; 2]| { - let d = [to[0] - t.quad[i][0], to[1] - t.quad[i][1]]; - let l = d[0].hypot(d[1]); - (l.is_finite() && l > 1e-9).then(|| [d[0] / l, d[1] / l]) - }; - let (a, b) = (unit(t.quad[(i + 1) % 4])?, unit(t.quad[(i + 3) % 4])?); - let s = [a[0] + b[0], a[1] + b[1]]; - let l = s[0].hypot(s[1]); - (l.is_finite() && l > 1e-9).then(|| [s[0] / l, s[1] / l]) + TransformSession::box_turn_direction(&t.quad, p) } /// The handle a drag in progress holds, else the one under document point `p`. @@ -362,7 +437,7 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], mods: egui::Modi return Some(CursorIcon::Default); } // A ⌘-drag outside the box still rotates, so ⌘ keeps the curved arrow there. - if rotate_cursor_dir(app, p).is_some() { + if rotate_dir(t, h, p).is_some() { // The canvas draws the curved rotate arrow (`rotate_cursor_dir`), also during the drag. return Some(CursorIcon::None); } @@ -382,7 +457,6 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], mods: egui::Modi Hit::Corner(i) => crate::tool_cursor::corner_resize(&t.quad, i, xf), Hit::Edge(i) => resize(edge_mid(i + 2), edge_mid(i)), Hit::Pivot => CursorIcon::Crosshair, - Hit::Inside => CursorIcon::Move, - Hit::Outside => CursorIcon::Alias, + Hit::Inside | Hit::Outside => CursorIcon::Default, }) } diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs index 3721fdeb2a..08d24d6607 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -79,14 +79,15 @@ fn a_click_near_rotation_handles_accepts_but_drags_and_option_pivot_do_not() { } #[test] -fn rotate_feedback_only_appears_just_outside_corners() { +fn rotate_feedback_covers_the_outside_zone() { let mut t = session(); - assert!(near_rotate_corner(&t, [-18.0, -18.0], 12.0)); - assert!(!near_rotate_corner(&t, [50.0, 25.0], 12.0), "inside moves the box"); - assert!(!near_rotate_corner(&t, [500.0, 500.0], 12.0), "far outside stays generic"); - assert!(!near_rotate_corner(&t, [2.0, 2.0], 12.0), "corner handle resizes"); + let rotates = |t: &TransformSession, p| near_rotate_corner(t, hit(t, p, 12.0)); + assert!(rotates(&t, [-18.0, -18.0])); + assert!(!rotates(&t, [30.0, 25.0]), "inside moves the box"); + assert!(rotates(&t, [500.0, 500.0]), "far outside also rotates"); + assert!(!rotates(&t, [2.0, 2.0]), "corner handle resizes"); t.mode = TransformMode::Distort; - assert!(!near_rotate_corner(&t, [-18.0, -18.0], 12.0)); + assert!(!rotates(&t, [-18.0, -18.0])); } #[test] @@ -329,7 +330,7 @@ fn bar_harness(setup: impl FnOnce(&mut PhotocraftApp)) -> egui_kittest::Harness< let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.ui.transform = Some(session()); setup(&mut app); - let mut h = Harness::builder().with_size(vec2(900.0, 360.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(1000.0, 360.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { return; @@ -1771,7 +1772,7 @@ fn hovering_just_outside_a_corner_asks_for_the_rotate_cursor() { assert_eq!(cursor(&app, &xf, tl, egui::Modifiers::NONE), Some(CursorIcon::None), "the arrow is drawn instead"); assert!(near2(rotate_cursor_dir(&app, tl), [h, h]), "{:?}", rotate_cursor_dir(&app, tl)); assert!(near2(rotate_cursor_dir(&app, br), [-h, -h]), "bends round the bottom-right corner"); - assert_eq!(cursor(&app, &xf, [60.0, 60.0], egui::Modifiers::NONE), Some(CursorIcon::Move)); + assert_eq!(cursor(&app, &xf, [60.0, 60.0], egui::Modifiers::NONE), Some(CursorIcon::Default)); assert_eq!(rotate_cursor_dir(&app, [60.0, 60.0]), None, "inside moves"); assert_eq!(rotate_cursor_dir(&app, [32.0, 32.0]), None, "the corner handle scales"); assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 32.0]), None); @@ -3568,9 +3569,10 @@ fn cursor_during_drag_stays_stable() { // Release drag crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 60.0 }, command); - // Once released, dragging has ended: far outside returns the commit-on-click cursor, + // Once released, dragging has ended: far outside returns the custom rotate cursor, // and hovering the corner without Command restores normal resize cursor. - assert_eq!(cursor(&app, &xf, [200.0, 200.0], command), Some(CursorIcon::Alias)); + assert_eq!(cursor(&app, &xf, [200.0, 200.0], command), Some(CursorIcon::None)); + assert!(rotate_cursor_dir(&app, [200.0, 200.0]).is_some()); assert_ne!(cursor(&app, &xf, [50.0, 60.0], egui::Modifiers::NONE), Some(CursorIcon::None)); } diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index 38eee2b665..bbd674c4f4 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -169,6 +169,11 @@ pub struct PenPath { /// commit replaces that subpath instead of adding one. #[serde(default)] pub resumes: Option, + /// The Curvature Pen carrying on a path: its knots as they were, which keep their shape while + /// the new anchors' handles follow them (`curvature_refresh`, #10726). The last one is the + /// endpoint drawing carries on from; only its outgoing handle bends into the new segment. + #[serde(default)] + pub fixed: Vec<[[f64; 2]; 3]>, } /// The subpath a Pen path carries on: where it is and what it was when drawing resumed. The @@ -196,6 +201,9 @@ impl PenPath { #[cfg(test)] mod tests; +#[cfg(test)] +mod path_drag_preview_tests; + #[cfg(test)] mod path_selection_tests; diff --git a/crates/ui-egui/src/tools/vector_ui/curvature.rs b/crates/ui-egui/src/tools/vector_ui/curvature.rs index ebd62becdb..a807bdae56 100644 --- a/crates/ui-egui/src/tools/vector_ui/curvature.rs +++ b/crates/ui-egui/src/tools/vector_ui/curvature.rs @@ -1,11 +1,58 @@ -use super::pen::{cancel_stale, pen_commit}; +use super::pen::{cancel_stale, pen_commit, resume_selected}; use super::{PenPath, PhotocraftApp}; -/// Recompute a Curvature Pen path's handles from its anchors and corner points. +/// Recompute a Curvature Pen path's handles from its anchors and corner points. The knots of a +/// path it carries on (`PenPath::fixed`) keep theirs, but for the endpoint's outgoing handle +/// and, once the path closes, the first knot's incoming handle (#10726). As in Photoshop, an open +/// path carried on is a new Curvature run from the endpoint: the endpoint's outgoing handle and +/// the new knots are those of a path drawn from the endpoint on, so the join is not made smooth. +/// The first knot's incoming handle, bent into the closing segment, stays in line with its +/// outgoing one, so that knot stays smooth. pub(super) fn curvature_refresh(pen: &mut PenPath, closed: bool) { let anchors: Vec<[f64; 2]> = pen.knots.iter().map(|k| k[0]).collect(); let corners: Vec = (0..anchors.len()).map(|i| pen.unlinked.contains(&i)).collect(); pen.knots = photocraft_vector::edit::curvature_knots(&anchors, &corners, closed); + let last = pen.fixed.len().saturating_sub(1); + let extended = pen.knots.len() > pen.fixed.len(); + if extended && !closed && !pen.fixed.is_empty() { + let run = photocraft_vector::edit::curvature_knots(anchors.get(last..).unwrap_or_default(), corners.get(last..).unwrap_or_default(), false); + for (k, r) in pen.knots.iter_mut().skip(last).zip(run) { + *k = r; + } + } + for (i, was) in pen.fixed.iter().enumerate() { + let Some(k) = pen.knots.get_mut(i) else { break }; + let (keep_in, keep_out) = (!(closed && i == 0), !(extended && i == last)); + k[0] = was[0]; + if keep_in { + k[1] = was[1]; + } + if keep_out { + k[2] = was[2]; + } + if !keep_in && keep_out && !corners.get(i).copied().unwrap_or(false) { + // An outgoing handle on its anchor gives no direction: the kept segment leaves + // towards its next control point, else the next anchor, so the bent handle comes in + // from the far side and the knot is still smooth (CodeRabbit on #10744). + let across = pen.fixed.get(i + 1).map(|n| [n[1], n[0]]); + let toward = std::iter::once(k[2]).chain(across.into_iter().flatten()); + if let Some(h) = toward.filter_map(|from| in_line(k[0], from, k[1])).next() { + k[1] = h; + } + } + } +} + +/// A handle as long as `bent` from `anchor`, on the far side of it from `kept`: `None` when +/// `kept` lies on the anchor and gives no direction. +fn in_line(anchor: [f64; 2], kept: [f64; 2], bent: [f64; 2]) -> Option<[f64; 2]> { + let (dx, dy) = (anchor[0] - kept[0], anchor[1] - kept[1]); + let d = dx.hypot(dy); + if !(d > 1e-9 && d.is_finite()) { + return None; + } + let len = (bent[0] - anchor[0]).hypot(bent[1] - anchor[1]); + len.is_finite().then(|| [anchor[0] + dx / d * len, anchor[1] + dy / d * len]) } /// The anchor of the Curvature Pen path being drawn under (x, y), for the cursor. @@ -44,6 +91,19 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { if app.ui.pen.is_none() { app.ui.pen_doc = app.session.active().map(|st| st.doc.id); } + // With an endpoint of an open path selected (a click, or ⌘/Ctrl-click, on it), the press + // carries that path on from there, as in Photoshop (#10726): its segments keep their shape, + // the press adds the next anchor and the commit extends that subpath. + if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) + && let Some(mut resumed) = resume_selected(app) + { + resumed.curvature = true; + resumed.fixed = resumed.knots.clone(); + app.ui.pen = Some(resumed); + } + // The anchors of the path carried on stay where they are; the new ones move. + let fixed = app.ui.pen.as_ref().map_or(0, |p| p.fixed.len()); + let hit = hit.filter(|&i| i >= fixed); let Some(owner) = capture_pen_owner(app) else { return }; let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); if pen.owner.is_none() { @@ -86,6 +146,11 @@ pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { cancel_stale_pen(app); let Some(i) = curvature_hit(app, x, y) else { return }; let Some(pen) = app.ui.pen.as_mut() else { return }; + // The knots of a path carried on keep their handles (`curvature_refresh`), but for the + // endpoint drawing carries on from: toggling one of the others would change nothing. + if i + 1 < pen.fixed.len() { + return; + } if let Some(at) = pen.unlinked.iter().position(|&c| c == i) { pen.unlinked.remove(at); } else { diff --git a/crates/ui-egui/src/tools/vector_ui/overlay.rs b/crates/ui-egui/src/tools/vector_ui/overlay.rs index 1e50b63b47..e2e8a241c7 100644 --- a/crates/ui-egui/src/tools/vector_ui/overlay.rs +++ b/crates/ui-egui/src/tools/vector_ui/overlay.rs @@ -1,5 +1,5 @@ use super::curvature::curvature_refresh; -use super::path_ops::{active_type_path, targeted_vector_mask, work_path_shown_in}; +use super::path_ops::{MovedPath, active_type_path, path_selection_preview, targeted_vector_mask, work_path_shown_in}; use super::pen::{closing, over_first_anchor, shift_45}; use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; use photocraft_doc::vector::Path; @@ -157,14 +157,17 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // a path tool is active, as in Photoshop: a Work Path left unselected (one a PSD arrived // with, or after Ctrl-click Load as Selection) would otherwise cover the marching ants (#6551). let anchors = shows_anchors(tool); + // A Path Selection drag shows its path where the release will put it. + let moving = path_selection_preview(app, doc); + let moved = |which: MovedPath| moving.as_ref().filter(|(m, _)| *m == which).map(|(_, p)| p); if let Some(wp) = doc.work_path.as_ref().filter(|_| work_path_shown_in(app, doc)) && (anchors || app.ui.selected_path.as_deref() == Some("work")) { - draw_path(wp, anchors); + draw_path(moved(MovedPath::Work).unwrap_or(wp), anchors); } // The saved path selected in the Paths panel is the target path (#6550). if let Some(p) = selected_saved_path(app) { - draw_path(p, anchors); + draw_path(moved(MovedPath::Saved).unwrap_or(p), anchors); } let st = app.session.active(); if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) @@ -188,8 +191,14 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // Path Selection's selected components show solid anchors, as in Photoshop. if tool == Tool::PathSelection && st.is_some_and(|s| s.doc.id == doc.id) - && let Some((_, path, _)) = crate::path_components::selected(app) + && let Some((r, path, _)) = crate::path_components::selected(app) { + let path = match r { + crate::direct_select::PathRef::Work => moved(MovedPath::Work), + crate::direct_select::PathRef::Saved(_) => moved(MovedPath::Saved), + _ => None, + } + .unwrap_or(&path); for k in app.ui.direct_selection.anchors.iter().filter_map(|[s, k]| path.subpaths.get(*s).and_then(|sp| sp.knots.get(*k))) { draw_anchor(painter, to_scr([k.anchor.x, k.anchor.y]), true, accent); } diff --git a/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs new file mode 100644 index 0000000000..436775f6de --- /dev/null +++ b/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs @@ -0,0 +1,167 @@ +//! #9306: a Path Selection drag shows its path following the pointer, where the release will put +//! it, instead of leaving it in place until the release. The document only changes on release. + +use egui::{Modifiers, Pos2}; +use photocraft_doc::vector::Path; +use photocraft_geom::{Affine, Point}; +use serde_json::json; + +use super::path_ops::{MovedPath, path_selection_preview}; +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform, tool_event}; +use crate::state::Tool; + +const NAME: &str = "Path 1"; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::PathSelection; + app.ui.extras.snap = false; + app +} + +fn square(x: f64, y: f64, w: f64) -> serde_json::Value { + json!({"closed": true, "knots": [[x, y], [x + w, y], [x + w, y + w], [x, y + w]]}) +} + +fn work(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.work_path.clone().unwrap() +} + +fn revision(app: &PhotocraftApp) -> u64 { + app.session.active().unwrap().revision +} + +fn preview(app: &PhotocraftApp) -> Option<(MovedPath, Path)> { + path_selection_preview(app, &app.session.active().unwrap().doc) +} + +fn press_and_move(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, Modifiers::NONE); + assert!(app.drag.is_some()); +} + +fn release(app: &mut PhotocraftApp, at: [f64; 2]) { + tool_event(app, ToolEvent::Up { x: at[0], y: at[1] }, Modifiers::NONE); +} + +fn anchors(p: &Path) -> Vec { + p.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| k.anchor)).collect() +} + +/// The centres of the anchor squares the vector overlay draws, document = screen pixels. +fn drawn_anchors(app: &PhotocraftApp) -> Vec { + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(200., 200.)), + zoom: 1.0, + center: [100., 100.], + flip: false, + rotation: 0., + aspect: 1., + }; + let doc = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| super::draw_overlay(app, ui.painter(), &xf, &doc)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) if (r.rect.width() - 6.0).abs() < 0.01 => Some(r.rect.center()), + _ => None, + }) + .collect() +} + +fn near(drawn: &[Pos2], p: Point) -> bool { + drawn.iter().any(|q| (f64::from(q.x) - p.x).abs() < 0.01 && (f64::from(q.y) - p.y).abs() < 0.01) +} + +#[test] +fn a_work_path_drag_shows_the_path_where_the_release_puts_it() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + let before = work(&app); + let rev = revision(&app); + press_and_move(&mut app, [20.0, 40.0], [30.4, 45.6]); + assert_eq!(work(&app), before, "the drag itself leaves the document alone"); + assert_eq!(revision(&app), rev); + let (which, shown) = preview(&app).expect("the drag previews the work path"); + assert_eq!(which, MovedPath::Work); + // The same whole-pixel offset the release moves by. + assert_eq!(shown, before.transform(&Affine::translate(10.0, 6.0))); + let drawn = drawn_anchors(&app); + assert!(anchors(&shown).iter().all(|p| near(&drawn, *p)), "anchors drawn at the moved path: {drawn:?}"); + assert!(!near(&drawn, Point::new(60.0, 20.0)), "not at the path's old corner: {drawn:?}"); + release(&mut app, [30.4, 45.6]); + assert_eq!(work(&app), shown, "the release lands where the preview showed"); + assert!(preview(&app).is_none()); +} + +#[test] +fn a_click_shows_no_move() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + press_and_move(&mut app, [20.0, 40.0], [20.2, 40.1]); + assert!(preview(&app).is_none()); +} + +#[test] +fn only_the_selected_component_moves_in_the_preview() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0), square(120.0, 120.0, 40.0)]}})).unwrap(); + let before = work(&app); + // Pressing the first square's edge selects that component only. + press_and_move(&mut app, [20.0, 40.0], [25.0, 47.0]); + let (which, shown) = preview(&app).expect("the drag previews the component move"); + assert_eq!(which, MovedPath::Work); + let moved = anchors(&shown); + let old = anchors(&before); + assert_eq!(&moved[..4], &old[..4].iter().map(|p| Point::new(p.x + 5.0, p.y + 7.0)).collect::>()[..]); + assert_eq!(&moved[4..], &old[4..], "the other component stays"); + let drawn = drawn_anchors(&app); + assert!(near(&drawn, Point::new(25.0, 27.0)) && near(&drawn, Point::new(120.0, 120.0)), "{drawn:?}"); + release(&mut app, [25.0, 47.0]); + assert_eq!(work(&app), shown); +} + +#[test] +fn the_selected_saved_path_previews_too() { + let mut app = app(); + app.run("path.set", json!({"name": NAME, "path": {"subpaths": [square(40.0, 40.0, 80.0)]}})).unwrap(); + app.ui.selected_path = Some(NAME.into()); + let saved = |app: &PhotocraftApp| app.session.active().unwrap().doc.paths.iter().find(|p| p.name == NAME).unwrap().path.clone(); + let before = saved(&app); + press_and_move(&mut app, [40.0, 80.0], [52.0, 71.0]); + let (which, shown) = preview(&app).expect("the drag previews the saved path"); + assert_eq!(which, MovedPath::Saved); + assert_eq!(saved(&app), before); + release(&mut app, [52.0, 71.0]); + assert_eq!(saved(&app), shown); +} + +#[test] +fn a_shape_layer_drag_previews_no_outline() { + // Its pixels move only on release, so its outline does not run ahead of them. + let mut app = app(); + app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#ff0000"})).unwrap(); + press_and_move(&mut app, [40.0, 40.0], [50.0, 50.0]); + assert!(preview(&app).is_none()); + release(&mut app, [50.0, 50.0]); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("not a shape layer") }; + assert_eq!(sh.path.control_bounds().map(|b| [b.0, b.1]), Some([30.0, 30.0]), "the release still moves it"); +} + +#[test] +fn a_stale_drag_previews_nothing() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + press_and_move(&mut app, [20.0, 40.0], [30.0, 50.0]); + assert!(preview(&app).is_some()); + // The document changed under the drag, so its release moves nothing either. + app.session.execute("path.set", json!({"name": NAME, "path": {"subpaths": [square(0.0, 0.0, 10.0)]}})).unwrap(); + assert!(preview(&app).is_none()); +} diff --git a/crates/ui-egui/src/tools/vector_ui/path_ops.rs b/crates/ui-egui/src/tools/vector_ui/path_ops.rs index 2d23f6d3a4..1815ad43fb 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_ops.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_ops.rs @@ -343,16 +343,61 @@ fn near_outline(path: &Path, at: [f64; 2], tol: f64) -> bool { !(at[0] < x0 - pad || at[0] > x1 + pad || at[1] < y0 - pad || at[1] > y1 + pad) } -pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { +/// How far a Path Selection drag from `start` to `end` moves its path: whole pixels. `None` for +/// a click (under half a pixel of travel), which picks a shape layer instead. +fn path_selection_offset(start: [f64; 2], end: [f64; 2]) -> Option<[f64; 2]> { let (dx, dy) = (end[0] - start[0], end[1] - start[1]); - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); if dx.abs() + dy.abs() < 0.5 { + return None; + } + Some([dx.round(), dy.round()]) +} + +/// The path a Path Selection drag in progress shows moved. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MovedPath { + Work, + /// The saved path selected in the Paths panel. + Saved, +} + +/// The work path or selected saved path of a Path Selection drag in progress, moved where its +/// release puts it ([`path_selection_finish`]: the same offset, only the selected components when +/// some but not all are), so the outline follows the pointer instead of jumping on release. +/// `None` without a live drag on `doc`, for a click, and for a shape layer's or vector mask's +/// path, whose pixels move only on release. +pub(crate) fn path_selection_preview(app: &PhotocraftApp, doc: &Document) -> Option<(MovedPath, Path)> { + let d = app.drag.as_ref().filter(|d| d.tool == Tool::PathSelection && d.vector_owner_valid(app))?; + if app.session.active().is_none_or(|st| st.doc.id != doc.id) { + return None; + } + let end = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let [dx, dy] = path_selection_offset(d.start, end).filter(|o| o.iter().all(|v| v.is_finite()))?; + if let Some((r, path)) = crate::path_components::moved_selected(app, [dx, dy]) { + return match r { + crate::direct_select::PathRef::Work => Some((MovedPath::Work, path)), + crate::direct_select::PathRef::Saved(_) => Some((MovedPath::Saved, path)), + _ => None, + }; + } + let (target, path) = target_path(app)?; + let moved = match target { + PathTarget::Work => MovedPath::Work, + PathTarget::Saved(_) => MovedPath::Saved, + PathTarget::Shape(_) | PathTarget::VectorMask(_) => return None, + }; + Some((moved, path.transform(&Affine::translate(dx, dy)))) +} + +pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let Some([dx, dy]) = path_selection_offset(start, end) else { let Some(st) = app.session.active() else { return }; if let Some(id) = pick_shape_at(&st.doc, start, tol) { let _ = app.run("layer.select", json!({"layer": id.0})); } return; - } + }; if target_path(app).is_none() { let Some(st) = app.session.active() else { return }; if let Some(id) = pick_shape_at(&st.doc, start, tol) { @@ -360,7 +405,7 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 } } // Selected components move on their own (`path_components`); with none, the whole path. - if let Some(r) = crate::path_components::move_selected(app, [dx.round(), dy.round()]) { + if let Some(r) = crate::path_components::move_selected(app, [dx, dy]) { if let Err(e) = r { app.ui.status = e; app.ui.status_error = true; @@ -369,17 +414,17 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 } let Some((target, path)) = target_path(app) else { return }; let _ = match target { - PathTarget::Shape(id) => app.run("shape.edit", json!({"layer": id, "move": [dx.round(), dy.round()]})), + PathTarget::Shape(id) => app.run("shape.edit", json!({"layer": id, "move": [dx, dy]})), PathTarget::VectorMask(id) => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("layer.vectorMask.edit", json!({"layer": id, "path": photocraft_engine::vector_cmds::path_json(&moved)})) } PathTarget::Work => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)})) } PathTarget::Saved(name) => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)})) } }; diff --git a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs index d49eeaae49..5b73b9430d 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs @@ -537,3 +537,28 @@ fn cmd_shift_and_alt_enter_use_the_path_selected_in_the_paths_panel() { press(&mut h, Key::Enter, Modifiers { alt: true, ..command() }); assert_eq!(selection(&h), Some([40, 40, 20, 20]), "{}", h.state().ui.status); } + +/// The selected area in pixels (partly selected pixels count in part). +fn selected_area(h: &Harness<'_, PhotocraftApp>) -> f32 { + let d = &h.state().session.active().unwrap().doc; + d.selection.as_ref().map_or(0.0, |s| s.read_region(d.bounds()).iter().sum()) +} + +/// #7726: as in Photoshop, ⇧⌘↩ adds the path to the selection, ⌥⌘↩ subtracts it and ⇧⌥⌘↩ +/// intersects them. A 100×60 selection and a 100×20 path overlapping it by 40×20; the areas are +/// Photoshop 2025's for the same document. +#[test] +fn shift_and_option_with_cmd_enter_combine_the_path_with_the_selection() { + let alt = Modifiers { alt: true, ..command() }; + let shift = Modifiers { shift: true, ..command() }; + let both = Modifiers { shift: true, ..alt }; + for (mods, area) in [(shift, 7200.0), (alt, 5200.0), (both, 800.0), (command(), 2000.0)] { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(80.0, 40.0, 100.0, 20.0)})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 60, "antiAlias": false})).unwrap(); + h.state_mut().ui.tool = Tool::Brush; + h.run_steps(2); + press(&mut h, Key::Enter, mods); + assert!((selected_area(&h) - area).abs() < 0.5, "{mods:?}: {} px, wanted {area}: {}", selected_area(&h), h.state().ui.status); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index c9106ea432..563b78455b 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -509,28 +509,47 @@ fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { if name == "work" { doc.work_path.as_ref() } else { doc.paths.iter().find(|p| p.name == name).map(|p| &p.path) } } -/// The open subpath that has its first or last anchor at `(x, y)`, as a Pen path to carry on -/// drawing from there (#6593): of a targeted vector mask, else in Shape mode of the selected shape -/// layer, else of the path a new Pen path would join (the one selected in the Paths panel, else -/// the work path). Its knots run towards that anchor: reversed, handles swapped, when it is the -/// first. -fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { +/// The path a Pen path carries on (#6593): a targeted vector mask, else in Shape mode the +/// selected shape layer's, else the path a new Pen path would join (the one selected in the Paths +/// panel, else the work path). Its Paths-panel name ("layer" for a layer's), layer and path. +fn resume_target(app: &PhotocraftApp) -> Option<(String, Option, Path)> { let st = app.session.active()?; - let doc = st.doc.id.0; - let (name, layer, path) = if let Some((id, p)) = targeted_vector_mask(app) { - ("layer".to_string(), Some(id), p) - } else if app.ui.tool_options.vector_mode == "shape" { + if let Some((id, p)) = targeted_vector_mask(app) { + return Some(("layer".to_string(), Some(id), p)); + } + if app.ui.tool_options.vector_mode == "shape" { let (id, p) = active_shape_path(app)?; - ("layer".to_string(), Some(id), p) - } else { - let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); - // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). - if name == "work" && !work_path_shown(app) { - return None; + return Some(("layer".to_string(), Some(id), p)); + } + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). + if name == "work" && !work_path_shown(app) { + return None; + } + let p = doc_path(&st.doc, &name)?.clone(); + Some((name, None, p)) +} + +/// Subpath `index` of the path a Pen path carries on, as a Pen path to carry on drawing from its +/// last anchor, or from its first when `reversed`: then its knots run backwards, handles swapped. +fn resumed(doc: u64, name: String, layer: Option, index: usize, sub: &photocraft_doc::vector::Subpath, reversed: bool) -> PenPath { + let mut knots: Vec<_> = sub.knots.iter().map(|k| (knot_points(k), k.smooth)).collect(); + if reversed { + knots.reverse(); + for (k, _) in &mut knots { + k.swap(1, 2); } - let p = doc_path(&st.doc, &name)?.clone(); - (name, None, p) - }; + } + let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); + let resumes = Resumed { doc, path: name, layer, index, was: sub.clone() }; + PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() } +} + +/// The open subpath that has its first or last anchor at `(x, y)` in the path a Pen path carries +/// on ([`resume_target`]), as a Pen path to carry on drawing from there (#6593). +pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { + let doc = app.session.active()?.doc.id.0; + let (name, layer, path) = resume_target(app)?; let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { let reversed = if s.knots.last().is_some_and(near) { @@ -540,19 +559,36 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { } else { return None; }; - let mut knots: Vec<_> = s.knots.iter().map(|k| (knot_points(k), k.smooth)).collect(); - if reversed { - knots.reverse(); - for (k, _) in &mut knots { - k.swap(1, 2); - } - } - let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); - let resumes = Resumed { doc, path: name.clone(), layer, index: i, was: s.clone() }; - Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() }) + Some(resumed(doc, name.clone(), layer, i, s, reversed)) }) } +/// The endpoint Direct Selection has selected ([`crate::direct_select::selected_open_end`]), as +/// a Pen path carrying its very subpath on from it (#10726). `None` unless that endpoint is in +/// the path a Pen path carries on ([`resume_target`]): one in another path starts a new one. +pub(super) fn resume_selected(app: &PhotocraftApp) -> Option { + let (target, index, knot) = crate::direct_select::selected_open_end(app)?; + let st = app.session.active()?; + let (name, layer, path) = resume_target(app)?; + let carried = match layer { + Some(id) => crate::direct_select::PathRef::Layer(id), + None if name == "work" => crate::direct_select::PathRef::Work, + None => crate::direct_select::PathRef::Saved(st.doc.paths.iter().position(|p| p.name == name)?), + }; + if carried != target { + return None; + } + let sub = path.subpaths.get(index).filter(|s| !s.closed)?; + let reversed = if knot + 1 == sub.knots.len() { + false + } else if knot == 0 { + true + } else { + return None; + }; + Some(resumed(st.doc.id.0, name, layer, index, sub, reversed)) +} + /// The resumed path with its subpath carried on: the one subpath of `drawn` takes the old one's /// anchors and closing, which keeps its other settings (its path operation). `None` when the /// subpath is no longer the one drawing resumed (another document, or the path changed). diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index 7b67544721..562e700bc3 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -3,7 +3,7 @@ use super::overlay::path_lines; use super::{Affine, Color32, PhotocraftApp, Tokens, Tool, ToolOptions, Value, ViewXform, json, paint_param}; pub fn is_shape_tool(t: Tool) -> bool { - matches!(t, Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape) + matches!(t, Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line | Tool::CustomShape) } fn rgb32(c: [f32; 4]) -> Color32 { @@ -80,6 +80,8 @@ pub(super) fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: Tool::Rectangle | Tool::CustomShape => json!({"kind": "rect", "rect": rect}), Tool::EllipseShape => json!({"kind": "ellipse", "rect": rect}), Tool::Triangle => json!({"kind": "polygon", "rect": rect, "sides": 3}), + // PhotoCraft's star defaults; the Star Tool's own options are not measured yet (#11377). + Tool::Star => json!({"kind": "star", "rect": rect, "sides": 5, "starRatio": 0.5}), _ => json!({"kind": "polygon", "rect": rect, "sides": o.polygon_sides.max(3)}), } }) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs new file mode 100644 index 0000000000..201bb30c33 --- /dev/null +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -0,0 +1,483 @@ +use super::*; +use crate::state::{ContentAwareOptions, TransformMode, TransformSession}; +use crate::transform_tool::{Hit, cursor, hit, rotate_cursor_dir}; +use egui::{CursorIcon, Modifiers, pos2}; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use serde_json::json; + +const QUAD: [[f64; 2]; 4] = [[100.0, 100.0], [500.0, 100.0], [500.0, 300.0], [100.0, 300.0]]; + +fn session(quad: [[f64; 2]; 4]) -> TransformSession { + TransformSession { + session: 1, + layer: 1, + rect: [100.0, 100.0, 500.0, 300.0], + quad, + pivot: quad.iter().fold([0.0; 2], |c, p| [c[0] + p[0] / 4.0, c[1] + p[1] / 4.0]), + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: TransformMode::Free, + content_aware: None, + skew_v: 0.0, + layers: Vec::new(), + } +} + +fn app(quad: [[f64; 2]; 4], zoom: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", serde_json::json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = zoom; + app.ui.transform = Some(session(quad)); + app +} + +fn view(zoom: f32) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0)), zoom, center: [300.0, 200.0], flip: false, rotation: 0.0, aspect: 1.0 } +} + +fn near(actual: Option<[f64; 2]>, expected: [f64; 2]) { + let actual = actual.expect("rotate direction"); + assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1e-6, "{actual:?} != {expected:?}"); +} + +/// Screen-sized zones must survive zoom changes; the interior control rejects an all-sided edge grab area. +#[test] +fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { + for zoom in [1.0, 2.0] { + let q = QUAD.map(|p| p.map(|v| v / zoom)); + let t = session(q); + // Coordinates are screen pixels, so the same table must hold at both document zooms. + for (p, expected) in [ + ([100.0, 100.0], Hit::Corner(0)), + ([105.0, 105.0], Hit::Corner(0)), + ([95.0, 95.0], Hit::Corner(0)), + ([500.0, 300.0], Hit::Corner(2)), + ([180.0, 100.0], Hit::Edge(0)), + ([180.0, 89.0], Hit::Edge(0)), + ([500.0, 150.0], Hit::Edge(1)), + ([511.0, 150.0], Hit::Edge(1)), + ([420.0, 311.0], Hit::Edge(2)), + ([89.0, 250.0], Hit::Edge(3)), + ([180.0, 101.0], Hit::Inside), + ([499.0, 150.0], Hit::Inside), + ([180.0, 180.0], Hit::Inside), + ([180.0, 88.0], Hit::Outside), + ([620.0, 200.0], Hit::Outside), + ] { + assert_eq!(hit(&t, p.map(|v| v / zoom), 12.0 / zoom), expected, "zoom {zoom}, screen {p:?}"); + } + let tiny = session([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]); + assert_eq!(TransformSession::box_hit(&tiny.quad, [1.0, -1.0], 12.0), Hit::Corner(0), "corners beat overlapping edge bands"); + } +} + +/// An overlapping corner zone must not steal a closer painted midpoint on a small box. +#[test] +fn small_boxes_keep_their_painted_mid_edge_handles() { + for side in [8.0, 16.0, 32.0] { + for zoom in [1.0, 2.0] { + let q = [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]].map(|p| p.map(|v| v / zoom)); + let t = session(q); + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + let midpoint = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + assert_eq!(hit(&t, midpoint, 12.0 / zoom), Hit::Edge(i), "side {side}, zoom {zoom}, edge {i}"); + assert_eq!(hit(&t, a, 12.0 / zoom), Hit::Corner(i)); + } + } + } +} + +/// Match the measured #11097 boundaries and ties on the top edge and one point outside it. +#[test] +fn measured_edge_corner_and_midpoint_zones_at_100_and_200_percent() { + use Hit::{Corner, Edge}; + let matrix: &[(f64, &[(f64, Hit)])] = &[ + (12.0, &[(3.0, Corner(0)), (4.0, Edge(0)), (5.0, Edge(0)), (6.0, Edge(0)), (9.0, Edge(0)), (10.0, Corner(1))]), + (24.0, &[(6.0, Corner(0)), (7.0, Edge(0)), (18.0, Edge(0)), (19.0, Corner(1))]), + (40.0, &[(10.0, Corner(0)), (11.0, Edge(0)), (30.0, Edge(0)), (31.0, Corner(1))]), + (100.0, &[(11.0, Corner(0)), (12.0, Edge(0))]), + ]; + for zoom in [1.0_f32, 2.0] { + let scale = |p: [f64; 2]| p.map(|v| v / f64::from(zoom)); + let xf = view(zoom); + for &(side, samples) in matrix { + let quad = [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]].map(scale); + let mut app = app(quad, zoom); + app.ui.tool_options.transform_reference_point = false; + let t = app.ui.transform.as_ref().unwrap(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(side as f32, side as f32)); + for y in [0.0, -1.0] { + for &(x, expected) in samples { + let p = scale([x, y]); + let icon = match expected { + Corner(0) => CursorIcon::ResizeNwSe, + Corner(1) => CursorIcon::ResizeNeSw, + _ => CursorIcon::ResizeVertical, + }; + assert_eq!(crate::transform_tool::box_hit(&app, t, p, 12.0 / f64::from(zoom)), expected, "{side}, {zoom}, {x}, {y}"); + assert_eq!(cursor(&app, &xf, p, Modifiers::NONE), Some(icon)); + assert_eq!(transform_controls_cursor(rect, pos2(x as f32, y as f32)), Some(icon)); + } + } + } + } +} + +/// Reproduce inward rounding through the view transform, with a one-pixel interior control. +#[test] +fn an_edge_survives_the_actual_f32_view_round_trip() { + for zoom in [1.0_f32, 2.0] { + let q = [[100.0, 100.00002], [500.0, 100.00002], [500.0, 300.0], [100.0, 300.0]].map(|p| p.map(|v| v / f64::from(zoom))); + let t = session(q); + let xf = view(zoom); + let p = xf.to_doc(xf.to_screen((180.0 / f64::from(zoom)) as f32, q[0][1] as f32)); + let inward_rounding = (p[1] - q[0][1]) * f64::from(zoom); + assert!(inward_rounding > 0.0 && inward_rounding < 1e-3, "fixture must expose inward f32 rounding: {inward_rounding}"); + assert_eq!(hit(&t, p, 12.0 / f64::from(zoom)), Hit::Edge(0)); + assert_eq!(hit(&t, [p[0], q[0][1] + 1.0 / f64::from(zoom)], 12.0 / f64::from(zoom)), Hit::Inside, "a whole pixel inside still moves"); + } +} + +/// The same small-edge press moves the Distort edge and stretches one axis in legacy unlinked scaling. +#[test] +fn a_small_edge_keeps_distort_and_legacy_aspect_behavior() { + for mode in [TransformMode::Distort, TransformMode::Free] { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.ui.extras.snap = false; + app.ui.transform_unlinked = true; + app.session.edit_prefs(|p| p.general.use_legacy_free_transform = true); + app.session.execute("shape.create", serde_json::json!({"kind":"rect","rect":[8,8,16,16],"fill":"#ff0000"})).unwrap(); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + app.ui.transform.as_mut().unwrap().mode = mode; + let before = app.ui.transform.as_ref().unwrap().quad; + for event in [ + crate::canvas::ToolEvent::Down { x: 24.0, y: 16.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Move { x: 40.0, y: 20.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Up { x: 40.0, y: 20.0 }, + ] { + crate::canvas::tool_event(&mut app, event, Modifiers::NONE); + } + let q = app.ui.transform.as_ref().unwrap().quad; + if mode == TransformMode::Distort { + let mut expected = before; + for corner in [1, 2] { + expected[corner][0] += 16.0; + expected[corner][1] += 4.0; + } + assert_eq!(q, expected, "Distort moves the pressed edge and holds the opposite edge"); + } else { + assert!((q[2][0] - q[0][0] - 32.0).abs() < 1e-6 && (q[2][1] - q[0][1] - 16.0).abs() < 1e-6, "a legacy edge stretches one axis: {q:?}"); + } + } +} + +/// At 45 degrees, corners and edges exchange cursor axis orientation at both tested zooms. +#[test] +fn turned_box_zones_and_resize_cursors_follow_the_quad() { + let turn = |p: [f64; 2]| { + let (x, y) = (p[0] - 300.0, p[1] - 200.0); + let k = std::f64::consts::FRAC_1_SQRT_2; + [300.0 + (x - y) * k, 200.0 + (x + y) * k] + }; + for zoom in [1.0_f32, 2.0] { + let scale = |p: [f64; 2]| turn(p).map(|v| v / f64::from(zoom)); + let q = QUAD.map(scale); + let app = app(q, zoom); + let xf = view(zoom); + let t = app.ui.transform.as_ref().unwrap(); + for (p, expected, icon) in [ + ([500.0, 100.0], Hit::Corner(1), CursorIcon::ResizeHorizontal), + ([111.0, 99.0], Hit::Corner(0), CursorIcon::ResizeVertical), + ([112.0, 99.0], Hit::Edge(0), CursorIcon::ResizeNeSw), + ([180.0, 95.0], Hit::Edge(0), CursorIcon::ResizeNeSw), + ([505.0, 150.0], Hit::Edge(1), CursorIcon::ResizeNwSe), + ([180.0, 101.0], Hit::Inside, CursorIcon::Default), + ([180.0, -20.0], Hit::Outside, CursorIcon::None), + ] { + assert_eq!(hit(t, scale(p), 12.0 / f64::from(zoom)), expected); + assert_eq!(cursor(&app, &xf, scale(p), Modifiers::NONE), Some(icon), "zoom {zoom}, {p:?}"); + } + assert_eq!(hit(t, scale([100.0, 100.0]), 12.0 / f64::from(zoom)), Hit::Corner(0)); + } +} + +/// Skewed edges and reversed winding must use actual segment geometry for hits and turn direction. +#[test] +fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { + let q = [[100.0, 100.0], [500.0, 140.0], [460.0, 300.0], [80.0, 260.0]]; + for q in [q, [q[1], q[0], q[3], q[2]]] { + let t = session(q); + assert_eq!(hit(&t, [220.0, 112.0], 12.0), Hit::Edge(0)); + assert_eq!(hit(&t, [220.0, 107.0], 12.0), Hit::Edge(0)); + assert_eq!(hit(&t, [220.0, 113.0], 12.0), Hit::Inside); + assert_eq!(hit(&t, [220.0, 90.0], 12.0), Hit::Outside); + near(TransformSession::box_turn_direction(&q, [220.0, 90.0]), [-0.1 / 1.01_f64.sqrt(), 1.0 / 1.01_f64.sqrt()]); + } +} + +/// A concave quad whose corner mean is beyond an edge rejects center-based normal orientation. +#[test] +fn concave_quad_edge_direction_uses_winding_in_both_orientations() { + let q = [[0.0, 0.0], [100.0, 0.0], [10.0, 10.0], [0.0, 100.0]]; + for q in [q, [q[1], q[0], q[3], q[2]]] { + near(TransformSession::box_turn_direction(&q, [56.0, 14.0]), [-1.0 / 82.0_f64.sqrt(), -9.0 / 82.0_f64.sqrt()]); + } +} + +/// Each outside position selects its inward turn orientation and hides the system cursor. +#[test] +fn outside_rotation_has_eight_edge_and_corner_orientations() { + let app = app(QUAD, 1.0); + let h = std::f64::consts::FRAC_1_SQRT_2; + for (p, direction) in [ + ([300.0, -20.0], [0.0, 1.0]), + ([620.0, -20.0], [-h, h]), + ([620.0, 200.0], [-1.0, 0.0]), + ([620.0, 420.0], [-h, -h]), + ([300.0, 420.0], [0.0, -1.0]), + ([-20.0, 420.0], [h, -h]), + ([-20.0, 200.0], [1.0, 0.0]), + ([-20.0, -20.0], [h, h]), + ] { + near(rotate_cursor_dir(&app, p), direction); + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::NONE), Some(CursorIcon::None)); + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::COMMAND), Some(CursorIcon::None)); + } +} + +/// A hidden reference point outside the quad must leave both hover and the press free to rotate. +#[test] +fn outside_reference_point_owns_the_pointer_only_when_shown() { + let p = [80.0, 40.0]; + let pivot = [76.0, 40.0]; + let to = [80.0, 48.0]; + for shown in [false, true] { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.ui.extras.snap = false; + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [4, 4, 16, 16], "fill": "#ff0000"})).unwrap(); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.pivot = pivot; + let quad = t.quad; + app.ui.tool_options.transform_reference_point = shown; + assert_eq!(rotate_cursor_dir(&app, p).is_some(), !shown); + let icon = if shown { CursorIcon::Crosshair } else { CursorIcon::None }; + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::NONE), Some(icon)); + + let down = crate::canvas::ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }; + crate::transform_tool::pointer(&mut app, down, Modifiers::NONE); + let drag = crate::canvas::ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }; + crate::transform_tool::pointer(&mut app, drag, Modifiers::NONE); + let t = app.ui.transform.as_ref().unwrap(); + if shown { + assert_eq!(t.quad, quad, "the reference-point drag leaves the box in place"); + assert_eq!(t.pivot, to); + } else { + assert_ne!(t.quad, quad, "the outside drag turns the box"); + assert_eq!(t.pivot, pivot); + } + } +} + +/// Compare unopened controls with an active session at identical points, including a free interior. +#[test] +fn unopened_move_controls_share_hover_but_keep_the_move_press_band() { + let app = app(QUAD, 1.0); + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 300.0)); + for (p, starts_transform) in [ + (pos2(100.0, 100.0), true), + (pos2(180.0, 95.0), true), + (pos2(180.0, 89.0), true), + (pos2(505.0, 150.0), true), + (pos2(518.0, 150.0), true), + (pos2(519.0, 150.0), false), + (pos2(300.0, 70.0), false), + (pos2(180.0, 101.0), false), + (pos2(620.0, 200.0), false), + (pos2(-20.0, -20.0), false), + ] { + let d = [f64::from(p.x), f64::from(p.y)]; + assert_eq!(transform_controls_cursor(r, p), cursor(&app, &view(1.0), d, Modifiers::NONE), "{p:?}"); + assert_eq!(transform_controls_hit(r, p), starts_transform, "{p:?}"); + match (transform_controls_turn(r, p), rotate_cursor_dir(&app, d)) { + (None, None) => {} + (Some(a), Some(b)) => near(Some([f64::from(a.x) / 16.0, f64::from(a.y) / 16.0]), b), + pair => panic!("unopened/open rotation disagreement: {pair:?}"), + } + } + assert_eq!(transform_controls_cursor(r, r.center()), Some(CursorIcon::Default)); + assert!(!transform_controls_hit(r, r.center()), "inside remains an ordinary Move drag"); +} + +fn move_controls_fixture() -> (Harness<'static, PhotocraftApp>, [LayerId; 2]) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "transparent"})).unwrap(); + let layers = [[60, 60, 80, 60], [230, 150, 40, 40]].map(|rect| { + app.run("shape.create", json!({"kind": "rect", "rect": rect, "fill": "#ff0000"})).unwrap(); + app.session.active().unwrap().active_layer.unwrap() + }); + app.run("layer.select", json!({"layer": layers[0].0, "mode": "replace"})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + app.ui.tool_options.move_auto_select = true; + app.ui.tool_options.move_target = "layer".into(); + app.ui.extras.snap = false; + let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + (h, layers) +} + +fn move_pointer(h: &mut Harness<'static, PhotocraftApp>, control: bool, kind: &str, [x, y]: [f32; 2]) { + if control { + use crate::control::{ControlRequest, Outcome}; + let (request, _) = ControlRequest::new("ui.pointer", json!({"events": [{"kind": kind, "x": x, "y": y}]})); + let ctx = h.ctx.clone(); + let Outcome::Done(reply) = crate::control::handle(h.state_mut(), &ctx, &request) else { panic!("expected pointer reply") }; + assert_eq!(reply["ok"], true, "{reply}"); + } else { + let xf = ViewXform::active(h.state()).unwrap(); + let pos = xf.to_screen(x, y); + assert!(xf.rect.contains(pos)); + h.hover_at(pos); + if kind != "move" { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: kind == "down", modifiers: Modifiers::NONE }); + } + h.run_steps(2); + } +} + +#[test] +fn move_controls_allow_auto_select_on_another_layer_outside_the_press_band() { + for control in [false, true] { + let (mut h, layers) = move_controls_fixture(); + let at = [250.0, 170.0]; + let xf = ViewXform::active(h.state()).unwrap(); + let frame = transform_controls_rect(h.state(), &xf).expect("the first layer has visible transform controls"); + assert!(!frame.expand(18.0).contains(xf.to_screen(at[0], at[1]))); + assert_eq!(h.state().session.active().unwrap().selected_layers(), vec![layers[0]]); + let before = h.state().session.active().unwrap().doc.clone(); + move_pointer(&mut h, control, "down", at); + move_pointer(&mut h, control, "up", at); + let state = h.state().session.active().unwrap(); + assert!(h.state().ui.transform.is_none(), "control={control}: Auto-Select does not start Free Transform"); + assert_eq!(state.selected_layers(), vec![layers[1]], "control={control}"); + assert_eq!(state.doc, before); + } +} + +#[test] +fn move_controls_allow_a_layer_marquee_from_empty_canvas_outside_the_press_band() { + for control in [false, true] { + let (mut h, layers) = move_controls_fixture(); + let start = [190.0, 125.0]; + let end = [285.0, 210.0]; + let xf = ViewXform::active(h.state()).unwrap(); + let frame = transform_controls_rect(h.state(), &xf).expect("the first layer has visible transform controls"); + assert!(!frame.expand(18.0).contains(xf.to_screen(start[0], start[1]))); + assert_eq!(h.state().session.active().unwrap().selected_layers(), vec![layers[0]]); + let before = h.state().session.active().unwrap().doc.clone(); + move_pointer(&mut h, control, "down", start); + move_pointer(&mut h, control, "move", end); + assert!(h.state().layer_box.is_some(), "control={control}: the empty-canvas drag starts a layer marquee"); + move_pointer(&mut h, control, "up", end); + let state = h.state().session.active().unwrap(); + assert!(h.state().ui.transform.is_none(), "control={control}: the marquee does not start Free Transform"); + assert!(h.state().layer_box.is_none(), "the marquee ends on release"); + assert_eq!(state.selected_layers(), vec![layers[1]], "control={control}"); + assert_eq!(state.doc, before); + } +} + +/// A press away from midpoint handles must scale an anchored preview without creating a history edit. +#[test] +fn move_whole_edge_press_enters_scaling_at_both_zooms() { + for zoom in [1.0, 2.0] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", serde_json::json!({"width": 640, "height": 480})).unwrap(); + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [100, 100, 400, 200], "fill": "#ff0000"})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = zoom; + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + app.ui.extras.snap = false; + let xf = view(zoom); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + // Away from the top edge's midpoint and corners: the native canvas first opens the + // controls here, then forwards the same document-space press to the transform tool. + assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, xf.to_screen(180.0, 100.0)), ControlsPress::Started); + for event in [ + crate::canvas::ToolEvent::Down { x: 180.0, y: 100.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Move { x: 180.0, y: 90.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Up { x: 180.0, y: 90.0 }, + ] { + crate::canvas::tool_event(&mut app, event, Modifiers::SHIFT); + } + let t = app.ui.transform.as_ref().expect("a handle release retains the transform session"); + assert!(t.quad[0][1] < 100.0, "the pressed edge scales"); + assert!((t.quad[2][1] - 300.0).abs() < 1e-6, "the opposite edge anchors scaling instead of moving the whole box"); + assert_eq!(app.session.active().unwrap().doc, before, "a preview does not commit pixels"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + } +} + +/// Excluded modes retain cursor ownership, and non-finite coordinates cannot activate controls. +#[test] +fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { + let mut app = app(QUAD, 1.0); + for mode in [TransformMode::Distort, TransformMode::Skew] { + app.ui.transform.as_mut().unwrap().mode = mode; + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Default)); + } + app.ui.transform.as_mut().unwrap().mode = TransformMode::Free; + app.ui.transform.as_mut().unwrap().content_aware = Some(ContentAwareOptions::default()); + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Default)); + app.ui.transform.as_mut().unwrap().content_aware = None; + let t = app.ui.transform.as_mut().unwrap(); + t.warp = Some(photocraft_geom::warp::Warp::none(t.rect)); + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Crosshair), "Warp retains its own cursor"); + app.ui.transform.as_mut().unwrap().warp = None; + assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 200.0]), None); + assert_eq!(TransformSession::box_turn_direction(&[[0.0, 0.0]; 4], [20.0, 20.0]), None); + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 300.0)); + for p in [pos2(f32::NAN, 200.0), pos2(f32::INFINITY, 200.0)] { + assert_eq!(transform_controls_cursor(r, p), None, "invalid points cannot hide the pointer"); + assert_eq!(transform_controls_turn(r, p), None); + assert!(!transform_controls_hit(r, p), "invalid points cannot activate the overlay"); + } +} + +/// Captured rotation must retain feedback after crossing into the box, independent of hover zones. +#[test] +fn rotate_drag_keeps_the_arrow_when_the_pointer_enters_the_box() { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [4, 4, 16, 16], "fill": "#ff0000"})).unwrap(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + crate::transform_tool::pointer(&mut app, crate::canvas::ToolEvent::Down { x: 80.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + crate::transform_tool::pointer(&mut app, crate::canvas::ToolEvent::Move { x: 10.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE); + assert!(rotate_cursor_dir(&app, [10.0, 10.0]).is_some(), "the held rotation gesture owns the arrow even inside"); + assert_eq!(cursor(&app, &view(1.0), [10.0, 10.0], Modifiers::NONE), Some(CursorIcon::None)); +} diff --git a/crates/ui-egui/src/transform_relative_position_tests.rs b/crates/ui-egui/src/transform_relative_position_tests.rs new file mode 100644 index 0000000000..bd6ed4d21f --- /dev/null +++ b/crates/ui-egui/src/transform_relative_position_tests.rs @@ -0,0 +1,175 @@ +//! Relative positioning in the transform options bar (#11679): a toggle between X and Y, off by +//! default; on, X and Y show the reference point's offset from where the box started and a typed +//! value moves the box by that much; off, they show its position again. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; + +const TOGGLE: &str = "Use relative positioning for reference point"; + +/// A 200 x 120 document with a layer filled at 40,20 (60 x 40) and Free Transform open on it: +/// the reference point starts at the box's centre, 70,40. +fn transforming() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.sync_views(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 20, "width": 60, "height": 40})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + app +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 200.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +/// What the X and Y fields show. +fn shown(h: &Harness<'_, PhotocraftApp>) -> (f64, f64) { + let read = |name: &str| h.get_by_role_and_label(Role::SpinButton, name).accesskit_node().numeric_value().unwrap(); + (read("X"), read("Y")) +} + +/// Types `text` into the X field and commits it with Tab, as a user does. +fn type_x(h: &mut Harness<'_, PhotocraftApp>, text: &str) { + h.get_by_role_and_label(Role::SpinButton, "X").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + for ch in text.chars() { + h.event(egui::Event::Text(ch.to_string())); + h.run_steps(1); + } + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +fn click_toggle(h: &mut Harness<'_, PhotocraftApp>) { + h.get_by_label(TOGGLE).click(); + h.run_steps(3); +} + +fn quad(h: &Harness<'_, PhotocraftApp>) -> [[f64; 2]; 4] { + h.state().ui.transform.as_ref().unwrap().quad +} + +fn moved(q: [[f64; 2]; 4], dx: f64) -> [[f64; 2]; 4] { + q.map(|[x, y]| [x + dx, y]) +} + +fn same(a: [[f64; 2]; 4], b: [[f64; 2]; 4]) -> bool { + a.iter().flatten().zip(b.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-6) +} + +#[test] +fn relative_positioning_is_off_by_default() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + assert!(!app.ui.tool_options.transform_relative_position); + // An older preferences file without the field reads it as off. + let o: crate::state::ToolOptions = serde_json::from_value(json!({})).unwrap(); + assert!(!o.transform_relative_position); +} + +#[test] +fn the_toggle_sits_between_x_and_y_and_turns_a_fresh_box_into_zero() { + let mut h = harness(transforming()); + let x = h.get_by_role_and_label(Role::SpinButton, "X").rect(); + let y = h.get_by_role_and_label(Role::SpinButton, "Y").rect(); + let toggle = h.get_by_label(TOGGLE).rect(); + assert!(x.max.x <= toggle.min.x && toggle.max.x <= y.min.x, "X {x:?}, toggle {toggle:?}, Y {y:?}"); + assert_eq!(shown(&h), (70.0, 40.0), "off: the reference point's position"); + click_toggle(&mut h); + assert!(h.state().ui.tool_options.transform_relative_position); + assert_eq!(shown(&h), (0.0, 0.0), "on: no offset yet"); +} + +#[test] +fn a_typed_x_moves_the_box_by_that_much_and_commits_as_free_transform() { + let mut h = harness(transforming()); + click_toggle(&mut h); + let before = quad(&h); + type_x(&mut h, "20"); + assert!(same(quad(&h), moved(before, 20.0)), "the box moved 20 px right: {:?}", quad(&h)); + assert_eq!(shown(&h), (20.0, 0.0)); + // Typing the same offset again doesn't move it further. + type_x(&mut h, "20"); + assert!(same(quad(&h), moved(before, 20.0)), "still 20 px: {:?}", quad(&h)); + let app = h.state_mut(); + crate::transform_tool::commit(app); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds().x0, 60, "the layer moved 20 px"); + assert_eq!(st.history.entries().last().map(ToString::to_string).as_deref(), Some("Free Transform")); +} + +#[test] +fn a_drag_updates_the_offset_and_turning_it_off_shows_the_position_again() { + let mut h = harness(transforming()); + click_toggle(&mut h); + { + let app = h.state_mut(); + let none = egui::Modifiers::NONE; + crate::transform_tool::pointer(app, ToolEvent::Down { x: 50.0, y: 30.0, pressure: 1.0 }, none); + crate::transform_tool::pointer(app, ToolEvent::Move { x: 55.0, y: 33.0, pressure: 1.0 }, none); + crate::transform_tool::pointer(app, ToolEvent::Up { x: 55.0, y: 33.0 }, none); + } + h.run_steps(3); + assert_eq!(shown(&h), (5.0, 3.0), "the offset follows the drag"); + click_toggle(&mut h); + assert!(!h.state().ui.tool_options.transform_relative_position); + assert_eq!(shown(&h), (75.0, 43.0), "off again: the reference point's position"); +} + +/// Typing X leaves Y exactly where it was, even when Y is a fraction the field's f32 can't hold +/// (CodeRabbit review on options_bar.rs:308): the field's rounding must not nudge the box. +#[test] +fn a_typed_x_leaves_a_fractional_y_exactly_where_it_was() { + let mut app = transforming(); + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x, y + 0.1]); + t.pivot[1] += 0.1; + let mut h = harness(app); + let before = quad(&h); + let pivot_y = h.state().ui.transform.as_ref().unwrap().pivot[1]; + type_x(&mut h, "90"); + let after = quad(&h); + assert!(same(after, moved(before, 20.0)), "the box moved 20 px right: {after:?}"); + assert_eq!(after.map(|p| p[1]), before.map(|p| p[1]), "Y is untouched"); + assert_eq!(h.state().ui.transform.as_ref().unwrap().pivot[1], pivot_y); +} + +/// A box squashed flat has no inverse map, so the offset comes from the map's pseudo-inverse: a +/// box squashed to zero width about its reference point reads 0 / 0, and one squashed onto its +/// left edge, carrying the reference point with it, reads -30 / 0 (CodeRabbit pre-merge check). +#[test] +fn a_flattened_box_reads_the_reference_points_own_offset() { + let mut app = transforming(); + app.ui.tool_options.transform_relative_position = true; + let t = app.ui.transform.as_mut().unwrap(); + t.quad = [[70.0, 20.0], [70.0, 20.0], [70.0, 60.0], [70.0, 60.0]]; + t.pivot = [70.0, 40.0]; + let mut h = harness(app); + assert_eq!(shown(&h), (0.0, 0.0), "squashed about the reference point"); + let t = h.state_mut().ui.transform.as_mut().unwrap(); + t.quad = [[40.0, 20.0], [40.0, 20.0], [40.0, 60.0], [40.0, 60.0]]; + t.pivot = [40.0, 40.0]; + h.run_steps(2); + assert_eq!(shown(&h), (-30.0, 0.0), "squashed onto the left edge"); +} diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index eeb91f44b9..e179e4f6d5 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -56,6 +56,8 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { guide: false, spelling: None, crop: None, + sampler: None, + sampler_at: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); diff --git a/crates/ui-egui/tests/it/actions_duplicate.rs b/crates/ui-egui/tests/it/actions_duplicate.rs new file mode 100644 index 0000000000..a9f9aeb206 --- /dev/null +++ b/crates/ui-egui/tests/it/actions_duplicate.rs @@ -0,0 +1,53 @@ +//! The panel duplicates the selected action/step and keeps its new row selected. +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_ui_egui::{PhotocraftApp, actions, dialogs}; +use serde_json::json; + +#[test] +fn duplicate_menu_selects_the_new_action_and_then_the_new_step() { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + let mut a = Action::new("Source", vec![("select.all".into(), json!({}))]); + a.disabled.insert(0); + a.dialogs.insert(0); + app.session.actions.list.push(a); + app.ui.actions.selected = Some(0); + app.ui.actions.expanded = vec![true]; + let mut h = Harness::builder().with_size(egui::vec2(360.0, 460.0)).build_ui_state(|ui, app| actions::panel_menu(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list.len(), 2); + assert_eq!(h.state().session.actions.list[1].name, "Source copy"); + assert_eq!(h.state().ui.actions.selected, Some(1)); + assert_eq!(h.state().ui.actions.selected_step, None); + assert_eq!(h.state().ui.actions.expanded, [true, true]); + h.state_mut().ui.actions.selected_step = Some(0); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected, Some(1)); + assert_eq!(h.state().ui.actions.selected_step, Some(1)); + let copied = &h.state().session.actions.list[1]; + assert_eq!(copied.steps.len(), 2); + assert_eq!(copied.disabled.iter().copied().collect::>(), [0, 1]); + assert_eq!(copied.dialogs.iter().copied().collect::>(), [0, 1]); +} + +#[test] +fn duplicate_cannot_shift_the_owner_of_a_suspended_recording() { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + app.session.actions.list.push(Action::new("Menu", vec![(atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"}))])); + app.run("actions.record", json!({"name": "Owner"})).unwrap(); + let result = app.run("actions.play", json!({"action": "Menu"})).unwrap(); + let dialog = result["paused"]["dialog"].as_u64().unwrap(); + let list = app.session.actions.list.clone(); + assert!(app.session.actions.recording.is_none()); + let error = app.run("actions.duplicate", json!({"action": "Menu"})).unwrap_err(); + assert!(error.contains("Confirm or cancel"), "{error}"); + assert_eq!(app.session.actions.list, list); + dialogs::cancel(&mut app, dialog).unwrap(); + assert_eq!(app.session.actions.recording.map(|(_, i)| i), Some(1)); + app.run("actions.stop", json!({})).unwrap(); +} diff --git a/crates/ui-egui/tests/it/actions_panel_menu.rs b/crates/ui-egui/tests/it/actions_panel_menu.rs new file mode 100644 index 0000000000..5383558bbc --- /dev/null +++ b/crates/ui-egui/tests/it/actions_panel_menu.rs @@ -0,0 +1,242 @@ +//! Real panel controls compose the remaining commands with consent and retained selection. +use std::sync::atomic::{AtomicUsize, Ordering}; + +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action, actions_edit_cmds, atn_cmds, prefs::PlaybackPerformance}; +use photocraft_ui_egui::{ + PhotocraftApp, actions, canvas, + control::{ControlRequest, Outcome, handle}, + dialogs, +}; +use serde_json::{Value, json}; + +/// A real panel and its dialogs with one selected action in a set. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":12})).unwrap(); + let mut a = Action::new("A", vec![]); + a.set = Some("S".into()); + app.session.actions.list.push(a); + app.ui.actions.selected = Some(0); + app.ui.actions.expanded = vec![true]; + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h +} + +/// Click the actual menu entry and return its modal identity. +fn open(h: &mut Harness<'static, PhotocraftApp>, label: &str) -> u64 { + h.get_by_label(label).click(); + h.run_steps(3); + h.state().ui.dialogs.last().unwrap().id +} + +/// The checkbox changes recording policy rather than disabling actual painting. +#[test] +fn allow_tool_recording_checkbox_records_only_enabled_strokes() { + let mut h = harness(); + h.state_mut().run("actions.record", json!({"action":"A"})).unwrap(); + for enabled in [true, false] { + h.get_by_label("Allow Tool Recording").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.record_tools, enabled); + h.state_mut().run("paint.stroke", json!({"points":[[2,3],[10,3]],"size":2,"color":if enabled {"#ff0000"} else {"#00ff00"}})).unwrap(); + } + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(h.state().session.actions.list[0].steps.len(), 1); + assert_eq!(h.state().session.actions.list[0].steps[0].1["color"], "#ff0000"); + assert_eq!(h.state().session.journal.iter().filter(|s| s.0 == "paint.stroke").count(), 2); +} + +/// Folder selection dispatches set duplication and keeps the new folder selected, empty ones included. +#[test] +fn duplicate_selected_set_keeps_folder_and_disclosure_ownership() { + let mut h = harness(); + h.state_mut().ui.actions.selected = None; + h.state_mut().ui.actions.selected_set = Some("S".into()); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected, None); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("S copy")); + assert_eq!(actions_edit_cmds::set_names(&h.state().session), ["S", "S copy"]); + assert_eq!(h.state().ui.actions.expanded, [true, false]); + h.state_mut().run("actions.newSet", json!({"name":"Empty"})).unwrap(); + h.state_mut().ui.actions.selected_set = Some("Empty".into()); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Empty copy")); +} + +/// Menu insertion uses an actual chooser and never samples or executes current command settings. +#[test] +fn insert_menu_item_chooser_cancels_or_records_descriptor_free_filter_event() { + let mut h = harness(); + let revision = h.state().session.active().unwrap().revision; + open(&mut h, "Insert Menu Item…"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().session.actions.list[0].steps.is_empty()); + let dialog = open(&mut h, "Insert Menu Item…"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("id".into(), json!("filter.blur.gaussianBlur")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list[0].steps[0], (atn_cmds::MENU_ITEM.into(), json!({"id":"filter.blur.gaussianBlur","eventKind":"name"}))); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert_eq!(h.state().ui.actions.selected_step, Some(0)); + let played = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = played["paused"]["dialog"].as_u64().unwrap(); + assert_eq!(h.state_mut().ui.dialog_mut(dialog).unwrap().fields["__command"], "filter.blur.gaussianBlur"); + dialogs::cancel(h.state_mut(), dialog).unwrap(); + h.state_mut().run("paint.stroke", json!({"points":[[6,6]],"size":3,"hardness":1,"color":"#000000"})).unwrap(); + let original = photocraft_compose::flatten(&h.state().session.active().unwrap().doc).into_px(); + let mut expected = Session::new(); + expected.add_document((*h.state().session.active().unwrap().doc).clone(), None); + expected.execute("filter.blur.gaussianBlur", json!({"radius":2})).unwrap(); + let expected = photocraft_compose::flatten(&expected.active().unwrap().doc).into_px(); + assert_ne!(expected, original, "the Gaussian fixture has a visible effect"); + h.state_mut().session.actions.list[0].steps.push(("select.all".into(), json!({}))); + let paused = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(2)); + dialogs::confirm(h.state_mut(), dialog).unwrap(); + assert_eq!(photocraft_compose::flatten(&h.state().session.active().unwrap().doc).into_px(), expected); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "confirmation resumes the following step"); +} + +/// The control protocol distinguishes direct playback from a menu click, even with a step gate. +#[test] +fn control_playback_opens_menu_settings_only_for_menu_invocation() { + let mut h = harness(); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + app.run("actions.insertMenuItem", json!({"action":"A","id":"filter.blur.gaussianBlur"})).unwrap(); + let steps = &mut app.session.actions.list[0].steps; + steps.insert(0, ("select.all".into(), json!({}))); + steps.push(("select.deselect".into(), json!({}))); + app.session.edit_prefs(|prefs| prefs.actions.performance = PlaybackPerformance::StepByStep); + + // Starting at the menu step checks the original dialog bug. Starting one step earlier + // also verifies Step by Step cannot defer the menu step past the control request. + for (from, ran) in [(1, 0), (0, 1)] { + let (req, _reply) = ControlRequest::new("engine.execute", json!({"command":"actions.play","params":{"action":"A","from":from}})); + let Outcome::Done(reply) = handle(app, &ctx, &req) else { panic!("direct playback must return immediately") }; + assert_eq!(reply["ok"], true, "{reply}"); + let played = &reply["result"]; + assert_eq!(played["ran"], ran, "{reply}"); + assert_eq!(played["failed"]["step"], 1, "{reply}"); + assert_eq!(played["failed"]["id"], atn_cmds::MENU_ITEM, "{reply}"); + assert!(played["failed"]["error"].as_str().unwrap().contains("interactive-required"), "{reply}"); + assert!(played.get("paused").is_none(), "{reply}"); + assert!(app.ui.dialogs.is_empty()); + } + + app.services.automation_authorize = Some(|_, _| Ok(())); + let (req, _reply) = ControlRequest::new("ui.menu.invoke", json!({"id":"actions.play","params":{"action":"A","from":1}})); + let Outcome::Done(reply) = handle(app, &ctx, &req) else { panic!("menu invocation must return its paused dialog") }; + assert_eq!(reply["ok"], true, "{reply}"); + let dialog = reply["result"]["paused"]["dialog"].as_u64().expect("menu playback opens Gaussian Blur settings"); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["__command"], "filter.blur.gaussianBlur"); + dialogs::cancel(app, dialog).unwrap(); + assert!(app.ui.dialogs.is_empty()); + assert!(app.session.active().unwrap().doc.selection.is_some(), "cancel must not run the following step"); +} + +static MENU_PREVIEW_CHECKS: AtomicUsize = AtomicUsize::new(0); +static MENU_PREVIEW_DENIALS: AtomicUsize = AtomicUsize::new(0); + +/// Count actual menu preview checks and deny an edited map path. +fn deny_menu_map_path(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "filter.blur.gaussianBlur" { + MENU_PREVIEW_CHECKS.fetch_add(1, Ordering::SeqCst); + if params.get("mapPath").and_then(Value::as_str).is_some_and(|path| !path.is_empty()) { + MENU_PREVIEW_DENIALS.fetch_add(1, Ordering::SeqCst); + return Err(EngineError::Other("map path denied".into())); + } + } + Ok(()) +} + +/// A descriptor-free step retains its playback gate and the identity of its opened editor. +#[test] +fn paused_menu_preview_keeps_the_requesting_gate_and_rejects_retargeting() { + let mut h = harness(); + h.state_mut().run("actions.insertMenuItem", json!({"action":"A","id":"filter.blur.gaussianBlur"})).unwrap(); + h.state_mut().session.authorize = Some(deny_menu_map_path); + let paused = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + h.state_mut().session.authorize = None; + + let context = h.ctx.clone(); + let checks = MENU_PREVIEW_CHECKS.load(Ordering::SeqCst); + assert!(canvas::ensure_texture(h.state_mut(), &context, 0, None).is_some()); + assert!(MENU_PREVIEW_CHECKS.load(Ordering::SeqCst) > checks, "the retained gate authorizes the real preview"); + + let revision = h.state().session.active().unwrap().revision; + let steps = h.state().session.actions.list[0].steps.clone(); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("mapPath".into(), json!("/outside/map.psd")); + let denials = MENU_PREVIEW_DENIALS.load(Ordering::SeqCst); + canvas::ensure_texture(h.state_mut(), &context, 0, None); + assert!(MENU_PREVIEW_DENIALS.load(Ordering::SeqCst) > denials, "the retained gate denies the edited path"); + + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__command".into(), json!("filter.distort.displace")); + let error = dialogs::confirm(h.state_mut(), dialog).unwrap_err(); + assert!(error.contains("the menu item command or its document changed"), "{error}"); + assert!(h.state().ui.dialogs.is_empty()); + assert!(!h.state().file_dialog_open()); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert_eq!(h.state().session.actions.list[0].steps, steps); +} + +/// Deny the default while allowing insertion of another mapped menu item. +fn deny_image_size_choice(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" || id == "actions.insertMenuItem" && params["id"] == "image.imageSize" { + return Err(EngineError::Other("choice denied".into())); + } + Ok(()) +} + +/// The chooser retains its caller and checks only the choice the caller confirms. +#[test] +fn menu_chooser_authorizes_the_selected_command_without_default_probe() { + let mut h = harness(); + h.state_mut().session.authorize = Some(deny_image_size_choice); + let dialog = open(&mut h, "Insert Menu Item…"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("id".into(), json!("filter.blur.gaussianBlur")); + h.state_mut().session.authorize = None; + dialogs::confirm(h.state_mut(), dialog).unwrap(); + let steps = h.state().session.actions.list[0].steps.clone(); + assert_eq!(steps, [(atn_cmds::MENU_ITEM.into(), json!({"id":"filter.blur.gaussianBlur","eventKind":"name"}))]); + + h.state_mut().session.authorize = Some(deny_image_size_choice); + let dialog = open(&mut h, "Insert Menu Item…"); + h.state_mut().session.authorize = None; + let error = dialogs::confirm(h.state_mut(), dialog).unwrap_err(); + assert!(error.contains("choice denied"), "{error}"); + assert_eq!(h.state().session.actions.list[0].steps, steps); +} + +/// The selected path is snapshotted without applying it to the document. +#[test] +fn insert_path_uses_actual_selected_saved_path() { + let mut h = harness(); + h.get_by_label("Insert Path"); + assert!(h.state_mut().run("actions.insertPath", json!({"action":"A"})).is_err()); + h.state_mut().run("path.set", json!({"name":"Curve","path":{"subpaths":[{"closed":false,"knots":[{"anchor":[1,2],"out":[4,5]},[9,8]]}]}})).unwrap(); + h.state_mut().ui.selected_path = Some("Curve".into()); + h.run_steps(3); + let revision = h.state().session.active().unwrap().revision; + h.get_by_label("Insert Path").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list[0].steps[0].0, "path.set"); + assert_eq!(h.state().session.actions.list[0].steps[0].1["path"]["subpaths"][0]["knots"][0]["out"], json!([4.0, 5.0])); + assert_eq!(h.state().session.active().unwrap().revision, revision); +} diff --git a/crates/ui-egui/tests/it/actions_record_again.rs b/crates/ui-egui/tests/it/actions_record_again.rs new file mode 100644 index 0000000000..dc91c67600 --- /dev/null +++ b/crates/ui-egui/tests/it/actions_record_again.rs @@ -0,0 +1,197 @@ +//! Record Again uses the real editor while retaining the selected step and permissions. +use std::sync::atomic::{AtomicUsize, Ordering}; + +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action}; +use photocraft_ui_egui::{PhotocraftApp, actions, canvas, dialogs, state::DialogKind}; +use serde_json::{Value, json}; + +/// Draw the actual panel and dialogs over one selected command, without a GPU or file service. +fn harness(command: &str, params: Value) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":8,"height":6})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let mut action = Action::new("A", vec![(command.into(), params), ("select.all".into(), json!({}))]); + action.disabled.insert(0); + action.dialogs.insert(0); + app.session.actions.list.push(action); + app.ui.actions.selected = Some(0); + app.ui.actions.selected_step = Some(0); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 760.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h +} + +/// Open by clicking the panel entry, preserving its selected-step and enablement checks. +fn open(h: &mut Harness<'static, PhotocraftApp>) -> u64 { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Record Again").click(); + h.run_steps(3); + h.state().ui.dialogs.last().unwrap().id +} + +/// A resize with different recorded and document sizes exposes seeding from the wrong source. +fn sizing() -> Harness<'static, PhotocraftApp> { + harness("image.imageSize", json!({"width":4,"height":3,"resample":"nearest"})) +} + +/// Cancel preserves the step and document; OK runs only that step and stores its new settings. +#[test] +fn seeded_editor_cancels_or_updates_only_the_original_step() { + let mut h = sizing(); + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + let dialog = open(&mut h); + let fields = &h.state().ui.dialogs.last().unwrap().fields; + assert_eq!(fields["__sizing"], true); + assert_eq!(fields["width"].as_f64(), Some(4.0)); + assert_eq!(fields["height"].as_f64(), Some(3.0)); + assert_eq!(fields["__recordAgain"], true); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list, original); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert!(h.state().ui.dialogs.iter().all(|d| d.id != dialog)); + let dialog = open(&mut h); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("width".into(), json!(6)); + fields.insert("height".into(), json!(5)); + fields.insert("__constrain".into(), json!(false)); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + let action = &h.state().session.actions.list[0]; + assert_eq!(action.steps.len(), 2); + assert_eq!(action.steps[0].1["width"].as_f64(), Some(6.0)); + assert!(action.disabled.contains(&0)); + assert!(action.dialogs.contains(&0)); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_engine::doc::Size::new(6, 5)); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); +} + +/// Permit initial settings while denying a later edit even if the current gate is removed. +fn limited_resize(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" && params.get("width").and_then(Value::as_f64).is_some_and(|w| w >= 10.0) { + Err(EngineError::Other("resize denied".into())) + } else { + Ok(()) + } +} + +/// Changed definitions/documents, retained denials, invalid settings and retargeting cannot overwrite. +#[test] +fn stale_denied_or_failed_edit_preserves_the_stored_step() { + for mode in ["action-stale", "document-stale", "denied", "retargeted", "failed"] { + let mut h = sizing(); + if mode == "denied" { + h.state_mut().session.authorize = Some(limited_resize); + } + let dialog = open(&mut h); + match mode { + "action-stale" => { + h.state_mut().run("actions.rename", json!({"action":"A","name":"Changed"})).unwrap(); + } + "document-stale" => { + h.state_mut().run("file.new", json!({"width":3,"height":3})).unwrap(); + } + "denied" => { + h.state_mut().session.authorize = None; + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("width".into(), json!(10)); + } + "retargeted" => { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__command".into(), json!("select.all")); + } + _ => { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("percent".into(), json!(-1)); + } + } + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + assert!(dialogs::confirm(h.state_mut(), dialog).is_err(), "{mode}"); + assert_eq!(h.state().session.actions.list, original, "{mode}"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "{mode}"); + h.run_steps(3); + let retry = open(&mut h); + dialogs::cancel(h.state_mut(), retry).unwrap(); + } +} + +/// Confirmation and Cancel of an impostor dialog leave the original editor's private state intact. +#[test] +fn another_dialog_cannot_consume_the_selected_step() { + let mut h = sizing(); + let real = open(&mut h); + let fields = h.state().ui.dialogs.last().unwrap().fields.clone(); + let fake = h.state_mut().ui.open_dialog(DialogKind::Command, fields.clone()); + assert!(dialogs::confirm(h.state_mut(), fake).is_err()); + let fake = h.state_mut().ui.open_dialog(DialogKind::Command, fields); + dialogs::cancel(h.state_mut(), fake).unwrap(); + dialogs::confirm(h.state_mut(), real).unwrap(); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_engine::doc::Size::new(4, 3)); +} + +static PREVIEW_CHECKS: AtomicUsize = AtomicUsize::new(0); + +/// Count actual preview authorization and refuse LUT paths or edited blur and gamma settings. +fn preview_gate(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if matches!(id, "filter.blur.gaussianBlur" | "image.adjustments.levels" | "image.adjustments.colorLookup") { + PREVIEW_CHECKS.fetch_add(1, Ordering::SeqCst); + if params.get("file").is_some() || ["radius", "gamma"].iter().any(|key| params.get(key).and_then(Value::as_f64).is_some_and(|v| v > 2.0)) { + return Err(EngineError::Other("preview denied".into())); + } + } + Ok(()) +} + +/// Both real preview routes check the retained caller before evaluating changed settings or LUT files. +#[test] +fn filter_and_adjustment_previews_retain_permissions_before_evaluation() { + for command in ["filter.blur.gaussianBlur", "image.adjustments.levels", "image.adjustments.colorLookup"] { + let params = if command.starts_with("filter.") { + json!({"radius":1}) + } else { + photocraft_engine::adjust_params::to_params( + &photocraft_engine::adjust_params::default_for(command.strip_prefix("image.adjustments.").unwrap(), photocraft_engine::doc::ColorMode::Rgb) + .unwrap(), + ) + }; + let mut h = harness(command, params); + h.state_mut().session.authorize = Some(preview_gate); + let dialog = open(&mut h); + h.state_mut().session.authorize = None; + let context = h.ctx.clone(); + PREVIEW_CHECKS.store(0, Ordering::SeqCst); + assert!(canvas::ensure_texture(h.state_mut(), &context, 0, None).is_some()); + assert!(PREVIEW_CHECKS.load(Ordering::SeqCst) > 0, "the permitted real preview request is authorized: {command}"); + if command == "image.adjustments.levels" { + assert!(dialogs::adjust_preview::display_doc(h.state_mut(), 0).is_some(), "the permitted adjustment preview is evaluated"); + } + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + if command.starts_with("filter.") { + fields.insert("radius".into(), json!(8)); + } else if command == "image.adjustments.levels" { + fields.insert("gamma".into(), json!(8)); + // This route parses settings even with Preview off; authorization must precede it. + fields.insert("__preview".into(), json!(false)); + } else { + fields.insert("file".into(), json!("/outside/forbidden.cube")); + } + PREVIEW_CHECKS.store(0, Ordering::SeqCst); + canvas::ensure_texture(h.state_mut(), &context, 0, None); + assert!(PREVIEW_CHECKS.load(Ordering::SeqCst) > 0, "the changed actual request was refused before evaluation: {command}"); + assert_eq!(h.state().session.actions.list, original); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err()); + assert_eq!(h.state().session.actions.list, original); + } +} diff --git a/crates/ui-egui/tests/it/brush_reset_defaults.rs b/crates/ui-egui/tests/it/brush_reset_defaults.rs new file mode 100644 index 0000000000..0c596a2102 --- /dev/null +++ b/crates/ui-egui/tests/it/brush_reset_defaults.rs @@ -0,0 +1,131 @@ +//! #11810: Reset Tool gives the Brush tool the reference application's tip, a 13 px soft round +//! (hardness 0 %, spacing 25 %), not the 20 px hard default. The other painting tools and +//! `paint.stroke` without a `brush` keep the default tip. + +use photocraft_ui_egui::control::{ControlRequest, handle}; +use photocraft_ui_egui::{PhotocraftApp, Tool}; +use serde_json::{Value, json}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app +} + +/// A control request as the control channel sends it, then the frame's sync. +fn control(app: &mut PhotocraftApp, method: &str, params: Value) -> Value { + let ctx = egui::Context::default(); + let (req, rx) = ControlRequest::new(method, params); + handle(app, &ctx, &req); + app.sync_views(); + rx.try_recv().unwrap_or(Value::Null) +} + +/// Pick `tool`, and swap its brush in as a frame does. +fn pick(app: &mut PhotocraftApp, tool: Tool) { + app.ui.tool = tool; + photocraft_ui_egui::paint_mouse::sync_tool_brush(app); + app.sync_views(); +} + +/// Size, hardness, spacing, angle and roundness of the live brush, as `brush.get` reports them +/// (rounded: they are `f32`s). +fn tip(app: &mut PhotocraftApp) -> [f64; 5] { + let b = app.run("brush.get", json!({})).unwrap(); + ["size", "hardness", "spacing", "angle", "roundness"].map(|k| (b[k].as_f64().unwrap() * 1e4).round() / 1e4) +} + +/// Give the live brush a tip that is neither default, so a reset has something to undo. +fn scramble(app: &mut PhotocraftApp) { + let b = &mut app.session.tools.brush; + (b.size, b.hardness, b.spacing, b.angle, b.roundness) = (57.0, 0.4, 0.6, 30.0, 0.5); +} + +const BRUSH_TIP: [f64; 5] = [13.0, 0.0, 0.25, 0.0, 1.0]; +const DEFAULT_TIP: [f64; 5] = [20.0, 1.0, 0.1, 0.0, 1.0]; + +#[test] +fn reset_tool_gives_the_brush_a_13_px_soft_tip() { + let mut app = app(); + pick(&mut app, Tool::Brush); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(tip(&mut app), BRUSH_TIP); +} + +#[test] +fn reset_all_tools_gives_the_brush_its_tip() { + let mut app = app(); + pick(&mut app, Tool::Brush); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + assert_eq!(tip(&mut app), BRUSH_TIP); +} + +/// Reset All Tools from another painting tool: the Brush gets its own tip when picked, not the +/// live brush. +#[test] +fn reset_all_tools_from_another_tool_gives_the_brush_its_tip() { + let mut app = app(); + pick(&mut app, Tool::Brush); + scramble(&mut app); + pick(&mut app, Tool::Eraser); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + assert_eq!(tip(&mut app), DEFAULT_TIP, "the Eraser"); + pick(&mut app, Tool::Brush); + assert_eq!(tip(&mut app), BRUSH_TIP, "the Brush"); +} + +/// Reset All Tools from the Brush: the other painting tools get the default tip when picked, not +/// the Brush's. +#[test] +fn reset_all_tools_from_the_brush_gives_the_other_tools_the_default_tip() { + let mut app = app(); + pick(&mut app, Tool::Brush); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + for tool in [Tool::Pencil, Tool::Eraser] { + pick(&mut app, tool); + assert_eq!(tip(&mut app), DEFAULT_TIP, "{tool:?}"); + } +} + +#[test] +fn other_painting_tools_keep_the_default_reset_tip() { + for tool in [Tool::Pencil, Tool::Eraser] { + let mut a = app(); + pick(&mut a, tool); + scramble(&mut a); + control(&mut a, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(tip(&mut a), DEFAULT_TIP, "{tool:?}"); + } +} + +#[test] +fn a_session_brush_keeps_the_default_tip() { + let mut app = app(); + assert_eq!(tip(&mut app), DEFAULT_TIP, "what `paint.stroke` paints with when no `brush` is given"); +} + +/// The reference's click with the reset Brush at 100,100 paints layer bounds 89,89 to 110,110: +/// the soft tip reaches past its nominal diameter. Ours reaches 89,89 to 112,112: the outermost +/// pixels are the stroke's 8-bit dither at 1/255 alpha, which the paint engine owns (not the reset +/// tip), so this allows 2 px. The default 20 px hard tip reaches 88,89 to 113,112. +#[test] +fn a_click_with_the_reset_brush_paints_the_soft_dab() { + let mut app = app(); + pick(&mut app, Tool::Brush); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"})); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("paint.stroke", json!({"points": [[100.5, 100.5, 1.0]]})).unwrap(); + let st = app.session.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let b = surf.content_bounds(); + for (got, want) in [(b.x0, 89), (b.y0, 89), (b.x1, 110), (b.y1, 110)] { + assert!((got - want).abs() <= 2, "bounds {b:?}, want 89,89 to 110,110"); + } + let alpha = |x| (surf.pixel(x, 100)[3] * 255.0).round(); + assert_eq!(alpha(100), 255.0, "full at the centre"); + assert!((50.0..200.0).contains(&alpha(104)), "half-way out the tip is partly covered: {}", alpha(104)); +} diff --git a/crates/ui-egui/tests/it/live_stroke_canvas.rs b/crates/ui-egui/tests/it/live_stroke_canvas.rs index a152f6678c..6942dba8a4 100644 --- a/crates/ui-egui/tests/it/live_stroke_canvas.rs +++ b/crates/ui-egui/tests/it/live_stroke_canvas.rs @@ -15,6 +15,58 @@ use serde_json::{Value, json}; type Harness = egui_kittest::Harness<'static, PhotocraftApp>; type Pixels = Vec<[u8; 4]>; +#[test] +fn raw_pointer_brush_and_mixer_pixels_appear_in_the_input_frame() { + use photocraft_ui_egui::state::Tool; + let _gpu = photocraft_testkit::gpu_lock(); + for flip in [false, true] { + for tool in [Tool::Brush, Tool::MixerBrush] { + let Some(mut h) = harness() else { return }; + let app = h.state_mut(); + app.run("file.new", json!({"width":300,"height":200,"background":"white"})).unwrap(); + app.run("edit.fill", json!({"color":"#2040df"})).unwrap(); + app.run("tools.setColors", json!({"foreground":"#ef2000"})).unwrap(); + app.run("tools.setBrush", json!({"brush":{"size":24,"hardness":1.0,"pressureSize":false,"smoothing":{"amount":0.0}}})).unwrap(); + app.ui.tool = tool; + app.ui.view.flip_horizontal = flip; + app.sync_views(); + h.run_steps(4); + let at = h.state().last_canvas_rect.center(); + let before = h.render().unwrap().get_pixel(at.x as u32, at.y as u32).0; + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.input_mut().events.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + let image = h.render().unwrap(); + assert_ne!(image.get_pixel(at.x as u32, at.y as u32).0, before, "{tool:?} flip={flip}: press visible in its own frame"); + let next = at + egui::vec2(40.0, 0.0); + h.input_mut().events.push(egui::Event::PointerMoved(next)); + h.step(); + let image = h.render().unwrap(); + assert_ne!(image.get_pixel(next.x as u32, next.y as u32).0, before, "{tool:?} flip={flip}: move visible in its own frame"); + if tool == Tool::MixerBrush + && !flip + && let Ok(path) = std::env::var("PHOTOCRAFT_TEST_SNAPSHOT") + { + image.save(path).unwrap(); + } + h.input_mut().events.push(egui::Event::PointerButton { + pos: next, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some(if tool == Tool::MixerBrush { "Mixer Brush" } else { "Brush Tool" })); + } + } +} + fn harness() -> Option { let built = std::panic::catch_unwind(|| { egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| { @@ -167,3 +219,102 @@ fn the_canvas_shows_the_committed_stroke_part_2() { fn the_canvas_shows_the_committed_stroke_part_3() { the_canvas_shows_the_committed_stroke_part(3); } + +/// A dialog-owned brush must remain above the image even when a live painting tool is selected. +#[test] +fn refine_edge_brush_stays_visible_above_the_canvas_with_live_tools() { + use photocraft_ui_egui::state::Tool; + let _gpu = photocraft_testkit::gpu_lock(); + for flip in [false, true] { + for tool in [Tool::Brush, Tool::MixerBrush] { + let Some(mut h) = harness() else { return }; + h.state_mut().run("file.new", json!({"width":1200,"height":800,"background":"black"})).unwrap(); + h.state_mut().run("select.all", json!({})).unwrap(); + h.state_mut().ui.tool = tool; + h.state_mut().ui.view.flip_horizontal = flip; + h.run_steps(4); + control(&mut h, "ui.set", json!({"zoom":1.0,"center":[600,400]})); + control(&mut h, "ui.menu.invoke", json!({"command":"select.refineEdge"})); + let fields = &mut h.state_mut().ui.dialogs.last_mut().expect("Select and Mask opens").fields; + fields.insert("__brush".into(), json!(true)); + fields.insert("__preview".into(), json!(false)); + h.run_steps(3); + let canvas = h.state().last_canvas_rect; + let at = egui::pos2(canvas.left() + 32.0, canvas.top() + 80.0); + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.step(); + let image = h.render().unwrap(); + let cross = image.get_pixel(at.x as u32 + 2, at.y as u32).0; + assert!(cross[0] > 100 && cross[1] > 100 && cross[2] > 100, "{tool:?} flip={flip}: Refine Edge cross is hidden: {cross:?}"); + if tool == Tool::MixerBrush + && !flip + && let Ok(path) = std::env::var("PHOTOCRAFT_DIALOG_SNAPSHOT") + { + image.save(path).unwrap(); + } + } + } +} + +/// Deferred live-tool painting uses the camera changed by this frame's navigation input. +#[test] +fn live_tool_camera_navigation_is_visible_in_the_input_frame() { + use photocraft_ui_egui::state::Tool; + let _gpu = photocraft_testkit::gpu_lock(); + for flip in [false, true] { + for tool in [Tool::Brush, Tool::MixerBrush] { + for zoom in [false, true] { + let Some(mut h) = harness() else { return }; + let app = h.state_mut(); + app.run("file.new", json!({"width":300,"height":200,"background":"white"})).unwrap(); + app.run("paint.stroke", json!({"points":[[40,40],[240,160]],"size":30,"hardness":1.0,"color":"#2050df"})).unwrap(); + app.run("prefs.set", json!({"values":{"tools.overscroll":true,"general.animatedZoom":false,"general.zoomWithScrollWheel":false}})).unwrap(); + app.ui.tool = tool; + app.ui.view.flip_horizontal = flip; + app.sync_views(); + h.run_steps(4); + control(&mut h, "ui.set", json!({"zoom":1.0,"center":[150,100]})); + let at = h.state().last_canvas_rect.center() + egui::vec2(30.0, 10.0); + h.hover_at(at); + h.run_steps(2); + let before = h.state().ui.views[0].clone(); + h.input_mut().events.push(if zoom { + egui::Event::Zoom(1.25) + } else { + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(30.0, 20.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + } + }); + h.step(); + let moved = h.state().ui.views[0].clone(); + assert!(if zoom { moved.zoom != before.zoom } else { moved.center != before.center }, "navigation was applied"); + let immediate = h.render().unwrap(); + let rect = h.state().last_canvas_rect.shrink(8.0); + h.step(); + let settled = h.render().unwrap(); + if tool == Tool::MixerBrush + && flip + && zoom + && let Ok(path) = std::env::var("PHOTOCRAFT_NAV_SNAPSHOT") + { + settled.save(path).unwrap(); + } + assert_eq!(h.state().ui.views[0].zoom, moved.zoom); + assert_eq!(h.state().ui.views[0].center, moved.center); + let mut diff = 0; + for y in rect.top() as u32..rect.bottom() as u32 { + for x in rect.left() as u32..rect.right() as u32 { + for (a, b) in immediate.get_pixel(x, y).0.into_iter().zip(settled.get_pixel(x, y).0) { + diff = diff.max(a.abs_diff(b)); + } + } + } + assert!(diff <= 1, "{tool:?} flip={flip} zoom={zoom}: canvas changes {diff}/255 one frame after navigation"); + } + } + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 4816d6c1df..41d3f3e662 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,8 +1,12 @@ //! The crate's integration tests, linked into one test binary. +mod actions_duplicate; +mod actions_panel_menu; +mod actions_record_again; mod adjust_preview_gpu; mod arbitrary_rotation_preview; mod batch_export_options; +mod brush_reset_defaults; mod camera_raw_histogram; mod camera_raw_localization; mod camera_raw_scope; diff --git a/crates/ui-egui/tests/magnetic_lasso_spacing.rs b/crates/ui-egui/tests/magnetic_lasso_spacing.rs new file mode 100644 index 0000000000..08230ccf8a --- /dev/null +++ b/crates/ui-egui/tests/magnetic_lasso_spacing.rs @@ -0,0 +1,182 @@ +//! #11687: the Magnetic Lasso drops its automatic fastening points as far apart as the reference +//! application does: with the button up about 4 x the stored spacing + 4 screen px, with the +//! button held the stored spacing itself, which is `floor(40 - 0.35 x Frequency)`. +//! +//! Driven through the public `tool_event` entry (the one the mouse and `ui.pointer` share) on a +//! straight edge at 100 % zoom; the spacing is the distance between consecutive fastening points. + +use egui::Modifiers; +use photocraft_engine::Session; +use photocraft_geom::Rect; +use photocraft_ui_egui::canvas::{ToolEvent, tool_event}; +use photocraft_ui_egui::state::Tool; +use photocraft_ui_egui::{PhotocraftApp, Services}; +use serde_json::json; + +const W: i32 = 900; +const H: i32 = 120; +/// The straight edge: white above, black below. +const EDGE_Y: f64 = 60.0; +const START_X: f64 = 20.0; +const END_X: f64 = 880.0; + +/// A white document with a black lower half, the Magnetic Lasso at `frequency`, 100 % zoom. +fn app(frequency: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": W, "height": H})).unwrap(); + app.sync_views(); + app.session + .edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(0, 0, W, H), &[1.0, 1.0, 1.0, 1.0]); + bg.fill_rect(Rect::new(0, EDGE_Y as i32, W, H), &[0.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::MagneticLasso; + app.ui.tool_options.magnetic_frequency = frequency; + assert!((app.point_zoom() - 1.0).abs() < 1e-6, "the test measures at 100 % zoom"); + app +} + +/// Click the first point on the edge, then move along it to the far end in 2 px steps; with +/// `held` the button stays down for the whole walk, otherwise it is released after the click. +fn walk(app: &mut PhotocraftApp, held: bool) { + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: START_X, y: EDGE_Y, pressure: 1.0 }, m); + if !held { + tool_event(app, ToolEvent::Up { x: START_X, y: EDGE_Y }, m); + } + let mut x = START_X; + while x < END_X { + x = (x + 2.0).min(END_X); + tool_event(app, ToolEvent::Move { x, y: EDGE_Y, pressure: 1.0 }, m); + } +} + +/// The distances between consecutive fastening points of the border so far. +fn gaps(app: &PhotocraftApp) -> Vec { + let m = &app.ui.magnetic; + let pts: Vec<[f64; 2]> = m.anchors.iter().map(|&i| m.path[i]).collect(); + pts.windows(2).map(|w| (w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1])).collect() +} + +/// Every gap is within 10 % of `want`, and there are at least `min` of them. +fn check(frequency: f32, held: bool, want: f64, min: usize) { + let mut a = app(frequency); + walk(&mut a, held); + let g = gaps(&a); + let state = if held { "held" } else { "up" }; + assert!(g.len() >= min, "Frequency {frequency}, button {state}: only {} gaps {g:?}, wanted at least {min} of ~{want}", g.len()); + for (i, d) in g.iter().enumerate() { + assert!((d - want).abs() <= want * 0.10, "Frequency {frequency}, button {state}: gap {i} is {d:.1} px, the reference measures ~{want} ({g:?})"); + } +} + +#[test] +fn button_up_spacing_at_frequency_0() { + check(0.0, false, 166.0, 4); +} + +#[test] +fn button_held_spacing_at_frequency_0() { + check(0.0, true, 39.0, 20); +} + +#[test] +fn button_up_spacing_at_the_default_frequency_57() { + check(57.0, false, 85.0, 9); +} + +#[test] +fn button_held_spacing_at_the_default_frequency_57() { + check(57.0, true, 20.0, 40); +} + +#[test] +fn button_up_spacing_at_frequency_100() { + check(100.0, false, 24.0, 30); +} + +#[test] +fn button_held_spacing_at_frequency_100() { + check(100.0, true, 5.0, 150); +} + +#[test] +fn releasing_the_button_widens_the_spacing_again() { + let mut a = app(57.0); + let m = Modifiers::NONE; + tool_event(&mut a, ToolEvent::Down { x: START_X, y: EDGE_Y, pressure: 1.0 }, m); + for x in (22..=300).step_by(2) { + tool_event(&mut a, ToolEvent::Move { x: f64::from(x), y: EDGE_Y, pressure: 1.0 }, m); + } + let held_points = a.ui.magnetic.anchors.len(); + tool_event(&mut a, ToolEvent::Up { x: 300.0, y: EDGE_Y }, m); + for x in (302..=600).step_by(2) { + tool_event(&mut a, ToolEvent::Move { x: f64::from(x), y: EDGE_Y, pressure: 1.0 }, m); + } + let g = gaps(&a); + let after = &g[held_points - 1..]; + assert!(after.len() >= 2, "points keep fastening after the release: {g:?}"); + assert!(after.iter().skip(1).all(|d| (d - 85.0).abs() <= 8.5), "after the release the gaps are ~85 px: {g:?}"); +} + +#[test] +fn out_of_range_and_non_finite_frequencies_are_clamped() { + // Above 100 behaves as 100, below 0 as 0, and NaN as the default 57 (button up). + check(250.0, false, 24.0, 30); + check(-30.0, false, 166.0, 4); + check(f32::NAN, false, 85.0, 9); +} + +/// Walks the edge like `walk`, at `zoom` (1.0 = 100 %), with the pointer moving 2 screen px a step +/// below 100 % and 4 screen px from 100 % up (coarser steps give alternating gaps at 50 %). +fn walk_at(app: &mut PhotocraftApp, held: bool, zoom: f32) { + app.ui.views[0].zoom = zoom; + assert!((app.point_zoom() - zoom).abs() < 1e-6, "the view is at the zoom under test"); + let m = Modifiers::NONE; + let step = (if zoom < 1.0 { 2.0 } else { 4.0 }) / f64::from(zoom); + tool_event(app, ToolEvent::Down { x: START_X, y: EDGE_Y, pressure: 1.0 }, m); + if !held { + tool_event(app, ToolEvent::Up { x: START_X, y: EDGE_Y }, m); + } + let mut x = START_X; + while x < END_X { + x = (x + step).min(END_X); + tool_event(app, ToolEvent::Move { x, y: EDGE_Y, pressure: 1.0 }, m); + } +} + +/// Frequency 57 at `zoom`: every gap (document px) is within 10 % of `want_doc`. +fn check_at(zoom: f32, held: bool, want_doc: f64, min: usize) { + let mut a = app(57.0); + walk_at(&mut a, held, zoom); + let g = gaps(&a); + let state = if held { "held" } else { "up" }; + assert!(g.len() >= min, "zoom {zoom}, button {state}: only {} gaps {g:?}, wanted at least {min} of ~{want_doc}", g.len()); + for (i, d) in g.iter().enumerate() { + assert!( + (d - want_doc).abs() <= want_doc * 0.10, + "zoom {zoom}, button {state}: gap {i} is {d:.1} document px, the reference measures ~{want_doc} ({g:?})" + ); + } +} + +#[test] +fn button_up_spacing_halves_below_100_percent_in_screen_px() { + check_at(0.5, false, 84.0, 9); // 42 screen px + check_at(2.0 / 3.0, false, 63.0, 12); // 42 screen px +} + +#[test] +fn button_up_spacing_stays_84_screen_px_above_100_percent() { + check_at(2.0, false, 42.0, 18); + check_at(3.0, false, 28.0, 28); +} + +#[test] +fn button_held_spacing_is_the_stored_value_in_document_px_above_100_percent() { + check_at(2.0, true, 20.0, 40); // 40 screen px + check_at(3.0, true, 20.0, 40); // 60 screen px +} diff --git a/crates/ui-egui/tests/magnetic_lasso_width_keys.rs b/crates/ui-egui/tests/magnetic_lasso_width_keys.rs new file mode 100644 index 0000000000..1f1caaceac --- /dev/null +++ b/crates/ui-egui/tests/magnetic_lasso_width_keys.rs @@ -0,0 +1,107 @@ +//! Magnetic Lasso keys: `[` / `]` change the Width by 1 px within 1-256, Shift+`[` jumps to the +//! minimum (1 px) and Shift+`]` to the maximum (256 px), with and without a border being drawn +//! (#11688). The keys go through the real shortcut handler on a bare egui context. + +use egui::{Event, Key, Modifiers, RawInput}; +use photocraft_ui_egui::canvas::{ToolEvent, tool_event}; +use photocraft_ui_egui::{PhotocraftApp, Tool, magnetic_lasso_ui}; +use serde_json::json; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::MagneticLasso; + app +} + +/// A border is started with a click, so it is being drawn while the keys are pressed. +fn start_border(app: &mut PhotocraftApp) { + tool_event(app, ToolEvent::Down { x: 52.0, y: 37.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: 52.0, y: 37.0 }, Modifiers::NONE); + assert!(magnetic_lasso_ui::replaces_selection(app), "a border is being drawn"); +} + +/// Presses `key` with `modifiers` for one frame and returns whether the Magnetic Lasso took it. +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) -> bool { + let ctx = egui::Context::default(); + let mut taken = false; + let events = vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]; + let mut out = ctx.run_ui(RawInput { events, ..Default::default() }, |ui| taken = magnetic_lasso_ui::keys(app, ui.ctx())); + out.textures_delta.clear(); + taken +} + +fn width(app: &PhotocraftApp) -> f32 { + app.ui.tool_options.magnetic_width +} + +fn with_and_without_border(check: impl Fn(&mut PhotocraftApp)) { + let mut plain = app(); + check(&mut plain); + let mut drawing = app(); + start_border(&mut drawing); + check(&mut drawing); + assert!(magnetic_lasso_ui::replaces_selection(&drawing), "the border being drawn stays as it is"); +} + +#[test] +fn shift_open_bracket_sets_the_width_to_one_from_any_width() { + with_and_without_border(|app| { + for from in [10.0, 2.0, 1.0, 100.0, 256.0] { + app.ui.tool_options.magnetic_width = from; + assert!(press(app, Key::OpenBracket, Modifiers::SHIFT)); + assert_eq!(width(app), 1.0, "Shift+[ from {from}"); + } + }); +} + +#[test] +fn shift_close_bracket_sets_the_width_to_256_from_any_width() { + with_and_without_border(|app| { + for from in [10.0, 255.0, 256.0, 100.0, 1.0] { + app.ui.tool_options.magnetic_width = from; + assert!(press(app, Key::CloseBracket, Modifiers::SHIFT)); + assert_eq!(width(app), 256.0, "Shift+] from {from}"); + } + }); +} + +#[test] +fn plain_brackets_still_step_by_one_and_stop_at_the_limits() { + with_and_without_border(|app| { + app.ui.tool_options.magnetic_width = 10.0; + assert!(press(app, Key::OpenBracket, Modifiers::NONE)); + assert_eq!(width(app), 9.0); + assert!(press(app, Key::CloseBracket, Modifiers::NONE)); + assert!(press(app, Key::CloseBracket, Modifiers::NONE)); + assert_eq!(width(app), 11.0); + app.ui.tool_options.magnetic_width = 1.0; + press(app, Key::OpenBracket, Modifiers::NONE); + assert_eq!(width(app), 1.0, "[ stops at 1"); + app.ui.tool_options.magnetic_width = 256.0; + press(app, Key::CloseBracket, Modifiers::NONE); + assert_eq!(width(app), 256.0, "] stops at 256"); + }); +} + +#[test] +fn shifted_curly_bracket_keys_set_the_limits() { + with_and_without_border(|app| { + app.ui.tool_options.magnetic_width = 10.0; + assert!(press(app, Key::OpenCurlyBracket, Modifiers::SHIFT)); + assert_eq!(width(app), 1.0, "Shift+{{"); + assert!(press(app, Key::CloseCurlyBracket, Modifiers::SHIFT)); + assert_eq!(width(app), 256.0, "Shift+}}"); + }); +} + +#[test] +fn a_bare_curly_bracket_key_is_left_alone() { + with_and_without_border(|app| { + app.ui.tool_options.magnetic_width = 10.0; + assert!(!press(app, Key::OpenCurlyBracket, Modifiers::NONE), "a bare {{ is not taken"); + assert!(!press(app, Key::CloseCurlyBracket, Modifiers::NONE), "a bare }} is not taken"); + assert_eq!(width(app), 10.0); + }); +} diff --git a/docs/atn.md b/docs/atn.md index 19f2611293..be7eca3787 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -88,6 +88,7 @@ row; the action still adds only one Undo entry. | `ClrR` | colorRange | Color Range | `select.colorRange` | | (string ID only) | colorLookup | Color Lookup (embedded 3D LUT forms) | `image.adjustments.colorLookup` | | `Btch` | batch | Batch (supported folder and naming forms) | `file.automate.batch` | +| `CrtD` | (no string ID) | Create Droplet (destination Folder with Override Action "Save As"; other forms stay kept steps) | `file.automate.createDroplet` | | (string ID only) | shapeBlur | Shape Blur (custom shape by installed ID) | `filter.blur.shapeBlur` | | (string ID only) | cafWorkspace | Content-Aware Fill | `edit.contentAwareFill` | | (string ID only) | perspectiveWarpTransform | Perspective Warp (indexed layout and warp planes) | `edit.perspectiveWarp` | @@ -995,9 +996,7 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo | Convert Mode, several layers, "Don't Flatten" | `T ` as a **class** (`Grys`), `Fltt` false, `Rstr` false | With `Fltt` and `Rstr` the step plays without the flatten question. | | New Group with a colour | `Usng` (`Clr ` label), `layerSectionStart`, `layerSectionEnd`, top-level `Nm ` | | | Shear (`Shr `) | `ShrP` list of `Pnt ` (`Hrzn`, `Vrtc` doubles), `UndA` (`WrpA` wrap around, `RptE` repeat edge pixels), `ShrS` long, `ShrE` long | The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. `ShrS` is 0 and `ShrE` the last point's index (count - 1). Script runs take any value unclamped and crashed on far-off `ShrE`/`ShrS` or a first `Vrtc` below 1. | -| Gradient tool, default mode | two steps: Make (`null` = class `contentLayer`, `Usng` = `Type` gradient layer object) then Set (`null` = `contentLayer` target, `T ` = the same object plus `Algn` false) | The object: `Dthr`, `gradientsInterpolationMethod`, `Angl` angle, `Type`, `Scl ` percent, `Ofst` (`Hrzn`, `Vrtc` percent), `Grad`. The new tool makes a gradient fill layer; Make holds the first click, Set the finished drag. | | Print (`print`) then Print One Copy (`printOneCopy`) | `print`: `null` = reference `printOutput` property of the target document; `printOutput` (`PstS`, `Inte`, `printSixteenBit`, `printerName` text, `printProofSetup`), `printViewProofSetup`, `printOutputOptions` (caption, marks, `Bckg` colour, `BrdT`, `Bld `, `Rslt`, `vectorData`, `PgPs`, `Left`, `Top `, `Scl `, crop rectangle), `osSpecificPrintInfo` raw data, `Cptn` text. `printOneCopy` has no keys. | Pressing Print in the dialog records both steps. | -| Create Droplet (`CrtD`) | `In ` alias: the droplet file; `Fldr` bool (include subfolders); `Usng` reference to the action; then the Batch keys below (destination, file naming, error log) when set | The same settings as Batch, with the droplet file instead of a source folder. | | Sky Replacement (`skyReplacement`) | `shiftEdge` double, `borderSmoothness` long (fade edge, default 50), `Brgh` long, `temperature` long, `harmonizationOpacity` long (35), `foregroundLightingOpacity` long, `lightingMode` blend mode (`Mltp`), `edgeLightingOpacity` long (70), `skyReplacementOutput` (`skyReplacementOutputToNewSheets` new layers, …), `Idnt` text (the sky preset's id), `Nm ` text (its name), `File` alias (the sky image) | Scale and Flip are **not** in the step: with 150 % and Flip on, the returned keys were the same. `foregroundLightingOpacity` is computed from the sky (44 and 29 for two skies). Without a chosen sky the dialog returns no keys. | **Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 04e0eecc04..acd93b311e 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the eraser end of the pen switches to the Eraser on Windows with Windows Ink on, #2285; no estimate changed; Art History Brush Opacity on the options bar, #10263; no estimate changed; Shift+right-click menu for every painting tool: Edit Brush… and the tool's own Mode list; Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Angle/Roundness fields left of the Brush Tip Shape widget, labels right-aligned, #3825 partial; the eraser end of the pen switches to the Eraser on Windows with Windows Ink on, #2285; no estimate changed; Art History Brush Opacity on the options bar, #10263; no estimate changed; Shift+right-click menu for every painting tool: Edit Brush… and the tool's own Mode list; Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -19,6 +19,7 @@ tablets against Photoshop side by side. | Section | Status | Evidence | |---|---|---| | Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | +| Brush Tip Shape places aligned Angle/Roundness controls left of the interactive widget | done | #3825 (Angle/Roundness layout only); UI regressions cover 14 themes at two panel widths, imported-value retention, numeric limits, actual arrow/handle gestures and existing command routing. | | Shape Dynamics (size/angle/roundness jitter, pen pressure/tilt/rotation/wheel controls, minimums, flip jitter, brush projection) | done | #174 | | Scattering (both axes, count, count jitter) | done | | | Texture (pattern, scale, brightness, contrast, mode, depth, each tip) | done | | @@ -134,6 +135,7 @@ tablets against Photoshop side by side. | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Brush-only Shift+right-click Mode menu shares options-bar choices, including Clear; preserves existing right-click gestures and consumes outside dismissal without painting (#4929). No brush-feel estimate changed. | +| 2026-10-13 | minor | Angle/Roundness controls use right-aligned labels in the left column, with the interactive widget on the right (#3825, partial). Units, limits and gestures retained; no brush-feel estimate changed. | | 2026-10-13 | minor | Transfer Wetness/Mix controls follow the active tool and retain their settings while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit and show themed keyboard focus (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | | 2026-10-14 | minor | Options-bar Mode for Clone Stamp, Pattern Stamp, History Brush and Healing (kept in each tool's brush) and Paint Bucket (with Clear); each tool's first brush starts in Normal. No brush-feel estimate changed. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dc4f36dddb..5934696f08 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,6 +56,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root - `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject`, `artboard` or `group` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Group (#10883) has no field of its own either and filters at once: it lists every group that is not an artboard with everything inside it (the groups inside an artboard too), and hides the layers that are in no group; each group stays open or closed as it is. Fields left out keep their value; a bad one is an error and changes nothing. +- `select.findLayers` (#10884, Select › Find Layers) filters the Layers panel by Name and selects none of the matches; as with any filter, a selected layer the filter hides is deselected (#10879). With `name` it sets the Name text; without it the text is kept. Through `engine.execute` or an action step that is all; through the menu, the shortcut or `ui.menu.invoke` without `name`, the Layers panel is also shown and its Name field takes the keyboard focus with the text selected (unless Filter Options › Hide hid the filter row, which stays hidden). The reply lists the ids of the layers the Name text matches in `selected` (trimmed, ignoring case: a blank text lists every layer, a text nothing matches none) and the Name filter applied in `layerFilter` (`{by: "name", name}`). - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. diff --git a/docs/development.md b/docs/development.md index 1fa912575f..d09fbfd26c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -404,6 +404,25 @@ none). A missing translation shows in English and fails nothing: `cargo xtask ci as its last step so the list appears in the log, and only prints a warning if the report itself can't run. +## Panel command routing + +Every panel button must dispatch an engine command by id (AGENTS.md rule 1), so the UI, CLI, +control channel and MCP stay equivalent. `cargo xtask panel-commands` (also a step of +`cargo xtask ci` and of CI) scans `crates/ui-egui/src` outside test code for click handlers: the +block of an `if` whose condition calls `.clicked()`, `.changed()`, `.dragged()` and friends, or a +widget function that takes a `Ui` and returns `bool`. Inside one it fails, with file:line, on a +direct document or session change: `active_mut()`, `Arc::make_mut` on a doc, +`session.undo/redo/select_layer/add_document/close(…)` or an assignment to a session field +(`app.session.tools.foreground = …`). Dispatch the command instead (`app.run`, `menus::invoke`). +A passing scan is not complete coverage. The scanner is lexical: it does not follow calls into +helpers, and it only recognises an `if` block (a `match` arm guard with a block counts) or a +`let` local holding a response, so a `match` on a response without such a guard, a response kept +in a struct field and tested later, or a closure callback is not checked. Preferences +(`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and switching the active document +are out of scope. Existing violations are listed in +`ALLOWLIST` in `xtask/src/panel_commands.rs` by file and function, each with its reason; an entry +that matches nothing any more is an error, so the list only shrinks. + ## Testing strategy - **Unit and property tests** in every crate. proptest is used for tile COW, regions and codecs. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 468365c6f6..3c39ba57b6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** minor (scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** minor (CLI `--metadata none|all` for `convert`, `run --out` and `batch`: `none` leaves the document XMP and free-form text out of flat exports, as Export As's Metadata: None, `all` the default, #9466; Hasselblad 3FR/FFF, the X2D II 100C, decodes; scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -27,11 +27,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); TIFF Options on Save As and Save a Copy (Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency, Discard Layers and Save a Copy; scripts pass `tiffCompression`, `tiffTransparency`, `tiffPyramid`, `tiffLayers`; #8827), no JPEG compression, Pixel Order or Byte Order (the encoder writes interleaved samples in the machine's byte order); undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); Save Transparency on keeps the transparency as the first extra sample ahead of the channels (a layered file marks it with `MTrn`, as Photoshop does), off mattes on white; unset, it is kept only without alpha or spot channels | -| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | -| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP (the CLI's `--metadata none` leaves the document XMP out, #9466); Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | +| PNG | R/W | yes | yes | 8/16-bit, ICC/text (the CLI's `--metadata none` leaves the document XMP and text out, #9466); APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode and Indexed Color documents save as palette BMPs (no transparency) | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP (the CLI's `--metadata none` leaves the document XMP out, #9466); Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -73,7 +73,8 @@ supports many more, including CR3). | Panasonic RW2, Pentax PEF | yes | | | Olympus ORF | uncompressed only | | | Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | | -| Others (CRW, 3FR, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | | +| Hasselblad 3FR/FFF | uncompressed only; compressed (lossless-JPEG) files: embedded preview only | X2D II 100C uses a measured Bayer phase; other bodies assume it with a warning (`crates/raw/src/hasselblad.rs`) | +| Others (CRW, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | | | .xmp sidecars, highlight modes, raw preferences | missing | FILE-215-11 | | Camera Raw as a PSD smart filter | partial | 2026-10-07: WB, Light/Presence, curves, HSL, grading, detail, grain, vignette mapped (FILE-215-12) | @@ -152,7 +153,9 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| | 2026-11-06 | minor | Scripted PSD/PSB saves take `maximizeCompatibility` (Save, Save As, Save a Copy, `app.save`, serve `doc.save`, MCP `doc_save`/`doc_export`, Batch, Image Processor, Layers to Files) over Preferences › File Handling for one save, and the CLI `--no-maximize-compatibility` (#8064). Layer channels and the merged image were already RLE (ZIP with prediction at 32-bit), as Photoshop writes them (#8645) | +| 2026-10-24 | minor | Hasselblad 3FR/FFF: the X2D II 100C's 16-bit uncompressed CFA decodes; compressed 3FR still opens as the embedded preview | | 2026-11-02 | minor | Actions Shear (`Shr `) loads, plays and saves the measured curve points and edge mode; unsafe subranges and unrepresentable forms remain kept steps (#10605). | +| 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. The ICC profile, EXIF and resolution are written as they are without the option (a palette PNG carries only its resolution, either way); PSD, PSB and `.pcraft` always keep the XMP (#9466) | | 2026-11-01 | minor | `.atn`: 23 newer measured forms mapped, including explicit Link/Unlink, Smart Object file operations, Make Type to Shape, radius-only Shape Blur, Artboard from Group and the Crop Photos parent; supported in batch 13; Link Layers keeps the measured toggle. Artboard forms with unmeasured settings and generated Crop helper steps remain kept. | | 2026-11-01 | minor | Recorded direct Gradient Map and Lab-range Replace Color load, play and retain their measured settings on save; unsupported forms remain preserved (part of #10607). | | 2026-11-02 | minor | TIFF Options on Save As and Save a Copy (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid (half-size levels marked reduced-resolution, down to 64 px, alpha-weighted; +45 ms on a 30 MP RGB/8 save), Save Transparency (on: transparency kept ahead of alpha and spot channels, `MTrn` in a layered file; off: matted on white) and Discard Layers and Save a Copy. Scripts, `doc.save`, MCP `save` and the CLI (`--tiff-transparency`, `--tiff-pyramid`) take the same answers. JPEG compression, Pixel Order and Byte Order not offered | diff --git a/docs/gaps.md b/docs/gaps.md index 481e11cedf..b34592cbd7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026) Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -270,6 +270,10 @@ what an agent can't finish alone. and 0/49% smoothing. Other presets, explicit spacing and remembered brushes retain their settings; reselect a built-in preset to take its new default. Other brush-feel and latency gaps remain open; percentages and estimates are unchanged. +- **Progress (2026-10-13, #3825 partial):** Brush Tip Shape places Angle/Roundness + controls in a left column with right-aligned labels and the interactive widget on + the right. Numeric units, limits, imported values and arrow/handle interactions + are retained; the assessment's other layout and control gaps remain open. - **Progress (2026-10-13, #3825 partial):** Transfer’s Wetness Jitter and Mix Jitter blocks are disabled outside the Mixer Brush, retaining their saved settings. Opacity and Flow keep their existing availability; other layout and brush-feel gaps remain open. @@ -466,6 +470,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; compressed 3FR still opens as the embedded preview | | 2026-11-06 | minor | G12: eraser end of flipped pens on Windows (Windows Ink on) selects the Eraser (#2285); no estimate changed | | 2026-10-29 | minor | G17: web build memory handling (#10024): up-front memory checks for Place, Image Size, smart-object re-render and Save; a low-memory warning; a stopped message when the module traps; PSD save reserves its whole output; no estimate changed | | 2026-11-02 | minor | G11: Batch Esc Continue/Stop, one plan and error log across interruptions, and read-only input previews without a progress bar (#4579); live tab parity remains open, AUTO-219-5 partial; readiness and estimate unchanged | @@ -562,6 +567,7 @@ 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 | G4: Angle/Roundness column and label alignment (#3825, partial); existing values and interactions retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 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/localization-ka.md b/docs/localization-ka.md new file mode 100644 index 0000000000..1d369f6a08 --- /dev/null +++ b/docs/localization-ka.md @@ -0,0 +1,84 @@ +# Georgian localization and terminology + +This note goes with the Georgian catalog (`crates/ui-egui/src/i18n/ka.tsv`). It covers every key +`cargo xtask i18n-coverage` measures and the runtime labels the other complete catalogs carry +(filter and adjustment parameters, Camera Raw, panels, dialogs, status messages). Translations are +written from the meaning of PhotoCraft's English labels; no proprietary translation resources, +fonts, screenshots or user documents are included. + +## Choosing Georgian + +Open **Edit > Preferences > Interface > Language** (Georgian: **რედაქტირება › პარამეტრები › +ინტერფეისი › ენა**) and choose **ქართული**. With `interface.language` set to `auto`, the system +locales `ka`, `ka-GE` and `ka_GE.UTF-8` resolve to Georgian. + +## Register and style + +Commands are verbal nouns (masdar: *შენახვა*, *გახსნა*, *შრის წაშლა*), not imperatives; prompts +and hints address the user politely (*დაწკაპეთ*, *გადაათრიეთ*). Product and technology names +(PhotoCraft, OpenType, CMYK, Lab) stay as they are, and the trailing `…` of dialog commands is kept. + +## Georgian script + +- Mkhedruli has no letter case, so nothing is capitalised and labels never use Mtavruli. + The text engine's **All Caps** maps Mkhedruli to Mtavruli through Unicode `to_uppercase`, which + is the Unicode-standard behaviour; there is no title-case feature that needs a Georgian guard. +- Nouns stay singular after a numeral, so the catalog uses one plural form and count-neutral + wording: `{n} layers` is `შრე: {n}` (`plural_none` in the registry). +- UI font: PhotoCraft bundles no Georgian face. The fallback list in `crates/text/src/scripts.rs` + uses system fonts: Noto Sans Georgian, Segoe UI, Sylfaen, DejaVu Sans and + FreeSans. The web build has no system fonts, so it needs a Georgian face before Georgian can render + there. +- PhotoCraft has no per-language date or number formatting, so nothing Georgian-specific applies. + +## Terminology + +Follow this table when adding or changing Georgian strings. + +| English source | Georgian | Note | +|---|---|---| +| File / Edit / Select / View / Window | ფაილი / რედაქტირება / მონიშვნა / ხედი / ფანჯარა | menu bar | +| Image | გამოსახულება | the edited picture | +| Canvas / Canvas Size… | ტილო / ტილოს ზომა… | | +| Layer / Layers | შრე / შრეები | | +| Selection | მონიშნული | the selected area; the menu is მონიშვნა | +| Layer Mask / Vector Mask | შრის ნიღაბი / ვექტორული ნიღაბი | | +| Clipping Mask | მომჭრელი ნიღაბი | | +| Smart Object | ჭკვიანი ობიექტი | | +| Rasterize | რასტრირება | | +| Path / Work Path | ტრაექტორია / სამუშაო ტრაექტორია | not კონტური | +| Stroke | კონტური | outline of a shape or text | +| Channel / Alpha | არხი / ალფა | | +| Spot Color / Spot Channel | სპოტ-ფერი / სპოტ-ფერის არხი | | +| Adjustments | კორექცია | | +| Blend Mode / Blending Options | შერევის რეჟიმი / შერევის პარამეტრები | | +| Opacity | გაუმჭვირვალობა | | +| Preset / Presets | პრესეტი / პრესეტები | | +| Pattern | პატერნი | | +| Swatches | ფერის ნიმუშები | | +| Slice | ნაჭერი | | +| Crop | ჩამოჭრა | | +| Feather | კიდეების შერბილება | | +| Hue / Lightness | ფერის ტონი / სიღიავე | | +| Threshold | ზღვარი | | +| Brush / Eraser / Smudge | ფუნჯი / საშლელი / გადღაბნა | | +| Healing Brush | აღმდგენი ფუნჯი | | +| Reset | ჩამოყრა | | +| Paste | ჩასმა | | +| Workspace | სამუშაო სივრცე | | +| Foreground / Background Color | წინა პლანის ფერი / ფონის ფერი | | + +## Not covered + +Engine error and status messages, user document, layer and preset names, automation command IDs +and the default `Untitled` / `Background` names stay in English, as in every language. + +## Maintenance + +Add a row to `ka.tsv` for each new English label: `contextsourcetranslation`, with +`@plural` rows holding one Georgian form and `@id` rows overriding a command id. Keep `{name}` +placeholders, `\n`, `…` and shortcut text exactly as in the source. Checks: + +- `cargo test -p photocraft-ui-egui --lib i18n`: registry, locale tags, placeholders and catalog + consistency. +- `cargo xtask i18n-coverage`: measured key coverage for `ka`. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 555aff508f..60521fb7b5 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** minor (Azerbaijani UI catalog merged with current languages; native review pending; Bosnian UI catalog; previously minor: Swedish, Finnish, Danish and Norwegian Bokmål UI catalogs; previously minor: Arabic UI catalog) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** minor (Georgian interface catalog; previously minor: Azerbaijani UI catalog merged with current languages; native review pending; Bosnian UI catalog; previously minor: Swedish, Finnish, Danish and Norwegian Bokmål UI catalogs; previously minor: Arabic UI catalog) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), -[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md), [Russian](localization-ru.md). +[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md), [Russian](localization-ru.md), [Georgian](localization-ka.md). **How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog @@ -24,7 +24,7 @@ Vietnamese UI; Arabic and Hebrew users get English "Middle East" builds with the composer, and its text engine handles RTL, Devanagari and Indic scripts. **Summary.** 7 of the 12 key languages at 100% of measured keys (status `partial` because of the -engine messages), 2 absent (Hindi, Vietnamese), plus 17 other registered languages. Weighted by +engine messages), 2 absent (Hindi, Vietnamese), plus 18 other registered languages. Weighted by speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorded only for Italian, 2026-10-12). @@ -47,13 +47,14 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde ## Other shipped languages -17 registered languages are outside the twelve-language table. The following eight have +18 registered languages are outside the twelve-language table. The following nine have 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic label the other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Hungarian -`hu` (4,347 rows) is also registered. Turkish `tr` +`hu` (4,347 rows) is also registered. Georgian `ka` (4,530 rows; every measured key on 2026-10-31; +Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, SF Georgian, DejaVu Sans; on the web the first served font for `Geor`). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse the Russian rule. Swedish `sv` (4,552 rows, added 2026-10-28) covers every measured key and the @@ -113,6 +114,7 @@ engine localization or native review is complete. | 2026-10-13 | minor | Italian: the `Screen` blend mode read *Schermo* (the display) and is now *Scherma*; the Dodge/Burn/Sponge tool names were already Adobe's (*Scherma*, *Brucia*, *Spugna*) and stay. Pinned by a test | | 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) | | 2026-10-22 | minor | Arabic row: paragraph direction has Auto / Left-to-right / Right-to-left buttons in the Paragraph panel and Type options bar (#6793); the visual RTL caret is still missing (TYPE-218-2 partial) | +| 2026-10-22 | minor | Georgian (`ka`) catalog | | 2026-10-28 | minor | Arabic (`ar`) UI catalog: every measured key, six plural forms; layer names and the title bar no longer reversed on egui 0.37 | | 2026-10-29 | minor | Swedish (`sv`) UI catalog: every measured key plus the dynamic labels, combined from #5985 and #9536 | | 2026-10-29 | minor | Finnish (`fi`), Danish (`da`) and Norwegian Bokmål (`nb`) UI catalogs: every measured key plus the dynamic labels, combined from #5666, #6961, #7267, #8072 and #9025 | diff --git a/docs/localization.md b/docs/localization.md index be4122d3c2..07276e2c57 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -36,6 +36,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Suomi | `fi` | one / other (partitive after every count but 1) | | Dansk | `da` | one / other | | Norsk bokmål | `nb` | one / other | +| ქართული | `ka` | one form (count-neutral: `შრე: {n}`) | | עברית | `he` | one / other | | اردو | `ur` | one / other | | Română | `ro` | one / few (0, 2–19, 101–119 …) / other (20–100, 120 …) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 60bdfcabd1..7c69231573 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,14 +2,14 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**661 of 661 menu items live (100.0%).** +**662 of 662 menu items live (100.0%).** | Menu | Live | Total | % | |---|---:|---:|---:| | File | 52 | 52 | 100% | | Edit | 75 | 75 | 100% | | Image | 61 | 61 | 100% | -| Layer | 161 | 161 | 100% | +| Layer | 162 | 162 | 100% | | Type | 74 | 74 | 100% | | Select | 25 | 25 | 100% | | Filter | 75 | 75 | 100% | diff --git a/docs/scorecard.md b/docs/scorecard.md index 7e92243bf8..bc6eba171c 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,9 +9,9 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 194 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 195 (target 0) | | [Type](#type) | 5 | 5 | 0 | | -| [Automation](#automation) | 5 | 3 | 3 | 661 of 661 menu items live (100.0%) | +| [Automation](#automation) | 5 | 4 | 2 | 662 of 662 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 194** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 10 of 195** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. @@ -256,7 +256,7 @@ Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace. ## Automation -Menu wiring (`docs/parity-checklist.md`): 661 of 661 menu items live (100.0%). This counts dispatch, not behaviour. +Menu wiring (`docs/parity-checklist.md`): 662 of 662 menu items live (100.0%). This counts dispatch, not behaviour. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -268,7 +268,7 @@ Menu wiring (`docs/parity-checklist.md`): 661 of 661 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. Desktop Esc asks Continue/Stop, retaining one plan and error log; a read-only canvas preview shows each scratch-session input without a progress bar. Inputs are not live tabs with Layers panel content, so full window parity remains open. | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 34ece9c72d..16d0a79e8c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026; Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切; +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; Equalize with a selection asks Selected Area Only or Entire Image Based On Selected Area; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026; Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切; The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -480,7 +480,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | | Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms; 2026-10-07: 30 MP TIFF open (banded, parallel strip/tile decode) Deflate 345 → 32 ms, LZW 428 → 43 ms, BigTIFF and every IFD readable; 2026-10-09: 4.2 MP Indexed Color, 256 colours, full-resolution CPU preview p50 3018 → 1061 ms (Ryzen AI 7 350, three measured runs; GPU upload excluded); 2026-10-10: Liquify 3500×2200, 800 px brush, visible 1:1 crop 3.20 → 3.63 ms p50 (5 runs) while refreshing ~9× as many pixels | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). Liquify now displays source-resolution detail above 100% zoom; its ≤1 ms update target is not met (3.73 ms p95 on a Windows run; GPU upload excluded). Native Indexed Color previews run on one background worker with stale-result rejection; large palettes can still take about a second to compute. Web previews remain synchronous. | | Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. | -| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), Nikon NEF (lossless + lossy compressed), RW2, uncompressed ORF; 2026-10-09: uncompressed Fujifilm RAF, Bayer and X-Trans (edge-directed X-Trans demosaic; 26 MP X-Trans develop 306 ms on 12 cores) | medium | CR3 and compressed RAF still open (#50); Nikon "lossy after split" decoded since 2026-10-31. Non-DNG cameras have no calibration: their colour (matrix + tone curve) is fitted per file to the camera's embedded JPEG, with the neutral sRGB-primaries fallback when the fit doesn't clearly help (2026-10-08: 8 D3200 NEFs, mean ΔE vs the camera JPEG 11.6–26.0 → 1.0–7.5); measured per-model calibration still open. | +| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), Nikon NEF (lossless + lossy compressed), RW2, uncompressed ORF; 2026-10-09: uncompressed Fujifilm RAF, Bayer and X-Trans (edge-directed X-Trans demosaic; 26 MP X-Trans develop 306 ms on 12 cores); 2026-10-24: uncompressed Hasselblad 3FR/FFF (the X2D II 100C; 105 MP develops in about 1.7 s) | medium | CR3, compressed RAF and compressed 3FR still open (#50); Nikon "lossy after split" decoded since 2026-10-31. Non-DNG cameras have no calibration: their colour (matrix + tone curve) is fitted per file to the camera's embedded JPEG, with the neutral sRGB-primaries fallback when the fit doesn't clearly help (2026-10-08: 8 D3200 NEFs, mean ΔE vs the camera JPEG 11.6–26.0 → 1.0–7.5); measured per-model calibration still open. | | AI / generative | none | ~0% | Deferred by decision (#41). | | Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; `.atn` actions load and save (2026-10-12); no ExtendScript/UXP; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. | | Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f80e1c5e18..8840897f38 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK and opens at Warming Filter (85), 25 %, as the reference application does, #9879; foreground/background swatches and exact CMYK picks restored across launches, #8485; Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -55,6 +55,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Press-and-hold or click-drag opens the flyout | done | `toolbar.rs`; right-click, long-press, press-drag | | Edit Toolbar customization | done | `edit.toolbar` | | Last selected tool restored after quitting and relaunching; temporary tool switches excluded | done | `tool_memory.rs`; `prefs_ui::tests::selected_tool_survives_a_restart_without_option_changes`, `selected_tool_excludes_temporary_switches` | +| Foreground/background colours restored across launches, including separate mask colours, HDR/alpha and exact CMYK picks | done | `tool_memory.rs`; `prefs/prefs_ui/tests.rs`: `tool_colors_survive_restart_before_the_first_frame`, `tool_colors_keep_pixel_and_mask_pairs_when_exiting_on_a_mask`, `tool_colors_restore_cmyk_inks_for_picker_and_fill_after_swapping_and_exiting_on_a_mask`, `tool_colors_preserve_finite_hdr_components` | ## Free Transform (⌘T) and transform handles @@ -223,6 +224,7 @@ for partial sets; [scope, tests and visual evidence](multi-channel-editing.md). | Paragraph box overflow mark: text past the box shows a + in its lower-right handle | done (2026-10-29): text a box can't hold (dropped layout lines) enlarges the box's lower-right handle and shows a black + in it, as the reference application does; agents read `typeBoxOverflow` in `ui.inspect` (false once the edit session ends). Differs: shown while the inline edit session is on the box, and not while its transform handles are up | `text::TextLayout::overflows`, `canvas_tests.rs` `an_overflowing_box_shows_a_plus_in_its_enlarged_lower_right_handle` | | Image › Adjustments › Selective Color: each of the nine colours keeps its own Cyan, Magenta, Yellow and Black; choosing another colour shows its values; OK and Preview apply all nine | done (#11463): one editor shared with the Properties panel (`adjust_editors/selective_color.rs`, `adjust_dialog/tests.rs`, 2026-11-05). Preset menu and `.asv` files not yet | | | ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` | +| Photo Filter keeps Preserve Luminosity: Image › Adjustments › Photo Filter… opens at Warming Filter (85) and 25 % after any OK, with Preserve Luminosity as last confirmed | done (#9879): measured in the reference application 2026-11-08 (filter, colour and density are not kept); ⌥ reopens at all the last settings; a smart filter's dialog shows that filter's own settings | `adjust_dialog/tests.rs` `photo_filter_keeps_only_preserve_luminosity_from_its_last_ok`, `photo_filter_reopens_with_the_choice_its_colour_implies` | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | | Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush, Pencil and Paint Bucket only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box is open they, and the number keys, still reach the tool (or the layers) without closing it; Puppet Warp and Perspective Warp keep them to themselves. Clone Stamp, Pattern Stamp, History Brush, Healing (its own short list) and Paint Bucket (with Clear) take them as their own mode; tools without a blend mode (the Erasers, Mixer Brush) set the layers. Differs: Behind (⇧⌥Q) is not implemented; Spot Healing, Color Replacement, Blur, Sharpen and Smudge ignore the keys until their options bar has a Mode, and Dodge, Burn and Sponge ignore them until their Range and mode keys (⇧⌥S/M/H, ⇧⌥D/S) exist | `blend_keys.rs` | @@ -247,7 +249,7 @@ Paragraph; the rest open as in-app floating windows. | Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far | | Command palette with recents | done | UI-217-7 | | Menu bar from the keyboard: Alt / F10 focus the bar, ← → ↓ ↩ drive it, Alt+letter opens a menu, underlined mnemonics run items | done | `menu_keys.rs`, #4988; macOS uses the native bar | -| Every preference takes effect | partial | 10 of 192 do nothing (scorecard, 2026-11-06) | +| Every preference takes effect | partial | 10 of 195 do nothing (scorecard, 2026-11-07) | | UI scale presets | done | UI-217-9; 75–125% in 5% steps (#2492, #3222) | | History snapshots: camera button, New Snapshot dialog (⌥-click: Name, From), the first snapshot (the document as opened), snapshot rows (click returns and selects, rename, delete; the trash deletes the selected snapshot, and a snapshot or state dragged onto it, #5324), `history.*Snapshot` commands; History Options' snapshot options (Automatically Create First Snapshot, New Snapshot When Saving, Show New Snapshot Dialog by Default) | partial | `history_cmds.rs`, `history_options_cmds.rs`, `snapshot_rows` in `panels.rs`, `snapshot_dialog.rs`; History Brush sources to come (#2966) | | Themes: Photoshop's four greys | partial | five themes; #2661 | @@ -265,11 +267,16 @@ Paragraph; the rest open as in-app floating windows. | 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` | | Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…; the single-channel items act on the targeted row (a colour, alpha or spot channel, Quick Mask, the layer mask, a one-channel image's composite) and are greyed without one; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… (only the modes the open grayscale documents fill, a different document per channel; greyed below three documents) open their dialogs, here and in the rows' menu, and Split Channels runs at once | done (2026-11-06) | `channels_panel.rs` `panel_menu`, `channels_panel/tests.rs`, `panel_docking/tests.rs`, `dialogs/merge_channels_ui.rs`; #11451. Panel Options (thumbnail size) to come | +| Layer ▸ Smart Objects ▸ Reset Transform, and the same item in the Layers panel row menu under Edit Contents for a smart object layer: the placement returns to 100 % of the current contents' own size with rotation, skew, Distort, Perspective **and the Warp** cleared, as the reference application clears the warp with the rest of the transform, the object left centred on the average of the placement's four corners, in one undo step; the smart filters stay; a stack whose filters can't all be applied, or contents that can't be read, refuses the step whole rather than recording the old warped pixels under the new placement | done (2026-11-07) | `layer.smartObjects.resetTransform`, `smart_cmds/placing.rs` (`reset_transform`), `panels/layer_menu_ui.rs`, `smart_cmds/tests.rs`; #11662, semantics measured against the reference application 2026-11-06 (#11675 discussion) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK; filter, colour and density reopen at Warming Filter (85) and 25 %, measured in the reference application (#9879); no percentage changed | +| 2026-11-07 | minor | Foreground/background colours restored before the first frame (`Preferences.toolColors`, #8485), with separate pixel/mask pairs, HDR/alpha, exact CMYK ink picks and a final normal-exit flush; malformed values are ignored one by one, and first saves from older preferences merge against the default swatches so separate edits in two instances survive. No percentage changed | +| 2026-11-07 | minor | Reset Transform on a smart object also clears the **Warp**, and centres on the average of the placement's four corners rather than the point its box centre maps to: measured in the reference application 2026-11-06 (a smart object warped with Arc 50, rotated 20° and scaled to 80 % came back with no warp at all, at its contents' own size, unrotated), which corrects the row below (#11662, discussion in #11675). The corner average is the box centre for an affine placement, so only Distort and Perspective placements move; the Smart Filter mask and linked masks keep following the placement, as under Free Transform. The step also requires its re-render: a visible filter PhotoCraft doesn't implement, or contents that can't be read, refuses it whole (no history state, placement and warp untouched) rather than recording the old warped pixels at the reset placement. No percentage changed | +| 2026-11-06 | minor | Layer ▸ Smart Objects ▸ Reset Transform, and the same item in the Layers panel row menu for a smart object layer (#11662): 100 % of the current contents' own size again with rotation, skew, Distort and Perspective cleared and the object left centred where it is, in one undo step; the Warp and the smart filters are untouched, as Photoshop resets the transform and not the warp. No percentage changed | | 2026-11-06 | minor | Channels panel menu (☰): the channel items, not only the rows' context menu; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs (New Channel and Merge Channels are new), and Channel Options… in Quick Mask mode opens a targeted alpha channel's options (#11451). No percentage changed | | 2026-11-05 | minor | Image › Adjustments › Selective Color keeps each colour's own Cyan, Magenta, Yellow and Black, and OK and Preview apply all nine colours; the dialog and the Properties panel share one editor (#11463) | | 2026-11-02 | minor | Edit › Transform › Distort: the side handles move their edge freely, both its corners by the drag, stopping before the box folds as a corner does; Perspective's side handles skew along their edge, as in Photoshop (#10085). Not a new command, so no percentage changed | @@ -366,3 +373,5 @@ Paragraph; the rest open as in-app floating windows. | 2026-11-03 | minor | Tools › Overscroll off centres an image smaller than the window on that axis, so zooming out around the pointer keeps it in the middle instead of where the zoom left it (#10488, #4799); this overrides #1718's Photoshop 25.4 measurement on the strength of two Photoshop users' reports, and Overscroll on keeps the free view; no percentage changed | | 2026-11-04 | minor | Each toolbar slot and tool key keeps its last tool across launches (`Preferences.groupTools`, #3569); the group row, stale since #2625, is done; no percentage changed | | 2026-10-31 | minor | Ctrl + Alt + wheel over the canvas zooms like ⌥ + wheel (#10488): ×1.1 a notch around the pointer with Animated Zoom off, eased with it on, and it follows the ⌥ swap when General › Zoom with Scroll Wheel is on. Windows and Linux report a held Ctrl as ctrl AND command (`wheel_nav::alt_gesture`), macOS ⌘ + ⌥ is left alone, plain ⌘/Ctrl + wheel still scrolls sideways; the combination was a dead gesture, so nothing existing was replaced. The centred-zoom half of the report is still open: `canvas.rs:1737` re-clamps the view centre each frame only when Tools › Overscroll is off (it is on by default), so the drift the reporter saw is more likely the deliberate nearest-image-point anchor (`view_xform.rs:239`, #3720) or Zoom Clicked Point to Center, which needs its own repro; no percentage changed | + + diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 617914afbd..0607d992fc 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -63,9 +63,9 @@ note = "No ONNX runtime dependency" [[item]] id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" -status = "missing" +status = "partial" issue = 219 -note = "No such xtask check" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 49068c3cbe..78be830a60 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod corpus_pins; mod i18n_coverage; mod ico; mod layers; +mod panel_commands; mod perf; mod pinned; mod scorecard; @@ -24,10 +25,12 @@ usage: cargo xtask commands: layers enforce the crate dependency layering (docs/architecture.md §3) + panel-commands fail on a shell click handler that changes the document or session directly instead of + dispatching a command id (allowlist: xtask/src/panel_commands.rs) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus one per optional web feature) test workspace tests (cargo nextest when installed, else cargo test), then doctests - ci the CI gate: fmt --check, clippy -D warnings, layers, scorecard --check, tests + ci the CI gate: fmt --check, clippy -D warnings, layers, panel-commands, scorecard --check, tests (nextest --profile ci), panic_hunt, doctests and wasm, with --locked --features heif (stops at first failure), then the i18n-coverage report corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest] @@ -61,6 +64,7 @@ fn main() -> ExitCode { let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), + Some("panel-commands") => panel_commands::run(&root()), Some("wasm") => cmd_wasm(), Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), @@ -279,6 +283,7 @@ fn cmd_ci() -> Result<(), String> { }), ), ("layers", Box::new(cmd_layers)), + ("panel-commands", Box::new(|| panel_commands::run(&root()))), ("scorecard", Box::new(|| scorecard::run(&root(), &["--check"]))), ("test", Box::new(|| cmd_gate_tests(false))), ("panic_hunt", Box::new(|| cmd_gate_tests(true))), diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs new file mode 100644 index 0000000000..eb929acfc7 --- /dev/null +++ b/xtask/src/panel_commands.rs @@ -0,0 +1,728 @@ +//! `cargo xtask panel-commands`: every panel button routes through a command id (AGENTS.md rule 1). +//! +//! The UI, CLI, control channel and MCP stay equivalent only while panels dispatch engine commands +//! by id (`app.run`, `menus::invoke`, `session.execute`, the shell's `UI_COMMANDS`). This check +//! scans the egui shell's source for click handlers that change the document or its history +//! directly instead. +//! +//! It is a lexical scanner, not a type checker (std only, no `syn`): +//! +//! * a **click handler** is the block of an `if` whose condition calls `.clicked()` (or +//! `double_clicked`, `secondary_clicked`, `triple_clicked`, `middle_clicked`, `changed`, +//! `dragged`, `drag_stopped`), reads a local bound by `let x = … .clicked() …;` in the same file, +//! or calls a widget function (one that takes a `Ui` and returns `bool`, like `color_readout`); +//! * inside it, a **direct mutation** is `active_mut()` (the mutable document state), +//! `Arc::make_mut` on a `doc`, `session.undo/redo/select_layer/add_document/close(…)`, or an +//! assignment to a session field (`app.session.tools.foreground = …`); +//! * test code is skipped: `*_tests.rs`, `tests.rs`, files under `tests/` and `#[cfg(test)]` items. +//! +//! Out of scope: preferences (`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and +//! switching the active document. Calls into helper functions are not followed, so a handler +//! that calls a helper which mutates the document passes; the check catches the inline pattern, +//! which is how such shortcuts start. Only `if` blocks (also a `match` arm guard with a block) +//! and `let` locals count as handlers: a `match` on a response without one, a response kept in a +//! struct field and tested later, or a closure callback is not scanned. +//! +//! Existing violations are listed in [`ALLOWLIST`] by file and enclosing function, each with the +//! reason it is still there. An entry that no longer matches anything is an error, so the list only +//! shrinks: fix the handler (dispatch a command id), then delete its entry. + +use std::path::{Path, PathBuf}; + +/// The shell source the check scans, relative to the workspace root. +pub const SCAN_ROOT: &str = "crates/ui-egui/src"; + +/// A known violation that is tolerated until its handler routes through a command. +#[derive(Debug, Clone, Copy)] +pub struct Allowed { + /// Path relative to the workspace root, with `/` separators. + pub file: &'static str, + /// Enclosing function of the click handler (`` outside any function). + pub func: &'static str, + /// Why it still mutates directly (and what the fix is). + pub reason: &'static str, +} + +/// Existing click handlers that change the document directly. Shrink only: each entry is a +/// follow-up (route it through an engine command), never a place to park new code. +pub const ALLOWLIST: &[Allowed] = &[ + // Window › Color: the colour field and hue strip (and their readout) assign the foreground or + // background colour on every drag frame, so the change never reaches the journal, action + // recording or the control channel's event log. Fix: dispatch `tools.setColors` (it already exists). + Allowed { + file: "crates/ui-egui/src/panels/color.rs", + func: "color_field", + reason: "Color panel field, hue strip and readout assign session.tools.foreground/background; dispatch tools.setColors", + }, +]; + +/// A click handler that changes the document or its history without a command id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + pub file: String, + pub line: usize, + pub func: String, + pub what: String, +} + +const TRIGGERS: &[&str] = &["clicked", "double_clicked", "secondary_clicked", "triple_clicked", "middle_clicked", "changed", "dragged", "drag_stopped"]; + +/// Session methods that change documents or history behind the command registry's back. +const SESSION_MUTATORS: &[&str] = &["undo", "redo", "select_layer", "add_document", "close"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Kind { + Ident(String), + Punct(char), + /// Literals and lifetimes: never part of a pattern. + Other, +} + +#[derive(Debug, Clone)] +struct Token { + kind: Kind, + line: usize, +} + +impl Token { + fn is_ident(&self, s: &str) -> bool { + matches!(&self.kind, Kind::Ident(i) if i == s) + } + fn is_punct(&self, c: char) -> bool { + self.kind == Kind::Punct(c) + } + fn ident(&self) -> Option<&str> { + match &self.kind { + Kind::Ident(i) => Some(i), + _ => None, + } + } +} + +/// Split Rust source into identifiers and punctuation, dropping comments and literals. +fn lex(src: &str) -> Vec { + let c: Vec = src.chars().collect(); + let at = |i: usize| c.get(i).copied().unwrap_or('\0'); + let mut out = Vec::new(); + let mut line = 1; + let mut i = 0; + while i < c.len() { + let ch = at(i); + if ch == '\n' { + line += 1; + i += 1; + } else if ch.is_whitespace() { + i += 1; + } else if ch == '/' && at(i + 1) == '/' { + while i < c.len() && at(i) != '\n' { + i += 1; + } + } else if ch == '/' && at(i + 1) == '*' { + (i, line) = skip_block_comment(&c, i, line); + } else if let Some(hashes) = raw_string_start(&c, i) { + // r"…", r#"…"#, br#"…"#: skip to the quote followed by the same number of hashes. + let start_line = line; + i += hashes.prefix; + while i < c.len() { + if at(i) == '"' && (1..=hashes.count).all(|k| at(i + k) == '#') { + i += 1 + hashes.count; + break; + } + if at(i) == '\n' { + line += 1; + } + i += 1; + } + out.push(Token { kind: Kind::Other, line: start_line }); + } else if ch == '"' || (ch == 'b' && at(i + 1) == '"') || (ch == 'c' && at(i + 1) == '"') { + let start_line = line; + (i, line) = skip_string(&c, i + if ch == '"' { 1 } else { 2 }, line); + out.push(Token { kind: Kind::Other, line: start_line }); + } else if ch == '\'' || (ch == 'b' && at(i + 1) == '\'') { + // Char literal ('a', '\n', b'x') or lifetime/label ('a, 'static). + let q = if ch == 'b' { i + 1 } else { i }; + if at(q + 1) == '\\' { + i = q + 2; + while i < c.len() && at(i) != '\'' && at(i) != '\n' { + i += 1; + } + i += 1; + } else if at(q + 2) == '\'' { + i = q + 3; + } else { + i = q + 1; + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_') { + i += 1; + } + } + out.push(Token { kind: Kind::Other, line }); + } else if ch.is_alphabetic() || ch == '_' { + let s = i; + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_') { + i += 1; + } + out.push(Token { kind: Kind::Ident(c.get(s..i).unwrap_or_default().iter().collect()), line }); + } else if ch.is_ascii_digit() { + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_' || (at(i) == '.' && at(i + 1).is_ascii_digit())) { + i += 1; + } + out.push(Token { kind: Kind::Other, line }); + } else { + out.push(Token { kind: Kind::Punct(ch), line }); + i += 1; + } + } + out +} + +/// Skip a (nested) block comment starting at `i`; returns the index after it and the new line. +fn skip_block_comment(c: &[char], mut i: usize, mut line: usize) -> (usize, usize) { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + let mut depth = 0usize; + while i < c.len() { + if at(i) == '/' && at(i + 1) == '*' { + depth += 1; + i += 2; + } else if at(i) == '*' && at(i + 1) == '/' { + depth = depth.saturating_sub(1); + i += 2; + if depth == 0 { + break; + } + } else { + if at(i) == '\n' { + line += 1; + } + i += 1; + } + } + (i, line) +} + +/// Skip a quoted string body starting just after its opening quote; returns the index after the +/// closing quote and the new line. +fn skip_string(c: &[char], mut i: usize, mut line: usize) -> (usize, usize) { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + while i < c.len() { + match at(i) { + '\\' => { + // A line continuation (`\` before a newline) still ends a source line. + if at(i + 1) == '\n' { + line += 1; + } + i += 2; + } + '"' => { + i += 1; + break; + } + '\n' => { + line += 1; + i += 1; + } + _ => i += 1, + } + } + (i, line) +} + +struct RawStart { + /// Characters before the body (`r#"` is 3). + prefix: usize, + count: usize, +} + +fn raw_string_start(c: &[char], i: usize) -> Option { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + // Not the tail of an identifier such as `for` or `bar`. + if i > 0 && (at(i - 1).is_alphanumeric() || at(i - 1) == '_') { + return None; + } + let mut j = i; + if at(j) == 'b' || at(j) == 'c' { + j += 1; + } + if at(j) != 'r' { + return None; + } + j += 1; + let mut count = 0; + while at(j) == '#' { + count += 1; + j += 1; + } + (at(j) == '"').then_some(RawStart { prefix: j + 1 - i, count }) +} + +/// Index of the bracket closing the one at `open` (`{`, `(` or `[`), or the last token. +fn matching(toks: &[Token], open: usize) -> usize { + let mut depth = 0usize; + for (k, t) in toks.iter().enumerate().skip(open) { + match t.kind { + Kind::Punct('{' | '(' | '[') => depth += 1, + Kind::Punct('}' | ')' | ']') => { + depth = depth.saturating_sub(1); + if depth == 0 { + return k; + } + } + _ => {} + } + } + toks.len().saturating_sub(1) +} + +/// First `{` (or `;`) after `from` outside parentheses and brackets. +fn next_block_or_semi(toks: &[Token], from: usize) -> Option { + let mut depth = 0usize; + for (k, t) in toks.iter().enumerate().skip(from) { + match t.kind { + Kind::Punct('(' | '[') => depth += 1, + Kind::Punct(')' | ']') => depth = depth.saturating_sub(1), + Kind::Punct('{' | ';') if depth == 0 => return Some(k), + _ => {} + } + } + None +} + +/// Drop every item under `#[cfg(test)]` (or a `cfg(all(test, …))`), keeping `cfg(not(test))`. +fn strip_cfg_test(toks: Vec) -> Vec { + let mut out = Vec::with_capacity(toks.len()); + let mut i = 0; + while i < toks.len() { + if is_test_attr(&toks, i) { + // Skip this attribute, any further attributes, then the item itself. + let mut k = i; + while toks.get(k).is_some_and(|t| t.is_punct('#')) && toks.get(k + 1).is_some_and(|t| t.is_punct('[')) { + k = matching(&toks, k + 1) + 1; + } + i = match next_block_or_semi(&toks, k) { + Some(e) if toks.get(e).is_some_and(|t| t.is_punct('{')) => matching(&toks, e) + 1, + Some(e) => e + 1, + None => toks.len(), + }; + continue; + } + if let Some(t) = toks.get(i) { + out.push(t.clone()); + } + i += 1; + } + out +} + +fn is_test_attr(toks: &[Token], i: usize) -> bool { + let (Some(hash), Some(open), Some(cfg)) = (toks.get(i), toks.get(i + 1), toks.get(i + 2)) else { return false }; + if !hash.is_punct('#') || !open.is_punct('[') || !cfg.is_ident("cfg") { + return false; + } + let close = matching(toks, i + 1); + let inner = toks.get(i + 3..close).unwrap_or_default(); + inner.iter().any(|t| t.is_ident("test")) && !inner.iter().any(|t| t.is_ident("not")) +} + +/// `(body open, body close, name)` of every function with a body. +fn functions(toks: &[Token]) -> Vec<(usize, usize, String)> { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("fn") { + continue; + } + let Some(name) = toks.get(i + 1).and_then(Token::ident) else { continue }; + if let Some(open) = next_block_or_semi(toks, i + 2).filter(|&o| toks.get(o).is_some_and(|t| t.is_punct('{'))) { + out.push((open, matching(toks, open), name.to_string())); + } + } + out +} + +/// Does `toks` call `.trigger(` for one of the click triggers? +fn calls_trigger(toks: &[Token]) -> bool { + toks.windows(3).any(|w| { + w.first().is_some_and(|t| t.is_punct('.')) + && w.get(1).and_then(Token::ident).is_some_and(|n| TRIGGERS.contains(&n)) + && w.get(2).is_some_and(|t| t.is_punct('(')) + }) +} + +/// Locals bound by `let name = … .clicked() …;`. +fn click_locals(toks: &[Token]) -> Vec { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("let") { + continue; + } + let mut n = i + 1; + if toks.get(n).is_some_and(|t| t.is_ident("mut")) { + n += 1; + } + let (Some(name), Some(eq)) = (toks.get(n).and_then(Token::ident), toks.get(n + 1)) else { continue }; + if !eq.is_punct('=') { + continue; + } + let Some(end) = next_block_or_semi(toks, n + 2) else { continue }; + // A `{` before the `;` (a block or closure value) is out of scope for this heuristic. + if toks.get(end).is_some_and(|t| t.is_punct(';')) && calls_trigger(toks.get(n + 2..end).unwrap_or_default()) { + out.push(name.to_string()); + } + } + out +} + +/// Direct document/history mutations in `toks`: `(token index, description)`. +fn mutations(toks: &[Token]) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + let next = |k: usize| toks.get(i + k); + let Some(name) = t.ident() else { continue }; + let call = next(1).is_some_and(|t| t.is_punct('(')); + if name == "active_mut" && call && i > 0 && toks.get(i - 1).is_some_and(|p| p.is_punct('.')) { + out.push((i, "`active_mut()` changes the document directly".to_string())); + } else if name == "make_mut" && call { + let close = matching(toks, i + 1); + let args = toks.get(i + 2..close).unwrap_or_default(); + if args.iter().any(|a| a.ident().is_some_and(|n| n == "doc" || n.ends_with("_doc") || n == "document")) { + out.push((i, "`Arc::make_mut` on a document changes it directly".to_string())); + } + } else if name == "session" + && next(1).is_some_and(|t| t.is_punct('.')) + && let Some(m) = next(2).and_then(Token::ident).filter(|m| SESSION_MUTATORS.contains(m)) + && next(3).is_some_and(|t| t.is_punct('(')) + { + out.push((i, format!("`session.{m}()` bypasses the command registry"))); + } else if name == "session" + && let Some(field) = assigned_field(toks, i) + { + out.push((i, format!("`session.{field} = …` changes session state directly"))); + } + } + out +} + +/// `a.b` when `toks[i]` (`session`) starts `session.a.b = …` or a compound assignment (`+=`, …). +fn assigned_field(toks: &[Token], i: usize) -> Option { + let mut path = Vec::new(); + let mut k = i + 1; + while toks.get(k).is_some_and(|t| t.is_punct('.')) { + path.push(toks.get(k + 1)?.ident()?.to_string()); + k += 2; + } + let first = toks.get(k)?; + let second = toks.get(k + 1); + let plain = first.is_punct('=') && !second.is_some_and(|t| t.is_punct('=') || t.is_punct('>')); + let compound = matches!(first.kind, Kind::Punct('+' | '-' | '*' | '/' | '|' | '&' | '^')) && second.is_some_and(|t| t.is_punct('=')); + (!path.is_empty() && (plain || compound)).then(|| path.join(".")) +} + +/// Names of functions that draw a widget and report an edit: they take a `Ui` and return `bool` +/// (`fn color_readout(ui: &mut Ui, …) -> bool`). `if widget(…) { … }` is a click handler too. +pub fn widget_fns(src: &str) -> Vec { + let toks = strip_cfg_test(lex(src)); + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("fn") { + continue; + } + let Some(name) = toks.get(i + 1).and_then(Token::ident) else { continue }; + let Some(open) = toks.iter().enumerate().skip(i + 2).find(|(_, t)| t.is_punct('(')).map(|(k, _)| k) else { continue }; + let close = matching(&toks, open); + let takes_ui = toks.get(open..close).unwrap_or_default().iter().any(|t| t.is_ident("Ui")); + let returns_bool = toks.get(close + 1).is_some_and(|t| t.is_punct('-')) + && toks.get(close + 2).is_some_and(|t| t.is_punct('>')) + && toks.get(close + 3).is_some_and(|t| t.is_ident("bool")); + if takes_ui && returns_bool && !out.iter().any(|n| n == name) { + out.push(name.to_string()); + } + } + out +} + +/// Every click handler in `src` that changes the document directly. `file` labels the results. +/// `widgets` lists the widget functions of the whole tree (see [`widget_fns`]). +pub fn scan(file: &str, src: &str, widgets: &[String]) -> Vec { + let toks = strip_cfg_test(lex(src)); + let fns = functions(&toks); + let locals = click_locals(&toks); + let mut out: Vec = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("if") { + continue; + } + let Some(open) = next_block_or_semi(&toks, i + 1) else { continue }; + if !toks.get(open).is_some_and(|t| t.is_punct('{')) { + continue; + } + let cond = toks.get(i + 1..open).unwrap_or_default(); + let is_click = calls_trigger(cond) + || cond.iter().any(|c| c.ident().is_some_and(|n| locals.iter().any(|l| l == n))) + || cond + .windows(2) + .any(|w| w.first().and_then(Token::ident).is_some_and(|n| widgets.iter().any(|f| f == n)) && w.get(1).is_some_and(|t| t.is_punct('('))); + if !is_click { + continue; + } + let close = matching(&toks, open); + let body = toks.get(open..=close).unwrap_or_default(); + for (k, what) in mutations(body) { + let at = open + k; + let line = toks.get(at).map_or(0, |t| t.line); + let func = fns.iter().filter(|(o, c, _)| *o < at && at < *c).min_by_key(|(o, c, _)| c - o).map_or("", |f| f.2.as_str()); + let v = Violation { file: file.to_string(), line, func: func.to_string(), what }; + if !out.contains(&v) { + out.push(v); + } + } + } + out.sort_by_key(|v| v.line); + out +} + +/// Is `path` (relative, `/`-separated) test code? +fn is_test_file(path: &str) -> bool { + path.ends_with("_tests.rs") || path.ends_with("/tests.rs") || path == "tests.rs" || path.contains("/tests/") || path.starts_with("tests/") +} + +/// Violations not covered by `allow`, and allowlist entries that match nothing (stale). +pub fn check<'a>(violations: &[Violation], allow: &'a [Allowed]) -> (Vec, Vec<&'a Allowed>) { + let covered = |v: &Violation, a: &Allowed| v.file == a.file && v.func == a.func; + let new = violations.iter().filter(|v| !allow.iter().any(|a| covered(v, a))).cloned().collect(); + let stale = allow.iter().filter(|a| !violations.iter().any(|v| covered(v, a))).collect(); + (new, stale) +} + +fn rust_files(dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for e in entries { + let p = e.map_err(|e| format!("{}: {e}", dir.display()))?.path(); + if p.is_dir() { + rust_files(&p, out)?; + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } + Ok(()) +} + +/// Scan the shell source under `root` (the workspace root). +pub fn scan_tree(root: &Path) -> Result, String> { + let base = root.join(SCAN_ROOT); + let mut files = Vec::new(); + rust_files(&base, &mut files)?; + files.sort(); + let mut sources = Vec::new(); + for f in files { + let rel = f.strip_prefix(&base).map_err(|e| e.to_string())?.to_string_lossy().replace('\\', "/"); + if is_test_file(&rel) { + continue; + } + let src = std::fs::read_to_string(&f).map_err(|e| format!("{}: {e}", f.display()))?; + sources.push((format!("{SCAN_ROOT}/{rel}"), src)); + } + let mut widgets = Vec::new(); + for (_, src) in &sources { + for name in widget_fns(src) { + if !widgets.contains(&name) { + widgets.push(name); + } + } + } + Ok(sources.iter().flat_map(|(file, src)| scan(file, src, &widgets)).collect()) +} + +pub fn run(root: &Path) -> Result<(), String> { + let violations = scan_tree(root)?; + let (new, stale) = check(&violations, ALLOWLIST); + println!("Panel command routing (AGENTS.md rule 1): click handlers in {SCAN_ROOT} must dispatch a command id\n"); + println!("{} direct mutation(s) in click handlers, {} allowlisted function(s)", violations.len(), ALLOWLIST.len()); + for v in &new { + println!(" {}:{}: in `{}`: {}; dispatch an engine command by id instead", v.file, v.line, v.func, v.what); + } + for a in ALLOWLIST.iter().filter(|a| !stale.iter().any(|s| s.file == a.file && s.func == a.func)) { + println!(" allowlisted {} `{}`: {}", a.file, a.func, a.reason); + } + for a in &stale { + println!(" stale allowlist entry {} `{}`: no direct mutation left there; delete it from xtask/src/panel_commands.rs", a.file, a.func); + } + if new.is_empty() && stale.is_empty() { + println!("OK: every other click handler routes through a command id."); + Ok(()) + } else { + Err(format!("{} unrouted click handler(s), {} stale allowlist entr(y/ies)", new.len(), stale.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(src: &str) -> Vec<(usize, String)> { + scan("f.rs", src, &[]).into_iter().map(|v| (v.line, v.func)).collect() + } + + #[test] + fn routed_button_passes() { + let src = r#" +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("Flatten").clicked() { + let _ = app.run("layer.flatten", json!({})); + } + if ui.button("Undo").clicked() { + menus::invoke(app, ctx, "edit.undo", Value::Null); + } +} +"#; + assert!(scan("f.rs", src, &[]).is_empty()); + } + + #[test] + fn direct_mutation_fails_with_file_line_and_function() { + let src = r#" +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("Hide").clicked() { + if let Some(st) = app.session.active_mut() { + st.active_layer = None; + } + } +} +"#; + let v = scan("crates/ui-egui/src/x.rs", src, &[]); + assert_eq!(v.len(), 1); + assert_eq!((v[0].file.as_str(), v[0].line, v[0].func.as_str()), ("crates/ui-egui/src/x.rs", 4, "panel")); + assert!(v[0].what.contains("active_mut")); + } + + #[test] + fn line_continuations_in_strings_keep_line_numbers() { + let src = " +fn panel(app: &mut App, ui: &mut Ui) { + ui.label(\"first \\ + second \\ + third\"); + if ui.button(\"Hide\").clicked() { + if let Some(st) = app.session.active_mut() { + st.active_layer = None; + } + } +} +"; + let v = scan("f.rs", src, &[]); + assert_eq!(v.len(), 1); + assert_eq!(v[0].line, 7); + } + + #[test] + fn a_match_arm_guard_with_a_block_is_a_click_handler() { + let src = " +fn panel(app: &mut App, ui: &mut Ui) { + match ui.button(\"Hide\") { + r if r.clicked() => { + app.session.active_mut(); + } + _ => {} + } +} +"; + assert_eq!(lines(src), [(5, "panel".to_string())]); + } + + #[test] + fn every_mutation_kind_and_trigger_is_caught() { + let src = r#" +fn a(app: &mut App, ui: &mut Ui) { + if ui.button("x").secondary_clicked() { app.session.undo(); } + if resp.double_clicked() && ok { Arc::make_mut(&mut st.doc).layers.clear(); } + let pressed = ui.button("y").clicked(); + if pressed { app.session.close(0); } + if slider.changed() { app.session.select_layer(id); } +} +"#; + assert_eq!(lines(src), vec![(3, "a".into()), (4, "a".into()), (6, "a".into()), (7, "a".into())]); + } + + #[test] + fn mutation_outside_click_handlers_test_code_comments_and_strings_pass() { + let src = r##" +fn sync(app: &mut App) { + if let Some(st) = app.session.active_mut() { st.dirty = true; } +} +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("x").clicked() { + // app.session.active_mut() + let s = "app.session.undo()"; + let r = r#"session.close(0)"#; + let c = '{'; + let _ = app.run("edit.undo", Value::Null); + } + if ui.button("y").clicked() { theme(Arc::make_mut(&mut fonts)); } +} +#[cfg(test)] +mod tests { + fn t(app: &mut App, ui: &mut Ui) { + if ui.button("x").clicked() { app.session.undo(); } + } +} +"##; + assert!(scan("f.rs", src, &[]).is_empty(), "{:?}", scan("f.rs", src, &[])); + } + + #[test] + fn widget_functions_and_session_assignments_count() { + let widgets = widget_fns("fn color_readout(ui: &mut egui::Ui, c: &mut [f32; 4]) -> bool { false }\nfn label(ui: &mut Ui) {}"); + assert_eq!(widgets, vec!["color_readout".to_string()]); + let src = r#" +fn field(app: &mut App, ui: &mut Ui) { + if color_readout(ui, &mut c) { app.session.tools.foreground = c; } + if r.dragged() { app.session.tools.brush.size += 1.0; } + if r.clicked() { let same = app.session.tools.foreground == c; app.ui.show = true; } + if r.clicked() { let _ = app.run("tools.setColors", json!({"foreground": "red"})); } +} +"#; + let v = scan("f.rs", src, &widgets); + assert_eq!(v.iter().map(|v| v.line).collect::>(), vec![3, 4]); + assert!(v[0].what.contains("session.tools.foreground"), "{}", v[0].what); + assert!(v[1].what.contains("session.tools.brush.size"), "{}", v[1].what); + } + + #[test] + fn cfg_not_test_is_scanned_and_lifetimes_do_not_confuse_the_lexer() { + let src = r#" +#[cfg(not(test))] +fn panel<'a>(app: &'a mut App, ui: &mut Ui) { + if ui.button("x").clicked() { app.session.redo(); } +} +"#; + assert_eq!(lines(src), vec![(4, "panel".into())]); + } + + #[test] + fn allowlisted_passes_and_stale_entry_is_reported() { + let v = vec![Violation { file: "a.rs".into(), line: 3, func: "panel".into(), what: "x".into() }]; + let allow = [Allowed { file: "a.rs", func: "panel", reason: "known" }, Allowed { file: "a.rs", func: "gone", reason: "fixed already" }]; + let (new, stale) = check(&v, &allow); + assert!(new.is_empty()); + assert_eq!(stale.len(), 1); + assert_eq!(stale[0].func, "gone"); + let (new, stale) = check(&v, &[]); + assert_eq!(new, v); + assert!(stale.is_empty()); + } + + #[test] + fn test_files_are_recognised() { + assert!(is_test_file("panels_tests.rs")); + assert!(is_test_file("file_dialog/tests.rs")); + assert!(is_test_file("discard_ui/tests/tiff_tests.rs")); + assert!(!is_test_file("panels.rs")); + assert!(!is_test_file("tests_ui.rs")); + } + + #[test] + fn the_allowlist_has_reasons_and_no_duplicates() { + for (i, a) in ALLOWLIST.iter().enumerate() { + assert!(!a.reason.trim().is_empty(), "{} `{}` needs a reason", a.file, a.func); + assert!(a.file.starts_with(SCAN_ROOT), "{} is outside {SCAN_ROOT}", a.file); + assert!(!ALLOWLIST.iter().skip(i + 1).any(|b| b.file == a.file && b.func == a.func), "duplicate {} `{}`", a.file, a.func); + } + } +}