Merge origin/main into craft-i18n-cutover

Keeps the deletion of the in-repo catalogs; #12357's Layer Via Copy/Cut
rows are in craft-i18n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Martin BallandClaude Opus 5.5 committed 2026-11-08 22:28:32 +00:00
commit dd39514ef0
117 files changed
+7874 -331

No files matched your search

+2 -1
View File
@@ -15,7 +15,7 @@ next to them.
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/text/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.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`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.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`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.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`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 |
@@ -31,6 +31,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/vertical-align-{top,middle,bottom}.svg` | Type options-bar alignment glyphs for vertical text: three columns hanging from a top line, centred on a middle line, or standing on a bottom line, 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 |
Generated
+1
View File
@@ -5618,6 +5618,7 @@ dependencies = [
"crc32fast",
"flate2",
"imagesize",
"kurbo 0.13.1",
"lopdf",
"miniz_oxide 0.9.1",
"photocraft-affinity",
+1 -1
View File
@@ -33,7 +33,7 @@ required-features = ["gpu"]
photocraft-automation = { path = "../../crates/automation" }
photocraft-doc = { workspace = true }
photocraft-engine = { workspace = true }
photocraft-io = { workspace = true }
photocraft-io = { workspace = true, features = ["pdf-editable"] }
photocraft-raster = { workspace = true }
photocraft-format = { path = "../../crates/format" }
photocraft-codecs = { workspace = true }
+4
View File
@@ -125,6 +125,10 @@ pub(crate) struct ConvertArgs {
/// PDF pages to import, 1-based: "3" or "2-7" (default all)
#[arg(long)]
pub(crate) pdf_pages: Option<String>,
/// Import PDF paths and embedded-font text as editable layers (experimental; unsupported
/// pages fall back to raster, and embedded fonts may change when edited or reopened)
#[arg(long)]
pub(crate) pdf_editable: bool,
#[command(flatten)]
pub(crate) gpu: GpuArgs,
#[command(flatten)]
+1
View File
@@ -46,6 +46,7 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write
psd_high_memory_approved: a.approve_high_memory,
pdf_dpi: a.pdf_dpi.unwrap_or(300.0),
pdf_pages: a.pdf_pages.as_deref().map(parse_pdf_pages).transpose()?,
pdf_editable: a.pdf_editable,
..Default::default()
};
let mut o = files::open_with_options(input, &import_opts)?;
+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Type options-bar glyph for vertical text (not from an icon set). -->
<path d="M3 20h18" />
<path d="M17 4v12" />
<path d="M12 10v6" />
<path d="M7 7v9" />
</svg>

After

Width:  |  Height:  |  Size: 395 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Type options-bar glyph for vertical text (not from an icon set). -->
<path d="M3 12h18" />
<path d="M17 6v12" />
<path d="M12 9v6" />
<path d="M7 7v10" />
</svg>

After

Width:  |  Height:  |  Size: 395 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Type options-bar glyph for vertical text (not from an icon set). -->
<path d="M3 4h18" />
<path d="M17 8v12" />
<path d="M12 8v6" />
<path d="M7 8v9" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

+4
View File
@@ -315,3 +315,7 @@ sync_github_name = false
[people.davidvhk]
real_name = "David Vanhoucke"
sync_github_name = true
[people.rmsff]
real_name = "Serikov Roman"
sync_github_name = false
+67 -49
View File
@@ -219,11 +219,44 @@ pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32,
}
}
if anti_alias {
antialias(&mut m, w, h);
let tol = (tolerance + 1e-3).floor().max(0.0);
let src = m.clone();
for (i, v) in m.iter_mut().enumerate() {
if src[i] == 0.0 && touches_region(i % w, i / w, w, h, |x, y| src[y * w + x] > 0.0) {
let p = px[i];
let over = (0..3).map(|c| (p[c] - target[c]).abs() * 255.0).fold(0.0f32, f32::max) - tol;
*v = f32::from(fringe_coverage(over, p[3].min(target[3]) * 255.0)) / 255.0;
}
}
}
m
}
/// How many levels past the tolerance the anti-aliased fringe fades over, from fully selected to
/// nothing. Measured in the reference application (#11776): 3 levels past gives 251, 5 gives
/// 249 and 123 gives 110; a straight fade to zero at 216 levels gives all three.
const FRINGE_LEVELS: f32 = 216.0;
/// Coverage (0–255) of an anti-aliased fringe pixel: a neighbour of the region whose most
/// different colour channel is `over` levels past the tolerance, and `alpha` (0–255) the lower
/// of its and the clicked pixel's opacity. Photoshop keeps the region itself fully selected and
/// softens only the pixels just outside it, fading with the distance past the tolerance; a
/// neighbour without colour (transparent, next to a shape on its own layer) gets none.
fn fringe_coverage(over: f32, alpha: f32) -> u8 {
let k = (1.0 - over.max(0.0) / FRINGE_LEVELS).clamp(0.0, 1.0) * (alpha / 255.0).clamp(0.0, 1.0);
(k * 255.0).round() as u8
}
/// True when one of the 8 neighbours of `(x, y)` (inside a `w` x `h` area) is in the region.
fn touches_region(x: usize, y: usize, w: usize, h: usize, selected: impl Fn(usize, usize) -> bool) -> bool {
(-1i32..=1).any(|dy| {
(-1i32..=1).any(|dx| {
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
(dx, dy) != (0, 0) && nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && selected(nx as usize, ny as usize)
})
})
}
/// Straight RGBA8 copy of `area` of a surface, read in parallel bands on native.
pub fn rgba8_image(s: &Surface, area: Rect) -> Vec<[u8; 4]> {
let (w, h) = (area.width() as usize, area.height() as usize);
@@ -270,6 +303,22 @@ impl Region {
/// byte mark per pixel, then anti-aliases just the edges inside the touched bounding box.
/// Matches [`magic_wand`] for 8-bit sources. `None` if the seed is outside `area`.
pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Option<Region> {
wand_region_within(img, area, seed, tolerance, contiguous, anti_alias, None)
}
/// [`wand_region`] whose contiguous fill only passes through pixels at least half selected in
/// `within` (the current selection's coverage over `area`, row-major): Subtract and Intersect
/// grow the region from the seed through the selection only (#11779). A seed outside it finds
/// nothing. A global match (`contiguous` off) ignores `within`.
pub fn wand_region_within(
img: &[[u8; 4]],
area: Rect,
seed: (i32, i32),
tolerance: f32,
contiguous: bool,
anti_alias: bool,
within: Option<&[f32]>,
) -> Option<Region> {
if !area.contains(seed.0, seed.1) {
return None;
}
@@ -277,6 +326,11 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
if w.checked_mul(h).is_none_or(|n| n > img.len()) {
return None;
}
let within = within.filter(|_| contiguous).filter(|m| m.len() >= w * h);
let inside = |i: usize| within.is_none_or(|m| m.get(i).is_some_and(|v| *v >= 0.5));
if !inside((seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize) {
return None;
}
let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize];
let tol = (tolerance + 1e-3).floor().max(0.0) as i32;
// Copying a selection can leave RGB under zero alpha. Those invisible colours must not
@@ -293,10 +347,10 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
continue;
}
let (mut l, mut r) = (x, x);
while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) {
while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) && inside(row + l - 1) {
l -= 1;
}
while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) {
while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) && inside(row + r + 1) {
r += 1;
}
marks[row + l..=row + r].fill(255);
@@ -308,7 +362,7 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
let nrow = ny * w;
let mut in_run = false;
for nx in l..=r {
let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]);
let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]) && inside(nrow + nx);
if ok && !in_run {
stack.push((nx, ny));
}
@@ -356,25 +410,12 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
let x = x0 + xx;
let v = at(x, y);
*o = v;
if !anti_alias {
continue;
}
// Same rule as `antialias`: edge pixels blend with their 3×3 neighbourhood; neighbours
// outside the area count as the pixel itself.
let mut sum = 0u32;
let mut edge = false;
for dy in -1i32..=1 {
for dx in -1i32..=1 {
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
let n = if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { v } else { at(nx as usize, ny as usize) };
edge |= n != v;
sum += n as u32;
}
}
if edge {
let avg = sum as f32 / (9.0 * 255.0);
let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg };
*o = (f * 255.0).round() as u8;
// Same rule as `magic_wand`: the region stays fully selected; a pixel just outside
// it gets a fringe by how far its colour is past the tolerance.
if anti_alias && v == 0 && touches_region(x, y, w, h, |nx, ny| at(nx, ny) != 0) {
let p = img[y * w + x];
let d = (0..3).map(|c| (p[c] as i32 - target[c] as i32).abs()).max().unwrap_or(0);
*o = fringe_coverage((d - tol) as f32, f32::from(p[3].min(target[3])));
}
}
};
@@ -435,32 +476,6 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti
}
}
/// Softens hard mask edges: edge pixels become the 3×3 average.
fn antialias(m: &mut [f32], w: usize, h: usize) {
let src = m.to_vec();
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let mut sum = 0.0;
let mut cnt = 0.0;
let mut edge = false;
for dy in -1i32..=1 {
for dx in -1i32..=1 {
let (xx, yy) = (x as i32 + dx, y as i32 + dy);
let v = if xx < 0 || yy < 0 || xx >= w as i32 || yy >= h as i32 { src[i] } else { src[yy as usize * w + xx as usize] };
edge |= v != src[i];
sum += v;
cnt += 1.0;
}
}
if edge {
// Keep selected pixels mostly selected, add a soft fringe.
m[i] = if src[i] > 0.5 { 0.5 + 0.5 * sum / cnt } else { 0.5 * sum / cnt };
}
}
}
}
/// Colour Range: coverage falls off linearly with the per-channel colour
/// distance; `fuzziness` in levels (0–200).
pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec<f32> {
@@ -780,10 +795,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
mod distance;
#[cfg(test)]
mod ellipse_aa_edge_tests;
mod inverse;
mod lasso;
mod marquee;
mod morphology;
#[cfg(test)]
mod wand_antialias_tests;
pub use distance::edt;
pub use inverse::inverse_surface;
+3 -1
View File
@@ -106,7 +106,9 @@ fn wand_contiguous_vs_global() {
let g = magic_wand(&px, a, (3, 3), 10.0, false, false);
assert_eq!(g.iter().filter(|v| **v > 0.0).count(), 80);
let aa = magic_wand(&px, a, (3, 3), 10.0, true, true);
assert!(aa[3 * 20 + 6] > 0.0 && aa[3 * 20 + 6] < 1.0);
// #11776: anti-aliasing keeps the region whole and adds no fringe next to a colour this far
// past the tolerance (white is 245 levels from red, beyond the fringe's fade).
assert_eq!((aa[3 * 20 + 5], aa[3 * 20 + 6]), (1.0, 0.0));
assert!(magic_wand(&px, a, (50, 3), 10.0, true, false).iter().all(|v| *v == 0.0));
}
@@ -0,0 +1,128 @@
//! The Magic Wand's Anti-alias against the reference measurement (#11776): the region itself
//! stays fully selected, and a one-pixel soft fringe lies only outside it, next to colours just
//! past the tolerance. Document 800 x 600: a solid red rectangle 100,100–300,250 on a transparent
//! layer, and a grey ramp of ten 40 px blocks (grey 100, 105, …, 145) from x 100 at y 350–450,
//! on its own transparent layer or composited on white (Sample All Layers).
use photocraft_geom::Rect;
use super::{Region, magic_wand, wand_region};
const AREA: Rect = Rect { x0: 0, y0: 0, x1: 800, y1: 600 };
const W: usize = 800;
const H: usize = 600;
fn red_rectangle() -> Vec<[u8; 4]> {
let mut img = vec![[0u8; 4]; W * H];
for y in 100..250 {
for x in 100..300 {
img[y * W + x] = [220, 30, 30, 255];
}
}
img
}
/// The grey ramp at y 350–450 on a transparent layer, or on white when `on_white`.
fn ramp(on_white: bool) -> Vec<[u8; 4]> {
let mut img = vec![if on_white { [255u8; 4] } else { [0u8; 4] }; W * H];
for y in 350..450 {
for b in 0..10u8 {
let g = 100 + 5 * b;
for x in 100 + 40 * b as usize..140 + 40 * b as usize {
img[y * W + x] = [g, g, g, 255];
}
}
}
img
}
/// (bounds of every touched pixel, fully selected count, partly selected count).
fn stats(r: &Region) -> (Rect, usize, usize) {
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
let (mut full, mut part) = (0, 0);
for y in r.bbox.y0..r.bbox.y1 {
for x in r.bbox.x0..r.bbox.x1 {
let v = (r.at(x, y) * 255.0).round() as u8;
if v == 0 {
continue;
}
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
if v == 255 { full += 1 } else { part += 1 }
}
}
(Rect::new(x0, y0, x1, y1), full, part)
}
fn coverage(r: &Region, x: i32, y: i32) -> u8 {
(r.at(x, y) * 255.0).round() as u8
}
#[test]
fn a_shape_next_to_a_clearly_different_colour_gets_no_fringe() {
let img = red_rectangle();
for aa in [true, false] {
let r = wand_region(&img, AREA, (150, 150), 32.0, true, aa).unwrap();
assert_eq!(stats(&r), (Rect::new(100, 100, 300, 250), 30000, 0), "anti-alias {aa}");
}
}
#[test]
fn the_grey_ramp_gets_one_nearly_selected_column_past_the_tolerance() {
let img = ramp(false);
// Tolerance 32 from grey 100: blocks 100 to 130 (x 100–380) fully selected; the 135 block
// is 3 levels past it, so its first column is nearly selected.
let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap();
let (bounds, full, part) = stats(&r);
assert_eq!((full, part), (280 * 100, 100), "{bounds:?}");
assert_eq!(bounds, Rect::new(100, 350, 381, 450));
for y in [350, 400, 449] {
assert_eq!(coverage(&r, 379, y), 255, "the region's last column stays fully selected");
assert_eq!(coverage(&r, 380, y), 251, "one fringe column of the next block, as measured");
assert_eq!(coverage(&r, 381, y), 0, "only one column");
}
// Transparent pixels above and below the ramp have no colour: no fringe there.
assert_eq!(coverage(&r, 200, 349), 0);
assert_eq!(coverage(&r, 200, 450), 0);
// Tolerance 10: x 100–220 plus one partly selected column of the 115 block.
let r = wand_region(&img, AREA, (110, 400), 10.0, true, true).unwrap();
let (bounds, full, part) = stats(&r);
assert_eq!((bounds, full, part), (Rect::new(100, 350, 221, 450), 120 * 100, 100));
assert_eq!(coverage(&r, 220, 400), 249, "5 levels past tolerance 10, as measured");
}
#[test]
fn anti_alias_off_has_no_partly_selected_pixels() {
let img = ramp(false);
let r = wand_region(&img, AREA, (110, 400), 32.0, true, false).unwrap();
assert_eq!(stats(&r), (Rect::new(100, 350, 380, 450), 28000, 0));
}
#[test]
fn the_8_bit_and_f32_wands_draw_the_same_fringe() {
let img = ramp(false);
let f: Vec<[f32; 4]> = img.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect();
for (tol, contiguous) in [(32.0, true), (10.0, true), (32.0, false)] {
let r = wand_region(&img, AREA, (110, 400), tol, contiguous, true).unwrap();
let m = magic_wand(&f, AREA, (110, 400), tol, contiguous, true);
for y in 340..460 {
for x in 90..520 {
let want = (m[y as usize * W + x as usize] * 255.0).round() as u8;
assert_eq!(coverage(&r, x, y), want, "tolerance {tol}, contiguous {contiguous} at {x},{y}");
}
}
}
}
#[test]
fn on_the_composite_the_white_around_the_ramp_gets_the_measured_fringe() {
// Sample All Layers: the ramp on white. White is 155 levels from grey 100, 123 past
// tolerance 32; the reference application's fringe there reads 110 (Photoshop 2025, the
// same points; the issue reports 131–177 around the ramp).
let img = ramp(true);
let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap();
for (x, y) in [(200, 349), (200, 450), (99, 400), (99, 349)] {
assert_eq!(coverage(&r, x, y), 110, "{x},{y}");
}
assert_eq!(coverage(&r, 380, 400), 251, "the near-miss block still gets the stronger fringe");
assert_eq!(coverage(&r, 200, 400), 255, "inside stays fully selected");
}
@@ -0,0 +1,71 @@
//! #11897: the Brush's chosen Clear on the Background paints as Normal. The options bar turns
//! Clear off at the press there (`clear_off_on_background` in ui-egui), so the engine never sees
//! it; a `"mode": "clear"` stroke that does reach the Background is a held `~`, which keeps
//! erasing to the background colour. On a pixel layer Clear still erases, and the Eraser on the
//! Background still paints the background colour.
use crate::Session;
use serde_json::{Value, json};
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap();
s.tools.foreground = BLACK;
s.tools.background = GREEN;
s
}
fn pixel(s: &mut Session, x: i32, y: i32) -> [f32; 4] {
let v = s.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
let a: Vec<f32> = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap() as f32).collect();
[a[0], a[1], a[2], a[3]]
}
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 0.02)
}
/// A hard stroke across (10..30, 10), as the canvas sends it in Clear mode.
fn clear_stroke() -> Value {
json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true, "mode": "clear"})
}
/// A held `~` on the Background erases to the background colour, as before #11897.
#[test]
fn a_clear_stroke_on_the_background_still_paints_the_background_colour() {
let mut s = session();
s.execute("paint.stroke", clear_stroke()).unwrap();
let p = pixel(&mut s, 20, 10);
assert!(close(p, GREEN), "painted {p:?}, not the foreground");
assert_eq!(s.active().unwrap().history.undo_label(), Some("Brush Tool"));
}
#[test]
fn a_normal_stroke_on_the_background_paints_the_foreground() {
let mut s = session();
s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0})).unwrap();
let p = pixel(&mut s, 20, 10);
assert!(close(p, BLACK), "{p:?}");
}
#[test]
fn clear_on_a_pixel_layer_still_erases() {
let mut s = session();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap();
s.execute("paint.stroke", clear_stroke()).unwrap();
let p = pixel(&mut s, 20, 10);
assert!(close(p, WHITE), "erased down to the white Background: {p:?}");
}
#[test]
fn the_eraser_on_the_background_still_paints_the_background_colour() {
let mut s = session();
s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true})).unwrap();
let p = pixel(&mut s, 20, 10);
assert!(close(p, GREEN), "{p:?}");
}
+19 -1
View File
@@ -242,9 +242,27 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) {
if let Some(wp) = &mut doc.work_path {
*wp = wp.transform(a);
}
// A note's icon point maps with the pixels; its popup keeps its size (#11795). Under Image Size
// and Canvas Size it also keeps its offset from the icon, as Photoshop's does. Where it sits
// after a turn or flip wasn't measured, so it keeps today's origin there: the top-left of the
// mapped rectangle.
for n in &mut doc.notes {
// An all-zero popup means none is stored (PSD export writes its default one): keep it.
if n.popup == [0.0; 4] {
n.position = map_pt(a, n.position);
continue;
}
let [px, py] = n.position;
let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]);
n.position = map_pt(a, n.position);
n.popup = map_box(a, n.popup);
let [m0, m1, m2, m3, ..] = a.m;
let [x, y] = if m1.abs() < EPS && m2.abs() < EPS && m0 > 0.0 && m3 > 0.0 {
[n.position[0] + dx, n.position[1] + dy]
} else {
let [l, t, ..] = map_box(a, n.popup);
[l, t]
};
n.popup = [x, y, x + w, y + h];
}
for g in &mut doc.measurement.count_groups {
for p in &mut g.points {
+6
View File
@@ -69,6 +69,8 @@ pub mod comps_cmds;
pub mod contour_lib_cmds;
#[cfg(test)]
mod count_delete_group_tests;
#[cfg(test)]
mod count_history_names_tests;
pub mod cutout_cmds;
pub mod distort_cmds;
pub mod document_preset_cmds;
@@ -114,6 +116,8 @@ pub(super) mod migrate_cmds;
pub mod mode_cmds;
pub mod multichannel_cmds;
pub mod notes_cmds;
#[cfg(test)]
mod notes_stored_values_tests;
mod ops;
pub mod paint_cmds;
mod params;
@@ -150,6 +154,8 @@ pub mod type_area_cmds;
pub mod type_caret_cmds;
pub mod type_cmds;
pub mod type_extra_cmds;
#[cfg(test)]
mod type_indexed_mode_tests;
pub mod type_mask_cmds;
pub mod type_spell_cmds;
pub mod type_styles_cmds;
+14 -5
View File
@@ -334,11 +334,11 @@ fn record_drops_the_steps_of_a_command_that_failed_and_was_taken_back() {
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no Duplicate step for a copy that was taken back");
// Straighten rolls back when its crop is refused after the rotation ran.
ruled_background(&mut s, 8);
ruled_layer(&mut s, 8);
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("refused".into())) } else { Ok(()) });
s.execute("actions.record", json!({"name": "Refused"})).unwrap();
assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err());
assert!(s.execute("image.analysis.straightenLayer", json!({"crop": true})).is_err());
assert_eq!(s.active().unwrap().doc.size.width, 80, "the rotation was taken back");
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no rotation step for a rotation that was taken back");
}
@@ -500,23 +500,32 @@ fn ruled_background(s: &mut Session, depth: u32) {
.unwrap();
}
/// The same, with a pixel layer on top: since #12033 the Background alone greys Straighten Layer
/// out, so the tests that run the command target a layer and pass `crop` explicitly.
fn ruled_layer(s: &mut Session, depth: u32) {
ruled_background(s, depth);
s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap();
}
#[test]
fn record_command_that_runs_commands_replays_it_once() {
// Straighten Layer runs Image Rotation and Crop itself: only its own call is recorded,
// so playback rotates once, not three times (rotate, crop, then straighten again).
for depth in [8, 16] {
let mut s = Session::new();
ruled_background(&mut s, depth);
ruled_layer(&mut s, depth);
s.execute("actions.record", json!({"name": "Level"})).unwrap();
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
s.execute("image.analysis.straightenLayer", json!({})).unwrap();
// #12033: the crop is asked for, as automation does; the Background it used to default to
// now greys the command out.
s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let got = s.execute("actions.get", json!({"action": "Level"})).unwrap();
let ids: Vec<&str> = got["steps"].as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect();
assert_eq!(ids, ["image.analysis.rulerTool", "image.analysis.straightenLayer"], "depth {depth}");
let direct = s.active().unwrap().doc.clone();
ruled_background(&mut s, depth);
ruled_layer(&mut s, depth);
let before = s.active().unwrap().history.past_len();
let played = s.execute("actions.play", json!({"action": "Level"})).unwrap();
assert!(played.get("failed").is_none(), "depth {depth}: {played}");
+92 -8
View File
@@ -13,6 +13,11 @@
//! values the luminosity (0.30 R + 0.59 G + 0.11 B) of the composite in the document's value
//! range (0–255 at 8 bits, 0–32768 at 16 bits, 0–1 at 32 bits), integrated density area × mean.
//! Lengths and areas are scaled by the measurement scale.
//!
//! Straighten Layer is offered where the reference application offers it: the ruler line is needed,
//! and the target must be a single layer that is not the Background, not a group and not locked in
//! place ([`straighten_disabled`], #12033). A hidden target layer keeps the button and is refused
//! with [`STRAIGHTEN_HIDDEN_ERROR`] before anything changes.
use std::sync::Arc;
@@ -138,6 +143,60 @@ fn has_ruler(s: &Session) -> std::result::Result<(), String> {
s.active().and_then(|d| d.doc.measurement.ruler).map(|_| ()).ok_or_else(|| "no ruler line".into())
}
/// The reference's alert for Straighten Layer on a hidden target layer (#12033), measured: one
/// alert window with an OK button, and the document and its History stay as they were. The button
/// is *enabled* for a hidden layer, so this is the command's refusal and not a greyed state; the
/// options bar shows the same text through `tl!` (English is the catalog source).
pub const STRAIGHTEN_HIDDEN_ERROR: &str = "Could not straighten the layer because the target layer is hidden.";
/// The enabled check of `image.analysis.straightenLayer`: a ruler line, and a target the reference
/// straightens ([`straighten_disabled`], #12033).
fn has_straightenable_layer(s: &Session) -> std::result::Result<(), String> {
has_ruler(s)?;
straighten_disabled(s).map_or(Ok(()), |why| Err(why.into()))
}
/// Why Straighten Layer is greyed out for the current target, `None` when its button is live
/// (#12033). Measured with a ruler line drawn: the Background, two or more selected layers, a
/// group, a layer with Lock All and a layer with Lock Position all grey it out.
///
/// Deliberately not here: a hidden target layer keeps the button enabled and is refused by
/// [`straighten`] with [`STRAIGHTEN_HIDDEN_ERROR`], and the states the ticket marks *not measured*
/// (a layer with a mask, a shape, vector or adjustment layer, artboards, and the lock kinds other
/// than Lock All and Lock Position) keep today's behaviour. A document with no active layer keeps
/// turning the whole canvas as it does now; an active id the document no longer holds is refused.
fn straighten_disabled(s: &Session) -> Option<&'static str> {
let st = s.active()?;
if st.selected_layers().len() > 1 {
return Some("several layers selected");
}
// The active layer is the one the Layers panel targets; with none, the command turns the
// canvas, which the reference was not measured for.
let id = st.active_layer?;
// An id the document no longer holds: `Session::edit` keeps the *selected* layers valid but
// not the active one, so an edit that drops the active layer can leave a stale id behind.
// Turning and cropping the whole canvas for a layer that is not there is the worse answer, and
// `select_layer` and `restore_layer_selection` both refuse such an id.
let Some(layer) = st.doc.layer(id) else {
return Some("no active layer");
};
if st.doc.is_background(id) {
return Some("the Background");
}
if layer.is_group() {
return Some("a group");
}
// Locks inherited from a group count, as they do for the Move tool.
let locks = st.doc.effective_locks(id);
if locks.all {
return Some("the layer is locked");
}
if locks.position {
return Some("the layer's position is locked");
}
None
}
fn has_log(s: &Session) -> std::result::Result<(), String> {
if s.analysis.log.is_empty() { Err("the Measurement Log is empty".into()) } else { Ok(()) }
}
@@ -718,6 +777,12 @@ pub fn straighten_angle(r: &Ruler) -> f64 {
fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
let d = doc(s)?;
let r = d.measurement.ruler.ok_or_else(|| EngineError::Other("no ruler line".into()))?;
// A hidden target layer keeps its button and gets this alert instead of a rotation (#12033).
// Asked before anything is read for the edit, so the document, its History and the ruler line
// stay exactly as they were; a layer inside a hidden group is hidden too (#571).
if s.active().and_then(|st| st.active_layer).is_some_and(|id| d.path_of(id).is_some_and(|path| !d.shown_at(&path))) {
return Err(EngineError::Other(STRAIGHTEN_HIDDEN_ERROR.into()));
}
let rot = straighten_angle(&r);
if rot.abs() < 1e-9 {
return Ok(json!({"angle": 0.0}));
@@ -796,7 +861,7 @@ fn count_add(s: &mut Session, p: &Value) -> Result<Value> {
if gi.is_some_and(|i| i >= group_count) {
return Err(bad("count.add", "no such group"));
}
let n = s.edit("Count", |d, _| {
let n = s.edit("Add Count Object", |d, _| {
if d.measurement.count_groups.is_empty() {
let g = new_group(d, None, None);
d.measurement.count_groups.push(g);
@@ -846,7 +911,7 @@ fn locate(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
fn count_remove(s: &mut Session, p: &Value) -> Result<Value> {
let (g, i) = locate(s, p, "count.remove")?;
s.edit("Count", |d, _| {
s.edit("Delete Count Object", |d, _| {
d.measurement.count_groups[g].points.remove(i);
Ok(())
})?;
@@ -856,7 +921,7 @@ fn count_remove(s: &mut Session, p: &Value) -> Result<Value> {
fn count_move(s: &mut Session, p: &Value) -> Result<Value> {
let to = point(p.get("to")).ok_or_else(|| bad("count.move", "pass `to`: [x, y]"))?;
let (g, i) = locate(s, p, "count.move")?;
s.edit("Count", |d, _| {
s.edit("Drag Count Object", |d, _| {
d.measurement.count_groups[g].points[i] = to;
Ok(())
})?;
@@ -881,7 +946,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result<Value> {
fn count_new_group(s: &mut Session, p: &Value) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let color = color_of("count.newGroup", p, "color")?;
s.edit("New Count Group", |d, _| {
s.edit("Add Count Group", |d, _| {
let g = new_group(d, name, color);
d.measurement.count_groups.push(g);
d.measurement.active_count_group = d.measurement.count_groups.len() - 1;
@@ -949,8 +1014,24 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result<Value> {
}
let activate = p.get("active").and_then(Value::as_bool) == Some(true) || p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key("group"));
let changed = grp != d.measurement.count_groups[g];
if changed || (activate && d.measurement.active_count_group != g) {
s.edit("Count Group Options", |d, _| {
let switches = activate && d.measurement.active_count_group != g;
if changed || switches {
// One field changed: Photoshop's name for it (#11763). Several fields, or a switch of the
// current group (not measured), keep "Count Group Options".
let old = &d.measurement.count_groups[g];
let fields = [
(grp.name != old.name, "Set Count Group Name"),
(grp.visible != old.visible, "Set Count Group Visibility"),
(grp.color != old.color, "Set Count Draw Color"),
(grp.marker_size != old.marker_size, "Set Count Marker Size"),
(grp.label_size != old.label_size, "Set Count Font Size"),
];
let mut one = fields.iter().filter(|(c, _)| *c).map(|(_, l)| *l);
let label = match (one.next(), one.next()) {
(Some(l), None) if !switches => l,
_ => "Count Group Options",
};
s.edit(label, |d, _| {
d.measurement.count_groups[g] = grp;
if activate {
d.measurement.active_count_group = g;
@@ -1297,8 +1378,8 @@ pub fn specs() -> Vec<CommandSpec> {
"image.analysis.straightenLayer",
"Straighten Layer",
[],
r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image)"##,
has_ruler,
r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image; off for the Background, two or more selected layers, a group, Lock All and Lock Position, and a hidden target layer answers "Could not straighten the layer because the target layer is hidden." with nothing changed (#12033))"##,
has_straightenable_layer,
straighten,
true
),
@@ -1332,5 +1413,8 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
#[cfg(test)]
#[path = "analysis_straighten_enabled_tests.rs"]
mod analysis_straighten_enabled_tests;
#[cfg(test)]
mod tests;
+5 -2
View File
@@ -231,10 +231,13 @@ fn straighten_layer_and_image() {
assert_eq!(s.active().unwrap().history.past_len(), before + 1, "one history step");
assert_eq!(d(&s).size.width, 80);
assert!(d(&s).measurement.ruler.is_none());
// The Background: rotate the canvas and crop to the image.
// The Background used to rotate the canvas and crop it to the image; #12033 greys that state
// out, so the same crop now happens on a layer and only when it is asked for.
let mut s = session(80, 60, 16);
s.execute("image.analysis.rulerTool", json!({"start": [0, 0], "end": [40, 40]})).unwrap();
let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap();
assert!(!s.is_enabled("image.analysis.straightenLayer"), "the Background is greyed out (#12033)");
s.execute("layer.new.layer", json!({})).unwrap();
let r = s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap();
assert_eq!(r["cropped"], true);
assert_eq!(r["angle"].as_f64().unwrap().abs(), 45.0);
let nd = d(&s);
@@ -0,0 +1,283 @@
//! Straighten Layer's enabled states and its hidden-layer refusal, measured against the reference
//! application (#12033).
//!
//! With a ruler line drawn the reference greys the button out for the Background, two or more
//! selected layers, a group, a layer with Lock All and a layer with Lock Position. A hidden target
//! layer keeps the button enabled: the click answers an alert ("Could not straighten the layer
//! because the target layer is hidden.") and nothing changes. Pixel, text and smart object layers
//! stay enabled and are rotated.
//!
//! A layer with a mask, a shape or vector layer, an adjustment layer, artboards and the other lock
//! kinds are *not measured* there: they keep today's behaviour, so the tests below only pin that
//! the new check does not reach them.
use photocraft_doc::LayerId;
use super::*;
const CMD: &str = "image.analysis.straightenLayer";
/// A white document with a Background, as File > New makes one.
fn session(w: u32, h: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap();
s
}
/// The same document in CMYK at 16 bits, which the reference also straightens.
fn cmyk_16(w: u32, h: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "mode": "cmyk", "depth": 16, "background": "white"})).unwrap();
s
}
/// A ruler line to straighten by: without one the command is off for every target.
fn rule(s: &mut Session) {
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
}
/// A block of opaque pixels on the layer: an empty layer has nothing to transform, which would
/// hide the state under test behind "nothing to transform". The colour has the layer's own channel
/// count, so it works for RGB, Grayscale and CMYK at 8 and 16 bits alike.
fn paint(s: &mut Session, l: LayerId) {
s.edit("paint", |doc, _| {
let surf = doc.layer_mut(l).unwrap().surface_mut().unwrap();
let mut px = vec![0.0; surf.channels()];
if let Some(first) = px.first_mut() {
*first = 1.0;
}
if let Some(last) = px.last_mut() {
*last = 1.0;
}
surf.fill_rect(Rect::new(10, 10, 50, 20), &px);
Ok(())
})
.unwrap();
}
/// A new pixel layer with something on it, which `layer.new.layer` makes the active one.
fn pixel_layer(s: &mut Session, name: &str) -> LayerId {
let l = LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap());
paint(s, l);
l
}
/// A type layer over the canvas.
fn text_layer(s: &mut Session) -> LayerId {
LayerId(s.execute("type.create", json!({"x": 40, "y": 50, "text": "Hi", "size": 20})).unwrap()["layer"].as_u64().unwrap())
}
/// A pixel layer turned into a smart object.
fn smart_layer(s: &mut Session) -> LayerId {
let l = pixel_layer(s, "Smart");
s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": l.0})).unwrap();
l
}
fn doc_of(s: &Session) -> Arc<Document> {
s.active().unwrap().doc.clone()
}
fn steps(s: &Session) -> usize {
s.active().unwrap().history.past_len()
}
/// A state the reference greys the button out for: the engine says the command is off, so the menu
/// item and the options-bar button follow, and a call answered anyway (the CLI, the control channel,
/// an action step) is refused as a disabled command rather than run. Nothing is touched: the
/// document is the same `Arc`, no History step is added and the ruler line stays.
fn assert_greyed(s: &mut Session, what: &str) {
let (before, history) = (doc_of(s), steps(s));
assert!(!s.is_enabled(CMD), "{what}: the button is greyed out");
let e = s.execute(CMD, json!({})).unwrap_err();
let disabled = matches!(&e, EngineError::Disabled(id, _) if id == CMD);
assert!(disabled, "{what}: refused as a disabled command, got {e}");
assert!(Arc::ptr_eq(&before, &doc_of(s)), "{what}: the document was not touched");
assert_eq!(steps(s), history, "{what}: no History step");
assert!(doc_of(s).measurement.ruler.is_some(), "{what}: the ruler line is still there");
}
/// The angle the ruler line of [`rule`] straightens by.
const ROT: f64 = 4.7636;
#[test]
fn the_background_greys_straighten_layer_out() {
// Today it rotates the canvas and crops it (800 x 600 becomes 500 x 375); the reference has no
// button for it at all.
let mut s = session(100, 80);
assert!(doc_of(&s).is_background(s.active().unwrap().active_layer.unwrap()), "the Background is the target");
rule(&mut s);
assert_greyed(&mut s, "the Background");
}
#[test]
fn two_selected_layers_grey_straighten_layer_out() {
let mut s = session(100, 80);
let a = pixel_layer(&mut s, "One");
let b = pixel_layer(&mut s, "Two");
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected");
rule(&mut s);
assert_greyed(&mut s, "two selected layers");
// Back to one layer, the button is live again.
s.execute("layer.select", json!({"layer": b.0})).unwrap();
assert!(s.is_enabled(CMD), "one selected layer");
}
#[test]
fn a_group_greys_straighten_layer_out() {
let mut s = session(100, 80);
pixel_layer(&mut s, "Inner");
s.execute("layer.groupLayers", json!({})).unwrap();
let g = s.active().unwrap().active_layer.unwrap();
assert!(doc_of(&s).layer(g).unwrap().is_group(), "the new group is the target");
rule(&mut s);
assert_greyed(&mut s, "a group");
// A layer inside the group is a layer, so the button is on for it.
let inner = doc_of(&s).layer(g).unwrap().children().unwrap()[0].id;
s.execute("layer.select", json!({"layer": inner.0})).unwrap();
assert!(s.is_enabled(CMD), "a layer in a group");
}
#[test]
fn lock_all_and_lock_position_grey_straighten_layer_out() {
// Measured: both grey the button. Today it is enabled and the command fails with
// `layer "Colors" is locked`, which is what a greyed button cannot do any more.
for key in ["all", "position"] {
let mut s = session(100, 80);
let l = pixel_layer(&mut s, "Colors");
s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap();
rule(&mut s);
assert_greyed(&mut s, &format!("Lock {}", if key == "all" { "All" } else { "Position" }));
}
}
#[test]
fn the_other_lock_kinds_are_not_reached_by_the_new_check() {
// Not measured in the reference, so they keep today's behaviour: the button stays on and the
// command answers as it does now.
for key in ["transparency", "pixels", "artboard"] {
let mut s = session(100, 80);
let l = pixel_layer(&mut s, "Colors");
s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap();
rule(&mut s);
assert!(s.is_enabled(CMD), "Lock {key}: not measured, still enabled");
}
}
#[test]
fn a_hidden_layer_stays_enabled_and_is_refused_with_the_alert() {
// The one measured refusal: the button is on, the click shows the alert and nothing changes.
let mut s = session(100, 80);
let l = pixel_layer(&mut s, "Gone");
s.execute("layer.setProps", json!({"layer": l.0, "visible": false})).unwrap();
rule(&mut s);
let (before, history) = (doc_of(&s), steps(&s));
assert!(s.is_enabled(CMD), "a hidden layer keeps the button on");
let e = s.execute(CMD, json!({})).unwrap_err();
assert_eq!(e.to_string(), STRAIGHTEN_HIDDEN_ERROR, "the reference's alert text");
assert!(matches!(e, EngineError::Other(_)), "an alert, not a greyed state: {e}");
assert!(Arc::ptr_eq(&before, &doc_of(&s)), "nothing changed");
assert_eq!(steps(&s), history, "no History step");
assert!(doc_of(&s).measurement.ruler.is_some(), "a straighten that never ran leaves the ruler line");
}
#[test]
fn a_layer_hidden_with_its_group_is_refused_the_same_way() {
// A layer inside a hidden group is hidden, as for the painting tools (#571).
let mut s = session(100, 80);
let l = pixel_layer(&mut s, "Inner");
s.execute("layer.groupLayers", json!({})).unwrap();
let g = s.active().unwrap().active_layer.unwrap();
s.execute("layer.setProps", json!({"layer": g.0, "visible": false})).unwrap();
s.execute("layer.select", json!({"layer": l.0})).unwrap();
rule(&mut s);
let (before, history) = (doc_of(&s), steps(&s));
assert!(s.is_enabled(CMD), "the target is a layer, not the group");
assert_eq!(s.execute(CMD, json!({})).unwrap_err().to_string(), STRAIGHTEN_HIDDEN_ERROR);
assert!(Arc::ptr_eq(&before, &doc_of(&s)));
assert_eq!(steps(&s), history);
}
#[test]
fn without_a_ruler_line_the_button_stays_greyed_for_every_layer() {
let mut s = session(100, 80);
pixel_layer(&mut s, "Paint");
assert!(!s.is_enabled(CMD), "no ruler line");
s.execute("layer.setProps", json!({"layer": s.active().unwrap().active_layer.unwrap().0, "visible": false})).unwrap();
assert!(!s.is_enabled(CMD), "a hidden layer without a ruler line stays greyed, not enabled");
}
#[test]
fn an_active_layer_the_document_no_longer_holds_is_refused() {
// `Session::edit` prunes the selected layers but not the active id, so an edit that drops the
// active layer can leave one behind (`select_layer` and `restore_layer_selection` both refuse
// such an id). The command must then say it is off, not rotate and crop the whole canvas for a
// layer that is not there.
let mut s = session(100, 80);
let l = pixel_layer(&mut s, "Gone");
s.edit("drop", |doc, _| {
doc.layers.retain(|layer| layer.id != l);
Ok(())
})
.unwrap();
assert_eq!(s.active().unwrap().active_layer, Some(l), "the target still points at the dropped layer");
assert!(doc_of(&s).layer(l).is_none(), "and the document does not hold it");
rule(&mut s);
assert_greyed(&mut s, "an active layer that is gone");
// The destructive case this closes: `crop` asks for the canvas, which would turn and shrink for
// a target that no longer exists.
let size = (doc_of(&s).size.width, doc_of(&s).size.height);
assert!(s.execute(CMD, json!({"crop": true})).is_err());
assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas kept its size");
}
#[test]
fn a_single_visible_layer_still_rotates_without_cropping() {
// The pin for the state that stays: the layer turns about the canvas centre, the canvas does not.
let mut s = session(100, 80);
pixel_layer(&mut s, "Paint");
rule(&mut s);
let (history, size) = (steps(&s), (doc_of(&s).size.width, doc_of(&s).size.height));
assert!(s.is_enabled(CMD), "a layer is what the button is for");
let r = s.execute(CMD, json!({})).unwrap();
assert_eq!(r["cropped"], false, "{r}");
assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{r}");
assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas keeps its size");
assert_eq!(steps(&s), history + 1, "one History step");
assert!(doc_of(&s).measurement.ruler.is_none(), "the ruler line is cleared");
assert!(s.undo());
assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "and one Undo takes it back");
}
#[test]
fn pixel_text_and_smart_object_layers_stay_enabled_and_rotate() {
// Measured, also in a CMYK 16-bit document.
type Maker = (&'static str, fn(&mut Session) -> LayerId);
let make: [Maker; 3] = [("pixel", |s| pixel_layer(s, "Paint")), ("type", text_layer), ("smart object", smart_layer)];
for (what, make_layer) in make {
for new in [session as fn(u32, u32) -> Session, cmyk_16] {
let mut s = new(100, 80);
make_layer(&mut s);
rule(&mut s);
assert!(s.is_enabled(CMD), "{what}: enabled");
let r = s.execute(CMD, json!({}));
let r = r.unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(r["cropped"], false, "{what}: rotated, not cropped");
assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{what}: {r}");
}
}
}
#[test]
fn an_explicit_crop_still_crops_for_automation() {
// The `crop` param stays available to automation where the command is enabled.
let mut s = session(100, 80);
pixel_layer(&mut s, "Paint");
rule(&mut s);
let r = s.execute(CMD, json!({"crop": true})).unwrap();
assert_eq!(r["cropped"], true, "{r}");
let d = doc_of(&s);
assert!(d.size.width < 100 && d.size.height < 80, "the canvas is rotated and cropped to {:?}", [d.size.width, d.size.height]);
}
@@ -0,0 +1,100 @@
//! #11763: each Count tool edit gets the reference application's History name. A `count.setGroup`
//! that changes exactly one field takes that field's name; several fields at once, or a switch of
//! the current group, keep "Count Group Options".
use serde_json::{Value, json};
use crate::Session;
/// A 100 × 100 document with one count group holding one marker at 10,10 (its first state is
/// "Add Count Object").
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
s
}
fn steps(s: &Session) -> usize {
s.active().unwrap().history.past_len()
}
/// Run `id` and return the History name of the one step it added.
fn named(s: &mut Session, id: &str, p: Value) -> String {
let before = steps(s);
s.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert_eq!(steps(s), before + 1, "{id} {p} adds exactly one step");
s.active().unwrap().history.undo_label().unwrap_or_default().to_string()
}
#[test]
fn marker_edits_are_add_drag_and_delete_count_object() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
// The first click makes "Count Group 1" too, still one "Add Count Object" step.
assert_eq!(named(&mut s, "count.add", json!({"x": 10, "y": 10})), "Add Count Object");
assert_eq!(named(&mut s, "count.add", json!({"x": 40, "y": 40})), "Add Count Object");
assert_eq!(named(&mut s, "count.move", json!({"x": 10, "y": 10, "to": [20, 20]})), "Drag Count Object");
assert_eq!(named(&mut s, "count.remove", json!({"x": 40, "y": 40})), "Delete Count Object");
}
#[test]
fn group_edits_keep_or_take_their_names() {
let mut s = session();
assert_eq!(named(&mut s, "count.newGroup", json!({"name": "Leaves"})), "Add Count Group");
assert_eq!(named(&mut s, "count.clear", json!({})), "Clear Count");
assert_eq!(named(&mut s, "count.deleteGroup", json!({})), "Delete Count Group");
}
#[test]
fn a_single_field_change_uses_that_fields_name() {
for (p, want) in [
(json!({"name": "Seeds"}), "Set Count Group Name"),
(json!({"visible": false}), "Set Count Group Visibility"),
(json!({"color": "#204080"}), "Set Count Draw Color"),
(json!({"markerSize": 5}), "Set Count Marker Size"),
(json!({"labelSize": 20}), "Set Count Font Size"),
] {
let mut s = session();
assert_eq!(named(&mut s, "count.setGroup", p.clone()), want, "{p}");
}
}
#[test]
fn several_fields_at_once_keep_count_group_options() {
let mut s = session();
assert_eq!(named(&mut s, "count.setGroup", json!({"markerSize": 5, "labelSize": 20})), "Count Group Options");
// A field sent with its current value doesn't count as a change.
let mut s = session();
let name = s.execute("image.analysis.countTool", json!({})).unwrap()["groups"][0]["name"].clone();
assert_eq!(named(&mut s, "count.setGroup", json!({"name": name, "markerSize": 6})), "Set Count Marker Size");
}
#[test]
fn switching_the_current_group_keeps_todays_name() {
let mut s = session();
s.execute("count.newGroup", json!({"name": "Two"})).unwrap();
assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0})), "Count Group Options", "only `group`");
assert_eq!(named(&mut s, "count.setGroup", json!({"group": 1, "active": true})), "Count Group Options", "`active: true`, no field change");
// A field change together with a switch is two changes: today's name too.
assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0, "active": true, "visible": false})), "Count Group Options");
}
#[test]
fn no_change_adds_no_step_and_bad_params_stay_errors() {
let mut s = session();
let before = steps(&s);
// The current group's own values: nothing changes, no step.
s.execute("count.setGroup", json!({"group": 0})).unwrap();
assert_eq!(steps(&s), before);
for (id, p) in [
("count.setGroup", json!({"group": 9, "name": "x"})),
("count.setGroup", json!({"markerSize": 2.5})),
("count.move", json!({"x": 90, "y": 90, "to": [1, 1]})),
("count.remove", json!({"x": 90, "y": 90})),
("count.add", json!({"x": 1, "y": 1, "group": 7})),
] {
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
assert_eq!(steps(&s), before, "{id} {p} adds no step");
}
}
+2 -2
View File
@@ -1267,8 +1267,8 @@ pub fn specs() -> Vec<CommandSpec> {
has_clip,
|s, p| paste(s, p, true)
),
spec!("layer.new.layerViaCopy", "Layer via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)),
spec!("layer.new.layerViaCut", "Layer via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)),
spec!("layer.new.layerViaCopy", "Layer Via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)),
spec!("layer.new.layerViaCut", "Layer Via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)),
spec!("layer.mergeVisible", "Merge Visible", &["Layer"], Some("Cmd+Shift+E"), "{}", has_doc, |s, _| merge_visible(s)),
spec!("image.autoTone", "Auto Tone", &["Image"], Some("Cmd+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "tone")),
spec!("image.autoContrast", "Auto Contrast", &["Image"], Some("Cmd+Alt+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "contrast")),
+16
View File
@@ -24,6 +24,7 @@ mod batch_run;
mod export;
mod info;
mod open;
mod open_as_smart_object;
mod paths;
mod place_steps;
mod processing;
@@ -50,6 +51,8 @@ pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_
pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults};
pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, tag_exr_colorspace};
use open::{approve_memory, close_all, close_others, open_as, place, revert};
pub use open_as_smart_object::admit_as_smart_object;
use open_as_smart_object::open_as_smart_object;
use paths::is_file;
#[cfg(test)]
use paths::openable_name;
@@ -285,6 +288,15 @@ pub fn specs() -> Vec<CommandSpec> {
native,
open_as
),
spec!(
"file.openAsSmartObject",
"Open as Smart Object…",
&["File"],
None,
r##"{"path":str,"confirmDamage":bool=false (a damaged PSD waits for an answer, as File > Open's does)} (opens the file as a new untitled document named "<file name without its last extension>-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color, smartObject} (or {held, name, warnings, color, damaged, smartObject} while a prompt waits)"##,
native,
open_as_smart_object
),
// One-shot permission for one file, not something to replay later: never journaled or
// recorded in an action, since replaying it in another session/on another file would
// grant memory use nobody approved (#10897). It also releases the queued source on both
@@ -421,6 +433,10 @@ mod tests;
#[path = "file_cmds/place_job_tests.rs"]
mod place_job_tests;
#[cfg(test)]
#[path = "file_cmds/open_as_smart_object_tests.rs"]
mod open_as_smart_object_tests;
#[cfg(test)]
mod aces_exr_tests {
use super::*;
+9 -3
View File
@@ -74,9 +74,15 @@ fn import_result(name: &str, bytes: &[u8]) -> Result<photocraft_io::ImportResult
/// The one import path every caller shares: allocation checkpoint, native `.pcraft` and format
/// dispatch through `photocraft_io::import_with_options`, typed memory refusals, and the
/// preview-only refusal wherever the import came from.
fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<photocraft_io::ImportResult> {
pub(super) fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<photocraft_io::ImportResult> {
import_result_for_open(name, bytes, opts).map(|(r, _)| r)
}
/// [`import_result_with`], also telling whether a PSD/PSB had damaged data it recovered from, as
/// File › Open's job learns it (`photocraft_io::import_for_open`), so an interactive caller can ask.
pub(super) fn import_result_for_open(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<(photocraft_io::ImportResult, bool)> {
crate::allocation::checkpoint("importing document")?;
let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?;
let (r, damaged) = photocraft_io::import_for_open(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?;
// Auxiliary imports return only a document and cannot surface the preview's fidelity warning.
// Open has its own warning-preserving path; never silently place or process a thumbnail.
if r.preview_only {
@@ -84,7 +90,7 @@ fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOpti
"{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here"
)));
}
Ok(r)
Ok((r, damaged))
}
// ---------- close / revert / save a copy / open as ----------
@@ -0,0 +1,115 @@
//! File › Open as Smart Object… (#11678): a file opens as a new untitled document with the file's
//! size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer.
//!
//! The layer is built as File › Place Embedded builds one (an embedded smart object at the file's own
//! size and position `0, 0`) and put into the document before it is admitted, so the opening
//! snapshot already shows it.
use super::open::import_result_for_open;
use super::paths::{file_name, read_file, stem, untitled_name};
use super::{Affine, Arc, Document, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param};
use crate::layer_place::Place;
const CMD: &str = "file.openAsSmartObject";
/// The history step's name.
const LABEL: &str = "Open As Smart Object";
/// Opens the file as a document holding one embedded smart object. The read, decode and flatten
/// run on the job's worker (inline under `execute`); the document is built, given File › Open's
/// colour policy and admitted, or held for its profile prompt, when the job is applied. The reply
/// has the shape of File › Open's job: `{document, docId, name, layer, warnings, color}`, or
/// `{held, name, warnings, color, smartObject}` while the profile prompt waits.
pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path", CMD)?.to_string();
let name = file_name(&path);
let opts = crate::jobs::import_options(s.prefs());
// An interactive caller (the app) asks before opening a PSD that had damaged data, as File > Open does.
let confirm_damage = p.get("confirmDamage").and_then(Value::as_bool).unwrap_or(false);
crate::jobs::run(
s,
&format!("Opening {name}"),
false,
move |ctx| {
ctx.progress(0.0, "Reading");
let bytes = read_file(&path)?;
ctx.check()?;
ctx.progress(0.05, "Decoding");
let (r, damaged) = import_result_for_open(&name, &bytes, &opts)?;
ctx.check()?;
ctx.progress(0.9, "Flattening");
// The file as one image: the smart object's rendered contents.
let pixels = flattened_checked(&r.document, r.document.pixel_format(), CMD)?;
Ok((path, name, Arc::new(bytes), r, pixels, confirm_damage && damaged))
},
|s, (path, name, bytes, r, pixels, damaged)| apply(s, &path, &name, bytes, r, pixels, damaged),
)
}
fn apply(
s: &mut Session,
path: &str,
file: &str,
bytes: Arc<Vec<u8>>,
r: photocraft_io::ImportResult,
pixels: photocraft_raster::Surface,
damaged: bool,
) -> Result<Value> {
let warnings = r.warnings;
let mut src = r.document;
super::tag_exr_colorspace(s, file, &mut src);
super::raw_defaults(s, &warnings, &mut src);
let layer_name = stem(path);
// "<name>-1", the next free number while a document with that name is open.
let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n) || s.held_open_named(n));
let mut doc = Document::new(name.clone(), src.size, src.mode, src.depth);
doc.resolution_dpi = src.resolution_dpi;
doc.icc_profile = src.icc_profile.clone();
doc.color_table = src.color_table.clone();
doc.duotone = src.duotone.clone();
// Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it.
let source = SmartSource::Embedded { file_name: file.to_string(), bytes };
let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels));
let layer = Layer::new(layer_name, LayerContent::Smart(so));
let layer_id = layer.id;
crate::layer_place::insert_new(&mut doc, None, layer, Place::AutoEditable);
// File › Open's completion: a raw has no embedded profile to ask about; otherwise the profile
// prompt holds the document, and without a question the Color Settings policy is applied.
let raw = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
let question = s.color.open_question(&doc).filter(|_| !raw);
if damaged || question.is_some() {
let held = s.hold_open(doc, false);
return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "damaged": damaged, "smartObject": true}));
}
let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag);
let index = admit_as_smart_object(s, doc);
let id = s.documents().get(index).map(|st| st.doc.id.0);
// Admission numbers the document again, so the reply reads the name it was admitted under.
let name = s.documents().get(index).map_or(name, |st| st.doc.name.clone());
Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color, "smartObject": true}))
}
/// Admits a document built by Open as Smart Object (one smart object layer) as an untitled
/// document: the opening snapshot already shows the placed layer, is named after it, and the
/// history is the single state "Open As Smart Object", as File › Open's is one state.
pub fn admit_as_smart_object(s: &mut Session, mut doc: Document) -> usize {
let layer_name = doc.layers.first().map(|l| l.name.clone());
// The number was free when the file was read, but documents held for their prompts did not count
// then, and one of them may have been cancelled since: take the first free one now, as each
// document is admitted.
if let Some(base) = &layer_name {
doc.name = untitled_name(base, |n| s.documents().iter().any(|d| d.doc.name == n));
}
// Untitled: no path, so Save asks where to save.
let index = s.add_document(doc, None);
if let Some(st) = s.active_mut() {
if let (Some(first), Some(name)) = (st.snapshots.iter_mut().find(|snap| snap.id == 0), layer_name) {
first.name = name;
}
st.history.set_current_label(LABEL);
}
index
}
@@ -0,0 +1,367 @@
//! File › Open as Smart Object… (#11678): a file opens as a new untitled document holding one
//! embedded smart object layer.
use super::*;
fn tmp(name: &str) -> String {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("photocraft-open-as-so-{}-{name}-{id}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d.to_string_lossy().into_owned()
}
/// A small PNG with a gradient, so the pixels differ from place to place.
fn write_png(dir: &str, name: &str, w: u32, h: u32) -> String {
let px: Vec<u8> = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) % 256) as u8])).collect();
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap();
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
let path = join(dir, name);
std::fs::write(&path, bytes).unwrap();
path
}
/// A two-layer 16-bit PSD at 300 ppi tagged with Display P3, made with the engine's own save.
fn write_psd16(dir: &str, name: &str, w: u32, h: u32) -> String {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "depth": 16})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap();
s.execute("edit.assignProfile", json!({"profile": "display-p3"})).unwrap();
let path = join(dir, name);
save_doc(&s.active().unwrap().doc, &path, None).unwrap();
path
}
fn imported(path: &str) -> Document {
photocraft_io::import(&file_name(path), &std::fs::read(path).unwrap()).unwrap().document
}
fn active(s: &Session) -> &Document {
&s.active().unwrap().doc
}
fn names(s: &Session) -> Vec<String> {
s.documents().iter().map(|d| d.doc.name.clone()).collect()
}
/// The one layer of the active document, which must be an embedded smart object.
fn embedded(s: &Session) -> (String, String, Arc<Vec<u8>>) {
let d = active(s);
assert_eq!(d.layers.len(), 1, "one layer, not a Background plus a layer");
let layer = &d.layers[0];
match &layer.content {
LayerContent::Smart(so) => match &so.source {
SmartSource::Embedded { file_name, bytes } => (layer.name.clone(), file_name.clone(), Arc::clone(bytes)),
other => panic!("expected an embedded smart object, got {other:?}"),
},
_ => panic!("the layer is not a smart object"),
}
}
#[test]
fn a_png_opens_as_an_untitled_document_with_one_embedded_smart_object() {
let dir = tmp("png");
let path = write_png(&dir, "a_test.png", 64, 48);
let mut s = Session::new();
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert_eq!(r["name"], "a_test-1", "{r}");
assert_eq!(s.documents().len(), 1);
let st = s.active().unwrap();
assert_eq!(st.doc.name, "a_test-1");
assert_eq!(st.path, None, "untitled: Save asks where to save");
let file = imported(&path);
let d = &st.doc;
assert_eq!((d.size.width, d.size.height), (64, 48));
assert_eq!(d.resolution_dpi, file.resolution_dpi);
assert_eq!((d.mode, d.depth), (file.mode, file.depth));
assert_eq!(d.icc_profile, file.icc_profile);
let (layer, original, bytes) = embedded(&s);
assert_eq!(layer, "a_test", "named after the file without its extension");
assert_eq!(original, "a_test.png", "Edit Contents opens the original file name");
assert_eq!(*bytes, std::fs::read(&path).unwrap(), "the file itself is embedded");
}
#[test]
fn the_smart_object_shows_the_file() {
let dir = tmp("pixels");
let path = write_png(&dir, "grad.png", 32, 16);
let mut s = Session::new();
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
let want = photocraft_compose::render(&imported(&path), Rect::new(0, 0, 32, 16)).px.clone();
let got = photocraft_compose::render(active(&s), Rect::new(0, 0, 32, 16)).px.clone();
assert_eq!(got, want);
}
#[test]
fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() {
let dir = tmp("psd");
let path = write_psd16(&dir, "c.layered.psd", 40, 30);
let mut s = Session::new();
// The files were measured in the working spaces, where nothing asks; Preserve keeps Display P3.
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
let file = imported(&path);
assert_eq!(file.resolution_dpi, 300.0);
assert!(file.icc_profile.is_some());
let d = active(&s);
assert_eq!(d.name, "c.layered-1", "only the last extension is dropped");
assert_eq!((d.size.width, d.size.height), (40, 30));
assert_eq!(d.resolution_dpi, 300.0);
assert_eq!((d.mode, d.depth), (file.mode, file.depth));
assert_eq!(d.depth, photocraft_doc::SampleType::U16);
assert_eq!(d.icc_profile, file.icc_profile);
let (layer, original, _) = embedded(&s);
assert_eq!((layer.as_str(), original.as_str()), ("c.layered", "c.layered.psd"));
}
#[test]
fn history_is_the_single_state_open_as_smart_object() {
let dir = tmp("history");
let path = write_png(&dir, "a_test.png", 16, 16);
let mut s = Session::new();
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.history.entries(), ["Open As Smart Object"], "one state, as File > Open has");
assert_eq!(st.snapshots.first().map(|snap| snap.name.as_str()), Some("a_test"), "the opening snapshot is named after the layer");
}
#[test]
fn the_opening_snapshot_shows_the_placed_smart_object() {
let dir = tmp("snapshot");
let path = write_png(&dir, "a_test.png", 16, 16);
let mut s = Session::new();
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
let st = s.active().unwrap();
let first = st.snapshots.first().unwrap();
assert_eq!(first.doc.layers.len(), 1, "snapshot 0 already holds the smart object");
assert!(matches!(first.doc.layers[0].content, LayerContent::Smart(_)));
assert_eq!(first.layers.active, Some(first.doc.layers[0].id));
assert_eq!(st.active_layer, Some(first.doc.layers[0].id));
assert_eq!(st.history.entries(), ["Open As Smart Object"]);
}
#[test]
fn the_number_is_the_next_free_one_and_starts_again_after_they_close() {
let dir = tmp("numbers");
let path = write_png(&dir, "a_test.png", 8, 8);
let mut s = Session::new();
for _ in 0..3 {
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
}
assert_eq!(names(&s), ["a_test-1", "a_test-2", "a_test-3"]);
while !s.documents().is_empty() {
s.close(0);
}
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert_eq!(names(&s), ["a_test-1"]);
}
#[test]
fn a_plain_open_of_the_same_file_is_unchanged() {
let dir = tmp("plain");
let path = write_png(&dir, "a_test.png", 8, 8);
let mut s = Session::new();
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
open_bytes_as(&mut s, &path, &std::fs::read(&path).unwrap(), None, Some(path.clone())).unwrap();
assert_eq!(names(&s), ["a_test-1", "a_test.png"]);
assert!(matches!(active(&s).layers[0].content, LayerContent::Raster(_)));
}
#[test]
fn several_files_open_one_document_each() {
let dir = tmp("several");
let a = write_png(&dir, "a_test.png", 8, 8);
let b = write_psd16(&dir, "d_other.psd", 12, 10);
let mut s = Session::new();
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
s.execute("file.openAsSmartObject", json!({"path": a})).unwrap();
s.execute("file.openAsSmartObject", json!({"path": b})).unwrap();
assert_eq!(names(&s), ["a_test-1", "d_other-1"]);
for st in s.documents() {
assert_eq!(st.doc.layers.len(), 1);
assert!(matches!(st.doc.layers[0].content, LayerContent::Smart(_)));
}
}
#[test]
fn a_missing_or_unreadable_file_is_an_error_not_a_crash() {
let dir = tmp("bad");
let junk = join(&dir, "notes.png");
std::fs::write(&junk, b"this is not an image").unwrap();
let text = join(&dir, "readme.txt");
std::fs::write(&text, b"hello").unwrap();
let mut s = Session::new();
for path in [join(&dir, "missing.png"), junk, text] {
let r = s.execute("file.openAsSmartObject", json!({"path": path}));
assert!(r.is_err(), "{path}: {r:?}");
}
assert!(s.documents().is_empty(), "a failed open leaves no document behind");
}
#[test]
fn bad_params_are_errors() {
let mut s = Session::new();
for params in [json!({}), json!({"path": 7}), json!({"path": null})] {
let r = s.execute("file.openAsSmartObject", params.clone());
assert!(r.is_err(), "{params}: {r:?}");
}
assert!(s.documents().is_empty());
}
fn job(r: crate::jobs::Started) -> crate::jobs::JobId {
match r {
crate::jobs::Started::Job(id) => id,
crate::jobs::Started::Done(v) => panic!("expected a background job, ran inline: {v}"),
}
}
/// Poll until job `id` ends (or 60 s pass); returns its event.
fn wait_event(s: &mut Session, id: crate::jobs::JobId) -> crate::jobs::JobEvent {
let t = std::time::Instant::now();
loop {
if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) {
return e;
}
assert!(t.elapsed() < std::time::Duration::from_secs(60), "job {id:?} did not finish");
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
#[test]
fn the_import_runs_as_a_job_when_started() {
let dir = tmp("job");
let path = write_png(&dir, "a_test.png", 32, 24);
let mut s = Session::new();
let id = job(s.start("file.openAsSmartObject", json!({"path": path})).unwrap());
assert!(s.documents().is_empty(), "nothing is admitted before the job is applied");
let e = wait_event(&mut s, id);
let crate::jobs::JobOutcome::Done(v) = &e.outcome else { panic!("{e:?}") };
assert!(v["docId"].is_u64(), "{v}");
assert_eq!(v["name"], "a_test-1", "{v}");
assert_eq!(s.documents().len(), 1);
embedded(&s);
}
#[test]
fn the_colour_policy_matches_file_open() {
let dir = tmp("policy");
let path = write_psd16(&dir, "c.layered.psd", 40, 30);
let settings = json!({"askOnMismatch": false, "policyRgb": "convert"});
let mut open = Session::new();
open.execute("edit.colorSettings", settings.clone()).unwrap();
open.execute("file.openAs", json!({"path": path})).unwrap();
let mut so = Session::new();
so.execute("edit.colorSettings", settings).unwrap();
so.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
let (a, b) = (active(&open), active(&so));
assert_eq!(a.icc_profile, b.icc_profile, "Convert is applied as File > Open applies it");
assert_eq!((a.mode, a.depth, a.size), (b.mode, b.depth, b.size));
assert_eq!(a.resolution_dpi, b.resolution_dpi);
}
#[test]
fn ask_when_opening_holds_the_document_like_file_open() {
let dir = tmp("ask");
let path = write_psd16(&dir, "c.layered.psd", 40, 30);
let mut s = Session::new();
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert_eq!(r["color"]["ask"], true, "{r}");
assert_eq!(r["smartObject"], true, "{r}");
assert!(r["held"].is_u64(), "{r}");
assert!(s.documents().is_empty(), "held for the profile prompt, not admitted");
let (doc, _) = s.take_held_open(r["held"].as_u64().unwrap()).unwrap();
assert_eq!(doc.name, "c.layered-1");
assert_eq!(doc.layers.len(), 1);
assert!(matches!(doc.layers[0].content, LayerContent::Smart(_)));
}
#[test]
fn the_reply_flags_a_smart_object_so_a_queued_document_keeps_its_history_label() {
let dir = tmp("flag");
let path = write_png(&dir, "a_test.png", 16, 12);
let mut s = Session::new();
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert_eq!(r["smartObject"], true, "{r}");
}
#[test]
fn a_damaged_psd_is_held_for_confirmation_when_asked_and_opens_otherwise() {
let dir = tmp("damaged");
let path = join(&dir, "d.psd");
std::fs::write(&path, crate::jobs::psd_open_fixture::make_psd(true)).unwrap();
let mut s = Session::new();
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
let r = s.execute("file.openAsSmartObject", json!({"path": path, "confirmDamage": true})).unwrap();
assert_eq!(r["damaged"], true, "{r}");
assert_eq!(r["smartObject"], true, "{r}");
assert!(r["held"].is_u64(), "{r}");
assert!(s.documents().is_empty(), "held for the damage prompt, not admitted");
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert!(r["docId"].is_u64(), "a headless caller opens the recovered content: {r}");
}
#[test]
fn a_document_held_for_its_prompt_does_not_share_its_name_with_the_next() {
let dir = tmp("held-name");
let path = write_psd16(&dir, "a_test.psd", 20, 20);
let mut s = Session::new();
let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert_eq!(first["name"], "a_test-1");
assert_eq!(second["name"], "a_test-2", "the held document's name is taken: {second}");
// Both held docs leave the session for the app's queue; admitting them one by one numbers each.
let (a, _) = s.take_held_open(first["held"].as_u64().unwrap()).unwrap();
let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap();
admit_as_smart_object(&mut s, a);
admit_as_smart_object(&mut s, b);
assert_eq!(names(&s), ["a_test-1", "a_test-2"]);
let mut t = Session::new();
let (c, _) = {
let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
t.take_held_open(r["held"].as_u64().unwrap()).unwrap()
};
let (d, _) = {
t.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap();
let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
t.take_held_open(r["held"].as_u64().unwrap()).unwrap()
};
assert_eq!(c.name, d.name, "taken out of the session, both are named a_test-1 when read");
admit_as_smart_object(&mut t, c);
admit_as_smart_object(&mut t, d);
assert_eq!(names(&t), ["a_test-1", "a_test-2"], "the second takes the next free number when admitted");
}
#[test]
fn a_cancelled_hold_frees_its_number_for_the_one_admitted_after_it() {
let dir = tmp("cancelled-hold");
let path = write_psd16(&dir, "photo.psd", 20, 20);
let mut s = Session::new();
let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert_eq!((first["name"].as_str(), second["name"].as_str()), (Some("photo-1"), Some("photo-2")));
// photo-1 is cancelled while photo-2 waits; photo-2 is admitted with nothing open.
s.take_held_open(first["held"].as_u64().unwrap()).unwrap();
let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap();
admit_as_smart_object(&mut s, b);
assert_eq!(names(&s), ["photo-1"], "the first free number is taken at admission, with no collision");
}
#[test]
fn the_reply_names_the_document_as_admitted() {
let dir = tmp("reply-name");
let path = write_psd16(&dir, "photo.psd", 20, 20);
let mut s = Session::new();
s.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap();
let held = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert_eq!(held["name"], "photo-1", "{held}");
assert!(held["held"].is_u64(), "{held}");
// The next open skips the prompt: photo-1 is held, so the number read was 2, but admission takes the first free one.
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
assert_eq!(names(&s), ["photo-1"], "{r}");
assert_eq!(r["name"], "photo-1", "the reply carries the name the document was admitted under: {r}");
}
+10 -3
View File
@@ -5,9 +5,12 @@ use photocraft_io::{ImportResult, raw::RawTuning};
use super::{EngineError, Result, Session, Value, json};
/// The prior settings of a sidecar, and its crop (in the raw's stored orientation).
type Prior = (CameraRaw, Option<photocraft_io::camera_raw_xmp::Crop>);
/// Read only a bounded regular XMP file. No ambient sidecar reads are granted to gated sessions.
#[cfg(not(target_arch = "wasm32"))]
fn prior_settings(path: Option<&str>) -> Result<Option<CameraRaw>> {
fn prior_settings(path: Option<&str>) -> Result<Option<Prior>> {
use std::io::Read;
let Some(path) = path else { return Ok(None) };
let sidecar = match path.rsplit_once('.') {
@@ -31,11 +34,11 @@ fn prior_settings(path: Option<&str>) -> Result<Option<CameraRaw>> {
if !report.ignored.is_empty() {
return Err(EngineError::Other(format!("{sidecar}: unsupported prior RAW settings: {}", report.ignored.join(", "))));
}
Ok(Some(settings))
Ok(Some((settings, report.crop)))
}
#[cfg(target_arch = "wasm32")]
fn prior_settings(path: Option<&str>) -> Result<Option<CameraRaw>> {
fn prior_settings(path: Option<&str>) -> Result<Option<Prior>> {
if path.is_some() { Err(EngineError::Other("cannot read RAW sidecars on the web".into())) } else { Ok(None) }
}
@@ -67,6 +70,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8])
return Err(EngineError::Other("RAW sidecar opening requires a trusted session; no implicit read capability is available".into()));
}
let prior = prior_settings(path)?;
let crop = prior.as_ref().and_then(|(_, crop)| *crop);
let prior = prior.map(|(settings, _)| settings);
let mut settings = prior.clone().unwrap_or_default();
let opts = crate::jobs::import_options(s.prefs());
let mut imported = photocraft_io::import_with_options(name, bytes, &opts).map_err(|error| EngineError::Other(format!("{name}: {error}")))?;
@@ -87,6 +92,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8])
clarity: settings.clarity,
texture: settings.texture,
dehaze: settings.dehaze,
// The sidecar's crop, as the interactive open applies it: before the Camera Raw step.
crop,
};
if tuning != (RawTuning { demosaic: opts.demosaic, ..Default::default() }) {
super::open::raw_defaults(s, &imported.warnings, &mut imported.document);
+21
View File
@@ -734,6 +734,27 @@ fn batch_quiet_raw_applies_prior_settings_and_workflow_without_touching_inputs()
assert!(s.batch_open_notices.is_none());
}
/// #9985: quiet RAW opening crops to the sidecar's crop, as the interactive open does.
#[test]
fn batch_quiet_raw_applies_the_sidecar_crop() {
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
let dir = tmp("batch-quiet-raw-crop");
let path = join(&dir, "raw.dng");
let (w, h) = (24, 16);
let bytes = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build();
std::fs::write(&path, &bytes).unwrap();
let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 };
let xmp = photocraft_io::camera_raw_xmp::save_with_crop(&photocraft_algo::camera_raw::CameraRaw::default(), Some(crop));
std::fs::write(join(&dir, "raw.xmp"), &xmp).unwrap();
let mut s = Session::new();
let output = join(&dir, "out");
let p = json!({"input":[path],"output":output,"format":"psd","suppressOpenOptions":true,"suppressWarnings":true,"steps":[]});
let result = s.execute("file.automate.batch", p).unwrap();
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
let actual = photocraft_io::import("raw.psd", &std::fs::read(join(&output, "raw.psd")).unwrap()).unwrap().document;
assert_eq!((actual.size.width, actual.size.height), (12, 8), "the 24x16 frame cropped to x 3..15, y 4..12");
}
fn tmp(name: &str) -> String {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+32 -10
View File
@@ -22,14 +22,33 @@ fn has_notes(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| !d.doc.notes.is_empty()) { Ok(()) } else { Err("the document has no notes".into()) }
}
/// PDF-style date (`D:YYYYMMDDhhmmssZ`) as Photoshop stores in notes; empty without a clock.
/// Photoshop's default note colour (#11795): RGB 255, 255, 131 (stored as 16-bit 0x8383 blue).
pub const DEFAULT_NOTE_COLOR: [f32; 3] = [1.0, 1.0, 131.0 / 255.0];
/// The PDF date (`D:YYYYMMDDHHMMSS+HH'mm'`) Photoshop stores in a note: local time with its
/// offset from UTC (#11795), through chrono. Empty without a clock (the web build).
fn pdf_date() -> String {
let iso = crate::analysis_cmds::now_iso();
if iso.len() < 19 {
return String::new();
#[cfg(not(target_arch = "wasm32"))]
{
let now = chrono::Local::now();
pdf_date_at(&now.with_timezone(now.offset()))
}
let digits: String = iso[..19].chars().filter(char::is_ascii_digit).collect();
format!("D:{digits}Z")
#[cfg(target_arch = "wasm32")]
{
String::new()
}
}
/// [`pdf_date`] for the moment `at`: its own wall-clock time and UTC offset.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn pdf_date_at<Tz: chrono::TimeZone>(at: &chrono::DateTime<Tz>) -> String
where
Tz::Offset: std::fmt::Display,
{
use chrono::Offset;
let off = at.offset().fix().local_minus_utc();
let (sign, off) = if off < 0 { ('-', -off) } else { ('+', off) };
format!("D:{}{sign}{:02}'{:02}'", at.format("%Y%m%d%H%M%S"), off / 3600, off % 3600 / 60)
}
pub fn note_json(i: usize, n: &Note) -> Value {
@@ -44,7 +63,8 @@ fn color_of(cmd: &str, p: &Value) -> Result<Option<Color>> {
if p.get("color").is_none() {
return Ok(None);
}
let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?;
let [r, g, b] = DEFAULT_NOTE_COLOR;
let c = crate::commands::color_param(cmd, p, "color", [r, g, b, 1.0])?;
Ok(Some(Color::rgb(c[0], c[1], c[2])))
}
@@ -65,10 +85,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
author: p.get("author").and_then(Value::as_str).unwrap_or("").to_string(),
text: p.get("text").and_then(Value::as_str).unwrap_or("").to_string(),
position: [x, y],
popup: [x + 20.0, y + 20.0, x + 260.0, y + 160.0],
// Photoshop's popup: 240 × 140, at the icon's top-left + (8, 10) (#11795).
popup: [x + 8.0, y + 10.0, x + 248.0, y + 150.0],
color: Color::rgb(DEFAULT_NOTE_COLOR[0], DEFAULT_NOTE_COLOR[1], DEFAULT_NOTE_COLOR[2]),
open: p.get("open").and_then(Value::as_bool).unwrap_or(true),
modified: pdf_date(),
..Default::default()
};
if let Some(c) = color_of(CMD, p)? {
n.color = c;
@@ -270,7 +291,8 @@ mod tests {
let d = s.active().unwrap().doc.clone();
assert_eq!(d.notes[0].text, "fixed");
assert_eq!(d.notes[0].position, [20.0, 12.0]);
assert_eq!(d.notes[0].popup[0], 40.0);
// The popup travels with its icon: 20 + 8 (#11795).
assert_eq!(d.notes[0].popup[0], 28.0);
// Unchanged edits don't add history.
let before = s.active().unwrap().history.past_len();
assert_eq!(s.execute("notes.set", json!({"index": 0, "text": "fixed"})).unwrap()["changed"], false);
@@ -0,0 +1,116 @@
//! #11795: what a new note stores, as in Photoshop: colour 255, 255, 131, a 240 × 140 popup at the
//! icon's top-left + (8, 10), and a local-time PDF date with its offset. Document operations move
//! the popup with its icon and keep its size.
use chrono::{FixedOffset, TimeZone};
use serde_json::json;
use super::notes_cmds::{DEFAULT_NOTE_COLOR, pdf_date_at};
use crate::Session;
fn session(w: u32, h: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap();
s
}
fn note(s: &Session, i: usize) -> photocraft_doc::Note {
s.active().unwrap().doc.notes[i].clone()
}
#[test]
fn a_new_note_has_the_default_colour_and_popup() {
let mut s = session(600, 400);
s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap();
let n = note(&s, 0);
let rgb = n.color.to_rgb();
assert_eq!(rgb.map(|v| (v * 255.0).round() as i32), [255, 255, 131], "{rgb:?}");
assert_eq!(DEFAULT_NOTE_COLOR.map(|v| (v * 65535.0).round() as u32), [0xffff, 0xffff, 0x8383], "the 16-bit components Photoshop saves");
assert_eq!(n.popup, [108.0, 110.0, 348.0, 250.0], "icon + (8, 10), 240 × 140");
// A note on the pasteboard keeps its popup there too.
s.execute("notes.add", json!({"x": -178, "y": 300})).unwrap();
assert_eq!(note(&s, 1).popup, [-170.0, 310.0, 70.0, 450.0]);
// An explicit colour still wins.
s.execute("notes.add", json!({"x": 10, "y": 10, "color": [0.25, 0.5, 1.0]})).unwrap();
assert_eq!(note(&s, 2).color.to_rgb(), [0.25, 0.5, 1.0]);
}
#[test]
fn the_date_is_local_time_with_its_offset() {
let at = |offset: i32| FixedOffset::east_opt(offset).unwrap().with_ymd_and_hms(2026, 11, 6, 20, 14, 55).unwrap();
assert_eq!(pdf_date_at(&at(3600)), "D:20261106201455+01'00'");
assert_eq!(pdf_date_at(&at(0)), "D:20261106201455+00'00'");
assert_eq!(pdf_date_at(&at(-(5 * 3600 + 30 * 60))), "D:20261106201455-05'30'");
// A new note's own date has that shape.
let mut s = session(100, 100);
s.execute("notes.add", json!({"x": 10, "y": 10})).unwrap();
let d = note(&s, 0).modified;
let b = d.as_bytes();
assert!(d.len() == 23 && d.starts_with("D:") && matches!(b[16], b'+' | b'-') && b[19] == b'\'' && b[22] == b'\'', "{d}");
assert!(d[2..16].chars().all(|c| c.is_ascii_digit()), "{d}");
}
#[test]
fn image_size_moves_the_icon_and_keeps_the_popup_size() {
let mut s = session(600, 400);
s.execute("notes.add", json!({"x": 100, "y": 99})).unwrap();
let before = note(&s, 0);
s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap();
let n = note(&s, 0);
assert_eq!(n.position, [50.0, 49.5], "the icon point scales as today");
let size = |p: [f64; 4]| [p[2] - p[0], p[3] - p[1]];
assert_eq!(size(n.popup), size(before.popup), "240 × 140 still");
assert_eq!([n.popup[0] - n.position[0], n.popup[1] - n.position[1]], [8.0, 10.0], "still at icon + (8, 10)");
}
#[test]
fn canvas_size_and_a_quarter_turn_keep_the_popup_size() {
let mut s = session(600, 400);
s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap();
s.execute("image.canvasSize", json!({"width": 400, "height": 300, "anchor": "center"})).unwrap();
let n = note(&s, 0);
assert_eq!(n.position, [0.0, 50.0], "moved by the canvas offset");
assert_eq!(n.popup, [8.0, 60.0, 248.0, 200.0]);
s.execute("image.imageRotation.90cw", json!({})).unwrap();
let r = note(&s, 0);
assert_eq!([r.popup[2] - r.popup[0], r.popup[3] - r.popup[1]], [240.0, 140.0], "a turn doesn't swap the popup's sides");
}
#[test]
fn a_mode_change_leaves_notes_alone() {
let mut s = session(100, 100);
s.execute("notes.add", json!({"x": 10, "y": 20})).unwrap();
let before = note(&s, 0);
s.execute("image.mode.cmyk", json!({})).unwrap();
assert_eq!(note(&s, 0), before);
}
#[test]
fn a_turn_keeps_todays_popup_origin_and_the_popup_size() {
// Where the popup sits after a turn wasn't measured (#11795), so it keeps today's origin (the
// top-left of the turned rectangle) and only the size rule applies.
let mut s = session(600, 400);
s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap();
// The popup [108, 110, 348, 250] turned 90° clockwise on a 400 px tall canvas spans x 150..290,
// y 108..348; it starts there and stays 240 × 140.
s.execute("image.imageRotation.90cw", json!({})).unwrap();
assert_eq!(note(&s, 0).popup, [150.0, 108.0, 390.0, 248.0]);
// A flip keeps today's mirrored popup, which already has its size.
let mut s = session(600, 400);
s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap();
s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap();
assert_eq!(note(&s, 0).popup, [252.0, 110.0, 492.0, 250.0]);
}
#[test]
fn an_unset_popup_stays_unset_under_image_size() {
// An all-zero popup means "none stored": PSD export writes its default popup for it, so Image
// Size must not turn it into a 0 × 0 rectangle somewhere else.
let mut s = session(600, 400);
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
doc.notes.push(photocraft_doc::Note { position: [100.0, 100.0], ..Default::default() });
s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap();
let n = note(&s, 0);
assert_eq!(n.position, [50.0, 50.0]);
assert_eq!(n.popup, [0.0; 4]);
}
+1 -1
View File
@@ -260,7 +260,7 @@ pub fn specs() -> Vec<CommandSpec> {
menu: &[],
shortcut: None,
params: brush_params!(
r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal" → {"damage","offset","aligned","nextSource"}"#
r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal","wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","offset","aligned","nextSource"}"#
),
enabled: has_pixel_layer,
run: healing_brush,
+26 -27
View File
@@ -130,26 +130,19 @@ pub(super) fn clone_stamp(s: &mut Session, p: &Value) -> Result<Value> {
let from_live = live.is_some();
let map = clone_mapping(s, p, &stroke, CMD)?;
let off = map.offset();
let dmg =
run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| {
if let Some(l) = live {
*surf = l.surface;
return Ok(l.bounds);
}
if flag(p, "wrap", false) {
let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0);
renderer.push(&stroke.points);
renderer.finish();
let before = surf.clone();
let bounds = renderer.bounds();
return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode }
.draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD));
}
let (bounds, cov) = coverage(CMD, &stroke)?;
// Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned.
let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?;
Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode))
})?;
let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| {
if let Some(l) = live {
*surf = l.surface;
return Ok(l.bounds);
}
if flag(p, "wrap", false) {
return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD));
}
let (bounds, cov) = coverage(CMD, &stroke)?;
// Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned.
let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?;
Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode))
})?;
let mut r = clone_result(dmg, off, aligned, p, &stroke);
if from_live {
r["fromLive"] = json!(true);
@@ -272,11 +265,14 @@ impl LiveRetouch {
/// Everything the stroke has touched so far.
pub fn bounds(&self) -> Rect {
let bounds = self.renderer.bounds().union(&self.tail);
if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) {
periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b))
} else {
bounds
}
if self.wraps() { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds }
}
/// Whether the stroke repeats across the canvas edges (View › Pattern Preview). Only the Clone
/// Stamp and the Healing Brush do: the reference application's other retouching tools stop
/// at the edge.
fn wraps(&self) -> bool {
matches!(self.cmd.as_str(), "paint.cloneStamp" | "paint.healingBrush") && flag(&self.params, "wrap", false)
}
/// Every point rendered so far, in order.
@@ -328,7 +324,7 @@ impl LiveRetouch {
let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect();
self.points.extend_from_slice(&pts);
self.renderer.push(&pts);
if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) {
if self.wraps() {
return self.push_periodic();
}
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
@@ -360,6 +356,8 @@ impl LiveRetouch {
let LivePaint::Clone { which, map, cmd } = &self.paint else {
return Err(bad(&self.cmd, "periodic painting requires a clone source"));
};
let (pre, id, pre_surf, live_cmd) = (&self.pre, self.id, &self.pre_surf, self.cmd.as_str());
let paint = |r| clone_sample(pre, id, pre_surf, *which, r, map, true, cmd);
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
periodic::Composite {
target: surf,
@@ -369,8 +367,9 @@ impl LiveRetouch {
selection: self.sel.as_ref(),
lock: self.lock,
mode: self.mode,
cmd: live_cmd,
}
.draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd))
.draw(dirty, &self.brush, &self.points, paint)
}
/// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`.
+18 -2
View File
@@ -1,4 +1,5 @@
use super::clone::{blend_param, clamp_samples, clone_mapping, clone_result, clone_sample};
use super::periodic;
use super::sampling::{SampleLayers, composite_region, sample_all_layers, sample_layers};
use super::{
BlendMode, CompleteParams, Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, Session, Stroke, Surface, Value, apply_coverage, bad,
@@ -53,15 +54,30 @@ pub(super) fn healing_brush(s: &mut Session, p: &Value) -> Result<Value> {
let map = clone_mapping(s, p, &stroke, CMD)?;
let off = map.offset();
let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| {
// Pattern Preview: source and destination repeat across the canvas edges, so a stroke over
// a seam heals as one patch and goes on wherever it repeats.
let wrap = flag(p, "wrap", false);
let (bounds, cov) = coverage(CMD, &stroke)?;
let (g, cov) = pad_coverage(bounds, &cov, 2);
let fmt = surf.format();
let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?;
let src = clone_sample(pre, id, surf, which, g, &map, wrap, CMD)?;
// The destination the texture is fitted to: the layer itself, or what the user sees when
// sampling several layers (so healing onto an empty layer matches the composite).
let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) };
let dst = if wrap {
periodic::sample(pre, id, surf, which, g, CMD)?
} else if which == SampleLayers::Current {
Region::read(surf, g)
} else {
composite_region(pre, id, which, g, fmt)
};
let mask: Vec<bool> = cov.iter().map(|c| *c > 0.0).collect();
let healed = heal_region(&fmt, &src, &dst, &mask);
if wrap {
return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| {
let part = healed.crop(r);
if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) }
});
}
Ok(apply_coverage(surf, g, &cov, stroke.brush.opacity, sel, lock, &healed, mode))
})?;
Ok(clone_result(dmg, off, aligned, p, &stroke))
+58 -5
View File
@@ -153,6 +153,8 @@ struct Plan {
/// Shift of the repaired area from the selection, and of the texture from the repaired area.
dst_shift: (i32, i32),
src_shift: (i32, i32),
/// Destination mode: a hard rectangular selection moves with the patch.
destination: bool,
offset: (i32, i32),
}
@@ -186,7 +188,7 @@ fn plan(s: &Session, p: &Value) -> Result<Plan> {
PatchMode::Source => ((0, 0), (dx, dy)),
PatchMode::Destination => ((dx, dy), (-dx, -dy)),
};
Ok(Plan { id, canvas, area, dst_shift, src_shift, offset: (dx, dy) })
Ok(Plan { id, canvas, area, dst_shift, src_shift, destination: mode == PatchMode::Destination, offset: (dx, dy) })
}
/// Heal `surf` at the selection (shifted by `dst_shift`) with texture read `src_shift` away, the
@@ -212,16 +214,63 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste
apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal)
}
/// Rows scanned per band by [`is_hard_rect_within`], so the coverage buffer stays small.
const RECT_BAND_ROWS: i32 = 64;
/// Whether `sel` is a hard-edged rectangle: nothing selected outside its own `bounds` and every pixel
/// inside fully selected (the only Destination selection measured to move with the patch). The
/// bounds may reach past the canvas; the patched area is then only the part on the canvas. Bounds
/// of more than [`MAX_PIXELS`] pixels (the patch's own cap) are never classified as a hard
/// rectangle, so a huge selection moved mostly off the canvas is not scanned and stays in place.
fn is_hard_rect(sel: &Surface, bounds: Rect) -> bool {
is_hard_rect_within(sel, bounds, MAX_PIXELS)
}
/// [`is_hard_rect`] with the pixel `limit` given. Scans `bounds` in bands of [`RECT_BAND_ROWS`] rows
/// and stops at the first pixel that is not fully selected.
fn is_hard_rect_within(sel: &Surface, bounds: Rect, limit: u64) -> bool {
let (w, h) = ((i64::from(bounds.x1) - i64::from(bounds.x0)).max(0) as u64, (i64::from(bounds.y1) - i64::from(bounds.y0)).max(0) as u64);
if w.saturating_mul(h) > limit || sel.default_pixel().first().copied().unwrap_or(0.0) > 0.0 {
return false;
}
let mut y = bounds.y0;
while y < bounds.y1 {
let y1 = y.saturating_add(RECT_BAND_ROWS).min(bounds.y1);
if !coverage(sel, Rect::new(bounds.x0, y, bounds.x1, y1)).iter().all(|c| *c >= 1.0) {
return false;
}
y = y1;
}
true
}
pub(super) fn patch(s: &mut Session, p: &Value) -> Result<Value> {
if flag(p, "contentAware", false) {
return patch_content_aware(s, p);
}
let plan = plan(s, p)?;
let label = HISTORY_NAME;
let dmg = run_stroke(s, label, plan.id, p, |pre, surf, _, lock| {
let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?;
Ok(patch_surface(surf, sel, &plan, lock, 1))
})?;
let dmg = if plan.destination {
// Destination: a plain rectangular selection moves with the patch, in the same history state.
let dmg = s.edit(label, |doc, _| {
let sel = doc.selection.clone().ok_or_else(|| bad(CMD, "select the area to patch first"))?;
let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?;
let dmg = patch_surface(surf, &sel, &plan, lock, 1);
let bounds = sel.content_bounds();
if is_hard_rect(&sel, bounds) {
let (dx, dy) = plan.offset;
doc.selection = Some(sel.translated(dx, dy, bounds));
}
Ok(dmg)
})?;
s.hint_damage(dmg);
dmg
} else {
run_stroke(s, label, plan.id, p, |pre, surf, _, lock| {
let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?;
Ok(patch_surface(surf, sel, &plan, lock, 1))
})?
};
Ok(json!({ "damage": damage_json(dmg), "offset": [plan.offset.0, plan.offset.1] }))
}
@@ -305,3 +354,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result<Value> {
},
)
}
#[cfg(test)]
#[path = "patch_destination_selection_tests.rs"]
mod patch_destination_selection_tests;
@@ -0,0 +1,203 @@
//! Patch in Destination mode moves a plain rectangular selection with the patch (#12119).
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 800, "height": 600, "depth": 8, "mode": "rgb"})).unwrap();
s.edit("setup", |doc, active| {
let b = doc.bounds();
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
let mut data = Vec::new();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let v = if (x / 8 + y / 8) % 2 == 0 { 0.2 } else { 0.8 };
let px = if x >= 400 { [0.1, 0.6, 0.2, 1.0] } else { [v, v, v, 1.0] };
data.extend(photocraft_raster::from_rgba(&fmt, px));
}
}
surf.write_region(b, &data);
Ok(())
})
.unwrap();
s
}
fn bounds(s: &mut Session) -> Value {
s.execute("document.inspect", json!({})).unwrap()["selectionBounds"].clone()
}
fn px(s: &Session, x: i32, y: i32) -> [f32; 4] {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
}
fn history_len(s: &Session) -> usize {
s.active().unwrap().history.past_len()
}
fn select_rect(s: &mut Session) {
s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap();
}
const DESTINATION: fn() -> Value = || json!({"mode": "destination", "offset": [460, 280]});
#[test]
fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() {
let mut s = session();
select_rect(&mut s);
let before = px(&s, 600, 420);
let source_before: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect();
let r = s.execute("paint.patch", DESTINATION()).unwrap();
assert_eq!(r["offset"], json!([460, 280]));
// The damage covers the patched area (the 40 px square at the destination, plus the solve's ring).
assert_eq!(r["damage"], json!([578, 398, 44, 44]));
assert_eq!(bounds(&mut s), json!([580, 400, 40, 40]));
assert_ne!(px(&s, 600, 420), before, "pixels changed too");
// Source pixels untouched.
let source_after: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect();
assert_eq!(source_after, source_before);
// The destination carries the source texture: the step between two neighbouring checker cells is kept
// in the same direction and at a similar strength (the seamless clone adds a smooth colour correction, and the
// red channel clamps at 0 on the dark green, so the step shrinks).
let step_src = px(&s, 140, 140)[0] - px(&s, 148, 140)[0];
let step_dst = px(&s, 600, 420)[0] - px(&s, 608, 420)[0];
assert!(step_src * step_dst > 0.0 && step_dst.abs() > 0.2, "texture kept: source step {step_src}, destination step {step_dst}");
// The moved selection is still a hard-edged rectangle exactly filling its bounds.
let d = s.active().unwrap();
let sel = d.doc.selection.as_ref().unwrap();
assert!(is_hard_rect(sel, sel.content_bounds()));
// One history state: a single undo brings back the pixels and the old selection.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(bounds(&mut s), json!([120, 120, 40, 40]));
assert_eq!(px(&s, 600, 420), before);
}
#[test]
fn destination_selection_stays_when_it_is_not_a_plain_rectangle() {
let cases: [(&str, Value, Value); 4] = [
("feathered", json!({"x": 120, "y": 120, "width": 40, "height": 40, "feather": 6.0}), json!(null)),
("ellipse", json!({"x": 120, "y": 120, "width": 40, "height": 40, "ellipse": true}), json!(null)),
("lasso", json!({"points": [[120, 120], [160, 130], [140, 165]]}), json!("lasso")),
("rectangle with a hole", json!({"x": 120, "y": 120, "width": 40, "height": 40}), json!("hole")),
];
for (name, params, kind) in cases {
let mut s = session();
let cmd = if kind == json!("lasso") { "select.lasso" } else { "select.rect" };
s.execute(cmd, params).unwrap();
if kind == json!("hole") {
// Same bounds as the rectangle, but not every pixel inside is fully selected.
s.execute("select.rect", json!({"x": 135, "y": 135, "width": 10, "height": 10, "mode": "subtract"})).unwrap();
}
let sel_before = bounds(&mut s);
let area = |s: &Session| -> Vec<[f32; 4]> { (400..440).flat_map(|y| (580..620).map(move |x| (x, y))).map(|(x, y)| px(s, x, y)).collect() };
let before = area(&s);
s.execute("paint.patch", DESTINATION()).unwrap();
assert_eq!(bounds(&mut s), sel_before, "{name}: the selection stays in place");
assert_ne!(area(&s), before, "{name}: the patch still lands");
}
}
#[test]
fn partial_coverage_and_oversized_rectangles_are_not_constructible_apart_from_the_hole_case() {
// `plan.area` is the selection's own content bounds, so a rectangle larger than the area cannot
// exist, and the area is never wider than the selection. The only way the bounds equal the area
// yet coverage falls short is a hole (covered above). An inverted selection makes the area the
// whole canvas, which no non-zero drag can keep on the canvas, so Patch refuses it.
let mut s = session();
select_rect(&mut s);
s.execute("select.inverse", json!({})).unwrap();
assert!(s.execute("paint.patch", DESTINATION()).is_err());
}
#[test]
fn destination_without_a_selection_fails_and_leaves_history_alone() {
let mut s = session();
// A marker step after the setup, so a stray undo would be visible.
s.edit("marker", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 20, 20), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
let h = history_len(&s);
assert!(s.execute("paint.patch", DESTINATION()).is_err());
assert_eq!(history_len(&s), h, "no history entry");
// Undo reverts the marker (the last real step), not the setup.
s.execute("edit.undo", json!({})).unwrap();
assert_ne!(px(&s, 15, 15), [1.0, 0.0, 0.0, 1.0], "the marker step was undone");
assert!((px(&s, 450, 300)[0] - 0.1).abs() < 0.01 && (px(&s, 450, 300)[1] - 0.6).abs() < 0.01, "the setup step is still there");
}
#[test]
fn redo_restores_the_patched_pixels_and_the_moved_selection() {
let mut s = session();
select_rect(&mut s);
let before = px(&s, 600, 420);
s.execute("paint.patch", DESTINATION()).unwrap();
let patched = px(&s, 600, 420);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(bounds(&mut s), json!([120, 120, 40, 40]));
assert_eq!(px(&s, 600, 420), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(bounds(&mut s), json!([580, 400, 40, 40]));
assert_eq!(px(&s, 600, 420), patched);
}
#[test]
fn source_mode_keeps_the_selection() {
let mut s = session();
select_rect(&mut s);
s.execute("paint.patch", json!({"offset": [260, 0]})).unwrap();
assert_eq!(bounds(&mut s), json!([120, 120, 40, 40]));
}
#[test]
fn content_aware_keeps_the_selection_and_changes_the_pixels() {
let mut s = session();
select_rect(&mut s);
// Content-Aware ignores the mode and fills the selection itself from the dragged-to place (the
// green half), so the pixels under the selection change.
let before = px(&s, 140, 140);
s.execute("paint.patch", json!({"offset": [400, 0], "contentAware": true})).unwrap();
assert_eq!(bounds(&mut s), json!([120, 120, 40, 40]));
assert_ne!(px(&s, 140, 140), before, "the selected area was filled");
}
#[test]
fn destination_moves_a_hard_rectangle_that_extends_past_the_canvas() {
let mut s = session();
// 60x40 rectangle with 20 columns off the left edge; the part on the canvas is x 0..40.
s.execute("select.rect", json!({"x": 0, "y": 100, "width": 60, "height": 40})).unwrap();
s.execute("select.transformSelection", json!({"dx": -20, "dy": 0})).unwrap();
let sel_before = bounds(&mut s);
let before = px(&s, 480, 400);
s.execute("paint.patch", DESTINATION()).unwrap();
assert_ne!(px(&s, 480, 400), before, "the patch lands on the canvas part");
let d = s.active().unwrap();
let sel = d.doc.selection.as_ref().unwrap();
assert_eq!(sel.content_bounds(), Rect::new(440, 380, 500, 420), "the whole rectangle moved, off-canvas part included");
assert!(sel.read_region(Rect::new(440, 380, 500, 420)).chunks_exact(sel.channels().max(1)).all(|p| p[0] >= 1.0));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(bounds(&mut s), sel_before);
assert_eq!(px(&s, 480, 400), before);
}
#[test]
fn hard_rect_check_is_capped_and_scans_every_band() {
let mut s = session();
// 40x150 rectangle: three bands of 64 rows (64 + 64 + 22).
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 40, "height": 150})).unwrap();
let d = s.active().unwrap();
let sel = d.doc.selection.as_ref().unwrap();
let b = sel.content_bounds();
assert_eq!(b, Rect::new(100, 100, 140, 250));
assert!(!is_hard_rect_within(sel, b, 5999), "over the limit is never a hard rectangle");
assert!(is_hard_rect_within(sel, b, 6000), "at the limit it is checked as usual");
assert!(is_hard_rect_within(sel, b, u64::MAX));
// A hole in the third band must still be found.
let mut holed = sel.clone();
let fmt = holed.format();
holed.write_region(Rect::new(120, 240, 121, 241), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 0.0, 0.0]));
assert!(!is_hard_rect_within(&holed, b, u64::MAX), "a hole past the first band fails the check");
}
+31 -2
View File
@@ -1,6 +1,6 @@
//! Bounded reads from the repeating document tile used by Pattern Preview.
use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, StrokePoint, Surface, alpha_index, apply_coverage, bad};
use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, Stroke, StrokePoint, Surface, alpha_index, apply_coverage, bad};
/// At most four disjoint rectangles in the document tile. Projecting the dirty area separately
/// from the stroke bounds avoids scanning the whole document when a small dab crosses a seam.
@@ -23,6 +23,33 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec<Rect> {
ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect()
}
/// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a
/// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and
/// they go on through the stroke's coverage wherever it repeats on the document.
#[allow(clippy::too_many_arguments)]
pub(super) fn stroke(
pre: &Document,
target: &mut Surface,
stroke: &Stroke,
selection: Option<&Surface>,
lock: bool,
mode: BlendMode,
cmd: &str,
mut paint: impl FnMut(&Surface, Rect) -> Result<Region>,
) -> Result<Rect> {
let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0);
renderer.push(&stroke.points);
renderer.finish();
let before = target.clone();
let bounds = renderer.bounds();
Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode, cmd }.draw(
bounds,
&stroke.brush,
&stroke.points,
|r| paint(&before, r),
)
}
/// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels.
/// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and
/// selection are applied once after all dabs, including repeated visits through other tiles.
@@ -34,6 +61,8 @@ pub(super) struct Composite<'a> {
pub selection: Option<&'a Surface>,
pub lock: bool,
pub mode: BlendMode,
/// The command an over-budget stroke's error names.
pub cmd: &'a str,
}
impl Composite<'_> {
@@ -138,7 +167,7 @@ impl Composite<'_> {
);
*work = work.saturating_add(r.width() as u64 * r.height() as u64);
if *work > 64 * 1024 * 1024 {
return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget"));
return Err(bad(self.cmd, "wrapped stroke exceeds its coverage budget"));
}
ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths));
let mut sampled = None;
+189
View File
@@ -2532,3 +2532,192 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() {
}
}
}
// ---------------------------------------------------------------------------------------------
// View › Pattern Preview: Clone Stamp and Healing Brush wrap across the canvas edges (#8883)
// ---------------------------------------------------------------------------------------------
/// The canvas the Pattern Preview tests wrap around.
const WRAP: (u32, u32) = (48, 36);
/// `texture` repeated every canvas, so each copy of a pixel holds the same colour.
fn tiled_texture(x: i32, y: i32) -> [f32; 4] {
texture(x.rem_euclid(WRAP.0 as i32), y.rem_euclid(WRAP.1 as i32))
}
/// A stroke from just inside the corner where the middle copy of a three-by-three tiling meets
/// the next copies, out across both of its edges.
fn corner_stroke(extra: &Value) -> Value {
let mut p = json!({"points": [[84, 64], [106, 79]], "size": 12, "hardness": 100});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
p
}
/// The stroke tools that wrap, with their options. Both composite a paint image through the
/// stroke's coverage (`periodic::Composite`), which matches the ordinary composite only within
/// rounding.
fn wrap_cases() -> [(&'static str, Value); 2] {
[("paint.healingBrush", json!({"offset": [-20, -14]})), ("paint.cloneStamp", json!({"offset": [-20, -14]}))]
}
/// The native-channel difference `periodic::Composite` may leave against the ordinary composite.
fn composite_tolerance(depth: u64) -> f32 {
match depth {
8 => 1.1 / 255.0,
16 => 1.1 / 65535.0,
_ => 0.00001,
}
}
#[test]
fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three() {
// A stroke with `"wrap": true` gives each pixel of the canvas what the same stroke gives the
// copy of that pixel it goes over, on a document holding the canvas tiled three by three.
let (w, h) = (WRAP.0 as usize, WRAP.1 as usize);
for depth in DEPTHS {
for (cmd, extra) in wrap_cases() {
let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb");
paint_layer(&mut canvas, tiled_texture);
let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb");
paint_layer(&mut tiled, tiled_texture);
let (mut wrapped, plain) = (corner_stroke(&extra), corner_stroke(&extra));
wrapped["wrap"] = json!(true);
canvas.execute(cmd, wrapped).unwrap_or_else(|e| panic!("{cmd} at {depth}: {e}"));
let before = layer_pixels(&tiled);
tiled.execute(cmd, plain).unwrap();
let (got, want) = (layer_pixels(&canvas), layer_pixels(&tiled));
let n = got.len() / (w * h);
let at = |x: usize, y: usize| (y * 3 * w + x) * n..(y * 3 * w + x + 1) * n;
let tolerance = composite_tolerance(depth);
let (mut moved, mut edges) = (0, [false; 4]);
for y in 0..h {
for x in 0..w {
let copies = (0..3).flat_map(|j| (0..3).map(move |i| (x + i * w, y + j * h)));
let changed: Vec<_> = copies.filter(|&(u, v)| want[at(u, v)] != before[at(u, v)]).collect();
let (pixel, expected) = match changed.as_slice() {
[] => (&got[(y * w + x) * n..][..n], &before[at(x, y)]),
[(u, v)] => (&got[(y * w + x) * n..][..n], &want[at(*u, *v)]),
_ => panic!("{cmd}: ({x}, {y}) has two copies under the stroke"),
};
let error = pixel.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: ({x}, {y}) is off by {error}");
if !changed.is_empty() {
moved += 1;
edges[0] |= x < 8;
edges[1] |= x >= w - 8;
edges[2] |= y < 8;
edges[3] |= y >= h - 8;
}
}
}
assert!(moved > 150 && edges.iter().all(|&e| e), "{cmd} {extra} at {depth}-bit: the stroke crossed both edges ({moved} px, {edges:?})");
}
}
}
#[test]
fn a_wrapped_stroke_inside_the_canvas_paints_as_an_ordinary_one() {
let p = json!({"points": [[130, 110], [150, 118], [168, 112]], "size": 14, "hardness": 60});
for depth in [8, 32] {
for (cmd, extra) in wrap_cases() {
let run = |wrap: bool| {
let mut s = session(300, 230, depth, "rgb");
paint_layer(&mut s, tiled_texture);
let mut q = p.clone();
for (k, v) in extra.as_object().unwrap() {
q[k] = v.clone();
}
q["wrap"] = json!(wrap);
s.execute(cmd, q).unwrap();
layer_pixels(&s)
};
let (plain, wrapped) = (run(false), run(true));
let error = plain.iter().zip(&wrapped).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(error <= composite_tolerance(depth), "{cmd} {extra} at {depth}-bit: off by {error}");
}
}
}
#[test]
fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() {
for (cmd, extra) in wrap_cases() {
let mut s = session(16, 12, 8, "rgb");
paint_layer(&mut s, tiled_texture);
let before = layer_pixels(&s);
let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
let r = s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
let d = r["damage"].as_array().map(|d| d.iter().filter_map(Value::as_i64).collect::<Vec<_>>()).unwrap_or_default();
assert!(matches!(d.as_slice(), [x, y, w, h] if *x >= 0 && *y >= 0 && x + w <= 16 && y + h <= 12), "{cmd}: damage {d:?} is on the canvas");
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas");
assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas");
}
}
#[test]
fn the_tools_the_reference_application_does_not_wrap_stop_at_the_canvas_edge() {
// Measured in the reference application: in Pattern Preview the Clone Stamp and Healing
// Brush carry a stroke on across the canvas edges, but Blur … Sponge, Color Replacement,
// Background Eraser, Pattern Stamp and History Brush warn that they don't wrap, and Spot
// Healing stops at the edge too. Their `wrap` stroke over the corner paints as the same
// stroke without it.
let points = [[36.0, 24.0], [58.0, 39.0]];
let cases = [
("paint.blur", json!({"strength": 80})),
("paint.sharpen", json!({"strength": 60})),
("paint.smudge", json!({"strength": 70})),
("paint.dodge", json!({"exposure": 60})),
("paint.burn", json!({"exposure": 40})),
("paint.sponge", json!({"mode": "saturate"})),
("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60})),
("paint.backgroundEraser", json!({"tolerance": 40})),
("paint.patternStamp", json!({"pattern": "Checkerboard"})),
("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7})),
("paint.historyBrush", json!({"state": 1})),
("paint.spotHealing", json!({"type": "contentAware"})),
];
for (cmd, extra) in cases {
let run = |wrap: bool| {
let mut s = session(WRAP.0, WRAP.1, 8, "rgb");
paint_layer(&mut s, tiled_texture);
// Painted over since history state 1, so the History Brush has something to bring back.
paint_layer(&mut s, |x, y| tiled_texture(x + 7, y + 5));
let mut p = dab_opts(&points, 100, &extra);
p["size"] = json!(12);
p["wrap"] = json!(wrap);
let before = layer_pixels(&s);
s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
(before, layer_pixels(&s))
};
let ((before, plain), (_, wrapped)) = (run(false), run(true));
assert!(plain != before, "{cmd} {extra}: the stroke paints inside the canvas");
assert!(wrapped == plain, "{cmd} {extra}: nothing comes back at the opposite edges");
}
}
#[test]
fn live_history_and_pattern_strokes_stop_at_the_canvas_edge() {
// The History Brush and aligned Pattern Stamp previews stop at the edge as their commits do.
let points = [[36.0, 24.0], [58.0, 39.0]];
let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect();
for (cmd, extra) in [("paint.historyBrush", json!({"state": 1})), ("paint.patternStamp", json!({"pattern": "Checkerboard"}))] {
let bounds = |wrap: bool| {
let mut s = session(WRAP.0, WRAP.1, 8, "rgb");
paint_layer(&mut s, tiled_texture);
paint_layer(&mut s, |_, _| [0.15, 0.2, 0.7, 1.0]);
let mut p = dab_opts(&points[..1], 100, &extra);
p["size"] = json!(12);
p["wrap"] = json!(wrap);
let mut live = LiveRetouch::begin(&s, cmd, &p).unwrap();
live.push(&pts[1..]).unwrap();
live.bounds()
};
assert_eq!(bounds(true), bounds(false), "{cmd}: the live stroke stays where it was drawn");
}
}
+10 -2
View File
@@ -17,6 +17,8 @@ mod modify_window;
mod color_range_performance;
#[cfg(test)]
mod color_range_window_tests;
#[cfg(test)]
mod wand_seed_outside_tests;
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
@@ -139,9 +141,15 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
Some(plane) => plane,
None => sample_rgba8(s, b(p, "sampleAllLayers", false))?,
};
let region = sel::wand_region(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true));
drop(img);
let m = mode(p);
// Subtract and Intersect grow the region from the seed through the selection only (#11779).
// A global match (Contiguous off) ignores the limit, so it skips the full-area mask.
let contiguous = b(p, "contiguous", true);
let within = (contiguous && matches!(m, SelectionMode::Subtract | SelectionMode::Intersect))
.then(|| s.active().and_then(|st| st.doc.selection.as_ref()).map(|sel| sel::mask_from_surface(Some(sel), area)))
.flatten();
let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), contiguous, b(p, "antiAlias", true), within.as_deref());
drop(img);
let selected = s.edit("Magic Wand", |doc, _| {
doc.selection = sel::combine_region(doc.selection.as_ref(), region.as_ref(), m);
Ok(doc.selection.is_some())
@@ -0,0 +1,113 @@
//! #11779: with Contiguous on, the Magic Wand's Subtract and Intersect grow the region from the
//! seed through the current selection only. A seed outside the selection subtracts nothing and
//! intersects to nothing.
use serde_json::json;
use crate::Session;
/// 800 × 600, white, with a grey ramp of ten 40 px blocks from x 100 (100, 105, …, 145).
fn ramp() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
for k in 0..10 {
let v = 100 + 5 * k;
s.execute("select.rect", json!({"x": 100 + 40 * k, "y": 350, "width": 40, "height": 100})).unwrap();
s.execute("edit.fill", json!({"color": format!("#{v:02x}{v:02x}{v:02x}")})).unwrap();
}
s.execute("select.deselect", json!({})).unwrap();
s
}
fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) {
s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap();
}
fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) {
s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap();
}
/// The columns of row 400 that are at least half selected: (first, end), or `None`. Every
/// column in between must be selected too, so a hole in the run fails the test.
fn selected(s: &Session) -> Option<(i32, i32)> {
let doc = &s.active().unwrap().doc;
let row = photocraft_geom::Rect::new(0, 400, 800, 401);
let m = photocraft_algo::selection::mask_from_surface(doc.selection.as_ref(), row);
let first = m.iter().position(|v| *v >= 0.5)?;
let last = m.iter().rposition(|v| *v >= 0.5)?;
assert!(m[first..=last].iter().all(|v| *v >= 0.5), "a hole in the selected run {first}..={last}");
Some((first as i32, last as i32 + 1))
}
/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them, plus
/// the Anti-alias fringe: column 380 of grey 135 at about 251/255, as measured in #11776.
fn ramp_selected() -> Session {
let mut s = ramp();
wand(&mut s, 110, 32.0, "replace");
assert_eq!(selected(&s), Some((100, 381)));
s
}
#[test]
fn a_seed_outside_the_selection_subtracts_nothing() {
let mut s = ramp_selected();
// Grey 145 (x 460–500) is outside the selection, though its own region reaches into it.
wand(&mut s, 480, 32.0, "subtract");
assert_eq!(selected(&s), Some((100, 381)), "unchanged");
}
#[test]
fn a_seed_outside_the_selection_intersects_to_nothing() {
let mut s = ramp_selected();
wand(&mut s, 480, 32.0, "intersect");
assert_eq!(selected(&s), None);
}
#[test]
fn a_seed_inside_the_selection_works_as_before() {
// Grey 125 with tolerance 10 reaches greys 115–135; inside the selection that is x 220–380.
// With the default Anti-alias fringe, as measured in #11779: Subtract leaves 100–219 (x 219
// keeps about 6/255, under the 0.5 cut) and Intersect 219–381.
let mut s = ramp_selected();
wand(&mut s, 320, 10.0, "subtract");
assert_eq!(selected(&s), Some((100, 219)));
let mut s = ramp_selected();
wand(&mut s, 320, 10.0, "intersect");
assert_eq!(selected(&s), Some((219, 381)));
}
#[test]
fn contiguous_off_keeps_the_global_match() {
// Without Contiguous the seed's colour matches everywhere, as before: greys 115–145 are
// x 220–500, wherever the seed lies.
let mut s = ramp_selected();
wand_global(&mut s, 480, 32.0, "subtract");
assert_eq!(selected(&s), Some((100, 219)));
let mut s = ramp_selected();
wand_global(&mut s, 480, 32.0, "intersect");
assert_eq!(selected(&s), Some((219, 381)));
}
#[test]
fn without_a_selection_subtract_and_intersect_select_nothing() {
for mode in ["subtract", "intersect"] {
let mut s = ramp();
wand(&mut s, 320, 10.0, mode);
assert_eq!(selected(&s), None, "{mode}");
}
}
#[test]
fn the_fill_passes_only_pixels_at_least_half_selected() {
use photocraft_algo::selection::wand_region_within;
use photocraft_geom::Rect;
// One flat grey row of four pixels; only the coverage limit decides the region.
let img = [[128u8, 128, 128, 255]; 4];
let area = Rect::new(0, 0, 4, 1);
let fill = |within: [f32; 4], seed: i32| wand_region_within(&img, area, (seed, 0), 0.0, true, false, Some(&within));
assert!(fill([0.49, 1.0, 1.0, 1.0], 0).is_none(), "a seed just under half selected finds nothing");
let r = fill([0.5, 1.0, 1.0, 1.0], 0).unwrap();
assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::<Vec<_>>(), [true; 4], "half selected counts");
let r = fill([1.0, 1.0, 0.49, 1.0], 0).unwrap();
assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::<Vec<_>>(), [true, true, false, false], "the fill stops at 0.49");
}
@@ -223,24 +223,24 @@ fn bad_layers_params_are_errors_not_panics() {
}
#[test]
fn straighten_layer_rotates_every_selected_layer() {
// Photoshop's Straighten Layer turns all the selected layers, not just the active one; it
// goes through `edit.transform` without a layer, so it takes the selection like Free Transform.
fn straighten_layer_wants_one_selected_layer() {
// #12033: the reference greys Straighten Layer out while two layers are selected, so it no
// longer turns the whole selection the way Free Transform does
// (`free_transform_moves_every_selected_layer_in_one_step` covers that).
let (mut s, paint, text) = two_layers();
let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text));
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
let steps = s.active().unwrap().history.past_len();
let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap();
assert_eq!(r["cropped"], false);
assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too");
assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned");
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step");
assert!(s.undo());
assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back");
assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected, the type layer active");
assert!(!s.is_enabled("image.analysis.straightenLayer"), "two selected layers");
assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err());
assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "nothing turned");
assert_eq!(s.active().unwrap().history.past_len(), steps, "and no history step");
// With one layer selected, only that layer turns.
s.execute("layer.select", json!({"layer": paint.0})).unwrap();
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
assert!(s.is_enabled("image.analysis.straightenLayer"), "a single layer");
s.execute("image.analysis.straightenLayer", json!({})).unwrap();
assert!(bounds(&s, paint) != p0);
assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put");
+52 -2
View File
@@ -8,7 +8,7 @@
use std::sync::Arc;
use photocraft_color::{Color, PixelFormat};
use photocraft_color::{Color, ColorMode, PixelFormat};
use photocraft_doc::text::{
AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection,
TextRun, TextShape,
@@ -937,6 +937,56 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result<TextLa
Ok(t)
}
/// The name of the history state a new type layer makes: what `type.create` records, and what the
/// Type tool's commit keeps in the modes [`commits_as_selection`] names (#12041).
pub const NEW_TYPE_LAYER_LABEL: &str = "New Type Layer";
/// Whether committing Type-tool text `t` in colour mode `mode` leaves no type layer, but a
/// selection in the shape of its glyphs, as Photoshop does in its Indexed Color and Bitmap
/// documents (#12041).
///
/// Both halves of the answer come from the issue:
/// - the modes: Indexed Color and Bitmap are the two documents that keep no type layer;
/// - the text: only the horizontal point text the measurement describes - the tool "still lets you
/// click and type" and the commit leaves "a selection in the shape of the typed glyphs". Paragraph
/// (box) text and vertical type are on its *Not measured* list, whose rule is that an unmeasured
/// case keeps today's behaviour - and today's behaviour is the outcome that makes sense here
/// anyway: a coverage mask cannot give back a box that reflows or a column of stacked lines, so
/// the selection would end the editability the layer exists for. The Type Mask tools keep turning
/// both into the selection, as they do today: there the user asked for one.
///
/// The check belongs to the Type tool's commit (the Type Mask tools' `type.createSelection` with
/// `removeLayer`, which it already runs for `ed.mask`) and *not* to the `type.create` command, even
/// though a colour-mode check there is what looks simplest:
/// - `type.create` is the primitive the tools build the layer they edit and show on canvas with
/// (`pointer_up` in `ui-egui`), and Preferences > Type > "Fill new type layers with placeholder
/// text" (on by default) already gives that first call non-empty text. Branching there would make
/// the click produce a selection and no layer, so there would be nothing to type into - while the
/// reference application still lets you click and type in these modes and shows the text while
/// editing. Only its commit differs.
/// - `type.create` is also what automation, actions and a script's new text layer go through, and
/// the measured difference is the tool's.
pub fn commits_as_selection(mode: ColorMode, t: &TextLayer) -> bool {
let measured = matches!(t.shape, TextShape::Point) && t.orientation == Orientation::Horizontal;
measured && matches!(mode, ColorMode::Indexed | ColorMode::Bitmap)
}
/// Name the step the Type tool's commit made in such a document (#12041). The commit runs the Type
/// Mask tools' `type.createSelection` with `removeLayer` - the same conversion, coalesced into the
/// one step of the typing session - and that command names the step "Type Mask", while Photoshop's
/// history state here is the type layer's own name.
///
/// An edit that does not carry the session's `coalesce` key (the Alt+arrow kerning step, which is
/// deliberately one state per press) ends the session's step, exactly as it does for the Type Mask
/// tools and for the plain type layer's rename on commit; the conversion is then a state of its own
/// and takes this name too. Photoshop repeats a state name whenever the same thing happens again,
/// so the two states read as the two type-layer steps they are.
pub fn name_selection_commit(s: &mut Session) {
if let Some(st) = s.active_mut() {
st.history.set_current_label(NEW_TYPE_LAYER_LABEL);
}
}
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","baseline":"normal|superscript|subscript","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
pub fn specs() -> Vec<CommandSpec> {
@@ -953,7 +1003,7 @@ pub fn specs() -> Vec<CommandSpec> {
let mut t = new_text_layer(s, p, "type.create")?;
let given = p.get("name").and_then(Value::as_str).map(str::to_string);
let name = given.clone().unwrap_or_else(|| layer_name(&t.text));
let id = s.edit("New Type Layer", |doc, active| {
let id = s.edit(NEW_TYPE_LAYER_LABEL, |doc, active| {
refresh(doc, &mut t);
let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Text(t)), crate::layer_place::Place::AutoEditable);
*active = Some(id);
@@ -0,0 +1,287 @@
//! #12041: committing Type-tool text in an Indexed Color or a Bitmap document.
//!
//! The reference application still lets you click and type there and shows the text while editing;
//! the commit leaves **no type layer** and a selection in the shape of the glyphs, as one history
//! state named "New Type Layer", and paints nothing. [`super::type_cmds::commits_as_selection`] is
//! that mode check and [`super::type_cmds::name_selection_commit`] the step's name; the conversion
//! is the Type Mask tools' `type.createSelection` with `removeLayer`, coalesced into the typing
//! session, which is what [`commit`] below runs and what `ui-egui`'s `commit_here` now does for the
//! Type tool in these modes.
//!
//! `type.create` itself stays mode-agnostic on purpose, and
//! [`the_type_layer_command_keeps_making_the_tools_working_layer`] pins that: the tools make the
//! layer they edit and show on canvas with that command, on the first click already with text in it
//! when Preferences > Type > "Fill new type layers with placeholder text" is on (the default), so a
//! mode branch there would take away the layer the session types into.
//!
//! The issue measures the horizontal point text you click and type, and lists paragraph text and
//! vertical type under *Not measured*, where its rule is that today's behaviour stays:
//! [`only_the_measured_horizontal_point_text_converts_in_these_modes`] pins that half of the check,
//! and [`an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states`] what happens to the
//! states when an edit outside the session's coalesce group comes between the typing and the commit.
use super::type_cmds::{commits_as_selection, name_selection_commit, new_text_layer};
use super::*;
use photocraft_doc::text::{Orientation, TextShape};
/// The typing session's coalesce key, as `ui-egui`'s `session_key` gives one.
const SESSION: &str = "type-session-12041";
/// The text the tool commits, at a baseline inside every document used here. `coalesce` for the
/// tool's own calls (one step for the session), without it for a script's or the pin's.
fn typed(coalesce: bool) -> Value {
let mut p = json!({ "text": "Hi", "x": 20, "y": 80, "size": 48 });
if coalesce {
p["coalesce"] = json!(SESSION);
}
p
}
/// A blank RGB document, to be converted to the mode under test.
fn blank() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 240, "height": 120, "background": "transparent"})).unwrap();
s
}
fn indexed() -> Session {
let mut s = blank();
s.execute("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})).unwrap();
assert_eq!(mode(&s), ColorMode::Indexed, "the setup made an Indexed Color document");
s
}
fn bitmap() -> Session {
let mut s = blank();
s.execute("image.mode.grayscale", json!({})).unwrap();
s.execute("image.mode.bitmap", json!({"method": "threshold", "frequency": 12, "shape": "diamond"})).unwrap();
assert_eq!(mode(&s), ColorMode::Bitmap, "the setup made a Bitmap document");
s
}
fn mode(s: &Session) -> ColorMode {
s.active().unwrap().doc.mode
}
/// The Type tool's commit of the session's working layer in a document [`commits_as_selection`]
/// names: the Type Mask tools' conversion, in the session's one step, which keeps the name the step
/// was opened with.
fn commit(s: &mut Session, layer: u64) {
s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).unwrap();
name_selection_commit(s);
}
fn text_layers(s: &Session) -> usize {
s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count()
}
fn layers(s: &Session) -> usize {
s.active().unwrap().doc.walk().len()
}
/// The selection's coverage over the whole canvas, in document pixels.
fn coverage(s: &Session) -> Vec<f32> {
let d = s.active().unwrap().doc.clone();
let b = d.bounds();
let mut out = Vec::new();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
out.push(d.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0)));
}
}
out
}
/// The selection's bounds, as its corners.
fn selection_corners(s: &Session) -> Option<[i32; 4]> {
s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()).map(|r| [r.x0, r.y0, r.x1, r.y1])
}
/// Every layer's pixels, quantised: what the commit must leave exactly as it was.
fn pixels(s: &Session) -> Vec<u8> {
let d = s.active().unwrap().doc.clone();
let b = d.bounds();
let mut out = Vec::new();
for (_, _, l) in d.walk() {
let Some(surface) = l.surface() else { continue };
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
out.extend(surface.rgba(x, y).map(|v| (v * 255.0).round() as u8));
}
}
}
out
}
fn history(s: &Session) -> Vec<String> {
s.active().unwrap().history.entries()
}
#[test]
fn committing_in_indexed_color_or_bitmap_leaves_the_glyphs_selected_and_no_layer() {
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
let mut s = make();
let (steps, layers_before, pixels_before) = (history(&s).len(), layers(&s), pixels(&s));
// The tool's session: a type layer to edit and show while typing, as measured.
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
assert_eq!(text_layers(&s), 1, "{name}: typing happens on a layer the tool can show");
commit(&mut s, layer);
assert_eq!(text_layers(&s), 0, "{name}: committing leaves no type layer");
assert_eq!(layers(&s), layers_before, "{name}: the working layer went with it");
assert!(s.active().unwrap().doc.selection.is_some(), "{name}: the glyphs are selected");
let selected = s.active().unwrap().doc.selection.as_ref().unwrap();
assert!(!selected.content_bounds().is_empty(), "{name}: the selection has a shape");
assert!(coverage(&s).iter().any(|v| *v > 0.0), "{name}: the selected pixels are the glyphs'");
assert_eq!(pixels(&s), pixels_before, "{name}: the commit paints nothing");
// One step for the whole session, named as Photoshop names it.
let h = history(&s);
assert_eq!(h.len(), steps + 1, "{name}: the session is one history state: {h:?}");
assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: its name");
// And undoing that one state gives the document back as it was.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none(), "{name}: undo takes the selection");
assert_eq!(layers(&s), layers_before, "{name}: undo takes the whole session");
assert_eq!(pixels(&s), pixels_before, "{name}: nothing else was changed");
}
}
#[test]
fn the_selection_is_the_glyph_shape_the_type_mask_of_the_same_text_makes() {
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
let mut s = make();
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
commit(&mut s, layer);
let (got, got_bounds) = (coverage(&s), selection_corners(&s));
// The pin: the same text straight through the Type Mask command.
let mut pin = make();
pin.execute("type.createSelection", typed(false)).unwrap();
assert_eq!(got_bounds, selection_corners(&pin), "{name}: same selection bounds");
assert_eq!(got, coverage(&pin), "{name}: same selection coverage");
}
}
#[test]
fn rgb_grayscale_cmyk_and_lab_keep_making_type_layers_at_every_depth() {
for (id, want) in [("rgb", ColorMode::Rgb), ("gray", ColorMode::Grayscale), ("cmyk", ColorMode::Cmyk), ("lab", ColorMode::Lab)] {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 240, "height": 120, "mode": id, "depth": depth, "background": "transparent"})).unwrap();
assert_eq!(mode(&s), want, "{id} at {depth} bit: the document really is that mode");
let point = new_text_layer(&s, &typed(false), "type.create").unwrap();
assert!(!commits_as_selection(want, &point), "{id} keeps type layers");
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
// Today's commit: rename the session's layer in the same step, select nothing.
let name = super::type_cmds::layer_name("Hi");
s.execute("type.edit", json!({"layer": layer, "name": name, "coalesce": SESSION})).unwrap();
assert_eq!(text_layers(&s), 1, "{id} at {depth} bit keeps its type layer");
assert!(s.active().unwrap().doc.selection.is_none(), "{id} at {depth} bit selects nothing");
let h = history(&s);
assert_eq!(h.len(), 2, "{id} at {depth} bit: open plus one step for the session: {h:?}");
assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{id} at {depth} bit keeps the step's name");
}
}
}
#[test]
fn the_type_layer_command_keeps_making_the_tools_working_layer_in_these_modes() {
// `type.create` is the primitive the Type tools create the layer they edit with (and scripts and
// actions their text layers with), so the colour-mode branch is the commit's, not its own.
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
let mut s = make();
let id = s.execute("type.create", typed(false)).unwrap()["layer"].as_u64().unwrap();
assert!(matches!(s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Text(_)), "{name}: the command still makes the layer");
assert!(s.active().unwrap().doc.selection.is_none(), "{name}: and selects nothing itself");
assert_eq!(history(&s).last().map(String::as_str), Some("New Type Layer"), "{name}: its own step name");
}
}
#[test]
fn a_refused_conversion_leaves_the_typed_text_and_the_document_alone() {
// The commit's fallback in `ui-egui` keeps today's type layer when the conversion is refused,
// so the typed text is not lost with a selection that was never made. Here the document would
// have to keep no layer at all, which is what `type.createSelection` with `removeLayer` refuses.
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
let mut s = make();
let only = s.active().unwrap().doc.layers[0].id.0;
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.delete", json!({"layer": only})).unwrap();
let (pixels_before, steps, label) = (pixels(&s), history(&s).len(), history(&s).last().cloned());
assert!(
s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).is_err(),
"{name}: the conversion is refused"
);
// `commit_here` stops there and keeps the layer, so the step is never renamed either.
assert_eq!(text_layers(&s), 1, "{name}: the typed text stays as a type layer");
assert!(s.active().unwrap().doc.selection.is_none(), "{name}: nothing was selected");
assert_eq!(pixels(&s), pixels_before, "{name}: the refused step painted nothing");
assert_eq!(history(&s).len(), steps, "{name}: the refused step is no new state");
assert_eq!(history(&s).last().cloned(), label, "{name}: the refused conversion renames nothing");
}
}
/// The measurement covers horizontal point text. Paragraph (box) text and vertical type are on the
/// issue's *Not measured* list, where its rule is that today's behaviour stays - and a coverage mask
/// could not give a reflowing box or a stacked column back anyway, so their editable layer stays.
#[test]
fn only_the_measured_horizontal_point_text_converts_in_these_modes() {
let s = indexed();
let point = new_text_layer(&s, &typed(false), "type.create").unwrap();
assert!(matches!(point.shape, TextShape::Point) && point.orientation == Orientation::Horizontal, "the fixture is horizontal point text");
let mut box_params = typed(false);
box_params["box"] = json!([20.0, 20.0, 180.0, 80.0]);
let paragraph = new_text_layer(&s, &box_params, "type.create").unwrap();
assert!(matches!(paragraph.shape, TextShape::Box { .. }), "the fixture is paragraph text");
let mut vertical_params = typed(false);
vertical_params["orientation"] = json!("vertical");
let vertical = new_text_layer(&s, &vertical_params, "type.create").unwrap();
assert_eq!(vertical.orientation, Orientation::Vertical, "the fixture is vertical text");
for mode in [ColorMode::Indexed, ColorMode::Bitmap] {
assert!(commits_as_selection(mode, &point), "{mode:?}: the measured text becomes the selection");
assert!(!commits_as_selection(mode, &paragraph), "{mode:?}: paragraph text keeps its type layer");
assert!(!commits_as_selection(mode, &vertical), "{mode:?}: vertical text keeps its type layer");
}
}
/// An edit that does not carry the session's `coalesce` key ends its coalesce group - in the tool
/// that is the deliberately per-press Alt+arrow kerning step. The commit then makes a state of its
/// own, which is what the Type Mask tools' commit and the plain layer's rename do in the same
/// situation today; nothing is mis-named and nothing else moves.
#[test]
fn an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states() {
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
let mut s = make();
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
// The stand-in for that kerning press: a real edit under no coalesce key.
s.execute("type.edit", json!({"layer": layer, "antialias": "sharp"})).unwrap();
let before = history(&s);
assert_eq!(before.last().map(String::as_str), Some("Edit Type"), "{name}: the unkeyed edit is a state of its own");
commit(&mut s, layer);
let h = history(&s);
assert_eq!(h.len(), before.len() + 1, "{name}: the commit is a further state once the group ended: {h:?}");
assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: named for the type layer either way");
assert_eq!(h[h.len() - 2].as_str(), "Edit Type", "{name}: the edit between them keeps its own name");
assert_eq!(text_layers(&s), 0, "{name}: still no type layer is left");
assert!(s.active().unwrap().doc.selection.is_some(), "{name}: still the glyphs are selected");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none(), "{name}: one undo takes the conversion");
assert_eq!(text_layers(&s), 1, "{name}: which brings the working layer back, as the mask tools' undo does");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(text_layers(&s), 1, "{name}: the next undo takes the unkeyed edit, not the session");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(text_layers(&s), 0, "{name}: and the third one takes the typing session");
}
}
+1 -1
View File
@@ -2156,7 +2156,7 @@ mod tests;
#[cfg(test)]
#[path = "../../io/tests/support/recovery_psd.rs"]
mod psd_open_fixture;
pub(crate) mod psd_open_fixture;
#[cfg(test)]
mod psd_open_confirmation_tests {
+2
View File
@@ -357,4 +357,6 @@ mod tests;
#[cfg(test)]
mod fx_mode_tests;
#[cfg(test)]
mod brush_clear_background_tests;
pub mod command_hints;
+5
View File
@@ -193,6 +193,11 @@ impl Session {
self.next_held_open
}
/// Whether a document held back for its prompt already has `name`.
pub(crate) fn held_open_named(&self, name: &str) -> bool {
self.held_opens.iter().any(|(_, d, _)| d.name == name)
}
/// Takes a document a background open held back for the profile prompt: the document and
/// whether its source is read-only. `None` when there is no such document (already taken).
pub fn take_held_open(&mut self, id: u64) -> Option<(Document, bool)> {
+6
View File
@@ -16,6 +16,9 @@ corpus = []
heif = ["photocraft-codecs/heif"]
# Test support for synthetic memory injection, estimate charges and verification across crates.
testgen = []
# Editable PDF import (experimental): painted paths and embedded-font text become editable
# layers; unsupported constructs keep the raster page.
pdf-editable = ["dep:kurbo"]
[dependencies]
photocraft-geom = { workspace = true }
@@ -53,6 +56,9 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste
ttf-parser = { version = "0.25", default-features = false }
# PDF import (pdf_import.rs): craft-pdf rasterizes pages in pure Rust; no shared library ships.
craft-pdf = { workspace = true }
# Editable PDF import (pdf_editable.rs): craft-pdf's Device trait uses kurbo paths and transforms.
# This is the same kurbo 0.13 that craft-pdf already links; naming it adds no crate to the graph.
kurbo = { version = "0.13", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }
+238 -8
View File
@@ -12,6 +12,13 @@
//! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature
//! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no
//! counterpart and are ignored.
//!
//! Crop (#9985): `crs:HasCrop="True"` with `crs:CropTop` / `CropLeft` / `CropBottom` /
//! `CropRight`, fractions of the developed frame in the raw's stored orientation (before its
//! Orientation tag turns it upright), is read into [`LoadReport::crop`]; the raw open crops the
//! document with it ([`crate::raw::RawTuning::crop`]). The settings have no crop (Camera Raw
//! Filter has none). Without `HasCrop`, or with `HasCrop="False"`, the crop keys are stale and
//! mean nothing. A straightened crop (`CropAngle` other than 0) isn't applied.
use photocraft_algo::camera_raw::{CameraRaw, validate_curve};
@@ -126,7 +133,6 @@ const METADATA: &[&str] = &[
"Version",
"ProcessVersion",
"HasSettings",
"HasCrop",
"WhiteBalance",
"ToneCurveName2012",
"CameraProfile",
@@ -147,16 +153,114 @@ const METADATA: &[&str] = &[
"SortName",
"Group",
"Description",
// The raw file the sidecar belongs to (Lightroom and Camera Raw name it).
"RawFileName",
];
/// The crop's keys besides `HasCrop`, and their values without a crop (the whole frame, level).
pub const CROP_KEYS: [&str; 5] = ["CropTop", "CropLeft", "CropBottom", "CropRight", "CropAngle"];
const NO_CROP: [f32; 5] = [0.0, 0.0, 1.0, 1.0, 0.0];
/// A Camera Raw crop: fractions of the developed frame in the raw's stored orientation, before
/// its Orientation tag turns it upright, with `0 <= left < right <= 1` and
/// `0 <= top < bottom <= 1`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Crop {
pub top: f32,
pub left: f32,
pub bottom: f32,
pub right: f32,
}
impl Crop {
/// The crop of the frame turned upright by TIFF orientation `o` (1–8; anything else is 1),
/// as fractions of the upright frame: the corners map as the pixels do.
pub fn oriented(self, o: u16) -> Crop {
let map = |u: f32, v: f32| match o {
2 => (1.0 - u, v),
3 => (1.0 - u, 1.0 - v),
4 => (u, 1.0 - v),
5 => (v, u),
6 => (1.0 - v, u),
7 => (1.0 - v, 1.0 - u),
8 => (v, 1.0 - u),
_ => (u, v),
};
let (ax, ay) = map(self.left, self.top);
let (bx, by) = map(self.right, self.bottom);
Crop { top: ay.min(by), left: ax.min(bx), bottom: ay.max(by), right: ax.max(bx) }
}
/// The crop on a `w × h` frame in pixels, `(x0, y0, x1, y1)` with `x1` and `y1` exclusive:
/// at least one pixel, inside the frame. `None` for an empty frame.
pub fn pixels(self, w: usize, h: usize) -> Option<(usize, usize, usize, usize)> {
let edge = |f: f32, n: usize| ((f64::from(f).clamp(0.0, 1.0) * n as f64).round() as usize).min(n);
let span = |a: f32, b: f32, n: usize| {
let lo = edge(a, n).min(n.checked_sub(1)?);
Some((lo, edge(b, n).max(lo + 1)))
};
let (x0, x1) = span(self.left, self.right, w)?;
let (y0, y1) = span(self.top, self.bottom, h)?;
Some((x0, y0, x1, y1))
}
}
/// What [`load`] applied.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LoadReport {
/// How many settings were applied.
/// How many settings were applied (a crop counts as one).
pub applied: usize,
/// `crs:` settings without a PhotoCraft control (set to something other than their neutral
/// value), or with an unusable value, in file order.
/// value), or with an unusable value, in file order; then the keys of a crop that isn't
/// applied (straightened, unreadable or empty).
pub ignored: Vec<String>,
/// The file's crop, when it has one that isn't the whole frame. The settings hold no crop,
/// so the caller applies it (a raw open crops the document) or reports it.
pub crop: Option<Crop>,
}
/// The crop keys as read, resolved by [`crop`] once the whole file is read.
#[derive(Default)]
struct CropFields {
has: Option<String>,
values: [Option<String>; 5],
}
/// The file's crop from its keys: applied when `HasCrop` is `True` and the values make a level,
/// non-empty rectangle (fractions are clamped to 0..1); otherwise its keys are reported.
fn crop(fields: &CropFields, report: &mut LoadReport) {
match fields.has.as_deref().map(str::trim) {
// No crop: the other keys are what Camera Raw left from an earlier or reset crop.
None => return,
Some(v) if v.eq_ignore_ascii_case("false") => return,
Some(v) if v.eq_ignore_ascii_case("true") => {}
Some(_) => {
report.ignored.push("HasCrop".into());
return;
}
}
let mut v = NO_CROP;
let mut usable = true;
for (slot, text) in v.iter_mut().zip(&fields.values) {
if let Some(text) = text {
match number(text) {
Some(n) => *slot = n,
None => usable = false,
}
}
}
let [top, left, bottom, right] = [v[0], v[1], v[2], v[3]].map(|f| f.clamp(0.0, 1.0));
// A straightened crop's rectangle is measured on the rotated frame: not applied.
usable &= v[4] == 0.0 && top < bottom && left < right;
if !usable {
let present = CROP_KEYS.iter().zip(&fields.values).filter(|(_, t)| t.is_some());
report.ignored.extend(present.map(|(k, _)| k.to_string()));
return;
}
if [top, left, bottom, right] != [0.0, 0.0, 1.0, 1.0] {
report.crop = Some(Crop { top, left, bottom, right });
report.applied += 1;
}
}
fn number(s: &str) -> Option<f32> {
@@ -197,8 +301,14 @@ fn neutral(name: &str, value: &str) -> bool {
}
}
fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) {
if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) {
fn apply(params: &mut CameraRaw, report: &mut LoadReport, crop: &mut CropFields, name: &str, value: &str) {
if name == "HasCrop" {
crop.has = Some(value.to_string());
} else if let Some(i) = CROP_KEYS.iter().position(|k| *k == name) {
if let Some(slot) = crop.values.get_mut(i) {
*slot = Some(value.to_string());
}
} else if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) {
match number(value) {
Some(v) => {
*field(params) = v.clamp(*lo, *hi);
@@ -232,10 +342,11 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result<LoadReport, String>
let mut next = params.clone();
let mut report = LoadReport::default();
let mut found = false;
let mut crop_fields = CropFields::default();
for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") {
for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) {
found = true;
apply(&mut next, &mut report, a.name(), a.value());
apply(&mut next, &mut report, &mut crop_fields, a.name(), a.value());
}
for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) {
found = true;
@@ -254,13 +365,14 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result<LoadReport, String>
report.ignored.push(name.to_string());
}
} else {
apply(&mut next, &mut report, name, c.text().unwrap_or(""));
apply(&mut next, &mut report, &mut crop_fields, name, c.text().unwrap_or(""));
}
}
}
if !found {
return Err("no Camera Raw settings in the file".into());
}
crop(&crop_fields, &mut report);
*params = next;
Ok(report)
}
@@ -295,6 +407,11 @@ pub fn is_photocraft_xmp(bytes: &[u8]) -> bool {
/// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads.
pub fn save(params: &CameraRaw) -> String {
save_with_crop(params, None)
}
/// [`save`] with a raw file's crop, so rewriting a sidecar keeps the crop it was opened with.
pub fn save_with_crop(params: &CameraRaw, crop: Option<Crop>) -> String {
let mut p = params.clone();
let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" };
let mut out = String::from(
@@ -315,6 +432,12 @@ pub fn save(params: &CameraRaw) -> String {
}
let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n);
out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style)));
if let Some(c) = crop {
out.push_str(&format!(
" crs:HasCrop=\"True\"\n crs:CropTop=\"{:.6}\"\n crs:CropLeft=\"{:.6}\"\n crs:CropBottom=\"{:.6}\"\n crs:CropRight=\"{:.6}\"\n crs:CropAngle=\"0\"\n",
c.top, c.left, c.bottom, c.right
));
}
out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n");
for (name, field) in CURVES {
let points = field(&mut p);
@@ -433,7 +556,7 @@ mod tests {
<crs:Clarity2012>abc</crs:Clarity2012></rdf:Description></rdf:RDF></x:xmpmeta>"#;
let r = load(&mut p, xmp).unwrap();
assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0));
assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] });
assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()], crop: None });
}
#[test]
@@ -479,4 +602,111 @@ mod tests {
assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other));
}
}
/// A settings file with the `crs:` attributes `attrs`.
fn with(attrs: &str) -> String {
format!(
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description xmlns:crs=\"{CRS_NS}\" crs:RawFileName=\"IMG_0004.CR2\" {attrs}/></rdf:RDF></x:xmpmeta>"
)
}
fn report(attrs: &str) -> LoadReport {
load(&mut CameraRaw::default(), with(attrs).as_bytes()).unwrap()
}
/// #9985: Lightroom's sidecar named the crop's bottom and right edges and the raw's file name
/// as unsupported; the crop is read now, and the file name is bookkeeping.
#[test]
fn reads_the_crop_and_not_the_file_name() {
let r = report(r#"crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="0""#);
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
assert_eq!(r.crop, Some(Crop { top: 0.1, left: 0.2, bottom: 0.9, right: 0.75 }));
assert_eq!(r.applied, 1);
// Element form, and the keys spread over two descriptions, read the same.
let xmp = format!(
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description xmlns:crs=\"{CRS_NS}\"><crs:HasCrop>True</crs:HasCrop><crs:CropBottom>0.5</crs:CropBottom></rdf:Description><rdf:Description xmlns:crs=\"{CRS_NS}\" crs:CropRight=\"0.5\"/></rdf:RDF>"
);
let r = load(&mut CameraRaw::default(), xmp.as_bytes()).unwrap();
assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.0, bottom: 0.5, right: 0.5 }), "missing edges are the frame's");
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
}
#[test]
fn no_crop_without_has_crop_true() {
let keys = r#"crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="3""#;
for has in ["", r#"crs:HasCrop="False""#, r#"crs:HasCrop="false""#] {
let r = report(&format!("{has} {keys}"));
assert_eq!((r.crop, r.ignored.clone(), r.applied), (None, Vec::new(), 0), "{has:?}: {r:?}");
}
// The whole frame is no crop, and nothing to report.
let r = report(r#"crs:HasCrop="True" crs:CropTop="0" crs:CropLeft="0" crs:CropBottom="1" crs:CropRight="1" crs:CropAngle="0""#);
assert_eq!((r.crop, r.ignored.len()), (None, 0));
let r = report(r#"crs:HasCrop="yes" crs:CropBottom="0.5""#);
assert_eq!((r.crop, r.ignored), (None, vec!["HasCrop".to_string()]));
}
/// Hostile and straightened crops never panic: out-of-range fractions are clamped to the
/// frame; an inverted, empty, unreadable or straightened crop isn't applied and its keys are
/// named.
#[test]
fn unusable_crops_are_reported_not_applied() {
let r = report(r#"crs:HasCrop="True" crs:CropTop="-0.5" crs:CropLeft="0.25" crs:CropBottom="7" crs:CropRight="1.0000001""#);
assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.25, bottom: 1.0, right: 1.0 }));
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
let all = || CROP_KEYS.iter().map(|k| k.to_string()).collect::<Vec<_>>();
for bad in [
r#"crs:CropTop="0.8" crs:CropLeft="0.1" crs:CropBottom="0.2" crs:CropRight="0.9" crs:CropAngle="0""#,
r#"crs:CropTop="0.5" crs:CropLeft="0.1" crs:CropBottom="0.5" crs:CropRight="0.9" crs:CropAngle="0""#,
r#"crs:CropTop="2" crs:CropLeft="0.1" crs:CropBottom="3" crs:CropRight="0.9" crs:CropAngle="0""#,
r#"crs:CropTop="NaN" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#,
r#"crs:CropTop="0.1" crs:CropLeft="inf" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#,
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="" crs:CropAngle="0""#,
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="1.38""#,
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="NaN""#,
] {
let r = report(&format!(r#"crs:HasCrop="True" {bad}"#));
assert_eq!((r.crop, r.ignored, r.applied), (None, all(), 0), "{bad}");
}
let r = report(r#"crs:HasCrop="True" crs:CropAngle="-2.5""#);
assert_eq!(r.ignored, vec!["CropAngle".to_string()], "only the keys the file has");
}
/// Corners map as `photocraft-raw`'s orientation maps pixels (`develop::source_xy`).
#[test]
fn crops_follow_the_orientation() {
let c = Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 };
let want = [
(1, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }),
(2, Crop { top: 0.1, left: 0.5, bottom: 0.6, right: 0.8 }),
(3, Crop { top: 0.4, left: 0.5, bottom: 0.9, right: 0.8 }),
(4, Crop { top: 0.4, left: 0.2, bottom: 0.9, right: 0.5 }),
(5, Crop { top: 0.2, left: 0.1, bottom: 0.5, right: 0.6 }),
(6, Crop { top: 0.2, left: 0.4, bottom: 0.5, right: 0.9 }),
(7, Crop { top: 0.5, left: 0.4, bottom: 0.8, right: 0.9 }),
(8, Crop { top: 0.5, left: 0.1, bottom: 0.8, right: 0.6 }),
(0, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }),
];
for (o, w) in want {
let got = c.oriented(o);
let close = [got.top - w.top, got.left - w.left, got.bottom - w.bottom, got.right - w.right].iter().all(|d| d.abs() < 1e-6);
assert!(close, "orientation {o}: {got:?}");
}
assert_eq!(c.pixels(10, 20), Some((2, 2, 5, 12)));
assert_eq!(Crop { top: 0.5, left: 0.5, bottom: 0.5001, right: 0.5001 }.pixels(10, 10), Some((5, 5, 6, 6)), "at least a pixel");
assert_eq!(Crop { top: 0.999, left: 0.999, bottom: 1.0, right: 1.0 }.pixels(10, 10), Some((9, 9, 10, 10)), "inside the frame");
assert_eq!(c.pixels(0, 10), None);
}
#[test]
fn save_with_crop_round_trips() {
let p = CameraRaw { contrast: 20.0, ..CameraRaw::default() };
let crop = Crop { top: 0.125, left: 0.25, bottom: 0.875, right: 0.5 };
let text = save_with_crop(&p, Some(crop));
assert!(is_photocraft_xmp(text.as_bytes()));
let mut back = CameraRaw::default();
let r = load(&mut back, text.as_bytes()).unwrap();
assert_eq!((back, r.crop), (p.clone(), Some(crop)));
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
assert_eq!(load(&mut CameraRaw::default(), save(&p).as_bytes()).unwrap().crop, None);
}
}
+10
View File
@@ -65,6 +65,8 @@ pub use ora::losses as ora_losses;
pub mod pattern_map;
mod pcraft_cache;
pub mod pdf;
#[cfg(all(feature = "pdf-editable", not(target_arch = "wasm32")))]
mod pdf_editable;
mod pdf_import;
mod pdn;
pub use pcraft_cache::PcraftExportCache;
@@ -612,6 +614,13 @@ pub struct ImportOptions {
pub pdf_dpi: f32,
/// The 1-based, inclusive page range to import; `None` imports every page (up to the cap).
pub pdf_pages: Option<(u32, u32)>,
/// Import PDF painted paths and embedded-font text as editable [`photocraft_doc::ShapeLayer`]s
/// and text layers instead of a page raster (experimental). Images, complex clips/masks,
/// patterns, non-normal blends and unembedded/Type 3 text make that page fall back to the
/// raster importer with a warning. Text that uses fonts embedded in the PDF may change font
/// when edited or reopened, because the document format does not carry the font bytes. Off by
/// default.
pub pdf_editable: bool,
/// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget.
pub psd_high_memory_approved: bool,
/// Optional estimate limit for high-memory approval. When set, approval only applies if
@@ -628,6 +637,7 @@ impl Default for ImportOptions {
flat_max_pixel_bytes: 0,
pdf_dpi: 300.0,
pdf_pages: None,
pdf_editable: false,
psd_high_memory_approved: false,
psd_approved_estimate: None,
}
File diff suppressed because it is too large. Load diff
+30 -1
View File
@@ -135,6 +135,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -
let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = dpi as f32;
#[cfg(feature = "pdf-editable")]
let mut editable_text = false;
let mut x: u32 = 0;
for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() {
ctl.check().map_err(|_| IoError::Cancelled)?;
@@ -147,6 +149,30 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -
if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS {
return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI")));
}
let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32);
#[cfg(feature = "pdf-editable")]
if opts.pdf_editable {
match crate::pdf_editable::page(page, rect, ctl) {
Ok(layers) => {
if layers.iter().any(|layer| matches!(&layer.content, LayerContent::Text(_))) {
editable_text = true;
}
let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group)));
x = x.saturating_add(width).saturating_add(GAP);
ctl.progress((index + 1) as f32 / count as f32);
continue;
}
Err(crate::pdf_editable::EditableError::Cancelled) => return Err(IoError::Cancelled),
Err(crate::pdf_editable::EditableError::Unsupported(reason)) => {
warnings.push(format!("PDF page {page_number}: {reason}; the page was rasterised"));
}
}
}
#[cfg(not(feature = "pdf-editable"))]
if opts.pdf_editable {
warnings.push(format!("PDF page {page_number}: editable PDF import is not built into this binary; the page was rasterised"));
}
// A cache per page, deliberately: a shared one would keep decoded images and fonts alive
// across up to 16 pages outside the pixel budget, and would hide an image-decode warning
// on a later page after the page that first hit it.
@@ -195,13 +221,16 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -
let band_height = (rows.len() / row_bytes) as u32;
surface.write_region(Rect::from_xywh(x as i32, top, width, band_height), &data);
}
let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32);
let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface));
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group)));
x = x.saturating_add(width).saturating_add(GAP);
ctl.progress((index + 1) as f32 / count as f32);
}
#[cfg(feature = "pdf-editable")]
if editable_text {
warnings.push("PDF text uses fonts embedded in the PDF; it may change font when edited or reopened".into());
}
Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false })
}
+30 -3
View File
@@ -50,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) ->
/// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze,
/// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the
/// developed RGB pixels, and the demosaic method.
/// developed RGB pixels, the demosaic method, and the crop.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RawTuning {
/// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`.
@@ -71,6 +71,9 @@ pub struct RawTuning {
pub texture: f32,
/// −100…100, as `CameraRaw::dehaze`.
pub dehaze: f32,
/// The crop of the raw's sidecar ([`crate::camera_raw_xmp::Crop`], in the stored
/// orientation): the developed frame is cropped to it once it is upright.
pub crop: Option<crate::camera_raw_xmp::Crop>,
}
/// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure,
@@ -106,7 +109,15 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
}
None => false,
};
let dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?;
let mut dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?;
let cropped = match tuning.crop {
Some(crop) => {
let o = if opts.orient { dev.info.orientation } else { 1 };
crop_developed(&mut dev, crop.oriented(o))?;
format!(", cropped to {}×{}", dev.width, dev.height)
}
None => String::new(),
};
let wb = if wb_applied {
format!("as-shot white balance, temperature {:+.0}, tint {:+.0}", tuning.temperature, tuning.tint)
} else {
@@ -117,10 +128,26 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
.filter(|(_, v)| *v != 0.0)
.map(|(k, v)| format!(", {k} {v:+.0}"))
.collect::<String>();
let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id());
let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id());
Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied))
}
/// Crops the upright developed frame to `crop` (fractions of it), as Camera Raw crops a raw.
fn crop_developed(dev: &mut photocraft_raw::Developed, crop: crate::camera_raw_xmp::Crop) -> Result<(), IoError> {
let (w, h) = (dev.width as usize, dev.height as usize);
let bad = || IoError::Raw(RawError::Malformed(format!("the developed {w}x{h} frame holds fewer samples than its size")));
let Some((x0, y0, x1, y1)) = crop.pixels(w, h) else { return Ok(()) };
let mut rgb = Vec::with_capacity((x1 - x0) * (y1 - y0) * 3);
for y in y0..y1 {
let row = y.checked_mul(w).ok_or_else(bad)?;
rgb.extend_from_slice(dev.rgb.get((row + x0) * 3..(row + x1) * 3).ok_or_else(bad)?);
}
dev.rgb = rgb;
dev.width = u32::try_from(x1 - x0).map_err(|_| bad())?;
dev.height = u32::try_from(y1 - y0).map_err(|_| bad())?;
Ok(())
}
/// The tone curve `bytes` develop with by default: the fitted look's for a file without colour
/// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw
/// this crate decodes). For previews of the open-time raw-stage sliders
+30
View File
@@ -420,6 +420,36 @@ fn uncalibrated_raw_takes_its_colour_from_the_camera_jpeg() {
assert_eq!(r.document.depth, SampleType::U16);
}
/// #9985: a sidecar's crop is measured on the raw as stored, before its Orientation turns it
/// upright: the developed frame is cropped to the same sensor pixels, whichever way it is turned.
#[test]
fn camera_raw_tuning_crops_in_the_stored_orientation() {
use photocraft_io::camera_raw_xmp::Crop;
use photocraft_io::raw::{RawTuning, import_raw_tuned};
let (w, h) = (40, 24);
// Stored pixels x 4..24, y 6..18; then the upright frame's size and the same pixels in it.
let crop = Crop { top: 0.25, left: 0.1, bottom: 0.75, right: 0.6 };
for (orientation, size, [x0, y0, x1, y1]) in
[(1, (40, 24), [4, 6, 24, 18]), (3, (40, 24), [16, 6, 36, 18]), (6, (24, 40), [6, 4, 18, 24]), (8, (24, 40), [6, 16, 18, 36])]
{
let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535));
spec.as_shot_neutral = Some([0.6, 1.0, 0.8]);
spec.orientation = orientation;
let bytes = spec.build();
let (full, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning::default()).unwrap();
let (cut, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning { crop: Some(crop), ..Default::default() }).unwrap();
assert_eq!((full.document.size.width, full.document.size.height), size, "orientation {orientation}");
assert_eq!((cut.document.size.width, cut.document.size.height), (x1 - x0, y1 - y0), "orientation {orientation}");
assert!(cut.warnings.iter().any(|n| n.contains(&format!("cropped to {}×{}", x1 - x0, y1 - y0))), "{:?}", cut.warnings);
let full = photocraft_io::document_to_image(&full.document, &mut Vec::new()).unwrap();
let cut = photocraft_io::document_to_image(&cut.document, &mut Vec::new()).unwrap();
// 16-bit RGB: 6 bytes a pixel.
let row = |y: u32| &full.data()[((y * size.0 + x0) * 6) as usize..((y * size.0 + x1) * 6) as usize];
let expected: Vec<u8> = (y0..y1).flat_map(|y| row(y).to_vec()).collect();
assert_eq!(cut.data(), expected, "orientation {orientation}: the crop is the same pixels of the uncropped develop");
}
}
#[test]
fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting() {
use photocraft_io::raw::{RawTuning, fit_preview_look, import_raw_tuned};
+94 -24
View File
@@ -4,6 +4,7 @@
//! Colours are compared in the surface's own colour channels (RGB, CMYK, Gray, Lab… at any depth),
//! so nothing is converted through RGB; only alpha changes.
use photocraft_color::SampleType;
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba};
use serde::{Deserialize, Serialize};
@@ -16,8 +17,15 @@ use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke};
#[serde(default, rename_all = "camelCase")]
pub struct BgEraseSettings {
pub sampling: Sampling,
/// Which matching pixels are erased. [`Limits::FindEdges`] grades the erase over the Tolerance
/// like [`Limits::Contiguous`] in this tool (#11904), unlike Color Replacement, where it turns
/// the soft falloff off.
pub limits: Limits,
/// 0..1: largest per-channel difference from the sampled colour that is erased.
/// 0..1: how far from the sampled colour a colour is still partly erased. The distance is the
/// largest increase plus the largest decrease over the colour channels (`dist`); an exact
/// match is erased fully, then less the further a colour is, and colours at or beyond the
/// Tolerance are kept (`graded_weight`). Protect Foreground Color still compares the
/// foreground by the largest per-channel difference (`max_channel_dist`).
pub tolerance: f32,
/// Keep pixels that match the foreground colour.
pub protect_foreground: bool,
@@ -40,11 +48,39 @@ impl Default for BgEraseSettings {
}
}
/// Largest per-channel difference over the colour channels.
/// Colour distance of `a` from `b` over the colour channels: the largest increase plus the largest
/// decrease (#11901). Changes in the same direction count like the largest of them; changes in
/// opposite directions add up, as the reference application measures them (a red-to-green edge is
/// about twice as far as one channel alone). One channel (Gray) gives the absolute difference.
fn dist(a: &[f32], b: &[f32]) -> f32 {
let (up, down) = a.iter().zip(b).fold((0.0f32, 0.0f32), |(up, down), (x, y)| {
let diff = x - y;
(up.max(diff), down.max(-diff))
});
up + down
}
/// The largest per-channel difference of `a` from `b`: the distance Protect Foreground Color keeps
/// using (#11901 measured only the sample match; #11903 keeps today's measure for the foreground).
fn max_channel_dist(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs()))
}
/// The match weight of one dab for a colour `d` away from the sampled colour (#11901): an exact
/// match is erased fully, then the weight falls off linearly over the whole Tolerance `tol` after
/// a short plateau `d0 = min(tol / 20, 8 levels)`, and colours at or beyond the Tolerance are kept.
/// Measured in the reference application with single clicks (one dab) at Tolerance 10 to 100.
fn graded_weight(d: f32, tol: f32) -> f32 {
if !d.is_finite() || d >= tol {
return if d <= 0.0 { 1.0 } else { 0.0 };
}
let d0 = (tol / 20.0).min(8.0 / 255.0);
if d <= d0 {
return 1.0;
}
(1.0 - (d - d0) / (tol - d0)).clamp(0.0, 1.0)
}
/// Erase along `stroke`. The brush opacity is ignored (Photoshop's Background Eraser has none);
/// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no
/// alpha channel.
@@ -70,22 +106,15 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option<i
let a = alpha_index(&fmt)?;
let nc = a;
let tol = if bs.tolerance.is_finite() { bs.tolerance.clamp(0.0, 1.0) } else { 0.0 };
let soft = bs.limits != Limits::FindEdges;
let native = |c: [f32; 4]| -> Vec<f32> { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() };
let swatch = native(bs.background);
let fg = native(bs.foreground);
let (sampling, limits, protect_foreground) = (bs.sampling, bs.limits, bs.protect_foreground);
let float = fmt.sample == SampleType::F32;
let mut once: Option<Option<Vec<f32>>> = None;
let match_w = move |d: f32| -> f32 {
if d > tol {
0.0
} else if soft && tol > 0.0 {
// Soft falloff over the last quarter of the tolerance.
((tol - d) / (tol * 0.25)).clamp(0.0, 1.0)
} else {
1.0
}
};
// Find Edges grades soft edges like Contiguous does instead of cutting them hard (#11904):
// the reference application keeps at least as much of a blurred transition with it.
let match_w = move |d: f32| -> f32 { graded_weight(d, tol) };
let pre = pre.clone();
// The pixels before the stroke under the working copy, read once per working area. Sampling
// and matching read these, so a dab landing on pixels earlier dabs already erased still
@@ -117,27 +146,40 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option<i
};
let Some(sampled) = sampled else { return };
let (w, h) = (r.width() as usize, r.height() as usize);
// Per-pixel match weight from the pre-stroke colours: the contiguous fill passes through
// pixels this stroke already erased.
// Which pixels the contiguous fill may pass, from the pre-stroke colours as before: it
// passes through pixels this stroke already erased.
let mut reach = vec![0.0f32; w * h];
// Per-pixel match weight from the working copy (#11902): a pixel an earlier dab erased
// partly is matched by its current, decontaminated colour, which is pushed away from the
// sampled colour, so later dabs erase it again only while it is still within the Tolerance.
let mut weight = vec![0.0f32; w * h];
let protected = |c: &[f32]| protect_foreground && max_channel_dist(c, &fg) <= tol;
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
if fp.at(x, y) <= 0.0 {
continue;
}
let p = orig.px(x, y);
if p[a] <= 0.0 {
let o = orig.px(x, y);
if o[a] <= 0.0 {
continue;
}
let c = &p[..nc];
if protect_foreground && dist(c, &fg) <= tol {
continue;
let i = (y - r.y0) as usize * w + (x - r.x0) as usize;
if !protected(&o[..nc]) {
reach[i] = match_w(dist(&o[..nc], &sampled));
}
let c = work.px(x, y);
if c[a] > 0.0 && !protected(&c[..nc]) {
weight[i] = match_w(dist(&c[..nc], &sampled));
}
weight[(y - r.y0) as usize * w + (x - r.x0) as usize] = match_w(dist(c, &sampled));
}
}
if limits != Limits::Discontiguous {
keep_connected(&mut weight, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize);
keep_connected(&mut reach, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize);
for (wv, rv) in weight.iter_mut().zip(&reach) {
if *rv <= 0.0 {
*wv = 0.0;
}
}
}
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
@@ -145,16 +187,32 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option<i
if k <= 0.0 {
continue;
}
let keep = 1.0 - k.min(1.0);
let p = work.px_mut(x, y);
p[a] *= 1.0 - k.min(1.0);
if p[a] <= 0.0 {
p[a] *= keep;
if p[a] <= 0.0 || keep <= 0.0 {
p.fill(0.0);
continue;
}
decontaminate(&mut p[..nc], &sampled, keep, float);
}
}
})
}
/// Colour decontamination of a partly erased pixel (#11902): of the colour `c`, the share `1 - keep`
/// the dab removed was the sampled colour, so what stays is pushed away from it until compositing
/// it at `keep` over the sampled colour gives back `c`: `(c - sampled × (1 - keep)) / keep` per
/// channel, in the document's own channels. Clipped to the channel range (0..1; 32-bit float keeps
/// values above 1 it already had).
fn decontaminate(c: &mut [f32], sampled: &[f32], keep: f32, float: bool) {
for (v, s) in c.iter_mut().zip(sampled) {
let hi = if float { v.max(1.0) } else { 1.0 };
let d = (*v - s * (1.0 - keep)) / keep;
*v = if d.is_finite() { d.clamp(0.0, hi) } else { v.clamp(0.0, hi) };
}
}
/// Zeroes the weights not 4-connected to `(sx, sy)` through non-zero weights.
fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize) {
let mut keep = vec![false; w * h];
@@ -191,6 +249,18 @@ fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize)
}
}
#[cfg(test)]
#[path = "bg_erase_graded_alpha_tests.rs"]
mod graded_alpha_tests;
#[cfg(test)]
#[path = "bg_erase_find_edges_tests.rs"]
mod find_edges_tests;
#[cfg(test)]
#[path = "bg_erase_decontamination_tests.rs"]
mod decontamination_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,212 @@
//! Background Eraser colour decontamination (#11902): a partly erased pixel loses the sampled
//! colour's share, so composited over the sampled colour it gives back its original colour, and
//! later dabs match its current, decontaminated colour. Expected values are the reference
//! application's measurements from the issue (8-bit RGB, 40 px hard round tip, sample (200,40,40)).
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba};
use crate::bg_erase::{BgEraseSettings, apply_background_eraser};
use crate::replace::{Limits, Sampling};
use crate::{BrushSettings, Stroke, StrokePoint};
/// Block `i` of the reference test layer: (200, 40 + 20 i, 40).
fn block(i: usize) -> [f32; 4] {
[200.0 / 255.0, (40.0 + 20.0 * i as f32) / 255.0, 40.0 / 255.0, 1.0]
}
/// Eight 100 px blocks side by side, 900 x 300; the reference's drag row y 450 is y 150 here.
fn blocks(fmt: PixelFormat) -> Surface {
let mut s = Surface::new(fmt);
for i in 0..8 {
let x = 100 * i as i32;
s.fill_rect(Rect::new(x, 0, x + 100, 300), &from_rgba(&fmt, block(i)));
}
s
}
fn stroke(points: &[(f64, f64)]) -> Stroke {
Stroke {
brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() },
points: points.iter().map(|&(x, y)| StrokePoint::new(x, y, 1.0)).collect(),
}
}
fn once(tolerance_percent: f32) -> BgEraseSettings {
BgEraseSettings { sampling: Sampling::Once, limits: Limits::Discontiguous, tolerance: tolerance_percent / 100.0, ..Default::default() }
}
fn swatch(tolerance_percent: f32) -> BgEraseSettings {
BgEraseSettings { sampling: Sampling::BackgroundSwatch, background: block(0), ..once(tolerance_percent) }
}
/// RGBA 0-255 at block `i`'s centre.
fn rgba(s: &Surface, i: usize) -> [i32; 4] {
let p = s.rgba(100 * i as i32 + 50, 150);
p.map(|v| (v * 255.0).round() as i32)
}
/// The reference's drag from block 0's centre to `to_x`, Sampling Once.
fn drag(fmt: PixelFormat, tolerance_percent: f32, to_x: f64) -> Surface {
let mut s = blocks(fmt);
apply_background_eraser(&mut s, &stroke(&[(50.0, 150.0), (to_x, 150.0)]), &once(tolerance_percent), None);
s
}
/// Separate single-click strokes on every block centre, `clicks` times, Background Swatch.
fn clicks(fmt: PixelFormat, tolerance_percent: f32, clicks: usize) -> Surface {
let mut s = blocks(fmt);
for _ in 0..clicks {
for i in 0..8 {
apply_background_eraser(&mut s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &swatch(tolerance_percent), None);
}
}
s
}
fn assert_alpha_row(s: &Surface, want: &[i32], what: &str) {
let got: Vec<i32> = (0..want.len()).map(|i| rgba(s, i)[3]).collect();
for (i, (g, w)) in got.iter().zip(want).enumerate() {
assert!((g - w).abs() <= 8, "{what}: block {i} alpha {g}, measured {w} (row {got:?})");
}
}
#[test]
fn the_eight_block_drag_gives_the_measured_rgba() {
const WANT: [[i32; 4]; 8] = [
[200, 40, 40, 0],
[200, 216, 40, 29],
[200, 184, 40, 71],
[200, 175, 40, 113],
[200, 172, 40, 155],
[200, 170, 40, 196],
[200, 169, 40, 238],
[200, 180, 40, 255],
];
let s = drag(PixelFormat::RGBA8, 50.0, 750.0);
for (i, want) in WANT.iter().enumerate() {
let got = rgba(&s, i);
// Block 0 is fully erased: only its alpha is defined.
let channels = if want[3] == 0 { 3..4 } else { 0..4 };
for c in channels {
assert!((got[c] - want[c]).abs() <= 8, "block {i}: got {got:?}, measured {want:?}");
}
}
}
#[test]
fn the_drag_table_of_11901_holds_with_decontamination() {
let rows: [(f32, [i32; 8]); 5] = [
(10.0, [0, 194, 255, 255, 255, 255, 255, 255]),
(20.0, [0, 90, 197, 255, 255, 255, 255, 255]),
(30.0, [0, 56, 126, 196, 255, 255, 255, 255]),
(50.0, [0, 30, 75, 114, 156, 198, 238, 255]),
(70.0, [0, 18, 48, 78, 107, 137, 167, 197]),
];
for (tol, want) in rows {
assert_alpha_row(&drag(PixelFormat::RGBA8, tol, 750.0), &want, &format!("drag at Tolerance {tol}"));
}
// At Tolerance 100 the decontaminated colours stay inside the Tolerance, so every dab erases
// a bit more; the drag on to 790 is the measured row (block 7 is no reference at 750).
assert_alpha_row(&drag(PixelFormat::RGBA8, 100.0, 790.0), &[0, 7, 19, 32, 44, 56, 68, 81], "drag at Tolerance 100");
}
#[test]
fn later_strokes_match_the_decontaminated_colour() {
let fmt = PixelFormat::RGBA8;
assert_alpha_row(&clicks(fmt, 100.0, 1), &[0, 12, 33, 54, 74, 95, 116, 136], "Tolerance 100, 1 click");
assert_alpha_row(&clicks(fmt, 100.0, 2), &[0, 10, 28, 45, 62, 80, 97, 114], "Tolerance 100, 2 clicks");
assert_alpha_row(&clicks(fmt, 100.0, 3), &[0, 8, 23, 38, 52, 67, 81, 96], "Tolerance 100, 3 clicks");
// At Tolerance 50 the decontaminated colours are outside the Tolerance: a second click leaves
// the block centres as they were.
let one = clicks(fmt, 50.0, 1);
assert_alpha_row(&one, &[0, 29, 71, 113, 155, 196, 238, 255], "Tolerance 50, 1 click");
let two = clicks(fmt, 50.0, 2);
for i in 1..8 {
assert_eq!(rgba(&two, i), rgba(&one, i), "Tolerance 50, 2 clicks: block {i}");
}
}
#[test]
fn partly_erased_pixels_composite_back_to_their_original_colour() {
// Composited over the sampled colour, each partly erased block centre gives back its original
// colour within 2 levels, after one click and after the drag.
for s in [clicks(PixelFormat::RGBA8, 50.0, 1), drag(PixelFormat::RGBA8, 50.0, 750.0)] {
for i in 1..7 {
let [r, g, b, a] = rgba(&s, i);
assert!(a > 0 && a < 255, "block {i} partly erased, alpha {a}");
let over = |c: i32, s: i32| (c * a + s * (255 - a)) as f32 / 255.0;
let orig = block(i).map(|v| v * 255.0);
for (k, (got, want)) in [over(r, 200), over(g, 40), over(b, 40)].iter().zip(orig).enumerate() {
assert!((got - want).abs() <= 2.0, "block {i} channel {k}: composite {got}, original {want}");
}
}
}
}
#[test]
fn kept_and_erased_pixels_and_the_channel_range() {
let s = drag(PixelFormat::RGBA8, 50.0, 750.0);
// Fully kept: the colour exactly; outside the brush: untouched.
assert_eq!(rgba(&s, 7), [200, 180, 40, 255]);
assert_eq!(s.rgba(350, 10), blocks(PixelFormat::RGBA8).rgba(350, 10));
// Fully erased: alpha 0.
assert_eq!(rgba(&s, 0)[3], 0);
// At Tolerance 100 the decontaminated green clips at 255 rather than wrapping, and 32-bit
// float results stay finite and within 0..1 for in-range colours.
let t = clicks(PixelFormat::RGBA8, 100.0, 1);
assert_eq!(rgba(&t, 7)[1], 255, "clipped, not wrapped");
let f = clicks(PixelFormat::RGBA32F, 100.0, 3);
for x in (0..900).step_by(7) {
for v in f.rgba(x, 150) {
assert!(v.is_finite() && (0.0..=1.0).contains(&v), "x {x}: {v}");
}
}
}
#[test]
fn it_works_in_the_documents_own_channels() {
// 16-bit RGB: the same RGBA as 8-bit.
let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true);
let a = drag(PixelFormat::RGBA8, 50.0, 750.0);
let b = drag(rgb16, 50.0, 750.0);
for i in 0..8 {
let (x, y) = (rgba(&a, i), rgba(&b, i));
for c in 0..4 {
assert!((x[c] - y[c]).abs() <= 2 || x[3] == 0, "16-bit block {i}: {y:?} against 8-bit {x:?}");
}
}
// Gray (8 and 16 bit): the gray level is decontaminated the same way, so the composite over
// the sampled gray gives back the original level, and a second click at Tolerance 50 changes
// nothing at the block centres.
for sample in [SampleType::U8, SampleType::U16] {
let gray = PixelFormat::new(ColorMode::Grayscale, sample, true);
let level = |i: usize| (40.0 + 20.0 * i as f32) / 255.0;
let mut s = Surface::new(gray);
for i in 0..8 {
let x = 100 * i as i32;
s.fill_rect(Rect::new(x, 0, x + 100, 300), &[level(i), 1.0]);
}
let bs = BgEraseSettings { background: [level(0), level(0), level(0), 1.0], ..swatch(50.0) };
let click_all = |s: &mut Surface| {
for i in 0..8 {
apply_background_eraser(s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &bs, None);
}
};
click_all(&mut s);
let first = s.clone();
for i in 1..7 {
let p = s.pixel(100 * i as i32 + 50, 150);
let (v, a) = (p[0], p[1]);
assert!(a > 0.0 && a < 1.0, "{sample:?} block {i} partly erased, alpha {a}");
let over = v * a + level(0) * (1.0 - a);
assert!((over - level(i)).abs() <= 2.0 / 255.0, "{sample:?} block {i}: composite {over}, original {}", level(i));
}
click_all(&mut s);
for i in 1..8 {
let at = (100 * i + 50, 150);
assert_eq!(s.pixel(at.0, at.1), first.pixel(at.0, at.1), "{sample:?} block {i}: a second click at Tolerance 50 changes nothing");
}
}
}
@@ -0,0 +1,101 @@
//! Background Eraser Limits Find Edges (#11904): a blurred edge is erased graded, keeping at least
//! as much of the transition as Contiguous does, instead of cut hard at the Tolerance. Expected
//! values are the reference application's measurements from the issue (8-bit RGB, 40 px and 60 px
//! hard round tips, Tolerance 50, Sampling Continuous).
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba};
use crate::bg_erase::{BgEraseSettings, apply_background_eraser};
use crate::replace::{Limits, Sampling};
use crate::{BrushSettings, Stroke, StrokePoint};
/// Measured red channel along y 70 from x 185 across the 4 px Gaussian red|green edge at x 200.
const RED: [i32; 30] = [220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30];
/// Alpha along y 70 from x 185 after a click at 190,70.
const FIND_EDGES: [i32; 15] = [8, 8, 8, 4, 4, 0, 0, 0, 17, 42, 76, 121, 176, 238, 255];
const CONTIGUOUS: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255];
fn rgb(c: [i32; 3]) -> [f32; 4] {
[c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]
}
/// The issue's test layer: red | green | blue | pink stripes (y 0-300), a white gap at y 140-160
/// across the red stripe, and the red|green edge blurred over y 0-130. A Gaussian blur mixes every
/// channel with the same weights, so green and blue follow red's mix fraction.
fn layer() -> Surface {
let fmt = PixelFormat::RGBA8;
let mut s = Surface::new(fmt);
for (x0, c) in [(0, [220, 30, 30]), (200, [30, 180, 60]), (400, [40, 60, 220]), (600, [240, 120, 120])] {
s.fill_rect(Rect::new(x0, 0, x0 + 200, 300), &from_rgba(&fmt, rgb(c)));
}
s.fill_rect(Rect::new(0, 140, 200, 160), &from_rgba(&fmt, rgb([255, 255, 255])));
for (i, r) in RED.iter().enumerate() {
let t = (220 - r) as f32 / 190.0;
let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0];
let x = 185 + i as i32;
s.fill_rect(Rect::new(x, 0, x + 1, 130), &from_rgba(&fmt, c));
}
s
}
fn click(at: (f64, f64), size: f32, limits: Limits) -> Surface {
let mut s = layer();
let stroke = Stroke {
brush: BrushSettings { size, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() },
points: vec![StrokePoint::new(at.0, at.1, 1.0)],
};
let bs = BgEraseSettings { sampling: Sampling::Continuous, limits, tolerance: 0.5, ..Default::default() };
apply_background_eraser(&mut s, &stroke, &bs, None);
s
}
fn alpha(s: &Surface, x: i32, y: i32) -> i32 {
(s.rgba(x, y)[3] * 255.0).round() as i32
}
#[test]
fn find_edges_grades_a_blurred_edge_instead_of_cutting_it() {
let fe = click((190.0, 70.0), 40.0, Limits::FindEdges);
let co = click((190.0, 70.0), 40.0, Limits::Contiguous);
for (i, want) in FIND_EDGES.iter().enumerate() {
let x = 185 + i as i32;
let (f, c) = (alpha(&fe, x, 70), alpha(&co, x, 70));
assert!((f - want).abs() <= 10, "x {x}: Find Edges alpha {f}, measured {want}");
assert!(f >= c, "x {x}: Find Edges keeps {f}, less than Contiguous's {c}");
}
// Past the transition nothing is erased (before, Find Edges cut through x 201).
for x in 200..230 {
assert_eq!(alpha(&fe, x, 70), 255, "x {x}");
}
}
#[test]
fn find_edges_on_a_hard_edge_gives_the_contiguous_result() {
// 60 px tip at 100,138 on red just above the white gap: column x 100 erased through y 139, the
// gap and the red below it kept.
let fe = click((100.0, 138.0), 60.0, Limits::FindEdges);
let co = click((100.0, 138.0), 60.0, Limits::Contiguous);
for y in 100..176 {
assert_eq!(alpha(&fe, 100, y), alpha(&co, 100, y), "y {y}");
}
for y in 110..140 {
assert_eq!(alpha(&fe, 100, y), 0, "red above the gap, y {y}");
}
for y in 140..176 {
assert_eq!(alpha(&fe, 100, y), 255, "the gap and the red below it, y {y}");
}
}
#[test]
fn contiguous_and_discontiguous_keep_their_results() {
// This item changes only Find Edges: the other two limits still give the measured row.
for limits in [Limits::Contiguous, Limits::Discontiguous] {
let s = click((190.0, 70.0), 40.0, limits);
for (i, want) in CONTIGUOUS.iter().enumerate() {
let x = 185 + i as i32;
assert!((alpha(&s, x, 70) - want).abs() <= 8, "{limits:?} x {x}: {} against {want}", alpha(&s, x, 70));
}
}
}
@@ -0,0 +1,189 @@
//! Background Eraser graded alpha (#11901): one dab erases a colour less the further it is from the
//! sampled colour, over the whole Tolerance, not fully up to the Tolerance. Expected values are the
//! reference application's single-click measurements from the issue (8-bit RGB, 40 px hard round
//! tip, Sampling Background Swatch with background (200,40,40), alpha at the patch centre).
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba};
use crate::bg_erase::{BgEraseSettings, apply_background_eraser};
use crate::replace::{Limits, Sampling};
use crate::{BrushSettings, Stroke, StrokePoint};
/// The sampled colour of every measurement: (200,40,40).
const SAMPLE: [i32; 3] = [200, 40, 40];
/// Allowed deviation from the measured alpha ("within 8 levels").
const LEVELS: i32 = 8;
fn rgb(c: [i32; 3]) -> [f32; 4] {
[c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]
}
fn click(at: (f64, f64)) -> Stroke {
Stroke {
brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() },
points: vec![StrokePoint::new(at.0, at.1, 1.0)],
}
}
fn swatch(tolerance_percent: f32, background: [f32; 4]) -> BgEraseSettings {
BgEraseSettings {
sampling: Sampling::BackgroundSwatch,
limits: Limits::Discontiguous,
tolerance: tolerance_percent / 100.0,
background,
..Default::default()
}
}
/// One click in the centre of a 100 px patch of `colour` (straight RGBA) in `fmt`; alpha 0-255 at
/// the centre.
fn one_click(fmt: PixelFormat, colour: [f32; 4], bs: &BgEraseSettings) -> i32 {
let mut s = Surface::new(fmt);
s.fill_rect(Rect::new(0, 0, 100, 100), &from_rgba(&fmt, colour));
apply_background_eraser(&mut s, &click((50.0, 50.0)), bs, None);
(s.rgba(50, 50)[3] * 255.0).round() as i32
}
/// The measured single-click table: (Tolerance, [(green difference d, alpha)]).
fn single_click_table() -> Vec<(f32, Vec<(i32, i32)>)> {
vec![
(10.0, vec![(10, 92), (20, 194), (30, 255), (40, 255)]),
(30.0, vec![(10, 21), (20, 56), (30, 91), (40, 126), (50, 161), (60, 196), (80, 255), (100, 255)]),
(50.0, vec![(10, 8), (20, 29), (30, 50), (40, 71), (50, 92), (60, 113), (80, 155), (100, 196), (120, 238), (140, 255)]),
(70.0, vec![(10, 3), (20, 18), (30, 33), (40, 48), (50, 63), (60, 78), (80, 107), (100, 137), (120, 167), (140, 197), (160, 227)]),
(100.0, vec![(10, 2), (20, 12), (30, 23), (40, 33), (50, 43), (60, 54), (80, 74), (100, 95), (120, 116), (140, 136), (160, 157)]),
]
}
fn assert_near(got: i32, want: i32, what: &str) {
assert!((got - want).abs() <= LEVELS, "{what}: got alpha {got}, measured {want}");
}
#[test]
fn one_click_falls_off_over_the_whole_tolerance() {
let bs = |tol| swatch(tol, rgb(SAMPLE));
for (tol, row) in single_click_table() {
for (d, want) in row {
let got = one_click(PixelFormat::RGBA8, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &bs(tol));
assert_near(got, want, &format!("Tolerance {tol}, green +{d}"));
}
}
}
#[test]
fn same_sign_differences_count_once_and_opposite_signs_add_up() {
// Measured at Tolerance 50, single clicks, sample (200,40,40): (difference per channel, alpha).
let rows: [([i32; 3], i32); 15] = [
([0, 20, 0], 29),
([-20, 0, 0], 29),
([0, 0, 20], 29),
([0, 20, 20], 29),
([0, 40, 20], 71),
([0, 20, 40], 71),
([0, 60, 20], 113),
([-20, 20, 0], 71),
([-40, 40, 0], 155),
([-60, 60, 0], 238),
([10, 10, 10], 8),
([20, 20, 20], 29),
([40, 40, 40], 71),
([-10, -10, -10], 8),
([-40, -40, -40], 71),
];
let bs = swatch(50.0, rgb(SAMPLE));
for (diff, want) in rows {
let colour = [SAMPLE[0] + diff[0], SAMPLE[1] + diff[1], SAMPLE[2] + diff[2]];
assert_near(one_click(PixelFormat::RGBA8, rgb(colour), &bs), want, &format!("difference {diff:?}"));
}
}
#[test]
fn a_blurred_edge_keeps_its_transition_graded() {
// Red (220,30,30) | green (30,180,60) at x 200, blurred with a 4 px Gaussian; the measured red
// channel along y 70 from x 185. A Gaussian blur mixes every channel with the same weights, so
// green and blue follow red's mix fraction.
const RED: [i32; 30] =
[220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30];
const ALPHA: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255];
let fmt = PixelFormat::RGBA8;
let mut s = Surface::new(fmt);
s.fill_rect(Rect::new(0, 0, 185, 140), &from_rgba(&fmt, rgb([220, 30, 30])));
s.fill_rect(Rect::new(215, 0, 400, 140), &from_rgba(&fmt, rgb([30, 180, 60])));
for (i, r) in RED.iter().enumerate() {
let t = (220 - r) as f32 / 190.0;
let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0];
let x = 185 + i as i32;
s.fill_rect(Rect::new(x, 0, x + 1, 140), &from_rgba(&fmt, c));
}
for limits in [Limits::Contiguous, Limits::Discontiguous] {
let mut t = s.clone();
let bs = BgEraseSettings { sampling: Sampling::Once, limits, tolerance: 0.5, ..Default::default() };
apply_background_eraser(&mut t, &click((190.0, 70.0)), &bs, None);
for (i, want) in ALPHA.iter().enumerate() {
let x = 185 + i as i32;
let got = (t.rgba(x, 70)[3] * 255.0).round() as i32;
assert_near(got, *want, &format!("{limits:?} x {x}"));
}
for x in 200..215 {
assert_eq!(t.rgba(x, 70)[3], 1.0, "{limits:?} x {x} beyond the Tolerance kept");
}
}
}
#[test]
fn an_exact_match_is_erased_and_far_colours_are_kept_at_every_tolerance() {
for tol in [0.0, 1.0, 10.0, 30.0, 50.0, 70.0, 100.0] {
let bs = swatch(tol, rgb(SAMPLE));
assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 0, "Tolerance {tol}: the sample itself");
if tol < 100.0 {
// Opposite-sign differences of 255 + 215: beyond every Tolerance below 100 %.
assert_eq!(one_click(PixelFormat::RGBA8, rgb([0, 255, 40]), &bs), 255, "Tolerance {tol}: a far colour");
}
}
// Tolerance 0 erases nothing but the exact colour.
assert_eq!(one_click(PixelFormat::RGBA8, rgb([200, 41, 40]), &swatch(0.0, rgb(SAMPLE))), 255);
}
#[test]
fn the_distance_is_measured_in_the_documents_own_channels() {
// 16-bit RGB: the same single-click table as 8-bit.
let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true);
for (tol, row) in single_click_table() {
for (d, want) in row {
let got = one_click(rgb16, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &swatch(tol, rgb(SAMPLE)));
assert_near(got, want, &format!("16-bit Tolerance {tol}, green +{d}"));
}
}
// Gray (8 and 16 bit): the distance is the absolute difference of the gray levels, so a gray
// patch d levels from the swatch erases like a one-channel difference of d in RGB, either way.
for sample in [SampleType::U8, SampleType::U16] {
let gray = PixelFormat::new(ColorMode::Grayscale, sample, true);
let level = |v: i32| [v as f32 / 255.0, v as f32 / 255.0, v as f32 / 255.0, 1.0];
for (tol, row) in single_click_table() {
for (d, want) in row {
let bs = swatch(tol, level(60));
if 60 + d <= 255 {
assert_near(one_click(gray, level(60 + d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, +{d}"));
}
if d <= 60 {
assert_near(one_click(gray, level(60 - d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, -{d}"));
}
}
}
}
}
#[test]
fn protect_foreground_keeps_todays_distance_to_the_foreground() {
// #11903 measured the foreground only one channel away and keeps today's measure otherwise:
// the largest per-channel difference. This foreground is 100 levels off in red and in green
// (opposite signs): within Tolerance 50 by that measure, though the sample-match distance of
// #11901 adds the two to 200. The pixel is the sampled colour, so only the protection keeps it.
let bs = BgEraseSettings { protect_foreground: true, foreground: rgb([100, 140, 40]), ..swatch(50.0, rgb(SAMPLE)) };
assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 255);
// Protection off: the same click erases the sample fully.
assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &BgEraseSettings { protect_foreground: false, ..bs }), 0);
}
+6
View File
@@ -295,6 +295,12 @@ impl FontDb {
self.register_blob(Blob::new(Arc::new(bytes)), None)
}
/// [`Self::register_font_data`] with the family name overridden, so an embedded subset can be
/// registered under a name that cannot shadow an installed full family. Returns the names added.
pub fn register_font_data_as(&mut self, bytes: Vec<u8>, family: &str) -> Vec<String> {
self.register_blob(Blob::new(Arc::new(bytes)), Some(family))
}
/// Registers embedded font data without copying it.
fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec<String> {
self.register_blob(Blob::new(Arc::new(bytes)), None)
+16
View File
@@ -142,6 +142,22 @@ impl TextEngine {
layer.cache = Some(r.surface);
r.rect
}
/// Registers font bytes (for example a font embedded in an imported PDF) for later layout.
/// Returns the family names the font provides, best first.
pub fn register_font_data(&mut self, bytes: Vec<u8>) -> Vec<String> {
self.fonts.register_font_data(bytes)
}
/// [`Self::register_font_data`] under `family`, which cannot shadow an installed family.
pub fn register_font_data_as(&mut self, bytes: Vec<u8>, family: &str) -> Vec<String> {
self.fonts.register_font_data_as(bytes, family)
}
/// Whether `family` is available to this engine.
pub fn has_family(&mut self, family: &str) -> bool {
self.fonts.has_family(family)
}
}
static SHARED: warm::Warm<std::sync::Mutex<TextEngine>> = warm::Warm::new();
+1
View File
@@ -137,6 +137,7 @@ impl PhotocraftApp {
order,
question,
policy_applied: false,
smart_object: false,
},
);
Ok(warnings)
@@ -0,0 +1,167 @@
//! #11821: the Brush Preset picker shows the tip's angle/roundness widget at its top left, the
//! Brush Settings panel's own widget, and its drags go through `tools.setBrush` as one journaled
//! change each; Escape reverts them as it does a Size or Hardness edit.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
use crate::PhotocraftApp;
use crate::brush_sections::{EllipseGrab, ellipse_drag, ellipse_grab, roundness_handles};
use crate::state::Tool;
fn harness() -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("tools.setBrush", json!({"brush": {"size": 20, "hardness": 1.0, "angle": 0.0, "roundness": 1.0}})).unwrap();
app.ui.tool = Tool::Brush;
app.sync_views();
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
let c = h.state().last_canvas_rect.center();
let ctx = h.ctx.clone();
crate::brush_picker::open(&mut h.state_mut().ui, &ctx, [c.x, c.y]);
h.run_steps(3);
h
}
/// The widget's centre on screen. It is the top block's first item, 84 × 84, and the Size column
/// starts one item spacing right of it, its first row (24 pt tall, the label centred in it) level
/// with the widget's top.
fn widget_centre(h: &Harness<'static, PhotocraftApp>) -> Pos2 {
use egui_kittest::kittest::Queryable;
let picker = h.ctx.memory(|m| m.area_rect(crate::brush_picker::picker_id())).expect("the picker is open");
let label = h.query_all_by_label(crate::i18n::t("Size")).find(|n| picker.contains(n.rect().center())).expect("the picker's Size label").rect();
let spacing = h.ctx.global_style().spacing.item_spacing.x;
Pos2::new(label.left() - spacing - 42.0, label.center().y - 12.0 + 42.0)
}
fn brush(h: &Harness<'static, PhotocraftApp>) -> (f32, f32) {
let b = &h.state().session.tools.brush;
(b.angle, b.roundness)
}
/// A press at `from`, moves through `path` and the release at its end, a frame each.
fn drag(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, path: &[Pos2]) {
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for p in path {
h.event(Event::PointerMoved(*p));
h.run_steps(1);
}
let end = path.last().copied().unwrap_or(from);
h.event(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
#[test]
fn the_widget_sits_at_the_pickers_top_left_left_of_size() {
use egui_kittest::kittest::Queryable;
let h = harness();
let c = widget_centre(&h);
// Its top left is the picker's content corner: inside the popup frame's margin and stroke.
let picker = h.ctx.memory(|m| m.area_rect(crate::brush_picker::picker_id())).expect("the picker is open");
let style = h.ctx.global_style();
let inset =
vec2(f32::from(style.spacing.menu_margin.left), f32::from(style.spacing.menu_margin.top)) + egui::Vec2::splat(style.visuals.window_stroke().width);
let corner = c - vec2(42.0, 42.0);
assert!((corner - (picker.min + inset)).length() <= 2.0, "the widget at the top left: {corner:?} vs {:?}", picker.min + inset);
// The Size field is right of it, in the same top block.
let size = h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("the Size field").rect();
assert!(size.left() > c.x + 42.0 && size.top() < c.y + 42.0, "{size:?} vs centre {c:?}");
}
#[test]
fn dragging_the_arrow_sets_the_angle_as_the_panel_widget_does() {
let mut h = harness();
let c = widget_centre(&h);
let (angle, roundness) = brush(&h);
// On the arrow (angle 0 points right), away from the roundness handles.
let from = c + vec2(28.0, 0.0);
assert_eq!(ellipse_grab(from - c, angle, roundness), EllipseGrab::Angle);
let to = c + vec2(-20.0, -20.0);
let journal = h.state().session.journal.len();
drag(&mut h, from, &[c + vec2(10.0, -25.0), to]);
let want = ellipse_drag(EllipseGrab::Angle, to - c, angle, roundness);
assert_eq!(brush(&h), want, "the panel widget's values for the same drag");
assert_eq!(want.0, 135.0);
let added: Vec<String> = h.state().session.journal[journal..].iter().map(|(id, _)| id.clone()).collect();
assert_eq!(added, ["tools.setBrush"], "one journaled change for the drag");
}
#[test]
fn dragging_a_handle_sets_the_roundness_as_the_panel_widget_does() {
let mut h = harness();
let c = widget_centre(&h);
let (angle, roundness) = brush(&h);
let handle = c + roundness_handles(angle, roundness)[0];
assert_eq!(ellipse_grab(handle - c, angle, roundness), EllipseGrab::Roundness);
let to = c + vec2(3.0, -12.0);
// The grab is judged where the drag starts: the first move stays within the handle's reach.
drag(&mut h, handle, &[handle + vec2(0.0, 6.5), c + vec2(2.0, -20.0), to]);
let want = ellipse_drag(EllipseGrab::Roundness, to - c, angle, roundness);
assert_eq!(brush(&h), want);
assert!((want.1 - 12.0 / 34.0).abs() < 1e-4, "{want:?}");
// The Brush Settings panel reads the same brush.
assert_eq!(h.state().session.tools.brush.roundness, want.1);
}
#[test]
fn escape_reverts_an_angle_edit_as_it_does_a_size_edit() {
let mut h = harness();
let c = widget_centre(&h);
let opened = brush(&h);
drag(&mut h, c + vec2(28.0, 0.0), &[c + vec2(20.0, -15.0), c + vec2(0.0, -30.0)]);
assert_eq!(brush(&h).0, 90.0);
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!(brush(&h), opened, "Escape goes back to the values the picker opened with");
assert_eq!(h.state().ui.brush_picker, None, "and closes it");
}
#[test]
fn enter_keeps_an_angle_edit_and_closes() {
let mut h = harness();
let c = widget_centre(&h);
drag(&mut h, c + vec2(28.0, 0.0), &[c + vec2(20.0, 15.0), c + vec2(0.0, 30.0)]);
assert_eq!(brush(&h).0, -90.0);
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert_eq!(brush(&h).0, -90.0);
assert_eq!(h.state().ui.brush_picker, None);
}
#[test]
fn non_finite_pointer_positions_leave_the_brush_unchanged() {
let mut h = harness();
let c = widget_centre(&h);
let before = brush(&h);
let handle = c + roundness_handles(before.0, before.1)[0];
h.event(Event::PointerMoved(handle));
h.run_steps(1);
h.event(Event::PointerButton { pos: handle, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
// The pointer reports non-finite positions while the handle is held. (egui's own debug checks
// panic once a drag is under way at such a position, or on a release there, so the test stops
// here.)
for p in [Pos2::new(f32::NAN, c.y), Pos2::new(c.x, f32::INFINITY)] {
h.event(Event::PointerMoved(p));
h.run_steps(1);
let now = brush(&h);
assert!(now.0.is_finite() && now.1.is_finite(), "{p:?}: {now:?}");
assert_eq!(now, before, "{p:?}");
}
}
+9
View File
@@ -1679,6 +1679,15 @@ fn draw_default_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &
{
crate::tool_cursor::close_path(painter, p)
}
// Over an endpoint of an open path, the continue-path pointer (#9356).
Tool::Pen
if {
let [x, y] = xf.to_doc(p);
crate::vector_ui::continues_path_at(app, tool, x, y)
} =>
{
crate::tool_cursor::continue_path(painter, p)
}
// The pen tools: their own icon with its nib on the point, or the crosshair for
// precise Other Cursors (#4639).
Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => {
@@ -402,8 +402,8 @@ pub const SELECTION_MENU: &[Row] = &[
Some(("Save Selection…", "select.saveSelection")),
Some(("Make Work Path…", "select.toWorkPath")),
None,
Some(("Layer via Copy", "layer.new.layerViaCopy")),
Some(("Layer via Cut", "layer.new.layerViaCut")),
Some(("Layer Via Copy", "layer.new.layerViaCopy")),
Some(("Layer Via Cut", "layer.new.layerViaCut")),
Some(("New Layer…", "layer.new.layer")),
None,
Some(("Free Transform", "edit.freeTransform")),
@@ -1000,8 +1000,8 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32;
true
}
/// A view menu row. Zoom In and Zoom Out step about the right-clicked point, as a Zoom tool click
/// does; the other rows run their View menu command.
/// A view menu row. The non-temporary Zoom tool's steps keep the view centre unless clicked-point
/// centring is enabled. Other view-tool cases retain their pressed-point anchoring.
fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> {
let i = app.session.active_index().ok_or("no document")?;
if command == FILL_SCREEN {
@@ -1019,6 +1019,9 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co
}
let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) };
let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center;
if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && app.ui.spring.is_none() && crate::hold_keys::held_tool(app, ctx).is_none() && !center_on_point {
return Ok(());
}
let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) };
let d = [d[0] as f32, d[1] as f32];
// The view scales about the centre, so the point keeps its screen position when its offset
@@ -2,6 +2,9 @@ use super::*;
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
#[path = "zoom_menu_anchor_tests.rs"]
mod zoom_menu_anchor_tests;
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
@@ -399,8 +402,8 @@ fn selection_menus_follow_photoshop_order() {
Some("Save Selection…"),
Some("Make Work Path…"),
None,
Some("Layer via Copy"),
Some("Layer via Cut"),
Some("Layer Via Copy"),
Some("Layer Via Cut"),
Some("New Layer…"),
None,
Some("Free Transform"),
@@ -548,11 +551,12 @@ fn zoom_and_hand_right_click_opens_the_view_menu() {
}
#[test]
fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() {
fn view_menu_zoom_in_keeps_the_view_centre() {
let mut app = zoom_app(400, 300);
app.ui.tool = Tool::Zoom;
let mut h = harness(app);
let zoom = h.state().ui.views[0].zoom;
let center = h.state().ui.views[0].center;
let doc = [300.0, 100.0];
let p = screen_of(h.state(), doc);
right_click(&mut h, p, Modifiers::NONE);
@@ -562,8 +566,7 @@ fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() {
assert!(h.state().ui.canvas_tool_menu.is_none());
let after = h.state().ui.views[0].zoom;
assert_eq!(after, crate::zoom_levels::step(zoom, 1, [400, 300]), "one Zoom In step");
let s = screen_of(h.state(), doc);
assert!((s - p).length() < 1.0, "the right-clicked point stays put: {s:?} vs {p:?}");
assert_eq!(h.state().ui.views[0].center, center);
assert_eq!(h.state().session.journal.last().map(|(id, _)| id.as_str()), Some("view.zoomIn"));
// Zoom Out returns to where it was.
right_click(&mut h, p, Modifiers::NONE);
@@ -574,12 +577,13 @@ fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() {
}
#[test]
fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() {
fn animated_zoom_off_view_menu_keeps_the_centre_without_a_transition() {
for command in ["view.zoomIn", "view.zoomOut", FILL_SCREEN] {
let mut app = zoom_app(400, 300);
app.ui.tool = Tool::Zoom;
app.run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false}})).unwrap();
let mut h = harness(app);
let center = h.state().ui.views[0].center;
let doc = [300.0, 100.0];
let p = screen_of(h.state(), doc);
right_click(&mut h, p, Modifiers::NONE);
@@ -592,17 +596,18 @@ fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() {
assert_eq!((xf.zoom, xf.center), (v.zoom, v.center), "{command}");
assert!(v.zoom_anchor.is_none() && v.zoom_transition.is_none(), "{command}");
if command != FILL_SCREEN {
assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: preserve the clicked point");
assert_eq!(v.center, center, "{command}: preserve the view centre");
}
}
}
#[test]
fn view_menu_zoom_keeps_its_anchor_during_the_animation() {
fn view_menu_zoom_keeps_its_centre_during_the_animation() {
let mut app = zoom_app(400, 300);
app.ui.tool = Tool::Zoom;
let mut h = harness(app);
let zoom = h.state().ui.views[0].zoom;
let center = h.state().ui.views[0].center;
let doc = [300.0, 100.0];
let p = screen_of(h.state(), doc);
right_click(&mut h, p, Modifiers::NONE);
@@ -614,7 +619,7 @@ fn view_menu_zoom_keeps_its_anchor_during_the_animation() {
h.step();
let displayed = crate::canvas::ViewXform::active(h.state()).unwrap();
assert!(displayed.zoom > zoom && displayed.zoom < h.state().ui.views[0].zoom);
assert!(displayed.to_screen(doc[0], doc[1]).distance(p) < 1.0);
assert!(displayed.to_screen(center[0], center[1]).distance(displayed.rect.center()) < 1.0);
h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3);
h.step();
assert!(!h.state().ui.views[0].zoom_animating);
@@ -0,0 +1,196 @@
use super::*;
#[test]
fn zoom_menu_steps_keep_the_view_centre_and_history() {
for animated in [false, true] {
for offset in [vec2(-160.0, -100.0), vec2(140.0, 80.0)] {
let mut app = zoom_app(2400, 1800);
app.ui.tool = Tool::Zoom;
app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false}})).unwrap();
let mut h = harness(app);
let v = &mut h.state_mut().ui.views[0];
v.zoom = 1.0;
v.center = [928.0, 686.0];
v.request_zoom_animation(false);
h.step();
let center = h.state().ui.views[0].center;
let st = h.state().session.active().unwrap();
let history = (st.history.past_len(), st.history.can_redo(), st.revision);
for (label, zoom) in [("Zoom In", 2.0), ("Zoom Out", 1.0), ("Zoom Out", 2.0 / 3.0)] {
let p = crate::canvas::ViewXform::active(h.state()).unwrap().rect.center() + offset;
right_click(&mut h, p, Modifiers::NONE);
h.get_by_label(label).click();
h.run_steps(3);
let v = &h.state().ui.views[0];
assert_eq!(v.zoom, zoom, "{label}: one ladder stop");
assert_eq!(v.center, center, "{label}: the right-click must not move the view centre");
let xf = crate::canvas::ViewXform::active(h.state()).unwrap();
assert!(xf.to_screen(center[0], center[1]).distance(xf.rect.center()) < 1.0);
assert!(h.state().ui.canvas_tool_menu.is_none());
let st = h.state().session.active().unwrap();
assert_eq!((st.history.past_len(), st.history.can_redo(), st.revision), history);
}
}
}
}
#[test]
fn clicked_point_zoom_keeps_its_anchor_and_hand_menu_rejects_step_commands() {
for (tool, center_on_point) in [(Tool::Zoom, true), (Tool::Hand, true), (Tool::Hand, false)] {
for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] {
let mut app = zoom_app(400, 300);
app.ui.tool = tool;
app.run("prefs.set", json!({"values": {"tools.zoomClickedPointToCenter": center_on_point, "tools.overscroll": true}})).unwrap();
let mut h = harness(app);
let before = h.state().ui.views[0].clone();
let p = screen_of(h.state(), [300.0, 100.0]);
right_click(&mut h, p, Modifiers::NONE);
let doc = h.state().ui.canvas_tool_menu.as_ref().unwrap().anchor.unwrap().map(|n| n as f32);
let ctx = h.ctx.clone();
if tool == Tool::Hand {
// #12022 gives Hand only fixed zoom rows. An injected step command must not run.
assert!(!available(h.state(), h.state().ui.canvas_tool_menu.as_ref().unwrap(), command));
choose(h.state_mut(), &ctx, command);
let v = &h.state().ui.views[0];
assert_eq!((v.zoom, v.center), (before.zoom, before.center), "Hand must reject {command}");
continue;
}
choose(h.state_mut(), &ctx, command);
let v = &h.state().ui.views[0];
assert_eq!(v.zoom, crate::zoom_levels::step(before.zoom, direction, [400, 300]));
let expected = if center_on_point { doc } else { std::array::from_fn(|i| doc[i] - (doc[i] - before.center[i]) * before.zoom / v.zoom) };
assert_eq!(v.center, expected, "{tool:?}: {command}");
assert!(v.zoom_animating);
assert_eq!(v.zoom_anchor.is_some(), !center_on_point);
}
}
}
#[test]
fn zoom_click_and_alt_click_still_anchor_at_the_pointer() {
for animated in [false, true] {
for (modifiers, direction) in [(Modifiers::NONE, 1), (Modifiers::ALT, -1)] {
let mut app = zoom_app(400, 300);
app.ui.tool = Tool::Zoom;
app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}}))
.unwrap();
let mut h = harness(app);
let zoom = h.state().ui.views[0].zoom;
let doc = [300.0, 100.0];
let p = screen_of(h.state(), doc);
h.event(egui::Event::ModifiersChanged(modifiers));
h.event(egui::Event::PointerMoved(p));
h.step();
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers });
h.step();
}
h.input_mut().time = Some(h.ctx.input(|i| i.time) + 0.3);
h.step();
assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, direction, [400, 300]));
assert!(screen_of(h.state(), doc).distance(p) < 1.0);
assert!(h.state().ui.canvas_tool_menu.is_none());
assert_eq!(h.state().session.active().unwrap().history.past_len(), 0);
}
}
}
#[test]
fn held_zoom_keys_still_open_no_view_menu() {
for tool in [Tool::Brush, Tool::Hand] {
for modifiers in [Modifiers::COMMAND, Modifiers::ALT] {
let mut app = zoom_app(400, 300);
app.ui.tool = tool;
let mut h = harness(app);
let modifiers =
Modifiers { mac_cmd: cfg!(target_os = "macos") && modifiers.command, ctrl: !cfg!(target_os = "macos") && modifiers.command, ..modifiers };
h.event(egui::Event::ModifiersChanged(modifiers));
h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers });
h.step();
assert!(matches!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomIn | crate::hold_keys::Temporary::ZoomOut)));
let before = h.state().ui.views[0].clone();
let p = screen_of(h.state(), [300.0, 100.0]);
right_click(&mut h, p, modifiers);
assert!(h.state().ui.canvas_tool_menu.is_none());
assert_eq!(h.state().ui.tool, tool);
let v = &h.state().ui.views[0];
assert_eq!((v.zoom, v.center), (before.zoom, before.center));
}
}
}
#[test]
fn held_zoom_with_zoom_selected_keeps_the_menu_pressed_point_anchor() {
for animated in [false, true] {
for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] {
let mut app = zoom_app(400, 300);
app.ui.tool = Tool::Zoom;
app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}}))
.unwrap();
let mut h = harness(app);
h.event(egui::Event::ModifiersChanged(Modifiers::ALT));
h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT });
h.step();
assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomOut));
let zoom = h.state().ui.views[0].zoom;
let center = h.state().ui.views[0].center;
let doc = [300.0, 100.0];
let p = screen_of(h.state(), doc);
h.event(egui::Event::PointerMoved(p));
h.step();
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::ALT });
h.step();
}
assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.tool == Tool::Zoom));
assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomOut));
let ctx = h.ctx.clone();
choose(h.state_mut(), &ctx, command);
assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, direction, [400, 300]));
assert_ne!(h.state().ui.views[0].center, center, "{command}: preserve the held tool's pressed-point behavior");
h.step();
h.input_mut().time = Some(ctx.input(|i| i.time) + 0.11);
h.step();
assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: anchor during animation");
h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3);
h.step();
assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: settled anchor");
assert_eq!(h.state().ui.tool, Tool::Zoom);
assert_eq!(h.state().session.active().unwrap().history.past_len(), 0);
}
}
}
#[test]
fn held_z_menu_keeps_its_existing_pressed_point_anchor() {
for label in ["Zoom In", "Zoom Out"] {
let mut app = zoom_app(400, 300);
app.ui.tool = Tool::Brush;
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::shortcuts::handle(app, &ctx);
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default());
h.run_steps(4);
h.event(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
assert_eq!(h.state().ui.tool, Tool::Zoom);
assert!(h.state().ui.spring.as_ref().is_some_and(|s| s.previous == Tool::Brush));
let doc = [300.0, 100.0];
let p = screen_of(h.state(), doc);
right_click(&mut h, p, Modifiers::NONE);
h.get_by_label(label).click();
h.run_steps(3);
assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{label}: a held Z keeps today's anchor");
h.event(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.tool, Tool::Brush);
}
}
@@ -1023,3 +1023,59 @@ fn a_right_click_moved_and_released_in_one_frame_still_opens_its_menu() {
assert_eq!(h.state().ui.brush_picker, None);
assert_eq!(h.state().ui.canvas_tool_menu, None);
}
/// #11897: Clear chosen on a pixel layer, then the Background activated: a Brush stroke there
/// paints the foreground, and the Mode reads Normal afterwards.
#[test]
fn chosen_clear_paints_normally_on_the_background_and_the_mode_reads_normal() {
let mut h = harness(None);
h.state_mut().ui.tool_options.brush_clear = true;
let bg = h.state().session.active().unwrap().doc.background().unwrap().id;
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
h.run_steps(2);
let (a, b) = drag(&mut h, PointerButton::Primary);
assert!(!h.state().ui.tool_options.brush_clear, "the Mode reads Normal");
let last = strokes(&h).last().cloned().unwrap();
assert!(last.get("mode").is_none() && !last["erase"].as_bool().unwrap(), "{last}");
let app = h.state();
let v = app.ui.views[0].clone();
let d = ((a + (b - a) * 0.5) - app.last_canvas_rect.center()) / v.zoom;
let st = app.session.active().unwrap();
let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32);
assert!(px[2] > 0.9 && px[0] < 0.1, "the foreground blue, not the background colour: {px:?}");
}
/// #11897: a Background with Lock All refuses the stroke, so a chosen Clear stays chosen.
#[test]
fn chosen_clear_stays_on_a_locked_background() {
let mut h = harness(None);
h.state_mut().ui.tool_options.brush_clear = true;
let bg = h.state().session.active().unwrap().doc.background().unwrap().id;
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
h.state_mut().run("layer.lockLayers", json!({"all": true})).unwrap();
h.run_steps(2);
drag(&mut h, PointerButton::Primary);
assert!(h.state().ui.tool_options.brush_clear, "the Mode still reads Clear");
}
/// #11897: a held `~` on the Background keeps erasing to the background colour.
#[test]
fn held_tilde_on_the_background_paints_the_background_colour() {
let mut h = harness(None);
h.state_mut().run("tools.setColors", json!({"background": "#00ff00"})).unwrap();
let bg = h.state().session.active().unwrap().doc.background().unwrap().id;
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
h.run_steps(2);
h.event(egui::Event::Key { key: egui::Key::Backtick, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
let (a, b) = drag(&mut h, PointerButton::Primary);
h.event(egui::Event::Key { key: egui::Key::Backtick, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert_eq!(strokes(&h).last().unwrap()["mode"], json!("clear"));
let app = h.state();
let v = app.ui.views[0].clone();
let d = ((a + (b - a) * 0.5) - app.last_canvas_rect.center()) / v.zoom;
let st = app.session.active().unwrap();
let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32);
assert!(px[1] > 0.9 && px[0] < 0.1 && px[2] < 0.1, "the background green, not the foreground blue: {px:?}");
}
+10
View File
@@ -111,6 +111,16 @@ pub(crate) fn close_path(painter: &Painter, at: Pos2) -> CursorIcon {
outline(painter, at, vec![close_ring()], true)
}
/// The Pen's pointer over an endpoint a click would carry the path on from (#9356): the centre
/// cross with a short slash below right, as Photoshop's pen shows one there.
pub(crate) fn continue_path(painter: &Painter, at: Pos2) -> CursorIcon {
outline(painter, at, vec![continue_slash()], true)
}
fn continue_slash() -> Vec<Vec2> {
[(6.5, 12.5), (11.5, 5.5), (12.5, 6.5), (7.5, 13.5)].into_iter().map(|(x, y)| vec2(x, y)).collect()
}
fn close_ring() -> Vec<Vec2> {
(0..16).map(|i| (i as f32 / 16.0 * std::f32::consts::TAU, vec2(9.0, 9.0))).map(|(a, c)| c + vec2(a.cos(), a.sin()) * 3.5).collect()
}
@@ -1140,3 +1140,16 @@ mod benchmark_stats {
assert_eq!(percentile(&[4.5], 0.95), 4.5, "one sample");
}
}
/// #9356: the continue-path pointer is the centre cross with a slash below right.
#[test]
fn continue_path_bitmap_has_the_centre_cross_and_a_slash_beside_it() {
for scale in [1.0, 2.0] {
let image = rasterize(&Shape::Outline { rings: vec![continue_slash()].into(), centre: true }, scale).unwrap();
let [x, y] = image.hotspot;
let at = |dx: f32, dy: f32| alpha(&image, x + (dx * scale) as u16, y + (dy * scale) as u16);
assert!(at(0.0, 0.0) > 230, "the centre cross");
assert!(at(9.5, 9.5) > 0, "the slash");
assert_eq!(at(0.0, 9.0), 0, "apart from the cross");
}
}
+15 -2
View File
@@ -19,6 +19,8 @@ pub enum Badge {
Intersect,
/// Four arrows: a press drags the point under the pointer (the Curvature Pen over an anchor).
Move,
/// One right-pointing arrow: a press drags the patch (the Patch tool over the selection).
Arrow,
}
impl Badge {
@@ -122,9 +124,12 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option<Badg
// Patch and Content-Aware Move lasso their area in the Lasso's mode (⇧ adds, ⌥ subtracts,
// #11024). Over the selection without them a press drags the patch, which combines nothing.
if matches!(tool, Tool::Patch | Tool::ContentAwareMove) {
let at = app.drag.as_ref().filter(|d| d.tool == tool).map(|d| d.start).or(app.hover_doc);
// A drag in progress keeps what its press decided (the release reads `drag.modifiers`).
let drag = app.drag.as_ref().filter(|d| d.tool == tool);
let at = drag.map(|d| d.start).or(app.hover_doc);
let m = drag.map_or(m, |d| d.modifiers);
if at.is_some_and(|p| crate::retouch_ui::patch_drags_selection(app, p, m)) {
return None;
return (tool == Tool::Patch).then_some(Badge::Arrow);
}
return Badge::from_mode(document_selection_mode(app, Tool::Lasso, m));
}
@@ -159,6 +164,10 @@ pub fn badge_glyph(b: Badge, c: Pos2, h: f32) -> Vec<[Pos2; 2]> {
}
v
}
Badge::Arrow => {
let a = h * 0.6;
vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c + vec2(h, 0.0), c + vec2(h - a, a)], [c + vec2(h, 0.0), c + vec2(h - a, -a)]]
}
Badge::Intersect => {
let d = h * 0.8;
vec![[c + vec2(-d, -d), c + vec2(d, d)], [c + vec2(-d, d), c + vec2(d, -d)]]
@@ -319,3 +328,7 @@ mod tests {
assert!(e.len() >= 16 && e.iter().all(|p| p.x >= -0.01 && p.x <= 100.01 && p.y >= -0.01 && p.y <= 50.01));
}
}
#[cfg(test)]
#[path = "../patch_cursor_badge_tests.rs"]
mod patch_cursor_badge_tests;
+19
View File
@@ -377,6 +377,24 @@ fn mixer_press_failed(app: &mut PhotocraftApp, message: String) {
}
}
/// The Brush's chosen Clear paints as Normal on the Background, and its Mode then reads Normal
/// (#11897): at the press of a Brush stroke on the Background's own pixels, Clear (chosen in the
/// options bar, not a held `~`) is turned off, so the stroke is sent as Normal. A Background whose
/// pixels are locked refuses the stroke, so Clear stays chosen there.
fn clear_off_on_background(app: &mut PhotocraftApp, tool: Tool) {
let on_background = app.session.active().is_some_and(|st| {
st.active_layer.is_some_and(|id| {
st.doc.is_background(id) && {
let locks = st.doc.effective_locks(id);
!locks.pixels && !locks.all
}
})
});
if tool == Tool::Brush && app.ui.tool_options.brush_clear && !app.held_erase && on_background && paint_target(app) == serde_json::json!("pixels") {
app.ui.tool_options.brush_clear = false;
}
}
/// Tool state machine. Shared by mouse input and automation.
pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
if consumes_cancelled_vector_event(app, ev) {
@@ -540,6 +558,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
_ => {}
}
crate::paint_mouse::sync_tool_brush(app);
clear_off_on_background(app, tool);
// Clear mode (`~` held, or chosen in the options bar) erases with the tool's own brush.
let clear = crate::paint_mouse::clears(app, tool);
let erase = std::mem::take(&mut app.secondary_erase) || tool == Tool::Eraser || clear;
@@ -54,6 +54,7 @@ fn identity_app() -> (PhotocraftApp, RawOpen, Control) {
bytes: Some(bytes),
developed_with: Default::default(),
demosaic: Default::default(),
crop: None,
};
let mut app = PhotocraftApp::new(session, crate::Services::default());
app.sync_views();
@@ -16,9 +16,9 @@ pub(super) enum Target {
/// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens
/// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open
/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture,
/// Dehaze or the demosaic method changed (those act on the sensor data, see
/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw
/// step, then marks the document unmodified. Cancel closes it.
/// Dehaze or the demosaic method changed, or the sidecar has a crop (those act on the sensor
/// data, see [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one
/// Camera Raw step, then marks the document unmodified. Cancel closes it.
#[derive(Clone, Debug)]
pub struct RawOpen {
pub document: photocraft_doc::DocId,
@@ -30,6 +30,9 @@ pub struct RawOpen {
pub developed_with: photocraft_io::raw::Demosaic,
/// The method chosen in the dialog: Open re-develops when it differs from `developed_with`.
pub demosaic: photocraft_io::raw::Demosaic,
/// The crop of the file's sidecar (in the raw's stored orientation): Open re-develops and
/// crops to it. The settings have no crop, so the dialog previews the whole frame.
pub crop: Option<photocraft_io::camera_raw_xmp::Crop>,
}
/// Should an interactive open of `name` show the open-time Camera Raw dialog? `warnings` are
@@ -72,6 +75,8 @@ pub fn queue_open_dialog(
bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())),
developed_with: method,
demosaic: method,
// Read now: a filmstrip raw that is never shown still opens with its sidecar's crop.
crop: path.and_then(read_sidecar).and_then(|(_, report, _)| report.crop),
};
let held = super::filmstrip::HeldRaw::new(app, raw);
app.raw_shelf.push(held);
@@ -86,7 +91,7 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe
/// [`open_raw`], or with `kept` settings and the settings they started from (a filmstrip raw
/// shown again).
pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen, kept: Option<(CameraRaw, CameraRaw)>) -> Result<(), String> {
pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, mut raw: RawOpen, kept: Option<(CameraRaw, CameraRaw)>) -> Result<(), String> {
let index = document_index(app, raw.document).ok_or("the raw document was closed")?;
app.session.set_active(index);
let (layer, surf, _) = crate::distort_ui::active_pixels(app)?;
@@ -104,6 +109,7 @@ pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, raw: R
let mut from_sidecar = false;
if let Some((loaded, report, file)) = raw.path.as_deref().and_then(read_sidecar) {
params = loaded;
raw.crop = report.crop;
from_sidecar = true;
app.ui.status = if report.ignored.is_empty() {
crate::i18n::fmt(tl!("Settings from {name}"), &[("name", &file)])
@@ -208,6 +214,9 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw:
clarity: params.clarity,
texture: params.texture,
dehaze: params.dehaze,
// The sidecar's crop: re-developing crops the document, before the Camera Raw step, so
// the post-crop vignette centres on the crop as in Camera Raw.
crop: raw.crop,
};
// The open itself: the default settings, developed with the method it was opened with.
let as_opened = photocraft_io::raw::RawTuning { demosaic: raw.developed_with, ..Default::default() };
@@ -387,9 +396,13 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str,
}
let Some(document) = app.session.documents().get(index).map(|st| st.doc.id) else { return };
let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method());
let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method };
let mut raw =
RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method, crop: None };
let mut params = CameraRaw::default();
let sidecar = path.and_then(read_sidecar).map(|(loaded, _, _)| loaded);
let sidecar = path.and_then(read_sidecar).map(|(loaded, report, _)| {
raw.crop = report.crop;
loaded
});
if let Some(loaded) = &sidecar {
params = loaded.clone();
} else if app.session.prefs().raw_defaults.apply_auto_tone
@@ -397,7 +410,7 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str,
{
photocraft_algo::camera_raw::auto_tone(&px, &mut params);
}
let r = if params.is_identity() {
let r = if params.is_identity() && raw.crop.is_none() {
let defaults = app.session.prefs().raw_defaults.clone();
finish_open_raw(app, index, params, None, &raw, &defaults)
} else {
@@ -88,7 +88,8 @@ pub(super) fn store_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam
None if params.is_identity() => return Stored::Defaults,
_ => {}
}
let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes();
// The crop it opened with stays in the file, though the dialog has no control for it.
let bytes = photocraft_io::camera_raw_xmp::save_with_crop(params, raw.crop).into_bytes();
let Some(write) = app.services.write.as_mut() else { return Stored::Skipped };
if let Err(e) = write(&sidecar, &bytes) {
crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)]));
@@ -146,7 +147,12 @@ pub(super) fn load_settings(app: &mut PhotocraftApp) -> Result<Value, String> {
let file = crate::file_open::display_name(&name);
let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?;
let mut next = d.params.clone();
let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?;
let mut report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?;
// A crop only applies as a raw file opens; the filter has none, so it is not supported.
if report.crop.take().is_some() {
report.applied = report.applied.saturating_sub(1);
report.ignored.extend(photocraft_io::camera_raw_xmp::CROP_KEYS.iter().take(4).map(|k| k.to_string()));
}
next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?;
if d.params.point_curve != next.point_curve {
d.curve_state = Default::default();
@@ -705,7 +705,7 @@ fn auto_tone_of_a_never_shown_raw_matches_the_dialogs() {
}
}
let method = photocraft_io::raw::Demosaic::Ahd;
let raw = RawOpen { document, name: "pattern.dng".into(), path: None, bytes: None, developed_with: method, demosaic: method };
let raw = RawOpen { document, name: "pattern.dng".into(), path: None, bytes: None, developed_with: method, demosaic: method, crop: None };
let (unshown, _) = filmstrip::first_settings(&app, &raw);
open_raw(&mut app, &ctx, raw).unwrap();
let shown = &app.camera_raw.as_ref().unwrap().params;
@@ -745,7 +745,8 @@ fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() {
path: None,
bytes: None,
developed_with: Default::default(),
demosaic: Default::default()
demosaic: Default::default(),
crop: None
}
)
.is_err()
@@ -988,6 +989,74 @@ fn a_rating_only_sidecar_is_left_alone() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #9985: the crop of a Lightroom sidecar crops the opened raw (with the dialog's Open and
/// without the dialog), and neither its edges nor the raw's file name are named as unsupported.
/// A straightened crop isn't applied and is named. PhotoCraft's own sidecar keeps the crop.
#[test]
fn a_sidecar_crop_crops_the_opened_raw() {
let lightroom = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 7.0"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:RawFileName="IMG_0100.cr2" crs:HasCrop="True" crs:CropTop="0.25" crs:CropLeft="0.125" crs:CropBottom="0.75" crs:CropRight="0.625" crs:CropAngle="0"/></rdf:RDF></x:xmpmeta>"#;
let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("crop", Some(lightroom));
let ctx = egui::Context::default();
let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height));
open_dialog_at(&mut app, &ctx, &dng, &raw_path);
assert!(app.ui.status.contains("IMG_0100.xmp") && !app.ui.status.contains("not supported"), "{}", app.ui.status);
assert_eq!(size(&app), Some((48, 32)), "the dialog previews the whole frame");
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap();
assert_eq!(size(&app), Some((24, 16)), "Open crops the 48x32 frame to x 6..30, y 8..24");
let st = app.session.active().unwrap();
assert_eq!(st.saved_revision, st.revision, "the crop is part of opening: unmodified");
assert!(written.lock().unwrap().is_empty(), "Lightroom's sidecar is kept");
app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap();
app.open_file(&raw_path, &dng).unwrap();
assert_eq!(size(&app), Some((24, 16)), "without the dialog too");
app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": true})).unwrap();
// Straightened: the rectangle is measured on the turned frame, so nothing is cropped.
std::fs::write(sidecar_path(&raw_path), lightroom.replace(r#"crs:CropAngle="0""#, r#"crs:CropAngle="1.38""#)).unwrap();
open_dialog_at(&mut app, &ctx, &dng, &raw_path);
assert!(app.ui.status.contains("CropAngle") && !app.ui.status.contains("RawFileName"), "{}", app.ui.status);
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap();
assert_eq!(size(&app), Some((48, 32)));
// PhotoCraft's own sidecar: a changed setting rewrites it, with the crop it was opened with.
let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 };
std::fs::write(sidecar_path(&raw_path), photocraft_io::camera_raw_xmp::save_with_crop(&CameraRaw::default(), Some(crop))).unwrap();
open_dialog_at(&mut app, &ctx, &dng, &raw_path);
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap();
assert_eq!(size(&app), Some((24, 16)));
let w = written.lock().unwrap().clone();
assert_eq!(w.len(), 1);
let mut back = CameraRaw::default();
let r = photocraft_io::camera_raw_xmp::load(&mut back, &w[0].1).unwrap();
assert_eq!((back.contrast, r.crop), (15.0, Some(crop)));
let _ = std::fs::remove_dir_all(&dir);
}
/// #9985 with the filmstrip: raws opened together each open with their own sidecar's crop, also
/// the one that is never shown and the one that was shown and left.
#[test]
fn filmstrip_raws_open_with_their_sidecar_crops() {
let lightroom = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 7.0"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:HasCrop="True" crs:CropTop="0.25" crs:CropLeft="0.125" crs:CropBottom="0.75" crs:CropRight="0.625" crs:CropAngle="0"/></rdf:RDF></x:xmpmeta>"#;
let (mut app, dng, dir, raw_path, _) = raw_with_sidecar("filmcrop", Some(lightroom));
let paths: Vec<String> = (1..3).map(|i| dir.join(format!("IMG_010{i}.cr2")).to_string_lossy().into_owned()).collect();
for p in &paths {
std::fs::write(p, &dng).unwrap();
std::fs::write(sidecar_path(p), lightroom).unwrap();
}
let ctx = egui::Context::default();
app.open_file(&raw_path, &dng).unwrap();
for p in &paths {
app.open_file(p, &dng).unwrap();
}
assert_eq!(app.raw_shelf.len(), 3);
filmstrip::open_held(&mut app, &ctx, 0).unwrap();
filmstrip::show_raw(&mut app, &ctx, 1).unwrap();
filmstrip::show_raw(&mut app, &ctx, 0).unwrap();
let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap();
assert_eq!(r["opened"].as_array().map(Vec::len), Some(3), "{r}");
let sizes: Vec<_> = app.session.documents().iter().map(|st| (st.doc.size.width, st.doc.size.height)).collect();
assert_eq!(sizes, vec![(24, 16); 3], "shown, shown and left, and never shown: all cropped");
let _ = std::fs::remove_dir_all(&dir);
}
/// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing
/// back: nothing changed.
#[test]
+11 -2
View File
@@ -43,6 +43,9 @@ pub struct Held {
/// The Color Settings policy was already applied (a background open added it before it had
/// to wait); otherwise opening applies it.
pub policy_applied: bool,
/// Held by Open as Smart Object: the document holds the smart object layer already, and is
/// admitted by [`photocraft_engine::file_cmds::admit_as_smart_object`].
pub smart_object: bool,
}
/// Dialog field for the high-memory confirmation prompt.
@@ -323,7 +326,7 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result<Val
if held.damage_pending {
return Err("damaged PSD cannot be opened before confirmation".into());
}
let Held { mut doc, read_only, damage_pending: _, name, path, warnings, bytes, slot, order, question, policy_applied } = held;
let Held { mut doc, read_only, damage_pending: _, name, path, warnings, bytes, slot, order, question, policy_applied, smart_object } = held;
let index = if question.is_some() || policy_applied {
if let Some(action) = action
&& let Err(e) = app.session.color.resolve_open(&mut doc, action)
@@ -331,7 +334,13 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result<Val
app.open_failed(&name, &e.to_string());
return Err(e.to_string());
}
app.session.add_document(doc, None)
if smart_object { photocraft_engine::file_cmds::admit_as_smart_object(&mut app.session, doc) } else { app.session.add_document(doc, None) }
} else if smart_object {
// A damage-only hold has no colour question: apply the policy as the engine's open does,
// then admit it as the smart object document it is (name, history label, snapshot).
let ignore_exif = app.session.prefs().file_handling.ignore_exif_profile_tag;
app.session.color.open_policy(&mut doc, ignore_exif);
photocraft_engine::file_cmds::admit_as_smart_object(&mut app.session, doc)
} else {
app.session.open_document(doc, None).0
};
+24
View File
@@ -295,6 +295,30 @@ impl PhotocraftApp {
})
}
/// File › Open as Smart Object…: the File › Open dialog (same filters, several files); each
/// chosen file opens as its own document holding one embedded smart object, in name order,
/// reporting each failure as File › Open does.
pub fn open_dialog_as_smart_object(&mut self) -> Result<Value, String> {
let initial_dir = last_used_dir(&self.ui.recent_files);
self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, |app, answer| {
let FileDialogAnswer::Paths(paths) = answer else { return Err(UNEXPECTED.into()) };
// Each file is a tracked open job like File › Open's, in name order. No `show_if_open`:
// the file opens as another document even when it is already open.
let mut paths: Vec<String> = paths.iter().map(|p| crate::file_open::absolute(p)).collect();
paths.sort_by(|a, b| crate::file_open::natural_cmp(a, b));
let batch = crate::jobs_ui::begin_batch(app);
for (seq, path) in paths.iter().enumerate() {
let order = crate::jobs_ui::OpenOrder { batch, seq };
if let Err(e) = crate::jobs_ui::start_open_as_smart_object(app, path, Some(order)) {
app.open_failed(&crate::file_open::display_name(path), &e);
}
}
crate::jobs_ui::end_batch(app);
app.sync_views();
Ok(Value::Null)
})
}
/// Opens each picked file's contents as its own document, in name order (like
/// [`Self::open_paths`]), reporting each failure.
fn open_contents(&mut self, mut files: Vec<(String, Vec<u8>)>) {
+2 -2
View File
@@ -600,12 +600,12 @@ fn digit_run(it: &mut std::iter::Peekable<std::str::Chars>) -> String {
}
#[cfg(not(target_arch = "wasm32"))]
fn absolute(p: &str) -> String {
pub(crate) fn absolute(p: &str) -> String {
std::path::absolute(p).map(|a| a.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string())
}
#[cfg(target_arch = "wasm32")]
fn absolute(p: &str) -> String {
pub(crate) fn absolute(p: &str) -> String {
p.to_string()
}
+40
View File
@@ -1697,3 +1697,43 @@ fn queued_drop_waits_for_an_external_document_job() {
assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place));
assert!(app.drop_places.is_empty());
}
/// File › Open as Smart Object… (#11678) asks with File › Open's dialog and opens each chosen
/// file as its own untitled document holding one embedded smart object.
#[test]
fn open_as_smart_object_dialog_opens_one_document_per_selected_file() {
let (dir, paths) = files("open-as-so", &["one.png", "two.png", "bad.png"], &png_bytes(8, 6));
std::fs::write(&paths[2], b"not an image").unwrap();
let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]);
menus::invoke(&mut app, &egui::Context::default(), "file.openAsSmartObject", json!({})).unwrap();
assert!(app.file_dialog_open(), "the menu item asks for files first");
answer(&mut app);
assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::<Vec<_>>(), ["one-1", "two-1"]);
assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1));
assert!(app.ui.notices.iter().any(|n| n.error && n.title.starts_with("Couldn't open bad.png")), "the unreadable file is reported");
let _ = std::fs::remove_dir_all(&dir);
}
/// File > Open as Smart Object... runs each file as a tracked background job like File > Open
/// does: a tab per file, the documents landing in name order, untitled, failures reported.
#[test]
fn open_as_smart_object_opens_in_the_background_in_name_order() {
let ctx = egui::Context::default();
let (dir, paths) = files("open-as-so-bg", &["two.png", "one.png", "bad.png"], &png_bytes(8, 6));
std::fs::write(&paths[2], b"not an image").unwrap();
let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]);
app.background_jobs = true;
menus::invoke(&mut app, &ctx, "file.openAsSmartObject", json!({})).unwrap();
answer(&mut app);
assert_eq!(app.jobs.opens.len(), 3, "a tab for each file while it opens");
assert!(app.session.documents().is_empty(), "nothing is on the UI thread's critical path");
tick_until(&mut app, &ctx, |app| app.jobs.opens.is_empty());
assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::<Vec<_>>(), ["one-1", "two-1"]);
assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1));
assert!(app.ui.notices.iter().any(|n| n.error && n.title.starts_with("Couldn't open bad.png")), "the unreadable file is reported");
let _ = std::fs::remove_dir_all(&dir);
}
+3
View File
@@ -182,6 +182,9 @@ pub static ICONS: &[(&str, &[u8])] = &[
("type-mask-vertical", include_bytes!("../../../assets/icons/type-mask-vertical.svg")),
("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")),
("unlink", include_bytes!("../../../assets/icons/unlink.svg")),
("vertical-align-bottom", include_bytes!("../../../assets/icons/vertical-align-bottom.svg")),
("vertical-align-middle", include_bytes!("../../../assets/icons/vertical-align-middle.svg")),
("vertical-align-top", include_bytes!("../../../assets/icons/vertical-align-top.svg")),
("wand", include_bytes!("../../../assets/icons/wand.svg")),
("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")),
("x", include_bytes!("../../../assets/icons/x.svg")),
+34 -6
View File
@@ -286,11 +286,38 @@ pub fn start_open(
// once. A preflight keeps the slot and set order in its memory prompt.
Started::Done(v) => finish_open(app, name, path.as_deref(), &v, slot, order),
Started::Job(job) => {
app.jobs.opens.push(OpenTab { job, name: name.to_string(), path, slot, order });
app.jobs.focus = Some(job);
app.ui.chrome.home = None;
app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", name)]);
app.ui.status_error = false;
track_open(app, OpenTab { job, name: name.to_string(), path, slot, order });
Ok(())
}
}
}
/// A file opening in the background gets its tab: focused, with the status line naming it.
fn track_open(app: &mut PhotocraftApp, tab: OpenTab) {
app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", &tab.name)]);
app.jobs.focus = Some(tab.job);
app.jobs.opens.push(tab);
app.ui.chrome.home = None;
app.ui.status_error = false;
}
/// File › Open as Smart Object… for one file: `file.openAsSmartObject` as a tracked open job, so
/// the result goes through File › Open's completion ([`finish_open`]: the profile prompt, the
/// Camera Raw open dialog for a raw, the import warnings). The document is untitled, so
/// Open Recent is untouched. Inline (background jobs off, or the web) it finishes at once.
pub(crate) fn start_open_as_smart_object(app: &mut PhotocraftApp, path: &str, order: Option<OpenOrder>) -> Result<(), String> {
let name = crate::file_open::display_name(path);
// `confirmDamage`: a damaged PSD waits for the user's answer, as in File › Open.
let params = json!({"path": path, "confirmDamage": true});
let started = if app.background_jobs {
app.session.start("file.openAsSmartObject", params).map_err(|e| e.to_string())?
} else {
Started::Done(app.session.execute("file.openAsSmartObject", params).map_err(|e| e.to_string())?)
};
match started {
Started::Done(v) => finish_open(app, &name, None, &v, None, order),
Started::Job(job) => {
track_open(app, OpenTab { job, name, path: None, slot: None, order });
Ok(())
}
}
@@ -608,7 +635,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) {
finish_save(app, ctx, save, &e.label, e.outcome);
return;
}
if e.command == OPEN_JOB {
if e.command == OPEN_JOB || app.jobs.opens.iter().any(|o| o.job == e.id) {
let Some(i) = app.jobs.opens.iter().position(|o| o.job == e.id) else { return };
let tab = app.jobs.opens.remove(i);
match e.outcome {
@@ -703,6 +730,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu
order,
question,
policy_applied,
smart_object: v.get("smartObject").and_then(Value::as_bool).unwrap_or(false),
};
// Held back for the profile prompt (Ask When Opening).
if let Some(id) = v.get("held").and_then(Value::as_u64) {
@@ -0,0 +1,63 @@
//! "Layer Via Copy" and "Layer Via Cut" carry a capital V in every menu, as in the reference
//! application (#12357), and every translation moved with the key.
use crate::canvas::canvas_tool_menu::SELECTION_MENU;
use crate::menu_catalog::CATALOG;
const NEW: [(&str, &str); 2] = [("Layer Via Copy", "layer.new.layerViaCopy"), ("Layer Via Cut", "layer.new.layerViaCut")];
const OLD: [&str; 2] = ["Layer via Copy", "Layer via Cut"];
#[test]
fn menu_bar_rows_use_the_capital_v() {
for (label, id) in NEW {
let row = CATALOG.iter().find(|r| r.3 == id).expect("catalog row");
assert_eq!(row.1, label);
}
assert!(CATALOG.iter().all(|r| !OLD.contains(&r.1)));
}
#[test]
fn command_labels_use_the_capital_v() {
let specs = photocraft_engine::cmd::command_specs();
for (label, id) in NEW {
let spec = specs.iter().find(|s| s.id == id).expect("command spec");
assert_eq!(spec.label, label);
}
}
#[test]
fn selection_tools_canvas_menu_uses_the_capital_v() {
for (label, id) in NEW {
assert!(SELECTION_MENU.iter().flatten().any(|r| *r == (label, id)), "{label} row");
}
assert!(SELECTION_MENU.iter().flatten().all(|r| !OLD.contains(&r.0)));
}
#[test]
fn every_locale_file_keeps_its_translation_under_the_new_keys() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n");
let mut checked = 0;
for entry in std::fs::read_dir(dir).expect("i18n dir") {
let path = entry.expect("entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tsv") {
continue;
}
let text = std::fs::read_to_string(&path).expect("tsv");
let keys: Vec<&str> = text.lines().filter_map(|l| l.split('\t').nth(1)).collect();
let name = path.display();
assert!(OLD.iter().all(|o| !keys.contains(o)), "{name} still has an old key");
// Hindi has no entries for these menus yet; every other file has both.
if keys.contains(&"Layer Via Copy") || keys.contains(&"Layer Via Cut") {
assert!(NEW.iter().all(|(n, _)| keys.contains(n)), "{name} lacks a new key");
checked += 1;
}
}
assert!(checked >= 30, "only {checked} locale files carry the keys");
}
#[test]
fn a_translation_is_found_under_the_new_key() {
let de = crate::i18n::Lang::from_code("de").expect("German");
assert_eq!(crate::i18n::tr(de, "Layer Via Copy"), "Ebene durch Kopieren");
assert_eq!(crate::i18n::tr(de, "Layer Via Cut"), "Ebene durch Ausschneiden");
}
+5
View File
@@ -907,6 +907,9 @@ mod color_tab_theme_tests;
#[cfg(test)]
mod new_layer_button_tests;
#[cfg(test)]
mod layer_via_label_tests;
#[cfg(test)]
mod smart_filter_rows_tests;
@@ -1024,6 +1027,8 @@ mod doc_tab_menu_tests;
#[cfg(test)]
mod layers_empty_area_menu_tests;
#[cfg(test)]
mod pencil_auto_erase_tooltip_tests;
#[cfg(test)]
mod preferences_submenu_tests;
#[cfg(test)]
mod selection_move_step_tests;
+3 -2
View File
@@ -7,6 +7,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[
(&["File"], "New…", Some("Cmd+N"), "file.new"),
(&["File"], "Open…", Some("Cmd+O"), "file.open"),
(&["File"], "Open As…", Some("Cmd+Alt+Shift+O"), "file.openAs"),
(&["File"], "Open as Smart Object…", None, "file.openAsSmartObject"),
(&["File"], "Open EXR Parts…", None, "file.openExrParts"),
(&["File"], "---", None, "---"),
(&["File"], "Close", Some("Cmd+W"), "file.close"),
@@ -256,8 +257,8 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[
(&["Layer", "New"], "Artboard from Layers…", None, "layer.new.artboardFromLayers"),
(&["Layer", "New"], "Frame from Layers", None, "layer.new.frameFromLayers"),
(&["Layer", "New"], "---", None, "---"),
(&["Layer", "New"], "Layer via Copy", Some("Cmd+J"), "layer.new.layerViaCopy"),
(&["Layer", "New"], "Layer via Cut", Some("Cmd+Shift+J"), "layer.new.layerViaCut"),
(&["Layer", "New"], "Layer Via Copy", Some("Cmd+J"), "layer.new.layerViaCopy"),
(&["Layer", "New"], "Layer Via Cut", Some("Cmd+Shift+J"), "layer.new.layerViaCut"),
(&["Layer"], "Duplicate Layer…", None, "layer.duplicate"),
(&["Layer", "Delete"], "Layer", None, "layer.delete"),
(&["Layer", "Delete"], "Hidden Layers", None, "layer.delete.hiddenLayers"),
@@ -345,6 +345,7 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para
let dialog = |app: &mut PhotocraftApp, fields: Value, choices: Value| Some(Ok(json!({"dialog": form(app, id, label, fields, choices)})));
match id {
"file.openAs" => Some(app.open_dialog_file()),
"file.openAsSmartObject" => Some(app.open_dialog_as_smart_object()),
"file.openExrParts" => open_exr_parts_dialog(app),
"file.saveACopy" => Some(save_a_copy(app)),
"file.placeEmbedded" | "file.placeLinked" => {
@@ -223,6 +223,7 @@ fn wraps(id: &str) -> bool {
matches!(
id,
"file.openAs"
| "file.openAsSmartObject"
| "file.openExrParts"
| "file.saveACopy"
| "file.placeEmbedded"
@@ -1880,6 +1880,47 @@ fn checkboxes_show_a_pressed_state_from_their_label() {
assert_eq!(fill(&h), Some(widgets::check_box_fill(true, false, &t)));
}
#[test]
fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut h = harness(0, 0);
h.state_mut().run("tools.setBrush", json!({"brush": {"spacing": 0.427, "spacingEnabled": false}})).unwrap();
h.run_steps(2);
let journal = h.state().session.journal.len();
let spacing = h.state().session.tools.brush.spacing;
let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing");
checkbox.click();
h.run_steps(2);
assert!(h.state().session.tools.brush.spacing_enabled);
assert_eq!(h.state().session.tools.brush.spacing, spacing);
assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush");
assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": true}));
let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap();
field.click();
h.key_press(egui::Key::ArrowUp);
h.run_steps(2);
assert_eq!(h.state().session.tools.brush.spacing, 0.44);
assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush");
assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacing": 0.44_f32}));
let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing");
checkbox.click();
h.run_steps(2);
assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": false}));
let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(44.0)).unwrap();
assert!(field.accesskit_node().is_disabled());
field.click();
h.key_press(egui::Key::ArrowUp);
h.run_steps(2);
assert_eq!(h.state().session.tools.brush.spacing, 0.44);
assert_eq!(h.state().session.journal.len(), journal + 3, "disabled input adds no command");
let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing");
checkbox.click();
h.run_steps(2);
assert_eq!(h.state().session.tools.brush.spacing, 0.44);
assert_eq!(h.state().session.journal.len(), journal + 4);
}
#[test]
fn scatter_header_edits_route_to_the_correct_brush_block_and_disabled_sections_ignore_input() {
use egui::accesskit::Role;
+28 -5
View File
@@ -54,6 +54,33 @@ fn signed_pct(ui: &mut egui::Ui, label: &str, v: &mut f32, min: f32, max: f32) -
changed
}
/// The checkbox labels the spacing field; toggling it must retain imported fractional values.
fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) {
let mut percent = (b.spacing * 100.0).round();
let mut changed = false;
ui.horizontal(|ui| {
widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
changed = ui
.add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0).accessible_name(tl!("Spacing")))
.inner
.changed();
});
});
changed |= ui
.add_enabled_ui(b.spacing_enabled, |ui| {
let s = widgets::slider(ui, &mut percent, 1.0..=1000.0, None);
s.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(percent), tl!("Spacing")));
s
})
.inner
.changed();
if changed {
b.spacing = (percent.round() / 100.0).clamp(0.01, 10.0);
}
ui.add_space(4.0);
}
fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str) -> bool {
let mut x = *v;
let changed = widgets::slider_row(ui, label, &mut x, range.clone(), suffix, None).changed();
@@ -356,11 +383,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset],
ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| {
pct(ui, tl!("Hardness"), &mut b.hardness, 1.0);
});
// Unchecked, the pointer's speed sets the spacing (Photoshop).
widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing"));
ui.add_space(2.0);
let on = b.spacing_enabled;
ui.add_enabled_ui(on, |ui| signed_pct(ui, tl!("Spacing"), &mut b.spacing, 0.01, 10.0));
spacing_row(ui, b);
None
}
@@ -340,6 +340,28 @@ fn ellipse_widget_drags_angle_and_roundness() {
assert_eq!(r, 1.0);
}
#[test]
fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() {
for kind in theme::ThemeKind::ALL {
for enabled in [false, true] {
let b = BrushSettings { spacing: 0.427, spacing_enabled: enabled, ..Default::default() };
let mut h = section_harness(0, b.clone());
crate::PhotocraftApp::setup_context(&h.ctx, kind);
h.run_steps(3);
assert_eq!(h.query_all_by_role_and_label(Role::Label, "Spacing").count(), 0, "{kind:?}: the checkbox is the single label");
assert_eq!(h.query_all_by_role_and_label(Role::CheckBox, "Spacing").count(), 1);
let checkbox = h.get_by_role_and_label(Role::CheckBox, "Spacing").rect();
let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap();
assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row");
assert_eq!(field.accesskit_node().is_disabled(), !enabled);
let slider = h.get_by_role_and_label(Role::Slider, "Spacing");
assert_eq!(slider.accesskit_node().numeric_value(), Some(43.0), "{kind:?}: the track is a named slider");
assert_eq!(slider.accesskit_node().is_disabled(), !enabled);
assert_eq!(h.state(), &b, "rendering does not round the saved spacing");
}
}
}
fn check_scatter_header(section: usize) {
for kind in theme::ThemeKind::ALL {
for both_axes in [false, true] {
@@ -367,6 +389,43 @@ fn check_scatter_header(section: usize) {
}
}
#[test]
fn spacing_row_retains_disabled_values_and_percentage_editing_limits() {
let mut h = section_harness(0, BrushSettings { spacing: 0.42, spacing_enabled: false, ..Default::default() });
let before = h.state().clone();
let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap();
let from = pos2(field.rect().left() - 100.0, field.rect().bottom() + 9.0);
let to = pos2(field.rect().right() - 25.0, from.y);
field.click();
h.key_press(egui::Key::ArrowUp);
h.event(egui::Event::Text("999".into()));
h.key_press(egui::Key::Enter);
h.run_steps(2);
drag(&mut h, from, to);
assert_eq!(h.state(), &before, "Off ignores both field input and a track drag");
let checkbox = h.get_by_role_and_label(Role::CheckBox, "Spacing");
checkbox.click();
h.run_steps(2);
assert!(h.state().spacing_enabled);
assert_eq!(h.state().spacing, 0.42, "enabling keeps the saved value");
let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap();
field.click();
h.key_press(egui::Key::ArrowUp);
h.run_steps(2);
assert_eq!(h.state().spacing, 0.43, "integer percentage edits become a fraction");
drag(&mut h, from, to);
assert!(h.state().spacing > 5.0, "the same track is editable when enabled");
for (spacing, value, key) in [(0.01, 1.0, egui::Key::ArrowDown), (10.0, 1000.0, egui::Key::ArrowUp)] {
h.state_mut().spacing = spacing;
h.run_steps(2);
let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap();
field.click();
h.key_press(key);
h.run_steps(2);
assert_eq!(h.state().spacing, spacing, "the percentage stays within 1..1000");
}
}
#[test]
fn scatter_header_scattering_places_axes_between_label_and_percentage_in_all_themes() {
check_scatter_header(2);
+2 -1
View File
@@ -222,7 +222,8 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens,
opt_label(ui, tl!("Smoothing"));
smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 });
widgets::vline(ui, 22.0);
widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase"));
widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase"))
.on_hover_text(tl!("Draws background color over foreground colors"));
}
Tool::Brush | Tool::Eraser => {
brush_preset_chip(ui, b, &mut app.ui);
@@ -0,0 +1,119 @@
//! The Patch tool's cursor shows a right-pointing arrow over the selection (#12130).
use egui::Modifiers;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::Drag;
use crate::state::Tool;
use crate::tool_feedback::{Badge, badge, badge_glyph};
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap();
app
}
const INSIDE: [f64; 2] = [30.0, 30.0];
const OUTSIDE: [f64; 2] = [80.0, 80.0];
#[test]
fn arrow_over_the_selection_without_modifiers() {
let mut app = app();
app.hover_doc = Some(INSIDE);
assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow));
assert_ne!(Badge::Arrow, Badge::Move, "a new single-arrow glyph");
// Size 4 is the half-length h of the shaft; the barbs fall back by a = 0.6 h.
let (h, a, eps) = (4.0_f32, 2.4_f32, 1e-5_f32);
let glyph = badge_glyph(Badge::Arrow, egui::pos2(0.0, 0.0), h);
assert_eq!(glyph.len(), 3, "one shaft and two barbs");
let near = |p: egui::Pos2, x: f32, y: f32| (p.x - x).abs() < eps && (p.y - y).abs() < eps;
assert!(near(glyph[0][0], -h, 0.0) && near(glyph[0][1], h, 0.0), "shaft through the centre: {:?}", glyph[0]);
for barb in &glyph[1..] {
assert!(near(barb[0], h, 0.0), "a barb starts at the tip: {barb:?}");
}
let (up, down) = (glyph[1][1], glyph[2][1]);
assert!((up.x - (h - a)).abs() < eps && (down.x - (h - a)).abs() < eps, "barbs end behind the tip");
assert!((up.y + down.y).abs() < eps && (up.y.abs() - a).abs() < eps, "barb ends mirror about the shaft: {up:?} {down:?}");
}
#[test]
fn the_arrow_stays_during_a_drag_started_inside() {
let mut app = app();
app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false));
app.hover_doc = Some(OUTSIDE);
assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow));
}
#[test]
fn outside_and_with_shift_or_alt_the_badges_are_as_before() {
let mut app = app();
app.hover_doc = Some(OUTSIDE);
assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), None);
app.hover_doc = Some(INSIDE);
assert_eq!(badge(&app, Tool::Patch, Modifiers::SHIFT), Some(Badge::Add));
assert_eq!(badge(&app, Tool::Patch, Modifiers::ALT), Some(Badge::Subtract));
}
#[test]
fn a_patch_drag_started_outside_does_not_show_the_arrow_when_it_enters_the_selection() {
let mut app = app();
app.hover_doc = Some(OUTSIDE);
let plain_outside = badge(&app, Tool::Patch, Modifiers::NONE);
app.drag = Some(Drag::new(Tool::Patch, OUTSIDE, vec![[OUTSIDE[0], OUTSIDE[1], 1.0]], Modifiers::NONE, false));
app.hover_doc = Some(INSIDE);
assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), plain_outside);
assert_ne!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow));
}
#[test]
fn content_aware_move_has_no_arrow_even_in_a_drag_started_inside() {
let mut app = app();
app.drag = Some(Drag::new(Tool::ContentAwareMove, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false));
app.hover_doc = Some(INSIDE);
assert_eq!(badge(&app, Tool::ContentAwareMove, Modifiers::NONE), None);
}
/// The release reads the press's modifiers, so ⇧ or ⌥ pressed mid-drag changes nothing.
#[test]
fn modifiers_pressed_mid_drag_do_not_change_the_badge() {
let mut app = app();
app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false));
app.hover_doc = Some(OUTSIDE);
for m in [Modifiers::SHIFT, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] {
assert_eq!(badge(&app, Tool::Patch, m), Some(Badge::Arrow), "{m:?}");
}
}
#[test]
fn a_drag_pressed_with_shift_or_alt_keeps_that_badge() {
let mut app = app();
app.hover_doc = Some(INSIDE);
for (pressed, want) in [(Modifiers::SHIFT, Badge::Add), (Modifiers::ALT, Badge::Subtract)] {
app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], pressed, false));
assert_eq!(badge(&app, Tool::Patch, pressed), Some(want));
assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(want), "released keys mid-drag");
}
}
#[test]
fn shift_and_alt_together_over_the_selection_intersect() {
let mut app = app();
app.hover_doc = Some(INSIDE);
assert_eq!(badge(&app, Tool::Patch, Modifiers::SHIFT | Modifiers::ALT), Some(Badge::Intersect));
}
#[test]
fn content_aware_move_and_the_curvature_pen_keep_their_cursors() {
let mut app = app();
app.hover_doc = Some(INSIDE);
assert_eq!(badge(&app, Tool::ContentAwareMove, Modifiers::NONE), None);
// The Curvature Pen over an anchor of the active path: four arrows.
let path = json!({"subpaths": [{"closed": true, "knots": [[40, 40], [160, 40], [160, 160], [40, 160]]}]});
app.run("path.set", json!({"name": "work", "path": path})).unwrap();
app.ui.tool = Tool::CurvaturePen;
app.hover_doc = Some([160.0, 40.0]);
assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), Some(Badge::Move));
assert_eq!(badge_glyph(Badge::Move, egui::pos2(0.0, 0.0), 4.0).len(), 10, "four-arrow glyph unchanged");
}
+116
View File
@@ -494,3 +494,119 @@ fn the_pen_does_not_carry_on_a_work_path_hidden_by_make_selection() {
assert_eq!(anchors(&saved.path), [vec![[150.0, 150.0], [200.0, 150.0], [200.0, 190.0]]], "the saved path carried on");
assert_eq!(anchors(&work_path(&app)).len(), 1, "the hidden Work Path untouched");
}
/// #9356: in Shape mode a Pen press on an endpoint of the selected shape layer's open path carries
/// that path on, from either end, and the commit updates the same layer in one History step
/// instead of making a new shape layer. Elsewhere the press starts a new shape as before.
#[test]
fn shape_mode_carries_on_the_selected_shapes_open_path() {
let mut app = app(Tool::Pen);
app.ui.tool_options.vector_mode = "shape".into();
let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.len();
let shape = |app: &PhotocraftApp| {
let st = app.session.active().unwrap();
match &st.doc.layer(st.active_layer.unwrap()).unwrap().content {
LayerContent::Shape(sh) => {
sh.path.subpaths.iter().map(|s| s.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect::<Vec<_>>()).collect::<Vec<_>>()
}
other => panic!("not a shape layer: {other:?}"),
}
};
let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len();
click(&mut app, 20.0, 20.0);
click(&mut app, 80.0, 20.0);
crate::vector_ui::pen_commit(&mut app, false);
let (n, before) = (layers(&app), steps(&app));
assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 81.0, 21.0), "the continue pointer over the endpoint");
assert!(!crate::vector_ui::continues_path_at(&app, Tool::Pen, 50.0, 20.0), "not mid-segment");
click(&mut app, 80.0, 20.0);
click(&mut app, 80.0, 80.0);
crate::vector_ui::pen_commit(&mut app, false);
assert_eq!(layers(&app), n, "no new shape layer");
assert_eq!(steps(&app), before + 1, "one History step");
assert_eq!(shape(&app), [vec![[20.0, 20.0], [80.0, 20.0], [80.0, 80.0]]]);
// From the first anchor, reversed.
click(&mut app, 20.0, 20.0);
click(&mut app, 20.0, 80.0);
crate::vector_ui::pen_commit(&mut app, false);
assert_eq!(shape(&app), [vec![[80.0, 80.0], [80.0, 20.0], [20.0, 20.0], [20.0, 80.0]]]);
// Anywhere else: a new shape layer.
click(&mut app, 150.0, 150.0);
click(&mut app, 200.0, 150.0);
crate::vector_ui::pen_commit(&mut app, false);
assert_eq!(layers(&app), n + 1);
}
/// #9356: with a vector mask targeted, the Pen carries on the mask's open subpath in place.
#[test]
fn a_targeted_vector_mask_carries_on_its_open_path() {
let mut app = app(Tool::Pen);
let id = app.session.active().unwrap().active_layer.unwrap();
let open = json!({"subpaths": [{"closed": false, "knots": [{"anchor": [20.0, 20.0]}, {"anchor": [80.0, 20.0]}]}]});
app.run("layer.vectorMask.add", json!({"layer": id.0, "path": open})).unwrap();
app.ui.vector_mask_target = true;
let mask = |app: &PhotocraftApp| {
let st = app.session.active().unwrap();
let p = &st.doc.layer(id).unwrap().vector_mask.as_ref().unwrap().path;
p.subpaths.iter().map(|s| s.knots.len()).collect::<Vec<_>>()
};
assert_eq!(mask(&app), [2]);
click(&mut app, 80.0, 20.0);
click(&mut app, 80.0, 80.0);
crate::vector_ui::pen_commit(&mut app, false);
assert_eq!(mask(&app), [3], "the same subpath, extended");
}
/// #9396 review: on a short open path whose two ends are both within reach of the pointer (as
/// when zoomed out), the press under the continue-path pointer carries the nearer end on and never
/// closes the path, which it did when the other end was its first anchor.
#[test]
fn a_press_in_reach_of_both_ends_carries_the_nearer_one_on_and_does_not_close() {
let mut app = app(Tool::Pen);
assert_eq!(app.point_zoom(), 1.0, "a 6 px reach in document pixels");
// The ends sit beside the first segment, which Auto Add/Delete would put an anchor on.
app.ui.tool_options.pen_auto_add_delete = false;
for (x, y) in [(20.0, 20.0), (60.0, 20.0), (60.0, 40.0), (26.0, 22.0)] {
click(&mut app, x, y);
}
crate::vector_ui::pen_commit(&mut app, false);
let ends = |app: &PhotocraftApp| {
let pen = app.ui.pen.as_ref().expect("a resumed draft");
assert!(pen.resumes.is_some(), "{pen:?}");
pen.knots.last().map(|k| k[0])
};
// (24, 22): 2 px from the last anchor, 4.5 px from the first.
assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 24.0, 22.0));
click(&mut app, 24.0, 22.0);
assert!(!work_path(&app).subpaths[0].closed, "the press carried the path on, it did not close it");
assert_eq!(ends(&app), Some([26.0, 22.0]), "from the nearer, last anchor");
click(&mut app, 100.0, 100.0);
crate::vector_ui::pen_commit(&mut app, false);
let sub = &work_path(&app).subpaths[0];
assert!(!sub.closed && sub.knots.len() == 5 && sub.knots[4].anchor.x == 100.0, "{sub:?}");
// (21, 20): 1 px from the first anchor, 5.4 px from the last: carried on from the first.
click(&mut app, 21.0, 20.0);
assert!(!work_path(&app).subpaths[0].closed);
assert_eq!(ends(&app), Some([20.0, 20.0]), "from the nearer, first anchor");
}
/// #9396 review: with the ends of two open subpaths both within reach, the press carries on the
/// subpath whose end is nearest the pointer, not the first one listed.
#[test]
fn a_press_in_reach_of_two_subpaths_ends_carries_the_nearest_on() {
let mut app = app(Tool::Pen);
assert_eq!(app.point_zoom(), 1.0, "a 6 px reach in document pixels");
app.ui.tool_options.pen_auto_add_delete = false;
click(&mut app, 20.0, 20.0);
click(&mut app, 60.0, 20.0);
crate::vector_ui::pen_commit(&mut app, false);
click(&mut app, 100.0, 100.0);
click(&mut app, 64.0, 22.0);
crate::vector_ui::pen_commit(&mut app, false);
assert_eq!(work_path(&app).subpaths.len(), 2, "two open subpaths");
// (63, 21): 3.2 px from the first subpath's last anchor, 1.4 px from the second's.
click(&mut app, 63.0, 21.0);
let pen = app.ui.pen.as_ref().expect("a resumed draft");
assert_eq!(pen.resumes.as_ref().map(|r| r.index), Some(1), "{pen:?}");
assert_eq!(pen.knots.last().map(|k| k[0]), Some([64.0, 22.0]), "from the second subpath's nearer end");
}
@@ -0,0 +1,34 @@
//! #11968: hovering the Pencil's Auto Erase checkbox shows "Draws background color over
//! foreground colors", in the Pro and the other themes.
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::PhotocraftApp;
use crate::state::Tool;
use crate::theme::ThemeKind;
const TIP: &str = "Draws background color over foreground colors";
fn bar(theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 80, "height": 80})).unwrap();
app.ui.tool = Tool::Pencil;
let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app);
PhotocraftApp::setup_context(&h.ctx, theme);
h.run_steps(4);
h
}
#[test]
fn auto_erase_has_the_reference_tooltip() {
for theme in [ThemeKind::ProMedium, ThemeKind::Classic] {
let mut h = bar(theme);
// Hovering the checkbox's label shows it.
h.get_by_label("Auto Erase").hover();
h.run_steps(60);
assert!(h.query_by_label(TIP).is_some(), "{theme:?}: no tooltip on hover");
}
}
+33
View File
@@ -569,6 +569,7 @@ fn profile_cancel_cancels_whole_queue_including_memory_prompts() {
order: None,
question: Some(json!({"missing": false, "working": "sRGB", "embedded": "Adobe RGB"})),
policy_applied: false,
smart_object: false,
},
);
@@ -600,6 +601,7 @@ fn mixed_prompts_follow_arrival_order_fifo() {
order: None,
question: None,
policy_applied: false,
smart_object: false,
},
);
@@ -974,3 +976,34 @@ fn inline_drop_slot_survives_the_memory_prompt() {
});
});
}
/// A damaged PSD opened as a smart object, once confirmed, is admitted as one: the history says
/// "Open As Smart Object" and two same-stem files get distinct names (#12212 review).
#[test]
fn confirmed_damaged_smart_object_opens_are_admitted_as_smart_objects_with_distinct_names() {
let mut app = app();
let bytes = recovery_psd::make_psd(true);
let dir = std::env::temp_dir().join(format!("photocraft-damaged-so-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut paths = Vec::new();
for sub in ["a", "b"] {
std::fs::create_dir_all(dir.join(sub)).unwrap();
let path = dir.join(sub).join("dmg.psd");
std::fs::write(&path, &bytes).unwrap();
paths.push(path.to_string_lossy().into_owned());
}
for path in &paths {
crate::jobs_ui::start_open_as_smart_object(&mut app, path, None).unwrap();
}
assert!(app.session.documents().is_empty());
for _ in 0..2 {
let id = damage_dialog(&mut app);
assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": id}))["ok"], true);
}
let names: Vec<_> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
assert_eq!(names, ["dmg-1", "dmg-2"]);
for st in app.session.documents() {
assert_eq!(st.history.entries(), ["Open As Smart Object"]);
}
let _ = std::fs::remove_dir_all(&dir);
}
+2
View File
@@ -58,5 +58,7 @@ pub mod vector_ui;
pub mod warp_preview;
pub mod zoom_tool;
#[cfg(test)]
mod count_show_extras_tests;
#[cfg(test)]
mod crop_options_labels_tests;
+30 -5
View File
@@ -7,6 +7,10 @@
//! part of the serialisable UI state so the control channel can read and drive it). The Color
//! Sampler points themselves live in the engine (`DocState.color_samplers`) so every front end
//! shares them; this module only maps pointer input and draws the numbered markers.
//!
//! The Ruler options bar's Straighten Layer button is on when the engine's check says so, and a
//! hidden target layer answers its click with the reference's alert rather than the status line
//! (#12033).
use egui::{Align2, Color32, CornerRadius, FontId, Pos2, RichText, Stroke, pos2, vec2};
use photocraft_doc::{Document, Ruler};
@@ -15,7 +19,7 @@ use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, ViewXform};
use crate::state::Tool;
use crate::state::{DialogKind, Tool};
use crate::theme::Tokens;
/// What a pointer drag is doing (not serialised).
@@ -68,7 +72,7 @@ impl Default for AnalysisUi {
log_selected: Vec::new(),
note_selected: None,
note_author: String::new(),
note_color: [1.0, 1.0, 0.51],
note_color: photocraft_engine::notes_cmds::DEFAULT_NOTE_COLOR,
use_measurement_scale: false,
dialog: None,
drag: None,
@@ -324,7 +328,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
}
Some((g, i)) => app.ui.analysis.drag = Some(Drag::Count(g, i, [x, y])),
None if !mods.alt => {
let _ = app.run("count.add", json!({"x": x, "y": y}));
// A marker that stays invisible looks like the click did nothing, so adding one
// brings View > Show > Count back. It is view state, not a history step.
let added = app.run("count.add", json!({"x": x, "y": y})).is_ok();
app.ui.view.show.count |= added;
}
None => {}
}
@@ -556,8 +563,26 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
crate::widgets::vline(ui, 22.0);
crate::widgets::checkbox(ui, &mut app.ui.analysis.use_measurement_scale, tl!("Use Measurement Scale"));
let has = doc.measurement.ruler.is_some();
if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() {
let _ = app.run("image.analysis.straightenLayer", json!({}));
// The engine decides this button's state, so it goes grey for exactly the targets
// Straighten Layer is off for: the Background, several selected layers, a group and a
// layer locked in place (#12033).
let straighten = app.session.is_enabled("image.analysis.straightenLayer");
if ui.add_enabled_ui(straighten, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() {
let hidden = photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR;
if let Err(why) = app.run("image.analysis.straightenLayer", json!({})) {
// A hidden target layer keeps the button on and answers the click with the
// reference's alert (its message and an OK button), changing nothing (#12033).
if why == hidden {
app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(hidden)}).as_object().cloned().unwrap_or_default());
}
// Every other refusal goes to the status line, as the eraser tools do. What the
// command itself answered is reported there by `PhotocraftApp::run`, but a
// refusal raised before it runs - the automation gate a control or MCP-driven
// click passes - returns early, and a click that says nothing reads as a dead
// button (#12148).
app.ui.status = why;
app.ui.status_error = true;
}
}
if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Clear"), 0.0)).inner.clicked() {
let _ = app.run("image.analysis.rulerTool", json!({"clear": true}));
@@ -296,3 +296,147 @@ fn count_size_fields_send_whole_numbers_the_engine_applies() {
let g = &app.session.active().unwrap().doc.measurement.count_groups[0];
assert_eq!((g.marker_size, g.label_size), (8, 20));
}
// ---------------------------------------------------------------- Straighten Layer button (#12033)
/// The Ruler options bar, drawn wide enough for its buttons to show.
fn ruler_bar(mut app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> {
use egui::vec2;
app.ui.tool = Tool::Ruler;
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state(
|ui: &mut egui::Ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::options_bar(app, ui);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
h.run_steps(4);
h
}
/// A ruler line to straighten by, without one the button is off for every target.
fn rule(app: &mut PhotocraftApp) {
app.run("image.analysis.rulerTool", json!({"start": [10, 30], "end": [150, 25]})).unwrap();
}
/// The message of every open error dialog, in the order they were opened.
fn alerts(app: &PhotocraftApp) -> Vec<&str> {
app.ui.dialogs.iter().filter(|d| d.kind == DialogKind::Error).filter_map(|d| d.fields.get("message").and_then(|m| m.as_str())).collect()
}
/// Each case opens one of the targets #12033 measured, on top of the plain document `app()` makes.
fn add_layer(app: &mut PhotocraftApp, name: &str) -> u64 {
app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap()
}
fn on_a_layer(app: &mut PhotocraftApp) {
add_layer(app, "Paint");
}
fn on_the_background(_: &mut PhotocraftApp) {}
fn on_two_layers(app: &mut PhotocraftApp) {
let a = add_layer(app, "One");
let b = add_layer(app, "Two");
app.run("layer.select", json!({"layer": a})).unwrap();
app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap();
}
fn on_a_group(app: &mut PhotocraftApp) {
add_layer(app, "Inner");
app.run("layer.groupLayers", json!({})).unwrap();
}
fn locked(app: &mut PhotocraftApp, key: &str) {
let l = add_layer(app, "Colors");
app.run("layer.setProps", json!({"layer": l, "locks": {key: true}})).unwrap();
}
fn on_lock_all(app: &mut PhotocraftApp) {
locked(app, "all");
}
fn on_lock_position(app: &mut PhotocraftApp) {
locked(app, "position");
}
/// #12033: the options bar asks the engine, so the button is greyed out for exactly the targets
/// `image.analysis.straightenLayer` is off for - the Background, two or more selected layers, a
/// group, Lock All and Lock Position - and live for a layer.
#[test]
fn the_straighten_layer_button_greys_out_where_the_command_is_off() {
use egui_kittest::kittest::{NodeT, Queryable};
type Case = (&'static str, fn(&mut PhotocraftApp), bool);
let cases: [Case; 6] = [
("a pixel layer", on_a_layer, true),
("the Background", on_the_background, false),
("two selected layers", on_two_layers, false),
("a group", on_a_group, false),
("Lock All", on_lock_all, false),
("Lock Position", on_lock_position, false),
];
for (what, prepare, live) in cases {
let (mut app, _) = app();
prepare(&mut app);
rule(&mut app);
let h = ruler_bar(app);
assert_eq!(h.state().session.is_enabled("image.analysis.straightenLayer"), live, "{what}: the command's own check");
let button = h.get_by_label("Straighten Layer").accesskit_node();
assert_eq!(!button.is_disabled(), live, "{what}: the options-bar button follows the command");
// The other Ruler button is unaffected: a ruler line is there to clear in every state.
assert!(!h.get_by_label("Clear").accesskit_node().is_disabled(), "{what}: Clear stays on");
}
}
/// A refused click says so. `PhotocraftApp::run` reports what the *command* answered in the status
/// line, but a refusal raised before it runs - the automation gate a control/MCP-driven click goes
/// through - returns early, so the button has to report it itself. As the toolbar flyout does
/// (`a_failed_edit_toolbar_from_the_flyout_reports_its_error_in_the_status_bar`).
#[test]
fn a_denied_synthetic_click_reports_the_refusal_instead_of_saying_nothing() {
use egui_kittest::kittest::{NodeT, Queryable};
let (mut app, _) = app();
on_a_layer(&mut app);
rule(&mut app);
app.automation_input = true;
app.services.automation_command =
Some(Box::new(|id, _| if id == "image.analysis.straightenLayer" { Err("straightening is denied".into()) } else { Ok(()) }));
let mut h = ruler_bar(app);
assert!(!h.get_by_label("Straighten Layer").accesskit_node().is_disabled(), "the layer is a valid target");
let steps = h.state().session.active().unwrap().history.past_len();
h.get_by_label("Straighten Layer").click();
h.run_steps(2);
let st = h.state();
assert_eq!(st.ui.status, "straightening is denied", "the refusal reaches the status line");
assert!(st.ui.status_error, "as an error");
assert!(alerts(st).is_empty(), "the alert stays the hidden layer's own");
let now = st.session.active().unwrap();
assert!(now.doc.measurement.ruler.is_some(), "nothing ran: the ruler line is still up");
assert_eq!(now.history.past_len(), steps, "and nothing was recorded");
}
/// #12033: a hidden target layer is the one state that keeps its button. The click answers with the
/// reference's alert - its measured message and an OK button - instead of the status line, and the
/// document, its History and the ruler line stay as they were.
#[test]
fn a_hidden_layer_keeps_the_button_and_the_click_shows_the_alert() {
use egui_kittest::kittest::{NodeT, Queryable};
let (mut app, _) = app();
let l = app.run("layer.new.layer", json!({"name": "Gone"})).unwrap()["layer"].as_u64().unwrap();
app.run("layer.setProps", json!({"layer": l, "visible": false})).unwrap();
rule(&mut app);
let mut h = ruler_bar(app);
assert!(!h.get_by_label("Straighten Layer").accesskit_node().is_disabled(), "a hidden layer keeps the button on");
let (before, steps) = (h.state().session.active().unwrap().doc.clone(), h.state().session.active().unwrap().history.past_len());
h.get_by_label("Straighten Layer").click();
h.run_steps(2);
let st = h.state();
assert_eq!(alerts(st), [photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR], "one alert with the reference's text");
let now = st.session.active().unwrap();
assert!(std::sync::Arc::ptr_eq(&before, &now.doc), "the document was not touched");
assert_eq!(now.history.past_len(), steps, "no History step");
assert!(now.doc.measurement.ruler.is_some(), "the ruler line is still there");
}
+65 -38
View File
@@ -91,31 +91,44 @@ pub(crate) fn opened_id() -> egui::Id {
picker_id().with("opened-with")
}
/// What Escape in the picker goes back to (#11816): the Size and Hardness it opened with (or a
/// picked preset brought), and the values its own fields last typed. Escape reverts a value only
/// while it is still the typed one, so a change from elsewhere (`]`, the options bar) stays.
/// What Escape in the picker goes back to (#11816): the Size, Hardness, Angle and Roundness it
/// opened with (or a picked preset brought), and the values its own controls last set. Escape
/// reverts a value only while it is still the picker's, so a change from elsewhere (`]`, the
/// options bar) stays. Angle and Roundness come from the tip widget (#11821).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Opened {
opened: [f32; 2],
typed: [f32; 2],
opened: [f32; 4],
typed: [f32; 4],
}
fn picker_values(b: &photocraft_engine::BrushSettings) -> [f32; 4] {
[b.size, b.hardness, b.angle, b.roundness]
}
impl Opened {
pub(crate) fn new(b: &photocraft_engine::BrushSettings) -> Self {
Self { opened: [b.size, b.hardness], typed: [b.size, b.hardness] }
Self { opened: picker_values(b), typed: picker_values(b) }
}
pub(crate) fn typed(&mut self, b: &photocraft_engine::BrushSettings) {
self.typed = [b.size, b.hardness];
self.typed = picker_values(b);
}
pub(crate) fn revert(&self, now: &photocraft_engine::BrushSettings) -> photocraft_engine::BrushSettings {
let mut b = now.clone();
if b.size == self.typed[0] {
b.size = self.opened[0];
let [size, hardness, angle, roundness] = self.opened;
let [typed_size, typed_hardness, typed_angle, typed_roundness] = self.typed;
if b.size == typed_size {
b.size = size;
}
if b.hardness == self.typed[1] {
b.hardness = self.opened[1];
if b.hardness == typed_hardness {
b.hardness = hardness;
}
if b.angle == typed_angle {
b.angle = angle;
}
if b.roundness == typed_roundness {
b.roundness = roundness;
}
b
}
@@ -260,35 +273,45 @@ pub fn body(
ui.set_width(size.x);
ui.set_min_height(size.y);
let top = ui.cursor().min.y;
// The Size and Hardness controls live in one block flush with the left edge, at most
// [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space past the block empty instead of
// stretching the rows across it.
ui.scope(|ui| {
ui.set_max_width(ui.available_width().min(HEAD_MAX_W));
// Size: value field plus a logarithmic slider (small sizes get most of the travel).
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Size")).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut size = b.size;
if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() {
b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE);
}
// The tip's angle/roundness widget at the top left (#11821), then the Size and Hardness
// controls in one block, at most [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space
// past the block empty instead of stretching the rows across it.
ui.horizontal_top(|ui| {
let (angle, roundness) = (b.angle, b.roundness);
crate::brush_sections::ellipse_widget(ui, &mut b.angle, &mut b.roundness);
// A pointer position that isn't finite leaves the tip as it was.
let finite = |p: egui::Pos2| p.x.is_finite() && p.y.is_finite();
let pointer_ok = ui.input(|i| i.pointer.interact_pos().is_none_or(finite) && i.pointer.latest_pos().is_none_or(finite));
if !(pointer_ok && b.angle.is_finite() && b.roundness.is_finite()) {
(b.angle, b.roundness) = (angle, roundness);
}
ui.vertical(|ui| {
ui.set_max_width(ui.available_width().min(HEAD_MAX_W));
// Size: value field plus a logarithmic slider (small sizes get most of the travel).
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Size")).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut size = b.size;
if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() {
b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE);
}
});
});
});
let mut lv = b.size.max(1.0).ln();
let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None);
if size_slider.changed() {
b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE);
}
dismiss_popup(&size_slider);
if b.tip == TipShape::Round {
let mut hard = b.hardness * 100.0;
let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None);
if hardness.changed() {
b.hardness = (hard / 100.0).clamp(0.0, 1.0);
let mut lv = b.size.max(1.0).ln();
let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None);
if size_slider.changed() {
b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE);
}
dismiss_popup(&hardness);
}
dismiss_popup(&size_slider);
if b.tip == TipShape::Round {
let mut hard = b.hardness * 100.0;
let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None);
if hardness.changed() {
b.hardness = (hard / 100.0).clamp(0.0, 1.0);
}
dismiss_popup(&hardness);
}
});
});
ui.add_space(6.0);
widgets::hairline(ui);
@@ -437,3 +460,7 @@ fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset],
}
});
}
#[cfg(test)]
#[path = "../brush_picker_angle_widget_tests.rs"]
mod brush_picker_angle_widget_tests;
@@ -0,0 +1,70 @@
//! #11770: adding a count marker while View > Show > Count is off switches it back on.
use egui::Modifiers;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, tool_event};
use crate::state::Tool;
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.ui.tool = Tool::Count;
app
}
fn click(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) {
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m);
tool_event(app, ToolEvent::Up { x, y }, m);
}
fn points(app: &PhotocraftApp) -> usize {
app.session.active().unwrap().doc.measurement.count_groups.iter().map(|g| g.points.len()).sum()
}
fn past(app: &PhotocraftApp) -> usize {
app.session.active().unwrap().history.past_len()
}
#[test]
fn a_click_with_show_count_off_adds_the_marker_and_turns_it_on() {
let mut app = app();
app.ui.view.show.count = false;
let before = past(&app);
click(&mut app, 40.0, 30.0, Modifiers::NONE);
assert_eq!(points(&app), 1);
assert!(app.ui.view.show.count, "the new marker is visible");
assert_eq!(past(&app), before + 1, "only the marker's own history state");
}
#[test]
fn a_click_with_show_count_on_behaves_as_before() {
let mut app = app();
app.ui.view.show.count = true;
click(&mut app, 40.0, 30.0, Modifiers::NONE);
assert_eq!(points(&app), 1);
assert!(app.ui.view.show.count);
}
#[test]
fn a_click_into_a_hidden_group_leaves_the_group_hidden() {
let mut app = app();
click(&mut app, 10.0, 10.0, Modifiers::NONE);
app.run("count.setGroup", json!({"group": 0, "visible": false})).unwrap();
app.ui.view.show.count = false;
click(&mut app, 90.0, 60.0, Modifiers::NONE);
assert_eq!(points(&app), 2);
assert!(app.ui.view.show.count);
assert!(!app.session.active().unwrap().doc.measurement.count_groups[0].visible);
}
#[test]
fn removing_a_marker_does_not_turn_show_count_on() {
let mut app = app();
click(&mut app, 10.0, 10.0, Modifiers::NONE);
app.ui.view.show.count = false;
click(&mut app, 10.0, 10.0, Modifiers::ALT);
assert_eq!(points(&app), 0);
assert!(!app.ui.view.show.count);
}
@@ -0,0 +1,72 @@
//! #11978: the Perspective Crop options bar shows its Cancel and Commit buttons only during a
//! session (a quad, or corners being clicked), and they still commit and cancel.
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use super::*;
const COMMIT: &str = "Commit current crop operation";
const CANCEL: &str = "Cancel current crop operation";
/// A 200 × 150 document with the Perspective Crop tool chosen and no session.
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
app.ui.tool = Tool::PerspectiveCrop;
app
}
fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1400.0, 80.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::options_bar(app, ui);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(4);
h
}
fn shows(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool {
h.query_by_label_contains(label).is_some()
}
#[test]
fn without_a_session_neither_button_shows() {
let h = harness(app());
assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL));
assert!(h.query_by_label("Show Grid").is_some(), "the bar still ends with Show Grid");
}
#[test]
fn a_pending_quad_shows_both_and_commit_commits() {
let mut app = app();
app.ui.perspective_crop = Some([[20.0, 20.0], [180.0, 20.0], [180.0, 130.0], [20.0, 130.0]]);
let mut h = harness(app);
assert!(shows(&h, COMMIT) && shows(&h, CANCEL));
h.get_by_label_contains(COMMIT).click();
h.run_steps(3);
let size = h.state().session.active().unwrap().doc.size;
assert_eq!((size.width, size.height), (160, 110), "Commit cropped to the quad");
assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL), "gone after commit");
}
#[test]
fn clicked_corners_show_both_and_cancel_cancels() {
let mut app = app();
app.pcrop.clicks = vec![[20.0, 20.0], [180.0, 25.0]];
let mut h = harness(app);
assert!(shows(&h, COMMIT) && shows(&h, CANCEL), "with clicked corners");
h.get_by_label_contains(CANCEL).click();
h.run_steps(3);
assert!(h.state().pcrop.clicks.is_empty(), "Cancel dropped the corners");
assert_eq!(h.state().session.active().unwrap().doc.size.width, 200, "nothing cropped");
assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL), "gone after cancel");
}
@@ -305,6 +305,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
crate::widgets::vline(ui, 22.0);
crate::widgets::checkbox(ui, &mut app.ui.tool_options.perspective_crop_grid, tl!("Show Grid"));
// Commit and Cancel show only during a session: a quad, clicked corners, or a drag (#11978).
if !pending(app) {
return;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let commit_tip = crate::i18n::fmt(tl!("Commit current crop operation ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]);
if crate::icons::button(ui, "check", 26.0, false, &commit_tip).clicked() {
@@ -316,6 +320,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
}
#[cfg(test)]
#[path = "perspective_crop_bar_buttons_tests.rs"]
mod perspective_crop_bar_buttons_tests;
#[cfg(test)]
mod tests {
use super::*;
+2 -2
View File
@@ -29,9 +29,9 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option<Value> {
let o = &app.ui.tool_options;
// The options bar's Mode is the tool's own brush's.
let mode = crate::blend_keys::brush_tool_mode(app.ui.tool, app.session.tools.brush.mode);
let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": mode.label()});
// View › Pattern Preview: both tools carry a stroke on across the canvas edges (#8883).
let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": mode.label(), "wrap": app.ui.view.pattern_preview});
if app.ui.tool == Tool::CloneStamp {
p["wrap"] = json!(app.ui.view.pattern_preview);
p["opacity"] = json!(app.session.tools.brush.opacity * 100.0);
p["flow"] = json!(app.session.tools.brush.flow * 100.0);
}
+40 -1
View File
@@ -28,6 +28,44 @@ fn stripes(app: &mut PhotocraftApp, step: usize, target: &str) {
}
}
#[test]
fn only_clone_stamp_and_healing_brush_wrap_in_pattern_preview() {
// View › Pattern Preview (#8883): a Clone Stamp or Healing Brush stroke across the right edge
// asks its command to go on at the opposite one, and only then. The reference application's
// other retouching tools stop at the edge.
let tools = [
(Tool::CloneStamp, "paint.cloneStamp", true),
(Tool::Healing, "paint.healingBrush", true),
(Tool::Blur, "paint.blur", false),
(Tool::Sharpen, "paint.sharpen", false),
(Tool::Smudge, "paint.smudge", false),
(Tool::Dodge, "paint.dodge", false),
(Tool::Burn, "paint.burn", false),
(Tool::Sponge, "paint.sponge", false),
(Tool::ColorReplacement, "paint.colorReplacement", false),
(Tool::PatternStamp, "paint.patternStamp", false),
(Tool::HistoryBrush, "paint.historyBrush", false),
(Tool::SpotHealing, "paint.spotHealing", false),
(Tool::BackgroundEraser, "paint.backgroundEraser", false),
];
for (tool, cmd, wraps) in tools {
for pattern_preview in [false, true] {
let mut app = app();
stripes(&mut app, 8, "pixels");
app.ui.view.pattern_preview = pattern_preview;
app.ui.clone_source = Some([20.0, 30.0]);
app.ui.tool = tool;
let m = egui::Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 86.0, y: 30.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 112.0, y: 30.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 30.0 }, m);
assert!(!app.ui.status_error, "{tool:?}: {}", app.ui.status);
let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().unwrap_or_else(|| panic!("{tool:?}: no {cmd}"));
assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), wraps && pattern_preview, "{tool:?}: {p}");
}
}
}
#[test]
fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() {
for pattern_preview in [false, true] {
@@ -587,7 +625,8 @@ fn patch_cursor_shows_the_selection_mode_badge() {
app.ui.selection_mode = 1;
assert_eq!(badge(&app, tool, none), Some(Badge::Add), "{tool:?}: the options-bar mode outside the selection");
app.hover_doc = Some([20.0, 30.0]);
assert_eq!(badge(&app, tool, none), None, "{tool:?}: a press over the selection drags the patch");
let over = (tool == Tool::Patch).then_some(Badge::Arrow);
assert_eq!(badge(&app, tool, none), over, "{tool:?}: a press over the selection drags the patch (Patch shows an arrow, #12130)");
assert_eq!(badge(&app, tool, shift), Some(Badge::Add), "{tool:?}: ⇧ over the selection adds");
}
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more