mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 21:52:54 +00:00
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:
117 files changed
+7874
-331
No files matched your search
+2
-1
@@ -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
@@ -5618,6 +5618,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"imagesize",
|
||||
"kurbo 0.13.1",
|
||||
"lopdf",
|
||||
"miniz_oxide 0.9.1",
|
||||
"photocraft-affinity",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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, ¶graph), "{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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -357,4 +357,6 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod fx_mode_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod brush_clear_background_tests;
|
||||
pub mod command_hints;
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>)>) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user