diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 34e00db22d..1455316ec5 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,7 +27,7 @@ next to them. | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -39,6 +39,7 @@ next to them. | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 076f0a1e98..def94cc8d9 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,9 +15,10 @@ pub const USAGE: &str = "\ photocraft-cli: headless PhotoCraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). - TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). + TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data); + --tiff-compression compresses it (none, the default; lzw; zip). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. @@ -26,10 +27,10 @@ USAGE: (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -73,20 +74,20 @@ struct Subcommand { const SUBCOMMANDS: &[Subcommand] = &[ Subcommand { name: "convert", - values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], + values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression"], bare: &["--tiff-layers", "--dds-mipmaps"], run: convert, }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression"], bare: &["--tiff-layers", "--dds-mipmaps"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression"], bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], run: batch, }, @@ -226,6 +227,14 @@ fn export_opts(a: &Args) -> Result { o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); } + if let Some(c) = a.get("--tiff-compression") { + o.encode.tiff_compression = match c { + "none" => photocraft_codecs::TiffCompression::None, + "lzw" => photocraft_codecs::TiffCompression::Lzw, + "zip" => photocraft_codecs::TiffCompression::Deflate, + _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")), + }; + } if let Some(c) = a.get("--dds-compression") { o.encode.dds.compression = photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; diff --git a/assets/icons/align-edge-bottom.svg b/assets/icons/align-edge-bottom.svg new file mode 100644 index 0000000000..e2993c8305 --- /dev/null +++ b/assets/icons/align-edge-bottom.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-hcenter.svg b/assets/icons/align-edge-hcenter.svg new file mode 100644 index 0000000000..264b4fe9bb --- /dev/null +++ b/assets/icons/align-edge-hcenter.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-left.svg b/assets/icons/align-edge-left.svg new file mode 100644 index 0000000000..192310fd3f --- /dev/null +++ b/assets/icons/align-edge-left.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-right.svg b/assets/icons/align-edge-right.svg new file mode 100644 index 0000000000..0386cf7475 --- /dev/null +++ b/assets/icons/align-edge-right.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-top.svg b/assets/icons/align-edge-top.svg new file mode 100644 index 0000000000..54d4bdce21 --- /dev/null +++ b/assets/icons/align-edge-top.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-vcenter.svg b/assets/icons/align-edge-vcenter.svg new file mode 100644 index 0000000000..6df5d35096 --- /dev/null +++ b/assets/icons/align-edge-vcenter.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/distribute-bottom.svg b/assets/icons/distribute-bottom.svg new file mode 100644 index 0000000000..20d3b31c33 --- /dev/null +++ b/assets/icons/distribute-bottom.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-hcenter.svg b/assets/icons/distribute-hcenter.svg new file mode 100644 index 0000000000..34faee88f4 --- /dev/null +++ b/assets/icons/distribute-hcenter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/assets/icons/distribute-horizontal.svg b/assets/icons/distribute-horizontal.svg new file mode 100644 index 0000000000..fef7592891 --- /dev/null +++ b/assets/icons/distribute-horizontal.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/distribute-left.svg b/assets/icons/distribute-left.svg new file mode 100644 index 0000000000..075305c0c0 --- /dev/null +++ b/assets/icons/distribute-left.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-right.svg b/assets/icons/distribute-right.svg new file mode 100644 index 0000000000..659d58fbee --- /dev/null +++ b/assets/icons/distribute-right.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-top.svg b/assets/icons/distribute-top.svg new file mode 100644 index 0000000000..1f02d74c9b --- /dev/null +++ b/assets/icons/distribute-top.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-vcenter.svg b/assets/icons/distribute-vcenter.svg new file mode 100644 index 0000000000..4d1734a083 --- /dev/null +++ b/assets/icons/distribute-vcenter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/assets/icons/distribute-vertical.svg b/assets/icons/distribute-vertical.svg new file mode 100644 index 0000000000..fdc7c6abd2 --- /dev/null +++ b/assets/icons/distribute-vertical.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index 75fa9a4bfb..87f8415b99 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -897,14 +897,39 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o label[i] = img as i32; } } - if no == 0 || nn == no { + if no == 0 { continue; } - let (ox, oy) = (sx / no as f64, sy / no as f64); let (cx, cy) = (nx / nn as f64, ny / nn as f64); + let contained = nn == no; + let (ox, oy) = if contained { + // Wholly inside the composite (#5745: two photos in one document, the second moved + // by Auto-Align so the canvas crops it to the part over the first). Its side of the + // seam is the one it lies towards from the composite's centre; an image centred on + // the composite has no side, and the earlier images keep it. + let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize); + for (i, l) in label.iter().enumerate() { + if *l >= 0 { + px += (i % w) as f64; + py += (i / w) as f64; + pn += 1; + } + } + let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64); + if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 { + continue; + } + (px, py) + } else { + (sx / no as f64, sy / no as f64) + }; let vertical = (cx - ox).abs() >= (cy - oy).abs(); // Image on the increasing side along the seam's cross axis? let positive = if vertical { cx > ox } else { cy > oy }; + // A contained image has no pixels of its own, so a seam on its outer edge would leave it + // nothing: break cost ties away from its side (towards larger k when it lies on the + // decreasing side) so it keeps its side of the overlap. + let prefer_high = contained && !positive; let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() }; let (lines, len) = if vertical { (h, w) } else { (w, h) }; let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line }; @@ -925,7 +950,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o let mut best = (0.0f32, usize::MAX); if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) { best = (f32::INFINITY, usize::MAX); - for dk in [-1i64, 0, 1] { + let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] }; + for dk in dks { let kk = k as i64 + dk; if kk < 0 || kk >= len as i64 { continue; @@ -952,7 +978,12 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o if seam[line] != usize::MAX { continue; } - let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue }; + let Some(k) = (0..len) + .filter(|&k| overlap[idx(line, k)]) + .min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) })) + else { + continue; + }; let (mut l, mut k) = (line, k); loop { seam[l] = k; @@ -972,9 +1003,13 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o if s == usize::MAX { continue; } + let on_side = |k: usize| if positive { k > s } else { k < s }; + // A contained image has no other cells, so where the overlap has nothing on its side + // of the seam (a line one cell wide) it takes the seam cell rather than nothing. + let take_seam = contained && !(0..len).any(|k| overlap[idx(line, k)] && on_side(k)); for k in 0..len { let i = idx(line, k); - if overlap[i] && ((positive && k > s) || (!positive && k < s)) { + if overlap[i] && (on_side(k) || (take_seam && k == s)) { label[i] = img as i32; } } @@ -1585,6 +1620,58 @@ mod tests { assert!(max_jump < 0.1, "max step {max_jump}"); assert!(cov.iter().all(|c| *c == 1.0)); } + + #[test] + fn an_image_wholly_inside_the_composite_still_gets_its_side() { + // #5745: the first image covers the whole 120×40 grid, the second only its right part + // (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything. + let (w, h) = (120usize, 40usize); + let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]]; + let cover: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()]; + let labels = seam_labels(w, h, &luma, &cover, &[0, 1]); + for y in 0..h { + assert_eq!(labels[y * w + 5], 0, "row {y}"); + assert_eq!(labels[y * w + w - 1], 1, "row {y}"); + // The second image's region is one run at the right of its seam. + let row = &labels[y * w..(y + 1) * w]; + let first = row.iter().position(|l| *l == 1).unwrap(); + assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}"); + } + // The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap + // instead of on the image's outer edge, which used to leave it nothing. + let left: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()]; + let labels = seam_labels(w, h, &luma, &left, &[0, 1]); + for y in 0..h { + let row = &labels[y * w..(y + 1) * w]; + assert_eq!(row[0], 1, "row {y}: {row:?}"); + assert_eq!(row[w - 1], 0, "row {y}"); + let last = row.iter().rposition(|l| *l == 1).unwrap(); + assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}"); + } + let (tw, th) = (40usize, 120usize); + let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]]; + let top: Vec> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()]; + let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]); + for x in 0..tw { + assert_eq!(labels[x], 1, "column {x}"); + assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}"); + } + // One column or row wide: the seam can only run through it, and the image keeps it. + for col in [0, w - 1] { + let strip: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w == col).collect()]; + let labels = seam_labels(w, h, &luma, &strip, &[0, 1]); + assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i % w == col)), "column {col}: {labels:?}"); + } + let strip: Vec> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw == 0).collect()]; + let labels = seam_labels(tw, th, &tluma, &strip, &[0, 1]); + assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i / tw == 0)), "{labels:?}"); + // An image centred on the composite has no side: the first image keeps the overlap. + let centred: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()]; + assert!(seam_labels(w, h, &luma, ¢red, &[0, 1]).iter().all(|l| *l == 0)); + // Two images covering the same cells, likewise. + let same = vec![vec![true; w * h]; 2]; + assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0)); + } } /// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index 45081950f7..d4cc56b2bd 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -151,12 +151,20 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { ) } -/// Native-channel pixels of a layer over `area` (transparent outside its content). -fn pixels(l: &Layer, area: Rect) -> Option<(Vec, photocraft_color::PixelFormat)> { - let s = l.surface()?; - Some((s.read_region(area), s.format())) +/// The most memory Auto-Blend's working buffers may take past the canvas: a bound independent +/// of the layers' own bounding boxes, which a sparse layer (a dot at each far corner) can make +/// arbitrarily large, and of how many layers are blended. +const MAX_BLEND_BYTES: u64 = 4 << 30; + +/// What Auto-Blend's buffers hold per work-area pixel, as f32: each layer's pixels, luminance, +/// alpha and mask weight, plus the merged picture and the depth map. +fn blend_bytes_per_pixel(layers: u64, channels: u64) -> u64 { + layers.saturating_mul(channels.saturating_add(3)).saturating_add(channels.saturating_mul(2)).saturating_mul(4) } +/// Auto-Blend's buffers, for allocation errors. +const BLEND_BUFFERS: &str = "Auto-Blend Layers"; + fn auto_blend(s: &mut Session, p: &Value) -> Result { let cmd = "edit.autoBlendLayers"; let ids = selected_pixel_layers(s); @@ -181,13 +189,24 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { } let st = s.active().ok_or(EngineError::NoDocument)?; let doc: std::sync::Arc = st.doc.clone(); - let mut area = Rect::EMPTY; + let mut union = Rect::EMPTY; + let mut content_px = 0u64; + let px_of = |r: &Rect| u64::from(r.width()) * u64::from(r.height()); for id in &ids { if let Some(b) = doc.layer(*id).and_then(|l| l.surface()).map(|s| s.content_bounds()) { - area = area.union(&b); + union = union.union(&b); + content_px = content_px.saturating_add(px_of(&b)); } } - let area = area.intersect(&doc.bounds()); + // The whole layers, past the canvas too (#5745): Auto-Align leaves a panorama's layers + // partly off the canvas, and Photoshop masks and blends all of each layer, so Reveal All + // shows the whole panorama. Layers so far apart that their bounds are mostly empty, or + // whose buffers would exceed `MAX_BLEND_BYTES`, keep to the canvas. + let canvas = doc.bounds(); + let channels = ids.iter().filter_map(|id| doc.layer(*id).and_then(|l| l.surface()).map(|s| s.channels() as u64)).max().unwrap_or(4); + let per_px = blend_bytes_per_pixel(ids.len() as u64, channels); + let fits = px_of(&union).saturating_mul(per_px) <= MAX_BLEND_BYTES && px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2); + let area = if fits { union } else { union.intersect(&canvas) }; if area.is_empty() { return Err(EngineError::Other("the selected layers are empty".into())); } @@ -198,9 +217,10 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let mut fmt = None; for id in &ids { let l = doc.layer(*id).ok_or(EngineError::NoLayer(*id))?; - let (px, f) = pixels(l, area).ok_or(EngineError::NoLayer(*id))?; + let surf = l.surface().ok_or(EngineError::NoLayer(*id))?; + let (px, f) = (crate::allocation::read_region(surf, area, BLEND_BUFFERS)?, surf.format()); let n = f.channels(); - let (mut lv, mut av) = (vec![0.0f32; w * h], vec![0.0f32; w * h]); + let (mut lv, mut av) = (crate::allocation::filled(w * h, 0.0f32, BLEND_BUFFERS)?, crate::allocation::filled(w * h, 0.0f32, BLEND_BUFFERS)?); for i in 0..w * h { let c = to_rgba(&f, &px[i * n..(i + 1) * n]); lv[i] = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; @@ -228,7 +248,9 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let surfs: Vec = ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::>()?; let order = seam_order(&surfs, 0); - let b = seam_blend(&surfs, area, &order, seamless, false, None); + // Seamless Tones and Colors also evens out exposure and colour between the layers (gain + // compensation over their overlaps), as in Photomerge. + let b = seam_blend(&surfs, area, &order, seamless, seamless, None); let weights: Vec> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect(); let blended = seamless.then(|| b.composite.read_region(area)); (weights, blended) @@ -476,6 +498,133 @@ mod tests { assert!(s.active().unwrap().doc.is_background(bg), "undo restores the Background"); } + /// Seamless Tones and Colors evens out a 10% exposure step between two panorama layers. + #[test] + fn auto_blend_panorama_seamless_matches_exposure() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + let mut ids = Vec::new(); + for (name, x0, x1, gain) in [("left", 0, 130, 1.0f32), ("right", 70, 200, 1.1)] { + s.execute("layer.new.layer", json!({ "name": name })).unwrap(); + s.edit(name, |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x0, 0, x1, 60), &[0.2 * gain, 0.4 * gain, 0.5 * gain, 1.0]); + Ok(()) + }) + .unwrap(); + ids.push(s.active().unwrap().active_layer.unwrap()); + } + select(&mut s, &ids); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap(); + let (a, b) = (blended.pixel(10, 30), blended.pixel(190, 30)); + // 0.5 vs 0.55 without gain compensation. + assert!((a[2] - b[2]).abs() < 0.015, "{a:?} {b:?}"); + } + + /// #5745: the top layer hangs off the canvas's right edge, so cropped to the canvas it lies + /// wholly over the bottom one. It used to get an all-black mask; now it shows on its side. + #[test] + fn auto_blend_panorama_of_a_layer_the_canvas_crops_to_all_overlap() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "left"})).unwrap(); + s.edit("l", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let left = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("r", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[left, right]); + s.execute("edit.autoBlendLayers", json!({"method": "panorama", "seamlessTones": false})).unwrap(); + let doc = &s.active().unwrap().doc; + let ml = doc.layer(left).unwrap().mask.as_ref().unwrap(); + let mr = doc.layer(right).unwrap().mask.as_ref().unwrap(); + assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0)); + assert_eq!((ml.value(195, 30), mr.value(195, 30)), (0.0, 1.0)); + for x in 0..200 { + assert_eq!(ml.value(x, 30) + mr.value(x, 30), 1.0, "x {x} shows one layer"); + } + } + + /// #5745: a layer Auto-Align left partly off the canvas is masked and blended over all of + /// its bounds, not cut off at the canvas edge. + #[test] + fn auto_blend_panorama_masks_past_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "left"})).unwrap(); + s.edit("l", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 150, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let left = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("r", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[left, right]); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let ml = doc.layer(left).unwrap().mask.as_ref().unwrap(); + let mr = doc.layer(right).unwrap().mask.as_ref().unwrap(); + assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0)); + assert_eq!((ml.value(300, 30), mr.value(300, 30)), (0.0, 1.0), "off the canvas"); + let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap(); + assert_eq!(blended.pixel(300, 30)[3], 1.0, "the merged layer covers the off-canvas part"); + } + + /// A sparse layer whose bounds are huge (a dot at each far corner) raises its own content + /// estimate, so the extent past the canvas is capped independently: it keeps to the canvas. + #[test] + fn auto_blend_sparse_huge_bounds_keep_to_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "a"})).unwrap(); + s.edit("a", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 150, 200), &[0.2, 0.4, 0.5, 1.0]); + surf.fill_rect(Rect::new(100_000, 100_000, 100_001, 100_001), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let a = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "b"})).unwrap(); + s.edit("b", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 0, 200, 200), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let b = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[a, b]); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let bb = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap().content_bounds(); + assert!(bb.x1 <= 200 && bb.y1 <= 200, "{bb:?}"); + } + + /// The budget past the canvas counts every layer's buffers, not just the work-area pixels. + #[test] + fn auto_blend_budget_counts_layers_and_channels() { + // Two RGBA layers: 4 bytes × (2 layers × (4 channels + luma, alpha, weight) + merged + depth). + assert_eq!(blend_bytes_per_pixel(2, 4), 88); + assert!(blend_bytes_per_pixel(5, 4) > blend_bytes_per_pixel(2, 4)); + // A 2^27-pixel area would need about 11 GiB for just two RGBA layers: it keeps to the canvas. + assert!((1u64 << 27).saturating_mul(blend_bytes_per_pixel(2, 4)) > MAX_BLEND_BYTES); + assert_eq!(blend_bytes_per_pixel(u64::MAX, 4), u64::MAX, "saturates instead of overflowing"); + } + #[test] fn auto_blend_stack_without_seamless() { let mut s = Session::new(); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 8df9be10fa..bf9d4247e1 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -973,6 +973,17 @@ fn preserves_roundness(p: &Value) -> bool { p.get("preserve").and_then(Value::as_str) == Some("roundness") } +/// Whether any of the boolean `flags` is on in `p` (present, not null and not `false`). +fn flag_set(p: &Value, flags: &[&str]) -> bool { + flags.iter().any(|f| p.get(*f).is_some_and(|v| !v.is_null() && *v != Value::Bool(false))) +} + +/// Select > Modify with "Apply effect at canvas bounds": the descriptor has no field for it, so such a +/// step is not exported (it would replay without the option). +fn applies_at_canvas_bounds(p: &Value) -> bool { + flag_set(p, &["applyAtCanvasBounds"]) +} + /// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's /// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such /// a step can't be exported faithfully. @@ -2290,6 +2301,14 @@ fn fill_to(id: &str, p: &Value) -> Option { d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); // `source` is the command's older name for `contents`. d = if p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str) == Some("pattern") { + // `Pttn` names no pattern and holds no placement: a chosen pattern, a scale, an angle + // or a phase would replay with the current pattern at its defaults (#7543, #7544, + // #7545, #7546). + let num = |k: &str, default: f64| p.get(k).is_some_and(|v| !v.is_null() && v.as_f64() != Some(default)); + let phase = p.get("phase").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| a.iter().any(|c| c.as_f64() != Some(0.0)))); + if p.get("pattern").is_some_and(|v| !v.is_null()) || num("scale", 100.0) || num("angle", 0.0) || phase { + return None; + } d.with("Usng", enm("FlCn", "Pttn")) } else { // An explicit colour overrides the foreground (#7199): it travels as Fill's `Clr `. @@ -3416,10 +3435,10 @@ const EVENTS: &[Event] = &[ }, // Selection. ev!("Invs", "inverse", "Inverse", "select.inverse", &[]), - ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)]), - ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)]), - ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)]), - ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)]), + ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)], unless applies_at_canvas_bounds), + ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)], unless applies_at_canvas_bounds), + ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)], unless applies_at_canvas_bounds), + ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)], unless applies_at_canvas_bounds), ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]), ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 2eeafaecb5..7d864b4cb9 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1758,6 +1758,33 @@ fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry( } } +/// #7540-#7542: Feather, Contract and Smooth (and Expand) with Apply Effect at Canvas Bounds, and +/// #7543-#7546: a pattern Paint Bucket with a chosen pattern, scale, angle or phase, can't be +/// written, so strict export refuses them. Their defaults still export and round-trip. +#[test] +fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + for id in ["select.modify.feather", "select.modify.expand", "select.modify.contract", "select.modify.smooth"] { + assert!(export("Audit7", &[&one(id, json!({"radius": 2, "applyAtCanvasBounds": true}))], &[], false).is_err(), "{id}"); + let plain = json!({"radius": 2, "applyAtCanvasBounds": false}); + let (bytes, _) = export("Audit7", &[&one(id, plain)], &[], false).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![(id.to_string(), json!({"radius": 2}))]); + } + let bucket = |extra: Value| { + let mut p = json!({"x": 5, "y": 5, "contents": "pattern", "opacity": 70, "tolerance": 80}); + if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) { + p.extend(e.clone()); + } + one("paint.bucket", p) + }; + for extra in [json!({"pattern": "AuditPattern"}), json!({"scale": 50}), json!({"angle": 30}), json!({"phase": [3, 5]})] { + assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_err(), "{extra}"); + } + for extra in [json!({}), json!({"scale": 100, "angle": 0, "phase": [0, 0]})] { + assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_ok(), "{extra}"); + } +} + /// Photoshop's Close has no document target: a Close of a specific document must not be /// exported as a plain Close, which would close the active document on playback. #[test] diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 0bb2b78928..199af85901 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -309,6 +309,8 @@ pub(crate) struct SaveOpts { pub tga_bits: Option, /// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16. pub dpx_bits: Option, + /// TIFF: the image compression (`"tiffCompression": "none" | "lzw" | "zip"`); unset, none. + pub tiff_compression: Option, /// DDS: the compression and whether to write the mip chain (`"ddsCompression"`, /// `"ddsMipmaps"`); unset, uncompressed without mipmaps. pub dds: photocraft_io::DdsOptions, @@ -326,6 +328,7 @@ impl Default for SaveOpts { tiff_layers: false, tga_bits: None, dpx_bits: None, + tiff_compression: None, dds: photocraft_io::DdsOptions::default(), image_previews: true, psd_maximize_compatibility: true, @@ -345,6 +348,7 @@ impl SaveOpts { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, dpx_bits: dpx_bits_param(p, cmd)?, + tiff_compression: tiff_compression_param(p, cmd)?, dds: dds_param(p, cmd)?.unwrap_or_default(), image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, @@ -377,6 +381,21 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + use photocraft_codecs::TiffCompression; + match p.get("tiffCompression") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_str() { + Some("none") => Ok(Some(TiffCompression::None)), + Some("lzw") => Ok(Some(TiffCompression::Lzw)), + Some("zip") => Ok(Some(TiffCompression::Deflate)), + _ => Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("`tiffCompression` must be \"none\", \"lzw\" or \"zip\", got {v}") }), + }, + } +} + /// The DPX bits per sample in `p` (`"dpxBits": 8 | 10 | 12 | 16`; absent or null: the document's /// depth). Anything else is a bad parameter of command `cmd`. pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result> { @@ -431,6 +450,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); opts.encode.dpx_bits = save.dpx_bits; + if let Some(c) = save.tiff_compression { + opts.encode.tiff_compression = c; + } opts.encode.dds = save.dds; if let Some(q) = save.quality { let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; @@ -566,6 +588,16 @@ pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { } } +/// Edit › Preferences › Raw Defaults › Resolution: the ppi a raw developed on open gets, as +/// Camera Raw's workflow options set it (the raw itself states none). `warnings` are the +/// import's notes, which say whether the raw was developed or only its preview opened. +pub fn raw_defaults(s: &Session, warnings: &[String], doc: &mut Document) { + let ppi = s.prefs().raw_defaults.resolution; + if ppi.is_finite() && warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)) { + doc.resolution_dpi = ppi.clamp(1.0, 10_000.0) as f32; + } +} + pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option) -> Result { crate::allocation::checkpoint("importing document")?; let decode_name = match as_ext { @@ -575,6 +607,7 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<& let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?; let mut doc = r.document; tag_exr_colorspace(s, &decode_name, &mut doc); + raw_defaults(s, &r.warnings, &mut doc); let path = match template_name(s, name) { Some(untitled) => { doc.name = untitled; @@ -1723,7 +1756,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##, native_doc, save_a_copy ), @@ -1786,7 +1819,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1795,7 +1828,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1823,7 +1856,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 913335afe1..fedd4a9612 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -92,6 +92,35 @@ fn batch_same_format_saves_camera_raws_as_psd() { } } +/// Edit › Preferences › Raw Defaults › Resolution sets the ppi a raw opens with, through +/// File › Open (the background open job) and through opening bytes; other files keep their own. +#[test] +fn a_developed_raw_opens_at_the_raw_defaults_resolution() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let (w, h) = (24, 16); + let raw = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build(); + let dir = tmp("raw-defaults"); + let path = join(&dir, "IMG_0002.dng"); + std::fs::write(&path, &raw).unwrap(); + let open = |s: &mut Session, path: &str| match s.start_open(path, crate::jobs::OpenSource::Path(path.into())).unwrap() { + crate::jobs::Started::Job(id) => { + s.wait_job(id).unwrap(); + } + crate::jobs::Started::Done(_) => {} + }; + let mut s = Session::new(); + open_bytes_as(&mut s, "IMG_0001.dng", &raw, None, None).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 300.0, "the default"); + s.execute("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap(); + open_bytes_as(&mut s, "IMG_0001.dng", &raw, None, None).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 240.0); + open(&mut s, &path); + assert_eq!(doc(&s).resolution_dpi, 240.0, "File › Open"); + let png = png(&dir, "a.png", 4, 4, "#ff0000"); + open(&mut s, &png); + assert_eq!(doc(&s).resolution_dpi, 72.0, "not a raw"); +} + #[test] fn revert_reloads_as_one_undoable_step() { let dir = tmp("revert"); @@ -1477,3 +1506,42 @@ fn folder_listing_recognises_gzipped_xcf_by_its_double_extension() { assert!(!super::openable_name("archive.tar.gz")); assert!(!super::openable_name("notes.txt")); } + +/// The Compression tag (259) of a TIFF's first directory. +fn tiff_compression_tag(b: &[u8]) -> Option { + let le = b.get(..2)? == b"II"; + let u16_at = |i: usize| b.get(i..i + 2).map(|s| if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }); + let u32_at = + |i: usize| b.get(i..i + 4).map(|s| if le { u32::from_le_bytes([s[0], s[1], s[2], s[3]]) } else { u32::from_be_bytes([s[0], s[1], s[2], s[3]]) }); + let ifd = u32_at(4)? as usize; + (0..usize::from(u16_at(ifd)?)).map(|k| ifd + 2 + 12 * k).find(|e| u16_at(*e) == Some(259)).and_then(|e| u16_at(e + 8)) +} + +/// #5301: `tiffCompression` picks a TIFF's compression (TIFF Options: None, LZW, ZIP); unset, +/// none. Anything else is refused before anything is written. +#[test] +fn save_a_copy_writes_the_tiff_compression_asked_for() { + let dir = tmp("tiff-compression"); + for (params, tag) in + [(json!({}), 1), (json!({"tiffCompression": "none"}), 1), (json!({"tiffCompression": "lzw"}), 5), (json!({"tiffCompression": "zip"}), 8)] + { + let mut s = session(12, 6, 8); + let out = join(&dir, "a.tif"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!(tiff_compression_tag(&bytes), Some(tag), "{params}"); + assert_eq!(photocraft_io::import("a.tif", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); + } + let mut s = session(8, 8, 8); + let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty())); + let before = state(&s); + for bad in [json!("jpeg"), json!("LZW"), json!(5), json!(true)] { + let out = join(&dir, "bad.tif"); + let r = s.execute("file.saveACopy", json!({"path": out, "tiffCompression": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + assert_eq!(state(&s), before, "{bad}: the document is as it was"); + } +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 4c3d63297b..ab1e350be4 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -551,6 +551,7 @@ impl Session { move |s, mut r: photocraft_io::ImportResult| { // File Handling's ACEScg option, before the colour prompt looks at the profile. crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); + crate::file_cmds::raw_defaults(s, &r.warnings, &mut r.document); // Ask When Opening: held back until the prompt is answered (`take_held_open`). A // developed raw has no embedded profile to ask about. let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index f317ebeca6..7e50c4089e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -444,6 +444,8 @@ impl Default for HistoryLog { pub struct FileHandling { pub image_previews: Ask, pub lowercase_extension: bool, + /// File › Save a Copy suggests the document's own name rather than " copy" (#8080). + pub do_not_append_copy: bool, /// Saves run in the background while you keep working (on), or in the foreground under a /// modal progress dialog that blocks the app until the file is written (off). pub save_in_background: bool, @@ -467,11 +469,20 @@ pub struct FileHandling { pub recent_files: Vec, } +impl FileHandling { + /// What File › Save a Copy appends to the document's name: " copy", or nothing with Do not + /// append "copy" to filename when saving a copy (#8080). + pub fn copy_suffix(&self) -> &'static str { + if self.do_not_append_copy { "" } else { " copy" } + } +} + impl Default for FileHandling { fn default() -> Self { Self { image_previews: Ask::Always, lowercase_extension: true, + do_not_append_copy: false, save_in_background: true, autosave: true, autosave_minutes: 10, @@ -1063,7 +1074,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "enhancedControls.scrubbySliderAcceleration", "rawDefaults.colorSpace", "rawDefaults.bitDepth", - "rawDefaults.resolution", "rawDefaults.sharpenFor", "rawDefaults.openAsSmartObject", "rawDefaults.applyAutoTone", diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 2bd765673f..33383d1045 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -67,20 +67,20 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), - ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), - ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None), - ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), - ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), - ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), - ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), - ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), - ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), - ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), - ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), - ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), - ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), + ("window.theme.pro", "Pro", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.proMedium", "Pro Medium Gray", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.studio", "Studio", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.solarizedDark", "Solarized", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.adwaitaDark", "Adwaita", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.breezeDark", "Breeze", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.softDark", "Soft", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.proLightGray", "Pro Light Gray", &["Window", "Theme", "Light Theme"], None), + ("window.theme.proLight", "Pro Light", &["Window", "Theme", "Light Theme"], None), + ("window.theme.studioLight", "Studio", &["Window", "Theme", "Light Theme"], None), + ("window.theme.classic", "Classic", &["Window", "Theme", "Light Theme"], None), + ("window.theme.adwaita", "Adwaita", &["Window", "Theme", "Light Theme"], None), + ("window.theme.breezeLight", "Breeze", &["Window", "Theme", "Light Theme"], None), + ("window.theme.softLight", "Soft", &["Window", "Theme", "Light Theme"], None), ("window.theme.system", "Sync with system", &["Window", "Theme"], None), ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), diff --git a/crates/ui-egui/src/align_bar.rs b/crates/ui-egui/src/align_bar.rs new file mode 100644 index 0000000000..d663283e02 --- /dev/null +++ b/crates/ui-egui/src/align_bar.rs @@ -0,0 +1,76 @@ +//! The Move tool's Align and Distribute controls (Photoshop shows them whenever the Move tool is +//! active; they enable once enough layers are selected). Every button dispatches an existing +//! `layer.align.*` / `layer.distribute.*` command. + +use serde_json::json; + +use crate::theme::Tokens; +use crate::{PhotocraftApp, icons}; + +/// (command id, icon, label). The labels are the Layer menu's, already translated. +const ALIGN: [(&str, &str, &str); 6] = [ + ("layer.align.leftEdges", "align-edge-left", "Left Edges"), + ("layer.align.horizontalCenters", "align-edge-hcenter", "Horizontal Centers"), + ("layer.align.rightEdges", "align-edge-right", "Right Edges"), + ("layer.align.topEdges", "align-edge-top", "Top Edges"), + ("layer.align.verticalCenters", "align-edge-vcenter", "Vertical Centers"), + ("layer.align.bottomEdges", "align-edge-bottom", "Bottom Edges"), +]; + +const DISTRIBUTE: [(&str, &str, &str); 8] = [ + ("layer.distribute.topEdges", "distribute-top", "Top Edges"), + ("layer.distribute.verticalCenters", "distribute-vcenter", "Vertical Centers"), + ("layer.distribute.bottomEdges", "distribute-bottom", "Bottom Edges"), + ("layer.distribute.leftEdges", "distribute-left", "Left Edges"), + ("layer.distribute.horizontalCenters", "distribute-hcenter", "Horizontal Centers"), + ("layer.distribute.rightEdges", "distribute-right", "Right Edges"), + ("layer.distribute.horizontally", "distribute-horizontal", "Horizontally"), + ("layer.distribute.vertically", "distribute-vertical", "Vertically"), +]; + +/// The six align icons followed by a menu holding both groups with icons and names. +pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.spacing_mut().item_spacing.x = 0.0; + for (cmd, icon, label) in ALIGN { + let tip = format!("{}: {}", tl!("Align"), tl!(label)); + let on = app.session.is_enabled(cmd); + if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, &tip)).inner.clicked() { + let _ = app.run(cmd, json!({})); + } + } + ui.add_space(6.0); + let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute")); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(200.0); + let mut section = |ui: &mut egui::Ui, title: &str, items: &[(&str, &str, &str)]| { + ui.label(egui::RichText::new(title).small().color(t.text_dim)); + for (cmd, icon, label) in items { + let on = app.session.is_enabled(cmd); + let tint = if on { t.icon } else { t.text_dim }; + let button = egui::Button::image_and_text(icons::image(icon, 16.0, tint), tl!(label)); + if ui.add_enabled(on, button).clicked() { + let _ = app.run(cmd, json!({})); + ui.close(); + } + } + }; + section(ui, tl!("Align"), &ALIGN); + ui.separator(); + section(ui, tl!("Distribute"), &DISTRIBUTE); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_button_is_a_live_command_with_an_icon() { + let specs = photocraft_engine::command_specs(); + for (cmd, icon, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) { + assert!(specs.iter().any(|c| c.id == *cmd), "{cmd} is not a command"); + assert!(icons::exists(icon), "{icon} is not embedded"); + } + } +} diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 090c0a0b3c..0d37beaac7 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -469,12 +469,16 @@ fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) - fn replace_raw_document( s: &mut photocraft_engine::Session, doc_id: photocraft_doc::DocId, - r: photocraft_io::ImportResult, + mut r: photocraft_io::ImportResult, wb: bool, ) -> photocraft_engine::Result { let index = s.documents().iter().position(|st| st.doc.id == doc_id).ok_or_else(|| photocraft_engine::EngineError::Other("the raw document was closed".into()))?; let path = s.documents().get(index).and_then(|st| st.path.clone()); + // The resolution it opened with (Preferences › Raw Defaults), which re-developing keeps. + if let Some(st) = s.documents().get(index) { + r.document.resolution_dpi = st.doc.resolution_dpi; + } s.close(index); let (opened, _) = s.open_document(r.document, path); let index = match s.execute("document.move", json!({"document": opened, "to": index})) { @@ -1886,6 +1890,25 @@ mod tests { assert!(mean_red(&app) > before + 0.05, "{before} → {}", mean_red(&app)); } + /// #7789: Preferences › Raw Defaults › Resolution sets a raw's ppi when the app opens it + /// itself (no background job) and from automation, and a Camera Raw re-develop keeps it. + #[test] + fn a_raw_opens_and_redevelops_at_the_raw_defaults_resolution() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + 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(); + 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(); + assert_eq!(r["redeveloped"], true, "{r}"); + assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0, "kept by the re-develop"); + app.open_automation_bytes("IMG_0005.dng", &dng).unwrap(); + assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0, "automation open"); + } + #[test] fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { let (mut app, dng) = raw_app(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c197989685..0dd7d2ef69 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4926,8 +4926,9 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, pts.push(h); } } - // Just the outline and its rubber band: the vertices aren't handles to grab. - crate::tool_feedback::draw_ants(painter, &pts, false); + // Just the outline and its rubber band (the vertices aren't handles to grab), solid until + // the selection is made (#7495). + crate::tool_feedback::draw_solid_path(painter, &pts, false); } crate::crop_straighten::draw(app, painter, |p| xf.to_screen(p[0] as f32, p[1] as f32), hover); crate::blur_pins::draw(app, painter, xf); @@ -5204,7 +5205,13 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View // On pixel centres like the marquee's ants, so a 1 pt line stays crisp wherever the // canvas sits (a pixel edge would blur it to grey). let pts: Vec = pts.into_iter().map(|p| p.round() + vec2(0.5, 0.5)).collect(); - crate::tool_feedback::draw_ants(painter, &pts, false); + // The Lasso's outline is solid until the release makes the selection (#7495); a Patch + // or Content-Aware Move area keeps its ants. + if d.tool == Tool::Lasso { + crate::tool_feedback::draw_solid_path(painter, &pts, false); + } else { + crate::tool_feedback::draw_ants(painter, &pts, false); + } } Tool::Gradient => { let a = xf.to_screen(d.start[0] as f32, d.start[1] as f32); @@ -9078,3 +9085,71 @@ mod display_memo_tests; #[cfg(test)] #[path = "canvas/blend_viewport_tests.rs"] mod blend_viewport_regression; + +#[cfg(test)] +mod lasso_preview_tests { + use super::*; + + fn xf() -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 } + } + + /// What `draw` paints: (black dashes, black solid paths). + fn painted(app: &mut PhotocraftApp, draw: impl Fn(&mut PhotocraftApp, &egui::Painter)) -> (usize, usize) { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui.painter())); + output.textures_delta.clear(); + let mut flat = Vec::new(); + for clipped in output.shapes { + let mut stack = vec![clipped.shape]; + while let Some(shape) = stack.pop() { + match shape { + egui::Shape::Vec(v) => stack.extend(v), + s => flat.push(s), + } + } + } + let dashes = flat.iter().filter(|s| matches!(s, egui::Shape::LineSegment { stroke, .. } if stroke.color == Color32::BLACK)).count(); + let solid = flat.iter().filter(|s| matches!(s, egui::Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::BLACK))).count(); + (dashes, solid) + } + + fn document() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app + } + + /// #7495: as in Photoshop, the Lasso's, Polygonal Lasso's and Magnetic Lasso's outline is a + /// solid line until the selection is made; a Patch area dragged out keeps its marching ants. + #[test] + fn lasso_outlines_are_solid_until_the_selection_is_made() { + let points = vec![[20.0, 20.0, 1.0], [120.0, 30.0, 1.0], [150.0, 140.0, 1.0], [40.0, 160.0, 1.0]]; + for tool in [Tool::Lasso, Tool::Patch] { + let mut app = document(); + app.ui.tool = tool; + app.drag = Some(Drag::new(tool, [20.0, 20.0], points.clone(), egui::Modifiers::NONE, false)); + let (dashes, solid) = painted(&mut app, |app, p| draw_drag_preview(app, p, &xf())); + if tool == Tool::Lasso { + assert_eq!((dashes, solid), (0, 1), "{tool:?}: one solid outline, no ants"); + } else { + assert!(dashes > 10 && solid == 0, "{tool:?}: still marching ants ({dashes} dashes)"); + } + } + + let mut app = document(); + app.ui.tool = Tool::PolygonLasso; + app.ui.polygon = vec![[20.0, 20.0], [120.0, 30.0], [150.0, 140.0]]; + let (dashes, solid) = painted(&mut app, |app, p| draw_tool_state(app, p, &xf(), Some(pos2(60.0, 150.0)))); + assert_eq!((dashes, solid), (0, 1), "Polygonal Lasso: its sides and rubber band are one solid line"); + + let mut app = document(); + app.ui.tool = Tool::MagneticLasso; + app.ui.magnetic.path = vec![[20.0, 20.0], [80.0, 25.0], [120.0, 60.0]]; + app.ui.magnetic.anchors = vec![0, 2]; + app.ui.magnetic.live = vec![[120.0, 60.0], [130.0, 100.0]]; + let (dashes, solid) = painted(&mut app, |app, p| draw_tool_state(app, p, &xf(), None)); + assert_eq!((dashes, solid), (0, 1), "Magnetic Lasso: the traced border is solid"); + } +} diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 058e81480f..a8545f10eb 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -888,6 +888,23 @@ fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) { spec_dialog(app, command, label, spec, fields); } +/// The Pen options bar's Make Selection (Path mode, #5703): the canvas menu's Make Selection… +/// dialog for the path being drawn (committed first, as the menu does) or the active path. +pub(crate) fn make_selection(app: &mut PhotocraftApp) -> Result<(), String> { + let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); + // Where `pen_commit` puts the path being drawn: a shape layer or the targeted vector mask, + // else the Paths panel path it adds to (a selected saved path, or the Work Path), which it + // selects so it is the active path. + let pending_layer = pending && (app.ui.tool_options.vector_mode == "shape" || crate::vector_ui::targeted_vector_mask(app).is_some()); + if pending { + crate::vector_ui::pen_commit(app, false); + } + let name = if pending_layer { Some("layer".to_string()) } else { crate::vector_ui::active_path_name(app) } + .ok_or_else(|| "No path is available for this action".to_string())?; + pen_dialog(app, "path.toSelection", "Make Selection…", &name); + Ok(()) +} + fn choose_pen(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { if command == "select.isolateLayers" { crate::menus::invoke(app, ctx, command, json!({}))?; @@ -1672,6 +1689,42 @@ mod tests { assert!(app.session.active().unwrap().doc.selection.is_some()); } + /// #5703: the Pen options bar's Make Selection (Path mode) opens the same dialog for the path + /// being drawn (finished into the Work Path first), and OK selects inside it; with no path at + /// all it reports that instead. + #[test] + fn pen_options_bar_makes_a_selection_from_the_path() { + let mut app = app(); + app.ui.tool = Tool::Pen; + assert!(make_selection(&mut app).is_err(), "no path yet"); + assert!(app.ui.dialogs.is_empty()); + for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { + crate::vector_ui::pen_down(&mut app, x, y, false); + crate::vector_ui::pen_up(&mut app); + } + make_selection(&mut app).unwrap(); + let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let st = app.session.active().unwrap(); + assert!(st.doc.work_path.is_some() && st.doc.selection.is_some()); + // The active (Work) path again, with no pen path in progress. + app.run("select.deselect", json!({})).unwrap(); + make_selection(&mut app).unwrap(); + let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some()); + // Drawn with a saved path selected, the path is added to that one (#4721): the dialog is + // for it, not for a Work Path. + app.run("path.rename", json!({"name": "work", "to": "Cutout"})).unwrap(); + app.ui.selected_path = Some("Cutout".into()); + for (x, y) in [(4.0, 24.0), (12.0, 24.0), (12.0, 30.0)] { + crate::vector_ui::pen_down(&mut app, x, y, false); + crate::vector_ui::pen_up(&mut app); + } + make_selection(&mut app).unwrap(); + assert_eq!(app.ui.dialogs.last().and_then(|d| d.fields.get("name")), Some(&json!("Cutout"))); + } + /// #1056: the Path Selection and Direct Selection tools' right-click opens the path menu for /// the selected path, as the Pen's does, instead of nothing. #[test] diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index bb827274ff..224fdb96cc 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1128,3 +1128,23 @@ fn save_of_a_flat_file_asks_after_the_dialog_when_another_app_changed_it() { assert!(!app.session.active().unwrap().is_dirty()); assert_eq!(app.session.journal.last().map(|s| s.0.as_str()), Some("file.save"), "still recorded as a plain Save"); } + +/// File › Save a Copy suggests " copy", and with Preferences › File Handling › Do not +/// append "copy" to filename when saving a copy, the document's own name, as in Photoshop +/// (#8080). The preference goes through `prefs.set` like the Preferences dialog's checkbox. +#[test] +fn save_a_copy_appends_copy_unless_the_preference_says_not_to() { + let ctx = egui::Context::default(); + for (off, name) in [(false, "My image copy.psd"), (true, "My image.psd")] { + let want = std::path::Path::new("/pics").join(name).to_string_lossy().into_owned(); + let (mut app, open, _) = jpeg_app(Some("/pics/My image.JPEG"), false); + app.run("prefs.set", json!({"path": "fileHandling.doNotAppendCopy", "value": off})).unwrap(); + menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!( + matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if *suggested == want), + "{off}: {:?}", + open.borrow().first().map(|(r, _)| r.clone()) + ); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 07e46b13c4..ea4efa65b5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3827,6 +3827,7 @@ navigator Zoom Zvětšení Chisel Hard Tvrdé tesání Chisel Soft Měkké tesání Open color-less EXR as ACEScg Otevřít EXR bez údaje o barevném prostoru jako ACEScg + Do not append "copy" to filename when saving a copy Nepřidávat „copy“ k názvu souboru při ukládání kopie # View › Guides › right-click a guide Edit Selected Guides Upravit vybraná vodítka @@ -3913,3 +3914,8 @@ navigator Zoom Zvětšení GPU adapter Adaptér GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Nejprve potvrďte nebo zrušte otevřený dialog kroku akce + Pro Medium Gray Pro (středně šedý) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (světle šedý) + Pro Light Pro (světlý) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6cce25b70b..3cb1cf5349 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3224,6 +3224,7 @@ curves Smooth Glätten Chisel Hard Meißeln hart Chisel Soft Meißeln weich Open color-less EXR as ACEScg Farbangabenloses EXR als ACEScg öffnen + Do not append "copy" to filename when saving a copy Beim Speichern einer Kopie „copy“ nicht an den Dateinamen anhängen # Export As metadata and profile options Color Profile Farbprofil EXIF EXIF @@ -3311,3 +3312,8 @@ curves Smooth Glätten GPU adapter GPU-Adapter # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Bestätige oder brich zuerst den offenen Dialog des Aktionsschritts ab + Pro Medium Gray Pro (Mittelgrau) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (Hellgrau) + Pro Light Pro (Hell) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ed11cce277..495604a155 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3914,6 +3914,7 @@ curves Smooth Εξομάλυνση Chisel Hard Σκληρή σμίλευση Chisel Soft Μαλακή σμίλευση Open color-less EXR as ACEScg Άνοιγμα EXR χωρίς χρωματική δήλωση ως ACEScg + Do not append "copy" to filename when saving a copy Να μην προστίθεται «copy» στο όνομα αρχείου κατά την αποθήκευση αντιγράφου # Export As metadata and profile options EXIF EXIF @@ -4002,3 +4003,8 @@ curves Smooth Εξομάλυνση GPU adapter Προσαρμογέας GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Επιβεβαιώστε ή ακυρώστε πρώτα το ανοιχτό παράθυρο βήματος της ενέργειας + Pro Medium Gray Pro μεσαίου γκρι + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro ανοιχτού γκρι + Pro Light Φωτεινό Pro diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2c657b0f9b..82d1ba9e21 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4177,6 +4177,7 @@ navigator Zoom Zoom Chisel Hard Cincelar duro Chisel Soft Cincelar suave Open color-less EXR as ACEScg Abrir EXR sin espacio de color como ACEScg + Do not append "copy" to filename when saving a copy No añadir «copy» al nombre de archivo al guardar una copia # Export As metadata and profile options # View › Guides › right-click a guide @@ -4264,3 +4265,8 @@ navigator Zoom Zoom GPU adapter Adaptador de GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Confirme o cancele primero el cuadro de diálogo abierto del paso de la acción + Pro Medium Gray Pro (gris medio) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (gris claro) + Pro Light Pro (claro) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6b1c01ddb8..bd7f8009ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3223,6 +3223,7 @@ curves Smooth Lisser Chisel Hard Ciselage dur Chisel Soft Ciselage doux Open color-less EXR as ACEScg Ouvrir les EXR sans espace colorimétrique en ACEScg + Do not append "copy" to filename when saving a copy Ne pas ajouter « copy » au nom de fichier lors de l’enregistrement d’une copie # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés @@ -3313,3 +3314,8 @@ curves Smooth Lisser GPU adapter Adaptateur GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Validez ou annulez d’abord la boîte de dialogue ouverte de l’étape de l’action + Pro Medium Gray Pro gris moyen + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro gris clair + Pro Light Pro clair diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b1e3143507..2469151be1 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4115,6 +4115,7 @@ cameraRaw Yellow Sárga Chisel Hard Kemény vésés Chisel Soft Lágy vésés Open color-less EXR as ACEScg Színinformáció nélküli EXR megnyitása ACEScg-ként + Do not append "copy" to filename when saving a copy Ne fűzze a „copy” szót a fájlnévhez másolat mentésekor # View › Guides › right-click a guide Edit Selected Guides Kijelölt segédvonalak szerkesztése @@ -4219,3 +4220,8 @@ cameraRaw Yellow Sárga GPU adapter GPU-adapter # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Előbb hagyd jóvá vagy vond vissza a művelet lépésének nyitott párbeszédablakát + Pro Medium Gray Pro középszürke + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro világosszürke + Pro Light Pro világos diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 497905c93b..a86ed7f26f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -795,6 +795,22 @@ Split Warp Vertically Bagi Warp Vertikal Statistics… Statistik… Refine Strokes Goresan penyempurnaan +# Counted status and panel messages +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} swatch diekspor ke {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} swatch diimpor dari {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} kuas diimpor dari {group} +@plural Played {n} step|Played {n} steps {n} langkah diputar +@plural {n} step|{n} steps {n} langkah +@plural Saved for Web: {n} file|Saved for Web: {n} files Disimpan untuk Web: {n} file +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} Smart Object tertaut yang berubah di disk telah diperbarui +@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} layer yang direkam oleh layer comp ini telah dihapus. Perbarui layer comp, atau hapus peringatannya. +@plural {n} plane|{n} planes {n} bidang +@plural {n} preset|{n} presets {n} preset +@plural {n} sample point removed|{n} sample points removed {n} titik sampel dihapus +@plural {n} slice|{n} slices {n} slice +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} peringatan preset kuas lainnya) +@plural {what} with {n} warning|{what} with {n} warnings {what} dengan {n} peringatan + @plural {n} item|{n} items {n} item Auto Otomatis (print to the printer) (cetak ke printer) @@ -3210,6 +3226,7 @@ curves Smooth Haluskan Chisel Hard Pahat Keras Chisel Soft Pahat Lembut Open color-less EXR as ACEScg Buka EXR tanpa informasi warna sebagai ACEScg + Do not append "copy" to filename when saving a copy Jangan tambahkan "copy" ke nama file saat menyimpan salinan # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih @@ -3300,3 +3317,8 @@ curves Smooth Haluskan GPU adapter Adaptor GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Konfirmasi atau batalkan dulu dialog langkah tindakan yang terbuka + Pro Medium Gray Pro Abu-abu Sedang + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro Abu-abu Terang + Pro Light Pro Terang diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index dbd07334ff..79142363c3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4179,6 +4179,7 @@ navigator Zoom Zoom Chisel Hard Cesello duro Chisel Soft Cesello morbido Open color-less EXR as ACEScg Apri gli EXR senza spazio colore come ACEScg + Do not append "copy" to filename when saving a copy Non aggiungere "copy" al nome del file quando si salva una copia # Color management, Liquify, glyph group, warp and Camera Raw labels the Serbian, Vietnamese and Japanese catalogs added Adobe-rgb-compat Adobe RGB (compatibile) @@ -4419,3 +4420,8 @@ shape Appearance Aspetto GPU adapter Adattatore GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Conferma o annulla prima la finestra di dialogo aperta del passo dell’azione + Pro Medium Gray Pro grigio medio + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro grigio chiaro + Pro Light Pro chiaro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 275bd096d1..3c4df72602 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3893,6 +3893,7 @@ curves Smooth スムーズ Chisel Hard チゼルハード Chisel Soft チゼルソフト Open color-less EXR as ACEScg 色空間のないEXRをACEScgとして開く + Do not append "copy" to filename when saving a copy コピーを保存するときにファイル名に「copy」を付けない # View › Guides › right-click a guide Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 @@ -3980,3 +3981,8 @@ curves Smooth スムーズ GPU adapter GPU アダプター # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first 先に開いているアクションのステップのダイアログを確定またはキャンセルしてください + Pro Medium Gray プロ(中間グレー) + Solarized Solarized + Breeze Breeze + Pro Light Gray プロ(ライトグレー) + Pro Light プロ(ライト) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e91a448d7f..8cf48914ee 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -789,6 +789,22 @@ Split Warp Vertically 뒤틀기 세로로 분할 Statistics… 통계… Refine Strokes 다듬기 획 +# Counted status and panel messages +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 색상 견본 {n}개를 {file}(으)로 내보냈습니다 +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}에서 색상 견본 {n}개를 가져왔습니다 +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group}에서 브러시 {n}개를 가져왔습니다 +@plural Played {n} step|Played {n} steps 단계 {n}개를 재생했습니다 +@plural {n} step|{n} steps 단계 {n}개 +@plural Saved for Web: {n} file|Saved for Web: {n} files 웹용으로 저장됨: 파일 {n}개 +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 디스크에서 변경된 연결된 스마트 오브젝트 {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 {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}개 + @plural {n} item|{n} items 항목 {n}개 Auto 자동 (print to the printer) (프린터로 인쇄) @@ -3400,6 +3416,7 @@ curves Smooth 매끄럽게 Chisel Hard 단단하게 조각 Chisel Soft 부드럽게 조각 Open color-less EXR as ACEScg 색 공간 정보 없는 EXR을 ACEScg로 열기 + Do not append "copy" to filename when saving a copy 사본 저장 시 파일 이름에 "copy"를 붙이지 않음 # Export As metadata and profile options Color Profile 색상 프로필 EXIF EXIF @@ -3407,6 +3424,7 @@ curves Smooth 매끄럽게 # View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 + Make Selection 선택 영역 만들기 Bicubic Automatic 쌍입방 자동 Bicubic Smoother 쌍입방 더 부드럽게 Bicubic Sharper 쌍입방 더 선명하게 @@ -3492,3 +3510,8 @@ curves Smooth 매끄럽게 GPU adapter GPU 어댑터 # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first 먼저 열려 있는 액션 단계 대화 상자를 확인하거나 취소하세요 + Pro Medium Gray 프로 중간 회색 + Solarized Solarized + Breeze Breeze + Pro Light Gray 프로 밝은 회색 + Pro Light 프로 밝게 diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 7d8d6457ef..e2150247b9 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -4,6 +4,13 @@ use super::*; use crate::{PhotocraftApp, Services}; use serde_json::json; +/// The File menu's title as the menu bar draws it in `lang`: the translation, plus the English +/// access key where it has no Latin letter of its own ("文件(F)", #5863). +fn file_title(lang: Lang) -> String { + let (shown, _) = crate::menu_keys::localized(&[tr(lang, "File").to_string()], &["File"], &[Some('F')]); + shown.into_iter().next().unwrap_or_default() +} + fn lang(code: &str) -> Lang { Lang::from_code(code).expect("registered language") } @@ -265,7 +272,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() { let h = harness(); assert_eq!(h.state().session.prefs().interface.language, "auto"); assert_eq!(current(), lang(code), "{tag}"); - assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang(code), "File")), "first-launch menu: {tag}"); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))), "first-launch menu: {tag}"); }); }); } @@ -316,7 +323,7 @@ fn an_explicit_language_survives_restart_on_a_different_system_language() { let h = harness_with_services(services); assert_eq!(h.state().session.prefs().interface.language, "ko"); assert_eq!(current(), lang("ko")); - assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang("ko"), "File"))); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang("ko")))); }); }); } @@ -335,7 +342,7 @@ fn a_control_request_changes_the_drawing_language_in_the_same_frame() { h.step(); assert_eq!(reply.try_recv().expect("response")["ok"], true); assert_eq!(current(), lang(code)); - assert!(drawn_text(&h).iter().any(|text| text == tr(lang(code), "File")), "the menu must change in this frame: {code}"); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))), "the menu must change in this frame: {code}"); assert!(std::sync::Arc::ptr_eq(&document, &h.state().session.active().expect("document").doc), "switching language must preserve the document"); } assert!(h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": []})).is_err()); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 39eaaeed59..3e7ea830e5 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -906,6 +906,54 @@ mod tests { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 1, 2, 2, 1]); } + /// Counted status messages read in Simplified Chinese instead of falling back to English (they were + /// missing from the catalog). + #[test] + fn simplified_chinese_counted_messages_are_translated() { + let lang = Lang::from_code("zh-hans").expect("zh-hans registered"); + assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "已播放 3 个步骤"); + let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); + assert_eq!(fmt(&brushes, &[("group", "水彩")]), "已从 水彩 导入 2 个画笔"); + assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 个切片"); + } + + /// Counted status messages read in Korean instead of falling back to English (they were + /// missing from the catalog). + #[test] + fn korean_counted_messages_are_translated() { + let lang = Lang::from_code("ko").expect("ko registered"); + assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "단계 3개를 재생했습니다"); + let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); + assert_eq!(fmt(&brushes, &[("group", "수채화")]), "수채화에서 브러시 2개를 가져왔습니다"); + assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "분할 영역 5개"); + } + + /// Counted status messages read in Indonesian instead of falling back to English (they were + /// missing from the catalog). + #[test] + fn indonesian_counted_messages_are_translated() { + let lang = Lang::from_code("id").expect("id registered"); + assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "3 langkah diputar"); + let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); + assert_eq!(fmt(&brushes, &[("group", "Cat Air")]), "2 kuas diimpor dari Cat Air"); + assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 slice"); + } + + /// Counted status messages read in Polish with all three forms instead of falling back to + /// English (they were missing from the catalog). + #[test] + fn polish_counted_messages_use_all_three_forms() { + let pl = Lang::from_code("pl").expect("pl registered"); + let steps = |n| trn(pl, n, "Played {n} step", "Played {n} steps"); + assert_eq!( + [steps(1), steps(3), steps(5), steps(12), steps(22)], + ["Odtworzono 1 krok", "Odtworzono 3 kroki", "Odtworzono 5 kroków", "Odtworzono 12 kroków", "Odtworzono 22 kroki"] + ); + let brushes = trn(pl, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); + assert_eq!(fmt(&brushes, &[("group", "Akwarele")]), "Zaimportowano 2 pędzle z Akwarele"); + assert_eq!(trn(pl, 5, "{n} slice", "{n} slices"), "5 plasterków"); + } + #[test] fn czech_plural_rule() { let forms: Vec = [0, 1, 2, 3, 4, 5, 11, 12, 21, 22, 100, u64::MAX].into_iter().map(plural_cs).collect(); diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 315771244e..70c2c4229c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3976,6 +3976,7 @@ curves Smooth Vloeiend maken Chisel Hard Beitel hard Chisel Soft Beitel zacht Open color-less EXR as ACEScg EXR zonder kleurruimte als ACEScg openen + Do not append "copy" to filename when saving a copy Geen "copy" aan de bestandsnaam toevoegen bij het opslaan van een kopie # View › Guides › right-click a guide Edit Selected Guides Geselecteerde hulplijnen bewerken @@ -4062,3 +4063,8 @@ curves Smooth Vloeiend maken GPU adapter GPU-adapter # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Bevestig of annuleer eerst het geopende dialoogvenster van de handelingsstap + Pro Medium Gray Pro middelgrijs + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro lichtgrijs + Pro Light Pro licht diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7e09d52e7d..9d65e242ba 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -811,6 +811,22 @@ Split Warp Vertically Podziel wypaczenie w pionie Statistics… Statystyki… Refine Strokes Pociągnięcia udoskonalania +# Counted status and panel messages (plural forms: 1 | 2–4 except 12–14 | the rest) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Wyeksportowano {n} próbkę koloru do {file}|Wyeksportowano {n} próbki kolorów do {file}|Wyeksportowano {n} próbek kolorów do {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Zaimportowano {n} próbkę koloru z {file}|Zaimportowano {n} próbki kolorów z {file}|Zaimportowano {n} próbek kolorów z {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Zaimportowano {n} pędzel z {group}|Zaimportowano {n} pędzle z {group}|Zaimportowano {n} pędzli z {group} +@plural Played {n} step|Played {n} steps Odtworzono {n} krok|Odtworzono {n} kroki|Odtworzono {n} kroków +@plural {n} step|{n} steps {n} krok|{n} kroki|{n} kroków +@plural Saved for Web: {n} file|Saved for Web: {n} files Zapisano dla Internetu: {n} plik|Zapisano dla Internetu: {n} pliki|Zapisano dla Internetu: {n} plików +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Zaktualizowano {n} połączony obiekt inteligentny zmieniony na dysku|Zaktualizowano {n} połączone obiekty inteligentne zmienione na dysku|Zaktualizowano {n} połączonych obiektów inteligentnych zmienionych na dysku +@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. Usunięto {n} warstwę zapisaną w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.|Usunięto {n} warstwy zapisane w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.|Usunięto {n} warstw zapisanych w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie. +@plural {n} plane|{n} planes {n} płaszczyzna|{n} płaszczyzny|{n} płaszczyzn +@plural {n} preset|{n} presets {n} ustawienie wstępne|{n} ustawienia wstępne|{n} ustawień wstępnych +@plural {n} sample point removed|{n} sample points removed Usunięto {n} punkt próbkowania|Usunięto {n} punkty próbkowania|Usunięto {n} punktów próbkowania +@plural {n} slice|{n} slices {n} plasterek|{n} plasterki|{n} plasterków +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} kolejne ostrzeżenie dotyczące ustawień wstępnych pędzli)|{warning} (+{n} kolejne ostrzeżenia dotyczące ustawień wstępnych pędzli)|{warning} (+{n} kolejnych ostrzeżeń dotyczących ustawień wstępnych pędzli) +@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} ostrzeżenie|{what}: {n} ostrzeżenia|{what}: {n} ostrzeżeń + @plural {n} item|{n} items {n} element|{n} elementy|{n} elementów Auto Automatycznie @@ -3814,6 +3830,7 @@ curves Smooth Wygładź Chisel Hard Dłuto twarde Chisel Soft Dłuto miękkie Open color-less EXR as ACEScg Otwieraj EXR bez profilu koloru jako ACEScg + Do not append "copy" to filename when saving a copy Nie dodawaj „copy” do nazwy pliku przy zapisywaniu kopii # View › Guides › right-click a guide Edit Selected Guides Edytuj zaznaczone prowadnice @@ -3902,3 +3919,8 @@ curves Smooth Wygładź GPU adapter Karta GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Najpierw zatwierdź lub anuluj otwarte okno kroku operacji + Pro Medium Gray Pro (średni szary) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (jasnoszary) + Pro Light Pro (jasny) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b69718a151..255d48655a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4006,6 +4006,7 @@ layerLabel No Color Sem cor Chisel Hard Cinzel duro Chisel Soft Cinzel suave Open color-less EXR as ACEScg Abrir EXR sem espaço de cor como ACEScg + Do not append "copy" to filename when saving a copy Não acrescentar "copy" ao nome do arquivo ao salvar uma cópia # View › Guides › right-click a guide Edit Selected Guides Editar guias selecionadas @@ -4092,3 +4093,157 @@ layerLabel No Color Sem cor GPU adapter Adaptador de GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Confirme ou cancele primeiro a caixa de diálogo aberta da etapa da ação + +# Channels panel menu: channel commands, spot channels, Quick Mask, Color Indicates + New Channel… Novo canal… + Duplicate Channel Duplicar canal + Delete Channel Excluir canal + Rename Channel… Renomear canal… + New Spot Channel… Novo canal de cor especial… + Merge Spot Channel Mesclar canal de cor especial + Convert to Alpha Channel Converter em canal alfa + Split Channels Dividir canais + Merge Channels… Mesclar canais… + Exit Quick Mask Sair da máscara rápida + Color Indicates Masked Areas A cor indica áreas mascaradas + Color Indicates Selected Areas A cor indica áreas selecionadas + +# Layer Style › Blending Options: advanced blending and Blend If + General Blending Mesclagem geral + Shallow Raso + Deep Profundo + Blend Interior Effects as Group Mesclar efeitos internos como grupo + Blend Clipped Layers as Group Mesclar camadas recortadas como grupo + Transparency Shapes Layer A transparência define a forma da camada + Layer Mask Hides Effects A máscara de camada oculta os efeitos + Vector Mask Hides Effects A máscara vetorial oculta os efeitos + This Layer: Esta camada: + Underlying Layer: Camada subjacente: + +# Canvas right-click menus: pasteboard color, paths and shapes + Dark Gray Cinza-escuro + Medium Gray Cinza médio + Select Custom Color… Selecionar cor personalizada… + Color Picker (Custom Canvas Color) Seletor de cores (Cor personalizada da tela de pintura) + Make Work Path… Criar demarcador de trabalho… + Create Vector Mask Criar máscara vetorial + Make Selection… Fazer seleção… + Fill Path… Preencher demarcador… + Clipping Path… Demarcador de corte… + Free Transform Path Transformação livre do demarcador + Unite Shapes at Overlap Unir formas na sobreposição + Subtract Shapes at Overlap Subtrair formas na sobreposição + Copy Fill Copiar preenchimento + Copy Complete Stroke Copiar traçado completo + Paste Fill Colar preenchimento + Paste Complete Stroke Colar traçado completo + Make Symmetry Path Criar demarcador de simetria + Disable Symmetry Path Desativar demarcador de simetria + +# Color Picker titles + Color Picker (Foreground Color) Seletor de cores (Cor de primeiro plano) + Color Picker (Background Color) Seletor de cores (Cor de fundo) + Pick Color From Image Escolher cor da imagem + +# Rasterize prompts + This layer must be rasterized before proceeding. Esta camada precisa ser rasterizada antes de continuar. + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Esta camada de texto precisa ser rasterizada antes de continuar. O texto não poderá mais ser editado. Rasterizar o texto? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Esta camada de forma precisa ser rasterizada antes de continuar. Ela não poderá mais ser editada como forma. Rasterizar a forma? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Este objeto inteligente precisa ser rasterizado antes de continuar. O conteúdo não poderá mais ser editado. Rasterizar o objeto inteligente? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Esta camada de preenchimento precisa ser rasterizada antes de continuar. O conteúdo do preenchimento não poderá mais ser editado. Rasterizar a camada? + +# GPU and display status (status bar, System Info) + Graphics adapter: {adapter} Adaptador gráfico: {adapter} + Backend: {backend} ({type}) Backend: {backend} ({type}) + Driver: {driver} Driver: {driver} + GPU backend preference: {backend} Preferência de backend da GPU: {backend} + Selected at launch: {backend} Selecionado ao iniciar: {backend} + Image compositor: {processor} Compositor de imagens: {processor} + Window renderer: {renderer} Renderizador da janela: {renderer} + Graphics adapter Adaptador gráfico + Software adapter Adaptador de software + unknown type tipo desconhecido + Fallback: {reason} Modo alternativo: {reason} + This session: {reason} Nesta sessão: {reason} + Platform: {os} {arch} Plataforma: {os} {arch} + GPU retry scheduled Nova tentativa da GPU agendada + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Salve seu trabalho e reinicie o PhotoCraft para tentar a aceleração por GPU novamente. A renderização pela CPU continua ativa nesta sessão. + GPU device was lost; using the CPU renderer. O dispositivo da GPU foi perdido; usando o renderizador da CPU. + GPU error; using the CPU renderer. Erro da GPU; usando o renderizador da CPU. + Monitor profile: {profile} Perfil do monitor: {profile} + Monitor profile in use: {profile} Perfil do monitor em uso: {profile} + Monitor profile in use on {display}: {profile} Perfil do monitor em uso em {display}: {profile} + Monitor profile on {display}: {profile} Perfil do monitor em {display}: {profile} + Monitor profile on {display} (main window): {profile} Perfil do monitor em {display} (janela principal): {profile} + Display profiles: {reason} Perfis de tela: {reason} + Display profile not used Perfil de tela não usado + previous reading kept; the last re-read failed: {reason} leitura anterior mantida; a última releitura falhou: {reason} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. A tela de pintura é exibida como sRGB. Escolha um perfil de monitor em Editar › Configurações de cores. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. A tela de pintura em {display} é exibida como sRGB. Escolha um perfil de monitor em Editar › Configurações de cores. + +# Status messages + Nothing to paste: the clipboard holds no image Nada para colar: a área de transferência não contém imagem + Opened {url} Aberto: {url} + Preferences saved Preferências salvas + Preferences were reset: {error} As preferências foram redefinidas: {error} + Couldn't save preferences: {error}. Retrying… Não foi possível salvar as preferências: {error}. Tentando novamente… + Preferences can't be saved Não é possível salvar as preferências + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. O PhotoCraft continua tentando; até que um salvamento dê certo, as alterações nas preferências serão perdidas ao fechar. + Invalid panel layout; restored the default dock Layout de painéis inválido; a disposição padrão dos painéis foi restaurada + Recovered {name} Recuperado: {name} + Autosave failed: {error} Falha ao salvar automaticamente: {error} + Exported presets to {path} Predefinições exportadas para {path} + Ran script {name} Script executado: {name} + Sent to the printer ({spooler}) Enviado para a impressora ({spooler}) + Printed to {path} Impresso em {path} + Exported {path} ({size}) Exportado: {path} ({size}) + Exported {path} Exportado: {path} + Saved a copy as {path} Cópia salva como {path} + Exported contents to {path} Conteúdo exportado para {path} + Saved {path}; image assets: {n} in {dir} Salvo: {path}; recursos de imagem: {n} em {dir} + Couldn't open {name}: {error} Não foi possível abrir {name}: {error} + Added swatch {name} Amostra adicionada: {name} + {message} (notes: {n}; {warning}) {message} (observações: {n}; {warning}) + Step {step} ({id}) failed: {error} Falha na etapa {step} ({id}): {error} + Could not rename the action: {error} Não foi possível renomear a ação: {error} + Action playback failed: {error} Falha ao reproduzir a ação: {error} + Clone source set at {x}, {y} Origem da clonagem definida em {x}, {y} + Color management: {error} Gerenciamento de cores: {error} + No custom shape "{name}": pick one in the options bar or Window › Shapes Nenhuma forma personalizada “{name}”: escolha uma na barra de opções ou em Janela › Formas + {label} is not available: {reason} {label} não está disponível: {reason} + Open Recent is unavailable on the web Abrir recentes não está disponível na web + Importing {what} from {file}… Importando {what} de {file}… + brushes pincéis + gradients degradês + This adjustment ({key}) is kept as imported and can't be edited yet. Este ajuste ({key}) é mantido como foi importado e ainda não pode ser editado. + {shortcut} assigned. {shortcut} atribuído. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} já está em uso por {commands} e será removido de lá quando você clicar em OK. + +# Panels and small controls + Grid view Visualização em grade + List view Visualização em lista + New Brush Preset Nova predefinição de pincel + Toggle the Brush Settings panel Alternar o painel Configurações do pincel + Scroll menu up Rolar o menu para cima + Scroll menu down Rolar o menu para baixo + {index} of {total} {index} de {total} + Data Set: {name} ({index}/{total}) Conjunto de dados: {name} ({index}/{total}) + (none) (nenhum) + Apply {option}: {state} Aplicar {option}: {state} + on ativado + off desativado + +# Liquify tools (tooltips) + Forward Warp Deformação para frente + Twirl Clockwise Torcer no sentido horário + Twirl Counterclockwise Torcer no sentido anti-horário + Pucker Franzir + Bloat Inflar + Push Left Empurrar para a esquerda + Freeze Mask Congelar máscara + Thaw Mask Descongelar máscara + Pro Medium Gray Pro cinza médio + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro cinza claro + Pro Light Pro claro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7518f34145..abf6fbd8f2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1087,6 +1087,7 @@ # Paragraph and character panels Font Size: Кегль: + Font size Кегль Type: Текст: Style Стиль Style: Normal Стиль: Обычный @@ -3188,6 +3189,7 @@ curves Smooth Сгладить Chisel Hard Жесткая огранка Chisel Soft Мягкая огранка Open color-less EXR as ACEScg Открывать EXR без цветового профиля как ACEScg + Do not append "copy" to filename when saving a copy Не добавлять «copy» к имени файла при сохранении копии # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие @@ -3280,3 +3282,8 @@ curves Smooth Сгладить GPU adapter Адаптер GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Сначала подтвердите или отмените открытый диалог шага операции + Pro Medium Gray Средне-серая Pro + Solarized Solarized + Breeze Breeze + Pro Light Gray Светло-серая Pro + Pro Light Светлая Pro diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 81410aa727..932c89c56f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4268,6 +4268,7 @@ shape Appearance Izgled Chisel Hard Tvrdo klesanje Chisel Soft Meko klesanje Open color-less EXR as ACEScg Otvori EXR bez podatka o bojama kao ACEScg + Do not append "copy" to filename when saving a copy Ne dodaji „copy“ u ime fajla pri čuvanju kopije Show home recent files Prikaži nedavne datoteke na početnom ekranu # View › Guides › right-click a guide @@ -4373,3 +4374,8 @@ shape Appearance Izgled GPU adapter GPU adapter # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Prvo potvrdite ili otkažite otvoreni dijalog koraka radnje + Pro Medium Gray Pro srednje siva + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro svetlosiva + Pro Light Pro svetla diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4564844589..5e23c9af7e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3928,6 +3928,7 @@ cameraData Lens Lens Chisel Hard Sert Keski Chisel Soft Yumuşak Keski Open color-less EXR as ACEScg Renk bilgisi olmayan EXR'yi ACEScg olarak aç + Do not append "copy" to filename when saving a copy Kopya kaydederken dosya adına "copy" ekleme # View › Guides › right-click a guide Edit Selected Guides Seçili Kılavuzları Düzenle @@ -4032,3 +4033,8 @@ cameraData Lens Lens GPU adapter GPU bağdaştırıcısı # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin + Pro Medium Gray Pro Orta Gri + Solarized Solarize + Breeze Esinti + Pro Light Gray Pro Açık Gri + Pro Light Profesyonel Işık diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ef28bb65cd..fc96584646 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3915,6 +3915,7 @@ curves Smooth Згладити Chisel Hard Жорстке різьблення Chisel Soft М'яке різьблення Open color-less EXR as ACEScg Відкривати EXR без колірного профілю як ACEScg + Do not append "copy" to filename when saving a copy Не додавати «copy» до імені файлу під час збереження копії # View › Guides › right-click a guide Edit Selected Guides Редагувати вибрані напрямні @@ -4011,3 +4012,8 @@ curves Smooth Згладити GPU adapter Адаптер GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Спочатку підтвердьте або скасуйте відкритий діалог кроку операції + Pro Medium Gray Середньосіра «Профі» + Solarized Solarized + Breeze Breeze + Pro Light Gray Світло-сіра «Профі» + Pro Light Світла «Профі» diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f5262075c5..cc61a3b750 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3686,6 +3686,7 @@ curves Smooth Làm mượt Chisel Hard Đục cứng Chisel Soft Đục mềm Open color-less EXR as ACEScg Mở EXR không khai báo màu sắc dưới dạng ACEScg + Do not append "copy" to filename when saving a copy Không thêm "copy" vào tên tệp khi lưu bản sao # View › Guides › right-click a guide Edit Selected Guides Sửa đường căn đã chọn @@ -3793,3 +3794,8 @@ curves Smooth Làm mượt GPU adapter Bộ điều hợp GPU # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Hãy xác nhận hoặc hủy hộp thoại bước hành động đang mở trước + Pro Medium Gray Pro xám vừa + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro xám nhạt + Pro Light Pro sáng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fe873c34c6..816e288c0f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -819,6 +819,22 @@ Split Warp Vertically 垂直分割变形 Statistics… 统计… Refine Strokes 调整描边 +# Counted status and panel messages +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已将 {n} 个色板导出到 {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已从 {file} 导入 {n} 个色板 +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已从 {group} 导入 {n} 个画笔 +@plural Played {n} step|Played {n} steps 已播放 {n} 个步骤 +@plural {n} step|{n} steps {n} 个步骤 +@plural Saved for Web: {n} file|Saved for Web: {n} files 已存储为 Web 所用格式:{n} 个文件 +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {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 {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} 条警告 + @plural {n} item|{n} items {n} 项 Auto 自动 (print to the printer) (输出到打印机) @@ -2842,6 +2858,7 @@ curves Smooth 平滑 Chisel Hard 雕刻清晰 Chisel Soft 雕刻柔和 Open color-less EXR as ACEScg 将无色彩空间信息的 EXR 以 ACEScg 打开 + Do not append "copy" to filename when saving a copy 存储副本时不在文件名后添加“copy” # Export As metadata and profile options Color Profile 颜色配置文件 EXIF EXIF @@ -2849,6 +2866,7 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Make Selection 建立选区 Bicubic Automatic 双三次(自动) Bicubic Smoother 双三次(较平滑) Bicubic Sharper 双三次(较锐利) @@ -2937,3 +2955,8 @@ curves Smooth 平滑 GPU adapter GPU 适配器 # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first 请先确认或取消打开的动作步骤对话框 + Pro Medium Gray 专业中灰 + Solarized Solarized + Breeze Breeze + Pro Light Gray 专业浅灰 + Pro Light 专业浅色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9bf3ad0263..fbd12a7fc7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2827,6 +2827,7 @@ curves Smooth 平滑 Chisel Hard 雕刻清晰 Chisel Soft 雕刻柔和 Open color-less EXR as ACEScg 將無色彩空間資訊的 EXR 以 ACEScg 開啟 + Do not append "copy" to filename when saving a copy 儲存副本時不在檔名後加上「copy」 # Export As metadata and profile options Color Profile 色彩描述檔 EXIF EXIF @@ -2834,6 +2835,7 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Make Selection 建立選取範圍 Bicubic Automatic 雙三次 (自動) Bicubic Smoother 雙三次 (較平滑) Bicubic Sharper 雙三次 (較銳利) @@ -2922,3 +2924,8 @@ curves Smooth 平滑 GPU adapter GPU 介面卡 # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first 請先確認或取消開啟中的動作步驟對話框 + Pro Medium Gray 專業中灰 + Solarized Solarized + Breeze Breeze + Pro Light Gray 專業淺灰 + Pro Light 專業淺色 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 049b097c08..db0a6f71b5 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -6,6 +6,20 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")), ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), ("align-right", include_bytes!("../../../assets/icons/align-right.svg")), + ("align-edge-bottom", include_bytes!("../../../assets/icons/align-edge-bottom.svg")), + ("align-edge-hcenter", include_bytes!("../../../assets/icons/align-edge-hcenter.svg")), + ("align-edge-left", include_bytes!("../../../assets/icons/align-edge-left.svg")), + ("align-edge-right", include_bytes!("../../../assets/icons/align-edge-right.svg")), + ("align-edge-top", include_bytes!("../../../assets/icons/align-edge-top.svg")), + ("align-edge-vcenter", include_bytes!("../../../assets/icons/align-edge-vcenter.svg")), + ("distribute-bottom", include_bytes!("../../../assets/icons/distribute-bottom.svg")), + ("distribute-hcenter", include_bytes!("../../../assets/icons/distribute-hcenter.svg")), + ("distribute-horizontal", include_bytes!("../../../assets/icons/distribute-horizontal.svg")), + ("distribute-left", include_bytes!("../../../assets/icons/distribute-left.svg")), + ("distribute-right", include_bytes!("../../../assets/icons/distribute-right.svg")), + ("distribute-top", include_bytes!("../../../assets/icons/distribute-top.svg")), + ("distribute-vcenter", include_bytes!("../../../assets/icons/distribute-vcenter.svg")), + ("distribute-vertical", include_bytes!("../../../assets/icons/distribute-vertical.svg")), ("app-window", include_bytes!("../../../assets/icons/app-window.svg")), ("artboard", include_bytes!("../../../assets/icons/artboard.svg")), ("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")), diff --git a/crates/ui-egui/src/layer_shortcut_tests.rs b/crates/ui-egui/src/layer_shortcut_tests.rs index fc9dd93570..64affc4789 100644 --- a/crates/ui-egui/src/layer_shortcut_tests.rs +++ b/crates/ui-egui/src/layer_shortcut_tests.rs @@ -208,3 +208,42 @@ fn custom_page_up_and_page_down_bindings_fire() { } assert_eq!(crate::shortcuts::parse("Shift+Pageup"), Some(egui::KeyboardShortcut::new(Modifiers::SHIFT, Key::PageUp))); } + +/// A click on the empty space below the last row of the Layers panel deselects every layer, as +/// in Photoshop (#8100); a click on a row still selects it. +#[test] +fn a_click_below_the_layers_deselects_them() { + use egui::{Event, PointerButton, pos2, vec2}; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let top = s.execute("layer.new.layer", json!({"name": "Top"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &r); + h.run_steps(8); + let click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + let selected = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().selected_layers(); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(top)]); + let rows = crate::layer_row_ui::recorded(&h.ctx); + let bottom = rows.iter().map(|r| r.row.bottom()).fold(f32::MIN, f32::max); + let row = rows.iter().find(|r| r.layer == top).unwrap().row; + click(&mut h, pos2(row.center().x, bottom + 40.0)); + assert!(selected(&h).is_empty(), "the empty space deselects every layer"); + assert_eq!(h.state().session.active().unwrap().active_layer, None); + // Past the double-click window, on the row as it is now drawn. + h.run_steps(40); + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == top).unwrap().row; + click(&mut h, pos2(row.left() + 46.0, row.center().y)); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(top)], "a row still selects"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 10591b0f22..25b16e9ab5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod adjust_dialog; pub mod adjust_editors; pub mod adjust_preview; pub mod adjust_ui; +mod align_bar; mod alt_grab; pub mod analysis_ui; pub mod anchor_tools; @@ -1520,7 +1521,9 @@ impl PhotocraftApp { } let import = self.services.import.as_ref().ok_or("no importer configured")?; let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; - let (doc, warnings) = import(name, bytes, max_svg_group_depth)?; + let (mut doc, warnings) = import(name, bytes, max_svg_group_depth)?; + // Preferences › Raw Defaults › Resolution, as the engine's open applies it. + photocraft_engine::file_cmds::raw_defaults(&self.session, &warnings, &mut doc); // Edit › Color Settings policies apply on open; Ask When Opening holds the file back // until its prompt is answered, and later files wait behind it (`open_prompt`). // Missing fonts are read from the document itself: it may not be open yet (#4559). @@ -1570,7 +1573,8 @@ impl PhotocraftApp { fn import_automation_document(&mut self, name: &str, bytes: &[u8]) -> Result, String> { let import = self.services.import.as_ref().ok_or("no importer configured")?; let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; - let (doc, warnings) = import(name, bytes, max_svg_group_depth)?; + let (mut doc, warnings) = import(name, bytes, max_svg_group_depth)?; + photocraft_engine::file_cmds::raw_defaults(&self.session, &warnings, &mut doc); // The caller records the path it read from. self.session.add_document(doc, None); if let Some(st) = self.session.active_mut() { diff --git a/crates/ui-egui/src/magnetic_lasso_ui.rs b/crates/ui-egui/src/magnetic_lasso_ui.rs index 90655368f3..28ef343212 100644 --- a/crates/ui-egui/src/magnetic_lasso_ui.rs +++ b/crates/ui-egui/src/magnetic_lasso_ui.rs @@ -420,7 +420,8 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, hover: } let screen = |q: &[f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let pts: Vec = m.path.iter().chain(m.live.iter().skip(1)).map(screen).collect(); - crate::tool_feedback::draw_ants(painter, &pts, false); + // Solid until the selection is made, as in Photoshop (#7495). + crate::tool_feedback::draw_solid_path(painter, &pts, false); for p in m.anchors.iter().filter_map(|&i| m.path.get(i)).map(screen) { let r = Rect::from_center_size(p, vec2(5.0, 5.0)); painter.rect_filled(r, 0.0, Color32::WHITE); diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 8a819fe319..52070c8371 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -8,7 +8,8 @@ //! They are assigned per menu level from the labels as shown (so translated), unique within //! the level: Photoshop's letters for the top menus where the label has them (F E I L Y S T V //! W H), else the first unused letter, word starts first. Only ASCII letters and digits are keys -//! the keyboard reports as such, so a label without one (Japanese, Chinese…) has no mnemonic. +//! the keyboard reports as such, so a label without one (Japanese, Chinese…) shows its English +//! label's letter after it, as Windows menus do in those languages: "文件(F)" ([`localized`]). //! //! The state is view state in egui memory, like [`crate::menu_nav::Nav`]. On macOS the native //! menu bar replaces the in-window one and this never runs. @@ -128,6 +129,49 @@ pub fn assign(labels: &[&str], preferred: &[Option]) -> Vec> out } +/// The labels to show and their mnemonics for translated `labels` whose English originals are +/// `english` (same order). A label with an ASCII letter or digit keeps its text and is assigned +/// as [`assign`] does. One without (Chinese, Japanese, Korean…) can't be typed on its own, so it +/// shows its English label's letter after it in parentheses and takes that letter, as Windows menus +/// do in those languages ("文件(F)", #5863). Mnemonics stay unique within the group, and a shown +/// "(F)" is always the row's key. +pub fn localized(labels: &[String], english: &[&str], preferred: &[Option]) -> (Vec, Vec>) { + let typed = |l: &str| l.chars().any(|c| c.is_ascii_alphanumeric()); + // The key goes before a trailing ellipsis, as Windows writes it: "新建(N)…". + let with_key = |l: &str, k: char| match l.strip_suffix('…').map(|b| (b, "…")).or_else(|| l.strip_suffix("...").map(|b| (b, "..."))) { + Some((base, tail)) => format!("{base}({k}){tail}"), + None => format!("{l}({k})"), + }; + let english_keys = assign(english, preferred); + let mut shown = Vec::with_capacity(labels.len()); + let mut pref = Vec::with_capacity(labels.len()); + for (i, label) in labels.iter().enumerate() { + match english_keys.get(i).copied().flatten().filter(|_| !typed(label)) { + Some(k) => { + shown.push(with_key(label, k)); + pref.push(Some(k)); + } + None => { + shown.push(label.clone()); + pref.push(preferred.get(i).copied().flatten()); + } + } + } + // The suffixed labels take their keys first: a label with letters of its own must not take + // the key a suffix shows ("Camera Raw 滤镜…" before "转换为智能滤镜(C)" leaves C to the latter). + let suffixed = |i: &usize| shown.get(*i) != labels.get(*i); + let order: Vec = (0..shown.len()).filter(suffixed).chain((0..shown.len()).filter(|i| !suffixed(i))).collect(); + let ordered: Vec<&str> = order.iter().map(|&i| shown.get(i).map_or("", String::as_str)).collect(); + let ordered_pref: Vec> = order.iter().map(|&i| pref.get(i).copied().flatten()).collect(); + let mut keys = vec![None; shown.len()]; + for (&i, key) in order.iter().zip(assign(&ordered, &ordered_pref)) { + if let Some(slot) = keys.get_mut(i) { + *slot = key; + } + } + (shown, keys) +} + /// `prefix + label` as menu text with the mnemonic's first occurrence in `label` underlined /// (none when `mnemonic` is None). `color` is the text colour; None takes the widget's. pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option, color: Option) -> egui::WidgetText { diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index af1bb84d46..1d59826aae 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -242,6 +242,34 @@ fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() { assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again"); } +/// #5863: a label with no Latin letter (Chinese, Japanese…) shows its English label's letter in +/// parentheses, before a trailing ellipsis, and takes it as its mnemonic; labels with letters and +/// the top menus' Photoshop letters are as before, and the letters stay unique. +#[test] +fn labels_without_latin_letters_show_their_english_key() { + let labels: Vec = ["文件", "编辑", "Image", "图层"].map(String::from).to_vec(); + let english = ["File", "Edit", "Image", "Layer"]; + let preferred = [Some('F'), Some('E'), Some('I'), Some('L')]; + let (shown, keys) = crate::menu_keys::localized(&labels, &english, &preferred); + assert_eq!(shown, ["文件(F)", "编辑(E)", "Image", "图层(L)"]); + assert_eq!(keys, [Some('F'), Some('E'), Some('I'), Some('L')]); + // Rows: the key goes before the ellipsis; a clash takes the next letter of the shown label. + let labels: Vec = ["新建…", "打开...", "关闭"].map(String::from).to_vec(); + let (shown, keys) = crate::menu_keys::localized(&labels, &["New…", "Open...", "Close"], &[]); + assert_eq!(shown, ["新建(N)…", "打开(O)...", "关闭(C)"]); + assert_eq!(keys, [Some('N'), Some('O'), Some('C')]); + let (shown, keys) = crate::menu_keys::localized(&["关闭".to_string(), "复制".to_string()], &["Close", "Copy"], &[]); + assert_eq!(shown, ["关闭(C)", "复制(O)"]); + assert_eq!(keys, [Some('C'), Some('O')]); + // A shown "(C)" keeps its key even when a label with letters of its own comes first. + let (shown, keys) = crate::menu_keys::localized(&["Copy".to_string(), "关闭".to_string()], &["Open", "Close"], &[]); + assert_eq!(shown, ["Copy", "关闭(C)"]); + assert_eq!(keys, [Some('O'), Some('C')]); + // An English UI is unchanged. + let labels: Vec = ["File", "Edit"].map(String::from).to_vec(); + assert_eq!(crate::menu_keys::localized(&labels, &["File", "Edit"], &[Some('F'), Some('E')]).0, labels); +} + /// The whole app: with the bar focused by an Alt tap, a command shortcut leaves the bar and runs /// in the same press (#6573); the bar's own keys still drive it. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a33a75d341..6494bf332e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1150,9 +1150,10 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { let mut keys = crate::menu_keys::Keys::load(ui.ctx()); keys.take_first_row(&mut nav); nav.underline = keys.show_underlines(ui.ctx()); - let top_labels: Vec = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect(); + let translated: Vec = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect(); let preferred: Vec> = TOP_MENUS.iter().map(|top| crate::menu_keys::TOP_PREFERRED.iter().find(|(t, _)| t == top).map(|(_, c)| *c)).collect(); - let top_mnemonics = crate::menu_keys::assign(&top_labels.iter().map(String::as_str).collect::>(), &preferred); + // A title with no Latin letter shows its English key: "文件(F)" (#5863). + let (top_labels, top_mnemonics) = crate::menu_keys::localized(&translated, &TOP_MENUS, &preferred); ui.scope(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let v = &mut ui.style_mut().visuals; @@ -1438,24 +1439,25 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click // of its first child. // Each row's mnemonic (#4988), unique within this level, in display order: the leaves' labels // and the submenus' names as shown. - let shown_labels: Vec = { + // A label with no Latin letter shows its English label's key after it: "新建(N)…" (#5863). + let (translated, english): (Vec, Vec<&str>) = { let mut subs: Vec<&str> = Vec::new(); items .iter() .filter_map(|it| { if it.path.len() == depth { - (it.label != "---").then(|| translated_menu_label(lang, it)) + (it.label != "---").then(|| (translated_menu_label(lang, it), it.label.as_str())) } else { let name = it.path.get(depth).map(String::as_str)?; (!subs.contains(&name)).then(|| { subs.push(name); - crate::i18n::tr(lang, name).to_string() + (crate::i18n::tr(lang, name).to_string(), name) }) } }) - .collect() + .unzip() }; - let mnemonics = crate::menu_keys::assign(&shown_labels.iter().map(String::as_str).collect::>(), &[]); + let (shown_labels, mnemonics) = crate::menu_keys::localized(&translated, &english, &[]); let mut next_row = 0usize; let mut shown_subs: Vec<&str> = Vec::new(); let mut last_was_sep = true; @@ -1468,7 +1470,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click } continue; } - let label = translated_menu_label(lang, it); + let label = shown_labels.get(next_row).cloned().unwrap_or_else(|| translated_menu_label(lang, it)); let mnemonic = mnemonics.get(next_row).copied().flatten(); next_row += 1; // Checked, unchecked and plain rows share one checkmark column (#5705). @@ -1504,8 +1506,9 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect(); let enabled = submenu_enabled(&child); let mnemonic = mnemonics.get(next_row).copied().flatten(); + let shown = shown_labels.get(next_row).cloned().unwrap_or_else(|| crate::i18n::tr(lang, name).to_string()); next_row += 1; - let title = crate::menu_keys::menu_row_text(ui, false, crate::i18n::tr(lang, name), mnemonic.filter(|_| nav.underline)); + let title = crate::menu_keys::menu_row_text(ui, false, &shown, mnemonic.filter(|_| nav.underline)); ui.add_enabled_ui(enabled, |ui| { nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| { let (response, inner) = submenu_row(ui, title, |ui| render_level(ui, &child, depth + 1, clicked, nav)); diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index e76c20eecd..6da6500d3a 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -865,7 +865,7 @@ mod tests { }; let themes = ids(children); assert_eq!(themes.last().map(String::as_str), Some("window.theme.toggle")); - assert!(themes.contains(&"window.theme.pro".to_string()) && themes.contains(&"window.theme.classic".to_string())); + assert!(themes.contains(&"[Dark Theme]".to_string()) && themes.contains(&"[Light Theme]".to_string())); assert_eq!(l.bar.find("window.theme.classic").unwrap().checked, Some(true)); assert_eq!(l.bar.find("window.theme.pro").unwrap().checked, Some(false)); assert_eq!(l.bar.find("window.theme.toggle").unwrap().label, "Next Appearance Mode"); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 78c4a65a4c..3dc2e3adbc 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1461,34 +1461,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); widgets::checkbox(ui, &mut o.move_show_transform, tl!("Show Transform Controls")); widgets::vline(ui, 22.0); - ui.spacing_mut().item_spacing.x = 0.0; - for (icon, tip, cmd) in [ - ("panels-top-left", tl!("Align left edges"), "layer.align.leftEdges"), - ("app-window", tl!("Align horizontal centers"), "layer.align.horizontalCenters"), - ("panel-right", tl!("Align right edges"), "layer.align.rightEdges"), - ] { - let on = app.session.is_enabled(cmd); - if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, tip)).inner.clicked() { - let _ = app.run(cmd, json!({})); - } - } - ui.add_space(6.0); - let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute")); - egui::Popup::menu(&more).show(|ui| { - ui.set_min_width(200.0); - let mut section = |ui: &mut egui::Ui, title: &str, prefix: &str| { - ui.label(egui::RichText::new(tl!(&title)).small().color(t.text_dim)); - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with(prefix)) { - if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(tl!(c.label))).clicked() { - let _ = app.run(c.id, json!({})); - ui.close(); - } - } - }; - section(ui, tl!("Align"), "layer.align."); - ui.separator(); - section(ui, tl!("Distribute"), "layer.distribute."); - }); + crate::align_bar::options(app, ui); } Tool::RectMarquee | Tool::EllipseMarquee => hint( ui, @@ -1515,6 +1488,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // turned on outside the Pro themes. widgets::checkbox(ui, &mut app.ui.tool_options.move_show_transform, tl!("Show Transform Controls")); hint(ui, tl!("Drag to move the active layer")); + widgets::vline(ui, 22.0); + crate::align_bar::options(app, ui); } Tool::Eyedropper => crate::eyedropper_ui::options(app, ui), Tool::Zoom => { @@ -2781,6 +2756,10 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(&(last, last_rect)) = drawn.last() { let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom())); layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions); + // A click on that empty space deselects every layer, as in Photoshop (#8100). + if below.height() > 0.0 && ui.interact(below, ui.id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() { + actions.push(("select.deselectLayers".into(), json!({}))); + } } // Tab / ⇧Tab in a rename: rename the layer below / above next, as in Photoshop. The // Background isn't renamed in place (double-clicking it converts it), so it ends there. diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index b9ed130839..e5489aaa16 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1228,6 +1228,10 @@ fn humanize(key: &str) -> String { } // Performance: presentation without vsync (#1343); like the other graphics settings, it // takes effect when the window next opens. + // File Handling: Photoshop's wording (#8080). + if key == "doNotAppendCopy" { + return "Do not append \"copy\" to filename when saving a copy".into(); + } if key == "lowLatencyDrawing" { return "Low-Latency Drawing (vsync off, applies at next launch)".into(); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ea43401132..d637f193d0 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -113,6 +113,23 @@ impl ThemeKind { pub fn is_pro(self) -> bool { matches!(self, ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::ProLightGray | ThemeKind::ProLight) } + /// Whether this theme belongs to the dark appearance family. + pub fn is_dark(self) -> bool { + matches!( + self, + ThemeKind::Studio + | ThemeKind::Pro + | ThemeKind::ProMedium + | ThemeKind::SolarizedDark + | ThemeKind::AdwaitaDark + | ThemeKind::BreezeDark + | ThemeKind::SoftDark + ) + } + /// Whether this theme belongs to the light appearance family. + pub fn is_light(self) -> bool { + !self.is_dark() + } pub fn next(self) -> Self { let i = Self::ALL.iter().position(|k| *k == self).unwrap_or(0); Self::ALL[(i + 1) % Self::ALL.len()] @@ -822,16 +839,7 @@ impl Tokens { } pub fn dark(&self) -> bool { - matches!( - self.kind, - ThemeKind::Studio - | ThemeKind::Pro - | ThemeKind::ProMedium - | ThemeKind::SolarizedDark - | ThemeKind::AdwaitaDark - | ThemeKind::BreezeDark - | ThemeKind::SoftDark - ) + self.kind.is_dark() } } diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index 2e66ae07c1..58bd14ccea 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -217,6 +217,20 @@ pub fn draw_ants(painter: &egui::Painter, points: &[Pos2], closed: bool) { painter.extend(ants(points, closed, phase)); } +/// A selection outline still being drawn (Lasso, Polygonal Lasso, Magnetic Lasso): a solid line, +/// as Photoshop draws it until the selection is made (#7495). Marching ants are for selections +/// that exist. A thin dark line on a soft light edge: Photoshop's look on light pixels, and still +/// visible on dark ones. +pub fn solid_path(points: &[Pos2], closed: bool) -> Vec { + let make = |s: Stroke| if closed { Shape::closed_line(points.to_vec(), s) } else { Shape::line(points.to_vec(), s) }; + vec![make(Stroke::new(3.0, Color32::from_white_alpha(140))), make(Stroke::new(1.0, Color32::BLACK))] +} + +/// Draw [`solid_path`]. +pub fn draw_solid_path(painter: &egui::Painter, points: &[Pos2], closed: bool) { + painter.extend(solid_path(points, closed)); +} + /// Polygon approximating the ellipse inscribed in `r` (enough vertices to look smooth). pub fn ellipse_points(r: egui::Rect) -> Vec { let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0); @@ -294,6 +308,18 @@ mod tests { assert!(badge_glyph(Badge::Intersect, c, 3.0).iter().all(|[a, b]| a.x != b.x && a.y != b.y)); } + /// #7495: an outline still being drawn is one unbroken line (no dashes, nothing marching), + /// black over a light edge. + #[test] + fn solid_path_is_one_unbroken_dark_line_on_a_light_edge() { + let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)]; + for closed in [false, true] { + let shapes = solid_path(&pts, closed); + assert!(shapes.iter().all(|s| matches!(s, Shape::Path(p) if p.closed == closed && p.points.len() == 3)), "{shapes:?}"); + assert_eq!(strokes(&shapes), vec![Color32::from_white_alpha(140), Color32::BLACK], "the dark line on top"); + } + } + #[test] fn ants_use_two_contrasting_colours_and_march() { let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)]; diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 8870924446..5b48b04f38 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1925,8 +1925,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { apply(app, ui.ctx(), json!({"fontStyle": style})); } } - let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); + let (r, icon) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); crate::icons::paint(ui, r, "type", 13.0, t.icon); + icon.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tl!("Font size"))); + // Photoshop's "T" scrubs the size field beside it, like the Character panel's icons (#6437). + crate::widgets::scrub_option_icon(ui, &icon, tl!("Font size")); let (unit, label) = type_unit(app); let mut display = size * unit; if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).changed() { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 526db1c617..3760d504a1 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -455,6 +455,110 @@ fn size_drag_is_live_and_one_history_step_per_drag() { assert_eq!(size_at(h.state(), id, 0), 20.0); } +fn options_harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(900.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.horizontal(|ui| super::options_bar(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h +} + +/// Drags the options bar's "T" icon `dx` points to the right with `mods` held. +fn scrub_size_icon(h: &mut Harness<'static, PhotocraftApp>, dx: f32, mods: Modifiers) { + let icon = h.get_by_label("Font size").rect().center(); + h.hover_at(icon); + h.run_steps(1); + press(h, icon, true); + for k in 1..=5 { + h.event(egui::Event::ModifiersChanged(mods)); + h.hover_at(icon + vec2(dx * k as f32 / 5.0, 0.0)); + h.run_steps(1); + } + press(h, icon + vec2(dx, 0.0), false); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn history_len(h: &Harness<'static, PhotocraftApp>) -> usize { + h.state().session.active().unwrap().history.entries().len() +} + +/// Photoshop's options bar "T" icon is a scrubby slider for the size beside it, like the +/// Character panel's icons (#6437): a point per point dragged, the whole drag one undo step. +#[test] +fn options_bar_t_icon_scrubs_the_size_in_one_history_step() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = options_harness(app); + let steps = history_len(&h); + scrub_size_icon(&mut h, 30.0, Modifiers::NONE); + assert_eq!(size_at(h.state(), id, 0), 50.0, "30 points to the right: 20 pt → 50 pt"); + assert_eq!(history_len(&h), steps + 1, "one history step for the whole drag"); + assert!(h.state_mut().session.undo()); + assert_eq!(size_at(h.state(), id, 0), 20.0); +} + +/// While editing with characters selected, scrubbing the "T" icon sizes only the selection. +#[test] +fn options_bar_t_icon_scrubs_the_selected_characters_only() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello world", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + caret: 11, + anchor: 6, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + document: None, + }); + let mut h = options_harness(app); + scrub_size_icon(&mut h, -8.0, Modifiers::NONE); + let sizes = [0, 5, 6, 10].map(|ci| size_at(h.state(), id, ci)); + assert_eq!(sizes, [20.0, 20.0, 12.0, 12.0], "\"world\" goes 20 pt → 12 pt, \"Hello \" stays"); +} + +/// Scrubbing the "T" icon stops at the size field's limits: 1 pt and 1296 pt. +#[test] +fn options_bar_t_icon_scrub_stops_at_the_size_limits() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = options_harness(app); + scrub_size_icon(&mut h, -60.0, Modifiers::NONE); + assert_eq!(size_at(h.state(), id, 0), 1.0, "60 points left of 20 pt stops at the minimum"); + scrub_size_icon(&mut h, 200.0, Modifiers::SHIFT); + assert_eq!(size_at(h.state(), id, 0), 1296.0, "Shift (10 pt per point) past the top stops at the maximum"); +} + +/// Rests the pointer on the widget named `label` past the tooltip delay; true when its tooltip +/// (a second node with the same name) shows. +fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, label: &str) -> bool { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + h.query_all_by_label(label).count() == 2 +} + +/// The scrubby "T" icon keeps its "Font size" tooltip: the size field's drag area, laid over the +/// icon after it, must not take its hover. +#[test] +fn options_bar_t_icon_shows_its_tooltip() { + let mut app = new_app(); + app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap(); + let mut h = options_harness(app); + assert!(tooltip_shows(&mut h, "Set the text color"), "the probe sees an ordinary tooltip"); + h.hover_at(egui::pos2(890.0, 55.0)); + h.run_steps(4); + assert!(tooltip_shows(&mut h, "Font size"), "hovering the \"T\" icon shows \"Font size\""); +} + /// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the /// column's centre line. fn vglyph(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, i: usize, f: f32) -> Pos2 { diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 3cba132f29..562d844011 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1292,6 +1292,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ), ), } + // Path mode: Make Selection from the path being drawn or the active path, as Photoshop's + // options bar's "Make: Selection…" does (#5703). + if app.ui.tool_options.vector_mode == "path" { + crate::widgets::vline(ui, 22.0); + let has_path = active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); + if ui.add_enabled(has_path, egui::Button::new(tl!("Make Selection"))).clicked() + && let Err(e) = crate::canvas_tool_menu::make_selection(app) + { + app.ui.status = e; + app.ui.status_error = true; + } + } return true; } let mut mode = "shape".to_string(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 29961d8bdd..3d1a29f60e 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1473,7 +1473,10 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) fn save_a_copy(app: &mut PhotocraftApp) -> Result { let doc = app.session.active().ok_or("no document")?.doc.clone(); let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a); - let suggested = format!("{stem} copy.psd"); + // " copy.psd", or with Preferences › File Handling › Do not append "copy" to filename + // when saving a copy the document's own name (only the extension may change), as in + // Photoshop (#8080). + let suggested = format!("{stem}{}.psd", app.session.prefs().file_handling.copy_suffix()); // The copy is of the document as it was when asked. app.pick_save(&suggested, move |app, path| { // An RGB Targa asks for its bits per pixel first, and a DDS texture for its compression; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 84b40a0172..330914886c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -405,6 +405,8 @@ struct LabelScrub { max_gap: f32, /// Options captions defer interaction until the adjacent field bounds are known. option_area: bool, + /// An options icon's tooltip, shown by the drag area laid over it (which takes its hover). + tip: Option<&'static str>, /// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast). steps: f32, started: bool, @@ -426,12 +428,23 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { /// A tool-options caption whose scrub target includes the adjacent field's outer box. /// Keep the original label bounds for linking; register the hit area before the numeric editor. pub fn scrub_option_label(ui: &Ui, label: &Response) { + scrub_option_area(ui, label, None); +} + +/// [`scrub_option_label`] for an icon with tooltip `tip`. The field's drag area is laid over the +/// icon after it, so egui hovers the area and not the icon: `on_hover_text` on the icon never shows. +pub fn scrub_option_icon(ui: &Ui, icon: &Response, tip: &'static str) { + scrub_option_area(ui, icon, Some(tip)); +} + +fn scrub_option_area(ui: &Ui, label: &Response, tip: Option<&'static str>) { let scrub = LabelScrub { pass: ui.ctx().cumulative_pass_nr(), label: label.id.with("pc-option-scrub"), rect: label.rect, max_gap: 24.0, option_area: true, + tip, steps: 0.0, started: false, dragging: false, @@ -487,6 +500,7 @@ fn label_scrub_response(ui: &Ui, resp: &Response, original_rect: Rect, max_gap: rect: original_rect, max_gap, option_area: false, + tip: None, steps, started: resp.drag_started(), dragging: resp.dragged(), @@ -558,7 +572,12 @@ fn value_field_box( // numeric button. It is not focusable; the editor drawn next wins inside its bounds. // Preserve a popup field's trailing arrow, and never extend outside the UI clip. let hit = Rect::from_min_max(pos2(s.rect.left(), rect.top()), pos2(rect.right() - trailing, rect.bottom())).intersect(ui.clip_rect()); - let resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + let mut resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + if let Some(tip) = s.tip + && resp.hover_pos().is_some_and(|p| s.rect.contains(p)) + { + resp = resp.on_hover_text(tip); + } label_scrub_response(ui, &resp, s.rect, s.max_gap) }); let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0c55ff601d..b04a293956 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-17 · **Change:** minor (the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; earlier: earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; 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: 45 filters (31 more: 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: 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; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; 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: 45 filters (31 more: 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: 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) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (45 mapped, among them Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks (#4758); undecodable type keeps saved pixels (FILE-215-7: extra alpha channels not written) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks (#4758); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); 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 | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) | | 2026-10-17 | minor | `.atn`: Apply Layer Comp by name maps to `layerComp.apply` and exports as `applyComp` (#7101) | | 2026-10-15 | minor | PSD smart filters: Clouds, Difference Clouds, Fibers and Lens Flare open as editable filters with their seeds, colours and lens, and save like Photoshop (checked against a Photoshop 27 file); render filters on a smart object work within the object, so a flare sits where Photoshop puts it | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e491b01097..a04cedf4f4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; a dragged guide shows its X or Y beside the pointer, #7680; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (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; 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) · **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, @@ -164,6 +164,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Geometry precision | partial | `geom::Point` f64, paths f64; paint internals mostly f32 | | Numeric fields: arithmetic, scrubby labels | partial | units in expressions and double-click reset missing (UI-217-13) | | Wheel steps a value field, ⇧ ×10: clicked into, wherever the pointer is; not clicked, a gesture that starts over it | partial | `field_wheel.rs` (#5473, #4061). Photoshop: a field takes the wheel once clicked into (shortcut guides, user reports); whether the pointer must stay over it is unconfirmed. Stepping on hover alone is Photopea's, kept deliberately | +| Type options bar: drag the "T" icon to change the font size | done | `type_tool.rs` `options_bar`: the icon scrubs the size field like the Character panel's icons (Shift x10, Alt fine), at layer or selection scope, one history step per drag, within 1–1296 pt (#6437) | ## Navigation @@ -235,6 +236,7 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) | | 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed | | 2026-10-16 | minor | Hand tool ⌘ / ⌥ held are temporary Zoom In / Zoom Out (#6434); Scroll All Windows and Bird's Eye View rows added as missing | +| 2026-10-16 | minor | Type options bar: dragging the "T" icon scrubs the font size, as the Character panel's icons do (#6437) | | 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial |