mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
Merge remote-tracking branch 'origin/main' into integration-batch-7
# Conflicts: # crates/ui-egui/src/panels.rs
This commit is contained in:
@@ -512,12 +512,11 @@ fn direction_cost(tp: [f32; 2], tq: [f32; 2], dx: i32, dy: i32, len: f32) -> f32
|
|||||||
if tp == [0.0; 2] || tq == [0.0; 2] {
|
if tp == [0.0; 2] || tq == [0.0; 2] {
|
||||||
return NEUTRAL_DIRECTION;
|
return NEUTRAL_DIRECTION;
|
||||||
}
|
}
|
||||||
let mut l = [dx as f32 / len, dy as f32 / len];
|
// An edge tangent is an axis, not a directed vector: reversing the strongest channel's
|
||||||
if tp[0] * l[0] + tp[1] * l[1] < 0.0 {
|
// gradient must not make an otherwise straight edge expensive to follow.
|
||||||
l = [-l[0], -l[1]];
|
let l = [dx as f32 / len, dy as f32 / len];
|
||||||
}
|
let dp = (tp[0] * l[0] + tp[1] * l[1]).abs().clamp(0.0, 1.0);
|
||||||
let dp = (tp[0] * l[0] + tp[1] * l[1]).clamp(-1.0, 1.0);
|
let dq = (l[0] * tq[0] + l[1] * tq[1]).abs().clamp(0.0, 1.0);
|
||||||
let dq = (l[0] * tq[0] + l[1] * tq[1]).clamp(-1.0, 1.0);
|
|
||||||
2.0 / (3.0 * PI) * (dp.acos() + dq.acos())
|
2.0 / (3.0 * PI) * (dp.acos() + dq.acos())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,39 @@ fn snaps_to_the_most_prominent_edge() {
|
|||||||
assert!((p[0] - 18.0).abs() < 0.01, "{p:?}");
|
assert!((p[0] - 18.0).abs() < 0.01, "{p:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gradient_polarity_does_not_make_a_straight_edge_look_like_a_turn() {
|
||||||
|
// A gradient can reverse across adjacent pixels when another colour channel becomes the
|
||||||
|
// strongest one. The edge tangent is an axis, so its sign must not change the path cost.
|
||||||
|
let forward = direction_cost([1.0, 0.0], [1.0, 0.0], 1, 0, 1.0);
|
||||||
|
let reversed = direction_cost([1.0, 0.0], [-1.0, 0.0], 1, 0, 1.0);
|
||||||
|
assert!(forward < 1e-6, "aligned edge: {forward}");
|
||||||
|
assert!(reversed < 1e-6, "same edge with reversed gradient polarity: {reversed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn traces_a_straight_edge_through_a_gradient_polarity_change() {
|
||||||
|
let read = Cell::new(0);
|
||||||
|
// The contrast stays high, but the edge changes from dark-to-light to light-to-dark halfway
|
||||||
|
// down. The horizontal seam intersects it; the guide stays on the vertical contour.
|
||||||
|
let mut f = image(
|
||||||
|
|x, y| {
|
||||||
|
let left = x < 32;
|
||||||
|
let top = y < 32;
|
||||||
|
let white = left != top;
|
||||||
|
grey(if white { 255 } else { 0 })
|
||||||
|
},
|
||||||
|
&read,
|
||||||
|
);
|
||||||
|
let mut t = Tracer::new(Rect::new(0, 0, 64, 64));
|
||||||
|
let path = t.trace(&mut f, [32.0, 5.0], [32.0, 59.0], &[[32.0, 32.0]], Settings::default());
|
||||||
|
assert_eq!(path.first(), Some(&[32.0, 5.0]));
|
||||||
|
assert_eq!(path.last(), Some(&[32.0, 59.0]));
|
||||||
|
for p in &path {
|
||||||
|
assert!((p[0] - 32.0).abs() < 1.0, "left the high-contrast edge: {p:?}; {path:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trace_follows_a_curved_edge() {
|
fn trace_follows_a_curved_edge() {
|
||||||
let read = Cell::new(0);
|
let read = Cell::new(0);
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
use std::io::{BufRead, Write};
|
use std::io::{BufRead, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex, PoisonError};
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -325,32 +327,67 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Headless TCP daemons with no clients should eventually release their listening port (#3682).
|
||||||
|
const TCP_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||||
|
|
||||||
/// Serve authenticated JSON lines on a loopback TCP address with one shared session.
|
/// Serve authenticated JSON lines on a loopback TCP address with one shared session.
|
||||||
/// Refuses non-loopback addresses and caps active connections and request bytes.
|
/// Refuses non-loopback addresses, caps connections and request bytes, and closes the listener
|
||||||
|
/// after an idle period with no connections. Active clients are never evicted by this timer.
|
||||||
pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> {
|
pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> {
|
||||||
|
serve_tcp_until_idle(addr, h, token, ready, TCP_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_tcp_until_idle(
|
||||||
|
addr: &str,
|
||||||
|
h: Arc<Mutex<Headless>>,
|
||||||
|
token: String,
|
||||||
|
ready: impl FnOnce(std::net::SocketAddr),
|
||||||
|
idle: Duration,
|
||||||
|
) -> Result<(), AutomationError> {
|
||||||
let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?;
|
let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?;
|
||||||
let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?;
|
let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||||
if !local.ip().is_loopback() {
|
if !local.ip().is_loopback() {
|
||||||
return Err(bad(format!("{addr} is not a loopback address")));
|
return Err(bad(format!("{addr} is not a loopback address")));
|
||||||
}
|
}
|
||||||
|
listener.set_nonblocking(true).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||||
ready(local);
|
ready(local);
|
||||||
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
|
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
|
||||||
let token = Arc::new(token);
|
let token = Arc::new(token);
|
||||||
for stream in listener.incoming() {
|
let active = Arc::new(AtomicUsize::new(0));
|
||||||
let Ok(mut stream) = stream else { continue };
|
let mut last_active = Instant::now();
|
||||||
let Some(permit) = limiter.try_acquire() else {
|
loop {
|
||||||
let _ = configure_stream(&stream);
|
match listener.accept() {
|
||||||
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
|
Ok((mut stream, _)) => {
|
||||||
continue;
|
last_active = Instant::now();
|
||||||
};
|
let Some(permit) = limiter.try_acquire() else {
|
||||||
let h = h.clone();
|
let _ = configure_stream(&stream);
|
||||||
let token = Arc::clone(&token);
|
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
|
||||||
std::thread::spawn(move || {
|
continue;
|
||||||
let _permit = permit;
|
};
|
||||||
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
|
// The listener is nonblocking; individual requests still use blocking socket I/O.
|
||||||
});
|
let _ = stream.set_nonblocking(false);
|
||||||
|
let h = h.clone();
|
||||||
|
let token = Arc::clone(&token);
|
||||||
|
let active = Arc::clone(&active);
|
||||||
|
active.fetch_add(1, Ordering::AcqRel);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _permit = permit;
|
||||||
|
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
|
||||||
|
active.fetch_sub(1, Ordering::AcqRel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
if active.load(Ordering::Acquire) != 0 {
|
||||||
|
last_active = Instant::now();
|
||||||
|
} else if last_active.elapsed() >= idle {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::ConnectionAborted) => {}
|
||||||
|
Err(e) => return Err(AutomationError::Io(e.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1146,6 +1183,38 @@ mod tests {
|
|||||||
assert!(h.handle("methods", Value::Null).is_ok());
|
assert!(h.handle("methods", Value::Null).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_tcp_daemon_releases_its_port_but_keeps_active_clients() {
|
||||||
|
use std::io::BufReader;
|
||||||
|
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
let h = Arc::new(Mutex::new(Headless::new()));
|
||||||
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let result = serve_tcp_until_idle("127.0.0.1:0", h, TOKEN.to_owned(), move |addr| ready_tx.send(addr).unwrap(), Duration::from_millis(200));
|
||||||
|
done_tx.send(result).unwrap();
|
||||||
|
});
|
||||||
|
let addr = ready_rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||||
|
let mut client = std::net::TcpStream::connect(addr).unwrap();
|
||||||
|
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||||
|
let mut reader = BufReader::new(client.try_clone().unwrap());
|
||||||
|
writeln!(client, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).unwrap();
|
||||||
|
let auth: Value = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(auth["result"]["authenticated"], true);
|
||||||
|
// The port stays owned well past the idle limit while a client is connected.
|
||||||
|
assert!(
|
||||||
|
matches!(done_rx.recv_timeout(Duration::from_millis(450)), Err(std::sync::mpsc::RecvTimeoutError::Timeout)),
|
||||||
|
"daemon exited with a live client"
|
||||||
|
);
|
||||||
|
drop(reader);
|
||||||
|
drop(client);
|
||||||
|
assert!(done_rx.recv_timeout(Duration::from_secs(3)).unwrap().is_ok(), "idle daemon did not exit cleanly");
|
||||||
|
server.join().unwrap();
|
||||||
|
assert!(TcpListener::bind(addr).is_ok(), "idle listener still owns its port");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tcp_serves_loopback() {
|
fn tcp_serves_loopback() {
|
||||||
use std::io::{BufReader, Write as _};
|
use std::io::{BufReader, Write as _};
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ fn selected_pixel_layers(s: &Session) -> Vec<LayerId> {
|
|||||||
st.selected_layers().into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Raster(_)))).collect()
|
st.selected_layers().into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Raster(_)))).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a lock keeps the layer `id` from being moved or warped: lock all, position or pixels.
|
||||||
|
/// The Background's own position lock doesn't count: like Free Transform, Auto-Align turns the
|
||||||
|
/// Background into a normal layer when it has to move it (Photoshop).
|
||||||
|
fn pinned(doc: &Document, id: LayerId) -> bool {
|
||||||
|
let l = doc.effective_locks(id);
|
||||||
|
l.all || l.pixels || (l.position && !doc.is_background(id))
|
||||||
|
}
|
||||||
|
|
||||||
fn two_layers(s: &Session) -> std::result::Result<(), String> {
|
fn two_layers(s: &Session) -> std::result::Result<(), String> {
|
||||||
s.active().ok_or("no document open")?;
|
s.active().ok_or("no document open")?;
|
||||||
if selected_pixel_layers(s).len() < 2 { Err("select two or more pixel layers in the Layers panel".into()) } else { Ok(()) }
|
if selected_pixel_layers(s).len() < 2 { Err("select two or more pixel layers in the Layers panel".into()) } else { Ok(()) }
|
||||||
@@ -63,7 +71,8 @@ fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let reference = match p.get("reference").and_then(Value::as_u64) {
|
let reference = match p.get("reference").and_then(Value::as_u64) {
|
||||||
Some(r) if ids.contains(&LayerId(r)) => LayerId(r),
|
Some(r) if ids.contains(&LayerId(r)) => LayerId(r),
|
||||||
Some(r) => return Err(bad(cmd, format!("reference {r} is not a selected pixel layer"))),
|
Some(r) => return Err(bad(cmd, format!("reference {r} is not a selected pixel layer"))),
|
||||||
None => ids[0],
|
// As in Photoshop, a locked layer stays put: it becomes the reference (#7080).
|
||||||
|
None => ids.iter().copied().find(|id| pinned(&doc, *id)).unwrap_or(ids[0]),
|
||||||
};
|
};
|
||||||
let ref_idx = ids.iter().position(|i| *i == reference).unwrap_or(0);
|
let ref_idx = ids.iter().position(|i| *i == reference).unwrap_or(0);
|
||||||
let area = doc.bounds();
|
let area = doc.bounds();
|
||||||
@@ -100,6 +109,9 @@ fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if moves.is_empty() {
|
if moves.is_empty() {
|
||||||
return Err(EngineError::Other("Auto-Align couldn't find enough matching detail between the layers".into()));
|
return Err(EngineError::Other("Auto-Align couldn't find enough matching detail between the layers".into()));
|
||||||
}
|
}
|
||||||
|
if let Some(l) = moves.iter().find(|&&i| pinned(&doc, ids[i])).and_then(|&i| doc.layer(ids[i])) {
|
||||||
|
return Err(EngineError::Other(format!("Auto-Align can't move the locked layer \"{}\"", l.name)));
|
||||||
|
}
|
||||||
let warped: Vec<(LayerId, Surface, Option<Surface>)> = moves
|
let warped: Vec<(LayerId, Surface, Option<Surface>)> = moves
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&i| {
|
.map(|&i| {
|
||||||
@@ -122,6 +134,8 @@ fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
.collect::<Result<_>>()?;
|
.collect::<Result<_>>()?;
|
||||||
s.edit("Auto-Align Layers", |doc, _| {
|
s.edit("Auto-Align Layers", |doc, _| {
|
||||||
for (id, px, mask) in &warped {
|
for (id, px, mask) in &warped {
|
||||||
|
// Photoshop turns the Background into a normal layer before moving it.
|
||||||
|
crate::extra_cmds::background_to_layer(doc, *id);
|
||||||
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||||
if let Some(surf) = l.surface_mut() {
|
if let Some(surf) = l.surface_mut() {
|
||||||
*surf = px.clone();
|
*surf = px.clone();
|
||||||
@@ -331,7 +345,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
spec!(
|
spec!(
|
||||||
"edit.autoAlignLayers",
|
"edit.autoAlignLayers",
|
||||||
"Auto-Align Layers…",
|
"Auto-Align Layers…",
|
||||||
r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##,
|
r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=the locked selected layer, else the bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##,
|
||||||
auto_align
|
auto_align
|
||||||
),
|
),
|
||||||
spec!(
|
spec!(
|
||||||
@@ -350,8 +364,15 @@ mod tests {
|
|||||||
/// Deterministic texture written into a layer at offset (dx, dy).
|
/// Deterministic texture written into a layer at offset (dx, dy).
|
||||||
fn texture_layer(s: &mut Session, name: &str, dx: i32, dy: i32) -> LayerId {
|
fn texture_layer(s: &mut Session, name: &str, dx: i32, dy: i32) -> LayerId {
|
||||||
s.execute("layer.new.layer", json!({"name": name})).unwrap();
|
s.execute("layer.new.layer", json!({"name": name})).unwrap();
|
||||||
s.edit("tex", |doc, active| {
|
let id = s.active().unwrap().active_layer.unwrap();
|
||||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
paint_texture(s, id, dx, dy);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the deterministic texture into layer `id` at offset (dx, dy).
|
||||||
|
fn paint_texture(s: &mut Session, id: LayerId, dx: i32, dy: i32) {
|
||||||
|
s.edit("tex", |doc, _| {
|
||||||
|
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
|
||||||
let mut seed = 99u64;
|
let mut seed = 99u64;
|
||||||
let mut rnd = || {
|
let mut rnd = || {
|
||||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||||
@@ -367,7 +388,6 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
s.active().unwrap().active_layer.unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select(s: &mut Session, ids: &[LayerId]) {
|
fn select(s: &mut Session, ids: &[LayerId]) {
|
||||||
@@ -411,6 +431,86 @@ mod tests {
|
|||||||
assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err());
|
assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// As in Photoshop, a locked layer stays where it is: it becomes the reference and the
|
||||||
|
/// others move onto it (#7080).
|
||||||
|
#[test]
|
||||||
|
fn auto_align_keeps_a_locked_layer_as_the_reference() {
|
||||||
|
for lock in ["all", "position", "pixels"] {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", json!({"width": 300, "height": 240, "background": "transparent"})).unwrap();
|
||||||
|
let a = texture_layer(&mut s, "A", 20, 20);
|
||||||
|
let b = texture_layer(&mut s, "B", 31, 13);
|
||||||
|
s.edit("lock", |doc, _| {
|
||||||
|
doc.layer_mut(b).unwrap().locks.set(lock, true);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
select(&mut s, &[a, b]);
|
||||||
|
let r = s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).unwrap();
|
||||||
|
assert_eq!(r["reference"], b.0, "{lock}: {r}");
|
||||||
|
let doc = &s.active().unwrap().doc;
|
||||||
|
let bb = doc.layer(b).unwrap().surface().unwrap().content_bounds();
|
||||||
|
assert_eq!((bb.x0, bb.y0), (31, 13), "{lock}: the locked layer stays put");
|
||||||
|
let ab = doc.layer(a).unwrap().surface().unwrap().content_bounds();
|
||||||
|
assert!((ab.x0 - 31).abs() <= 1 && (ab.y0 - 13).abs() <= 1, "{lock}: {ab:?} {r}");
|
||||||
|
// An explicit reference that would move the locked layer is refused.
|
||||||
|
s.undo();
|
||||||
|
assert!(s.execute("edit.autoAlignLayers", json!({"projection": "reposition", "reference": a.0})).is_err());
|
||||||
|
// Two locked layers: nothing may move.
|
||||||
|
s.edit("lock", |doc, _| {
|
||||||
|
doc.layer_mut(a).unwrap().locks.set(lock, true);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).is_err());
|
||||||
|
let bb = s.active().unwrap().doc.layer(a).unwrap().surface().unwrap().content_bounds();
|
||||||
|
assert_eq!((bb.x0, bb.y0), (20, 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Background doesn't block Auto-Align: when a projection reprojects every layer
|
||||||
|
/// (Cylindrical, Spherical, or Auto choosing one), or another layer is the reference, it
|
||||||
|
/// becomes a normal layer ("Layer 0") and moves, as in Photoshop and as Free Transform does.
|
||||||
|
#[test]
|
||||||
|
fn auto_align_moves_the_background_as_a_normal_layer() {
|
||||||
|
let setup = || {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", json!({"width": 300, "height": 240, "background": "white"})).unwrap();
|
||||||
|
let bg = s.active().unwrap().doc.background().unwrap().id;
|
||||||
|
paint_texture(&mut s, bg, 20, 20);
|
||||||
|
let b = texture_layer(&mut s, "B", 31, 13);
|
||||||
|
select(&mut s, &[bg, b]);
|
||||||
|
(s, bg, b)
|
||||||
|
};
|
||||||
|
for projection in ["cylindrical", "spherical", "auto"] {
|
||||||
|
let (mut s, bg, b) = setup();
|
||||||
|
let r = s.execute("edit.autoAlignLayers", json!({"projection": projection})).unwrap_or_else(|e| panic!("{projection}: {e}"));
|
||||||
|
let doc = &s.active().unwrap().doc;
|
||||||
|
let moved = r["aligned"].as_array().unwrap().iter().any(|a| a["layer"] == bg.0);
|
||||||
|
assert_eq!(moved, projection != "auto" || r["layout"] != "perspective", "{projection}: {r}");
|
||||||
|
assert_eq!(doc.is_background(bg), !moved, "{projection}: a moved Background is a normal layer: {r}");
|
||||||
|
if moved {
|
||||||
|
assert_eq!(doc.layer(bg).unwrap().name, "Layer 0", "{projection}");
|
||||||
|
}
|
||||||
|
assert!(doc.layer(b).is_some(), "{projection}");
|
||||||
|
s.undo();
|
||||||
|
assert!(s.active().unwrap().doc.is_background(bg), "{projection}: undo restores the Background");
|
||||||
|
}
|
||||||
|
// Planar, with the other layer as the reference: the Background moves onto it.
|
||||||
|
let (mut s, bg, b) = setup();
|
||||||
|
s.execute("edit.autoAlignLayers", json!({"projection": "reposition", "reference": b.0})).unwrap();
|
||||||
|
let doc = &s.active().unwrap().doc;
|
||||||
|
assert!(!doc.is_background(bg));
|
||||||
|
// Default reference: the Background (bottom) stays put and stays the Background.
|
||||||
|
let (mut s, bg, b) = setup();
|
||||||
|
let r = s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).unwrap();
|
||||||
|
assert_eq!(r["reference"], bg.0, "{r}");
|
||||||
|
let doc = &s.active().unwrap().doc;
|
||||||
|
assert!(doc.is_background(bg));
|
||||||
|
let bb = doc.layer(b).unwrap().surface().unwrap().content_bounds();
|
||||||
|
assert!((bb.x0 - 20).abs() <= 1 && (bb.y0 - 20).abs() <= 1, "{bb:?} {r}");
|
||||||
|
}
|
||||||
|
|
||||||
/// The moving layer's mask is warped as a mask: one grey channel whose outside keeps its
|
/// The moving layer's mask is warped as a mask: one grey channel whose outside keeps its
|
||||||
/// default (reveal-all stays revealed, hide-all stays hidden), for planar and mesh warps.
|
/// default (reveal-all stays revealed, hide-all stays hidden), for planar and mesh warps.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -782,46 +782,32 @@ fn selective_from(d: &Descriptor) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn selective_to(p: &Value) -> Option<Descriptor> {
|
fn selective_to(p: &Value) -> Option<Descriptor> {
|
||||||
// Each range, as the command reads it (`adjust_cmds::selective_from_params`): the array form
|
// Match command execution: scalar components override only the selected range, `relative`
|
||||||
// `"reds": [c, m, y, k]` first, then the scalar form, which names one range in `"colors"` and
|
// takes precedence over `method`, and invalid values must not become a different edit.
|
||||||
// gives the channels it changes. Writing only the arrays dropped the scalar form's values, and
|
let photocraft_doc::Adjustment::SelectiveColor { relative, adjustments } = crate::adjust_cmds::selective_from_params(p, None).ok()? else {
|
||||||
// the step exported as an adjustment that changes nothing (#7672).
|
return None;
|
||||||
let mut rows: Vec<Option<[f64; 4]>> = vec![None; SELECTIVE_RANGES.len()];
|
};
|
||||||
for (row, (_, name)) in rows.iter_mut().zip(SELECTIVE_RANGES) {
|
|
||||||
if let Some(a) = p.get(*name).and_then(Value::as_array) {
|
|
||||||
let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
|
||||||
*row = Some([v(0), v(1), v(2), v(3)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(named) = jstr(p, "colors") {
|
|
||||||
let i = SELECTIVE_RANGES.iter().position(|(_, name)| *name == named)?;
|
|
||||||
let row = rows.get_mut(i)?;
|
|
||||||
let mut v = row.unwrap_or([0.0; 4]);
|
|
||||||
for (slot, key) in v.iter_mut().zip(["cyan", "magenta", "yellow", "black"]) {
|
|
||||||
// A channel that is given but is not a number is an error in the command
|
|
||||||
// (`adjust_cmds::selective_from_params`), so a step carrying one cannot be written
|
|
||||||
// either: ignoring it would export a different adjustment than the one recorded.
|
|
||||||
// `null` counts as absent there, and so here.
|
|
||||||
match p.get(key) {
|
|
||||||
None | Some(Value::Null) => {}
|
|
||||||
Some(_) => *slot = jnum(p, key)?,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*row = Some(v);
|
|
||||||
}
|
|
||||||
let mut list = Vec::new();
|
let mut list = Vec::new();
|
||||||
for (row, (ps, _)) in rows.iter().zip(SELECTIVE_RANGES) {
|
for (name, row) in crate::adjust_cmds::RANGES.into_iter().zip(adjustments) {
|
||||||
if let Some(v) = row {
|
if p.get(name).is_some_and(|v| !v.is_null()) || jstr(p, "colors") == Some(name) {
|
||||||
|
let ps = lookup_back(SELECTIVE_RANGES, name)?;
|
||||||
|
let [cyan, magenta, yellow, black] = row.map(f64::from);
|
||||||
list.push(D::Descriptor(
|
list.push(D::Descriptor(
|
||||||
desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v[0])).with("Mgnt", pct(v[1])).with("Yllw", pct(v[2])).with("Blck", pct(v[3])),
|
desc("ClrC")
|
||||||
|
.with("Clrs", enm("Clrs", ps))
|
||||||
|
.with("Cyn ", pct(cyan))
|
||||||
|
.with("Mgnt", pct(magenta))
|
||||||
|
.with("Yllw", pct(yellow))
|
||||||
|
.with("Blck", pct(black)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut d = desc("SlcC").with("presetKind", enm("presetKindType", "presetKindCustom"));
|
Some(
|
||||||
if let Some(m) = jstr(p, "method") {
|
desc("SlcC")
|
||||||
d = d.with("Mthd", enm("CrcM", if m == "absolute" { "Absl" } else { "Rltv" }));
|
.with("presetKind", enm("presetKindType", "presetKindCustom"))
|
||||||
}
|
.with("Mthd", enm("CrcM", if relative { "Rltv" } else { "Absl" }))
|
||||||
Some(d.with("ClrC", D::List(list)))
|
.with("ClrC", D::List(list)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`.
|
/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`.
|
||||||
|
|||||||
@@ -450,15 +450,35 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
|
|||||||
|
|
||||||
/// How far a paste moves the clipboard pixels: not at all in place (or when they lie on the
|
/// How far a paste moves the clipboard pixels: not at all in place (or when they lie on the
|
||||||
/// canvas and no `center` is given), else onto `center` (the view centre from the UI) or the
|
/// canvas and no `center` is given), else onto `center` (the view centre from the UI) or the
|
||||||
/// canvas centre.
|
/// canvas centre, then kept on the canvas as Photoshop keeps it: pixels that fit stay wholly
|
||||||
|
/// inside, larger ones start at the canvas's top-left corner (#6759). `"clampToCanvas": false`
|
||||||
|
/// skips that, for Paste Into / Outside, which centre on the selection.
|
||||||
pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
|
pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
|
||||||
if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
|
if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
}
|
}
|
||||||
let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
|
let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
|
||||||
let (cx, cy) = c.unwrap_or(((f64::from(canvas.x0) + f64::from(canvas.x1)) / 2.0, (f64::from(canvas.y0) + f64::from(canvas.y1)) / 2.0));
|
let (cx, cy) = c.unwrap_or((mid(canvas.x0, canvas.x1), mid(canvas.y0, canvas.y1)));
|
||||||
let b = clip.bounds;
|
let b = clip.bounds;
|
||||||
((cx - (f64::from(b.x0) + f64::from(b.x1)) / 2.0).round() as i32, (cy - (f64::from(b.y0) + f64::from(b.y1)) / 2.0).round() as i32)
|
let (dx, dy) = ((cx - mid(b.x0, b.x1)).round(), (cy - mid(b.y0, b.y1)).round());
|
||||||
|
if p.get("clampToCanvas").and_then(Value::as_bool) == Some(false) || b.is_empty() {
|
||||||
|
return (dx as i32, dy as i32);
|
||||||
|
}
|
||||||
|
(clamp_shift(dx, b.x0, b.x1, canvas.x0, canvas.x1), clamp_shift(dy, b.y0, b.y1, canvas.y0, canvas.y1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The midpoint of `a..b`, each edge converted before adding so extreme coordinates can't overflow.
|
||||||
|
fn mid(a: i32, b: i32) -> f64 {
|
||||||
|
(f64::from(a) + f64::from(b)) / 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shift `d` of the span `b0..b1` along one axis, changed as little as needed to keep the span
|
||||||
|
/// inside `c0..c1` when it fits; a span longer than the canvas starts at `c0`.
|
||||||
|
fn clamp_shift(d: f64, b0: i32, b1: i32, c0: i32, c1: i32) -> i32 {
|
||||||
|
let (b0, b1, c0, c1) = (i64::from(b0), i64::from(b1), i64::from(c0), i64::from(c1));
|
||||||
|
// `as` saturates a NaN or out-of-range shift, then the clamp brings it into range.
|
||||||
|
let start = if b1 - b0 <= c1 - c0 { b0.saturating_add(d as i64).clamp(c0, c1 - (b1 - b0)) } else { c0 };
|
||||||
|
i32::try_from(start - b0).unwrap_or(if start > b0 { i32::MAX } else { i32::MIN })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface {
|
pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface {
|
||||||
@@ -994,7 +1014,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
&["Edit"],
|
&["Edit"],
|
||||||
Some("Cmd+V"),
|
Some("Cmd+V"),
|
||||||
concat!(
|
concat!(
|
||||||
r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"#,
|
r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"clampToCanvas":bool=true (keep the paste on the canvas, or at its top-left when larger),"#,
|
||||||
paste_target!(),
|
paste_target!(),
|
||||||
"} (with no document open: a new document from the clipboard)"
|
"} (with no document open: a new document from the clipboard)"
|
||||||
),
|
),
|
||||||
@@ -1288,6 +1308,78 @@ mod tests {
|
|||||||
assert_eq!(s.active().unwrap().doc.layers.len(), 4);
|
assert_eq!(s.active().unwrap().doc.layers.len(), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `w`×`h` document with an opaque `cw`×`ch` image from another app on the clipboard.
|
||||||
|
fn external_clip_session(w: i32, h: i32, cw: i32, ch: i32) -> Session {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", json!({"width": w, "height": h})).unwrap();
|
||||||
|
let mut surface = Surface::new(PixelFormat::RGBA8);
|
||||||
|
surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]);
|
||||||
|
s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None });
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #6759: wherever the view is centred, a paste that fits the canvas stays wholly on it.
|
||||||
|
#[test]
|
||||||
|
fn paste_that_fits_stays_on_the_canvas() {
|
||||||
|
for (w, h, center, want) in [
|
||||||
|
(60, 100, [40.0, 45.0], Rect::new(0, 0, 60, 100)),
|
||||||
|
(60, 100, [-500.0, 900.0], Rect::new(0, 0, 60, 100)),
|
||||||
|
(100, 100, [10.0, 80.0], Rect::new(0, 0, 60, 100)),
|
||||||
|
(100, 100, [95.0, 50.0], Rect::new(40, 0, 100, 100)),
|
||||||
|
(100, 120, [50.0, 60.0], Rect::new(20, 10, 80, 110)),
|
||||||
|
] {
|
||||||
|
let mut s = external_clip_session(w, h, 60, 100);
|
||||||
|
s.execute("edit.paste", json!({ "center": center })).unwrap();
|
||||||
|
assert_eq!(active_bounds(&s), want, "{w}x{h} canvas, view centre {center:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #6759: a paste larger than the canvas starts at its top-left corner, wherever the view is.
|
||||||
|
#[test]
|
||||||
|
fn paste_larger_than_the_canvas_starts_at_its_top_left() {
|
||||||
|
for center in [[0.0, 0.0], [25.0, 20.0], [1000.0, 1000.0], [-1000.0, 30.0]] {
|
||||||
|
let mut s = external_clip_session(50, 40, 60, 100);
|
||||||
|
s.execute("edit.paste", json!({ "center": center })).unwrap();
|
||||||
|
assert_eq!(active_bounds(&s), Rect::new(0, 0, 60, 100), "view centre {center:?}");
|
||||||
|
}
|
||||||
|
// Wider but not taller: at the left edge, and wholly inside vertically.
|
||||||
|
for (center, want) in
|
||||||
|
[([500.0, 500.0], Rect::new(0, 100, 60, 200)), ([-500.0, -500.0], Rect::new(0, 0, 60, 100)), ([25.0, 120.0], Rect::new(0, 70, 60, 170))]
|
||||||
|
{
|
||||||
|
let mut s = external_clip_session(50, 200, 60, 100);
|
||||||
|
s.execute("edit.paste", json!({ "center": center })).unwrap();
|
||||||
|
assert_eq!(active_bounds(&s), want, "view centre {center:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paste Into centres on the selection even where that runs off the canvas.
|
||||||
|
#[test]
|
||||||
|
fn paste_into_is_not_clamped_to_the_canvas() {
|
||||||
|
let mut s = session();
|
||||||
|
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
|
||||||
|
s.execute("edit.copy", json!({})).unwrap();
|
||||||
|
s.execute("select.rect", json!({"x": 90, "y": 90, "width": 10, "height": 10})).unwrap();
|
||||||
|
s.execute("edit.pasteSpecial.pasteInto", json!({})).unwrap();
|
||||||
|
assert_eq!(active_bounds(&s), Rect::new(75, 85, 115, 105));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clamp never overflows or panics: NaN, infinite and `i32`-limit shifts saturate (Rule 9).
|
||||||
|
#[test]
|
||||||
|
fn paste_offset_clamp_survives_extreme_coordinates() {
|
||||||
|
assert_eq!(clamp_shift(f64::NAN, 0, 10, 0, 100), 0);
|
||||||
|
assert_eq!(clamp_shift(f64::INFINITY, 0, 10, 0, 100), 90);
|
||||||
|
assert_eq!(clamp_shift(f64::NEG_INFINITY, i32::MIN, i32::MAX, 0, 1), i32::MAX);
|
||||||
|
assert_eq!(clamp_shift(1e30, i32::MIN, i32::MIN + 1, i32::MAX - 1, i32::MAX), i32::MAX);
|
||||||
|
let clip = Clip {
|
||||||
|
surface: Surface::new(PixelFormat::RGBA8),
|
||||||
|
bounds: Rect::new(i32::MAX - 2, i32::MAX - 2, i32::MAX, i32::MAX),
|
||||||
|
layers: None,
|
||||||
|
resolution: None,
|
||||||
|
};
|
||||||
|
let (dx, dy) = paste_offset(&clip, Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false);
|
||||||
|
assert_eq!((dx, dy), (i32::MIN, i32::MIN), "an unrepresentable shift saturates instead of wrapping");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn paste_with_malformed_center_does_not_panic() {
|
fn paste_with_malformed_center_does_not_panic() {
|
||||||
// A short/empty `center` array must not panic (Rule 9) — it falls back to the canvas centre.
|
// A short/empty `center` array must not panic (Rule 9) — it falls back to the canvas centre.
|
||||||
|
|||||||
@@ -336,6 +336,9 @@ fn can_reselect(s: &Session) -> std::result::Result<(), String> {
|
|||||||
reselect_target(s).map(|_| ()).ok_or_else(|| "there is no selection to restore".into())
|
reselect_target(s).map(|_| ()).ok_or_else(|| "there is no selection to restore".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Edit › Paste Special › Paste Into (or Outside): the clipboard centred on the selection, not
|
||||||
|
/// clamped to the canvas, as a new layer masked to the selection (or its inverse), or into the
|
||||||
|
/// targeted mask or channel. One history step.
|
||||||
fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
||||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||||
let selection = d.doc.selection.clone().ok_or(EngineError::Other("Paste Into needs a selection".into()))?;
|
let selection = d.doc.selection.clone().ok_or(EngineError::Other("Paste Into needs a selection".into()))?;
|
||||||
@@ -353,7 +356,8 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
|||||||
selection
|
selection
|
||||||
};
|
};
|
||||||
let label = if outside { "Paste Outside" } else { "Paste Into" };
|
let label = if outside { "Paste Outside" } else { "Paste Into" };
|
||||||
let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0]});
|
// Centred on the selection, even where that runs off the canvas (not clamped like a paste).
|
||||||
|
let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0], "clampToCanvas": false});
|
||||||
if let Some(c) = p.get("center") {
|
if let Some(c) = p.get("center") {
|
||||||
params["center"] = c.clone();
|
params["center"] = c.clone();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and
|
//! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and
|
||||||
//! bit depth) conversions, Duplicate.
|
//! bit depth) conversions, Duplicate.
|
||||||
|
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface};
|
use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface};
|
||||||
use photocraft_color::{ColorMode, SampleType};
|
use photocraft_color::{ColorMode, SampleType};
|
||||||
use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size};
|
use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size};
|
||||||
@@ -297,6 +299,7 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl
|
|||||||
}
|
}
|
||||||
// Content that reaches the canvas edge keeps it: a Background stays opaque to the border.
|
// Content that reaches the canvas edge keeps it: a Background stays opaque to the border.
|
||||||
let canvas = Rect::from_xywh(0, 0, ow, oh);
|
let canvas = Rect::from_xywh(0, 0, ow, oh);
|
||||||
|
let taken = crate::smart_cmds::take_caches(&mut doc.layers);
|
||||||
for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| {
|
for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| {
|
||||||
*surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas);
|
*surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas);
|
||||||
});
|
});
|
||||||
@@ -321,6 +324,7 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl
|
|||||||
crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] });
|
crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] });
|
||||||
doc.size = Size::new(nw, nh);
|
doc.size = Size::new(nw, nh);
|
||||||
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All);
|
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All);
|
||||||
|
let Ok(()) = crate::smart_cmds::restore_unrendered(doc, taken, |c| Ok::<_, Infallible>(resize_surface_in_canvas(c, sx, sy, filter, canvas)));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||||
@@ -371,6 +375,26 @@ pub(crate) fn translate_doc(doc: &mut Document, cmd: &str, dx: i32, dy: i32) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete Cropped Pixels on a layer tree: crops pixel-layer data only to `r`. Type and smart
|
||||||
|
/// objects move by shifting their rendered pixels, so cutting those would clip them at the old
|
||||||
|
/// edge. A video layer's stored frames are its pixels too: the displayed content is only the frame
|
||||||
|
/// at the playhead, so the others would bring the cropped pixels back when scrubbing (#4956).
|
||||||
|
fn delete_cropped_pixels(layers: &mut [Layer], r: Rect) {
|
||||||
|
for_each_layer(layers, &mut |l| {
|
||||||
|
if let Some(fc) = &mut l.fill_cache {
|
||||||
|
fc.surface = crop_surface(&fc.surface, r);
|
||||||
|
}
|
||||||
|
if let Some(v) = &mut l.video {
|
||||||
|
for frame in v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()) {
|
||||||
|
*frame = crop_surface(frame, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let LayerContent::Raster(s) = &mut l.content {
|
||||||
|
*s = crop_surface(s, r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Crops the document to `r` (in current document coordinates).
|
/// Crops the document to `r` (in current document coordinates).
|
||||||
/// A frame that extends past the canvas fills the Background's new area with `extension` (the
|
/// A frame that extends past the canvas fills the Background's new area with `extension` (the
|
||||||
/// background colour, as Photoshop's Crop tool does); other layers get transparent pixels there.
|
/// background colour, as Photoshop's Crop tool does); other layers get transparent pixels there.
|
||||||
@@ -382,7 +406,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
|
|||||||
let old_canvas = doc.bounds();
|
let old_canvas = doc.bounds();
|
||||||
let extends = r.x0 < old_canvas.x0 || r.y0 < old_canvas.y0 || r.x1 > old_canvas.x1 || r.y1 > old_canvas.y1;
|
let extends = r.x0 < old_canvas.x0 || r.y0 < old_canvas.y0 || r.x1 > old_canvas.x1 || r.y1 > old_canvas.y1;
|
||||||
if delete_pixels {
|
if delete_pixels {
|
||||||
for_each_surface(&mut doc.layers, false, &mut |surf, _| *surf = crop_surface(surf, r));
|
delete_cropped_pixels(&mut doc.layers, r);
|
||||||
}
|
}
|
||||||
translate_doc(doc, cmd, dx, dy)?;
|
translate_doc(doc, cmd, dx, dy)?;
|
||||||
doc.size = Size::new(r.width(), r.height());
|
doc.size = Size::new(r.width(), r.height());
|
||||||
@@ -753,7 +777,14 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
|
|||||||
if delete {
|
if delete {
|
||||||
let keep = turned_crop_source(size, new, angle, target);
|
let keep = turned_crop_source(size, new, angle, target);
|
||||||
s.edit("Crop", |doc, _| {
|
s.edit("Crop", |doc, _| {
|
||||||
for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = crop_surface(surf, keep));
|
// Type and smart objects stay whole, as in the crop itself: the rotation re-renders
|
||||||
|
// them, but a smart object whose source is unavailable keeps its cache.
|
||||||
|
delete_cropped_pixels(&mut doc.layers, keep);
|
||||||
|
for_each_layer(&mut doc.layers, &mut |l| {
|
||||||
|
if let Some(m) = &mut l.mask {
|
||||||
|
m.surface = crop_surface(&m.surface, keep);
|
||||||
|
}
|
||||||
|
});
|
||||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||||
ch.surface = crop_surface(&ch.surface, keep);
|
ch.surface = crop_surface(&ch.surface, keep);
|
||||||
}
|
}
|
||||||
@@ -1093,6 +1124,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete Cropped Pixels on a shrinking crop deletes the cropped area from every stored video
|
||||||
|
/// frame (and the original footage), not just the displayed one (#4956).
|
||||||
|
#[test]
|
||||||
|
fn delete_cropped_pixels_crops_stored_video_frames() {
|
||||||
|
let mut s = session();
|
||||||
|
s.edit("video", |doc, active| {
|
||||||
|
let l = doc.layer_mut(active.unwrap()).unwrap();
|
||||||
|
let red = l.surface().unwrap().clone();
|
||||||
|
let mut blue = red.clone();
|
||||||
|
blue.fill_rect(Rect::new(0, 0, 40, 20), &[0.0, 0.0, 1.0, 1.0]);
|
||||||
|
let mut v = photocraft_doc::VideoData::new(vec![red.clone(), blue.clone()], 24.0);
|
||||||
|
v.original_frames = Some(vec![red, blue]);
|
||||||
|
l.video = Some(v);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
s.execute("image.crop", json!({"x": 15, "y": 0, "width": 25, "height": 20, "deleteCroppedPixels": true})).unwrap();
|
||||||
|
assert_eq!(doc(&s).size, Size::new(25, 20));
|
||||||
|
let v = doc(&s).layers[1].video.as_ref().unwrap();
|
||||||
|
let originals = v.original_frames.as_ref().unwrap();
|
||||||
|
assert_eq!((v.frames.len(), originals.len()), (2, 2));
|
||||||
|
for frames in [&v.frames, originals] {
|
||||||
|
// The red square (10..20, 5..15) keeps only its right half; the full blue frame
|
||||||
|
// keeps exactly the 25×20 crop.
|
||||||
|
assert_eq!(frames[0].content_bounds(), Rect::new(0, 5, 5, 15));
|
||||||
|
assert_eq!(frames[0].pixel(2, 10), vec![1.0, 0.0, 0.0, 1.0]);
|
||||||
|
assert_eq!(frames[1].content_bounds(), Rect::new(0, 0, 25, 20));
|
||||||
|
assert_eq!(frames[1].pixel(24, 19), vec![0.0, 0.0, 1.0, 1.0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size
|
/// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size
|
||||||
/// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the
|
/// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the
|
||||||
/// resolution changes.
|
/// resolution changes.
|
||||||
@@ -1400,6 +1462,51 @@ mod tests {
|
|||||||
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 8), [0.0; 4]);
|
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 8), [0.0; 4]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete Cropped Pixels deletes pixel-layer data only: type and smart objects keep what lies
|
||||||
|
/// outside the frame, so moving them later doesn't show them cut at the old edge.
|
||||||
|
#[test]
|
||||||
|
fn delete_cropped_pixels_keeps_type_and_smart_objects_whole() {
|
||||||
|
let mut s = session();
|
||||||
|
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||||
|
s.execute("type.create", json!({"x": 10, "y": 15, "text": "Hi", "size": 12})).unwrap();
|
||||||
|
let bounds = |s: &Session| -> Vec<Rect> { doc(s).layers[1..].iter().map(|l| l.surface().unwrap().content_bounds()).collect() };
|
||||||
|
let before = bounds(&s);
|
||||||
|
assert!(before.iter().all(|b| b.x0 < 15 && b.x1 > 15), "both cross the crop edge: {before:?}");
|
||||||
|
s.execute("image.crop", json!({"x": 15, "y": 0, "width": 25, "height": 20})).unwrap();
|
||||||
|
assert!(matches!(doc(&s).layers[1].content, LayerContent::Smart(_)) && matches!(doc(&s).layers[2].content, LayerContent::Text(_)));
|
||||||
|
assert_eq!(bounds(&s), before.iter().map(|b| b.translate(-15, 0)).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A turned crop deleting pixels keeps type and smart objects whole too, also a smart object
|
||||||
|
/// whose source is unavailable (the rotation can't re-render it, so it keeps its cache): each
|
||||||
|
/// ends up exactly as the same turned crop with Delete Cropped Pixels off leaves it.
|
||||||
|
#[test]
|
||||||
|
fn turned_crop_deleting_pixels_keeps_type_and_smart_objects_whole() {
|
||||||
|
let [deleted, kept] = [true, false].map(|delete| {
|
||||||
|
let mut s = session();
|
||||||
|
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||||
|
s.edit("unlink", |doc, active| {
|
||||||
|
if let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content {
|
||||||
|
sm.source = photocraft_doc::SmartSource::Linked { path: "missing-photocraft-test-source.psd".into() };
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
s.execute("type.create", json!({"x": 10, "y": 15, "text": "Hi", "size": 12})).unwrap();
|
||||||
|
s.execute("image.crop", json!({"x": 20, "y": 0, "width": 20, "height": 20, "angle": 2.0, "deleteCroppedPixels": delete})).unwrap();
|
||||||
|
s
|
||||||
|
});
|
||||||
|
let (a, b) = (doc(&deleted), doc(&kept));
|
||||||
|
assert!(matches!(a.layers[1].content, LayerContent::Smart(_)) && matches!(a.layers[2].content, LayerContent::Text(_)));
|
||||||
|
for i in [1, 2] {
|
||||||
|
let (sa, sb) = (a.layers[i].surface().unwrap(), b.layers[i].surface().unwrap());
|
||||||
|
let r = sb.content_bounds();
|
||||||
|
assert!(r.x0 < 0, "layer {i} reaches past the crop's left edge: {r:?}");
|
||||||
|
assert_eq!(sa.content_bounds(), r, "layer {i}");
|
||||||
|
assert_eq!(sa.read_region(r), sb.read_region(r), "layer {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The Background keeps the pixels a crop with Delete Cropped Pixels off hid: extending the
|
/// The Background keeps the pixels a crop with Delete Cropped Pixels off hid: extending the
|
||||||
/// canvas over them again (Reveal All, Canvas Size) shows them rather than painting them over
|
/// canvas over them again (Reveal All, Canvas Size) shows them rather than painting them over
|
||||||
/// with the background colour (#7086).
|
/// with the background colour (#7086).
|
||||||
|
|||||||
@@ -186,6 +186,33 @@ pub fn source_decode_count(bytes: &[u8], fmt: PixelFormat) -> u32 {
|
|||||||
SOURCE_CACHE.lock().ok().and_then(|c| c.iter().find(|e| e.key == key).map(|e| e.decodes)).unwrap_or(0)
|
SOURCE_CACHE.lock().ok().and_then(|c| c.iter().find(|e| e.key == key).map(|e| e.decodes)).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn take_caches(layers: &mut [Layer]) -> Vec<(LayerId, Surface)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for l in layers {
|
||||||
|
match &mut l.content {
|
||||||
|
LayerContent::Smart(sm) => out.extend(sm.cache.take().map(|c| (l.id, c))),
|
||||||
|
LayerContent::Group(g) => out.extend(take_caches(&mut g.children)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restore_unrendered<E>(
|
||||||
|
doc: &mut Document,
|
||||||
|
taken: Vec<(LayerId, Surface)>,
|
||||||
|
mut fallback: impl FnMut(&Surface) -> std::result::Result<Surface, E>,
|
||||||
|
) -> std::result::Result<(), E> {
|
||||||
|
for (id, cache) in taken {
|
||||||
|
if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content)
|
||||||
|
&& sm.cache.is_none()
|
||||||
|
{
|
||||||
|
sm.cache = Some(fallback(&cache)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Surface {
|
fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Surface {
|
||||||
let n = fmt.channels();
|
let n = fmt.channels();
|
||||||
let mut data = vec![0.0f32; buf.px.len() * n];
|
let mut data = vec![0.0f32; buf.px.len() * n];
|
||||||
|
|||||||
@@ -838,13 +838,17 @@ fn transform_doc(
|
|||||||
// Photoshop turns the Background into a normal layer before transforming it.
|
// Photoshop turns the Background into a normal layer before transforming it.
|
||||||
crate::extra_cmds::background_to_layer(doc, id);
|
crate::extra_cmds::background_to_layer(doc, id);
|
||||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||||
transform_layer_with(if is_group { None } else { sel.as_ref() }, group, l, h, affine, interp, ctl)?;
|
let layer_sel = if is_group { None } else { sel.as_ref() };
|
||||||
|
check_layer_budget(layer_sel, l, h)?;
|
||||||
|
let taken = if h.is_identity() { Vec::new() } else { crate::smart_cmds::take_caches(std::slice::from_mut(l)) };
|
||||||
|
apply_transform(layer_sel, group, l, h, affine, interp, ctl)?;
|
||||||
if !h.is_identity() {
|
if !h.is_identity() {
|
||||||
// Type layers re-render from their new transform.
|
// Type layers re-render from their new transform.
|
||||||
let snapshot = doc.clone();
|
let snapshot = doc.clone();
|
||||||
if let Some(l) = doc.layer_mut(id) {
|
if let Some(l) = doc.layer_mut(id) {
|
||||||
refresh_text(&snapshot, l);
|
refresh_text(&snapshot, l);
|
||||||
}
|
}
|
||||||
|
crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), h, interp))?;
|
||||||
// The selection outline moves with the pixels.
|
// The selection outline moves with the pixels.
|
||||||
if let Some(sel) = &doc.selection {
|
if let Some(sel) = &doc.selection {
|
||||||
doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty());
|
doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty());
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! #7672: strict ATN export must preserve both accepted Selective Color parameter forms.
|
||||||
|
use photocraft_engine::actions_cmds::Action;
|
||||||
|
use photocraft_engine::{Session, atn_cmds};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
const RANGES: [&str; 9] = ["reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks"];
|
||||||
|
const COMMANDS: [&str; 2] = ["image.adjustments.selectiveColor", "layer.newAdjustmentLayer.selectiveColor"];
|
||||||
|
|
||||||
|
fn fixture(depth: u32) -> Session {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", json!({"width": 10, "height": 2, "depth": depth})).unwrap();
|
||||||
|
let palette = [
|
||||||
|
[0.85, 0.10, 0.05, 1.0],
|
||||||
|
[0.85, 0.80, 0.05, 1.0],
|
||||||
|
[0.05, 0.85, 0.10, 1.0],
|
||||||
|
[0.05, 0.80, 0.85, 1.0],
|
||||||
|
[0.10, 0.05, 0.85, 1.0],
|
||||||
|
[0.85, 0.05, 0.80, 1.0],
|
||||||
|
[0.90, 0.93, 0.95, 1.0],
|
||||||
|
[0.45, 0.50, 0.55, 1.0],
|
||||||
|
[0.05, 0.08, 0.10, 1.0],
|
||||||
|
[0.65, 0.20, 0.70, 0.4],
|
||||||
|
];
|
||||||
|
s.edit("synthetic colours", |doc, active| {
|
||||||
|
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||||
|
let format = surface.format();
|
||||||
|
let bytes: Vec<_> = palette.iter().cycle().take(20).flat_map(|rgba| photocraft_raster::from_rgba(&format, *rgba)).collect();
|
||||||
|
surface.write_region(doc_bounds(), &bytes);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc_bounds() -> photocraft_doc::Rect {
|
||||||
|
photocraft_doc::Rect::from_xywh(0, 0, 10, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pixels(s: &Session) -> Vec<[f32; 4]> {
|
||||||
|
photocraft_compose::flatten(&s.active().unwrap().doc).px
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_cases(id: &str, cases: &[Value], require_effect: bool) {
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for depth in [8, 16, 32] {
|
||||||
|
for params in cases {
|
||||||
|
let action = Action::new("Selective Color", vec![(id.into(), params.clone())]);
|
||||||
|
let mut direct = fixture(depth);
|
||||||
|
let before = pixels(&direct);
|
||||||
|
direct.execute(id, params.clone()).unwrap();
|
||||||
|
let expected = pixels(&direct);
|
||||||
|
if require_effect {
|
||||||
|
assert_ne!(expected, before, "ineffective fixture: {id}, depth {depth}, {params}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut native = fixture(depth);
|
||||||
|
native.actions.list.push(action.clone());
|
||||||
|
native.execute("actions.play", json!({"action": action.name})).unwrap();
|
||||||
|
assert_eq!(pixels(&native), expected, "native replay: {id}, depth {depth}, {params}");
|
||||||
|
|
||||||
|
let (bytes, skipped) = atn_cmds::export("Regression", &[&action], &[], false).unwrap();
|
||||||
|
assert!(skipped.is_empty());
|
||||||
|
let imported = atn_cmds::import(&bytes).unwrap();
|
||||||
|
assert!(imported.unsupported.is_empty());
|
||||||
|
assert!(imported.warnings.is_empty());
|
||||||
|
assert_eq!(imported.actions.len(), 1);
|
||||||
|
assert_eq!(imported.actions[0].steps.len(), 1);
|
||||||
|
assert_eq!(imported.actions[0].steps[0].0, id);
|
||||||
|
|
||||||
|
let mut replay = fixture(depth);
|
||||||
|
replay.actions.list = imported.actions;
|
||||||
|
replay.execute("actions.play", json!({"action": action.name})).unwrap();
|
||||||
|
if pixels(&replay) != expected {
|
||||||
|
failures.push(format!("{id}, depth {depth}, {params}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(failures.is_empty(), "ATN playback changed the result:\n{}", failures.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scalar_cases() -> Vec<Value> {
|
||||||
|
RANGES
|
||||||
|
.iter()
|
||||||
|
.flat_map(|range| {
|
||||||
|
["relative", "absolute"].map(|method| json!({"method": method, "colors": range, "cyan": 35, "magenta": -12.5, "yellow": 17.75, "black": 9}))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_selective_color_actions_preserve_pixels_at_every_depth() {
|
||||||
|
let mut cases = scalar_cases();
|
||||||
|
cases.push(json!({"method": "absolute", "colors": "reds", "cyan": 35}));
|
||||||
|
check_cases(COMMANDS[0], &cases, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_selective_color_adjustment_layers_preserve_pixels_at_every_depth() {
|
||||||
|
check_cases(COMMANDS[1], &scalar_cases(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_forms_method_precedence_nulls_and_clamping_keep_their_effect() {
|
||||||
|
let cases = [
|
||||||
|
json!({"method": "absolute", "reds": [10, 20, 30, 40], "blues": [-10, 5, 0, 4], "colors": "reds", "cyan": 0, "yellow": -17.5}),
|
||||||
|
json!({"method": "relative", "relative": false, "colors": "reds", "cyan": 35}),
|
||||||
|
json!({"method": "absolute", "relative": true, "colors": "neutrals", "black": 15}),
|
||||||
|
json!({"colors": "blues", "magenta": -25}),
|
||||||
|
json!({"method": null, "relative": null, "reds": null, "colors": "reds", "cyan": 35, "black": null}),
|
||||||
|
json!({"method": "absolute", "colors": "reds", "cyan": 175, "magenta": -150}),
|
||||||
|
json!({"method": "absolute", "reds": [175, -150, 0, 0]}),
|
||||||
|
json!({"method": "absolute", "reds": [10, 20, 30, 40], "colors": "reds", "cyan": null}),
|
||||||
|
json!({}),
|
||||||
|
json!({"colors": "reds"}),
|
||||||
|
];
|
||||||
|
for id in COMMANDS {
|
||||||
|
check_cases(id, &cases, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_range_array_controls_still_preserve_pixels() {
|
||||||
|
let cases: Vec<_> = RANGES.iter().map(|range| json!({"method": "absolute", *range: [35, -12.5, 17.75, 9]})).collect();
|
||||||
|
for id in COMMANDS {
|
||||||
|
check_cases(id, &cases, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_selective_color_params_cannot_be_silently_exported() {
|
||||||
|
for params in [
|
||||||
|
json!({"colors": "purples", "cyan": 35}),
|
||||||
|
json!({"colors": 3}),
|
||||||
|
json!({"colors": "reds", "cyan": "35"}),
|
||||||
|
json!({"method": "sideways"}),
|
||||||
|
json!({"relative": "yes"}),
|
||||||
|
json!({"reds": [10, 20]}),
|
||||||
|
json!({"reds": [10, "20", 0, 0]}),
|
||||||
|
] {
|
||||||
|
for id in COMMANDS {
|
||||||
|
let action = Action::new("Invalid", vec![(id.into(), params.clone())]);
|
||||||
|
assert!(atn_cmds::export("Regression", &[&action], &[], false).is_err(), "strict export accepted {id} {params}");
|
||||||
|
let (_, skipped) = atn_cmds::export("Regression", &[&action], &[], true).unwrap();
|
||||||
|
assert_eq!(skipped.len(), 1, "skipUnsupported must report {id} {params}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ mod affinity;
|
|||||||
mod artboard_export_names;
|
mod artboard_export_names;
|
||||||
mod atn_crystallize_cell_size;
|
mod atn_crystallize_cell_size;
|
||||||
mod atn_ripple_amount;
|
mod atn_ripple_amount;
|
||||||
|
mod atn_selective_color;
|
||||||
mod atn_stroke_width;
|
mod atn_stroke_width;
|
||||||
mod background_and_mask_depth;
|
mod background_and_mask_depth;
|
||||||
mod background_layer_consistency;
|
mod background_layer_consistency;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use photocraft_doc::{LayerContent, SmartObject, SmartSource};
|
||||||
|
use photocraft_engine::{EngineError, Session};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn deny_blur(id: &str, _: &Value) -> photocraft_engine::Result<()> {
|
||||||
|
if id == "filter.blur.gaussianBlur" { Err(EngineError::Other("denied".into())) } else { Ok(()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edits() -> [(&'static str, Value); 2] {
|
||||||
|
[("edit.transform", json!({"scaleX": 50, "scaleY": 100})), ("image.imageSize", json!({"width": 100, "height": 50}))]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blurred_smart_object() -> Session {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||||
|
s.execute("layer.new.layer", json!({})).unwrap();
|
||||||
|
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap();
|
||||||
|
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||||
|
s.execute("select.deselect", json!({})).unwrap();
|
||||||
|
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||||
|
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_width(s: &Session) -> i32 {
|
||||||
|
let st = s.active().unwrap();
|
||||||
|
let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
|
||||||
|
sm.cache.as_ref().unwrap().content_bounds().width() as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_smart_object(s: &mut Session, edit: impl FnOnce(&mut SmartObject)) {
|
||||||
|
let st = s.active_mut().unwrap();
|
||||||
|
let id = st.active_layer.unwrap();
|
||||||
|
let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut st.doc).layer_mut(id).unwrap().content else { panic!("not a smart object") };
|
||||||
|
edit(sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_smart_object_whose_filters_are_denied_keeps_the_resampled_fallback() {
|
||||||
|
for (cmd, params) in edits() {
|
||||||
|
let mut s = blurred_smart_object();
|
||||||
|
let before = cache_width(&s);
|
||||||
|
s.authorize = Some(deny_blur);
|
||||||
|
s.execute(cmd, params).unwrap();
|
||||||
|
let after = cache_width(&s);
|
||||||
|
assert!(after < before * 3 / 4, "{cmd}: the fallback stayed at {after} px wide (was {before})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_smart_object_that_fails_to_re_render_keeps_the_resampled_fallback() {
|
||||||
|
let unrenderable =
|
||||||
|
[SmartSource::Linked { path: "missing.psd".into() }, SmartSource::Embedded { file_name: "broken.png".into(), bytes: Arc::new(vec![0; 16]) }];
|
||||||
|
for (cmd, params) in edits() {
|
||||||
|
for source in &unrenderable {
|
||||||
|
let mut s = blurred_smart_object();
|
||||||
|
let before = cache_width(&s);
|
||||||
|
edit_smart_object(&mut s, |sm| sm.source = source.clone());
|
||||||
|
s.execute(cmd, params.clone()).unwrap();
|
||||||
|
let after = cache_width(&s);
|
||||||
|
assert!(after < before * 3 / 4, "{cmd} with {source:?}: the fallback stayed at {after} px wide (was {before})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_allowed_smart_object_still_re_renders_at_the_new_size() {
|
||||||
|
for (cmd, params) in edits() {
|
||||||
|
let mut s = blurred_smart_object();
|
||||||
|
let before = cache_width(&s);
|
||||||
|
s.execute(cmd, params).unwrap();
|
||||||
|
let after = cache_width(&s);
|
||||||
|
assert!(after < before * 3 / 4, "{cmd}: {after} px wide (was {before})");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ impl AtomicFs for RealFs {
|
|||||||
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
|
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
sync_file(&std::fs::File::open(dir)?)
|
open_dir_for_sync(dir)?.map_or(Ok(()), |d| sync_file(&d))
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -254,14 +254,79 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &
|
|||||||
let _ = fs.remove(&tmp);
|
let _ = fs.remove(&tmp);
|
||||||
return Err(rename_error(e, path));
|
return Err(rename_error(e, path));
|
||||||
}
|
}
|
||||||
|
// The new bytes are in place, but until the directory is flushed the rename may not survive
|
||||||
|
// a power loss (#4968). Windows fails the save on any failed flush (#7858). Elsewhere a file
|
||||||
|
// system that can't flush directories at all counts as done; any other failure is reported,
|
||||||
|
// so the caller keeps the document unsaved and its recovery data instead of discarding them
|
||||||
|
// on a save that may not last.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fs.sync_dir(&dir).map_err(|e| publication_error(e, path))?;
|
fs.sync_dir(&dir).map_err(|e| publication_error(e, path))?;
|
||||||
// Keep the existing Unix policy here; #5420 separately changes its error handling.
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
let _ = fs.sync_dir(&dir);
|
dir_synced(fs.sync_dir(&dir)).map_err(|e| unconfirmed_error(e, path))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `dir` opened to flush it, or `None` when it can't be opened for that because it has no read
|
||||||
|
/// permission (a write-and-search-only folder): no flush can be attempted there, so none failed.
|
||||||
|
/// Any other error is returned.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn open_dir_for_sync(dir: &Path) -> io::Result<Option<std::fs::File>> {
|
||||||
|
match std::fs::File::open(dir) {
|
||||||
|
Ok(d) => Ok(Some(d)),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `result` of flushing a directory, with "this file system can't flush directories" counted as
|
||||||
|
/// success: `EINVAL` (Linux file systems without directory fsync), `ENOTSUP`/`EOPNOTSUPP` and,
|
||||||
|
/// on Apple platforms, `ENOTTY`, or [`io::ErrorKind::Unsupported`]. Waiting or retrying won't
|
||||||
|
/// change those, and failing every save there would make saving impossible. Any other error is
|
||||||
|
/// returned: the flush was attempted and failed.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub(crate) fn dir_synced(result: io::Result<()>) -> io::Result<()> {
|
||||||
|
match result {
|
||||||
|
Err(e) if dir_sync_unsupported(&e) => Ok(()),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn dir_sync_unsupported(e: &io::Error) -> bool {
|
||||||
|
if e.kind() == io::ErrorKind::Unsupported {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// errno values: EINVAL is 22 everywhere; ENOTSUP and EOPNOTSUPP are 95 on Linux and
|
||||||
|
// Android, 45 and 102 on Apple platforms, 45 on the BSDs; ENOTTY is 25. Elsewhere 45 means
|
||||||
|
// something else (illumos: EDEADLK), so only EINVAL and `Unsupported` count there.
|
||||||
|
const EINVAL: i32 = 22;
|
||||||
|
let unsupported: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) {
|
||||||
|
&[EINVAL, 95]
|
||||||
|
} else if cfg!(target_vendor = "apple") {
|
||||||
|
&[EINVAL, 45, 102, 25]
|
||||||
|
} else if cfg!(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")) {
|
||||||
|
&[EINVAL, 45]
|
||||||
|
} else if cfg!(unix) {
|
||||||
|
&[EINVAL]
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
};
|
||||||
|
e.raw_os_error().is_some_and(|code| unsupported.contains(&code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The error for a file that was published but whose directory flush failed: same kind, saying
|
||||||
|
/// the file is in place but may not survive a power loss. (Windows reports `publication_error`.)
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn unconfirmed_error(e: io::Error, path: &Path) -> io::Error {
|
||||||
|
context(
|
||||||
|
e,
|
||||||
|
format!(
|
||||||
|
"{} was written, but the folder could not be flushed to disk, so the save may not survive a power loss or crash; save again or to another folder",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Run `rename`, retrying with backoff while it fails with `PermissionDenied` (see
|
/// Run `rename`, retrying with backoff while it fails with `PermissionDenied` (see
|
||||||
/// [`RenameRetry`]). For callers that rename through their own handles (capability directories).
|
/// [`RenameRetry`]). For callers that rename through their own handles (capability directories).
|
||||||
pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
|
pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ struct Faulty {
|
|||||||
/// Fail the first N renames with PermissionDenied (a transient lock), then succeed.
|
/// Fail the first N renames with PermissionDenied (a transient lock), then succeed.
|
||||||
locked_renames: Cell<u32>,
|
locked_renames: Cell<u32>,
|
||||||
renames: Cell<u32>,
|
renames: Cell<u32>,
|
||||||
/// Failure of the durability barrier after publication (#7858).
|
/// Fail the directory flush (the durability barrier after publication, #4968, #7858)
|
||||||
fail_sync_dir: bool,
|
/// with this raw OS error, or with this kind.
|
||||||
|
fail_sync_dir: Option<Result<i32, io::ErrorKind>>,
|
||||||
synced: Cell<u32>,
|
synced: Cell<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,10 +73,11 @@ impl AtomicFs for Faulty {
|
|||||||
/// Count publication barriers and optionally fail before the real directory flush.
|
/// Count publication barriers and optionally fail before the real directory flush.
|
||||||
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
|
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
|
||||||
self.synced.set(self.synced.get() + 1);
|
self.synced.set(self.synced.get() + 1);
|
||||||
if self.fail_sync_dir {
|
match self.fail_sync_dir {
|
||||||
return Err(io::Error::other("injected publication flush failure"));
|
Some(Ok(code)) => Err(io::Error::from_raw_os_error(code)),
|
||||||
|
Some(Err(kind)) => Err(io::Error::new(kind, "injected directory flush failure")),
|
||||||
|
None => RealFs.sync_dir(dir),
|
||||||
}
|
}
|
||||||
RealFs.sync_dir(dir)
|
|
||||||
}
|
}
|
||||||
fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> {
|
fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> {
|
||||||
RealFs.copy_metadata(from, to)
|
RealFs.copy_metadata(from, to)
|
||||||
@@ -92,7 +94,7 @@ fn a_failed_publication_flush_is_reported_and_can_be_retried() {
|
|||||||
let d = TempDir::new("publication-flush");
|
let d = TempDir::new("publication-flush");
|
||||||
let p = d.0.join("document.pcraft");
|
let p = d.0.join("document.pcraft");
|
||||||
std::fs::write(&p, ORIGINAL).unwrap();
|
std::fs::write(&p, ORIGINAL).unwrap();
|
||||||
let fs = Faulty { fail_sync_dir: true, ..Default::default() };
|
let fs = Faulty { fail_sync_dir: Some(Err(io::ErrorKind::Other)), ..Default::default() };
|
||||||
|
|
||||||
let error = atomic_write_with(&fs, NO_RETRY, &p, b"new document").unwrap_err();
|
let error = atomic_write_with(&fs, NO_RETRY, &p, b"new document").unwrap_err();
|
||||||
assert_eq!(fs.synced.get(), 1, "{error}");
|
assert_eq!(fs.synced.get(), 1, "{error}");
|
||||||
@@ -444,3 +446,38 @@ fn sync_file_flushes_a_local_file() {
|
|||||||
let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
|
let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
|
||||||
sync_file(&file).unwrap();
|
sync_file(&file).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #4968: the rename succeeded but the directory flush failed. The new bytes are in place, and
|
||||||
|
/// the save is reported as unconfirmed (same kind, saying so) instead of as a success, so the
|
||||||
|
/// caller keeps the document unsaved and its recovery data.
|
||||||
|
#[test]
|
||||||
|
fn a_failed_directory_flush_is_reported_not_swallowed() {
|
||||||
|
let d = TempDir::new("syncdir");
|
||||||
|
let p = d.0.join("doc.pcraft");
|
||||||
|
std::fs::write(&p, ORIGINAL).unwrap();
|
||||||
|
let fs = Faulty { fail_sync_dir: Some(Err(io::ErrorKind::Other)), ..Default::default() };
|
||||||
|
let e = atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap_err();
|
||||||
|
assert_eq!(e.kind(), io::ErrorKind::Other);
|
||||||
|
let msg = e.to_string();
|
||||||
|
assert!(msg.contains("doc.pcraft") && msg.contains("may not survive"), "{msg}");
|
||||||
|
assert_eq!(std::fs::read(&p).unwrap(), b"new", "published: the new bytes are in place");
|
||||||
|
assert_no_temp(&d.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file system that can't flush directories at all (Linux's EINVAL, ENOTSUP, or an
|
||||||
|
/// `Unsupported` error) still saves: failing every save there would make saving impossible.
|
||||||
|
/// Windows fails the save on any failed flush (#7858), so this is the Unix rule.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn a_directory_flush_the_file_system_cannot_do_still_saves() {
|
||||||
|
let d = TempDir::new("syncdir-unsupported");
|
||||||
|
let p = d.0.join("doc.pcraft");
|
||||||
|
let fs = Faulty { fail_sync_dir: Some(Err(io::ErrorKind::Unsupported)), ..Default::default() };
|
||||||
|
atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap();
|
||||||
|
assert_eq!(std::fs::read(&p).unwrap(), b"new");
|
||||||
|
// EINVAL (22) means "can't flush a directory" only on Unix; EIO (5) is a real failure.
|
||||||
|
assert_eq!(dir_synced(Err(io::Error::from_raw_os_error(22))).is_ok(), cfg!(unix));
|
||||||
|
assert!(dir_synced(Err(io::Error::from_raw_os_error(5))).is_err());
|
||||||
|
let fs = Faulty { fail_sync_dir: Some(Ok(5)), ..Default::default() };
|
||||||
|
assert!(atomic_write_with(&fs, NO_RETRY, &p, b"newer").is_err());
|
||||||
|
}
|
||||||
|
|||||||
+121
-4
@@ -587,6 +587,50 @@ fn validate_bundle_file(root: &Path, path: &Path) -> Result<()> {
|
|||||||
plain_file_or_missing(path)
|
plain_file_or_missing(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flush `dir`, the folder `written` was just renamed into, as `atomic_write` does (with the
|
||||||
|
/// full-flush fallback of `sync_file`, #1336). Windows flushes through the injected `AtomicFs`
|
||||||
|
/// and fails on any error (#7858).
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn flush_bundle_dir(dir: &Path, written: &Path) -> std::io::Result<()> {
|
||||||
|
use crate::atomic::AtomicFs;
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
FLUSHED.with(|f| f.borrow_mut().push(dir.to_path_buf()));
|
||||||
|
let named = |p: Option<&Path>, name: &str| p.and_then(Path::file_name).is_some_and(|n| n == name);
|
||||||
|
if FAIL_DIR_FLUSH.with(|f| {
|
||||||
|
let hit = f.get().is_some_and(|name| named(Some(written), name) || named(written.parent(), name));
|
||||||
|
if hit {
|
||||||
|
f.set(None);
|
||||||
|
}
|
||||||
|
hit
|
||||||
|
}) {
|
||||||
|
return Err(std::io::Error::other("injected folder flush failure"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = written;
|
||||||
|
crate::atomic::RealFs.sync_dir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`flush_bundle_dir`], as the save's error when the flush was attempted and failed: `written`
|
||||||
|
/// is in place, but may not survive a power loss or crash (#4968).
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn confirm_flushed(dir: &Path, written: &Path) -> Result<()> {
|
||||||
|
crate::atomic::dir_synced(flush_bundle_dir(dir, written)).map_err(|e| {
|
||||||
|
FormatError::Io(std::io::Error::new(
|
||||||
|
e.kind(),
|
||||||
|
format!("{} was written, but its folder could not be flushed to disk, so the save may not survive a power loss or crash: {e}", written.display()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, not(windows)))]
|
||||||
|
thread_local! {
|
||||||
|
/// Tests: the bundle file, or folder, whose next folder flush fails, once, on this thread.
|
||||||
|
static FAIL_DIR_FLUSH: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
|
||||||
|
/// Tests: the folders flushed on this thread, in order.
|
||||||
|
static FLUSHED: std::cell::RefCell<Vec<PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
|
||||||
/// Publish a validated bundle entry through a flushed temporary file and replacing rename.
|
/// Publish a validated bundle entry through a flushed temporary file and replacing rename.
|
||||||
/// On Windows, propagate a failed directory barrier even though the new entry is already visible.
|
/// On Windows, propagate a failed directory barrier even though the new entry is already visible.
|
||||||
fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atomic::AtomicFs) -> Result<()> {
|
fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atomic::AtomicFs) -> Result<()> {
|
||||||
@@ -639,10 +683,10 @@ fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atom
|
|||||||
{
|
{
|
||||||
fs.sync_dir(parent).map_err(|e| crate::atomic::publication_error(e, path))?;
|
fs.sync_dir(parent).map_err(|e| crate::atomic::publication_error(e, path))?;
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
// Elsewhere a failed flush is reported too (#4968), but a file system that can't flush a
|
||||||
{
|
// directory at all still saves, as in `atomic_write`.
|
||||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
#[cfg(not(windows))]
|
||||||
}
|
confirm_flushed(parent, path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
if result.is_err() && validate_bundle_file(root, &tmp).is_ok() {
|
if result.is_err() && validate_bundle_file(root, &tmp).is_ok() {
|
||||||
@@ -994,6 +1038,13 @@ impl PcraftWriter {
|
|||||||
fs.sync_dir(&dir.join(sub)).map_err(|e| crate::atomic::publication_error(e, &dir.join(sub)))?;
|
fs.sync_dir(&dir.join(sub)).map_err(|e| crate::atomic::publication_error(e, &dir.join(sub)))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
for sub in ["tiles", "blobs"] {
|
||||||
|
let folder = dir.join(sub);
|
||||||
|
if folder.is_dir() {
|
||||||
|
confirm_flushed(&folder, &folder)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest, fs)?;
|
write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest, fs)?;
|
||||||
// Omitted previews belong to the previous manifest until the new one commits.
|
// Omitted previews belong to the previous manifest until the new one commits.
|
||||||
// After commit, a locked leftover is only housekeeping, just like unused objects.
|
// After commit, a locked leftover is only housekeeping, just like unused objects.
|
||||||
@@ -1156,6 +1207,72 @@ fn gc_pinned_directory_handles_resist_subdirectory_swap() {
|
|||||||
std::fs::remove_dir_all(root).unwrap();
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #4968: a manifest whose folder can't be flushed fails the save, and the objects the previous
|
||||||
|
/// manifest references stay in place; the next save that flushes removes what is unreferenced.
|
||||||
|
#[cfg(all(test, not(windows)))]
|
||||||
|
#[test]
|
||||||
|
fn a_failed_manifest_flush_keeps_the_previous_objects() {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let dir = std::env::temp_dir().join(format!("pcraft-flush-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let dir = std::fs::canonicalize(dir).unwrap();
|
||||||
|
let mut doc = Document::new("flush", photocraft_doc::Size::new(64, 64), photocraft_color::ColorMode::Rgb, SampleType::U8);
|
||||||
|
let id = doc.insert_above(None, photocraft_doc::Layer::raster("paint", doc.pixel_format()));
|
||||||
|
let paint = |doc: &mut Document, red: f32| {
|
||||||
|
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[red, 0.5, 0.25, 1.0]);
|
||||||
|
};
|
||||||
|
paint(&mut doc, 1.0);
|
||||||
|
let mut w = PcraftWriter::new();
|
||||||
|
w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
|
||||||
|
let old = list_objects(&dir).unwrap();
|
||||||
|
paint(&mut doc, 0.0);
|
||||||
|
FAIL_DIR_FLUSH.with(|f| f.set(Some(MANIFEST)));
|
||||||
|
let e = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("could not be flushed"), "{e}");
|
||||||
|
let now = list_objects(&dir).unwrap();
|
||||||
|
assert!(old.is_subset(&now), "the previous manifest's objects are kept");
|
||||||
|
assert!(now.len() > old.len(), "the new objects were written");
|
||||||
|
let stats = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
|
||||||
|
assert!(stats.objects_removed > 0, "the next save removes the unreferenced objects");
|
||||||
|
assert_eq!(crate::load_path(&dir).unwrap(), doc);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An object whose folder flush failed is reused by the retried save, which writes no new object
|
||||||
|
/// there: that save still flushes the object folder before committing a manifest that names it.
|
||||||
|
#[cfg(all(test, not(windows)))]
|
||||||
|
#[test]
|
||||||
|
fn a_retried_save_flushes_the_folder_of_a_reused_object() {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let dir = std::env::temp_dir().join(format!("pcraft-retry-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let dir = std::fs::canonicalize(dir).unwrap();
|
||||||
|
// One layer of one tile: the retry finds its only object already written.
|
||||||
|
let mut doc = Document::new("retry", photocraft_doc::Size::new(16, 16), photocraft_color::ColorMode::Rgb, SampleType::U8);
|
||||||
|
let id = doc.insert_above(None, photocraft_doc::Layer::raster("paint", doc.pixel_format()));
|
||||||
|
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 16, 16), &[1.0, 0.5, 0.25, 1.0]);
|
||||||
|
let mut w = PcraftWriter::new();
|
||||||
|
FAIL_DIR_FLUSH.with(|f| f.set(Some("tiles")));
|
||||||
|
let e = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("could not be flushed"), "{e}");
|
||||||
|
assert!(!dir.join(MANIFEST).exists(), "nothing committed");
|
||||||
|
let written = list_objects(&dir).unwrap();
|
||||||
|
assert_eq!(written.iter().filter(|o| o.starts_with("tiles/")).count(), 1, "{written:?}");
|
||||||
|
FLUSHED.with(|f| f.borrow_mut().clear());
|
||||||
|
let stats = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
|
||||||
|
assert_eq!(stats.tiles_written, 0, "the tile is reused");
|
||||||
|
let flushed = FLUSHED.with(|f| f.borrow().clone());
|
||||||
|
let tiles = flushed.iter().position(|d| *d == dir.join("tiles"));
|
||||||
|
let manifest = flushed.iter().rposition(|d| *d == dir);
|
||||||
|
assert!(tiles.is_some() && tiles < manifest, "tiles flushed before the manifest: {flushed:?}");
|
||||||
|
assert_eq!(crate::load_path(&dir).unwrap(), doc);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn list_objects(dir: &Path) -> Result<HashSet<String>> {
|
fn list_objects(dir: &Path) -> Result<HashSet<String>> {
|
||||||
let mut out = HashSet::new();
|
let mut out = HashSet::new();
|
||||||
for sub in ["tiles", "blobs"] {
|
for sub in ["tiles", "blobs"] {
|
||||||
|
|||||||
@@ -139,6 +139,19 @@ pub fn label(key: &[u8; 4]) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keys of a Brightness/Contrast `CgEd` descriptor: the ones [`write`] emits plus Photoshop's
|
||||||
|
/// preset bookkeeping.
|
||||||
|
const BRIT_CGED_KEYS: [&str; 9] = ["Vrsn", "Brgh", "Cntr", "means", "Lab ", "useLegacy", "Auto", "presetKind", "presetFileName"];
|
||||||
|
|
||||||
|
/// Whether a `brit` layer's `CgEd` descriptor holds settings Brightness/Contrast has no place
|
||||||
|
/// for. Photoshop 2026's Light adjustment (Exposure, Contrast, Highlights, Shadows, Whites,
|
||||||
|
/// Blacks) is stored under the `brit` key; read as Brightness/Contrast it kept only Contrast and
|
||||||
|
/// lost the rest without a word (#6542), so such a layer stays [`Adjustment::Unsupported`].
|
||||||
|
pub fn is_extended_brit(cged: Option<&[u8]>) -> bool {
|
||||||
|
cged.and_then(|c| VersionedDescriptor::parse_prefix(c).ok())
|
||||||
|
.is_some_and(|(v, _)| v.descriptor.items.iter().any(|(k, _)| !BRIT_CGED_KEYS.iter().any(|n| k.is(n))))
|
||||||
|
}
|
||||||
|
|
||||||
/// Why [`parse`] keeps a block as [`Adjustment::Unsupported`], when there is more to say than
|
/// Why [`parse`] keeps a block as [`Adjustment::Unsupported`], when there is more to say than
|
||||||
/// "it could not be read" (so far: Color Lookup).
|
/// "it could not be read" (so far: Color Lookup).
|
||||||
pub fn unreadable_reason(key: &[u8; 4], data: &[u8]) -> Option<String> {
|
pub fn unreadable_reason(key: &[u8; 4], data: &[u8]) -> Option<String> {
|
||||||
@@ -204,6 +217,8 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment {
|
|||||||
b"nvrt" => Some(Adjustment::Invert),
|
b"nvrt" => Some(Adjustment::Invert),
|
||||||
b"thrs" => be16(data, 0).map(|v| Adjustment::Threshold { level: f32::from(v) / 255.0 }),
|
b"thrs" => be16(data, 0).map(|v| Adjustment::Threshold { level: f32::from(v) / 255.0 }),
|
||||||
b"post" => be16(data, 0).map(|v| Adjustment::Posterize { levels: u32::from(v) }),
|
b"post" => be16(data, 0).map(|v| Adjustment::Posterize { levels: u32::from(v) }),
|
||||||
|
// Kept raw (with its `CgEd`, which the PSD writer keeps while the layer is unchanged).
|
||||||
|
b"brit" if is_extended_brit(cged) => None,
|
||||||
b"brit" => {
|
b"brit" => {
|
||||||
let modern = cged.and_then(|c| VersionedDescriptor::parse_prefix(c).ok()).and_then(|(v, _)| {
|
let modern = cged.and_then(|c| VersionedDescriptor::parse_prefix(c).ok()).and_then(|(v, _)| {
|
||||||
let d = v.descriptor;
|
let d = v.descriptor;
|
||||||
|
|||||||
+68
-1
@@ -182,6 +182,17 @@ pub fn color_from_desc(d: &Descriptor) -> Option<Color> {
|
|||||||
Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0)
|
Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.map(|c| with_pc_alpha(c, d))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PSD colour descriptors have no alpha. PhotoCraft writes `pcAl` (0..1) so shape fill
|
||||||
|
/// opacity round-trips (#6180). Photoshop ignores the key.
|
||||||
|
fn with_pc_alpha(mut c: Color, d: &Descriptor) -> Color {
|
||||||
|
// A hostile file's NaN or infinity would survive the clamp: keep the colour opaque instead.
|
||||||
|
if let Some(a) = num(d.get("pcAl")).filter(|a| a.is_finite()) {
|
||||||
|
c.alpha = (a as f32).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`Color`] → descriptor in its own model.
|
/// [`Color`] → descriptor in its own model.
|
||||||
@@ -204,6 +215,13 @@ pub fn color_to_desc(c: &Color) -> Descriptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Solid shape fills have no other alpha slot. Write `pcAl` only here so gradient stops
|
||||||
|
/// keep using `Trns` alone (#6180).
|
||||||
|
fn color_to_desc_with_pc_alpha(c: &Color) -> Descriptor {
|
||||||
|
let desc = color_to_desc(c);
|
||||||
|
if c.alpha.is_finite() && (c.alpha - 1.0).abs() > 1e-6 { desc.with("pcAl", Value::Double(f64::from(c.alpha.clamp(0.0, 1.0)))) } else { desc }
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn get_desc<'a>(d: &'a Descriptor, key: &str) -> Option<&'a Descriptor> {
|
pub(crate) fn get_desc<'a>(d: &'a Descriptor, key: &str) -> Option<&'a Descriptor> {
|
||||||
match d.get(key)? {
|
match d.get(key)? {
|
||||||
Value::Descriptor(x) | Value::GlobalObject(x) => Some(x),
|
Value::Descriptor(x) | Value::GlobalObject(x) => Some(x),
|
||||||
@@ -499,7 +517,7 @@ pub fn write_fill(f: &Fill) -> ([u8; 4], Vec<u8>) {
|
|||||||
/// A fill as (block key, descriptor with class `null`).
|
/// A fill as (block key, descriptor with class `null`).
|
||||||
pub fn fill_to_desc(f: &Fill) -> ([u8; 4], Descriptor) {
|
pub fn fill_to_desc(f: &Fill) -> ([u8; 4], Descriptor) {
|
||||||
match f {
|
match f {
|
||||||
Fill::Solid(c) => (*b"SoCo", Descriptor::new("null").with("Clr ", Value::Descriptor(color_to_desc(c)))),
|
Fill::Solid(c) => (*b"SoCo", Descriptor::new("null").with("Clr ", Value::Descriptor(color_to_desc_with_pc_alpha(c)))),
|
||||||
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => {
|
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => {
|
||||||
let prc = |v: f32| Value::UnitFloat { unit: *b"#Prc", value: f64::from(v * 100.0) };
|
let prc = |v: f32| Value::UnitFloat { unit: *b"#Prc", value: f64::from(v * 100.0) };
|
||||||
let mut d = Descriptor::new("null")
|
let mut d = Descriptor::new("null")
|
||||||
@@ -1025,6 +1043,55 @@ mod tests {
|
|||||||
assert!(super::color_from_desc(&Descriptor::new("HSBC").with("Strt", Value::Double(50.0))).is_none());
|
assert!(super::color_from_desc(&Descriptor::new("HSBC").with("Strt", Value::Double(50.0))).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_descriptor_keeps_photocraft_alpha() {
|
||||||
|
use photocraft_color::Color;
|
||||||
|
use photocraft_doc::Fill;
|
||||||
|
let c = Color::rgba(0.2, 0.4, 0.6, 0.25);
|
||||||
|
let (k, d) = super::fill_to_desc(&Fill::Solid(c));
|
||||||
|
let Fill::Solid(back) = super::fill_from_desc(&k, &d).unwrap() else { panic!("solid") };
|
||||||
|
assert!((back.alpha - 0.25).abs() < 1e-5);
|
||||||
|
assert!((back.to_rgb()[0] - 0.2).abs() < 1e-3);
|
||||||
|
let (k, d) = super::fill_to_desc(&Fill::Solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||||
|
let Fill::Solid(opaque) = super::fill_from_desc(&k, &d).unwrap() else { panic!("solid") };
|
||||||
|
assert!((opaque.alpha - 1.0).abs() < 1e-6);
|
||||||
|
assert!(super::color_to_desc(&c).get("pcAl").is_none(), "generic colour descriptors have no pcAl");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_finite_photocraft_alpha_is_ignored() {
|
||||||
|
use photocraft_color::Color;
|
||||||
|
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||||
|
let d = super::color_to_desc(&Color::rgb(0.2, 0.4, 0.6)).with("pcAl", Value::Double(bad));
|
||||||
|
let c = super::color_from_desc(&d).unwrap();
|
||||||
|
assert_eq!(c.alpha, 1.0, "pcAl {bad}");
|
||||||
|
}
|
||||||
|
let d = super::color_to_desc(&Color::rgb(0.2, 0.4, 0.6)).with("pcAl", Value::Double(0.5));
|
||||||
|
assert!((super::color_from_desc(&d).unwrap().alpha - 0.5).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gradient_stop_alpha_round_trips_once() {
|
||||||
|
use photocraft_color::Color;
|
||||||
|
use photocraft_doc::Fill;
|
||||||
|
let fill = Fill::Gradient {
|
||||||
|
stops: vec![(0.0, Color::rgba(1.0, 0.0, 0.0, 0.5)), (1.0, Color::WHITE)],
|
||||||
|
angle: 90.0,
|
||||||
|
scale: 1.0,
|
||||||
|
style: photocraft_doc::GradientStyle::Linear,
|
||||||
|
reverse: false,
|
||||||
|
opacity_stops: vec![],
|
||||||
|
midpoints: vec![],
|
||||||
|
offset: (0.0, 0.0),
|
||||||
|
dither: false,
|
||||||
|
align: true,
|
||||||
|
};
|
||||||
|
let (k, d) = super::fill_to_desc(&fill);
|
||||||
|
let Fill::Gradient { stops, opacity_stops, .. } = super::fill_from_desc(&k, &d).unwrap() else { panic!("grad") };
|
||||||
|
assert!((stops[0].1.alpha - 1.0).abs() < 1e-4, "colour alpha lives in Trns, not the stop colour");
|
||||||
|
assert!(opacity_stops.iter().any(|(_, a)| (*a - 0.5).abs() < 1e-3), "{opacity_stops:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blending_ranges_map_to_blend_if() {
|
fn blending_ranges_map_to_blend_if() {
|
||||||
use photocraft_doc::{BlendIf, BlendRange};
|
use photocraft_doc::{BlendIf, BlendRange};
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ pub(super) fn export_item(f: &SmartFilter) -> Result<Descriptor, String> {
|
|||||||
Descriptor::new("Hst2").with("H ", master.0).with("Strt", master.1).with("Lght", setting(p, "lightness", 0.0, -100.0, 100.0)?),
|
Descriptor::new("Hst2").with("H ", master.0).with("Strt", master.1).with("Lght", setting(p, "lightness", 0.0, -100.0, 100.0)?),
|
||||||
)];
|
)];
|
||||||
for (i, key) in RANGES.iter().enumerate() {
|
for (i, key) in RANGES.iter().enumerate() {
|
||||||
if let Some(o) = p.get(*key) {
|
// A `null` range is an untouched one, like a missing key.
|
||||||
|
if let Some(o) = p.get(*key).filter(|o| !o.is_null()) {
|
||||||
adjs.extend(range_entry(i, o)?);
|
adjs.extend(range_entry(i, o)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,6 +304,7 @@ mod tests {
|
|||||||
assert_eq!(export(json!({"hue": 12.4})), json!({"colorize": false, "hue": 12, "saturation": 0, "lightness": 0}));
|
assert_eq!(export(json!({"hue": 12.4})), json!({"colorize": false, "hue": 12, "saturation": 0, "lightness": 0}));
|
||||||
assert_eq!(export(json!({"hue": -400, "lightness": 101, "layer": 3})), json!({"colorize": false, "hue": -180, "saturation": 0, "lightness": 100}));
|
assert_eq!(export(json!({"hue": -400, "lightness": 101, "layer": 3})), json!({"colorize": false, "hue": -180, "saturation": 0, "lightness": 100}));
|
||||||
assert_eq!(export(J::Null), json!({"colorize": false, "hue": 0, "saturation": 0, "lightness": 0}));
|
assert_eq!(export(J::Null), json!({"colorize": false, "hue": 0, "saturation": 0, "lightness": 0}));
|
||||||
|
assert_eq!(export(json!({"reds": null})), json!({"colorize": false, "hue": 0, "saturation": 0, "lightness": 0}));
|
||||||
// Colorize starts at 25 % saturation and wraps the hue into 0..360.
|
// Colorize starts at 25 % saturation and wraps the hue into 0..360.
|
||||||
assert_eq!(export(json!({"colorize": true, "hue": -30})), json!({"colorize": true, "hue": 330, "saturation": 25, "lightness": 0}));
|
assert_eq!(export(json!({"colorize": true, "hue": -30})), json!({"colorize": true, "hue": 330, "saturation": 25, "lightness": 0}));
|
||||||
// The full set (`to_params`): neutral ranges at their default extent are left out, reds
|
// The full set (`to_params`): neutral ranges at their default extent are left out, reds
|
||||||
|
|||||||
@@ -2171,6 +2171,42 @@ mod tests {
|
|||||||
assert_eq!(density, 0.5);
|
assert_eq!(density, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn light_adjustment_is_kept_verbatim_and_warned() {
|
||||||
|
// #6542: Photoshop 2026's Light layer is a `brit` block whose `CgEd` descriptor carries
|
||||||
|
// more than Brightness/Contrast. Synthetic block: the extra key names are placeholders.
|
||||||
|
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
|
||||||
|
let brit = vec![0, 0, 0xff, 0xfe, 0, 127, 0, 0];
|
||||||
|
let d = Descriptor::new("null")
|
||||||
|
.with("Vrsn", Value::Integer(1))
|
||||||
|
.with("Brgh", Value::Integer(0))
|
||||||
|
.with("Cntr", Value::Integer(-2))
|
||||||
|
.with("Exps", Value::Double(-2.3))
|
||||||
|
.with("Hghl", Value::Integer(-64))
|
||||||
|
.with("useLegacy", Value::Boolean(false));
|
||||||
|
let cged = VersionedDescriptor::new(d).to_bytes();
|
||||||
|
let adjustment = crate::adjust_map::parse(b"brit", &brit, Some(&cged), crate::adjust_map::Channels::Rgb);
|
||||||
|
assert_eq!(adjustment, photocraft_doc::Adjustment::Unsupported { psd_key: "brit".into(), raw: brit.clone() });
|
||||||
|
let mut doc = Document::with_background("Light", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8, photocraft_doc::Color::WHITE);
|
||||||
|
let mut layer = Layer::new("Light 1", LayerContent::Adjustment(adjustment));
|
||||||
|
layer.psd_blocks = vec![(*b"brit", std::sync::Arc::new(brit.clone())), (*b"CgEd", std::sync::Arc::new(cged.clone()))];
|
||||||
|
doc.layers.push(layer);
|
||||||
|
let block = |file: &PsdFile, key: &[u8; 4]| {
|
||||||
|
let records = &file.layer_info.as_ref().unwrap().layers;
|
||||||
|
records.iter().flat_map(|r| r.blocks.iter()).find(|b| b.key == *key).map(|b| b.data.clone())
|
||||||
|
};
|
||||||
|
let file = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap();
|
||||||
|
let (restored, warnings) = crate::psd_import::psd_to_document(&file);
|
||||||
|
assert!(warnings.iter().any(|w| w.contains("\"Light 1\"") && w.contains("Light settings")), "{warnings:?}");
|
||||||
|
// Saved again unchanged, both blocks are written back byte for byte.
|
||||||
|
let again = PsdFile::from_bytes(&document_to_psd(&restored).to_bytes().unwrap()).unwrap();
|
||||||
|
assert_eq!(block(&again, b"brit").as_deref(), Some(&brit[..]));
|
||||||
|
assert_eq!(block(&again, b"CgEd").as_deref(), Some(&cged[..]));
|
||||||
|
// A plain Brightness/Contrast descriptor still reads as Brightness/Contrast.
|
||||||
|
let plain = crate::adjust_map::write(&photocraft_doc::Adjustment::BrightnessContrast { brightness: 5.0, contrast: -2.0, legacy: false });
|
||||||
|
assert!(!crate::adjust_map::is_extended_brit(Some(&plain[1].1)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn size_limit_boundary_keeps_limit_and_switches_above_it() {
|
fn size_limit_boundary_keeps_limit_and_switches_above_it() {
|
||||||
assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE)));
|
assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE)));
|
||||||
|
|||||||
@@ -391,10 +391,16 @@ impl Ctx<'_> {
|
|||||||
// Kept verbatim for saving, but it renders as nothing: say so rather than open the
|
// Kept verbatim for saving, but it renders as nothing: say so rather than open the
|
||||||
// layer silently without its effect (#1763).
|
// layer silently without its effect (#1763).
|
||||||
if matches!(adj, photocraft_doc::Adjustment::Unsupported { .. }) {
|
if matches!(adj, photocraft_doc::Adjustment::Unsupported { .. }) {
|
||||||
let why = adjust_map::unreadable_reason(k, &data).map(|r| format!(" ({r})")).unwrap_or_default();
|
// Photoshop 2026's Light adjustment shares the Brightness/Contrast key (#6542).
|
||||||
|
let light = k == b"brit" && adjust_map::is_extended_brit(cged);
|
||||||
|
let why = if light {
|
||||||
|
" (Light adjustments, with Exposure, Highlights, Shadows, Whites and Blacks, aren't supported yet)".to_string()
|
||||||
|
} else {
|
||||||
|
adjust_map::unreadable_reason(k, &data).map(|r| format!(" ({r})")).unwrap_or_default()
|
||||||
|
};
|
||||||
self.warn(format!(
|
self.warn(format!(
|
||||||
"layer \"{name}\": its {} settings could not be read{why}; the layer is kept as saved but has no effect",
|
"layer \"{name}\": its {} settings could not be read{why}; the layer is kept as saved but has no effect",
|
||||||
adjust_map::label(k)
|
if light { "Light" } else { adjust_map::label(k) }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
LayerContent::Adjustment(adj)
|
LayerContent::Adjustment(adj)
|
||||||
|
|||||||
@@ -1820,6 +1820,13 @@ mod tests {
|
|||||||
sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})),
|
sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})),
|
||||||
sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})),
|
sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})),
|
||||||
sf("image.adjustments.shadowsHighlights", json!({})),
|
sf("image.adjustments.shadowsHighlights", json!({})),
|
||||||
|
sf("filter.sharpen.sharpen", json!({})),
|
||||||
|
// Photoshop stores Dust & Scratches' radius in whole pixels (a `long`).
|
||||||
|
sf("filter.noise.dustAndScratches", json!({"radius": 4, "threshold": 12})),
|
||||||
|
sf(
|
||||||
|
"image.adjustments.hueSaturation",
|
||||||
|
json!({"colorize": false, "hue": 10, "saturation": -20, "lightness": 5, "reds": {"hue": 5, "saturation": 0, "lightness": -3, "range": [315, 345, 15, 45]}}),
|
||||||
|
),
|
||||||
// A pencil-drawn curve: 256 output levels (`Mpng`).
|
// A pencil-drawn curve: 256 output levels (`Mpng`).
|
||||||
sf("image.adjustments.curves", json!({"pencil": true, "points": (0..256).rev().collect::<Vec<i32>>()})),
|
sf("image.adjustments.curves", json!({"pencil": true, "points": (0..256).rev().collect::<Vec<i32>>()})),
|
||||||
sf("filter.distort.pinch", json!({"amount": -28})),
|
sf("filter.distort.pinch", json!({"amount": -28})),
|
||||||
@@ -2877,6 +2884,68 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A filter PhotoCraft doesn't model, or a known one with settings it doesn't, is kept verbatim; only its visibility and blend change.
|
/// A filter PhotoCraft doesn't model, or a known one with settings it doesn't, is kept verbatim; only its visibility and blend change.
|
||||||
|
#[test]
|
||||||
|
fn photoshop_sharpen_without_fltr_imports_as_sharpen() {
|
||||||
|
// #6267: Photoshop's Sharpen item holds only its name, blending and `filterID`.
|
||||||
|
let item = Descriptor::new("filterFX")
|
||||||
|
.with("Nm ", Value::Text(UnicodeString::new_nul("Sharpen")))
|
||||||
|
.with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal"))))
|
||||||
|
.with("enab", Value::Boolean(true))
|
||||||
|
.with("hasoptions", Value::Boolean(false))
|
||||||
|
.with("filterID", Value::Integer(1_399_353_968));
|
||||||
|
let f = filter_from_item(&item);
|
||||||
|
assert_eq!((f.command.as_str(), &f.params), ("filter.sharpen.sharpen", &json!({})));
|
||||||
|
// Hue/Saturation with an unnumbered range and Dust & Scratches with a missing setting fail
|
||||||
|
// cleanly instead of panicking.
|
||||||
|
let bad = SmartFilter {
|
||||||
|
command: "image.adjustments.hueSaturation".into(),
|
||||||
|
params: json!({"reds": {"range": [1, "x", 3, 4]}}),
|
||||||
|
blend: BlendMode::Normal,
|
||||||
|
opacity: 1.0,
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
assert!(item_for_filter(&bad).is_err());
|
||||||
|
let dust = Descriptor::new("filterFX")
|
||||||
|
.with("Fltr", Value::Descriptor(Descriptor::new("DstS").with("Rds ", Value::Integer(3))))
|
||||||
|
.with("filterID", Value::Integer(code(b"DstS")));
|
||||||
|
assert_eq!(filter_from_item(&dust).command, UNSUPPORTED_FILTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hue_saturation_ranges_are_not_lost() {
|
||||||
|
let hue_sat = |params: J| item_for_filter(&sf("image.adjustments.hueSaturation", params));
|
||||||
|
// A range that is not an object is an error, not a silently dropped setting.
|
||||||
|
assert!(hue_sat(json!({"reds": 5})).is_err());
|
||||||
|
assert!(hue_sat(json!({"reds": null})).is_ok());
|
||||||
|
// Colorize's 25% default is the master saturation's only: a range's stays 0.
|
||||||
|
let d = hue_sat(json!({"colorize": true, "reds": {"hue": 5}})).unwrap();
|
||||||
|
let adjs = adjustment_list(get_desc(&d, "Fltr").unwrap()).unwrap();
|
||||||
|
let saturations: Vec<_> = adjs.iter().map(|a| int(a, "Strt")).collect();
|
||||||
|
assert_eq!(saturations, [Some(25), Some(0)]);
|
||||||
|
// A repeated range (or master) would overwrite the first: the filter is kept verbatim.
|
||||||
|
let entry = |range: Option<i32>| {
|
||||||
|
let e = Descriptor::new("Hst2").with("H ", Value::Integer(0)).with("Strt", Value::Integer(0)).with("Lght", Value::Integer(0));
|
||||||
|
let Some(r) = range else { return Value::Descriptor(e) };
|
||||||
|
let e = ["BgnR", "BgnS", "EndS", "EndR"].iter().fold(e.with("LclR", Value::Integer(r)), |e, k| e.with(k, Value::Integer(10)));
|
||||||
|
Value::Descriptor(e)
|
||||||
|
};
|
||||||
|
for adjs in [vec![entry(None), entry(Some(1)), entry(Some(1))], vec![entry(None), entry(None)]] {
|
||||||
|
let fltr = Descriptor::new("HStr").with("Clrz", Value::Boolean(false)).with("Adjs", Value::List(adjs));
|
||||||
|
let item = Descriptor::new("filterFX")
|
||||||
|
.with("Nm ", Value::Text(UnicodeString::new_nul("Hue/Saturation...")))
|
||||||
|
.with(
|
||||||
|
"blendOptions",
|
||||||
|
Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal"))),
|
||||||
|
)
|
||||||
|
.with("enab", Value::Boolean(true))
|
||||||
|
.with("Fltr", Value::Descriptor(fltr))
|
||||||
|
.with("filterID", Value::Integer(code(b"HStr")));
|
||||||
|
let f = filter_from_item(&item);
|
||||||
|
assert_eq!(f.command, UNSUPPORTED_FILTER);
|
||||||
|
assert_eq!(item_for_filter(&f).unwrap(), item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_filters_keep_their_descriptor() {
|
fn unknown_filters_keep_their_descriptor() {
|
||||||
let item = Descriptor::new("filterFX")
|
let item = Descriptor::new("filterFX")
|
||||||
|
|||||||
@@ -258,10 +258,20 @@ pub fn parse_vstk(data: &[u8], dpi: f32) -> Option<VstkInfo> {
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
let paint = get_desc(&d, "strokeStyleContent").and_then(content_fill).unwrap_or(Fill::Solid(photocraft_color::Color::BLACK));
|
let paint = get_desc(&d, "strokeStyleContent").and_then(content_fill).unwrap_or(Fill::Solid(photocraft_color::Color::BLACK));
|
||||||
|
let mut opacity = num(d.get("strokeStyleOpacity")).map_or(1.0, |v| v as f32 / 100.0);
|
||||||
|
// Write folds colour alpha into strokeStyleOpacity for Photoshop. Restore both Appearance
|
||||||
|
// fields when pcAl is present so PhotoCraft does not show a maxed colour alpha (#6180).
|
||||||
|
if let Fill::Solid(c) = &paint
|
||||||
|
&& c.alpha.is_finite()
|
||||||
|
&& c.alpha > 1e-6
|
||||||
|
&& (c.alpha - 1.0).abs() > 1e-6
|
||||||
|
{
|
||||||
|
opacity = (opacity / c.alpha).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
let stroke = ShapeStroke {
|
let stroke = ShapeStroke {
|
||||||
width: unit_px(d.get("strokeStyleLineWidth"), dpi).unwrap_or(1.0) as f32,
|
width: unit_px(d.get("strokeStyleLineWidth"), dpi).unwrap_or(1.0) as f32,
|
||||||
paint,
|
paint,
|
||||||
opacity: num(d.get("strokeStyleOpacity")).map_or(1.0, |v| v as f32 / 100.0),
|
opacity,
|
||||||
align,
|
align,
|
||||||
cap,
|
cap,
|
||||||
join,
|
join,
|
||||||
@@ -276,10 +286,28 @@ fn en(ty: &str, v: &str) -> Value {
|
|||||||
Value::Enumerated { type_id: Id::new(ty), value: Id::new(v) }
|
Value::Enumerated { type_id: Id::new(ty), value: Id::new(v) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photoshop's stroke colour has no alpha. Fold the colour's alpha into `strokeStyleOpacity`
|
||||||
|
/// so Appearance still matches after a PSD round-trip (#6180).
|
||||||
|
fn fold_stroke_color_alpha(s: &ShapeStroke) -> ShapeStroke {
|
||||||
|
let mut out = s.clone();
|
||||||
|
if let Fill::Solid(c) = &mut out.paint
|
||||||
|
&& c.alpha.is_finite()
|
||||||
|
&& (c.alpha - 1.0).abs() > 1e-6
|
||||||
|
{
|
||||||
|
out.opacity = (out.opacity * c.alpha).clamp(0.0, 1.0);
|
||||||
|
c.alpha = 1.0;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn vstk_bytes(stroke: Option<&ShapeStroke>, fill_enabled: bool, dpi: f32) -> Vec<u8> {
|
pub fn vstk_bytes(stroke: Option<&ShapeStroke>, fill_enabled: bool, dpi: f32) -> Vec<u8> {
|
||||||
let default = ShapeStroke::default();
|
let default = ShapeStroke::default();
|
||||||
let s = stroke.unwrap_or(&default);
|
let folded = stroke.map(fold_stroke_color_alpha);
|
||||||
let (_, content) = fill_to_desc(&s.paint);
|
let s = folded.as_ref().unwrap_or(&default);
|
||||||
|
// Keep pcAl on the original colour so Appearance can restore fill/stroke alphas separately
|
||||||
|
// from the folded Photoshop opacity (#6180).
|
||||||
|
let paint = stroke.map(|st| &st.paint).unwrap_or(&s.paint);
|
||||||
|
let (_, content) = fill_to_desc(paint);
|
||||||
let class = match s.paint {
|
let class = match s.paint {
|
||||||
Fill::Solid(_) => "solidColorLayer",
|
Fill::Solid(_) => "solidColorLayer",
|
||||||
Fill::Gradient { .. } => "gradientLayer",
|
Fill::Gradient { .. } => "gradientLayer",
|
||||||
@@ -660,6 +688,20 @@ mod tests {
|
|||||||
assert!(off.stroke.is_none() && off.fill_enabled);
|
assert!(off.stroke.is_none() && off.fill_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vstk_folds_color_alpha_into_stroke_opacity() {
|
||||||
|
let s = ShapeStroke { width: 2.0, paint: Fill::Solid(Color::rgba(0.0, 0.0, 1.0, 0.4)), opacity: 0.5, ..ShapeStroke::default() };
|
||||||
|
let bytes = vstk_bytes(Some(&s), true, 72.0);
|
||||||
|
let raw = crate::blocks::parse_prefix_versioned(&bytes).unwrap();
|
||||||
|
let file_opacity = crate::blocks::num(raw.get("strokeStyleOpacity")).unwrap() as f32 / 100.0;
|
||||||
|
assert!((file_opacity - 0.2).abs() < 1e-4, "Photoshop sees the product in strokeStyleOpacity");
|
||||||
|
let got = parse_vstk(&bytes, 72.0).unwrap().stroke.unwrap();
|
||||||
|
assert!((got.opacity - 0.5).abs() < 1e-4);
|
||||||
|
let Fill::Solid(c) = got.paint else { panic!("solid") };
|
||||||
|
assert!((c.alpha - 0.4).abs() < 1e-5);
|
||||||
|
assert!((c.to_rgb()[2] - 1.0).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vogk_roundtrip() {
|
fn vogk_roundtrip() {
|
||||||
for s in [
|
for s in [
|
||||||
|
|||||||
@@ -135,6 +135,37 @@ fn shape_layer_typed_roundtrip() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #6180: Appearance fill and stroke alphas survive PSD save/load, and a redraw
|
||||||
|
/// (move/crop) uses those values instead of snapping to opaque.
|
||||||
|
#[test]
|
||||||
|
fn rectangle_appearance_alphas_survive_psd_and_redraw() {
|
||||||
|
let mut d = doc();
|
||||||
|
let sh = ShapeLayer {
|
||||||
|
path: photocraft_vector::shapes::rect(16.0, 16.0, 128.0, 96.0),
|
||||||
|
fill: Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 0.5))),
|
||||||
|
stroke: Some(ShapeStroke { width: 4.0, paint: Fill::Solid(Color::rgba(0.0, 0.0, 1.0, 0.4)), opacity: 0.5, ..ShapeStroke::default() }),
|
||||||
|
live: Some(LiveShape::Rect { rect: [16.0, 16.0, 128.0, 96.0], radii: [0.0; 4] }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
d.layers.push(shape_layer(sh));
|
||||||
|
let b = roundtrip(&d);
|
||||||
|
let LayerContent::Shape(s) = &b.layers[1].content else { panic!("not a shape") };
|
||||||
|
let Fill::Solid(fill) = s.fill.as_ref().unwrap() else { panic!("solid fill") };
|
||||||
|
assert!((fill.alpha - 0.5).abs() < 1e-4, "fill appearance alpha {}", fill.alpha);
|
||||||
|
let st = s.stroke.as_ref().unwrap();
|
||||||
|
let Fill::Solid(stroke_c) = &st.paint else { panic!("solid stroke") };
|
||||||
|
assert!((stroke_c.alpha - 0.4).abs() < 1e-4, "stroke colour alpha {}", stroke_c.alpha);
|
||||||
|
assert!((st.opacity - 0.5).abs() < 1e-4, "stroke opacity {}", st.opacity);
|
||||||
|
let mut redrawn = s.clone();
|
||||||
|
redrawn.cache = Some(photocraft_vector::render_shape(&redrawn, photocraft_color::PixelFormat::RGBA8, Rect::new(0, 0, 256, 256)));
|
||||||
|
let Fill::Solid(fill2) = redrawn.fill.as_ref().unwrap() else { panic!("solid fill") };
|
||||||
|
assert!((fill2.alpha - 0.5).abs() < 1e-4);
|
||||||
|
let st2 = redrawn.stroke.as_ref().unwrap();
|
||||||
|
let Fill::Solid(stroke2) = &st2.paint else { panic!("solid stroke") };
|
||||||
|
assert!((stroke2.alpha - 0.4).abs() < 1e-4);
|
||||||
|
assert!((st2.opacity - 0.5).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_stroke_styles_roundtrip_through_psd_vstk() {
|
fn custom_stroke_styles_roundtrip_through_psd_vstk() {
|
||||||
for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] {
|
for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] {
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||||
|
let mut pre_px = vec![[0.0f32; 4]; w * h];
|
||||||
|
pre.read_rgba_into(r, &mut pre_px);
|
||||||
// Per-pixel match weight.
|
// Per-pixel match weight.
|
||||||
let mut weight = vec![0.0f32; w * h];
|
let mut weight = vec![0.0f32; w * h];
|
||||||
for y in r.y0..r.y1 {
|
for y in r.y0..r.y1 {
|
||||||
@@ -136,7 +138,7 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut
|
|||||||
}
|
}
|
||||||
// Match against the pre-stroke colours, so pixels recoloured by earlier dabs of
|
// Match against the pre-stroke colours, so pixels recoloured by earlier dabs of
|
||||||
// this stroke still count as the sampled colour.
|
// this stroke still count as the sampled colour.
|
||||||
let c = pre.rgba(x, y);
|
let c = pre_px[(y - r.y0) as usize * w + (x - r.x0) as usize];
|
||||||
if c[3] <= 0.0 {
|
if c[3] <= 0.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1116,7 +1116,8 @@ impl Surface {
|
|||||||
|
|
||||||
/// Composite-friendly RGBA read (converts from the surface's model).
|
/// Composite-friendly RGBA read (converts from the surface's model).
|
||||||
pub fn rgba(&self, x: i32, y: i32) -> [f32; 4] {
|
pub fn rgba(&self, x: i32, y: i32) -> [f32; 4] {
|
||||||
let p = self.pixel(x, y);
|
let mut p = [0.0f32; 5];
|
||||||
|
self.read_pixel(x, y, &mut p);
|
||||||
to_rgba(&self.format, &p)
|
to_rgba(&self.format, &p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -629,6 +629,84 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #8984: the Curves dialog has a resize grip; its graph grows with the dialog, the controls
|
||||||
|
/// stay inside it, and it reopens at the size it was left at.
|
||||||
|
#[test]
|
||||||
|
fn curves_dialog_resizes_with_its_graph_and_reopens_at_that_size() {
|
||||||
|
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
|
||||||
|
PhotocraftApp::setup_context(&h.ctx, Default::default());
|
||||||
|
let graph = |h: &Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::<egui::Rect>(dialog_mem("curves").with("curves-graph"))).unwrap();
|
||||||
|
let window = |h: &Harness<'_, PhotocraftApp>, id: u64| h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap();
|
||||||
|
let id = open(h.state_mut(), "image.adjustments.curves").unwrap();
|
||||||
|
h.run_steps(4);
|
||||||
|
let (opened, small) = (window(&h, id), graph(&h));
|
||||||
|
assert!(small.width() > 270.0 && small.width() < 300.0, "opens at the 300-point graph: {small:?}");
|
||||||
|
// Drag the grip (bottom-right corner) 300 points right and 200 down.
|
||||||
|
let corner = opened.max - egui::vec2(12.0, 12.0);
|
||||||
|
let by = egui::vec2(300.0, 200.0);
|
||||||
|
h.event(egui::Event::PointerMoved(corner));
|
||||||
|
h.step();
|
||||||
|
h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||||
|
h.step();
|
||||||
|
for i in 1..=4 {
|
||||||
|
h.event(egui::Event::PointerMoved(corner + by * (i as f32 / 4.0)));
|
||||||
|
h.step();
|
||||||
|
}
|
||||||
|
h.event(egui::Event::PointerButton { pos: corner + by, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||||
|
h.run_steps(6);
|
||||||
|
let (wide, big) = (window(&h, id), graph(&h));
|
||||||
|
assert!(big.width() > small.width() + 250.0, "the graph follows the dialog: {small:?} → {big:?}");
|
||||||
|
assert!((big.width() - big.height()).abs() < 1.0, "still square: {big:?}");
|
||||||
|
assert!(wide.contains_rect(big) && wide.right() - big.right() < 40.0, "no blank column: window {wide:?}, graph {big:?}");
|
||||||
|
for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] {
|
||||||
|
assert!(wide.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} inside the window");
|
||||||
|
}
|
||||||
|
// Cancel, open again: the size it was left at.
|
||||||
|
h.get_by_label("Cancel").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
assert!(h.state().ui.dialogs.is_empty());
|
||||||
|
let again = open(h.state_mut(), "image.adjustments.curves").unwrap();
|
||||||
|
h.run_steps(4);
|
||||||
|
assert!((graph(&h).width() - big.width()).abs() < 2.0, "reopened at {:?}, left at {big:?}", graph(&h));
|
||||||
|
assert!((window(&h, again).width() - wide.width()).abs() < 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #8984: however tall the graph's width would make it, the dialog still fits the window.
|
||||||
|
#[test]
|
||||||
|
fn curves_graph_never_pushes_the_dialog_off_a_short_window() {
|
||||||
|
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 700.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
|
||||||
|
PhotocraftApp::setup_context(&h.ctx, Default::default());
|
||||||
|
h.ctx.data_mut(|m| m.insert_persisted(egui::Id::new(("dialog-place", "image.adjustments.curves")).with("size"), egui::vec2(1200.0, 1200.0)));
|
||||||
|
let id = open(h.state_mut(), "image.adjustments.curves").unwrap();
|
||||||
|
h.run_steps(5);
|
||||||
|
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(dialog_mem("curves").with("curves-graph"))).unwrap();
|
||||||
|
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap();
|
||||||
|
assert!(graph.height() <= 700.0 - 360.0, "{graph:?}");
|
||||||
|
assert!(window.height() <= 700.0, "{window:?}");
|
||||||
|
assert!(window.contains_rect(h.get_by_label("OK").rect()));
|
||||||
|
// The size the window squeezed it to is not remembered: a larger window gets 1200 again.
|
||||||
|
let kept = h.ctx.data_mut(|m| m.get_persisted::<egui::Vec2>(egui::Id::new(("dialog-place", "image.adjustments.curves")).with("size")));
|
||||||
|
assert_eq!(kept, Some(egui::vec2(1200.0, 1200.0)));
|
||||||
|
// Dragged far wider, the dialog stops where the graph stops: no blank column.
|
||||||
|
let corner = window.max - egui::vec2(12.0, 12.0);
|
||||||
|
h.event(egui::Event::PointerMoved(corner));
|
||||||
|
h.step();
|
||||||
|
h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||||
|
h.step();
|
||||||
|
h.event(egui::Event::PointerMoved(corner + egui::vec2(600.0, 0.0)));
|
||||||
|
h.step();
|
||||||
|
h.event(egui::Event::PointerButton {
|
||||||
|
pos: corner + egui::vec2(600.0, 0.0),
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
|
pressed: false,
|
||||||
|
modifiers: egui::Modifiers::NONE,
|
||||||
|
});
|
||||||
|
h.run_steps(5);
|
||||||
|
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(dialog_mem("curves").with("curves-graph"))).unwrap();
|
||||||
|
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap();
|
||||||
|
assert!(window.right() - graph.right() < 40.0, "no blank column: window {window:?}, graph {graph:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancel_leaves_the_document_untouched() {
|
fn cancel_leaves_the_document_untouched() {
|
||||||
let mut harness =
|
let mut harness =
|
||||||
|
|||||||
@@ -357,6 +357,27 @@ fn gradient_bar(p: &egui::Painter, r: Rect, from: Color32, to: Color32, vertical
|
|||||||
const CURVE_HANDLE_SIZE: f32 = 7.0;
|
const CURVE_HANDLE_SIZE: f32 = 7.0;
|
||||||
const CURVE_GRAPH_PADDING: f32 = 5.0;
|
const CURVE_GRAPH_PADDING: f32 = 5.0;
|
||||||
|
|
||||||
|
/// The room left on screen for the Curves dialog's other rows (title, channel and mode, Input and
|
||||||
|
/// Output, eyedroppers, buttons) when its graph is as tall as it can be.
|
||||||
|
const CURVES_CHROME: f32 = 360.0;
|
||||||
|
/// The smallest and the default largest side of the Curves graph: 300 points was the fixed
|
||||||
|
/// size until #8984.
|
||||||
|
const CURVE_SIDE: (f32, f32) = (120.0, 300.0);
|
||||||
|
|
||||||
|
/// The Curves graph's side (#8984): the width it is given, in the Properties panel and in the
|
||||||
|
/// resizable dialog, and no taller than the window leaves room for beside the other rows.
|
||||||
|
fn curve_side(ui: &egui::Ui) -> f32 {
|
||||||
|
let tallest = curve_side_max(ui.ctx());
|
||||||
|
let width = ui.available_width();
|
||||||
|
if width.is_finite() { width.clamp(CURVE_SIDE.0, tallest) } else { CURVE_SIDE.1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The largest the Curves graph gets in this window: as tall as the window leaves room for beside
|
||||||
|
/// the dialog's other rows, and never under the old 300 points. The Curves dialog is no wider.
|
||||||
|
pub(crate) fn curve_side_max(ctx: &egui::Context) -> f32 {
|
||||||
|
(ctx.content_rect().height() - CURVES_CHROME).max(CURVE_SIDE.1)
|
||||||
|
}
|
||||||
|
|
||||||
fn curve_graph(full: Rect, side: f32) -> Rect {
|
fn curve_graph(full: Rect, side: f32) -> Rect {
|
||||||
let base = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
|
let base = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
|
||||||
Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING))
|
Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING))
|
||||||
@@ -461,7 +482,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
|||||||
let pencil = v.get("pencil").and_then(Value::as_bool).unwrap_or(false);
|
let pencil = v.get("pencil").and_then(Value::as_bool).unwrap_or(false);
|
||||||
let mut pts = read_curve(v, key);
|
let mut pts = read_curve(v, key);
|
||||||
let mut map = read_map(v, key);
|
let mut map = read_map(v, key);
|
||||||
let side = ui.available_width().clamp(120.0, 300.0);
|
let side = curve_side(ui);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
let (full, resp) = ui.allocate_exact_size(vec2(side, side + 14.0), Sense::click_and_drag());
|
let (full, resp) = ui.allocate_exact_size(vec2(side, side + 14.0), Sense::click_and_drag());
|
||||||
let graph = curve_graph(full, side);
|
let graph = curve_graph(full, side);
|
||||||
|
|||||||
@@ -206,6 +206,27 @@ fn curve_of(a: &Adjustment) -> Vec<[f32; 2]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #8984: the Curves graph in Properties grows with the panel instead of stopping at 300
|
||||||
|
/// points, stays square, and edits where it is clicked at its new size.
|
||||||
|
#[test]
|
||||||
|
fn curves_graph_grows_with_the_properties_panel() {
|
||||||
|
let mut h = app_harness("curves", "rgb", 8);
|
||||||
|
let (id, _) = layer(&h);
|
||||||
|
let narrow = curves_graph(&h, id);
|
||||||
|
assert!(narrow.width() < 300.0, "{narrow:?}");
|
||||||
|
crate::panels::request_dock_width(&h.ctx, 760.0);
|
||||||
|
h.run_steps(8);
|
||||||
|
let wide = curves_graph(&h, id);
|
||||||
|
// 760 points of panel, but the 900-point window leaves room for 540 beside the other rows.
|
||||||
|
assert!(wide.width() > narrow.width() + 250.0 && wide.height() <= 900.0 - 360.0, "{narrow:?} → {wide:?}");
|
||||||
|
assert!((wide.width() - wide.height()).abs() < 1.0, "square: {wide:?}");
|
||||||
|
// A point added at the graph's centre lands at 128, 128 of the curve.
|
||||||
|
click(&mut h, wide.center());
|
||||||
|
let c = curve_of(&layer(&h).1);
|
||||||
|
assert_eq!(c.len(), 3, "{c:?}");
|
||||||
|
assert!((c[1][0] - 127.5).abs() < 2.0 && (c[1][1] - 127.5).abs() < 2.0, "{c:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// Issue #43: pressing on a point selects it (no new point), Delete/Backspace and ⌘/Ctrl-click
|
/// Issue #43: pressing on a point selects it (no new point), Delete/Backspace and ⌘/Ctrl-click
|
||||||
/// remove points, dragging off the graph removes one, endpoints stay.
|
/// remove points, dragging off the graph removes one, endpoints stay.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1445,6 +1445,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
|
|||||||
}
|
}
|
||||||
if let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview)
|
if let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview)
|
||||||
&& app.session.active_index() == Some(idx)
|
&& app.session.active_index() == Some(idx)
|
||||||
|
&& pv.doc.id == st.doc.id
|
||||||
&& st.doc.layer(photocraft_doc::LayerId(t.layer)).is_some()
|
&& st.doc.layer(photocraft_doc::LayerId(t.layer)).is_some()
|
||||||
{
|
{
|
||||||
return (pv.doc.clone(), (1 << 40) + pv.session);
|
return (pv.doc.clone(), (1 << 40) + pv.session);
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
|||||||
// Photoshop updates the colour on the press itself, not only when the button is released
|
// Photoshop updates the colour on the press itself, not only when the button is released
|
||||||
// (#6937): `is_pointer_button_down_on` is true from the press frame onwards (it covers
|
// (#6937): `is_pointer_button_down_on` is true from the press frame onwards (it covers
|
||||||
// dragging too), while `clicked` fires on release and `dragged` only after a move.
|
// dragging too), while `clicked` fires on release and `dragged` only after a move.
|
||||||
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on() || resp.clicked()) {
|
if let Some(p) = widgets::pick_pos(&resp) {
|
||||||
let (x, y) = (((p.x - field.left()) / field.width()).clamp(0.0, 1.0), ((p.y - field.top()) / field.height()).clamp(0.0, 1.0));
|
let (x, y) = (((p.x - field.left()) / field.width()).clamp(0.0, 1.0), ((p.y - field.top()) / field.height()).clamp(0.0, 1.0));
|
||||||
let c = field_color(&mode, fz, x, y);
|
let c = field_color(&mode, fz, x, y);
|
||||||
let keep = match mode.as_str() {
|
let keep = match mode.as_str() {
|
||||||
@@ -503,7 +503,7 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
|||||||
let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)];
|
let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)];
|
||||||
ui.painter().add(egui::Shape::convex_polygon(tri, t.text, Stroke::NONE));
|
ui.painter().add(egui::Shape::convex_polygon(tri, t.text, Stroke::NONE));
|
||||||
}
|
}
|
||||||
if let Some(p) = sresp.interact_pointer_pos().filter(|_| sresp.is_pointer_button_down_on() || sresp.clicked()) {
|
if let Some(p) = widgets::pick_pos(&sresp) {
|
||||||
let z = 1.0 - ((p.y - strip.top()) / strip.height()).clamp(0.0, 1.0);
|
let z = 1.0 - ((p.y - strip.top()) / strip.height()).clamp(0.0, 1.0);
|
||||||
let h = match mode.as_str() {
|
let h = match mode.as_str() {
|
||||||
"h" => Some([z * 360.0, hsv[1], hsv[2]]),
|
"h" => Some([z * 360.0, hsv[1], hsv[2]]),
|
||||||
@@ -863,6 +863,95 @@ mod tests {
|
|||||||
h.state().get("color").and_then(Value::as_str).unwrap().to_string()
|
h.state().get("color").and_then(Value::as_str).unwrap().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Press at `at` and hold, with no release and no time passing, then run one frame.
|
||||||
|
fn press<S>(h: &mut Harness<'static, S>, at: egui::Pos2) {
|
||||||
|
press_button(h, at, egui::PointerButton::Primary, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_button<S>(h: &mut Harness<'static, S>, at: egui::Pos2, button: egui::PointerButton, pressed: bool) {
|
||||||
|
h.event(egui::Event::PointerMoved(at));
|
||||||
|
h.event(egui::Event::PointerButton { pos: at, button, pressed, modifiers: egui::Modifiers::NONE });
|
||||||
|
h.run_steps(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #7793: the field and the slider pick on the frame the button goes down, not after the
|
||||||
|
/// pointer has travelled past egui's click threshold or been held for a while, and follow
|
||||||
|
/// moves of a few points.
|
||||||
|
#[test]
|
||||||
|
fn field_and_slider_pick_on_press_and_follow_small_moves() {
|
||||||
|
let mut f = Map::new();
|
||||||
|
f.insert("color".into(), json!("#336699"));
|
||||||
|
let mut h = Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(
|
||||||
|
|ui, f: &mut Map<String, Value>| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let o = ui.cursor().min;
|
||||||
|
let gap = ui.spacing().item_spacing.x;
|
||||||
|
ui.data_mut(|d| d.insert_temp(egui::Id::new("test-origin"), (o, gap)));
|
||||||
|
color_field(ui, f);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
);
|
||||||
|
h.run_steps(2);
|
||||||
|
let (o, gap) = h.ctx.data(|d| d.get_temp::<(egui::Pos2, f32)>(egui::Id::new("test-origin"))).unwrap();
|
||||||
|
// Field: saturation across, brightness down (hue mode).
|
||||||
|
let at = o + vec2(FIELD * 0.5, FIELD * 0.5);
|
||||||
|
press(&mut h, at);
|
||||||
|
let picked = color(&h);
|
||||||
|
assert_ne!(picked, "#336699", "the press itself picks");
|
||||||
|
let hsv = rgb_to_hsb(parse_hex(&picked).unwrap());
|
||||||
|
assert!((hsv[1] - 0.5).abs() < 0.02 && (hsv[2] - 0.5).abs() < 0.02, "{picked} {hsv:?}");
|
||||||
|
// A 3-point nudge (under the 6-point click threshold) moves the pick.
|
||||||
|
h.event(egui::Event::PointerMoved(at + vec2(3.0, 0.0)));
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_ne!(color(&h), picked, "a small drag adjusts the pick");
|
||||||
|
h.event(egui::Event::PointerButton {
|
||||||
|
pos: at + vec2(3.0, 0.0),
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
|
pressed: false,
|
||||||
|
modifiers: egui::Modifiers::NONE,
|
||||||
|
});
|
||||||
|
h.run_steps(2);
|
||||||
|
// Hue slider: a press near the bottom picks a hue near 0 at once.
|
||||||
|
let before = color(&h);
|
||||||
|
press(&mut h, o + vec2(FIELD + gap + 9.0, FIELD * 0.95));
|
||||||
|
assert_ne!(color(&h), before, "the slider picks on press");
|
||||||
|
let hue = rgb_to_hsb(parse_hex(&color(&h)).unwrap())[0];
|
||||||
|
assert!(hue < 30.0 || hue > 330.0, "hue {hue}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the primary button picks: a right or middle press (and its release) on the field or
|
||||||
|
/// the slider leaves the colour alone.
|
||||||
|
#[test]
|
||||||
|
fn field_and_slider_ignore_secondary_and_middle_presses() {
|
||||||
|
let mut f = Map::new();
|
||||||
|
f.insert("color".into(), json!("#336699"));
|
||||||
|
let mut h = Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(
|
||||||
|
|ui, f: &mut Map<String, Value>| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let o = ui.cursor().min;
|
||||||
|
let gap = ui.spacing().item_spacing.x;
|
||||||
|
ui.data_mut(|d| d.insert_temp(egui::Id::new("test-origin"), (o, gap)));
|
||||||
|
color_field(ui, f);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
);
|
||||||
|
h.run_steps(2);
|
||||||
|
let (o, gap) = h.ctx.data(|d| d.get_temp::<(egui::Pos2, f32)>(egui::Id::new("test-origin"))).unwrap();
|
||||||
|
for at in [o + vec2(FIELD * 0.5, FIELD * 0.5), o + vec2(FIELD + gap + 9.0, FIELD * 0.95)] {
|
||||||
|
for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
|
||||||
|
press_button(&mut h, at, button, true);
|
||||||
|
assert_eq!(color(&h), "#336699", "{button:?} press at {at:?}");
|
||||||
|
h.event(egui::Event::PointerMoved(at + vec2(3.0, 0.0)));
|
||||||
|
h.run_steps(1);
|
||||||
|
press_button(&mut h, at + vec2(3.0, 0.0), button, false);
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_eq!(color(&h), "#336699", "{button:?} drag and release at {at:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hex_field_shows_uppercase_without_rewriting_the_selected_color() {
|
fn hex_field_shows_uppercase_without_rewriting_the_selected_color() {
|
||||||
let h = picker("#12abef");
|
let h = picker("#12abef");
|
||||||
|
|||||||
@@ -226,15 +226,21 @@ pub fn clear(o: &mut ToolOptions) {
|
|||||||
o.crop_resolution.clear();
|
o.crop_resolution.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Swaps W and H (the ⇄ button, X). False outside W x H x Resolution mode.
|
/// Swaps W and H for the Crop tool's X. False outside W x H x Resolution mode.
|
||||||
pub fn swap(o: &mut ToolOptions) -> bool {
|
pub fn swap(o: &mut ToolOptions) -> bool {
|
||||||
if o.crop_ratio != WHR {
|
if o.crop_ratio != WHR {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::mem::swap(&mut o.crop_width, &mut o.crop_height);
|
swap_fields(o);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ⇄ button between the W and H fields: always swaps, as Perspective Crop shows the fields
|
||||||
|
/// without the Crop tool's W x H x Resolution mode.
|
||||||
|
fn swap_fields(o: &mut ToolOptions) {
|
||||||
|
std::mem::swap(&mut o.crop_width, &mut o.crop_height);
|
||||||
|
}
|
||||||
|
|
||||||
/// After the preset dropdown chose `crop_ratio`: a size preset fills the fields and Front Image
|
/// After the preset dropdown chose `crop_ratio`: a size preset fills the fields and Front Image
|
||||||
/// reads the document, both leaving W x H x Resolution mode on.
|
/// reads the document, both leaving W x H x Resolution mode on.
|
||||||
pub fn chosen(app: &mut PhotocraftApp) {
|
pub fn chosen(app: &mut PhotocraftApp) {
|
||||||
@@ -298,7 +304,7 @@ fn text_field(ui: &mut egui::Ui, id: &str, value: &mut String, hint: &str, width
|
|||||||
pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) {
|
pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) {
|
||||||
text_field(ui, "crop-width", &mut o.crop_width, tl!("Width"), 64.0, normalize_length);
|
text_field(ui, "crop-width", &mut o.crop_width, tl!("Width"), 64.0, normalize_length);
|
||||||
if crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() {
|
if crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() {
|
||||||
swap(o);
|
swap_fields(o);
|
||||||
}
|
}
|
||||||
text_field(ui, "crop-height", &mut o.crop_height, tl!("Height"), 64.0, normalize_length);
|
text_field(ui, "crop-height", &mut o.crop_height, tl!("Height"), 64.0, normalize_length);
|
||||||
text_field(ui, "crop-resolution", &mut o.crop_resolution, tl!("Resolution"), 64.0, normalize_resolution);
|
text_field(ui, "crop-resolution", &mut o.crop_resolution, tl!("Resolution"), 64.0, normalize_resolution);
|
||||||
|
|||||||
@@ -181,16 +181,47 @@ fn resizable_dialog(fields: &serde_json::Map<String, Value>) -> Option<egui::Vec
|
|||||||
Some(crate::prefs_ui::PREFERENCES_SIZE)
|
Some(crate::prefs_ui::PREFERENCES_SIZE)
|
||||||
} else if fields.contains_key("__web") {
|
} else if fields.contains_key("__web") {
|
||||||
Some(egui::vec2(960.0, 900.0))
|
Some(egui::vec2(960.0, 900.0))
|
||||||
|
} else if is_curves(fields) {
|
||||||
|
Some(CURVES_SIZE)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `fields` are the Image › Adjustments › Curves dialog's.
|
||||||
|
fn is_curves(fields: &serde_json::Map<String, Value>) -> bool {
|
||||||
|
fields.get("__adjust").and_then(Value::as_str) == Some("curves")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size the Curves dialog first opens at: the 300-point graph it had before it could be
|
||||||
|
/// resized (#5113); it grows to fit its controls below the graph.
|
||||||
|
const CURVES_SIZE: egui::Vec2 = egui::vec2(300.0, 420.0);
|
||||||
|
/// The smallest a resizable dialog is dragged to: Curves keeps a usable graph (#8984), the others
|
||||||
|
/// their two-column layouts.
|
||||||
|
fn resizable_min(fields: &serde_json::Map<String, Value>) -> egui::Vec2 {
|
||||||
|
if is_curves(fields) { egui::vec2(300.0, 420.0) } else { egui::vec2(560.0, 400.0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the size the user last gave a resizable dialog is kept (#8984), beside its place.
|
||||||
|
fn size_id(place: egui::Id) -> egui::Id {
|
||||||
|
place.with("size")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The largest a resizable dialog of `fields` with smallest size `min` is dragged to: the window
|
||||||
|
/// less a margin, and for Curves no wider than its graph can grow (#8984), so no blank column.
|
||||||
|
fn resizable_max(ctx: &egui::Context, fields: &serde_json::Map<String, Value>, min: egui::Vec2) -> egui::Vec2 {
|
||||||
|
let mut max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(min);
|
||||||
|
if is_curves(fields) {
|
||||||
|
max.x = max.x.min(crate::adjust_editors::curve_side_max(ctx)).max(min.x);
|
||||||
|
}
|
||||||
|
max
|
||||||
|
}
|
||||||
|
|
||||||
fn show_modal<T>(
|
fn show_modal<T>(
|
||||||
modal: egui::Modal,
|
modal: egui::Modal,
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
id: egui::Id,
|
id: egui::Id,
|
||||||
resizable: Option<egui::Vec2>,
|
resizable: Option<(egui::Vec2, egui::Vec2, egui::Vec2)>,
|
||||||
interactive: bool,
|
interactive: bool,
|
||||||
contents: impl FnOnce(&mut egui::Ui) -> T,
|
contents: impl FnOnce(&mut egui::Ui) -> T,
|
||||||
) -> egui::ModalResponse<T> {
|
) -> egui::ModalResponse<T> {
|
||||||
@@ -199,17 +230,10 @@ fn show_modal<T>(
|
|||||||
if !interactive {
|
if !interactive {
|
||||||
ui.disable();
|
ui.disable();
|
||||||
}
|
}
|
||||||
if let Some(size) = resizable {
|
if let Some((size, min, max)) = resizable {
|
||||||
let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0));
|
|
||||||
// No stroke: Resize outlines its contents 2 points out, an inner box inside the
|
// No stroke: Resize outlines its contents 2 points out, an inner box inside the
|
||||||
// dialog's frame that left the title and buttons flush against it (#8199).
|
// dialog's frame that left the title and buttons flush against it (#8199).
|
||||||
egui::Resize::default()
|
egui::Resize::default().id(id.with("resize")).with_stroke(false).default_size(size.min(max)).min_size(min).max_size(max).show(ui, contents)
|
||||||
.id(id.with("resize"))
|
|
||||||
.with_stroke(false)
|
|
||||||
.default_size(size.min(max))
|
|
||||||
.min_size(egui::vec2(560.0, 400.0))
|
|
||||||
.max_size(max)
|
|
||||||
.show(ui, contents)
|
|
||||||
} else {
|
} else {
|
||||||
contents(ui)
|
contents(ui)
|
||||||
}
|
}
|
||||||
@@ -289,14 +313,22 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||||||
let web = fields.contains_key("__web");
|
let web = fields.contains_key("__web");
|
||||||
let web_popup_open = web && egui::Popup::is_any_open(ctx);
|
let web_popup_open = web && egui::Popup::is_any_open(ctx);
|
||||||
let resizable = resizable_dialog(&fields);
|
let resizable = resizable_dialog(&fields);
|
||||||
let modal = show_modal(modal, ctx, id, resizable, interactive, |ui| {
|
// Curves reopens at the size it was left at (#8984).
|
||||||
|
let remembered = if is_curves(&fields) { ctx.data_mut(|m| m.get_persisted::<egui::Vec2>(size_id(place))) } else { None };
|
||||||
|
let resize = resizable.map(|size| {
|
||||||
|
let min = resizable_min(&fields);
|
||||||
|
(remembered.unwrap_or(size), min, resizable_max(ctx, &fields, min))
|
||||||
|
});
|
||||||
|
let mut given = None;
|
||||||
|
let modal = show_modal(modal, ctx, id, resize, interactive, |ui| {
|
||||||
sizing = ui.is_sizing_pass();
|
sizing = ui.is_sizing_pass();
|
||||||
let available = ui.available_size();
|
let available = ui.available_size();
|
||||||
|
given = Some(available);
|
||||||
// A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title
|
// A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title
|
||||||
// rule and the button row fill the width they are given, so it needs its own maximum too.
|
// rule and the button row fill the width they are given, so it needs its own maximum too.
|
||||||
let compact = d.fields.contains_key("__compact");
|
let compact = d.fields.contains_key("__compact");
|
||||||
// The curve editor caps its graph allocation at 300 points (#5113).
|
// The curve editor's graph is as wide as the dialog lets it be (#5113, #8984).
|
||||||
let curves = fields.get("__adjust").and_then(Value::as_str) == Some("curves");
|
let curves = is_curves(&fields);
|
||||||
if !compact {
|
if !compact {
|
||||||
ui.set_min_width(if curves { 300.0 } else { 380.0 });
|
ui.set_min_width(if curves { 300.0 } else { 380.0 });
|
||||||
}
|
}
|
||||||
@@ -323,7 +355,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||||||
} else if compact {
|
} else if compact {
|
||||||
260.0
|
260.0
|
||||||
} else if curves {
|
} else if curves {
|
||||||
300.0
|
available.x.max(300.0)
|
||||||
} else {
|
} else {
|
||||||
440.0
|
440.0
|
||||||
}));
|
}));
|
||||||
@@ -582,6 +614,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||||||
});
|
});
|
||||||
// The picker has a larger frame margin; include that whole frame in the canvas hit guard.
|
// The picker has a larger frame margin; include that whole frame in the canvas hit guard.
|
||||||
shown.push(if color_picker { modal.response.rect } else { modal.inner });
|
shown.push(if color_picker { modal.response.rect } else { modal.inner });
|
||||||
|
// The size Curves was given (dragged or grown to fit) is the one it reopens at (#8984).
|
||||||
|
// A size at the window's limit is the window's, not the user's: a large remembered size
|
||||||
|
// squeezed into a small window is kept for a larger one.
|
||||||
|
if let (false, true, Some(size), Some((opened, _, max))) = (sizing, is_curves(&fields), given.filter(|g| g.is_finite()), resize)
|
||||||
|
&& size != opened.min(max)
|
||||||
|
&& size.x < max.x - 0.5
|
||||||
|
&& size.y < max.y - 0.5
|
||||||
|
&& remembered != Some(size)
|
||||||
|
{
|
||||||
|
ctx.data_mut(|m| m.insert_persisted(size_id(place), size));
|
||||||
|
}
|
||||||
// Pin once laid out at its real size (the first frame is an invisible sizing pass).
|
// Pin once laid out at its real size (the first frame is an invisible sizing pass).
|
||||||
if !sizing && (web || pinned.is_none() || drag != egui::Vec2::ZERO) {
|
if !sizing && (web || pinned.is_none() || drag != egui::Vec2::ZERO) {
|
||||||
let screen = ctx.content_rect();
|
let screen = ctx.content_rect();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use egui::{Color32, ColorImage};
|
use egui::{Color32, ColorImage};
|
||||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
use photocraft_doc::{DocId, Document, LayerContent, LayerId};
|
||||||
use photocraft_geom::Rect;
|
use photocraft_geom::Rect;
|
||||||
use photocraft_raster::Surface;
|
use photocraft_raster::Surface;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -91,15 +91,50 @@ pub(crate) fn surface_image(surf: &Surface, r: Rect, max_side: usize) -> ColorIm
|
|||||||
ColorImage::new([tw, th], px)
|
ColorImage::new([tw, th], px)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_doc(app: &PhotocraftApp) -> Option<DocId> {
|
||||||
|
app.session.active().map(|st| st.doc.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_doc(app: &PhotocraftApp, document: DocId) -> bool {
|
||||||
|
active_doc(app) == Some(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops Liquify / Puppet / Perspective Warp / Vanishing Point / Filter Gallery when the
|
||||||
|
/// document they were opened on is no longer active, so they cannot steal undo, block layer
|
||||||
|
/// delete, or draw another document's pixels (#5115).
|
||||||
|
pub fn cancel_stale(app: &mut PhotocraftApp) {
|
||||||
|
let active = active_doc(app);
|
||||||
|
if app.distort.liquify.as_ref().is_some_and(|d| Some(d.document) != active) {
|
||||||
|
crate::liquify_ui::cancel(app);
|
||||||
|
}
|
||||||
|
if app.distort.puppet.as_ref().is_some_and(|p| Some(p.document) != active) {
|
||||||
|
app.distort.puppet = None;
|
||||||
|
}
|
||||||
|
if app.distort.perspective.as_ref().is_some_and(|p| Some(p.document) != active) {
|
||||||
|
app.distort.perspective = None;
|
||||||
|
}
|
||||||
|
if app.distort.vanishing.as_ref().is_some_and(|v| Some(v.document) != active) {
|
||||||
|
app.distort.vanishing = None;
|
||||||
|
}
|
||||||
|
if app.distort.gallery.as_ref().is_some_and(|g| Some(g.document) != active) {
|
||||||
|
app.distort.gallery = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The document shown on the canvas while Puppet or Perspective Warp previews a layer.
|
/// The document shown on the canvas while Puppet or Perspective Warp previews a layer.
|
||||||
pub fn display_doc(app: &PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
|
pub fn display_doc(app: &PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
|
||||||
if app.session.active_index() != Some(idx) {
|
if app.session.active_index() != Some(idx) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if let Some(p) = &app.distort.puppet {
|
let id = app.session.documents().get(idx).map(|st| st.doc.id)?;
|
||||||
|
if let Some(p) = &app.distort.puppet
|
||||||
|
&& p.document == id
|
||||||
|
{
|
||||||
return Some((p.preview_doc.clone(), (1 << 42) + p.key));
|
return Some((p.preview_doc.clone(), (1 << 42) + p.key));
|
||||||
}
|
}
|
||||||
if let Some(p) = &app.distort.perspective {
|
if let Some(p) = &app.distort.perspective
|
||||||
|
&& p.document == id
|
||||||
|
{
|
||||||
return Some((p.preview_doc.clone(), (1 << 42) + p.key));
|
return Some((p.preview_doc.clone(), (1 << 42) + p.key));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -137,23 +172,24 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
|||||||
|
|
||||||
/// Pointer events (document coordinates) while a mode is active. True when consumed.
|
/// Pointer events (document coordinates) while a mode is active. True when consumed.
|
||||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
||||||
|
cancel_stale(app);
|
||||||
if app.distort.caf.is_some() {
|
if app.distort.caf.is_some() {
|
||||||
crate::caf_ui::pointer(app, ev, mods);
|
crate::caf_ui::pointer(app, ev, mods);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if app.distort.liquify.is_some() {
|
if app.distort.liquify.as_ref().is_some_and(|d| on_doc(app, d.document)) {
|
||||||
crate::liquify_ui::pointer(app, ev, mods);
|
crate::liquify_ui::pointer(app, ev, mods);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if app.distort.puppet.is_some() {
|
if app.distort.puppet.as_ref().is_some_and(|p| on_doc(app, p.document)) {
|
||||||
crate::puppet_ui::pointer(app, ev, mods);
|
crate::puppet_ui::pointer(app, ev, mods);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if app.distort.perspective.is_some() {
|
if app.distort.perspective.as_ref().is_some_and(|p| on_doc(app, p.document)) {
|
||||||
crate::perspective_ui::pointer(app, ev, mods);
|
crate::perspective_ui::pointer(app, ev, mods);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if app.distort.vanishing.is_some() {
|
if app.distort.vanishing.as_ref().is_some_and(|v| on_doc(app, v.document)) {
|
||||||
crate::vp_ui::pointer(app, ev, mods);
|
crate::vp_ui::pointer(app, ev, mods);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -164,6 +200,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
|||||||
/// of the rest of the app should be skipped.
|
/// of the rest of the app should be skipped.
|
||||||
pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||||
use egui::{Key, Modifiers};
|
use egui::{Key, Modifiers};
|
||||||
|
cancel_stale(app);
|
||||||
let enter = |ctx: &egui::Context| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter));
|
let enter = |ctx: &egui::Context| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter));
|
||||||
let esc = |ctx: &egui::Context| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape));
|
let esc = |ctx: &egui::Context| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape));
|
||||||
if app.distort.gallery.is_some() {
|
if app.distort.gallery.is_some() {
|
||||||
@@ -245,19 +282,26 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
|
|||||||
|
|
||||||
/// Canvas overlay for the on-canvas modes: pins, planes and meshes over the guides.
|
/// Canvas overlay for the on-canvas modes: pins, planes and meshes over the guides.
|
||||||
pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
||||||
if let Some(p) = &app.distort.puppet {
|
if let Some(p) = &app.distort.puppet
|
||||||
|
&& on_doc(app, p.document)
|
||||||
|
{
|
||||||
crate::puppet_ui::draw(p, painter, xf);
|
crate::puppet_ui::draw(p, painter, xf);
|
||||||
}
|
}
|
||||||
if let Some(p) = &app.distort.perspective {
|
if let Some(p) = &app.distort.perspective
|
||||||
|
&& on_doc(app, p.document)
|
||||||
|
{
|
||||||
crate::perspective_ui::draw(p, painter, xf);
|
crate::perspective_ui::draw(p, painter, xf);
|
||||||
}
|
}
|
||||||
if let Some(v) = &app.distort.vanishing {
|
if let Some(v) = &app.distort.vanishing
|
||||||
|
&& on_doc(app, v.document)
|
||||||
|
{
|
||||||
crate::vp_ui::draw(v, painter, xf);
|
crate::vp_ui::draw(v, painter, xf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options bar for the on-canvas modes. True when drawn.
|
/// Options bar for the on-canvas modes. True when drawn.
|
||||||
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool {
|
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool {
|
||||||
|
cancel_stale(app);
|
||||||
if app.distort.puppet.is_some() {
|
if app.distort.puppet.is_some() {
|
||||||
crate::puppet_ui::options_bar(app, ui);
|
crate::puppet_ui::options_bar(app, ui);
|
||||||
return true;
|
return true;
|
||||||
@@ -295,6 +339,7 @@ pub fn drop_foreign(app: &mut PhotocraftApp) {
|
|||||||
|
|
||||||
/// Full-window dialogs (Liquify, Content-Aware Fill, Filter Gallery).
|
/// Full-window dialogs (Liquify, Content-Aware Fill, Filter Gallery).
|
||||||
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||||
|
cancel_stale(app);
|
||||||
drop_foreign(app);
|
drop_foreign(app);
|
||||||
if app.distort.caf.is_some() {
|
if app.distort.caf.is_some() {
|
||||||
crate::caf_ui::show(app, ctx);
|
crate::caf_ui::show(app, ctx);
|
||||||
@@ -312,3 +357,93 @@ pub(crate) fn tolerance(app: &PhotocraftApp) -> f64 {
|
|||||||
let z = app.point_zoom().max(0.01);
|
let z = app.point_zoom().max(0.01);
|
||||||
8.0 / f64::from(z)
|
8.0 / f64::from(z)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use photocraft_geom::Rect;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn paint_bar(app: &mut PhotocraftApp) {
|
||||||
|
app.session
|
||||||
|
.edit("paint", |doc, active| {
|
||||||
|
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 30, 80, 50), &[1.0, 0.0, 0.0, 1.0]);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #5115: Puppet Warp is per-document. Switching tabs drops the session so Ctrl+Z and
|
||||||
|
/// layer delete apply to the new document, and the warp mesh does not leak onto it.
|
||||||
|
#[test]
|
||||||
|
fn switching_documents_drops_puppet_and_restores_undo_and_delete() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||||
|
app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap();
|
||||||
|
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||||
|
paint_bar(&mut app);
|
||||||
|
app.sync_views();
|
||||||
|
menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap();
|
||||||
|
pointer(&mut app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||||
|
pointer(&mut app, ToolEvent::Up { x: 50.0, y: 40.0 }, egui::Modifiers::NONE);
|
||||||
|
assert!(app.distort.puppet.is_some());
|
||||||
|
let first = app.session.active().unwrap().doc.id;
|
||||||
|
app.session.execute("file.new", json!({"width": 60, "height": 40, "depth": 8})).unwrap();
|
||||||
|
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||||
|
app.sync_views();
|
||||||
|
assert!(app.distort.puppet.is_none(), "puppet does not follow the new document");
|
||||||
|
assert!(display_doc(&app, app.session.active_index().unwrap()).is_none());
|
||||||
|
let layers_before = app.session.active().unwrap().doc.layers.len();
|
||||||
|
app.session.execute("layer.delete", json!({})).unwrap();
|
||||||
|
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers_before - 1);
|
||||||
|
assert!(app.session.set_active(0));
|
||||||
|
app.sync_views();
|
||||||
|
assert_eq!(app.session.active().unwrap().doc.id, first);
|
||||||
|
let past = app.session.active().unwrap().history.past_len();
|
||||||
|
app.session.execute("edit.undo", json!({})).unwrap();
|
||||||
|
assert_eq!(app.session.active().unwrap().history.past_len(), past - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_puppet_is_one_undo_step_and_the_layer_stays_deletable() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||||
|
app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap();
|
||||||
|
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||||
|
paint_bar(&mut app);
|
||||||
|
app.sync_views();
|
||||||
|
menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap();
|
||||||
|
pointer(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||||
|
pointer(&mut app, ToolEvent::Up { x: 25.0, y: 40.0 }, egui::Modifiers::NONE);
|
||||||
|
pointer(&mut app, ToolEvent::Down { x: 75.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||||
|
pointer(&mut app, ToolEvent::Move { x: 75.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||||
|
pointer(&mut app, ToolEvent::Up { x: 75.0, y: 20.0 }, egui::Modifiers::NONE);
|
||||||
|
let before = app.session.active().unwrap().history.past_len();
|
||||||
|
crate::puppet_ui::commit(&mut app);
|
||||||
|
assert!(app.distort.puppet.is_none());
|
||||||
|
assert_eq!(app.session.active().unwrap().history.past_len(), before + 1);
|
||||||
|
app.session.execute("edit.undo", json!({})).unwrap();
|
||||||
|
assert_eq!(app.session.active().unwrap().history.past_len(), before);
|
||||||
|
let layers = app.session.active().unwrap().doc.layers.len();
|
||||||
|
app.session.execute("layer.delete", json!({})).unwrap();
|
||||||
|
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switching_documents_drops_liquify() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||||
|
app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap();
|
||||||
|
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||||
|
paint_bar(&mut app);
|
||||||
|
crate::liquify_ui::open(&mut app, &ctx).unwrap();
|
||||||
|
assert!(app.distort.liquify.is_some());
|
||||||
|
app.session.execute("file.new", json!({"width": 40, "height": 40, "depth": 8})).unwrap();
|
||||||
|
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||||
|
app.sync_views();
|
||||||
|
assert!(app.distort.liquify.is_none());
|
||||||
|
let past = app.session.active().unwrap().history.past_len();
|
||||||
|
app.session.execute("edit.undo", json!({})).unwrap();
|
||||||
|
assert_eq!(app.session.active().unwrap().history.past_len(), past - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::hash::{Hash, Hasher};
|
|||||||
|
|
||||||
use egui::{Align2, Color32, FontId, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2};
|
use egui::{Align2, Color32, FontId, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2};
|
||||||
use photocraft_algo::{FilterParams, GALLERY_CATEGORIES, GalleryFilter};
|
use photocraft_algo::{FilterParams, GALLERY_CATEGORIES, GalleryFilter};
|
||||||
use photocraft_doc::LayerId;
|
use photocraft_doc::{DocId, LayerId};
|
||||||
use photocraft_geom::Rect;
|
use photocraft_geom::Rect;
|
||||||
use photocraft_raster::{Interrupt, Surface};
|
use photocraft_raster::{Interrupt, Surface};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
@@ -66,6 +66,8 @@ fn default_params(f: GalleryFilter) -> Map<String, Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct GalleryDialog {
|
pub struct GalleryDialog {
|
||||||
|
/// Document this dialog belongs to (#5115).
|
||||||
|
pub document: DocId,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
layer_name: String,
|
layer_name: String,
|
||||||
canvas: Rect,
|
canvas: Rect,
|
||||||
@@ -100,6 +102,7 @@ pub struct GalleryDialog {
|
|||||||
impl GalleryDialog {
|
impl GalleryDialog {
|
||||||
pub fn describe(&self) -> Value {
|
pub fn describe(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
|
"document": self.document.0,
|
||||||
"layer": self.layer.0,
|
"layer": self.layer.0,
|
||||||
"effects": self.effects.iter().map(EffectLayer::to_json).collect::<Vec<_>>(),
|
"effects": self.effects.iter().map(EffectLayer::to_json).collect::<Vec<_>>(),
|
||||||
"selected": self.selected,
|
"selected": self.selected,
|
||||||
@@ -351,6 +354,7 @@ fn open_on(
|
|||||||
let open_cats = std::array::from_fn(|i| effects[sel].filter.category() == GALLERY_CATEGORIES[i]);
|
let open_cats = std::array::from_fn(|i| effects[sel].filter.category() == GALLERY_CATEGORIES[i]);
|
||||||
let history = EditHistory::new(&effects);
|
let history = EditHistory::new(&effects);
|
||||||
app.distort.gallery = Some(GalleryDialog {
|
app.distort.gallery = Some(GalleryDialog {
|
||||||
|
document: st.doc.id,
|
||||||
layer,
|
layer,
|
||||||
layer_name,
|
layer_name,
|
||||||
canvas,
|
canvas,
|
||||||
|
|||||||
@@ -1499,6 +1499,7 @@ impl PhotocraftApp {
|
|||||||
// ... and to the document it was started on.
|
// ... and to the document it was started on.
|
||||||
crate::canvas::polygon_cancel_stale(self);
|
crate::canvas::polygon_cancel_stale(self);
|
||||||
crate::crop_ui::cancel_stale(self);
|
crate::crop_ui::cancel_stale(self);
|
||||||
|
crate::distort_ui::cancel_stale(self);
|
||||||
crate::perspective_crop_ui::cancel_stale(self);
|
crate::perspective_crop_ui::cancel_stale(self);
|
||||||
// A pen path under construction belongs to the document it was started on too.
|
// A pen path under construction belongs to the document it was started on too.
|
||||||
vector_ui::cancel_stale(self);
|
vector_ui::cancel_stale(self);
|
||||||
@@ -3633,10 +3634,11 @@ mod clipboard_tests {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paste is enabled from the clipboard service alone (no read); the explicit paste imports
|
||||||
|
/// the OS image, once, and centres it in the view like Photoshop does for foreign clipboard
|
||||||
|
/// content.
|
||||||
#[test]
|
#[test]
|
||||||
fn external_clipboard_enables_and_centres_paste() {
|
fn external_clipboard_enables_and_centres_paste() {
|
||||||
// Paste is enabled from the clipboard service alone (no read); the explicit paste imports
|
|
||||||
// the OS image, once, and centres it like Photoshop does for foreign clipboard content.
|
|
||||||
let (os, reads) = (OsClip::default(), Arc::default());
|
let (os, reads) = (OsClip::default(), Arc::default());
|
||||||
let mut app = app_with_os_clipboard(&os, &reads);
|
let mut app = app_with_os_clipboard(&os, &reads);
|
||||||
*os.lock().unwrap() = Some((4, 4, [255u8, 0, 0, 255].repeat(16)));
|
*os.lock().unwrap() = Some((4, 4, [255u8, 0, 0, 255].repeat(16)));
|
||||||
@@ -3644,8 +3646,9 @@ mod clipboard_tests {
|
|||||||
assert!(crate::menus::is_enabled(&app, "edit.paste"));
|
assert!(crate::menus::is_enabled(&app, "edit.paste"));
|
||||||
assert!(crate::menus::is_enabled(&app, "edit.pasteSpecial.pasteInPlace"));
|
assert!(crate::menus::is_enabled(&app, "edit.pasteSpecial.pasteInPlace"));
|
||||||
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0, "enablement never reads the clipboard");
|
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0, "enablement never reads the clipboard");
|
||||||
|
app.ui.views[0].center = [40.0, 20.0];
|
||||||
let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.paste", serde_json::json!({})).unwrap();
|
let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.paste", serde_json::json!({})).unwrap();
|
||||||
assert_ne!(r["offset"], serde_json::json!([0, 0]), "external image is centred: {r}");
|
assert_eq!(r["offset"], serde_json::json!([38, 18]), "external image is centred in the view: {r}");
|
||||||
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1, "one read per explicit paste");
|
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1, "one read per explicit paste");
|
||||||
// Without a clipboard service Paste greys until something is copied in the app.
|
// Without a clipboard service Paste greys until something is copied in the app.
|
||||||
let mut plain = PhotocraftApp::new(Session::new(), Services::default());
|
let mut plain = PhotocraftApp::new(Session::new(), Services::default());
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2};
|
use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2};
|
||||||
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, ProxyImage, auto_cell};
|
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, ProxyImage, auto_cell};
|
||||||
use photocraft_doc::{Document, LayerId};
|
use photocraft_doc::{DocId, Document, LayerId};
|
||||||
use photocraft_geom::Rect;
|
use photocraft_geom::Rect;
|
||||||
use photocraft_raster::Surface;
|
use photocraft_raster::Surface;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -153,6 +153,8 @@ impl LiquifyOpts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct LiquifyDialog {
|
pub struct LiquifyDialog {
|
||||||
|
/// Document this dialog belongs to (#5115).
|
||||||
|
pub document: DocId,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
layer_name: String,
|
layer_name: String,
|
||||||
canvas: Rect,
|
canvas: Rect,
|
||||||
@@ -219,6 +221,7 @@ fn union_rect(a: Option<Rect>, b: Rect) -> Option<Rect> {
|
|||||||
impl LiquifyDialog {
|
impl LiquifyDialog {
|
||||||
pub fn describe(&self) -> Value {
|
pub fn describe(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
|
"document": self.document.0,
|
||||||
"layer": self.layer.0,
|
"layer": self.layer.0,
|
||||||
"tool": self.opts.tool,
|
"tool": self.opts.tool,
|
||||||
"size": self.opts.size,
|
"size": self.opts.size,
|
||||||
@@ -529,6 +532,7 @@ fn open_on(
|
|||||||
let out = vec![[0u8; 4]; proxy.w * proxy.h];
|
let out = vec![[0u8; 4]; proxy.w * proxy.h];
|
||||||
let center = [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0];
|
let center = [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0];
|
||||||
let mut d = LiquifyDialog {
|
let mut d = LiquifyDialog {
|
||||||
|
document: st.doc.id,
|
||||||
layer,
|
layer,
|
||||||
layer_name: name,
|
layer_name: name,
|
||||||
canvas,
|
canvas,
|
||||||
|
|||||||
@@ -583,6 +583,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
|
|||||||
ui.spacing_mut().item_spacing.x = 8.0;
|
ui.spacing_mut().item_spacing.x = 8.0;
|
||||||
for c in row {
|
for c in row {
|
||||||
let (r, resp) = ui.allocate_exact_size(card, Sense::click());
|
let (r, resp) = ui.allocate_exact_size(card, Sense::click());
|
||||||
|
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||||
let (w, h, _) = c.size(&units);
|
let (w, h, _) = c.size(&units);
|
||||||
let on = match c {
|
let on = match c {
|
||||||
Card::Builtin(p) => chosen == p.0,
|
Card::Builtin(p) => chosen == p.0,
|
||||||
|
|||||||
@@ -4177,9 +4177,8 @@ pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||||||
};
|
};
|
||||||
ui.painter().add(color_grid(field, 16, |x, y| hsva_color32(at(x, y))));
|
ui.painter().add(color_grid(field, 16, |x, y| hsva_color32(at(x, y))));
|
||||||
ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside);
|
ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside);
|
||||||
if (fresp.dragged() || fresp.clicked())
|
let field_pick = widgets::pick_pos(&fresp);
|
||||||
&& let Some(p) = fresp.interact_pointer_pos()
|
if let Some(p) = field_pick {
|
||||||
{
|
|
||||||
let x = ((p.x - field.left()) / field.width()).clamp(0.0, 1.0);
|
let x = ((p.x - field.left()) / field.width()).clamp(0.0, 1.0);
|
||||||
let y = ((p.y - field.top()) / field.height()).clamp(0.0, 1.0);
|
let y = ((p.y - field.top()) / field.height()).clamp(0.0, 1.0);
|
||||||
if brightness {
|
if brightness {
|
||||||
@@ -4200,9 +4199,8 @@ pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||||||
if brightness { egui::ecolor::Hsva::new(hsva.h, hsva.s, 1.0 - f, 1.0) } else { egui::ecolor::Hsva::new(1.0 - f, 1.0, 1.0, 1.0) }
|
if brightness { egui::ecolor::Hsva::new(hsva.h, hsva.s, 1.0 - f, 1.0) } else { egui::ecolor::Hsva::new(1.0 - f, 1.0, 1.0, 1.0) }
|
||||||
};
|
};
|
||||||
ui.painter().add(color_grid(strip, 24, |_, y| hsva_color32(strip_at(y))));
|
ui.painter().add(color_grid(strip, 24, |_, y| hsva_color32(strip_at(y))));
|
||||||
if (sresp.dragged() || sresp.clicked())
|
let strip_pick = widgets::pick_pos(&sresp);
|
||||||
&& let Some(p) = sresp.interact_pointer_pos()
|
if let Some(p) = strip_pick {
|
||||||
{
|
|
||||||
let f = ((p.y - strip.top()) / strip.height()).clamp(0.0, 1.0);
|
let f = ((p.y - strip.top()) / strip.height()).clamp(0.0, 1.0);
|
||||||
if brightness {
|
if brightness {
|
||||||
hsva.v = 1.0 - f;
|
hsva.v = 1.0 - f;
|
||||||
@@ -4213,10 +4211,11 @@ pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||||||
let y = strip.top() + (1.0 - if brightness { hsva.v } else { hsva.h }) * strip.height();
|
let y = strip.top() + (1.0 - if brightness { hsva.v } else { hsva.h }) * strip.height();
|
||||||
let tri = vec![pos2(strip.right() + 1.0, y), pos2(strip.right() + 6.0, y - 4.0), pos2(strip.right() + 6.0, y + 4.0)];
|
let tri = vec![pos2(strip.right() + 1.0, y), pos2(strip.right() + 6.0, y - 4.0), pos2(strip.right() + 6.0, y + 4.0)];
|
||||||
ui.painter().add(egui::Shape::convex_polygon(tri, t.text, Stroke::NONE));
|
ui.painter().add(egui::Shape::convex_polygon(tri, t.text, Stroke::NONE));
|
||||||
if fresp.dragged() || fresp.clicked() || sresp.dragged() || sresp.clicked() {
|
if field_pick.is_some() || strip_pick.is_some() {
|
||||||
if bg_active {
|
if bg_active {
|
||||||
app.session.tools.background = hsva_srgb(hsva);
|
app.session.tools.background = hsva_srgb(hsva);
|
||||||
} else {
|
} else if app.session.tools.foreground != hsva_srgb(hsva) {
|
||||||
|
// A held, unmoved press repeats the pick: restyle selected type only on a change.
|
||||||
app.session.tools.foreground = hsva_srgb(hsva);
|
app.session.tools.foreground = hsva_srgb(hsva);
|
||||||
crate::apply_color::foreground_changed(app, crate::apply_color::gesture(ui.ctx()));
|
crate::apply_color::foreground_changed(app, crate::apply_color::gesture(ui.ctx()));
|
||||||
}
|
}
|
||||||
@@ -4856,6 +4855,80 @@ mod color_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #7793: the Color panel's field and hue strip pick on the frame the button goes down (no
|
||||||
|
/// release, no time passing) and follow moves smaller than egui's 6-point click threshold.
|
||||||
|
#[test]
|
||||||
|
fn color_panel_field_and_hue_strip_pick_on_press() {
|
||||||
|
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||||
|
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
|
||||||
|
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(
|
||||||
|
|ui, app: &mut PhotocraftApp| {
|
||||||
|
let at = (ui.cursor().min, ui.available_width());
|
||||||
|
ui.data_mut(|d| d.insert_temp(egui::Id::new("test-origin"), at));
|
||||||
|
color_field(app, ui);
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||||
|
h.run_steps(2);
|
||||||
|
let (o, w) = h.ctx.data(|d| d.get_temp::<(Pos2, f32)>(egui::Id::new("test-origin"))).unwrap();
|
||||||
|
let field_w = w - 38.0 - 14.0 - 16.0;
|
||||||
|
let press = |h: &mut egui_kittest::Harness<'static, PhotocraftApp>, at: Pos2, pressed: bool| {
|
||||||
|
h.event(egui::Event::PointerMoved(at));
|
||||||
|
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||||
|
h.run_steps(1);
|
||||||
|
};
|
||||||
|
let at = o + vec2(38.0 + 8.0 + field_w * 0.5, 60.0);
|
||||||
|
press(&mut h, at, true);
|
||||||
|
let picked = h.state().session.tools.foreground;
|
||||||
|
assert_ne!(picked, [0.2, 0.4, 0.6, 1.0], "the press itself picks");
|
||||||
|
let hsva = srgb_hsva(picked);
|
||||||
|
assert!((hsva.s - 0.5).abs() < 0.03 && (hsva.v - 0.5).abs() < 0.03, "{hsva:?}");
|
||||||
|
h.event(egui::Event::PointerMoved(at + vec2(3.0, 0.0)));
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_ne!(h.state().session.tools.foreground, picked, "a small drag adjusts the pick");
|
||||||
|
press(&mut h, at + vec2(3.0, 0.0), false);
|
||||||
|
h.run_steps(1);
|
||||||
|
// Hue strip: a press a third of the way down is a hue of about 240° at once.
|
||||||
|
let before = h.state().session.tools.foreground;
|
||||||
|
press(&mut h, o + vec2(38.0 + 8.0 + field_w + 8.0 + 7.0, 40.0), true);
|
||||||
|
let now = h.state().session.tools.foreground;
|
||||||
|
assert_ne!(now, before, "the strip picks on press");
|
||||||
|
assert!((srgb_hsva(now).h * 360.0 - 240.0).abs() < 8.0, "{:?}", srgb_hsva(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the primary button picks: a right or middle press (and its release) on the field or
|
||||||
|
/// the hue strip leaves the foreground colour alone.
|
||||||
|
#[test]
|
||||||
|
fn color_panel_ignores_secondary_and_middle_presses() {
|
||||||
|
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||||
|
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
|
||||||
|
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(
|
||||||
|
|ui, app: &mut PhotocraftApp| {
|
||||||
|
let at = (ui.cursor().min, ui.available_width());
|
||||||
|
ui.data_mut(|d| d.insert_temp(egui::Id::new("test-origin"), at));
|
||||||
|
color_field(app, ui);
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||||
|
h.run_steps(2);
|
||||||
|
let (o, w) = h.ctx.data(|d| d.get_temp::<(Pos2, f32)>(egui::Id::new("test-origin"))).unwrap();
|
||||||
|
let field_w = w - 38.0 - 14.0 - 16.0;
|
||||||
|
let field = o + vec2(38.0 + 8.0 + field_w * 0.5, 60.0);
|
||||||
|
let strip = o + vec2(38.0 + 8.0 + field_w + 8.0 + 7.0, 40.0);
|
||||||
|
for at in [field, strip] {
|
||||||
|
for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
|
||||||
|
for pressed in [true, false] {
|
||||||
|
h.event(egui::Event::PointerMoved(at));
|
||||||
|
h.event(egui::Event::PointerButton { pos: at, button, pressed, modifiers: egui::Modifiers::NONE });
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_eq!(h.state().session.tools.foreground, [0.2, 0.4, 0.6, 1.0], "{button:?} pressed={pressed} at {at:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Click the hex field, select its text and type `text` one key per frame.
|
/// Click the hex field, select its text and type `text` one key per frame.
|
||||||
fn type_hex(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) {
|
fn type_hex(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) {
|
||||||
use egui_kittest::kittest::Queryable;
|
use egui_kittest::kittest::Queryable;
|
||||||
|
|||||||
@@ -352,6 +352,36 @@ mod tests {
|
|||||||
assert_eq!(ordered([[10.0, 90.0], [100.0, 5.0], [0.0, 0.0], [90.0, 100.0]]), [[0.0, 0.0], [100.0, 5.0], [90.0, 100.0], [10.0, 90.0]]);
|
assert_eq!(ordered([[10.0, 90.0], [100.0, 5.0], [0.0, 0.0], [90.0, 100.0]]), [[0.0, 0.0], [100.0, 5.0], [90.0, 100.0], [10.0, 90.0]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clicking ⇄ in the rendered options bar swaps W and H, although Perspective Crop never sets
|
||||||
|
/// the Crop tool's W x H x Resolution mode (#7059).
|
||||||
|
#[test]
|
||||||
|
fn the_options_bar_swap_button_swaps_w_and_h() {
|
||||||
|
use egui_kittest::kittest::Queryable;
|
||||||
|
let doc = Document::with_background("p", Size::new(200, 150), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||||
|
let mut s = photocraft_engine::Session::new();
|
||||||
|
s.add_document(doc, None);
|
||||||
|
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||||
|
app.ui.tool = Tool::PerspectiveCrop;
|
||||||
|
app.ui.tool_options.crop_width = "4 in".into();
|
||||||
|
app.ui.tool_options.crop_height = "6 in".into();
|
||||||
|
assert_ne!(app.ui.tool_options.crop_ratio, crate::crop_size::WHR);
|
||||||
|
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 60.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::ProMedium);
|
||||||
|
h.run_steps(4);
|
||||||
|
h.get_by_label("Swaps height and width").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
let o = &h.state().ui.tool_options;
|
||||||
|
assert_eq!((o.crop_width.as_str(), o.crop_height.as_str()), ("6 in", "4 in"));
|
||||||
|
}
|
||||||
|
|
||||||
/// The pending quad belongs to its document (#1918): switching documents drops it, so it is
|
/// The pending quad belongs to its document (#1918): switching documents drops it, so it is
|
||||||
/// never drawn on the other one; on its own document it stays.
|
/// never drawn on the other one; on its own document it stays.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use egui::{Color32, Pos2, Stroke, TextureHandle, pos2};
|
use egui::{Color32, Pos2, Stroke, TextureHandle, pos2};
|
||||||
use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, linked_corners, plane_point, straighten};
|
use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, linked_corners, plane_point, straighten};
|
||||||
use photocraft_doc::{Document, LayerId};
|
use photocraft_doc::{DocId, Document, LayerId};
|
||||||
use photocraft_geom::Rect;
|
use photocraft_geom::Rect;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ enum Drag {
|
|||||||
|
|
||||||
pub struct PerspSession {
|
pub struct PerspSession {
|
||||||
pub key: u64,
|
pub key: u64,
|
||||||
|
/// Document this session belongs to (#5115).
|
||||||
|
pub document: DocId,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
command: String,
|
command: String,
|
||||||
bounds: Rect,
|
bounds: Rect,
|
||||||
@@ -56,7 +58,7 @@ const GRID: usize = 40;
|
|||||||
|
|
||||||
impl PerspSession {
|
impl PerspSession {
|
||||||
pub fn describe(&self) -> Value {
|
pub fn describe(&self) -> Value {
|
||||||
json!({"layer": self.layer.0, "mode": self.mode, "planes": self.planes})
|
json!({"document": self.document.0, "layer": self.layer.0, "mode": self.mode, "planes": self.planes})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_grid(&mut self) {
|
fn update_grid(&mut self) {
|
||||||
@@ -134,6 +136,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context, command: &str) -> Res
|
|||||||
let texture = ctx.load_texture(format!("persp-{key}"), crate::distort_ui::surface_image(&surface, bounds, 2048), egui::TextureOptions::LINEAR);
|
let texture = ctx.load_texture(format!("persp-{key}"), crate::distort_ui::surface_image(&surface, bounds, 2048), egui::TextureOptions::LINEAR);
|
||||||
let mut s = PerspSession {
|
let mut s = PerspSession {
|
||||||
key,
|
key,
|
||||||
|
document: st.doc.id,
|
||||||
layer,
|
layer,
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
bounds,
|
bounds,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use egui::{Color32, Stroke, TextureHandle, pos2, vec2};
|
use egui::{Color32, Stroke, TextureHandle, pos2, vec2};
|
||||||
use photocraft_algo::puppet::{ITERATIONS, PuppetDensity, PuppetMesh, PuppetMode, PuppetPin, PuppetSolver, PuppetWarp, build_mesh};
|
use photocraft_algo::puppet::{ITERATIONS, PuppetDensity, PuppetMesh, PuppetMode, PuppetPin, PuppetSolver, PuppetWarp, build_mesh};
|
||||||
use photocraft_doc::{Document, LayerId};
|
use photocraft_doc::{DocId, Document, LayerId};
|
||||||
use photocraft_geom::Rect;
|
use photocraft_geom::Rect;
|
||||||
use photocraft_raster::Surface;
|
use photocraft_raster::Surface;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -23,6 +23,8 @@ use crate::canvas::{ToolEvent, ViewXform};
|
|||||||
|
|
||||||
pub struct PuppetSession {
|
pub struct PuppetSession {
|
||||||
pub key: u64,
|
pub key: u64,
|
||||||
|
/// Document this session belongs to (#5115).
|
||||||
|
pub document: DocId,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
/// Command to commit with (`edit.puppetWarp` or `layer.smartObjects.puppetWarp`).
|
/// Command to commit with (`edit.puppetWarp` or `layer.smartObjects.puppetWarp`).
|
||||||
command: String,
|
command: String,
|
||||||
@@ -98,6 +100,7 @@ impl PuppetSession {
|
|||||||
|
|
||||||
pub fn describe(&self) -> Value {
|
pub fn describe(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
|
"document": self.document.0,
|
||||||
"layer": self.layer.0,
|
"layer": self.layer.0,
|
||||||
"pins": self.warp.pins,
|
"pins": self.warp.pins,
|
||||||
"mode": self.warp.mode,
|
"mode": self.warp.mode,
|
||||||
@@ -202,6 +205,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context, command: &str) -> Res
|
|||||||
let texture = ctx.load_texture(format!("puppet-{key}"), img, egui::TextureOptions::LINEAR);
|
let texture = ctx.load_texture(format!("puppet-{key}"), img, egui::TextureOptions::LINEAR);
|
||||||
let mut s = PuppetSession {
|
let mut s = PuppetSession {
|
||||||
key,
|
key,
|
||||||
|
document: st.doc.id,
|
||||||
layer,
|
layer,
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
surface,
|
surface,
|
||||||
@@ -760,10 +764,13 @@ mod tests {
|
|||||||
crate::distort_ui::drop_foreign(&mut app);
|
crate::distort_ui::drop_foreign(&mut app);
|
||||||
assert!(app.distort.puppet.is_some());
|
assert!(app.distort.puppet.is_some());
|
||||||
app.session.execute("file.new", json!({"width": 32, "height": 32, "depth": 8})).unwrap();
|
app.session.execute("file.new", json!({"width": 32, "height": 32, "depth": 8})).unwrap();
|
||||||
assert!(crate::distort_ui::display_doc(&app, 1).is_some());
|
let active = app.session.active_index().unwrap();
|
||||||
|
// The session stays until drop_foreign; the preview must not follow this document (#5115).
|
||||||
|
assert!(app.distort.puppet.is_some());
|
||||||
|
assert!(crate::distort_ui::display_doc(&app, active).is_none());
|
||||||
crate::distort_ui::drop_foreign(&mut app);
|
crate::distort_ui::drop_foreign(&mut app);
|
||||||
assert!(app.distort.puppet.is_none());
|
assert!(app.distort.puppet.is_none());
|
||||||
assert!(crate::distort_ui::display_doc(&app, 1).is_none());
|
assert!(crate::distort_ui::display_doc(&app, active).is_none());
|
||||||
// Closing the document it was started on drops it too.
|
// Closing the document it was started on drops it too.
|
||||||
assert!(app.session.set_active(0));
|
assert!(app.session.set_active(0));
|
||||||
crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap();
|
crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap();
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||||||
// Liquify is a full-window custom dialog with focusable sliders. egui can therefore claim
|
// Liquify is a full-window custom dialog with focusable sliders. egui can therefore claim
|
||||||
// keyboard input before the distortion-mode handler below runs. Give Liquify's local
|
// keyboard input before the distortion-mode handler below runs. Give Liquify's local
|
||||||
// shortcuts first refusal (Undo, brush size, tool keys), but never steal keys from text edits.
|
// shortcuts first refusal (Undo, brush size, tool keys), but never steal keys from text edits.
|
||||||
|
crate::distort_ui::cancel_stale(app);
|
||||||
if app.distort.liquify.is_some() {
|
if app.distort.liquify.is_some() {
|
||||||
// Undo and redo still apply while a numeric field (Size, Pressure, …) has focus.
|
// Undo and redo still apply while a numeric field (Size, Pressure, …) has focus.
|
||||||
// Other Liquify keys must not steal characters from that field (#4027).
|
// Other Liquify keys must not steal characters from that field (#4027).
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
use egui::{Color32, Pos2, Stroke, TextureHandle, pos2};
|
use egui::{Color32, Pos2, Stroke, TextureHandle, pos2};
|
||||||
use photocraft_algo::transform::Homography;
|
use photocraft_algo::transform::Homography;
|
||||||
use photocraft_algo::vanishing::{PlaneCheck, Scene, VpPlane};
|
use photocraft_algo::vanishing::{PlaneCheck, Scene, VpPlane};
|
||||||
use photocraft_doc::{LayerContent, LayerId};
|
use photocraft_doc::{DocId, LayerContent, LayerId};
|
||||||
use photocraft_engine::vp_cmds::VP;
|
use photocraft_engine::vp_cmds::VP;
|
||||||
use photocraft_geom::Rect;
|
use photocraft_geom::Rect;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -46,6 +46,8 @@ struct Clip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct VpSession {
|
pub struct VpSession {
|
||||||
|
/// Document this session belongs to (#5115).
|
||||||
|
pub document: DocId,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
canvas: Rect,
|
canvas: Rect,
|
||||||
pub planes: Vec<VpPlane>,
|
pub planes: Vec<VpPlane>,
|
||||||
@@ -80,6 +82,7 @@ impl VpSession {
|
|||||||
let center = self.center();
|
let center = self.center();
|
||||||
let planes: Vec<Value> = self.planes.iter().map(|p| json!({"corners": p.corners, "check": p.check(center)})).collect();
|
let planes: Vec<Value> = self.planes.iter().map(|p| json!({"corners": p.corners, "check": p.check(center)})).collect();
|
||||||
json!({
|
json!({
|
||||||
|
"document": self.document.0,
|
||||||
"layer": self.layer.0,
|
"layer": self.layer.0,
|
||||||
"tool": self.tool,
|
"tool": self.tool,
|
||||||
"planes": planes,
|
"planes": planes,
|
||||||
@@ -310,6 +313,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
app.distort.vanishing = Some(VpSession {
|
app.distort.vanishing = Some(VpSession {
|
||||||
|
document: st.doc.id,
|
||||||
layer,
|
layer,
|
||||||
canvas,
|
canvas,
|
||||||
planes: Vec::new(),
|
planes: Vec::new(),
|
||||||
|
|||||||
@@ -3001,6 +3001,16 @@ pub(crate) fn color_swatch(ui: &mut Ui, color: Color32) -> Response {
|
|||||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
/// Where a press or drag on a picking surface (a colour field, a hue strip) points, from the
|
||||||
|
/// frame the button goes down and on every move after it. `dragged()` waits for egui's click
|
||||||
|
/// threshold (a few points of travel, or a long hold), so a press picked nothing until the
|
||||||
|
/// pointer had moved away and fine adjustments jumped (#7793). Only the primary button picks:
|
||||||
|
/// `is_pointer_button_down_on` is true for any button, so a right or middle press is ignored.
|
||||||
|
pub fn pick_pos(resp: &Response) -> Option<Pos2> {
|
||||||
|
let held = resp.is_pointer_button_down_on()
|
||||||
|
&& resp.ctx.input(|i| i.pointer.primary_down() && !i.pointer.button_down(egui::PointerButton::Secondary) && !i.pointer.middle_down());
|
||||||
|
if held || resp.clicked() { resp.interact_pointer_pos() } else { None }
|
||||||
|
}
|
||||||
/// A colour swatch that only reports clicks (no popup): for colours edited in PhotoCraft's own
|
/// A colour swatch that only reports clicks (no popup): for colours edited in PhotoCraft's own
|
||||||
/// Color Picker dialog. `label` names it for hover text and accessibility.
|
/// Color Picker dialog. `label` names it for hover text and accessibility.
|
||||||
pub fn color_swatch_button(ui: &mut Ui, color: Color32, label: &str) -> Response {
|
pub fn color_swatch_button(ui: &mut Ui, color: Color32, label: &str) -> Response {
|
||||||
|
|||||||
@@ -376,6 +376,11 @@ capability interface. Interactive desktop file pickers retain normal user-select
|
|||||||
|
|
||||||
## Headless server
|
## Headless server
|
||||||
|
|
||||||
|
An unused TCP headless server exits after 30 minutes without a client connection, releasing
|
||||||
|
its listening port so a later session can reuse it (#3682). An active connection prevents
|
||||||
|
idle shutdown, including during a long command. Clients should reconnect by restarting
|
||||||
|
`photocraft-cli serve` if they need a server after the idle deadline.
|
||||||
|
|
||||||
`photocraft-cli serve` keeps one headless engine session (no window, no GPU) and answers the same
|
`photocraft-cli serve` keeps one headless engine session (no window, no GPU) and answers the same
|
||||||
JSON-lines envelope on stdio, or on `127.0.0.1:<port>` with `--port <port>` (loopback only; each
|
JSON-lines envelope on stdio, or on `127.0.0.1:<port>` with `--port <port>` (loopback only; each
|
||||||
authenticated connection shares the session). TCP uses the same first-frame `auth` exchange and
|
authenticated connection shares the session). TCP uses the same first-frame `auth` exchange and
|
||||||
|
|||||||
Reference in New Issue
Block a user