mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-20 22:24:10 -04:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c003452f | ||
|
|
d4e71d568f | ||
|
|
045d2317f5 | ||
|
|
cf66b7c9e1 | ||
|
|
1cf662fbf8 | ||
|
|
94bce47b10 | ||
|
|
019fce25e7 | ||
|
|
28af006ec7 | ||
|
|
4b3d448715 | ||
|
|
627baa422a | ||
|
|
a195a85664 | ||
|
|
c53af5c41e | ||
|
|
2314270763 | ||
|
|
cad2ed4610 |
No files matched your search
@@ -186,10 +186,23 @@ pub fn bucket_fill_src(
|
||||
let img = crate::selection::rgba8_image(s, area);
|
||||
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
|
||||
drop(img);
|
||||
bucket_fill_region_src(s, ®ion, opacity, selection, lock_transparency, src);
|
||||
true
|
||||
}
|
||||
|
||||
/// Paints a bucket region sampled before the edit, so its boundary can come from the visible
|
||||
/// composite while only `s` receives the colour or pattern. Selection coverage still clips it.
|
||||
pub fn bucket_fill_region_src(
|
||||
s: &mut Surface,
|
||||
region: &crate::selection::Region,
|
||||
opacity: f32,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
) {
|
||||
// Composite only over the filled region's box.
|
||||
let b = region.bbox;
|
||||
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -244,4 +257,17 @@ mod tests {
|
||||
assert_eq!(s.pixel(8, 2), vec![1.0, 1.0, 1.0, 1.0]);
|
||||
assert!(!bucket_fill(&mut s, a, (50, 1), 10.0, true, false, [1.0, 0.0, 0.0, 1.0], 1.0, None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_region_uses_document_coordinates_and_multiplies_coverage() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
let region = crate::selection::Region { bbox: Rect::new(-3, 4, 0, 5), mask: vec![255, 128, 255] };
|
||||
let mut selection = Surface::new(PixelFormat::GRAY8);
|
||||
selection.fill_rect(Rect::new(-3, 4, -1, 5), &[0.5]);
|
||||
bucket_fill_region_src(&mut s, ®ion, 0.5, Some(&selection), false, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!((s.rgba(-3, 4)[3] - 0.25).abs() < 0.005);
|
||||
assert!((s.rgba(-2, 4)[3] - 0.125).abs() < 0.005);
|
||||
assert_eq!(s.rgba(-1, 4), [0.0; 4], "outside the selection");
|
||||
assert_eq!(s.rgba(-4, 4), [0.0; 4], "outside the region");
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,15 @@ fn from_values(w: u32, h: u32, layout: ChannelLayout, maxval: u32, values: Vec<u
|
||||
}
|
||||
}
|
||||
|
||||
/// `w × h × channels` samples. `Limits::none()` lets any header through, so the product is
|
||||
/// checked here rather than trusted to the limits.
|
||||
fn sample_count(w: u32, h: u32, channels: usize) -> Result<usize, CodecError> {
|
||||
(w as usize)
|
||||
.checked_mul(h as usize)
|
||||
.and_then(|p| p.checked_mul(channels))
|
||||
.ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{h}x{channels} samples do not fit in memory")))
|
||||
}
|
||||
|
||||
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecError> {
|
||||
if maxval < 256 {
|
||||
if data.len() < n {
|
||||
@@ -100,10 +109,10 @@ fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecErro
|
||||
}
|
||||
Ok(data[..n].iter().map(|&v| v as u32).collect())
|
||||
} else {
|
||||
if data.len() < n * 2 {
|
||||
let Some(len) = n.checked_mul(2).filter(|&len| data.len() >= len) else {
|
||||
return Err(err("truncated raster"));
|
||||
}
|
||||
Ok(data[..n * 2].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
|
||||
};
|
||||
Ok(data[..len].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,15 +126,16 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
b'1' | b'4' => {
|
||||
let (w, h) = (r.uint()?, r.uint()?);
|
||||
limits.check(w, h, ChannelLayout::Gray, SampleType::U8)?;
|
||||
let n = w as usize * h as usize;
|
||||
let mut px = Vec::with_capacity(n);
|
||||
let n = sample_count(w, h, 1)?;
|
||||
let mut px;
|
||||
if kind == b'4' {
|
||||
r.single_ws()?;
|
||||
let row = (w as usize).div_ceil(8);
|
||||
let data = r.rest();
|
||||
if data.len() < row * h as usize {
|
||||
if row.checked_mul(h as usize).is_none_or(|len| data.len() < len) {
|
||||
return Err(err("truncated raster"));
|
||||
}
|
||||
px = Vec::with_capacity(n);
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let bit = (data[y * row + x / 8] >> (7 - x % 8)) & 1;
|
||||
@@ -133,6 +143,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Each ASCII pixel takes at least a byte, so the file bounds the reservation (#1110).
|
||||
px = Vec::with_capacity(n.min(r.rest().len()));
|
||||
while px.len() < n {
|
||||
r.skip_ws_and_comments();
|
||||
match r.b.get(r.pos) {
|
||||
@@ -150,12 +162,14 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
let (w, h, maxval) = (r.uint()?, r.uint()?, r.uint()?);
|
||||
let layout = if matches!(kind, b'2' | b'5') { ChannelLayout::Gray } else { ChannelLayout::Rgb };
|
||||
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
|
||||
let n = w as usize * h as usize * layout.channels();
|
||||
let n = sample_count(w, h, layout.channels())?;
|
||||
let values = if matches!(kind, b'5' | b'6') {
|
||||
r.single_ws()?;
|
||||
read_binary(r.rest(), n, maxval)?
|
||||
} else {
|
||||
let mut v = Vec::with_capacity(n);
|
||||
// Each ASCII sample is a digit and a separator (the last may lack one), so the
|
||||
// file, not the header, bounds the reservation (#1110).
|
||||
let mut v = Vec::with_capacity(n.min(r.rest().len() / 2 + 1));
|
||||
for _ in 0..n {
|
||||
v.push(r.uint()?);
|
||||
}
|
||||
@@ -177,7 +191,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
let nc = layout.channels();
|
||||
let row = w as usize * nc;
|
||||
let data = r.rest();
|
||||
if data.len() < row * h as usize * 4 {
|
||||
if sample_count(w, h, nc)?.checked_mul(4).is_none_or(|len| data.len() < len) {
|
||||
return Err(err("truncated raster"));
|
||||
}
|
||||
let le = scale < 0.0;
|
||||
@@ -235,7 +249,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
};
|
||||
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
|
||||
let n = w as usize * h as usize * layout.channels();
|
||||
let n = sample_count(w, h, layout.channels())?;
|
||||
let values = read_binary(r.rest(), n, maxval)?;
|
||||
from_values(w, h, layout, maxval, values)
|
||||
}
|
||||
|
||||
@@ -100,6 +100,42 @@ fn pnm_bomb_header_rejected() {
|
||||
assert!(is_limit(decode(b"PF\n100000 100000\n-1.0\n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pnm_header_alone_reserves_nothing_without_limits() {
|
||||
// #1110: ASCII PGM/PPM reserved four bytes per declared sample before reading any. A
|
||||
// 2³¹ × 2³¹ header asked for more than isize::MAX ("capacity overflow"), and PBM reserved
|
||||
// 2⁶² bytes before finding the raster missing (an abort).
|
||||
let none = opts(Limits::none());
|
||||
let side = 1u32 << 31;
|
||||
for f in [format!("P1\n{side} {side}\n"), format!("P2\n{side} {side}\n255\n"), format!("P3\n{side} {side}\n255\n"), format!("P4\n{side} {side}\n")] {
|
||||
assert!(decode_with(f.as_bytes(), &none).is_err(), "{f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pnm_header_sizes_that_overflow_usize_are_errors_without_limits() {
|
||||
let m = u32::MAX;
|
||||
let none = opts(Limits::none());
|
||||
for f in [
|
||||
format!("P3\n{m} {m}\n255\n"),
|
||||
format!("P6\n{m} {m}\n255\n"),
|
||||
format!("P5\n{m} {m}\n65535\n"),
|
||||
format!("PF\n{m} {m}\n-1.0\n"),
|
||||
format!("P7\nWIDTH {m}\nHEIGHT {m}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n"),
|
||||
] {
|
||||
assert!(decode_with(f.as_bytes(), &none).is_err(), "{f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_pnm_with_the_tightest_layout_still_decodes() {
|
||||
// The capacity bound for #1110 must not cut off files with no slack: PBM digits need no
|
||||
// separator, and the last ASCII sample needs no trailing whitespace.
|
||||
assert_eq!(decode(b"P1\n3 1\n010").unwrap().data(), &[255, 0, 255]);
|
||||
assert_eq!(decode(b"P2\n3 1\n255\n1 2 3").unwrap().data(), &[1, 2, 3]);
|
||||
assert_eq!(decode(b"P3\n1 1\n255\n9 8 7").unwrap().data(), &[9, 8, 7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_bomb_header_rejected() {
|
||||
// SOI + SOF0 claiming 65535x65535x3 with no scan data.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Paint helpers: Paint Bucket and Gradient tool.
|
||||
|
||||
use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_src, paint_gradient};
|
||||
use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_region_src, bucket_fill_src, paint_gradient};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, blend_from_str};
|
||||
@@ -40,6 +40,16 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color(p.get("color"), fg);
|
||||
let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32);
|
||||
let (tol, contiguous, aa, opacity) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), f(p, "opacity", 100.0) / 100.0);
|
||||
let all = b(p, "sampleAllLayers", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels;
|
||||
// Other visible layers can define the region, but the fill still writes only to the target.
|
||||
let region = if all {
|
||||
let (area, img) = crate::selection_cmds::sample_rgba8(s, true)?;
|
||||
let region = photocraft_algo::selection::wand_region(&img, area, (x, y), tol, contiguous, aa);
|
||||
drop(img);
|
||||
region
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Fill source: foreground colour (default) or a pattern (the Paint Bucket "Fill" dropdown).
|
||||
let source = p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str).unwrap_or("foreground");
|
||||
let pattern = if source == "pattern" {
|
||||
@@ -55,14 +65,23 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let area = doc.bounds();
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, *active, p)?;
|
||||
let ok = if let Some((tile, scale, angle, phase)) = &pattern {
|
||||
let ok = if all && region.is_none() {
|
||||
false
|
||||
} else if let Some((tile, scale, angle, phase)) = &pattern {
|
||||
// Render the pattern over the canvas once, then sample it at each filled pixel.
|
||||
let place = photocraft_compose::pattern::Placement::new(photocraft_geom::Rect::EMPTY, false, *phase, *scale, *angle);
|
||||
let rendered = photocraft_compose::pattern::render(tile, &place, area);
|
||||
let w = area.width() as usize;
|
||||
bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |px, py| {
|
||||
rendered[(py - area.y0) as usize * w + (px - area.x0) as usize]
|
||||
})
|
||||
let src = |px: i32, py: i32| rendered[(py - area.y0) as usize * w + (px - area.x0) as usize];
|
||||
if let Some(region) = ®ion {
|
||||
bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, src);
|
||||
true
|
||||
} else {
|
||||
bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src)
|
||||
}
|
||||
} else if let Some(region) = ®ion {
|
||||
bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, |_, _| c);
|
||||
true
|
||||
} else {
|
||||
bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref(), lock)
|
||||
};
|
||||
@@ -117,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Paint Bucket",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
enabled: crate::commands::has_paintable,
|
||||
run: bucket,
|
||||
journal: true,
|
||||
@@ -151,6 +170,151 @@ mod tests {
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)
|
||||
}
|
||||
|
||||
/// A visible wall on the Background with an empty, active layer above it.
|
||||
fn bucket_layers(depth: u32) -> (Session, photocraft_doc::LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 20, "height": 10, "depth": depth, "background": "white"})).unwrap();
|
||||
let bottom = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("wall", |doc, _| {
|
||||
doc.layer_mut(bottom).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 0, 11, 10), &[0.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
(s, bottom)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_sample_all_layers_uses_visible_boundaries_at_all_depths() {
|
||||
for depth in [8, 16, 32] {
|
||||
for all in [None, Some(false), Some(true)] {
|
||||
let (mut s, bottom) = bucket_layers(depth);
|
||||
let before = s.active().unwrap().doc.layer(bottom).unwrap().clone();
|
||||
let mut p = json!({"x": 2, "y": 2, "color": "#ff0000", "antiAlias": false, "tolerance": 0});
|
||||
if let Some(all) = all {
|
||||
p["sampleAllLayers"] = json!(all);
|
||||
}
|
||||
assert_eq!(s.execute("paint.bucket", p.clone()).unwrap()["filled"], true);
|
||||
assert_eq!(px(&s, 5, 5), vec![1.0, 0.0, 0.0, 1.0], "depth {depth}, all {all:?}");
|
||||
let beyond = if all == Some(true) { vec![0.0; 4] } else { vec![1.0, 0.0, 0.0, 1.0] };
|
||||
assert_eq!(px(&s, 15, 5), beyond, "depth {depth}, all {all:?}: sample flag controls the boundary");
|
||||
assert_eq!(s.active().unwrap().doc.layer(bottom).unwrap(), &before, "only the active layer is written");
|
||||
if all == Some(true) {
|
||||
assert_eq!(px(&s, 10, 5), vec![0.0; 4], "wall itself is untouched");
|
||||
assert!(s.undo());
|
||||
p["contiguous"] = json!(false);
|
||||
s.execute("paint.bucket", p).unwrap();
|
||||
assert_eq!(px(&s, 15, 5), vec![1.0, 0.0, 0.0, 1.0], "noncontiguous finds both white regions");
|
||||
assert_eq!(px(&s, 10, 5), vec![0.0; 4]);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.execute("paint.bucket", json!({"x": -1, "y": 2, "sampleAllLayers": true})).unwrap()["filled"], false);
|
||||
assert_eq!(px(&s, 5, 5), vec![0.0; 4], "an off-canvas sample does not fill the layer");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_sample_all_layers_ignores_hidden_layers() {
|
||||
let (mut s, _) = bucket_layers(8);
|
||||
s.edit("hidden wall", |doc, active| {
|
||||
let l = doc.layer_mut(active.unwrap()).unwrap();
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 5, 10), &[0.0, 0.0, 0.0, 1.0]);
|
||||
l.visible = false;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.bucket", json!({"x": 2, "y": 2, "color": "#ff0000", "antiAlias": false, "tolerance": 0, "sampleAllLayers": true})).unwrap();
|
||||
assert_eq!(px(&s, 4, 5), vec![1.0, 0.0, 0.0, 1.0], "hidden wall does not bound the fill");
|
||||
assert_eq!(px(&s, 8, 5), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(px(&s, 15, 5), vec![0.0; 4], "visible wall still bounds the fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_sample_all_layers_keeps_selection_opacity_and_history() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, _) = bucket_layers(depth);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 2, "width": 20, "height": 6})).unwrap();
|
||||
s.edit("feather", |doc, _| {
|
||||
doc.selection.as_mut().unwrap().fill_rect(Rect::new(0, 2, 4, 8), &[0.5]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let selection = s.active().unwrap().doc.selection.clone();
|
||||
s.execute("paint.bucket", json!({"x": 2, "y": 5, "color": "#ff0000", "antiAlias": false, "tolerance": 0, "opacity": 50, "sampleAllLayers": true}))
|
||||
.unwrap();
|
||||
let (feathered, full) = (px(&s, 2, 5), px(&s, 6, 5));
|
||||
assert!((feathered[3] - 0.25).abs() < 0.005 && (full[3] - 0.5).abs() < 0.005, "depth {depth}: selection coverage multiplies opacity");
|
||||
assert_eq!(full[..3], [1.0, 0.0, 0.0]);
|
||||
assert_eq!(px(&s, 6, 1), vec![0.0; 4], "outside selection");
|
||||
assert_eq!(px(&s, 15, 5), vec![0.0; 4], "outside sampled region");
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Paint Bucket"));
|
||||
assert!(s.undo());
|
||||
assert_eq!(px(&s, 2, 5), vec![0.0; 4]);
|
||||
assert_eq!(s.active().unwrap().doc.selection, selection);
|
||||
assert!(s.redo());
|
||||
assert_eq!(px(&s, 2, 5), feathered);
|
||||
assert_eq!(px(&s, 6, 5), full);
|
||||
assert_eq!(s.active().unwrap().doc.selection, selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_sample_all_layers_pattern_uses_the_same_region() {
|
||||
let (mut s, _) = bucket_layers(8);
|
||||
s.execute(
|
||||
"paint.bucket",
|
||||
json!({"x": 2, "y": 2, "contents": "pattern", "pattern": "Diagonal Lines", "antiAlias": false, "tolerance": 0, "sampleAllLayers": true}),
|
||||
)
|
||||
.unwrap();
|
||||
let left: Vec<f32> = (0..10).map(|x| px(&s, x, 0)[0]).collect();
|
||||
assert!(left.iter().any(|&r| r < 0.3) && left.iter().any(|&r| r > 0.9), "pattern varies inside the region: {left:?}");
|
||||
assert_eq!(px(&s, 10, 5), vec![0.0; 4], "wall untouched");
|
||||
assert_eq!(px(&s, 15, 5), vec![0.0; 4], "region past the wall untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_sample_all_layers_mask_and_channel_targets_keep_their_own_boundaries() {
|
||||
for target in [json!("mask"), json!({"channel": 0}), json!("quickMask")] {
|
||||
for all in [false, true] {
|
||||
let (mut s, _) = bucket_layers(8);
|
||||
match crate::channel_cmds::target_of(&json!({"target": target})) {
|
||||
crate::channel_cmds::Target::Mask => {
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
}
|
||||
crate::channel_cmds::Target::Alpha(_) => {
|
||||
s.execute("channel.new", json!({"fill": "white"})).unwrap();
|
||||
}
|
||||
crate::channel_cmds::Target::QuickMask => {
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
}
|
||||
_ => panic!("expected a grayscale target"),
|
||||
}
|
||||
let p = json!({"x": 2, "y": 2, "color": "#000000", "antiAlias": false, "tolerance": 0, "target": target, "sampleAllLayers": all});
|
||||
s.edit("target wall", |doc, active| {
|
||||
let area = doc.bounds();
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &p).unwrap();
|
||||
surf.fill_rect(area, &[1.0]);
|
||||
surf.fill_rect(Rect::new(5, 0, 6, 10), &[0.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("paint.bucket", p.clone()).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
let surf = match crate::channel_cmds::target_of(&p) {
|
||||
crate::channel_cmds::Target::Mask => &d.layer(s.active().unwrap().active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface,
|
||||
crate::channel_cmds::Target::Alpha(i) => &d.channels[i].surface,
|
||||
crate::channel_cmds::Target::QuickMask => &d.quick_mask.as_ref().unwrap().surface,
|
||||
_ => panic!("expected a grayscale target"),
|
||||
};
|
||||
assert_eq!(surf.sample_channel(2, 5, 0), 0.0, "{target}, all {all}: filled target");
|
||||
assert_eq!(surf.sample_channel(7, 5, 0), 1.0, "{target}, all {all}: stopped at target wall");
|
||||
assert_eq!(px(&s, 2, 5), vec![0.0; 4], "layer pixels remain untouched");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_fill_contiguous() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -296,16 +296,17 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn modify(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
// The dialogs' ranges (Photoshop's limits); anything else is refused, never clamped silently.
|
||||
let max = match op {
|
||||
"border" => 200.0,
|
||||
"feather" => 1000.0,
|
||||
_ => 500.0,
|
||||
let (min, max): (f32, f32) = match op {
|
||||
"border" => (1.0, 200.0),
|
||||
"feather" => (0.1, 1000.0),
|
||||
_ => (1.0, 500.0),
|
||||
};
|
||||
let r = f(p, "radius", 1.0);
|
||||
// A negative radius is refused too (#993): it used to be clamped to 0, which left the
|
||||
// selection alone (Expand, Contract) or cleared it (Border) while reporting success.
|
||||
if !r.is_finite() || !(0.0..=max).contains(&r) {
|
||||
return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: format!("radius must be a number in 0..{max}") });
|
||||
// Below the dialog's minimum (a negative or zero radius, #993) or above its maximum is
|
||||
// refused: a clamp to 0 left the selection alone (Expand, Contract) or cleared it (Border)
|
||||
// while reporting success.
|
||||
if !(min..=max).contains(&r) {
|
||||
return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: format!("radius must be a number in {min}..{max}") });
|
||||
}
|
||||
// Photoshop's "Apply effect at canvas bounds": when on, the canvas edge is a selection edge
|
||||
// (Select All then Contract shrinks from the edges); when off, the selection is taken to
|
||||
@@ -801,6 +802,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_refuses_radii_below_the_minimum() {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
// Below each dialog's minimum (1 px, Feather 0.1 px) is refused, not clamped to 0: a
|
||||
// negative Border radius used to clear the selection and report success (#993).
|
||||
let below = [
|
||||
("border", -5.0),
|
||||
("border", 0.5),
|
||||
("smooth", -5.0),
|
||||
("smooth", 0.0),
|
||||
("expand", -5.0),
|
||||
("expand", 0.0),
|
||||
("contract", -5.0),
|
||||
("contract", 0.9),
|
||||
("feather", -5.0),
|
||||
("feather", 0.0),
|
||||
("feather", 0.05),
|
||||
];
|
||||
let before = current_mask(&s.active().unwrap().doc).1;
|
||||
for (op, r) in below {
|
||||
let err = s.execute(&format!("select.modify.{op}"), json!({"radius": r})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{op} {r}: {err}");
|
||||
assert!(current_mask(&s.active().unwrap().doc).1 == before, "{op} {r}: the selection changed");
|
||||
}
|
||||
// The minimum itself is still accepted.
|
||||
s.execute("select.modify.feather", json!({"radius": 0.1})).unwrap();
|
||||
s.execute("select.modify.expand", json!({"radius": 1})).unwrap();
|
||||
assert_eq!(coverage(&s, 9, 15), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_params_that_would_wrap_are_rejected() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -2967,7 +2967,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
Tool::PaintBucket => {
|
||||
let o = app.ui.tool_options.clone();
|
||||
let contents = if o.bucket_fill_pattern { "pattern" } else { "foreground" };
|
||||
let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)}));
|
||||
let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)}));
|
||||
return;
|
||||
}
|
||||
Tool::RedEye => {
|
||||
|
||||
@@ -74,16 +74,19 @@ fn n(f: &Map<String, Value>, k: &str, d: f64) -> f64 {
|
||||
fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>) -> Result<Document, String> {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.add_document(doc.clone(), None);
|
||||
let scale = n(f, "scale", 100.0) / 100.0;
|
||||
let mut w = (doc.size.width as f64 * scale).round().max(1.0);
|
||||
let mut scale = n(f, "scale", 100.0) / 100.0;
|
||||
if let Some(m) = max_side {
|
||||
let long = doc.size.width.max(doc.size.height) as f64 * scale;
|
||||
if long > m as f64 {
|
||||
w = (w * m as f64 / long).round().max(1.0);
|
||||
scale *= m as f64 / long;
|
||||
}
|
||||
}
|
||||
if (w - doc.size.width as f64).abs() >= 1.0 {
|
||||
s.execute("image.imageSize", json!({"width": w, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }})).map_err(|e| e.to_string())?;
|
||||
// Both dimensions are sent: a scale can leave a 1 px width unchanged while halving the
|
||||
// height, and the engine would otherwise keep the aspect ratio from the width alone.
|
||||
let (w, h) = scaled_size(doc.size.width as f64, doc.size.height as f64, scale);
|
||||
if (w - doc.size.width as f64).abs() >= 1.0 || (h - doc.size.height as f64).abs() >= 1.0 {
|
||||
s.execute("image.imageSize", json!({"width": w, "height": h, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }}))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let fmt = s_fmt(f);
|
||||
if !f.get("transparency").and_then(Value::as_bool).unwrap_or(true) || fmt == "jpg" {
|
||||
@@ -92,6 +95,12 @@ fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>
|
||||
s.active().map(|d| (*d.doc).clone()).ok_or_else(|| "export failed".into())
|
||||
}
|
||||
|
||||
/// The exported pixel size: each side scaled and rounded on its own, never below 1 px. The
|
||||
/// dialog's size label and `export_document` share it so the label names the file's real size.
|
||||
fn scaled_size(w: f64, h: f64, scale: f64) -> (f64, f64) {
|
||||
((w * scale).round().max(1.0), (h * scale).round().max(1.0))
|
||||
}
|
||||
|
||||
/// Export As already supports lossy WebP; reuse the Quick Export defaults when
|
||||
/// choosing that format so both flows expose the same quality and lossless options.
|
||||
fn set_format_defaults(f: &mut Map<String, Value>, fmt: &str, prefs: &photocraft_engine::prefs::Export) {
|
||||
@@ -188,8 +197,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
crate::widgets::value_field(ui, &mut sc, 1.0..=1000.0, "%", 70.0);
|
||||
});
|
||||
f.insert("scale".into(), json!(sc.round()));
|
||||
let (bw, bh) = (n(f, "__w", f64::from(doc.size.width)) as f32, n(f, "__h", f64::from(doc.size.height)) as f32);
|
||||
let (w, h) = ((bw * sc / 100.0).round(), (bh * sc / 100.0).round());
|
||||
let (bw, bh) = (n(f, "__w", f64::from(doc.size.width)), n(f, "__h", f64::from(doc.size.height)));
|
||||
let (w, h) = scaled_size(bw, bh, f64::from(sc) / 100.0);
|
||||
ui.label(egui::RichText::new(format!("{w} × {h} px")).color(t.text_dim).size(11.5));
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
@@ -283,6 +292,51 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn export_document_scales_height_when_rounded_width_is_unchanged() {
|
||||
use photocraft_doc::{Color, ColorMode, SampleType, Size};
|
||||
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for (width, height, scale, expected) in [
|
||||
(1, 100, 50, (1, 50)),
|
||||
(1, 200, 50, (1, 100)),
|
||||
(10, 100, 99, (10, 99)),
|
||||
(10, 100, 101, (10, 101)),
|
||||
(400, 300, 50, (200, 150)),
|
||||
(400, 300, 100, (400, 300)),
|
||||
] {
|
||||
let doc = Document::with_background("x", Size::new(width, height), ColorMode::Rgb, depth, Color::WHITE);
|
||||
let before = doc.clone();
|
||||
let mut f = Map::new();
|
||||
f.insert("format".into(), json!("png"));
|
||||
f.insert("scale".into(), json!(scale));
|
||||
let out = export_document(&doc, &f, None).unwrap();
|
||||
assert_eq!((out.size.width, out.size.height), expected, "{width} × {height} at {scale}%, {depth:?}");
|
||||
assert_eq!(out.depth, depth, "export preserves the document's depth");
|
||||
assert_eq!(doc, before, "export leaves the source document unchanged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_document_proxy_limits_both_dimensions_before_rounding() {
|
||||
use photocraft_doc::{Color, ColorMode, SampleType, Size};
|
||||
|
||||
for (width, height, scale, max_side, expected) in
|
||||
[(1, 1000, 100, 360, (1, 360)), (1, 1000, 50, 360, (1, 360)), (1, 1000, 25, 360, (1, 250)), (13, 20, 50, 8, (5, 8)), (20, 13, 50, 8, (8, 5))]
|
||||
{
|
||||
let doc = Document::with_background("x", Size::new(width, height), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let before = doc.clone();
|
||||
let mut f = Map::new();
|
||||
f.insert("format".into(), json!("png"));
|
||||
f.insert("scale".into(), json!(scale));
|
||||
let out = export_document(&doc, &f, Some(max_side)).unwrap();
|
||||
assert_eq!((out.size.width, out.size.height), expected, "{width} × {height} at {scale}%, proxy limit {max_side}");
|
||||
assert!(out.size.width.max(out.size.height) <= max_side, "proxy respects its longest-side limit");
|
||||
assert_eq!(doc, before, "preview leaves the source document unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_document_scales_and_flattens() {
|
||||
let doc = Document::with_background(
|
||||
@@ -303,6 +357,33 @@ mod tests {
|
||||
assert_eq!(settings(&f).jpeg_quality, Some(85));
|
||||
}
|
||||
|
||||
/// #1353: a scale that changes only the height (the width rounds back to the original)
|
||||
/// used to be skipped, so the exported file kept the original size while the dialog
|
||||
/// labelled it as scaled.
|
||||
#[test]
|
||||
fn export_document_scales_when_only_the_height_changes() {
|
||||
let mut f = Map::new();
|
||||
f.insert("format".into(), json!("png"));
|
||||
f.insert("scale".into(), json!(50));
|
||||
for (src_h, want_h) in [(100u32, 50u32), (200, 100)] {
|
||||
let doc = Document::with_background(
|
||||
"x",
|
||||
photocraft_doc::Size::new(1, src_h),
|
||||
photocraft_doc::ColorMode::Rgb,
|
||||
photocraft_doc::SampleType::U8,
|
||||
photocraft_doc::Color::WHITE,
|
||||
);
|
||||
let out = export_document(&doc, &f, None).unwrap();
|
||||
assert_eq!((out.size.width, out.size.height), (1, want_h), "1 × {src_h} at 50%");
|
||||
// The preview proxy takes the same path.
|
||||
let proxy = export_document(&doc, &f, Some(40)).unwrap();
|
||||
assert_eq!((proxy.size.width, proxy.size.height), (1, 40), "proxy of 1 × {src_h}");
|
||||
}
|
||||
// The label and the export agree, and neither side drops below 1 px.
|
||||
assert_eq!(scaled_size(1.0, 100.0, 0.5), (1.0, 50.0));
|
||||
assert_eq!(scaled_size(10.0, 300.0, 0.01), (1.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_export_formats_uses_independent_quality_preferences() {
|
||||
let prefs = photocraft_engine::prefs::Export { jpeg_quality: 43, webp_quality: 72, webp_lossless: false, ..Default::default() };
|
||||
|
||||
@@ -121,6 +121,46 @@ fn clipboard_preset(f: &Map<String, Value>) -> Option<Preset> {
|
||||
Some((CLIPBOARD, dim(0)?, dim(1)?, 72.0))
|
||||
}
|
||||
|
||||
/// Set Width or Height (`key`) from a value typed in `unit`: whole pixels for `file.new`, plus the
|
||||
/// typed value so the field keeps showing it (see [`shown_size`]).
|
||||
pub fn set_size(f: &mut Map<String, Value>, key: &str, v: f32, unit: &str, ppi: f32) {
|
||||
f.insert(key.into(), px_value(from_unit(v, unit, ppi)));
|
||||
f.insert(typed_key(key), json!({"value": v, "unit": unit}));
|
||||
f.remove("__preset");
|
||||
}
|
||||
|
||||
/// What Width or Height (`key`, `d` pixels when unset) shows in `unit`: the value the user typed
|
||||
/// while it still gives the field's pixel count, else the pixels converted. Converting the rounded
|
||||
/// pixels back on every keystroke turned a typed `5` mm into 14 px and the text into `4.9` (#1147).
|
||||
/// A size over the 300000 px limit shows the limit, not the typed value the document won't have.
|
||||
pub fn shown_size(f: &Map<String, Value>, key: &str, d: f32, unit: &str, ppi: f32) -> f32 {
|
||||
let px = get_f(f, key, d);
|
||||
let typed = f.get(&typed_key(key)).and_then(Value::as_object).filter(|t| unit != "px" && t.get("unit").and_then(Value::as_str) == Some(unit));
|
||||
match typed.and_then(|t| t.get("value")).and_then(Value::as_f64) {
|
||||
Some(v) if from_unit(v as f32, unit, ppi).max(1.0) == px => v as f32,
|
||||
_ => to_unit(px, unit, ppi),
|
||||
}
|
||||
}
|
||||
|
||||
fn typed_key(key: &str) -> String {
|
||||
format!("__{key}Typed")
|
||||
}
|
||||
|
||||
/// Swap Width and Height (the Orientation buttons), with the values typed for them, so a typed
|
||||
/// 841 x 1189 mm turns into 1189 x 841 mm, not 1188.9 x 841.
|
||||
pub fn swap_size(f: &mut Map<String, Value>) {
|
||||
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
|
||||
f.insert("width".into(), px_value(h));
|
||||
f.insert("height".into(), px_value(w));
|
||||
let (typed_w, typed_h) = (f.remove(&typed_key("width")), f.remove(&typed_key("height")));
|
||||
if let Some(t) = typed_h {
|
||||
f.insert(typed_key("width"), t);
|
||||
}
|
||||
if let Some(t) = typed_w {
|
||||
f.insert(typed_key("height"), t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a preset to the dialog fields.
|
||||
pub fn apply_preset(f: &mut Map<String, Value>, p: &Preset) {
|
||||
f.insert("width".into(), json!(p.1));
|
||||
@@ -260,10 +300,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
let mut unit = get_s(f, "__unit", "px");
|
||||
small_label(ui, tl!("Width"));
|
||||
ui.horizontal(|ui| {
|
||||
let mut w = to_unit(get_f(f, "width", 1920.0), &unit, ppi);
|
||||
let mut w = shown_size(f, "width", 1920.0, &unit, ppi);
|
||||
if widgets::value_field(ui, &mut w, 0.01..=300_000.0, "", 110.0).changed() {
|
||||
f.insert("width".into(), px_value(from_unit(w, &unit, ppi)));
|
||||
f.remove("__preset");
|
||||
set_size(f, "width", w, &unit, ppi);
|
||||
}
|
||||
let opts: Vec<(String, &str)> = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect();
|
||||
if widgets::dropdown(ui, "nd-unit", &mut unit, &opts, 120.0) {
|
||||
@@ -272,18 +311,16 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
});
|
||||
small_label(ui, tl!("Height"));
|
||||
ui.horizontal(|ui| {
|
||||
let mut h = to_unit(get_f(f, "height", 1080.0), &unit, ppi);
|
||||
let mut h = shown_size(f, "height", 1080.0, &unit, ppi);
|
||||
if widgets::value_field(ui, &mut h, 0.01..=300_000.0, "", 110.0).changed() {
|
||||
f.insert("height".into(), px_value(from_unit(h, &unit, ppi)));
|
||||
f.remove("__preset");
|
||||
set_size(f, "height", h, &unit, ppi);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
small_label(ui, tl!("Orientation"));
|
||||
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
|
||||
for (icon, portrait) in [("rectangle-vertical", true), ("rectangle-horizontal", false)] {
|
||||
if icons::button(ui, icon, 24.0, (h > w) == portrait, if portrait { "Portrait" } else { "Landscape" }).clicked() && (h > w) != portrait {
|
||||
f.insert("width".into(), px_value(h));
|
||||
f.insert("height".into(), px_value(w));
|
||||
swap_size(f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -353,6 +390,38 @@ mod tests {
|
||||
assert_eq!(to_unit(640.0, "px", 72.0), 640.0);
|
||||
}
|
||||
|
||||
/// #1147: 5 mm at 72 ppi is 14 px, which is 4.94 mm. The field must keep showing the typed 5.
|
||||
#[test]
|
||||
fn typed_size_in_a_physical_unit_shows_as_typed() {
|
||||
let mut f = crate::state::UiState::new_document_fields();
|
||||
set_size(&mut f, "height", 5.0, "mm", 72.0);
|
||||
assert_eq!(command_params(&f)["height"], json!(14));
|
||||
assert_eq!(shown_size(&f, "height", 1080.0, "mm", 72.0), 5.0);
|
||||
// Another unit, or pixels changed elsewhere (a preset, the orientation swap): converted.
|
||||
assert_eq!(shown_size(&f, "height", 1080.0, "cm", 72.0), to_unit(14.0, "cm", 72.0));
|
||||
f.insert("height".into(), json!(1080));
|
||||
assert_eq!(shown_size(&f, "height", 1080.0, "mm", 72.0), to_unit(1080.0, "mm", 72.0));
|
||||
// Pixels show whole pixels.
|
||||
set_size(&mut f, "width", 512.4, "px", 72.0);
|
||||
assert_eq!(shown_size(&f, "width", 1920.0, "px", 72.0), 512.0);
|
||||
// Over the 300000 px limit the field shows the limit.
|
||||
set_size(&mut f, "width", 300_000.0, "mm", 72.0);
|
||||
assert_eq!(command_params(&f)["width"], json!(300_000));
|
||||
assert_eq!(shown_size(&f, "width", 1920.0, "mm", 72.0), to_unit(300_000.0, "mm", 72.0));
|
||||
}
|
||||
|
||||
/// #1147: the Orientation buttons swap the typed values too: 841 x 1189 mm becomes 1189 x 841.
|
||||
#[test]
|
||||
fn orientation_swap_keeps_the_typed_values() {
|
||||
let mut f = crate::state::UiState::new_document_fields();
|
||||
set_size(&mut f, "width", 841.0, "mm", 72.0);
|
||||
set_size(&mut f, "height", 1189.0, "mm", 72.0);
|
||||
swap_size(&mut f);
|
||||
let p = command_params(&f);
|
||||
assert_eq!((p["width"].clone(), p["height"].clone()), (json!(3370), json!(2384)));
|
||||
assert_eq!((shown_size(&f, "width", 1920.0, "mm", 72.0), shown_size(&f, "height", 1080.0, "mm", 72.0)), (1189.0, 841.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_keeps_physical_size_in_physical_units_and_pixels_in_px() {
|
||||
let a4 = CATEGORIES.iter().find(|c| c.0 == "Print").unwrap().1.iter().find(|p| p.0 == "A4").unwrap();
|
||||
@@ -558,6 +627,20 @@ mod tests {
|
||||
assert_eq!(created(&h), (600, 450, 300.0));
|
||||
}
|
||||
|
||||
/// #1147: typed digit by digit, millimetres used to be rewritten after each keystroke
|
||||
/// (`8` became `8.1`), so 841 never arrived.
|
||||
#[test]
|
||||
fn typed_size_in_millimetres_keeps_every_digit() {
|
||||
let mut h = harness();
|
||||
let mut f = fields(&h);
|
||||
f.insert("__unit".into(), serde_json::json!("mm"));
|
||||
set_fields(&mut h, f);
|
||||
type_into(&mut h, 0, "841");
|
||||
type_into(&mut h, 1, "1189");
|
||||
enter(&mut h);
|
||||
assert_eq!(created(&h), (2384, 3370, 72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_units_keeps_the_typed_pixel_size() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -51,12 +51,38 @@ fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool
|
||||
ui.data(|d| d.get_temp::<Tool>(key)).filter(|t| slot.contains(t)).unwrap_or(slot[0])
|
||||
}
|
||||
|
||||
/// [`TOOL_SECTIONS`] without the tools hidden in Edit › Toolbar (`hidden` holds tool names: the
|
||||
/// dialog writes `Tool` debug names, `edit.toolbar` takes any name `Tool::from_name` reads). A
|
||||
/// slot keeps only its visible tools, so its flyout lists just those; a slot with none left is
|
||||
/// dropped, and so is a section with no slots. Each slot carries its index in `TOOL_SECTIONS`,
|
||||
/// so its remembered tool and flyout stay put when other slots are hidden.
|
||||
fn visible_sections(hidden: &[String]) -> Vec<Vec<(usize, Vec<Tool>)>> {
|
||||
let hidden: Vec<Tool> = hidden.iter().map(String::as_str).filter_map(Tool::from_name).collect();
|
||||
let mut index = 0usize;
|
||||
let mut sections = Vec::new();
|
||||
for section in TOOL_SECTIONS {
|
||||
let mut slots = Vec::new();
|
||||
for slot in section.iter() {
|
||||
let tools: Vec<Tool> = slot.iter().copied().filter(|tool| !hidden.contains(tool)).collect();
|
||||
if !tools.is_empty() {
|
||||
slots.push((index, tools));
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if !slots.is_empty() {
|
||||
sections.push(slots);
|
||||
}
|
||||
}
|
||||
sections
|
||||
}
|
||||
|
||||
pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) };
|
||||
let sections = visible_sections(&app.session.prefs().toolbar.hidden);
|
||||
// Two columns when the header chevron asks for them, or when one column doesn't fit.
|
||||
let slots: usize = TOOL_SECTIONS.iter().map(|g| g.len()).sum();
|
||||
let double = app.ui.panels.toolbar_double || toolbar_needs_double(slots, TOOL_SECTIONS.len(), bx, t.pro, ui.available_rect_before_wrap().height());
|
||||
let slots: usize = sections.iter().map(Vec::len).sum();
|
||||
let double = app.ui.panels.toolbar_double || toolbar_needs_double(slots, sections.len(), bx, t.pro, ui.available_rect_before_wrap().height());
|
||||
let w = if double { w1 + bx + 2.0 } else { w1 };
|
||||
egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show(
|
||||
ui,
|
||||
@@ -94,10 +120,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if ui.input(|i| i.pointer.any_pressed()) {
|
||||
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
|
||||
}
|
||||
let mut slot_index = 0usize;
|
||||
// Pro has no group dividers, so its two columns fill every row across groups.
|
||||
let flat: Vec<&[Tool]> = TOOL_SECTIONS.iter().flat_map(|s| s.iter().copied()).collect();
|
||||
let sections: Vec<&[&[Tool]]> = if t.pro { vec![&flat[..]] } else { TOOL_SECTIONS.to_vec() };
|
||||
let sections: Vec<Vec<(usize, Vec<Tool>)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections };
|
||||
for (si, section) in sections.iter().enumerate() {
|
||||
// Photoshop 2026 draws one uninterrupted column (no group dividers).
|
||||
if si > 0 && !t.pro {
|
||||
@@ -106,12 +130,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator));
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
let rows: Vec<&[&[Tool]]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() };
|
||||
let rows: Vec<&[(usize, Vec<Tool>)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() };
|
||||
for row in rows {
|
||||
ui.horizontal(|ui| {
|
||||
for slot in row.iter() {
|
||||
let key = egui::Id::new(("tool-slot", slot_index));
|
||||
slot_index += 1;
|
||||
for (slot_index, slot) in row.iter() {
|
||||
let key = egui::Id::new(("tool-slot", *slot_index));
|
||||
let tool = slot_tool(ui, app.ui.tool, slot, key);
|
||||
let sel = slot.contains(&app.ui.tool);
|
||||
let tip = if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) };
|
||||
@@ -3237,6 +3260,57 @@ mod type_flyout_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod toolbar_hidden_tests {
|
||||
use super::*;
|
||||
|
||||
/// Clickable tool-sized buttons the toolbar drew in its last pass.
|
||||
fn tool_buttons(app: &mut PhotocraftApp) -> usize {
|
||||
let ctx = egui::Context::default();
|
||||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
for time in [0.0, 0.1] {
|
||||
let mut out = ctx.run_ui(
|
||||
egui::RawInput { time: Some(time), screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() },
|
||||
|ui| toolbar(app, ui),
|
||||
);
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let size = Vec2::splat(if Tokens::get(&ctx).pro { 30.0 } else { 36.0 });
|
||||
ctx.viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size && w.sense.senses_click()).count())
|
||||
}
|
||||
|
||||
fn hide(app: &mut PhotocraftApp, tools: &[Tool]) {
|
||||
let names: Vec<String> = tools.iter().map(|tool| format!("{tool:?}")).collect();
|
||||
app.session.edit_prefs(|p| p.toolbar.hidden = names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_tools_leave_the_toolbar() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let all = tool_buttons(&mut app);
|
||||
let slots: usize = TOOL_SECTIONS.iter().map(|section| section.len()).sum();
|
||||
hide(&mut app, &Tool::ALL);
|
||||
assert_eq!(tool_buttons(&mut app), all - slots, "every tool slot is gone when every tool is hidden");
|
||||
hide(&mut app, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush]);
|
||||
assert_eq!(tool_buttons(&mut app), all - 1, "a slot whose tools are all hidden is gone");
|
||||
hide(&mut app, &[Tool::Sponge]);
|
||||
assert_eq!(tool_buttons(&mut app), all, "a slot with a visible tool stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_keeps_only_its_visible_tools_and_its_index() {
|
||||
let sections = visible_sections(&["Sponge".to_string(), "Move".to_string()]);
|
||||
assert_eq!(sections.len(), TOOL_SECTIONS.len() - 1, "the Move section has no tools left");
|
||||
let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Sponge)).unwrap();
|
||||
let slot = sections.iter().flatten().find(|(i, _)| *i == index).map(|(_, tools)| tools.clone());
|
||||
assert_eq!(slot, Some(vec![Tool::Dodge, Tool::Burn]));
|
||||
assert!(sections.iter().flatten().all(|(_, tools)| !tools.contains(&Tool::Move) && !tools.contains(&Tool::Sponge)));
|
||||
assert_eq!(visible_sections(&[]).iter().map(Vec::len).sum::<usize>(), TOOL_SECTIONS.iter().map(|section| section.len()).sum::<usize>());
|
||||
let by_command = visible_sections(&["sponge".to_string(), "move".to_string()]);
|
||||
assert_eq!(by_command, sections, "edit.toolbar names are read like `ui.set {{tool}}` names");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod properties_card_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! #1353: Export As must scale the height even when the rounded width stays unchanged.
|
||||
//! Drive the real Scale field and Export button, then decode the PNG the writer received.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_ui_egui::{PhotocraftApp, Services, menus};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
type Writes = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
fn harness() -> Option<(Harness, Writes)> {
|
||||
let built = std::panic::catch_unwind(|| {
|
||||
let writes = Writes::default();
|
||||
let captured = writes.clone();
|
||||
let h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(
|
||||
move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(|suggested| Some(suggested.to_string()))),
|
||||
export: Some(Box::new(|doc, path, settings| {
|
||||
let opts = photocraft_io::ExportOptions {
|
||||
xmp: if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None },
|
||||
..Default::default()
|
||||
};
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
write: Some(Box::new(move |path, bytes| {
|
||||
captured.borrow_mut().push((path.to_string(), bytes.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
app.session.prefs.edit(|p| p.interface.language = "en".into());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app
|
||||
},
|
||||
);
|
||||
(h, writes)
|
||||
});
|
||||
match built {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn evidence(h: &mut Harness, depth: u8, stage: &str) {
|
||||
if let Some(dir) = std::env::var_os("PHOTOCRAFT_EXPORT_EVIDENCE_DIR") {
|
||||
std::fs::create_dir_all(&dir).expect("evidence directory");
|
||||
h.render().expect("render UI").save(std::path::Path::new(&dir).join(format!("{stage}-{depth}bit.png"))).expect("save UI evidence");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_as_scales_a_narrow_image_without_editing_the_source() {
|
||||
let Some((mut h, writes)) = harness() else { return };
|
||||
h.run_steps(4);
|
||||
for depth in [8, 16, 32] {
|
||||
writes.borrow_mut().clear();
|
||||
{
|
||||
let app = h.state_mut();
|
||||
while app.session.active().is_some() {
|
||||
app.run("file.close", json!({"discard": true})).expect("close");
|
||||
}
|
||||
app.run("file.new", json!({"width": 1, "height": 100, "depth": depth, "background": "white"})).expect("new narrow document");
|
||||
app.sync_views();
|
||||
}
|
||||
let st = h.state().session.active().expect("document");
|
||||
let source = st.doc.clone();
|
||||
let history = st.history.entries();
|
||||
let revision = st.revision;
|
||||
|
||||
let ctx = h.ctx.clone();
|
||||
menus::invoke(h.state_mut(), &ctx, "file.export.exportAs", json!({})).expect("open Export As");
|
||||
h.run_steps(6);
|
||||
let label = h.get_by_label("Scale").rect();
|
||||
let field = h
|
||||
.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.find(|n| {
|
||||
let r = n.rect();
|
||||
r.left() >= label.right() && r.left() < label.right() + 100.0 && (r.center().y - label.center().y).abs() < 3.0
|
||||
})
|
||||
.expect("Scale numeric field");
|
||||
field.click();
|
||||
h.run_steps(2);
|
||||
// Clicking a numeric field selects its current value; type through egui's text input.
|
||||
h.event(egui::Event::Text("50".into()));
|
||||
h.run_steps(3);
|
||||
// Click away to commit the field without Enter confirming the dialog.
|
||||
h.get_by_label("Image Size").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("1 × 50 px").is_some(), "{depth}-bit: dialog shows the requested output size");
|
||||
evidence(&mut h, depth, "dialog");
|
||||
|
||||
h.get_by_label("Export").click();
|
||||
h.run_steps(6);
|
||||
evidence(&mut h, depth, "result");
|
||||
let written = writes.borrow();
|
||||
assert_eq!(written.len(), 1, "{depth}-bit: Export writes one file");
|
||||
let (path, bytes) = &written[0];
|
||||
assert!(path.ends_with(".png"), "PNG export path: {path}");
|
||||
if let Some(dir) = std::env::var_os("PHOTOCRAFT_EXPORT_EVIDENCE_DIR") {
|
||||
std::fs::write(std::path::Path::new(&dir).join(format!("exported-{depth}bit.png")), bytes).expect("save exported PNG evidence");
|
||||
}
|
||||
let png = photocraft_codecs::decode_as(photocraft_codecs::Format::Png, bytes).expect("decode the written PNG");
|
||||
assert_eq!(png.dimensions(), (1, 50), "{depth}-bit: written dimensions match the dialog");
|
||||
|
||||
let st = h.state().session.active().expect("source remains open");
|
||||
assert!(Arc::ptr_eq(&st.doc, &source), "{depth}-bit: source document is unchanged");
|
||||
assert_eq!((st.doc.size.width, st.doc.size.height), (1, 100));
|
||||
assert_eq!(st.history.entries(), history, "export must not add source history");
|
||||
assert_eq!(st.revision, revision, "export must not change source revision");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! #1118: Paint Bucket's All Layers checkbox must sample the visible image while painting
|
||||
//! only the active layer. Drive the checkbox and canvas through real egui pointer events.
|
||||
|
||||
use egui::{Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_doc::LayerId;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::canvas::ViewXform;
|
||||
use photocraft_ui_egui::control::{ControlRequest, Outcome, handle};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
|
||||
fn harness() -> Option<Harness> {
|
||||
let built = std::panic::catch_unwind(|| {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 720.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.session.prefs.edit(|p| p.interface.language = "en".into());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app
|
||||
})
|
||||
});
|
||||
match built {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(h: &mut Harness) -> LayerId {
|
||||
let background = {
|
||||
let app = h.state_mut();
|
||||
while app.session.active().is_some() {
|
||||
app.run("file.close", json!({"discard": true})).expect("close");
|
||||
}
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).expect("new");
|
||||
let background = app.session.active().expect("document").active_layer.expect("background");
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).expect("select left half");
|
||||
app.run("edit.fill", json!({"contents": "black"})).expect("black left half");
|
||||
app.run("select.deselect", json!({})).expect("deselect");
|
||||
app.run("layer.new.layer", json!({"name": "Bucket fill"})).expect("empty active layer");
|
||||
app.run("tools.setColors", json!({"foreground": [1.0, 0.0, 0.0, 1.0]})).expect("red foreground");
|
||||
app.sync_views();
|
||||
app.ui.extras.rulers = false;
|
||||
let o = &mut app.ui.tool_options;
|
||||
o.tolerance = 0.0;
|
||||
o.anti_alias = false;
|
||||
o.contiguous = true;
|
||||
o.sample_all_layers = false;
|
||||
o.bucket_fill_pattern = false;
|
||||
o.fill_opacity = 100.0;
|
||||
background
|
||||
};
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = ControlRequest::new("ui.set", json!({"tool": "paintBucket", "zoom": 1.0, "center": [200, 150]}));
|
||||
assert!(matches!(handle(h.state_mut(), &ctx, &req), Outcome::Done(_)));
|
||||
h.run_steps(6);
|
||||
background
|
||||
}
|
||||
|
||||
fn screen(h: &Harness, x: f32, y: f32) -> Pos2 {
|
||||
let app = h.state();
|
||||
let v = &app.ui.views[0];
|
||||
ViewXform { rect: photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect), zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal }
|
||||
.to_screen(x, y)
|
||||
}
|
||||
|
||||
fn pixels(h: &Harness, layer: LayerId) -> Vec<[f32; 4]> {
|
||||
let st = h.state().session.active().expect("document");
|
||||
let surface = st.doc.layer(layer).expect("layer").surface().expect("pixel layer");
|
||||
let mut pixels = vec![[0.0; 4]; 400 * 300];
|
||||
surface.read_rgba_into(Rect::new(0, 0, 400, 300), &mut pixels);
|
||||
pixels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_layers_checkbox_limits_bucket_fill_to_the_visible_color() {
|
||||
let Some(mut h) = harness() else { return };
|
||||
h.run_steps(4);
|
||||
for all_layers in [false, true] {
|
||||
let background = setup(&mut h);
|
||||
let before = pixels(&h, background);
|
||||
if all_layers {
|
||||
h.get_by_label("All Layers").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
assert_eq!(h.state().ui.tool_options.sample_all_layers, all_layers, "checkbox changed the option");
|
||||
|
||||
let pos = screen(&h, 300.0, 150.0);
|
||||
h.event(egui::Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(6);
|
||||
|
||||
// Optional real UI evidence, saved before assertions so a failing baseline has a frame.
|
||||
if let Some(dir) = std::env::var_os("PHOTOCRAFT_BUCKET_EVIDENCE_DIR") {
|
||||
std::fs::create_dir_all(&dir).expect("evidence directory");
|
||||
let name = if all_layers { "all-layers-on.png" } else { "all-layers-off.png" };
|
||||
h.render().expect("render UI").save(std::path::Path::new(&dir).join(name)).expect("save UI evidence");
|
||||
}
|
||||
|
||||
assert_eq!(pixels(&h, background), before, "bucket must leave the sampled background unchanged");
|
||||
let active = h.state().session.active().expect("document").active_layer.expect("active layer");
|
||||
for (i, pixel) in pixels(&h, active).into_iter().enumerate() {
|
||||
let x = i % 400;
|
||||
let expected = if all_layers && x < 200 { [0.0; 4] } else { [1.0, 0.0, 0.0, 1.0] };
|
||||
assert_eq!(pixel, expected, "All Layers={all_layers}, active-layer pixel ({x}, {})", i / 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 3 | 11 | 13 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 8 | 14 | 8 | |
|
||||
| [Tools](#tools) | 9 | 14 | 8 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 56 of 140 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
|
||||
@@ -151,6 +151,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog |
|
||||
| TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog |
|
||||
| TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo |
|
||||
| TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error |
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
|
||||
Reference in new issue
Block a user