mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 13:16:54 -04:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ff15802a | ||
|
|
7406c26bd4 | ||
|
|
2c82295e4c | ||
|
|
3ffc1b658e | ||
|
|
cc3626305d | ||
|
|
106d581ce7 | ||
|
|
11e4249f5a | ||
|
|
074dc28b6a | ||
|
|
f1d9f71b10 | ||
|
|
929a525d4f | ||
|
|
af50169892 | ||
|
|
6d324d3f0d | ||
|
|
601ae7b9b4 | ||
|
|
8ad3f085f2 | ||
|
|
3e04181088 | ||
|
|
f8d23085b7 | ||
|
|
fa56acf278 | ||
|
|
0b0f4b72e6 | ||
|
|
5bd228e5b5 | ||
|
|
df8ac3d764 | ||
|
|
e6f7b42c30 | ||
|
|
b287b7ff3c | ||
|
|
1fb8d054a9 | ||
|
|
ddb45174fc | ||
|
|
266a473de3 | ||
|
|
e866443bd7 | ||
|
|
960959f8a6 | ||
|
|
b31a98c3d8 | ||
|
|
2a282119d6 | ||
|
|
4623ab24b1 | ||
|
|
07dafe23e0 | ||
|
|
b9eb9cfb62 | ||
|
|
eb6cb73938 | ||
|
|
98a24d1269 | ||
|
|
6ab334d3b8 | ||
|
|
b5b2e380d6 | ||
|
|
42dd44e78a | ||
|
|
130aed626a | ||
|
|
6d9a603d9c | ||
|
|
31f4896405 | ||
|
|
00fe0967fd | ||
|
|
914b55c4c5 | ||
|
|
5c8d0ad735 | ||
|
|
25641e432f | ||
|
|
9fcbd61253 | ||
|
|
644fdcfe9c | ||
|
|
008595d2d4 | ||
|
|
7f863587b5 |
No files matched your search
@@ -5,7 +5,7 @@ use std::io::{BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
@@ -83,12 +83,14 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
let (mut req, rrx) = ControlRequest::new(method, params);
|
||||
req.deadline = Some(deadline);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
let mut r = rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
@@ -124,6 +126,7 @@ mod tests {
|
||||
let handler = std::thread::spawn(move || {
|
||||
let first = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(first.method, "test.large");
|
||||
assert!(first.deadline.is_some_and(|deadline| deadline > std::time::Instant::now()), "native requests carry their reply deadline");
|
||||
first.reply.send(json!({"ok": true, "result": "x".repeat(MAX_RESPONSE_BYTES)})).unwrap();
|
||||
let second = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(second.method, "test.small");
|
||||
|
||||
@@ -39,3 +39,8 @@ real_name = "Arthur Vinicius"
|
||||
|
||||
[people.SckraT]
|
||||
sync_github_name = true
|
||||
|
||||
[people.GamerWil26]
|
||||
real_name = "Willy Foo"
|
||||
display_name = "Willy Foo"
|
||||
sync_github_name = false
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Deterministic 24 MP blur/resize benchmark. Run with --release; no private photos needed.
|
||||
use photocraft_algo::{
|
||||
FilterParams, apply_in,
|
||||
resample::{Resample, resize_surface_in_canvas},
|
||||
};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use std::time::Instant;
|
||||
fn main() {
|
||||
let bounds = Rect::new(0, 0, 6000, 4000);
|
||||
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
|
||||
for y in (0..4000).step_by(128) {
|
||||
let r = Rect::new(0, y, 6000, (y + 128).min(4000));
|
||||
let mut data = Vec::new();
|
||||
for yy in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
data.extend_from_slice(&[(x % 251) as f32 / 250.0, (yy % 241) as f32 / 240.0, ((x + yy) % 239) as f32 / 238.0, 1.0]);
|
||||
}
|
||||
}
|
||||
src.write_region(r, &data);
|
||||
}
|
||||
for op in ["blur3", "bicubic1791"] {
|
||||
for rep in 0..4 {
|
||||
let t = Instant::now();
|
||||
let out = if op == "blur3" {
|
||||
apply_in(&src, &FilterParams::GaussianBlur { radius: 3.0 }, bounds, bounds, None, bounds)
|
||||
} else {
|
||||
resize_surface_in_canvas(&src, 1791.0 / 6000.0, 1194.0 / 4000.0, Resample::Bicubic, bounds)
|
||||
};
|
||||
let elapsed = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let sample = out.read_region(Rect::new(100, 100, 140, 130));
|
||||
let checksum = sample.iter().fold(0u64, |s, v| s.wrapping_mul(31).wrapping_add(v.to_bits() as u64));
|
||||
println!("{op} rep={rep} ms={elapsed:.3} checksum={checksum}");
|
||||
std::hint::black_box(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
-6
@@ -55,12 +55,21 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
for ty in 0..th {
|
||||
let row = &p[ty * ww * n..(ty + 1) * ww * n];
|
||||
let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n];
|
||||
for ox in 0..ow {
|
||||
let d = &mut dst[ox * n..(ox + 1) * n];
|
||||
for (i, kv) in kx.iter().enumerate() {
|
||||
let s = &row[(ox + i) * n..(ox + i + 1) * n];
|
||||
for c in 0..n {
|
||||
d[c] += s[c] * kv;
|
||||
match n {
|
||||
1 => conv_row::<1>(row, dst, kx),
|
||||
2 => conv_row::<2>(row, dst, kx),
|
||||
3 => conv_row::<3>(row, dst, kx),
|
||||
4 => conv_row::<4>(row, dst, kx),
|
||||
5 => conv_row::<5>(row, dst, kx),
|
||||
_ => {
|
||||
for ox in 0..ow {
|
||||
let d = &mut dst[ox * n..(ox + 1) * n];
|
||||
for (i, kv) in kx.iter().enumerate() {
|
||||
let s = &row[(ox + i) * n..(ox + i + 1) * n];
|
||||
for c in 0..n {
|
||||
d[c] += s[c] * kv;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +89,21 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
res
|
||||
}
|
||||
|
||||
// Fixed channel counts let LLVM keep one pixel's accumulators in registers and vectorize
|
||||
// channels. The tap order is unchanged; unusual multichannel images keep the generic path.
|
||||
fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
|
||||
let pixels = src.as_chunks::<N>().0;
|
||||
for (x, out) in dst.as_chunks_mut::<N>().0.iter_mut().enumerate() {
|
||||
let mut acc = [0.0f32; N];
|
||||
for (pixel, weight) in pixels.get(x..).unwrap_or(&[]).iter().zip(kernel) {
|
||||
for c in 0..N {
|
||||
acc[c] += pixel[c] * weight;
|
||||
}
|
||||
}
|
||||
*out = acc;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
|
||||
// Exact kernel for small radii; beyond that three box passes approximate the Gaussian with
|
||||
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010).
|
||||
@@ -803,6 +827,34 @@ mod tests {
|
||||
assert!(coarse < expected * 0.1, "former 64-interval sampling unexpectedly resolved the patch: {coarse} vs {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_channel_convolution_matches_scalar_tap_order() {
|
||||
fn check<const N: usize>() {
|
||||
for width in [1, 7, 256] {
|
||||
for radius in [0.0, 0.5, 3.0, 4.0] {
|
||||
let k = gaussian_kernel(radius);
|
||||
let src: Vec<f32> = (0..(width + k.len() - 1) * N).map(|i| ((i * 73) % 251) as f32 / 250.0).collect();
|
||||
let mut expected = vec![0.0f32; width * N];
|
||||
for x in 0..width {
|
||||
for (i, w) in k.iter().enumerate() {
|
||||
for c in 0..N {
|
||||
expected[x * N + c] += src[(x + i) * N + c] * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut actual = vec![0.0f32; width * N];
|
||||
conv_row::<N>(&src, &mut actual, &k);
|
||||
assert_eq!(actual, expected, "channels={N} width={width} radius={radius}");
|
||||
}
|
||||
}
|
||||
}
|
||||
check::<1>();
|
||||
check::<2>();
|
||||
check::<3>();
|
||||
check::<4>();
|
||||
check::<5>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_gaussian_matches_exact_kernel() {
|
||||
// A hard vertical edge (premultiplied RGBA-ish, 2 channels) blurred with σ = 12.
|
||||
|
||||
+67
-14
@@ -148,6 +148,41 @@ pub fn resize_surface_in_canvas(s: &Surface, sx: f64, sy: f64, filter: Resample,
|
||||
/// Samples one source read may hold (64 MB of `f32`); larger windows are read in row chunks.
|
||||
const READ_BUDGET: usize = 1 << 24;
|
||||
|
||||
// Preserve tap order and edge weights while exposing the common channel counts to LLVM.
|
||||
fn horizontal_fixed<const N: usize>(row: &[f32], out: &mut [f32], taps: &[Taps], x_lo: i32, def: &[f32]) {
|
||||
let pixels = row.as_chunks::<N>().0;
|
||||
for (t, d) in taps.iter().zip(out.as_chunks_mut::<N>().0) {
|
||||
let mut acc = [0.0f32; N];
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
if let Some(sp) = pixels.get((t.start + k as i32 - x_lo) as usize) {
|
||||
for c in 0..N {
|
||||
acc[c] += sp[c] * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (c, v) in acc.iter_mut().enumerate() {
|
||||
*v += def.get(c).copied().unwrap_or(0.0) * t.outside;
|
||||
}
|
||||
*d = acc;
|
||||
}
|
||||
}
|
||||
|
||||
fn horizontal_dynamic(row: &[f32], out: &mut [f32], taps: &[Taps], x_lo: i32, def: &[f32], n: usize) {
|
||||
for (t, d) in taps.iter().zip(out.chunks_exact_mut(n)) {
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
let sx_ = (t.start + k as i32 - x_lo) as usize;
|
||||
if let Some(sp) = row.get(sx_ * n..(sx_ + 1) * n) {
|
||||
for c in 0..n {
|
||||
d[c] += sp[c] * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..n {
|
||||
d[c] += def[c] * t.outside;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
|
||||
resize_with_budget(s, sx, sy, filter, edge, READ_BUDGET)
|
||||
}
|
||||
@@ -174,20 +209,13 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
|
||||
const BAND: usize = 64;
|
||||
let bands: Vec<(usize, usize)> = (0..vy.len()).step_by(BAND).map(|b| (b, (b + BAND).min(vy.len()))).collect();
|
||||
// Horizontal pass of one source row (`row` spans `x_lo..x_hi`) into `dw` samples.
|
||||
let horizontal = |row: &[f32], out: &mut [f32]| {
|
||||
for (t, d) in hx.iter().zip(out.chunks_exact_mut(n)) {
|
||||
for (k, w) in t.weights.iter().enumerate() {
|
||||
let sx_ = (t.start + k as i32 - x_lo) as usize;
|
||||
if let Some(sp) = row.get(sx_ * n..(sx_ + 1) * n) {
|
||||
for c in 0..n {
|
||||
d[c] += sp[c] * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..n {
|
||||
d[c] += def[c] * t.outside;
|
||||
}
|
||||
}
|
||||
let horizontal = |row: &[f32], out: &mut [f32]| match n {
|
||||
1 => horizontal_fixed::<1>(row, out, &hx, x_lo, &def),
|
||||
2 => horizontal_fixed::<2>(row, out, &hx, x_lo, &def),
|
||||
3 => horizontal_fixed::<3>(row, out, &hx, x_lo, &def),
|
||||
4 => horizontal_fixed::<4>(row, out, &hx, x_lo, &def),
|
||||
5 => horizontal_fixed::<5>(row, out, &hx, x_lo, &def),
|
||||
_ => horizontal_dynamic(row, out, &hx, x_lo, &def, n),
|
||||
};
|
||||
// A source row of nothing but the default pixel, after the horizontal pass.
|
||||
let def_row = {
|
||||
@@ -321,6 +349,31 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_channel_rows_match_dynamic_resampling() {
|
||||
fn check<const N: usize>() {
|
||||
for scale in [0.12, 0.5, 1.0, 2.3] {
|
||||
for filter in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
|
||||
for clamp in [None, Some((-3, 31))] {
|
||||
let ts = taps(-5, 41, scale, filter, clamp, (-3, 31));
|
||||
let row: Vec<f32> = (0..35 * N).map(|i| ((i * 73) % 251) as f32 / 250.0).collect();
|
||||
let def: Vec<f32> = (0..N).map(|i| i as f32 / 7.0).collect();
|
||||
let mut expected = vec![0.0; ts.len() * N];
|
||||
let mut actual = expected.clone();
|
||||
horizontal_dynamic(&row, &mut expected, &ts, -3, &def, N);
|
||||
horizontal_fixed::<N>(&row, &mut actual, &ts, -3, &def);
|
||||
assert_eq!(actual, expected, "N={N} scale={scale} filter={filter:?} clamp={clamp:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
check::<1>();
|
||||
check::<2>();
|
||||
check::<3>();
|
||||
check::<4>();
|
||||
check::<5>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_scale_is_lossless() {
|
||||
for f in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Client for the desktop app's JSON-lines control protocol
|
||||
//! (`docs/control-protocol.md`): one JSON request per line, replies matched
|
||||
//! by `id`. Keeps one connection and reconnects on failure.
|
||||
//! by `id`. Keeps one connection and reconnects for the next call after a failure.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -50,58 +50,55 @@ impl BridgeClient {
|
||||
/// Call a control method; returns its `result` or the app's error.
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
// One retry with a fresh connection (the app may have restarted).
|
||||
for attempt in 0..2 {
|
||||
if guard.is_none() {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| {
|
||||
AutomationError::Bridge(format!(
|
||||
"cannot connect to {} ({e}); start the app with `photocraft --control <port>` and matching control credentials",
|
||||
self.addr
|
||||
))
|
||||
})?;
|
||||
let (r, w) = s.into_split();
|
||||
let mut conn = (BufReader::new(r), w);
|
||||
let auth_id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let auth = tokio::time::timeout(Duration::from_secs(5), exchange(&mut conn, auth_id, AUTH_METHOD, &json!({"token": self.token})))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge("control authentication timed out".into()))??;
|
||||
auth?;
|
||||
*guard = Some(conn);
|
||||
if guard.is_none() {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| {
|
||||
AutomationError::Bridge(format!(
|
||||
"cannot connect to {} ({e}); start the app with `photocraft --control <port>` and matching control credentials",
|
||||
self.addr
|
||||
))
|
||||
})?;
|
||||
let (r, w) = s.into_split();
|
||||
let mut conn = (BufReader::new(r), w);
|
||||
let auth_id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let auth = tokio::time::timeout(Duration::from_secs(5), exchange(&mut conn, auth_id, AUTH_METHOD, &json!({"token": self.token})))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge("control authentication timed out".into()))??;
|
||||
auth?;
|
||||
*guard = Some(conn);
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
};
|
||||
match tokio::time::timeout(self.timeout, exchange(conn, id, method, ¶ms)).await {
|
||||
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => {
|
||||
// An oversized frame leaves unread bytes. Drop this connection and
|
||||
// report the budget failure without retrying a possibly completed edit.
|
||||
*guard = None;
|
||||
Err(error)
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
};
|
||||
match tokio::time::timeout(self.timeout, exchange(conn, id, method, ¶ms)).await {
|
||||
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => {
|
||||
// An oversized frame leaves unread bytes. Drop this connection and
|
||||
// report the budget failure without retrying a possibly completed edit.
|
||||
*guard = None;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Ok(v)) => return v,
|
||||
Ok(Err(e)) if attempt == 0 => {
|
||||
*guard = None;
|
||||
let _ = e;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
*guard = None;
|
||||
return Err(e);
|
||||
}
|
||||
Err(_) => {
|
||||
*guard = None;
|
||||
return Err(AutomationError::Bridge(format!("`{method}` timed out after {:?}", self.timeout)));
|
||||
}
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => {
|
||||
// A failed write or lost reply does not prove the edit was not applied.
|
||||
// Reconnect on the next call, but never resend this operation (#1007).
|
||||
*guard = None;
|
||||
Err(AutomationError::Bridge(format!("`{method}` failed: {e}; operation may have completed; inspect state before retrying")))
|
||||
}
|
||||
Err(_) => {
|
||||
*guard = None;
|
||||
Err(AutomationError::Bridge(format!(
|
||||
"`{method}` timed out after {:?}; operation may have completed; inspect state before retrying",
|
||||
self.timeout
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(AutomationError::Bridge("unreachable".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Outer `Err` = transport failure (retryable); inner = app-level result.
|
||||
/// Outer `Err` = transport failure with an uncertain outcome; inner = app-level result.
|
||||
async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Result<Result<Value, AutomationError>, AutomationError> {
|
||||
let mut line = serde_json::to_string(&json!({"id": id, "method": method, "params": params})).map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
line.push('\n');
|
||||
@@ -115,7 +112,7 @@ async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Res
|
||||
loop {
|
||||
buf.clear();
|
||||
// Read raw bytes so truncation inside a UTF-8 character still reports the
|
||||
// budget error instead of a retryable decoding/transport error.
|
||||
// budget error instead of a decoding/transport error.
|
||||
let n = (&mut conn.0).take((MAX_RESPONSE_BYTES + 1) as u64).read_until(b'\n', &mut buf).await.map_err(io)?;
|
||||
if n > MAX_RESPONSE_BYTES {
|
||||
return Ok(Err(AutomationError::BadRequest(format!("bridge response exceeds {MAX_RESPONSE_BYTES} bytes; operation may have completed"))));
|
||||
|
||||
@@ -92,6 +92,8 @@ pub struct SaveParams {
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct PreviewParams {
|
||||
/// Headless mode only: document index (default: the active document).
|
||||
/// Bridge previews capture the app window and reject `index`.
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
/// Longest side of a headless preview (default 1024, maximum 2048).
|
||||
@@ -100,6 +102,15 @@ pub struct PreviewParams {
|
||||
pub max_side: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScreenshotParams {
|
||||
/// Downscale the window screenshot to this longest side (maximum 2048).
|
||||
/// Omit or pass zero to keep the original size.
|
||||
#[serde(default)]
|
||||
pub max_side: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SelectParams {
|
||||
pub index: usize,
|
||||
@@ -447,6 +458,9 @@ impl PhotocraftMcp {
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
if p.index.is_some() {
|
||||
return Ok(fail("`index` is only supported in headless mode; bridge previews capture the app window"));
|
||||
}
|
||||
self.screenshot(b, Some(max)).await
|
||||
}
|
||||
|
||||
@@ -571,7 +585,7 @@ impl PhotocraftMcp {
|
||||
}
|
||||
|
||||
#[tool(description = "Bridge mode: screenshot of the live app window as PNG.")]
|
||||
async fn ui_screenshot(&self, Parameters(p): Parameters<PreviewParams>) -> Result<CallToolResult, McpError> {
|
||||
async fn ui_screenshot(&self, Parameters(p): Parameters<ScreenshotParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => self.screenshot(b, p.max_side).await,
|
||||
None => bridge_only("ui_screenshot"),
|
||||
|
||||
@@ -507,6 +507,59 @@ async fn bridge_forwards_to_control_protocol() {
|
||||
app.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_lost_reply_does_not_replay_an_edit_and_next_call_reconnects() {
|
||||
use photocraft_automation::{BridgeClient, Headless};
|
||||
use std::time::Duration;
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let app = tokio::spawn(async move {
|
||||
let mut backend = Headless::new();
|
||||
backend.handle("doc.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let initial_layers = backend.session.active().unwrap().doc.layer_count();
|
||||
let initial_history = backend.session.active().unwrap().history.past_len();
|
||||
let mut seen = Vec::new();
|
||||
for connection in 0..2 {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let (read, mut write) = socket.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let (auth, authenticated) = photocraft_automation::security::authentication_reply(&line, CONTROL_TOKEN);
|
||||
assert!(authenticated, "each connection must authenticate");
|
||||
write.write_all(format!("{auth}\n").as_bytes()).await.unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
seen.push(request["params"]["command"].as_str().unwrap().to_owned());
|
||||
let result = backend.handle(request["method"].as_str().unwrap(), request["params"].clone()).unwrap();
|
||||
if connection == 0 {
|
||||
// The edit has completed, but both socket halves close before its reply.
|
||||
continue;
|
||||
}
|
||||
let reply = json!({"id": request["id"], "ok": true, "result": result});
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
}
|
||||
let document = backend.session.active().unwrap();
|
||||
assert_eq!(document.doc.layer_count(), initial_layers + 1, "the edit was applied once");
|
||||
assert_eq!(document.history.past_len(), initial_history + 1, "one undo step");
|
||||
seen
|
||||
});
|
||||
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap();
|
||||
let result = bridge.call("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Once"}})).await;
|
||||
let error = result.unwrap_err().to_string();
|
||||
assert!(error.contains("operation may have completed"), "{error}");
|
||||
assert!(error.contains("inspect"), "{error}");
|
||||
let document = bridge.call("engine.execute", json!({"command": "document.inspect"})).await.unwrap();
|
||||
assert_eq!(document["layers"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(app.await.unwrap(), ["layer.new.layer", "document.inspect"]);
|
||||
})
|
||||
.await
|
||||
.expect("bridge lost-reply regression must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_previews_downscale_before_enforcing_the_png_budget() {
|
||||
use photocraft_automation::budgets::MAX_PNG_BYTES;
|
||||
@@ -545,6 +598,60 @@ async fn bridge_previews_downscale_before_enforcing_the_png_budget() {
|
||||
app.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_previews_reject_document_indices() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let mut errors = Vec::new();
|
||||
for tool in ["ui_screenshot", "doc_render_preview"] {
|
||||
let args = json!({"index": 0, "max_side": 20}).as_object().unwrap().clone();
|
||||
let result = client.call_tool(CallToolRequestParams::new(tool).with_arguments(args)).await;
|
||||
let error = match result {
|
||||
Err(error) => Some(error.to_string()),
|
||||
Ok(reply) if reply.is_error == Some(true) => Some(text(&reply)),
|
||||
Ok(_) => None,
|
||||
};
|
||||
errors.push((tool, error));
|
||||
|
||||
let reply = call(&client, tool, json!({"max_side": 20})).await;
|
||||
assert_ne!(reply.is_error, Some(true), "{tool}: {}", text(&reply));
|
||||
let image = reply.content.iter().find_map(|content| content.as_image()).expect("screenshot");
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(&image.data).unwrap();
|
||||
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (20, 10), "{tool}");
|
||||
}
|
||||
let tools = client.list_all_tools().await.unwrap();
|
||||
let schema = &tools.iter().find(|tool| tool.name == "ui_screenshot").unwrap().input_schema;
|
||||
client.cancel().await.unwrap();
|
||||
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), app).await.unwrap().unwrap();
|
||||
|
||||
assert!(errors.iter().all(|(_, error)| error.as_ref().is_some_and(|error| error.contains("index"))), "{errors:?}");
|
||||
assert_eq!(seen.iter().filter(|request| request["method"] == "ui.screenshot").count(), 2, "rejected indices must not reach the app");
|
||||
assert!(schema["properties"].get("index").is_none(), "{schema:?}");
|
||||
assert!(schema["properties"].get("max_side").is_some(), "{schema:?}");
|
||||
assert_eq!(schema.get("additionalProperties"), Some(&json!(false)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn headless_previews_preserve_document_indices() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
json_of(&call(&client, "doc_new", json!({"width": 12, "height": 6, "background": "white"})).await);
|
||||
json_of(&call(&client, "doc_new", json!({"width": 6, "height": 12, "background": "black"})).await);
|
||||
for (args, dimensions, pixel) in [(json!({"index": 0, "max_side": 6}), (6, 3), [255, 255, 255, 255]), (json!({"max_side": 6}), (3, 6), [0, 0, 0, 255])] {
|
||||
let reply = call(&client, "doc_render_preview", args).await;
|
||||
assert_ne!(reply.is_error, Some(true), "{}", text(&reply));
|
||||
let image = reply.content.iter().find_map(|content| content.as_image()).expect("preview");
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(&image.data).unwrap();
|
||||
let image = photocraft_codecs::decode(&png).unwrap();
|
||||
assert_eq!(image.dimensions(), dimensions);
|
||||
assert!(image.to_rgba8().as_chunks::<4>().0.iter().all(|actual| actual[..] == pixel[..]));
|
||||
}
|
||||
assert_eq!(json_of(&call(&client, "session_list", json!({})).await)["active"], 1);
|
||||
let tools = client.list_all_tools().await.unwrap();
|
||||
let schema = &tools.iter().find(|tool| tool.name == "doc_render_preview").unwrap().input_schema;
|
||||
assert!(schema["properties"].get("index").is_some(), "{schema:?}");
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_rejects_non_loopback() {
|
||||
assert!(PhotocraftMcp::bridge("10.0.0.5:7878", CONTROL_TOKEN).is_err());
|
||||
|
||||
@@ -407,6 +407,8 @@ struct Layout {
|
||||
chunks_per_plane: usize,
|
||||
/// WhiteIsZero: invert the gray channel.
|
||||
invert: bool,
|
||||
/// The decoded sample (chunky) or file plane (planar) of the alpha channel (when there is one).
|
||||
alpha_sample: usize,
|
||||
/// Associated (premultiplied) alpha to turn straight.
|
||||
premultiplied: bool,
|
||||
/// Palette images: the colour map (16-bit RGB per index).
|
||||
@@ -419,6 +421,10 @@ impl Layout {
|
||||
fn out_channels(&self) -> usize {
|
||||
self.out_layout.channels()
|
||||
}
|
||||
/// The decoded sample (chunky) or file plane (planar) of output channel `k`.
|
||||
fn source_sample(&self, k: usize) -> usize {
|
||||
if self.out_layout.has_alpha() && k + 1 == self.out_channels() { self.alpha_sample } else { k }
|
||||
}
|
||||
fn out_row(&self) -> usize {
|
||||
self.w * self.out_channels() * self.out.bytes()
|
||||
}
|
||||
@@ -518,6 +524,17 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// TIFF 6.0 lets any extra sample be the alpha (#1349). For RGB and CMYK the `tiff` crate
|
||||
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
|
||||
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
|
||||
// position past the samples the file has is ignored.
|
||||
let color = match photometric {
|
||||
2 => 3,
|
||||
5 => 4,
|
||||
_ => 1,
|
||||
};
|
||||
let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp);
|
||||
let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0);
|
||||
let mut patches: Vec<(u64, Vec<u8>)> = Vec::new();
|
||||
let encode_u = |v: u64, len: usize| -> Vec<u8> {
|
||||
let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() };
|
||||
@@ -532,7 +549,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
// The directory is seen as the last of its chain: the chain was walked already, and a
|
||||
// cycle through it must not fail this page.
|
||||
patches.push((dir.next_at, vec![0; if f.big { 8 } else { 4 }]));
|
||||
if invert || palette.is_some() {
|
||||
if invert || palette.is_some() || all_samples {
|
||||
let e = photometric_entry.ok_or_else(|| err("missing PhotometricInterpretation"))?;
|
||||
let len = match e.ty {
|
||||
3 if e.count == 1 => 2,
|
||||
@@ -555,11 +572,13 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
return Err(err("inconsistent image directory"));
|
||||
}
|
||||
let ct = dec.colortype().map_err(map_err)?;
|
||||
let alpha_first = matches!(extra.first(), Some(1 | 2));
|
||||
let gray_alpha = alpha_first || (extra.is_empty() && spp == 2);
|
||||
let gray_alpha = alpha_at.is_some() || (extra.is_empty() && spp == 2);
|
||||
let (out_layout, chunk_spp) = match ct {
|
||||
_ if palette.is_some() => (ChannelLayout::Rgb, 1),
|
||||
tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1),
|
||||
tiff::ColorType::Multiband { num_samples, .. } if all_samples => {
|
||||
(if photometric == 2 { ChannelLayout::Rgba } else { ChannelLayout::CmykA }, usize::from(num_samples))
|
||||
}
|
||||
tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => {
|
||||
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
|
||||
}
|
||||
@@ -570,6 +589,11 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))),
|
||||
};
|
||||
let chunk_spp = if planar { 1 } else { chunk_spp };
|
||||
// The sample (chunky) or plane (planar) that holds the alpha: the colour samples come first.
|
||||
let alpha_sample = out_layout.color_channels() + alpha_at.unwrap_or(0);
|
||||
if out_layout.has_alpha() && (alpha_sample as u64 >= spp || (!planar && alpha_sample >= chunk_spp)) {
|
||||
return Err(err("the alpha sample is past the samples of a pixel"));
|
||||
}
|
||||
if matches!(src, Src::Bits(_)) && (out_layout.channels() > 1 || spp != 1) && palette.is_none() {
|
||||
return Err(CodecError::unsupported(F, format!("{bits}-bit samples with {spp} samples per pixel")));
|
||||
}
|
||||
@@ -628,7 +652,8 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
across,
|
||||
chunks_per_plane,
|
||||
invert: invert && palette.is_none(),
|
||||
premultiplied: out_layout.has_alpha() && extra.first() == Some(&1),
|
||||
alpha_sample,
|
||||
premultiplied: out_layout.has_alpha() && alpha_at.and_then(|a| extra.get(a)) == Some(&1),
|
||||
palette,
|
||||
scratch_cap,
|
||||
};
|
||||
@@ -722,8 +747,9 @@ fn decode_band(lay: &Layout, dec: &mut Decoder<Patched<'_>>, scratch: &mut Vec<u
|
||||
let px = lay.out_channels() * lay.out.bytes();
|
||||
let mut tally = Tally::default();
|
||||
for plane in 0..lay.planes {
|
||||
let file_plane = lay.source_sample(plane);
|
||||
for col in 0..lay.across {
|
||||
let chunk = plane
|
||||
let chunk = file_plane
|
||||
.checked_mul(lay.chunks_per_plane)
|
||||
.and_then(|c| c.checked_add(b.checked_mul(lay.across)?))
|
||||
.and_then(|c| c.checked_add(col))
|
||||
@@ -795,7 +821,7 @@ fn convert_row(lay: &Layout, src: &[u8], dst: &mut [u8], n: usize, plane: usize)
|
||||
transfer(lay.src, src, 1, 0, dst, lay.out, och, plane, n);
|
||||
} else {
|
||||
for k in 0..och {
|
||||
transfer(lay.src, src, lay.chunk_spp, k, dst, lay.out, och, k, n);
|
||||
transfer(lay.src, src, lay.chunk_spp, lay.source_sample(k), dst, lay.out, och, k, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,48 @@ fn extra_samples_that_are_not_alpha_are_dropped() {
|
||||
assert_eq!(img.data(), &rgb_of(2, 1)[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_after_an_unspecified_extra_sample_is_kept() {
|
||||
// #1349: TIFF 6.0 lets alpha be any extra sample; ExtraSamples [0, 2] puts it second.
|
||||
let (w, h) = (5usize, 3usize);
|
||||
for (photometric, color, layout) in [(2u16, 3usize, ChannelLayout::Rgba), (5, 4, ChannelLayout::CmykA), (1, 1, ChannelLayout::GrayA)] {
|
||||
let spp = color + 2;
|
||||
let t = truth(w * h * spp, 13 + spp as u64);
|
||||
for (little, big) in ORDERS {
|
||||
for (bits, format, file, want_all, sample) in depths(&t, little) {
|
||||
let bps = bits as usize / 8;
|
||||
// The colour samples, then the second extra sample (the alpha).
|
||||
let want: Vec<u8> = want_all.chunks(spp * bps).flat_map(|p| [&p[..color * bps], &p[(color + 1) * bps..]].concat()).collect();
|
||||
for planar in [false, true] {
|
||||
let chunks = if planar { planar_strips(&file, w, h, spp, bps, 2) } else { file.chunks(w * spp * bps * 2).map(<[u8]>::to_vec).collect() };
|
||||
let mut d = Dir { entries: base(w as u32, h as u32, bits, spp as u16, photometric), chunks, tiled: false }
|
||||
.tag(278, LONG, &[2])
|
||||
.tag(339, SHORT, &vec![u64::from(format); spp])
|
||||
.tag(338, SHORT, &[0, 2]);
|
||||
if planar {
|
||||
d = d.tag(284, SHORT, &[2]);
|
||||
}
|
||||
let img = decode_ok(&build(little, big, &[d], &[0]).bytes);
|
||||
let what = format!("photometric {photometric} little {little} big {big} bits {bits} planar {planar}");
|
||||
assert_eq!((img.layout(), img.sample_type()), (layout, sample), "{what}");
|
||||
assert_eq!(img.data(), &want[..], "{what}");
|
||||
assert_eq!(img.warnings, [], "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The issue's file: 2x2 RGB, every pixel 200 100 50, then 17 (unspecified), then alpha 128.
|
||||
let px: Vec<u8> = [200u8, 100, 50, 17, 128].repeat(4);
|
||||
let d = strips(2, 2, 8, 5, 2, 2, &px).tag(338, SHORT, &[0, 2]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.data(), &[200u8, 100, 50, 128].repeat(4)[..]);
|
||||
// Associated alpha in the same position is made straight.
|
||||
let px: Vec<u8> = [100u8, 50, 25, 17, 128].repeat(4);
|
||||
let d = strips(2, 2, 8, 5, 2, 2, &px).tag(338, SHORT, &[0, 1]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.data(), &[199u8, 100, 50, 128].repeat(4)[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn associated_alpha_is_made_straight() {
|
||||
// Premultiplied: half-transparent mid gray is stored as 64 with alpha 128.
|
||||
|
||||
@@ -309,7 +309,14 @@ pub fn thumbnail_buffer(doc: &Document, max_side: u32) -> Buffer {
|
||||
/// The document's composite area-averaged (premultiplied) down to `w`×`h` (clamped to the
|
||||
/// document size), rendered in bands so no full-size composite is held.
|
||||
pub fn render_reduced(doc: &Document, w: u32, h: u32) -> Buffer {
|
||||
render_reduced_in_bands(doc, w, h, None, 0)
|
||||
render_reduced_in_bands(doc, doc.bounds(), w, h, None, 0)
|
||||
}
|
||||
|
||||
/// [`render_reduced`] of any area of the document plane, `rect`, which may lie past the canvas
|
||||
/// (layer pixels there are kept until a crop deletes them). The buffer's rect is in output pixels
|
||||
/// from (0, 0).
|
||||
pub fn render_reduced_rect(doc: &Document, rect: Rect, w: u32, h: u32) -> Buffer {
|
||||
render_reduced_in_bands(doc, rect, w, h, None, 0)
|
||||
}
|
||||
|
||||
/// The part of [`render_reduced`]`(doc, w, h)` that a change to `damage` (document pixels) can
|
||||
@@ -317,13 +324,12 @@ pub fn render_reduced(doc: &Document, w: u32, h: u32) -> Buffer {
|
||||
/// same values the whole reduction gives them. The buffer's rect is in output pixels (empty when
|
||||
/// `damage` misses the document), so a reduced canvas texture can update only what a stroke touched.
|
||||
pub fn render_reduced_damage(doc: &Document, w: u32, h: u32, damage: Rect) -> Buffer {
|
||||
render_reduced_in_bands(doc, w, h, Some(damage), 0)
|
||||
render_reduced_in_bands(doc, doc.bounds(), w, h, Some(damage), 0)
|
||||
}
|
||||
|
||||
/// [`render_reduced`] (or, with `damage`, [`render_reduced_damage`]) with an explicit band height
|
||||
/// (see [`render_bands`]).
|
||||
fn render_reduced_in_bands(doc: &Document, w: u32, h: u32, damage: Option<Rect>, band_rows: i32) -> Buffer {
|
||||
let b = doc.bounds();
|
||||
/// [`render_reduced_rect`] (or, with `damage`, [`render_reduced_damage`]) of area `b` with an
|
||||
/// explicit band height (see [`render_bands`]).
|
||||
fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Option<Rect>, band_rows: i32) -> Buffer {
|
||||
let (fw, fh) = (b.width() as usize, b.height() as usize);
|
||||
let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize);
|
||||
let full = Rect::from_xywh(0, 0, w as u32, h as u32);
|
||||
|
||||
@@ -1405,7 +1405,7 @@ fn reference_reduce(doc: &Document, w: usize, h: usize) -> Vec<[f32; 4]> {
|
||||
fn reduced_render_matches_the_full_composite_averaged() {
|
||||
let d = tall_doc(53, 1000);
|
||||
for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 512), (1, 1, 256)] {
|
||||
let got = render_reduced_in_bands(&d, w, h, None, band);
|
||||
let got = render_reduced_in_bands(&d, d.bounds(), w, h, None, band);
|
||||
assert_eq!((got.rect.width(), got.rect.height()), (w, h));
|
||||
assert_eq!(got.px, reference_reduce(&d, w as usize, h as usize), "{w}x{h} band {band}");
|
||||
}
|
||||
@@ -1421,9 +1421,9 @@ fn reduced_damage_matches_the_whole_reduction() {
|
||||
// whole reduction's, for uneven factors, damage on span edges and bands smaller than the area.
|
||||
let d = tall_doc(53, 1000);
|
||||
for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 64), (53, 1000, 0), (1, 1, 256)] {
|
||||
let all = render_reduced_in_bands(&d, w, h, None, band);
|
||||
let all = render_reduced_in_bands(&d, d.bounds(), w, h, None, band);
|
||||
for dmg in [Rect::new(0, 0, 1, 1), Rect::new(5, 17, 6, 18), Rect::new(12, 300, 40, 701), Rect::new(-9, 990, 80, 2000), Rect::new(0, 0, 53, 1000)] {
|
||||
let part = render_reduced_in_bands(&d, w, h, Some(dmg), band);
|
||||
let part = render_reduced_in_bands(&d, d.bounds(), w, h, Some(dmg), band);
|
||||
let r = part.rect;
|
||||
assert!(!r.is_empty() && r.x0 >= 0 && r.y0 >= 0 && r.x1 as u32 <= w && r.y1 as u32 <= h, "{w}x{h} {dmg:?} -> {r:?}");
|
||||
for y in r.y0..r.y1 {
|
||||
@@ -1442,10 +1442,40 @@ fn reduced_damage_matches_the_whole_reduction() {
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(render_reduced_in_bands(&d, w, h, Some(Rect::new(60, 0, 70, 10)), band).rect.is_empty());
|
||||
assert!(render_reduced_in_bands(&d, d.bounds(), w, h, Some(Rect::new(60, 0, 70, 10)), band).rect.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_render_of_an_area_past_the_canvas() {
|
||||
// The Crop tool shows layer pixels beyond the canvas: rendered in place, they must equal the
|
||||
// same pixels on a canvas that holds them (adjustment layers apply out there too).
|
||||
let paint = |d: &mut Document, dx: i32, dy: i32| {
|
||||
let mut l = Layer::raster("big", PixelFormat::RGBA8);
|
||||
let s = l.surface_mut().unwrap();
|
||||
for y in -10..40 {
|
||||
for x in -20..60 {
|
||||
let a = if x < 0 || y >= 30 { 0.6 } else { 1.0 };
|
||||
s.fill_rect(Rect::new(x + dx, y + dy, x + dx + 1, y + dy + 1), &[(x + 20) as f32 / 80.0, (y + 10) as f32 / 50.0, 0.5, a]);
|
||||
}
|
||||
}
|
||||
d.layers.push(l);
|
||||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||||
};
|
||||
let mut d = Document::new("d", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
|
||||
paint(&mut d, 0, 0);
|
||||
let mut whole = Document::new("w", Size::new(80, 50), ColorMode::Rgb, SampleType::U8);
|
||||
paint(&mut whole, 20, 10);
|
||||
let area = Rect::new(-20, -10, 60, 40);
|
||||
for (w, h) in [(80, 50), (8, 5), (27, 13), (1, 1)] {
|
||||
let got = render_reduced_rect(&d, area, w, h);
|
||||
assert_eq!(got.rect, Rect::new(0, 0, w as i32, h as i32));
|
||||
assert_eq!(got.px, render_reduced(&whole, w, h).px, "{w}x{h}");
|
||||
}
|
||||
// Past every layer there is nothing to show.
|
||||
assert!(render_reduced_rect(&d, Rect::new(100, 100, 120, 110), 20, 10).px.iter().all(|p| p[3] == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_documents_thumbnail_from_a_proxy() {
|
||||
// 4200 × 4200 (17.6 MP) is above PROXY_THUMBNAIL_PIXELS: the proxy path must still give a
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//!
|
||||
//! The list lives on [`Session`] so the panel, the CLI, the control channel and MCP share one
|
||||
//! copy. Recording copies replayable journal entries (commands whose [`CommandSpec::journal`] is
|
||||
//! set, except `actions.*`) into an action. Playback runs those steps with [`Session::execute`]
|
||||
//! set, except `actions.*` and the already-expanded `edit.transform.again`) into an action.
|
||||
//! Playback runs those steps with [`Session::execute`]
|
||||
//! and stops at the first error, leaving one history step per step that ran.
|
||||
//!
|
||||
//! An untrusted session installs [`Session::authorize`]. `actions.play` calls it for every nested
|
||||
@@ -58,10 +59,10 @@ impl ActionState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A journal entry worth replaying: the command records itself, and it is not an `actions.*`
|
||||
/// command (those would nest recording and playback).
|
||||
/// A journal entry worth replaying: omit Actions commands and the already-expanded Again wrapper.
|
||||
pub fn replayable(id: &str) -> bool {
|
||||
!id.starts_with("actions.") && crate::commands::find(id).is_some_and(|c| c.journal)
|
||||
// Again journals its concrete transform too; recording the wrapper would replay it twice.
|
||||
!id.starts_with("actions.") && id != "edit.transform.again" && crate::commands::find(id).is_some_and(|c| c.journal)
|
||||
}
|
||||
|
||||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
@@ -299,6 +300,74 @@ mod tests {
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn translated_square(s: &mut Session, depth: u32) {
|
||||
s.execute("file.new", json!({"width": 20, "height": 8, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.edit("red square", |doc, _| {
|
||||
doc.layers[0].surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 4, 0], "interpolation": "nearest"})).unwrap();
|
||||
assert_eq!(pixel(s, 0, 4, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_transform_again_replays_one_concrete_transform_at_each_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
translated_square(&mut s, depth);
|
||||
let from = s.journal.len();
|
||||
s.execute("actions.record", json!({"name": "Again"})).unwrap();
|
||||
s.execute("edit.transform.again", json!({})).unwrap();
|
||||
let ids: Vec<&str> = s.journal.iter().skip(from).map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["edit.transform", "edit.transform.again"], "both commands stay in the journal during recording");
|
||||
let live_count = s.journal.iter().skip(from).filter(|(id, _)| replayable(id)).count();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let recorded = s.execute("actions.get", json!({"action": "Again"})).unwrap();
|
||||
let steps = recorded["steps"].as_array().unwrap();
|
||||
assert_eq!(steps.len(), 1, "depth {depth}: record the concrete transform once");
|
||||
assert_eq!(live_count, 1, "depth {depth}: the live Actions count agrees");
|
||||
assert_eq!(steps[0][0], "edit.transform");
|
||||
assert_eq!(steps[0][1]["matrix"], json!([1, 0, 0, 1, 4, 0]));
|
||||
assert_eq!(steps[0][1]["interpolation"], "nearest");
|
||||
|
||||
translated_square(&mut s, depth);
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
let played = s.execute("actions.play", json!({"action": "Again"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "depth {depth}: {played}");
|
||||
assert_eq!(played["ran"], 1, "depth {depth}: apply the recorded transform once");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before + 1, "depth {depth}: one undo step");
|
||||
assert_eq!(pixel(&s, 0, 8, 2), vec![1.0, 0.0, 0.0, 1.0], "depth {depth}: moved four pixels");
|
||||
assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4], "depth {depth}: no second transform");
|
||||
assert!(s.undo());
|
||||
assert_eq!(pixel(&s, 0, 4, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(pixel(&s, 0, 8, 2), vec![0.0; 4]);
|
||||
assert!(s.redo());
|
||||
assert_eq!(pixel(&s, 0, 8, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(pixel(&s, 0, 4, 2), vec![0.0; 4]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_explicit_transform_keeps_one_replayable_step() {
|
||||
let mut s = Session::new();
|
||||
translated_square(&mut s, 8);
|
||||
s.execute("actions.record", json!({"name": "Move"})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 4, 0], "interpolation": "nearest"})).unwrap();
|
||||
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 1);
|
||||
let recorded = s.execute("actions.get", json!({"action": "Move"})).unwrap();
|
||||
assert_eq!(recorded["steps"][0][0], "edit.transform");
|
||||
|
||||
translated_square(&mut s, 8);
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
let played = s.execute("actions.play", json!({"action": "Move"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
assert_eq!(played["ran"], 1);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before + 1);
|
||||
assert_eq!(pixel(&s, 0, 8, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_keeps_only_replayable_steps() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -174,12 +174,14 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> {
|
||||
/// Run several commands as one history step labelled `label`.
|
||||
fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let (before, history) = (st.doc.clone(), st.history.clone());
|
||||
let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target());
|
||||
let r = f(s);
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
st.history = history;
|
||||
match r {
|
||||
Ok(v) => {
|
||||
// As in `Session::edit`: undo targets what was selected just before the step (#1356).
|
||||
st.history.set_current_layers(prior);
|
||||
st.history.record(label, before, st.layer_target());
|
||||
st.history.trim(&st.doc);
|
||||
st.coalesce = None;
|
||||
|
||||
@@ -338,6 +338,12 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The canvas grown to every layer's pixels: Image › Reveal All's new canvas, and what the Crop
|
||||
/// tool shows while a frame is edited.
|
||||
pub fn reveal_all_bounds(doc: &Document) -> Rect {
|
||||
doc.walk().into_iter().filter_map(|(_, _, l)| l.surface().map(|s| s.content_bounds())).fold(doc.bounds(), |a, b| a.union(&b))
|
||||
}
|
||||
|
||||
// ---------- layers ----------
|
||||
|
||||
fn layer_from_background(s: &mut Session) -> Result<Value> {
|
||||
@@ -652,9 +658,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("image.adjustments.equalize", "Equalize", &["Image", "Adjustments"], None, "{}", has_pixels, |s, _| equalize(s)),
|
||||
spec!("image.revealAll", "Reveal All", &["Image"], None, "{}", has_doc, |s, _| {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let canvas = d.doc.bounds();
|
||||
let all = d.doc.walk().into_iter().filter_map(|(_, _, l)| l.surface().map(|s| s.content_bounds())).fold(canvas, |a, b| a.union(&b));
|
||||
if all == canvas {
|
||||
let all = reveal_all_bounds(&d.doc);
|
||||
if all == d.doc.bounds() {
|
||||
return Ok(json!({"changed": false}));
|
||||
}
|
||||
s.execute("image.crop", json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}))?;
|
||||
@@ -1037,6 +1042,7 @@ mod tests {
|
||||
let mut s = session(8);
|
||||
s.execute("layer.translate", json!({"dx": 0, "dy": 0})).ok();
|
||||
paint_square(&mut s, Rect::new(-5, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(reveal_all_bounds(doc(&s)), Rect::new(-5, 0, 40, 30));
|
||||
s.execute("image.revealAll", json!({})).unwrap();
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height), (45, 30));
|
||||
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false);
|
||||
|
||||
@@ -1493,8 +1493,9 @@ mod tests {
|
||||
assert_eq!(sel(&s), vec![m]);
|
||||
s.undo();
|
||||
assert_eq!(doc(&s).layer_count(), before);
|
||||
assert_eq!(sel(&s), vec![c], "undo leaves a valid selection");
|
||||
assert_eq!(sel(&s), vec![a, c], "undo selects the layers that were merged");
|
||||
// One layer selected: Merge Down.
|
||||
s.execute("layer.select", json!({"layer": c.0})).unwrap();
|
||||
s.execute("layer.mergeLayers", json!({})).unwrap();
|
||||
assert_eq!(doc(&s).layer_count(), before - 1);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ pub struct DocState {
|
||||
pub active_layer: Option<LayerId>,
|
||||
/// Every selected layer in the Layers panel (⌘/⇧-click), in selection order. Like
|
||||
/// `active_layer`, selecting is not a history step, but undo and redo restore the layers each
|
||||
/// state targeted when it was created; see [`DocState::selected_layers`].
|
||||
/// state targeted when the next step was taken; see [`DocState::selected_layers`].
|
||||
pub selected_layers: Vec<LayerId>,
|
||||
/// Anchor of ⇧-click range selection (the last plainly or ⌘-clicked layer).
|
||||
pub layer_anchor: Option<LayerId>,
|
||||
@@ -451,6 +451,9 @@ impl Session {
|
||||
let restrict = self.color_restrict;
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let before = st.doc.clone();
|
||||
// Selecting layers is not a step, so the state this edit leaves behind targets what was
|
||||
// selected just before it (#1356): undoing a stroke keeps the painted layer active.
|
||||
let prior = st.layer_target();
|
||||
let mut doc = (*before).clone();
|
||||
let mut active = st.active_layer;
|
||||
let r = f(&mut doc, &mut active)?;
|
||||
@@ -465,6 +468,7 @@ impl Session {
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let layers = st.layer_target();
|
||||
if key.is_none() || st.coalesce != key || !st.history.can_undo() {
|
||||
st.history.set_current_layers(prior);
|
||||
st.history.record(label, before, layers);
|
||||
st.history.trim(&st.doc);
|
||||
} else {
|
||||
|
||||
@@ -378,6 +378,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"deleted": n}))
|
||||
}
|
||||
|
||||
/// The most parts one Divide Slice makes. Resolving the slice list is quadratic in the slice count
|
||||
/// (`slices::resolve`), so a 1000 x 1000 division, a million slices, left every later list or
|
||||
/// export with ~5e11 steps of work (#1020).
|
||||
const MAX_DIVIDE_PARTS: i32 = 10_000;
|
||||
|
||||
fn divide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "slice.divide";
|
||||
let t = find(s, p, cmd)?;
|
||||
@@ -386,6 +391,9 @@ fn divide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if down == 1 && across == 1 {
|
||||
return Err(bad(cmd, "give \"horizontal\" (slices down) and/or \"vertical\" (slices across) > 1"));
|
||||
}
|
||||
if down * across > MAX_DIVIDE_PARTS {
|
||||
return Err(bad(cmd, format!("{down} x {across} = {} parts is more than one division makes ({MAX_DIVIDE_PARTS})", down * across)));
|
||||
}
|
||||
let r = match t {
|
||||
Target::Auto(r) => r,
|
||||
Target::Stored(id) => s.active().and_then(|d| d.doc.slices.get(id)).map(|sl| sl.rect).ok_or_else(|| bad(cmd, "no such slice"))?,
|
||||
@@ -517,7 +525,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"slice.divide",
|
||||
"Divide Slice…",
|
||||
&[],
|
||||
r##"{"slice":id | "number":n,"horizontal":n=1 (slices down),"vertical":n=1 (slices across)} → {slices}"##,
|
||||
r##"{"slice":id | "number":n,"horizontal":n=1 (slices down),"vertical":n=1 (slices across); at most 10000 parts} → {slices}"##,
|
||||
unlocked,
|
||||
divide
|
||||
),
|
||||
|
||||
@@ -325,3 +325,24 @@ fn out_of_range_slice_ids_are_rejected_not_wrapped() {
|
||||
assert_eq!(doc(&s).slices.list.len(), 1);
|
||||
assert_eq!(doc(&s).slices.list[0].name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_refuses_more_parts_than_one_division_makes() {
|
||||
// #1020: 1000 x 1000 was accepted (a million slices), and resolving the list is quadratic in
|
||||
// the slice count.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1000, "height": 1000})).unwrap();
|
||||
s.execute("slice.new", json!({"rect": [0, 0, 1000, 1000]})).unwrap();
|
||||
let id = doc(&s).slices.list[0].id;
|
||||
for (h, v) in [(101, 100), (100, 101), (1000, 1000)] {
|
||||
let (before, past) = (doc(&s).slices.clone(), s.active().unwrap().history.past_len());
|
||||
let err = s.execute("slice.divide", json!({"slice": id, "horizontal": h, "vertical": v})).unwrap_err();
|
||||
assert!(err.to_string().contains("10000"), "{h} x {v}: {err}");
|
||||
assert_eq!((&doc(&s).slices, s.active().unwrap().history.past_len()), (&before, past), "{h} x {v}: nothing changed");
|
||||
}
|
||||
// At the limit it divides, and the list resolves.
|
||||
let r = s.execute("slice.divide", json!({"slice": id, "horizontal": 100, "vertical": 100})).unwrap();
|
||||
assert_eq!(r["count"], 10_000);
|
||||
assert_eq!(doc(&s).slices.list.len(), 10_000);
|
||||
assert!(s.execute("slice.list", json!({})).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Undoing a paint stroke keeps the painted layer active (#1356), as in Photoshop: selecting a
|
||||
//! layer is not a history step, so the state a step leaves behind targets what was selected just
|
||||
//! before the step, not what was selected when the state before it was made.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn target(s: &Session) -> (Option<LayerId>, Vec<LayerId>) {
|
||||
let st = s.active().unwrap();
|
||||
(st.active_layer, st.selected_layers.clone())
|
||||
}
|
||||
|
||||
fn one(id: LayerId) -> (Option<LayerId>, Vec<LayerId>) {
|
||||
(Some(id), vec![id])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_stroke_on_a_rasterized_shape_keeps_it_active() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
let bg = s.active().unwrap().doc.layers[0].id;
|
||||
let id = LayerId(s.execute("shape.create", json!({"kind": "ellipse", "rect": [4, 4, 40, 30]})).unwrap()["layer"].as_u64().unwrap());
|
||||
// Rasterize the shape layer while the Background is the active layer (Layers panel context
|
||||
// menu), then select the rasterized layer and paint on it.
|
||||
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
s.execute("layer.rasterize.layer", json!({"layer": id.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": id.0})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[4, 40], [60, 40]], "size": 6})).unwrap();
|
||||
assert!(s.undo());
|
||||
assert_eq!(target(&s), one(id), "undo of the stroke left the painted layer (background is {bg:?})");
|
||||
assert!(s.redo());
|
||||
assert_eq!(target(&s), one(id), "redo targets the painted layer");
|
||||
// Undoing the stroke and then the rasterize goes back to what was active before each step.
|
||||
assert!(s.undo());
|
||||
assert!(s.undo());
|
||||
assert_eq!(target(&s), one(bg), "the rasterize was taken with the background active");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_fill_keeps_the_filled_layer_active() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
let bg = s.active().unwrap().doc.layers[0].id;
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let new = target(&s).0.unwrap();
|
||||
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(s.undo());
|
||||
assert_eq!(target(&s), one(bg), "the fill was on the background");
|
||||
// Undoing New Layer still goes back to the layer active before it.
|
||||
assert!(s.undo());
|
||||
assert_eq!(target(&s), one(bg));
|
||||
// Redo returns New Layer's state as it was left: the background had been selected in it.
|
||||
assert!(s.redo());
|
||||
assert_eq!(target(&s), one(bg));
|
||||
assert!(s.active().unwrap().doc.layer(new).is_some());
|
||||
}
|
||||
@@ -96,8 +96,9 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
|
||||
doc.icc_profile = img.icc.clone().map(Arc::new);
|
||||
doc.metadata.exif = img.meta.exif.clone().map(Arc::new);
|
||||
doc.metadata.xmp = img.meta.xmp.clone();
|
||||
if let Some((x, _)) = img.meta.dpi {
|
||||
if let Some((x, y)) = img.meta.dpi {
|
||||
doc.resolution_dpi = x;
|
||||
warnings.extend(crate::unequal_resolution_warning(f64::from(x), f64::from(y)));
|
||||
}
|
||||
if !img.meta.text.is_empty() {
|
||||
warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len()));
|
||||
|
||||
@@ -94,6 +94,14 @@ pub struct ImportResult {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The import note for a file whose horizontal and vertical resolutions (`x`, `y`, in pixels per
|
||||
/// inch) differ: a document has one resolution, so it keeps the horizontal one and saves it on
|
||||
/// both axes (#1018). Differences under 0.01 ppi are below what the warning can show.
|
||||
pub(crate) fn unequal_resolution_warning(x: f64, y: f64) -> Option<String> {
|
||||
((x - y).abs() >= 0.01)
|
||||
.then(|| format!("the vertical resolution ({y:.2} ppi) differs from the horizontal ({x:.2} ppi); the document keeps {x:.2} ppi for both"))
|
||||
}
|
||||
|
||||
/// Result of [`export`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExportResult {
|
||||
|
||||
@@ -450,6 +450,14 @@ impl Ex {
|
||||
if w.is_empty() {
|
||||
self.warnings.push(format!("layer \"{}\": {} adjustment is not yet written to PSD", l.name, a.label()));
|
||||
}
|
||||
// Photoshop's `clrL` descriptor has no interpolation setting, so the
|
||||
// reopened layer renders trilinear.
|
||||
if matches!(a, photocraft_doc::Adjustment::ColorLookup { tetrahedral: true, .. }) {
|
||||
self.warnings.push(format!(
|
||||
"layer \"{}\": Color Lookup tetrahedral interpolation is saved as trilinear (PSD has no interpolation setting)",
|
||||
l.name
|
||||
));
|
||||
}
|
||||
regenerated.extend(w);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,8 +540,10 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
match r.id {
|
||||
ids::RESOLUTION_INFO => {
|
||||
if let Ok(ri) = photocraft_psd::ResolutionInfo::from_bytes(&r.data) {
|
||||
let f = if ri.h_res_unit == 2 { 2.54 } else { 1.0 };
|
||||
doc.resolution_dpi = (ri.h_res() * f) as f32;
|
||||
let ppi = |res: f64, unit: u16| if unit == 2 { res * 2.54 } else { res };
|
||||
let (x, y) = (ppi(ri.h_res(), ri.h_res_unit), ppi(ri.v_res(), ri.v_res_unit));
|
||||
doc.resolution_dpi = x as f32;
|
||||
warnings.extend(crate::unequal_resolution_warning(x, y));
|
||||
}
|
||||
}
|
||||
ids::ICC_PROFILE => doc.icc_profile = Some(Arc::new(r.data.clone())),
|
||||
|
||||
@@ -70,8 +70,10 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
|
||||
// The profile, resolution, XMP and EXIF are the TIFF's own tags; they win over any copy
|
||||
// in the resources.
|
||||
set_resource(&mut resources, ids::ICC_PROFILE, img.icc.clone());
|
||||
if let Some((x, _)) = img.meta.dpi.filter(|d| d.0 > 0.0) {
|
||||
set_resource(&mut resources, ids::RESOLUTION_INFO, Some(ResolutionInfo::from_dpi(f64::from(x)).to_bytes()));
|
||||
if let Some((x, y)) = img.meta.dpi.filter(|d| d.0 > 0.0) {
|
||||
// Both axes, so the PSD import notes a vertical resolution the document can't keep.
|
||||
let ri = ResolutionInfo { v_res_fixed: ResolutionInfo::from_dpi(f64::from(y)).v_res_fixed, ..ResolutionInfo::from_dpi(f64::from(x)) };
|
||||
set_resource(&mut resources, ids::RESOLUTION_INFO, Some(ri.to_bytes()));
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
set_resource(&mut resources, ids::XMP, Some(xmp.as_bytes().to_vec()));
|
||||
|
||||
@@ -200,6 +200,28 @@ fn every_adjustment_kind_round_trips_16bit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's `clrL` has no interpolation setting, so tetrahedral comes back trilinear: the
|
||||
/// save must say so (#1347). Trilinear saves without a warning.
|
||||
#[test]
|
||||
fn color_lookup_tetrahedral_warns_on_psd_save() {
|
||||
let lut = photocraft_cms::lutfile::LutFile::from_fn("t", 2, |c| if c == [1.0; 3] { [1.0; 3] } else { [0.0; 3] });
|
||||
for tetrahedral in [true, false] {
|
||||
let adj = Adjustment::ColorLookup { name: "test.cube".into(), lut: Some(Arc::new(lut.data.clone())), size: 2, tetrahedral, dither: false };
|
||||
let mut d = Document::new("lut", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = d.pixel_format();
|
||||
d.layers.push(raster("Background", fmt, d.bounds(), 1, false));
|
||||
d.layers.push(Layer::new("Look", LayerContent::Adjustment(adj)));
|
||||
let (back, warnings, _) = export_import(&d);
|
||||
let warned = warnings.iter().any(|w| w.contains("\"Look\"") && w.contains("tetrahedral") && w.contains("trilinear"));
|
||||
assert_eq!(warned, tetrahedral, "tetrahedral={tetrahedral}: {warnings:?}");
|
||||
let content = back.layers.get(1).map(|l| &l.content);
|
||||
let Some(LayerContent::Adjustment(Adjustment::ColorLookup { tetrahedral: t, lut: Some(_), size: 2, .. })) = content else {
|
||||
panic!("Color Lookup lost: {:?}", content.map(LayerContent::kind_name))
|
||||
};
|
||||
assert!(!t, "PSD reopens as trilinear");
|
||||
}
|
||||
}
|
||||
|
||||
/// One document per blend mode: the mode on a pixel layer (with fill opacity and a mask), on a
|
||||
/// clipped layer, on a group, inside a pass-through group, and on an adjustment layer.
|
||||
fn blend_doc(mode: BlendMode) -> Document {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! A file whose horizontal and vertical resolutions differ opens at the horizontal one, and the
|
||||
//! import says the vertical one is not kept (#1018). Equal axes, the common case, open silently.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, Metadata, SampleType as CSample};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::resources::ids;
|
||||
use photocraft_psd::testgen;
|
||||
use photocraft_psd::{Compression, ImageResource, ResolutionInfo, Version};
|
||||
|
||||
fn resolution_notes(r: &ImportResult) -> Vec<&String> {
|
||||
r.warnings.iter().filter(|w| w.contains("vertical resolution")).collect()
|
||||
}
|
||||
|
||||
fn png(dpi: (f32, f32)) -> Vec<u8> {
|
||||
let meta = Metadata { dpi: Some(dpi), ..Default::default() };
|
||||
let img = Image::from_raw(2, 2, ChannelLayout::Rgb, CSample::U8, vec![10u8; 12]).unwrap().with_meta(meta);
|
||||
photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_with_unequal_axes_reports_the_dropped_vertical_resolution() {
|
||||
let r = import("x.png", &png((300.0, 150.0))).unwrap();
|
||||
assert!((r.document.resolution_dpi - 300.0).abs() < 0.01, "{}", r.document.resolution_dpi);
|
||||
let notes = resolution_notes(&r);
|
||||
assert_eq!(notes.len(), 1, "{:?}", r.warnings);
|
||||
assert!(notes[0].contains("150.01") && notes[0].contains("300.00"), "{notes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_with_equal_axes_opens_silently() {
|
||||
let r = import("x.png", &png((300.0, 300.0))).unwrap();
|
||||
assert!((r.document.resolution_dpi - 300.0).abs() < 0.01, "{}", r.document.resolution_dpi);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
fn psd(ri: ResolutionInfo) -> Vec<u8> {
|
||||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||||
f.resources.retain(|r| r.id != ids::RESOLUTION_INFO);
|
||||
f.resources.push(ImageResource::new(ids::RESOLUTION_INFO, ri.to_bytes()));
|
||||
f.to_bytes().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_with_unequal_axes_reports_the_dropped_vertical_resolution() {
|
||||
let ri = ResolutionInfo { v_res_fixed: ResolutionInfo::from_dpi(150.0).v_res_fixed, ..ResolutionInfo::from_dpi(300.0) };
|
||||
let r = import("x.psd", &psd(ri)).unwrap();
|
||||
assert!((r.document.resolution_dpi - 300.0).abs() < 1e-3, "{}", r.document.resolution_dpi);
|
||||
let notes = resolution_notes(&r);
|
||||
assert_eq!(notes.len(), 1, "{:?}", r.warnings);
|
||||
assert!(notes[0].contains("150.00") && notes[0].contains("300.00"), "{notes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_with_equal_axes_opens_silently() {
|
||||
let r = import("x.psd", &psd(ResolutionInfo::from_dpi(300.0))).unwrap();
|
||||
assert!((r.document.resolution_dpi - 300.0).abs() < 1e-3, "{}", r.document.resolution_dpi);
|
||||
assert!(resolution_notes(&r).is_empty(), "{:?}", r.warnings);
|
||||
// Each axis is read in its own unit: 100 pixels per centimetre on both is 254 ppi on both.
|
||||
let mut cm = ResolutionInfo::from_dpi(100.0);
|
||||
cm.h_res_unit = 2;
|
||||
cm.v_res_unit = 2;
|
||||
let r = import("x.psd", &psd(cm)).unwrap();
|
||||
assert!((r.document.resolution_dpi - 254.0).abs() < 1e-3, "{}", r.document.resolution_dpi);
|
||||
assert!(resolution_notes(&r).is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A layered TIFF opens through the PSD importer with the TIFF's own resolution tags.
|
||||
fn layered_tiff(dpi: (f32, f32)) -> Vec<u8> {
|
||||
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
|
||||
let out = export(&d, "x.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap();
|
||||
let mut img = photocraft_codecs::decode(&out.bytes).unwrap();
|
||||
assert!(img.meta.photoshop_layers.is_some(), "layer data");
|
||||
img.meta.dpi = Some(dpi);
|
||||
photocraft_codecs::encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_tiff_with_unequal_axes_reports_the_dropped_vertical_resolution() {
|
||||
let r = import("x.tif", &layered_tiff((300.0, 150.0))).unwrap();
|
||||
assert!(r.document.layers.len() > 1, "opened with its layers");
|
||||
assert!((r.document.resolution_dpi - 300.0).abs() < 1e-3, "{}", r.document.resolution_dpi);
|
||||
let notes = resolution_notes(&r);
|
||||
assert_eq!(notes.len(), 1, "{:?}", r.warnings);
|
||||
assert!(notes[0].contains("150.00") && notes[0].contains("300.00"), "{notes:?}");
|
||||
// Equal axes: no note.
|
||||
let r = import("x.tif", &layered_tiff((300.0, 300.0))).unwrap();
|
||||
assert!(resolution_notes(&r).is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
@@ -15,9 +15,10 @@ use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::{Document, LayerId};
|
||||
|
||||
/// The layers a state targeted when it was created: the active ("key") layer and every selected
|
||||
/// layer. Undo and redo bring them back with the state's document, as the reference app does;
|
||||
/// selecting layers is not a step of its own, so it doesn't change a state's target.
|
||||
/// The layers a state targeted: the active ("key") layer and every selected layer. Undo and redo
|
||||
/// bring them back with the state's document, as the reference app does. Selecting layers is not
|
||||
/// a step of its own, so the session updates the current state's target with
|
||||
/// [`History::set_current_layers`] just before recording the next step (#1356).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct LayerTarget {
|
||||
pub active: Option<LayerId>,
|
||||
|
||||
@@ -107,7 +107,8 @@ impl Manifest {
|
||||
return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id)));
|
||||
}
|
||||
let name = self.name.trim();
|
||||
if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) {
|
||||
let printable = name.chars().all(|c| !c.is_control() && !hidden_format(c)) && name.chars().any(visible);
|
||||
if !printable || name.chars().count() > 64 {
|
||||
return Err(Error::Manifest("`name` must be 1-64 printable characters".into()));
|
||||
}
|
||||
if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 {
|
||||
@@ -167,8 +168,8 @@ impl Manifest {
|
||||
format!("{{{}}}", parts.join(","))
|
||||
}
|
||||
|
||||
/// Validates user `params` against the schema: missing keys take their defaults, numbers are
|
||||
/// clamped into range, unknown keys are dropped; a value of the wrong type is an error.
|
||||
/// Validates user `params` against the schema: missing keys take their defaults, numbers (defaults
|
||||
/// included) are clamped into range, unknown keys are dropped; a value of the wrong type is an error.
|
||||
pub fn resolve_params(&self, params: &Value) -> Result<Map<String, Value>> {
|
||||
let given = match params {
|
||||
Value::Null => &Map::new(),
|
||||
@@ -181,11 +182,11 @@ impl Manifest {
|
||||
let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}"));
|
||||
let resolved = match spec {
|
||||
ParamSpec::Number { min, max, default } => match v {
|
||||
None => json!(default),
|
||||
None => json!((*default).clamp(*min, *max)),
|
||||
Some(v) => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)),
|
||||
},
|
||||
ParamSpec::Int { min, max, default } => match v {
|
||||
None => json!(default),
|
||||
None => json!((*default).clamp(*min, *max)),
|
||||
Some(v) => {
|
||||
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
|
||||
json!((f.round().clamp(*min as f64, *max as f64)) as i64)
|
||||
@@ -251,6 +252,62 @@ pub fn valid_id(id: &str) -> bool {
|
||||
(1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
}
|
||||
|
||||
/// The Unicode format characters (general category Cf, Unicode 15.1). Most draw nothing, and the bidi
|
||||
/// controls among them (U+061C, U+200E-200F, U+202A-202E, U+2066-2069) reorder the text around them.
|
||||
const FORMAT: &[(char, char)] = &[
|
||||
('\u{AD}', '\u{AD}'),
|
||||
('\u{600}', '\u{605}'),
|
||||
('\u{61C}', '\u{61C}'),
|
||||
('\u{6DD}', '\u{6DD}'),
|
||||
('\u{70F}', '\u{70F}'),
|
||||
('\u{890}', '\u{891}'),
|
||||
('\u{8E2}', '\u{8E2}'),
|
||||
('\u{180E}', '\u{180E}'),
|
||||
('\u{200B}', '\u{200F}'),
|
||||
('\u{202A}', '\u{202E}'),
|
||||
('\u{2060}', '\u{2064}'),
|
||||
('\u{2066}', '\u{206F}'),
|
||||
('\u{FEFF}', '\u{FEFF}'),
|
||||
('\u{FFF9}', '\u{FFFB}'),
|
||||
('\u{110BD}', '\u{110BD}'),
|
||||
('\u{110CD}', '\u{110CD}'),
|
||||
('\u{13430}', '\u{1343F}'),
|
||||
('\u{1BCA0}', '\u{1BCA3}'),
|
||||
('\u{1D173}', '\u{1D17A}'),
|
||||
('\u{E0001}', '\u{E0001}'),
|
||||
('\u{E0020}', '\u{E007F}'),
|
||||
];
|
||||
|
||||
/// Default-ignorable characters outside Cf (fillers, variation selectors): allowed in a name, but they
|
||||
/// draw nothing on their own.
|
||||
const BLANK: &[(char, char)] = &[
|
||||
('\u{34F}', '\u{34F}'),
|
||||
('\u{115F}', '\u{1160}'),
|
||||
('\u{17B4}', '\u{17B5}'),
|
||||
('\u{180B}', '\u{180D}'),
|
||||
('\u{180F}', '\u{180F}'),
|
||||
('\u{3164}', '\u{3164}'),
|
||||
('\u{FE00}', '\u{FE0F}'),
|
||||
('\u{FFA0}', '\u{FFA0}'),
|
||||
('\u{E0100}', '\u{E01EF}'),
|
||||
];
|
||||
|
||||
fn in_ranges(c: char, ranges: &[(char, char)]) -> bool {
|
||||
ranges.iter().any(|&(lo, hi)| (lo..=hi).contains(&c))
|
||||
}
|
||||
|
||||
/// A format character a menu label must not carry: it hides in the label or reorders it. The zero-width
|
||||
/// non-joiner and joiner stay allowed, since Persian, Indic scripts and emoji sequences need them and they
|
||||
/// cannot reorder text.
|
||||
fn hidden_format(c: char) -> bool {
|
||||
!matches!(c, '\u{200C}' | '\u{200D}') && in_ranges(c, FORMAT)
|
||||
}
|
||||
|
||||
/// A character that draws something on its own.
|
||||
fn visible(c: char) -> bool {
|
||||
!c.is_whitespace() && !c.is_control() && !in_ranges(c, FORMAT) && !in_ranges(c, BLANK)
|
||||
}
|
||||
|
||||
fn valid_key(k: &str) -> bool {
|
||||
(1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') && !k.starts_with('_') && k != "id" && k != "layer"
|
||||
}
|
||||
@@ -287,6 +344,28 @@ mod tests {
|
||||
assert!(m.resolve_params(&Value::Null).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_defaults_are_clamped() {
|
||||
// Issue #1012: a missing value fell back to a default outside the declared range unclamped.
|
||||
let m = Manifest::parse(
|
||||
br#"{"id":"org.example.tone","name":"Tone","kind":"filter",
|
||||
"params":{"amount":{"type":"number","min":0,"max":100,"default":1000},
|
||||
"steps":{"type":"int","min":1,"max":16,"default":1000},
|
||||
"low":{"type":"number","min":-1,"max":1,"default":-5},
|
||||
"count":{"type":"int","min":3,"max":9}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = json!({"amount": 100.0, "steps": 16, "low": -1.0, "count": 3});
|
||||
assert_eq!(Value::Object(m.resolve_params(&json!({})).unwrap()), resolved);
|
||||
assert_eq!(Value::Object(m.resolve_params(&Value::Null).unwrap()), resolved);
|
||||
assert_eq!(Value::Object(m.resolve_params(&json!({"amount": null, "steps": null})).unwrap()), resolved);
|
||||
let given = m.resolve_params(&json!({"amount": 500, "steps": 500, "low": 0.5, "count": 5})).unwrap();
|
||||
assert_eq!(Value::Object(given), json!({"amount": 100.0, "steps": 16, "low": 0.5, "count": 5}));
|
||||
// In-range defaults are unchanged.
|
||||
let full = Manifest::parse(FULL.as_bytes()).unwrap();
|
||||
assert_eq!(Value::Object(full.resolve_params(&json!({})).unwrap()), json!({"amount": 50.0, "mode": "hard", "mono": false, "seed": 0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_manifests() {
|
||||
for bad in [
|
||||
@@ -309,4 +388,33 @@ mod tests {
|
||||
assert!(Manifest::parse(&[0xff, 0xfe]).is_err());
|
||||
assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err());
|
||||
}
|
||||
|
||||
fn with_name(name: &str) -> Result<Manifest> {
|
||||
Manifest::parse(json!({"id": "org.example.tone", "name": name, "kind": "filter"}).to_string().as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_must_be_printable_unicode() {
|
||||
for bad in [
|
||||
"Tone\u{202E}evil",
|
||||
"\u{2067}Tone\u{2069}",
|
||||
"Tone\u{200F}",
|
||||
"\u{200B}\u{200B}\u{200B}",
|
||||
"Tone\u{00AD}",
|
||||
"\u{FEFF}Tone",
|
||||
"\u{200D}",
|
||||
"\u{3164}\u{FE0F}",
|
||||
" \u{3000} ",
|
||||
"Tone\u{7}",
|
||||
] {
|
||||
assert!(with_name(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
// Unicode names still install, with joiners where the script or emoji needs them.
|
||||
for good in ["Tone…", "Kontrast ±", "می\u{200C}خواهم", "\u{1F469}\u{200D}\u{1F4BB} Code", "\u{2764}\u{FE0F} Love", "色调"] {
|
||||
assert_eq!(with_name(good).map(|m| m.name).ok().as_deref(), Some(good));
|
||||
}
|
||||
// The documented limit counts characters, not bytes.
|
||||
assert!(with_name(&"é".repeat(64)).is_ok());
|
||||
assert!(with_name(&"é".repeat(65)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -755,6 +755,7 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
|
||||
|
||||
/// Longest side of the Navigator panel's image (about twice the panel's width, for HiDPI).
|
||||
pub const NAVIGATOR_SIDE: u32 = 512;
|
||||
pub(crate) type NavigatorCache = (std::sync::Weak<Document>, u64, egui::TextureHandle);
|
||||
/// How long adjustment-dialog settings must stay unchanged before the navigator shows them.
|
||||
const NAVIGATOR_SETTLE_MS: f64 = 200.0;
|
||||
|
||||
@@ -770,9 +771,7 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
let (doc, preview_key) = display_doc(app, idx);
|
||||
let (display, display_key) = canvas_display(app, &doc, None);
|
||||
let preview_key = preview_key ^ display_key;
|
||||
let key = egui::Id::new(("navigator", id.0));
|
||||
type Cached = (std::sync::Weak<Document>, u64, egui::TextureHandle);
|
||||
let cached: Option<Cached> = ctx.data(|d| d.get_temp(key));
|
||||
let cached = app.navigator_textures.get(&id).cloned();
|
||||
if let Some((r, p, t)) = &cached
|
||||
&& r.ptr_eq(&snapshot)
|
||||
&& *p == preview_key
|
||||
@@ -804,7 +803,7 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
None => ctx.load_texture(format!("navigator-{}", id.0), image, TextureOptions::LINEAR),
|
||||
};
|
||||
app.perf.span("navigator", crate::gpu_canvas::now_ms() - t0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, (snapshot, preview_key, tex.clone())));
|
||||
app.navigator_textures.insert(id, (snapshot, preview_key, tex.clone()));
|
||||
Some(tex.id())
|
||||
}
|
||||
|
||||
@@ -1896,6 +1895,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
let drop_shadow = border == photocraft_engine::prefs::CanvasBorder::DropShadow;
|
||||
// Drop shadow, checkerboard, document image.
|
||||
let img_rect = xf.doc_rect(doc.bounds());
|
||||
// Crop tool: the layers' pixels past the canvas while a frame is edited, under the canvas
|
||||
// (whose edge blends into them) and without its shadow.
|
||||
let beyond = draw_beyond_canvas(app, &painter, &xf, idx, output);
|
||||
let drop_shadow = drop_shadow && !beyond;
|
||||
// Live adjustment previews on big documents use a downsampled proxy (see proxy.rs), unless
|
||||
// Preferences › Performance › Low Resolution Previews is off.
|
||||
let mut on_gpu = false;
|
||||
@@ -2035,11 +2038,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
let tl = xf.to_doc(rect.min);
|
||||
let br = xf.to_doc(rect.max);
|
||||
let q = 256 * step as i32; // quantise the region so small pans reuse the cache
|
||||
// The view centre can be any finite number (`ui.set`, #980). Past ±2³⁰ there is no document
|
||||
// to outline, and the padding below (at most 2 · 256 · 64) can't overflow i32 from there.
|
||||
let px = |v: f64| v.clamp(-f64::from(1 << 30), f64::from(1 << 30)) as i32;
|
||||
let vis = photocraft_geom::Rect::new(
|
||||
(tl[0].floor() as i32).div_euclid(q) * q - q,
|
||||
(tl[1].floor() as i32).div_euclid(q) * q - q,
|
||||
((br[0].ceil() as i32).div_euclid(q) + 2) * q,
|
||||
((br[1].ceil() as i32).div_euclid(q) + 2) * q,
|
||||
px(tl[0].floor()).div_euclid(q) * q - q,
|
||||
px(tl[1].floor()).div_euclid(q) * q - q,
|
||||
(px(br[0].ceil()).div_euclid(q) + 2) * q,
|
||||
(px(br[1].ceil()).div_euclid(q) + 2) * q,
|
||||
);
|
||||
let key = crate::surface_fingerprint(sel)
|
||||
^ (step as u64) << 56
|
||||
@@ -2173,6 +2179,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// (Preferences › Tools, `paint_mouse`).
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
|
||||
// The Crop tool's frame is edited from the press (before egui's drag threshold), so the
|
||||
// pixels past the canvas show at once, as in Photoshop.
|
||||
if tool == Tool::Crop && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()) && crate::crop_ui::press(app) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// Capture temporary Type transforms at the actual press, before egui's drag threshold.
|
||||
// Releasing Command before the first recognised move must not turn it into text selection.
|
||||
let type_press = egui::Id::new("type-pointer-press-modifiers");
|
||||
@@ -2395,7 +2406,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::layer_pick_ui::show(app, &ctx);
|
||||
crate::canvas_tool_menu::show(app, &ctx);
|
||||
crate::snap_ui::draw(app, &painter, &xf);
|
||||
if border == photocraft_engine::prefs::CanvasBorder::Line {
|
||||
// The canvas edge isn't outlined while the pixels past it show (Photoshop's crop preview).
|
||||
if border == photocraft_engine::prefs::CanvasBorder::Line && !beyond {
|
||||
painter.rect_stroke(img_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside);
|
||||
}
|
||||
crate::type_tool::draw_overlay(app, &painter, &xf);
|
||||
@@ -2682,6 +2694,139 @@ fn crop_overlay(painter: &egui::Painter, r: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Crop tool shows past the canvas, cached per document state ([`draw_beyond_canvas`]).
|
||||
#[derive(Clone)]
|
||||
struct BeyondCanvas {
|
||||
snapshot: std::sync::Weak<Document>,
|
||||
key: u64,
|
||||
/// Every layer's pixels and the canvas (`extra_cmds::reveal_all_bounds`).
|
||||
extent: DRect,
|
||||
/// The composite around the canvas: each band, the area its texture covers, the texture.
|
||||
bands: Vec<(DRect, DRect, egui::TextureHandle)>,
|
||||
}
|
||||
|
||||
/// Band `r` around the canvas `c` (on screen) pushed a point under the canvas's edge, so that the
|
||||
/// edge's anti-aliased pixels blend with the band rather than with the pasteboard.
|
||||
fn under_canvas(mut r: Rect, c: Rect) -> Rect {
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.01;
|
||||
if near(r.max.y, c.min.y) {
|
||||
r.max.y += 1.0;
|
||||
}
|
||||
if near(r.min.y, c.max.y) {
|
||||
r.min.y -= 1.0;
|
||||
}
|
||||
if near(r.max.x, c.min.x) {
|
||||
r.max.x += 1.0;
|
||||
}
|
||||
if near(r.min.x, c.max.x) {
|
||||
r.min.x -= 1.0;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// `outer` minus `inner`: the bands above, below, left and right of it (empty ones dropped).
|
||||
fn ring(outer: DRect, inner: DRect) -> Vec<DRect> {
|
||||
let i = inner.intersect(&outer);
|
||||
if i.is_empty() {
|
||||
return if outer.is_empty() { Vec::new() } else { vec![outer] };
|
||||
}
|
||||
[
|
||||
DRect::new(outer.x0, outer.y0, outer.x1, i.y0),
|
||||
DRect::new(outer.x0, i.y1, outer.x1, outer.y1),
|
||||
DRect::new(outer.x0, i.y0, i.x0, i.y1),
|
||||
DRect::new(i.x1, i.y0, outer.x1, i.y1),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|r| !r.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Crop tool, while a frame is edited (`crop_ui::shows_beyond_canvas`): like Photoshop's crop
|
||||
/// preview, the canvas grows to every layer's pixels (a crop with Delete Cropped Pixels off keeps
|
||||
/// them; a moved layer reaches past the edges) and to the frame, transparency drawn as the
|
||||
/// checkerboard, all under the shield `crop_overlay` adds. Only the ring around the canvas is
|
||||
/// composited, on the CPU, once per document state: at most `MAX_TEXTURE` texels per side, from a
|
||||
/// downsampled proxy past that (so a far-off layer costs no more than a 16 MP composite).
|
||||
/// Drawn before the canvas, each band reaching a point under its edge. Returns whether anything
|
||||
/// past the canvas was drawn.
|
||||
fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, output: Option<u32>) -> bool {
|
||||
let ctx = painter.ctx().clone();
|
||||
let Some((snapshot, id)) = app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id)) else { return false };
|
||||
let key = egui::Id::new(("crop-beyond-canvas", id.0, output));
|
||||
let frame =
|
||||
app.ui.crop_rect.filter(|f| f.iter().all(|v| v.is_finite()) && app.session.active_index() == Some(idx) && crate::crop_ui::shows_beyond_canvas(app));
|
||||
let Some(f) = frame else {
|
||||
ctx.data_mut(|d| d.remove::<BeyondCanvas>(key));
|
||||
return false;
|
||||
};
|
||||
let (doc, preview_key) = display_doc(app, idx);
|
||||
if doc.has_artboards() {
|
||||
return false;
|
||||
}
|
||||
let (display, display_key) = canvas_display(app, &doc, output);
|
||||
let doc_key = preview_key ^ display_key;
|
||||
let cached: Option<BeyondCanvas> = ctx.data(|d| d.get_temp(key));
|
||||
let c = match cached.filter(|c| c.snapshot.ptr_eq(&snapshot) && c.key == doc_key) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let extent = photocraft_engine::extra_cmds::reveal_all_bounds(&doc);
|
||||
// One texel per `k` document pixels keeps every texture within MAX_TEXTURE.
|
||||
let k = extent.width().max(extent.height()).div_ceil(MAX_TEXTURE).max(1);
|
||||
let proxy = (k > 1).then(|| photocraft_compose::proxy::proxy_document(&doc, k));
|
||||
let src = proxy.as_ref().unwrap_or(&*doc);
|
||||
let k = k as i32;
|
||||
let opts = TextureOptions { magnification: egui::TextureFilter::Nearest, ..TextureOptions::LINEAR };
|
||||
let bands = ring(extent, doc.bounds())
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
// The proxy pixels over `b`: document pixels / k, rounded outwards.
|
||||
let up = |v: i32| v.div_euclid(k) + i32::from(v.rem_euclid(k) != 0);
|
||||
let p = DRect::new(b.x0.div_euclid(k), b.y0.div_euclid(k), up(b.x1), up(b.y1));
|
||||
let buf = photocraft_compose::render_reduced_rect(src, p, p.width(), p.height());
|
||||
let tex = ctx.load_texture("crop-beyond-canvas", display_image(display.as_deref(), &buf), opts);
|
||||
(b, DRect::new(p.x0.saturating_mul(k), p.y0.saturating_mul(k), p.x1.saturating_mul(k), p.y1.saturating_mul(k)), tex)
|
||||
})
|
||||
.collect();
|
||||
app.perf.span("crop-beyond-canvas", crate::gpu_canvas::now_ms() - t0);
|
||||
let c = BeyondCanvas { snapshot, key: doc_key, extent, bands };
|
||||
ctx.data_mut(|d| d.insert_temp(key, c.clone()));
|
||||
c
|
||||
}
|
||||
};
|
||||
// Transparency out to the frame, in phase with the canvas's own checkerboard.
|
||||
let canvas = xf.doc_rect(doc.bounds());
|
||||
let fr = DRect::new(f[0].floor() as i32, f[1].floor() as i32, f[2].ceil() as i32, f[3].ceil() as i32);
|
||||
let square = app.session.prefs().transparency_and_gamut.square();
|
||||
let checker_id = square.map(|_| checker(app, &ctx));
|
||||
let around = ring(c.extent.union(&fr), doc.bounds());
|
||||
for &b in &around {
|
||||
let r = under_canvas(xf.doc_rect(b), canvas);
|
||||
match square.zip(checker_id) {
|
||||
Some((sq, id)) => {
|
||||
let uv = Rect::from_min_max(((r.min - canvas.min) / (2.0 * sq)).to_pos2(), ((r.max - canvas.min) / (2.0 * sq)).to_pos2());
|
||||
painter.image(id, r, uv, Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (b, t, tex) in &c.bands {
|
||||
let part = |a: i32, o: i32, len: u32| (i64::from(a) - i64::from(o)) as f32 / len.max(1) as f32;
|
||||
let (mut u0, mut u1) = (part(b.x0, t.x0, t.width()), part(b.x1, t.x0, t.width()));
|
||||
if xf.flip {
|
||||
(u0, u1) = (u1, u0);
|
||||
}
|
||||
let uv = Rect::from_min_max(pos2(u0, part(b.y0, t.y0, t.height())), pos2(u1, part(b.y1, t.y0, t.height())));
|
||||
// The texture's edge texels stretch over the point under the canvas.
|
||||
let (r, e) = (xf.doc_rect(*b), under_canvas(xf.doc_rect(*b), canvas));
|
||||
let at = |p: Pos2| uv.min + (p - r.min) / r.size() * uv.size();
|
||||
painter.image(tex.id(), e, Rect::from_min_max(at(e.min), at(e.max)), Color32::WHITE);
|
||||
}
|
||||
!around.is_empty()
|
||||
}
|
||||
|
||||
/// Overlays that persist between gestures: polygonal or magnetic lasso in progress, pending crop box.
|
||||
fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, hover: Option<Pos2>) {
|
||||
crate::magnetic_lasso_ui::draw(app, painter, xf, hover);
|
||||
@@ -3538,6 +3683,68 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual texture-retention measurement on six 6 MP documents"]
|
||||
fn closed_canvas_memory_measurement() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
ctx.input_mut(|i| i.max_texture_side = 8192);
|
||||
for cycle in 1..=6 {
|
||||
app.run("file.new", json!({"width":3000,"height":2000})).unwrap();
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
// Drain uploaded image deltas just as a renderer does; count live texture owners.
|
||||
ctx.tex_manager().write().take_delta().clear();
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
let bytes: usize = ctx.tex_manager().read().allocated().map(|(_, m)| m.bytes_used()).sum();
|
||||
println!("closed cycle={cycle} texture_bytes={bytes} rss={:?}", photocraft_testkit::perf::current_rss_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_documents_release_navigator_but_keep_open_document_textures() {
|
||||
let mut app = app_on_two_displays();
|
||||
let ctx = egui::Context::default();
|
||||
let first = app.session.active().unwrap().doc.clone();
|
||||
let a = ensure_texture(&mut app, &ctx, 0, Some(1)).unwrap().0;
|
||||
// The same handle ownership as navigator_texture, without requiring a GPU adapter.
|
||||
let tex = ctx.load_texture("navigator-test", egui::ColorImage::filled([64, 64], egui::Color32::WHITE), TextureOptions::LINEAR);
|
||||
let nav = tex.id();
|
||||
app.navigator_textures.insert(first.id, (std::sync::Arc::downgrade(&first), 0, tex));
|
||||
app.run("file.new", json!({"width":32,"height":32})).unwrap();
|
||||
let other = app.session.active().unwrap().doc.id;
|
||||
let b = ensure_texture(&mut app, &ctx, 1, Some(1)).unwrap().0;
|
||||
drop(app.session.close(0));
|
||||
app.sync_views();
|
||||
assert!(ctx.tex_manager().read().meta(a).is_none());
|
||||
assert!(ctx.tex_manager().read().meta(nav).is_none());
|
||||
assert!(ctx.tex_manager().read().meta(b).is_some());
|
||||
assert!(app.canvases.keys().all(|(id, _)| *id == other));
|
||||
assert!(app.navigator_textures.is_empty());
|
||||
app.session.add_document((*first).clone(), None);
|
||||
app.sync_views();
|
||||
assert_ne!(ensure_texture(&mut app, &ctx, 1, Some(1)).unwrap().0, a, "reopening a preserved ID creates a fresh texture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_documents_release_cpu_canvas_textures() {
|
||||
let mut app = app_on_two_displays();
|
||||
let ctx = egui::Context::default();
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
for cycle in 0..12 {
|
||||
if cycle > 0 {
|
||||
app.session.add_document((*original).clone(), None);
|
||||
app.sync_views();
|
||||
}
|
||||
let a = ensure_texture(&mut app, &ctx, 0, Some(1)).unwrap().0;
|
||||
let b = ensure_texture(&mut app, &ctx, 0, Some(4)).unwrap().0;
|
||||
assert_ne!(a, b);
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
assert!(app.canvases.is_empty(), "closed canvas metadata retained at cycle {cycle}");
|
||||
assert!(ctx.tex_manager().read().meta(a).is_none(), "first display texture retained");
|
||||
assert!(ctx.tex_manager().read().meta(b).is_none(), "second display texture retained");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_canvas_textures_are_per_display() {
|
||||
// Two windows of one document on different displays: each has its own texture of
|
||||
@@ -4012,6 +4219,34 @@ mod tests {
|
||||
shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_view_centre_draws_the_selection_outline_without_overflow() {
|
||||
// #980: `ui.set` takes any finite centre, and the outline's visible region was quantised
|
||||
// with unchecked i32 arithmetic: the frame panicked ("attempt to multiply/subtract with
|
||||
// overflow") and release builds wrapped to an inverted region.
|
||||
let ctx = egui::Context::default();
|
||||
// Zoomed far out the quantisation step, and so the padding, is largest (64 · 256 px).
|
||||
let cases = [([1e30, 1e30], false), ([-1e30, -1e30], false), ([3e9, -3e9], false), ([512.0, 512.0], true)];
|
||||
for ((centre, traced), zoom) in cases.into_iter().flat_map(|c| [(c, 1.0), (c, 1.0 / 64.0)]) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 1024, "height": 1024})).unwrap();
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 512, "height": 512})).unwrap();
|
||||
app.sync_views();
|
||||
let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({ "center": centre, "zoom": zoom }));
|
||||
let _ = crate::control::handle(&mut app, &ctx, &req);
|
||||
let view = app.ui.views[0].clone();
|
||||
assert_eq!(view.zoom, zoom, "ui.set applied the zoom");
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), view.clone(), false);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
// Control: a centre on the selection's corner still traces the outline (the selection
|
||||
// spans several 64 px trace cells even zoomed out).
|
||||
let segs = app.outline_cache.as_ref().map_or(0, |(_, _, s)| s.len());
|
||||
assert_eq!(segs > 0, traced, "{centre:?} at zoom {zoom}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_drags_paint_no_stand_in_shape_over_the_canvas() {
|
||||
// #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (`button: "secondary"` opens the tool's canvas context menu or Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase)
|
||||
//! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points
|
||||
//! (`count` at most [`MAX_CLICKS`])
|
||||
//! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}`: synthetic keyboard input
|
||||
//! - `ui.resize {width, height}`: resize the main window
|
||||
//! - `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or, with `error: true`,
|
||||
@@ -45,12 +46,14 @@ pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
/// Requests still queued at this instant are rejected; already dispatched work continues.
|
||||
pub deadline: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx }, rx)
|
||||
(Self { method: method.into(), params, reply: tx, deadline: None }, rx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +99,11 @@ pub const UI_SET_FIELDS: [&str; 21] = [
|
||||
"gradientClassic",
|
||||
];
|
||||
|
||||
/// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app
|
||||
/// feeds in its own frame before replying, so `count` sizes both the input queue and the wait;
|
||||
/// the same ceiling as the steps of one batch request.
|
||||
pub const MAX_CLICKS: u64 = 256;
|
||||
|
||||
fn ok(v: Value) -> Outcome {
|
||||
Outcome::Done(json!({"ok": true, "result": v}))
|
||||
}
|
||||
@@ -626,13 +634,14 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
"middle" => egui::PointerButton::Middle,
|
||||
_ => egui::PointerButton::Primary,
|
||||
};
|
||||
let clicks = if req.method == "ui.click" { u("count").unwrap_or(1) } else { 0 };
|
||||
if clicks > MAX_CLICKS {
|
||||
return err(format!("`count` must be at most {MAX_CLICKS} (got {clicks})"));
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
if req.method == "ui.click" {
|
||||
let clicks = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
for _ in 0..clicks {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: Default::default() });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: Default::default() });
|
||||
}
|
||||
for _ in 0..clicks {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: Default::default() });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: Default::default() });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Outcome::AfterInput
|
||||
@@ -816,6 +825,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_queued_edit_does_not_run_and_a_live_retry_runs_once() {
|
||||
use std::time::{Duration, Instant};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let before = app.session.active().unwrap();
|
||||
let layers = before.doc.layers.len();
|
||||
let steps = before.history.past_len();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
app = app.with_control(rx);
|
||||
let (mut expired, expired_reply) = ControlRequest::new("engine.execute", json!({"command": "layer.new.layer"}));
|
||||
expired.deadline = Some(Instant::now() - Duration::from_secs(1));
|
||||
tx.send(expired).unwrap();
|
||||
let (mut retry, retry_reply) = ControlRequest::new("engine.execute", json!({"command": "layer.new.layer"}));
|
||||
retry.deadline = Some(Instant::now() + Duration::from_secs(60));
|
||||
tx.send(retry).unwrap();
|
||||
app.drain_control(&egui::Context::default());
|
||||
assert_eq!(expired_reply.try_recv().unwrap(), json!({"ok": false, "error": "timeout"}));
|
||||
assert_eq!(retry_reply.try_recv().unwrap()["ok"], true);
|
||||
let after = app.session.active().unwrap();
|
||||
assert_eq!(after.doc.layers.len(), layers + 1, "only the live retry adds a layer");
|
||||
assert_eq!(after.history.past_len(), steps + 1, "the expired edit adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_free_requests_run_even_after_the_receiver_is_dropped() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
app = app.with_control(rx);
|
||||
let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "layer.new.layer"}));
|
||||
assert!(req.deadline.is_none());
|
||||
drop(reply);
|
||||
tx.send(req).unwrap();
|
||||
app.drain_control(&egui::Context::default());
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_pointer_opens_agent_visible_selection_menu() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
@@ -1202,4 +1250,28 @@ mod tests {
|
||||
let r = app.run("actions.play", json!({"action": "Open"})).unwrap();
|
||||
assert_eq!(r["ran"], 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_click_count_is_rejected() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
// #982: a huge count used to queue a press and a release per click before replying.
|
||||
for count in [json!(MAX_CLICKS + 1), json!(1_000_000_000u64), json!(u64::MAX)] {
|
||||
let r = call(&mut app, &ctx, "ui.click", json!({"x": 10, "y": 10, "count": count}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"].as_str().unwrap().contains("`count` must be at most 256"), "{r}");
|
||||
assert!(app.synthetic.is_empty(), "a rejected click queues nothing");
|
||||
}
|
||||
// Single, double and the largest allowed clicks still queue a move plus a press and a release each.
|
||||
for (count, events) in [(None, 3), (Some(2), 5), (Some(MAX_CLICKS), 2 * MAX_CLICKS as usize + 1)] {
|
||||
let params = match count {
|
||||
Some(n) => json!({"x": 10, "y": 10, "count": n}),
|
||||
None => json!({"x": 10, "y": 10}),
|
||||
};
|
||||
let (req, _rx) = ControlRequest::new("ui.click", params);
|
||||
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::AfterInput));
|
||||
assert_eq!(app.synthetic.len(), events);
|
||||
app.synthetic.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@
|
||||
//! ↵ commits (`image.crop`, see `canvas::commit_crop`) and Esc cancels. The pending frame lives in
|
||||
//! `UiState::crop_rect`, so the control channel reads it. `image.crop` has no angle, so dragging
|
||||
//! outside the frame draws a new one rather than rotating it.
|
||||
//!
|
||||
//! As in Photoshop, from the first press until the crop is committed or cancelled the canvas also
|
||||
//! shows the layers' pixels past its edges (kept by a crop with Delete Cropped Pixels off, or moved
|
||||
//! out), with transparency out to the frame, under the shield ([`shows_beyond_canvas`]).
|
||||
|
||||
use egui::{CursorIcon, Modifiers};
|
||||
|
||||
@@ -32,6 +36,8 @@ pub struct CropState {
|
||||
pub default_frame: bool,
|
||||
/// The document (index and size) the default frame was made for.
|
||||
frame_for: Option<(usize, u32, u32)>,
|
||||
/// The frame is being edited (pressed since it was made): the canvas shows what lies past it.
|
||||
pub editing: bool,
|
||||
}
|
||||
|
||||
/// A crop gesture in progress. Rects are `[x0, y0, x1, y1]` in document coordinates.
|
||||
@@ -158,6 +164,7 @@ pub fn set_space(app: &mut PhotocraftApp, down: bool) {
|
||||
/// cancelled or committed, it frames the selection's bounds, or the whole canvas.
|
||||
pub fn ensure_frame(app: &mut PhotocraftApp) {
|
||||
if app.ui.tool != Tool::Crop {
|
||||
app.crop.editing = false;
|
||||
// Leaving the tool drops an untouched default frame (a drawn one stays pending).
|
||||
if app.crop.default_frame {
|
||||
app.ui.crop_rect = None;
|
||||
@@ -183,6 +190,7 @@ pub fn ensure_frame(app: &mut PhotocraftApp) {
|
||||
app.ui.crop_rect = Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]);
|
||||
app.crop.default_frame = true;
|
||||
app.crop.frame_for = key;
|
||||
app.crop.editing = false;
|
||||
}
|
||||
|
||||
/// A crop gesture is in progress: Space repositions the frame rather than panning.
|
||||
@@ -190,6 +198,22 @@ pub fn active(app: &PhotocraftApp) -> bool {
|
||||
app.ui.tool == Tool::Crop && app.crop.drag.is_some()
|
||||
}
|
||||
|
||||
/// The frame is being edited, so the canvas shows the pixels past its edges (Photoshop's crop
|
||||
/// preview): from the first press with the tool until the crop is committed or cancelled.
|
||||
pub fn shows_beyond_canvas(app: &PhotocraftApp) -> bool {
|
||||
app.ui.tool == Tool::Crop && app.ui.crop_rect.is_some() && (app.crop.editing || app.crop.drag.is_some())
|
||||
}
|
||||
|
||||
/// The button went down on the canvas with the Crop tool: the frame is being edited from now on.
|
||||
/// Returns true when that is new.
|
||||
pub fn press(app: &mut PhotocraftApp) -> bool {
|
||||
let new = app.ui.tool == Tool::Crop && app.ui.crop_rect.is_some() && !app.crop.editing;
|
||||
if new {
|
||||
app.crop.editing = true;
|
||||
}
|
||||
new
|
||||
}
|
||||
|
||||
fn tolerance(app: &PhotocraftApp) -> f64 {
|
||||
HANDLE_PX / (app.current_zoom() as f64).max(0.01)
|
||||
}
|
||||
@@ -208,6 +232,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { .. } => {
|
||||
app.crop.editing = true;
|
||||
let frame = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite()));
|
||||
app.crop.drag = Some(match frame.map(|r| (r, hit(r, p, tolerance(app)))) {
|
||||
Some((rect, Hit::Handle(hx, hy))) => CropDrag::Resize { hx, hy, start: p, rect },
|
||||
@@ -350,6 +375,36 @@ mod tests {
|
||||
assert_eq!(app.ui.crop_rect, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_is_edited_from_a_press_until_commit_cancel_or_another_tool() {
|
||||
let mut app = app(SampleType::U8);
|
||||
ensure_frame(&mut app);
|
||||
assert!(!shows_beyond_canvas(&app), "a new frame is not being edited");
|
||||
assert!(press(&mut app));
|
||||
assert!(shows_beyond_canvas(&app));
|
||||
assert!(!press(&mut app), "only the first press is news");
|
||||
// A click on the frame keeps it edited.
|
||||
drag(&mut app, &[[50.0, 50.0]], NONE);
|
||||
assert!(shows_beyond_canvas(&app));
|
||||
// Esc drops the frame: the new default one isn't edited.
|
||||
app.ui.crop_rect = None;
|
||||
ensure_frame(&mut app);
|
||||
assert!(!shows_beyond_canvas(&app));
|
||||
// A drawn frame is edited until it is committed.
|
||||
drag(&mut app, &[[10.0, 10.0], [60.0, 40.0]], NONE);
|
||||
assert!(shows_beyond_canvas(&app));
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 50.0, 30.0]));
|
||||
assert!(!shows_beyond_canvas(&app));
|
||||
// Or until another tool is picked.
|
||||
drag(&mut app, &[[5.0, 5.0], [20.0, 20.0]], NONE);
|
||||
app.ui.tool = Tool::Brush;
|
||||
ensure_frame(&mut app);
|
||||
assert!(!app.crop.editing && !shows_beyond_canvas(&app));
|
||||
assert!(!press(&mut app), "no frame without the Crop tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_finds_handles_inside_and_outside() {
|
||||
let r = [10.0, 10.0, 110.0, 60.0];
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
Layer Vrstva
|
||||
Type Text
|
||||
Select Výběr
|
||||
Top Left Vlevo nahoře
|
||||
Top Right Vpravo nahoře
|
||||
Bottom Left Vlevo dole
|
||||
Bottom Right Vpravo dole
|
||||
Filter Filtr
|
||||
View Zobrazení
|
||||
Window Okno
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -21,6 +21,10 @@
|
||||
Layer Ebene
|
||||
Type Text
|
||||
Select Auswahl
|
||||
Top Left Oben links
|
||||
Top Right Oben rechts
|
||||
Bottom Left Unten links
|
||||
Bottom Right Unten rechts
|
||||
Filter Filter
|
||||
View Ansicht
|
||||
Window Fenster
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -17,6 +17,10 @@
|
||||
Layer Capa
|
||||
Type Texto
|
||||
Select Selección
|
||||
Top Left Superior izquierda
|
||||
Top Right Superior derecha
|
||||
Bottom Left Inferior izquierda
|
||||
Bottom Right Inferior derecha
|
||||
Filter Filtro
|
||||
View Ver
|
||||
Window Ventana
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -24,6 +24,10 @@
|
||||
Layer Calque
|
||||
Type Texte
|
||||
Select Sélection
|
||||
Top Left En haut à gauche
|
||||
Top Right En haut à droite
|
||||
Bottom Left En bas à gauche
|
||||
Bottom Right En bas à droite
|
||||
Filter Filtre
|
||||
View Affichage
|
||||
Window Fenêtre
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -17,6 +17,10 @@
|
||||
Layer Layer
|
||||
Type Teks
|
||||
Select Seleksi
|
||||
Top Left Kiri atas
|
||||
Top Right Kanan atas
|
||||
Bottom Left Kiri bawah
|
||||
Bottom Right Kanan bawah
|
||||
Filter Filter
|
||||
View Tampilan
|
||||
Window Jendela
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -17,6 +17,10 @@
|
||||
Layer Livello
|
||||
Type Testo
|
||||
Select Selezione
|
||||
Top Left In alto a sinistra
|
||||
Top Right In alto a destra
|
||||
Bottom Left In basso a sinistra
|
||||
Bottom Right In basso a destra
|
||||
Filter Filtro
|
||||
View Vista
|
||||
Window Finestra
|
||||
|
||||
|
@@ -21,6 +21,10 @@
|
||||
Layer レイヤー
|
||||
Type 文字
|
||||
Select 選択
|
||||
Top Left 左上
|
||||
Top Right 右上
|
||||
Bottom Left 左下
|
||||
Bottom Right 右下
|
||||
Filter フィルター
|
||||
View 表示
|
||||
Window ウィンドウ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -21,6 +21,10 @@
|
||||
Layer Camada
|
||||
Type Tipo
|
||||
Select Selecionar
|
||||
Top Left Superior esquerdo
|
||||
Top Right Superior direito
|
||||
Bottom Left Inferior esquerdo
|
||||
Bottom Right Inferior direito
|
||||
Filter Filtro
|
||||
View Visualizar
|
||||
Window Janela
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -24,6 +24,10 @@
|
||||
Layer Слой
|
||||
Type Текст
|
||||
Select Выделение
|
||||
Top Left Верхний левый
|
||||
Top Right Верхний правый
|
||||
Bottom Left Нижний левый
|
||||
Bottom Right Нижний правый
|
||||
Filter Фильтр
|
||||
View Просмотр
|
||||
Window Окно
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -17,6 +17,10 @@
|
||||
Layer 图层
|
||||
Type 文字
|
||||
Select 选择
|
||||
Top Left 左上
|
||||
Top Right 右上
|
||||
Bottom Left 左下
|
||||
Bottom Right 右下
|
||||
Filter 滤镜
|
||||
View 视图
|
||||
Window 窗口
|
||||
|
||||
|
@@ -25,6 +25,10 @@
|
||||
Layer 圖層
|
||||
Type 文字
|
||||
Select 選取
|
||||
Top Left 左上
|
||||
Top Right 右上
|
||||
Bottom Left 左下
|
||||
Bottom Right 右下
|
||||
Filter 濾鏡
|
||||
View 檢視
|
||||
Window 視窗
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -170,6 +170,31 @@ fn control_requests_wait_for_jobs_unless_asked_not_to() {
|
||||
assert_eq!(v["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_control_job_applies_after_its_dispatch_deadline() {
|
||||
let mut h = app_harness();
|
||||
let ctx = h.ctx.clone();
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
let (mut req, reply) = crate::ControlRequest::new("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 2}}));
|
||||
req.deadline = Some(deadline);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
h.state_mut().control_rx = Some(rx);
|
||||
tx.send(req).unwrap();
|
||||
h.state_mut().drain_control(&ctx);
|
||||
let job = h.state().jobs.waiters.first().expect("the accepted request waits for its job").0;
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "the job has not been applied yet");
|
||||
assert!(reply.try_recv().is_err());
|
||||
// No frames poll the worker until the transport's deadline has elapsed.
|
||||
std::thread::sleep(deadline.saturating_duration_since(Instant::now()));
|
||||
assert!(Instant::now() >= deadline);
|
||||
drop(reply);
|
||||
let result = job_reply(&mut h, job);
|
||||
assert_eq!(result["ok"], true, "{result}");
|
||||
assert!(result["result"]["filter"].is_object(), "{result}");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
|
||||
}
|
||||
|
||||
/// The control reply for `job`, once the app's frame loop has applied it.
|
||||
fn job_reply(h: &mut Harness<'static, PhotocraftApp>, job: JobId) -> Value {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -97,7 +97,7 @@ fn a_new_active_layer_opens_its_groups_and_scrolls_into_view() {
|
||||
h.run_steps(4);
|
||||
assert!(!expanded(h.state(), group), "{theme:?}: stays closed");
|
||||
// Undo and other commands that change the active layer reveal it too: undoing a new
|
||||
// layer deep in the list makes the top layer active again.
|
||||
// layer made deep in the list makes the layer active before it active again (#1356).
|
||||
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
@@ -105,11 +105,13 @@ fn a_new_active_layer_opens_its_groups_and_scrolls_into_view() {
|
||||
assert!(expanded(h.state(), group));
|
||||
h.state_mut().run("layer.new.layer", json!({"name": "Scratch"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()));
|
||||
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!in_view(&h, row(&h, "Deep inside").unwrap()));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "{theme:?}: undo made the top layer active");
|
||||
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: undo revealed it");
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer, Some(inner), "{theme:?}: undo made the inner layer active");
|
||||
assert!(in_view(&h, row(&h, "Deep inside").unwrap()), "{theme:?}: undo revealed it");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -304,6 +304,8 @@ pub struct PhotocraftApp {
|
||||
/// Canvas caches per (document, display): CPU textures hold monitor values; the GPU
|
||||
/// canvas state is shared (`canvas::GPU_OUTPUT`).
|
||||
canvases: HashMap<(DocId, u32), canvas::CanvasCache>,
|
||||
/// Navigator textures belong to open documents, not the lifetime of the egui context.
|
||||
navigator_textures: HashMap<DocId, canvas::NavigatorCache>,
|
||||
/// Display profile readings (#569).
|
||||
monitors: monitor_status::State,
|
||||
checker: Option<egui::TextureHandle>,
|
||||
@@ -482,6 +484,7 @@ impl PhotocraftApp {
|
||||
ui: UiState::default(),
|
||||
services,
|
||||
canvases: HashMap::new(),
|
||||
navigator_textures: HashMap::new(),
|
||||
monitors: Default::default(),
|
||||
checker: None,
|
||||
drag: None,
|
||||
@@ -728,6 +731,8 @@ impl PhotocraftApp {
|
||||
}
|
||||
self.ui.views = views.into_iter().map(Option::unwrap_or_default).collect();
|
||||
self.ui.windows.retain_mut(|w| now(w.document).map(|d| w.document = d).is_some());
|
||||
self.canvases.retain(|(doc, _), _| ids.contains(doc));
|
||||
self.navigator_textures.retain(|doc, _| ids.contains(doc));
|
||||
self.view_docs = ids;
|
||||
self.prune_thumbs();
|
||||
self.sync_mask_targets();
|
||||
@@ -942,6 +947,10 @@ impl PhotocraftApp {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
let reply = req.reply.clone();
|
||||
if req.deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
|
||||
let _ = reply.send(serde_json::json!({"ok": false, "error": "timeout"}));
|
||||
continue;
|
||||
}
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
|
||||
@@ -336,7 +336,12 @@ fn clicking_the_vector_mask_targets_it_for_the_path_tools() {
|
||||
let k = layer(&h, masked).vector_mask.unwrap().path.subpaths[0].knots[0].anchor;
|
||||
assert_eq!((k.x, k.y), (25.0, 12.0));
|
||||
// The Pen adds a subpath to it.
|
||||
h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], dragging: false });
|
||||
h.state_mut().ui.pen = Some(crate::vector_ui::PenPath {
|
||||
knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]],
|
||||
unlinked: Vec::new(),
|
||||
dragging: false,
|
||||
adjusting_last: false,
|
||||
});
|
||||
crate::vector_ui::pen_commit(h.state_mut(), true);
|
||||
assert_eq!(layer(&h, masked).vector_mask.unwrap().path.subpaths.len(), 2);
|
||||
assert!(h.state().session.active().unwrap().doc.work_path.is_none(), "not a work path");
|
||||
|
||||
@@ -885,7 +885,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
if top == "Help" {
|
||||
// The search field scrolls with the rows, as part of the menu's content.
|
||||
crate::menu_nav::level(ui, 1, &mut nav, |ui, nav| {
|
||||
help_search(ui, items, &mut clicked, nav);
|
||||
help_search(ui, items, opening, &mut clicked, nav);
|
||||
render_level_rows(ui, &mine, 1, &mut clicked, nav);
|
||||
});
|
||||
} else {
|
||||
@@ -1014,7 +1014,8 @@ pub fn search_items<'a>(items: &'a [MenuItem], query: &str, lang: crate::i18n::L
|
||||
|
||||
/// Help › Search : a field at the top of the Help menu that finds any menu command
|
||||
/// by name. Results show their menu path; click, ↓ then ↩, or ↩ in the field runs one.
|
||||
fn help_search(ui: &mut egui::Ui, items: &[MenuItem], clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
|
||||
/// `opening`: this frame's release ends the click on the Help title that opened the menu.
|
||||
fn help_search(ui: &mut egui::Ui, items: &[MenuItem], opening: bool, clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let lang = crate::i18n::current();
|
||||
let text_id = egui::Id::new("help-menu-search");
|
||||
@@ -1025,7 +1026,9 @@ fn help_search(ui: &mut egui::Ui, items: &[MenuItem], clicked: &mut Option<Strin
|
||||
ctx.data_mut(|d| d.insert_temp(pass_id, pass));
|
||||
let mut query: String = if reopened { String::new() } else { ctx.data(|d| d.get_temp(text_id)).unwrap_or_default() };
|
||||
let field = ui.add(egui::TextEdit::singleline(&mut query).id(text_id.with("field")).hint_text(crate::i18n::tr(lang, "Search menus")).desired_width(220.0));
|
||||
if reopened {
|
||||
// The menu opens on the press; the release a frame later completes a click on the title,
|
||||
// outside the field, and egui takes the focus away from it again (#1447, #1438).
|
||||
if reopened || opening {
|
||||
field.request_focus();
|
||||
}
|
||||
let results = search_items(items, &query, lang);
|
||||
@@ -1329,6 +1332,62 @@ mod tests {
|
||||
assert!(ids("zzzzqqq").is_empty());
|
||||
}
|
||||
|
||||
/// #1447, #1438: a click on Help leaves its search field focused, so typing searches at once,
|
||||
/// and clicking into the field and typing keeps the menu open. Whole app, real clicks (press
|
||||
/// and release in separate frames), with the custom title bar (Windows, Linux) and without.
|
||||
#[test]
|
||||
fn help_search_takes_typing_and_clicks_without_closing_the_menu() {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
for (custom, doc, scale) in [(true, false, 1.0), (true, true, 1.25), (false, true, 1.5)] {
|
||||
let what = format!("custom title bar {custom}, document {doc}, scale {scale}");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0 / scale, 800.0 / scale)).with_pixels_per_point(scale).with_max_steps(64).build_eframe(
|
||||
move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.custom_titlebar = custom;
|
||||
if doc {
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
}
|
||||
app
|
||||
},
|
||||
);
|
||||
h.run_steps(4);
|
||||
let click = |h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2| {
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
};
|
||||
let typing = |h: &mut Harness<'_, PhotocraftApp>, text: &str| {
|
||||
for c in text.chars() {
|
||||
h.event(Event::Text(c.to_string()));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(3);
|
||||
};
|
||||
let open = |h: &Harness<'_, PhotocraftApp>| h.query_by_label_contains("About PhotoCraft").is_some();
|
||||
let field_id = egui::Id::new("help-menu-search").with("field");
|
||||
let help = h.get_by_label("Help").rect().center();
|
||||
click(&mut h, help);
|
||||
assert!(open(&h), "Help opened ({what})");
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(field_id), "the click on Help leaves the search field focused ({what})");
|
||||
typing(&mut h, "lev");
|
||||
assert!(open(&h), "typing keeps Help open ({what})");
|
||||
assert!(h.query_all_by_label_contains("› Levels").next().is_some(), "typing right away searches ({what})");
|
||||
let field = h.query_all_by_role(egui::accesskit::Role::TextInput).find(|n| n.is_focused()).map(|n| n.rect().center());
|
||||
let field = field.unwrap_or_else(|| panic!("the search field is in the accessibility tree ({what})"));
|
||||
click(&mut h, field);
|
||||
assert!(open(&h), "clicking the search field keeps Help open ({what})");
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(field_id), "and focused ({what})");
|
||||
typing(&mut h, "els");
|
||||
assert!(open(&h), "typing after the click keeps Help open ({what})");
|
||||
assert!(h.query_all_by_label_contains("› Levels").next().is_some(), "and lists the matches ({what})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropdown_items_have_room() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
@@ -66,7 +66,8 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = Tool::Pen;
|
||||
let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]];
|
||||
h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], dragging: false });
|
||||
h.state_mut().ui.pen =
|
||||
Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], unlinked: Vec::new(), dragging: false, adjusting_last: false });
|
||||
h.run_steps(2);
|
||||
press(&mut h, Key::Enter, command());
|
||||
let app = h.state();
|
||||
@@ -76,7 +77,8 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() {
|
||||
// Plain ↩ still only finishes a path.
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = Tool::Pen;
|
||||
h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], dragging: false });
|
||||
h.state_mut().ui.pen =
|
||||
Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], unlinked: Vec::new(), dragging: false, adjusting_last: false });
|
||||
h.run_steps(2);
|
||||
press(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert!(h.state().session.active().unwrap().doc.work_path.is_some());
|
||||
|
||||
@@ -846,12 +846,14 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods
|
||||
q[i] = [g.quad0[i][0] + mx, g.quad0[i][1] + my];
|
||||
q[j] = [g.quad0[j][0] - mx, g.quad0[j][1] - my];
|
||||
s.quad = keep_convex(g.quad0, q);
|
||||
s.pivot = carry_pivot(g.quad0, s.quad, g.pivot0);
|
||||
}
|
||||
Hit::Corner(i) if mods.command => {
|
||||
// Distort: move the corner freely, up to where the box would fold over (#1323).
|
||||
let mut q = g.quad0;
|
||||
q[i] = [g.quad0[i][0] + dx, g.quad0[i][1] + dy];
|
||||
s.quad = keep_convex(g.quad0, q);
|
||||
s.pivot = carry_pivot(g.quad0, s.quad, g.pivot0);
|
||||
}
|
||||
Hit::Edge(i) if mods.command => {
|
||||
// Skew: the edge's two corners move together (⇧: only along the edge).
|
||||
@@ -872,6 +874,7 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods
|
||||
s.quad[c] = [g.quad0[c][0] - mx, g.quad0[c][1] - my];
|
||||
s.quad[d] = [g.quad0[d][0] - mx, g.quad0[d][1] - my];
|
||||
}
|
||||
s.pivot = carry_pivot(g.quad0, s.quad, g.pivot0);
|
||||
}
|
||||
Hit::Corner(_) | Hit::Edge(_) => {
|
||||
// Work in the box's own (unit) frame so rotated/skewed boxes scale along their axes.
|
||||
@@ -950,12 +953,25 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods
|
||||
[x, y]
|
||||
};
|
||||
s.quad = [map(r[0], r[1]), map(r[2], r[1]), map(r[2], r[3]), map(r[0], r[3])];
|
||||
let (x, y) = h.apply(pu, pv);
|
||||
s.pivot = [x, y];
|
||||
// ⌥ scales about the reference point, so it stays put; otherwise it rides along with
|
||||
// the box, keeping its place in it (#1329).
|
||||
s.pivot = if mods.alt { g.pivot0 } else { carry_pivot(g.quad0, s.quad, g.pivot0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the reference point lands when the box goes from `quad0` to `quad`: the same place
|
||||
/// within the box, as in Photoshop, where it is attached to the bounding box (#1329). A box
|
||||
/// that has collapsed leaves it where it was.
|
||||
fn carry_pivot(quad0: [[f64; 2]; 4], quad: [[f64; 2]; 4], pivot0: [f64; 2]) -> [f64; 2] {
|
||||
let unit = [0.0, 0.0, 1.0, 1.0];
|
||||
let (Some(h0), Some(h1)) = (Homography::rect_to_quad(unit, quad0), Homography::rect_to_quad(unit, quad)) else { return pivot0 };
|
||||
let Some(inv) = h0.inverse() else { return pivot0 };
|
||||
let (u, v) = inv.apply(pivot0[0], pivot0[1]);
|
||||
let (x, y) = h1.apply(u, v);
|
||||
if x.is_finite() && y.is_finite() { [x, y] } else { pivot0 }
|
||||
}
|
||||
|
||||
/// An anchor or a handle on a section line. The inner control points of a patch are not shown.
|
||||
fn on_section_edge(i: usize, j: usize) -> bool {
|
||||
i.is_multiple_of(3) || j.is_multiple_of(3)
|
||||
@@ -1887,6 +1903,54 @@ mod tests {
|
||||
assert!(close(s.quad, corners([0.0, 0.0, 100.0, 80.0])), "{:?}", s.quad);
|
||||
}
|
||||
|
||||
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
|
||||
(a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// The reference point is attached to the box (#1329): scaling, skewing, distorting and
|
||||
/// perspective carry it along to the same place within the box; ⌥-scaling about it and
|
||||
/// rotating about it leave it put.
|
||||
#[test]
|
||||
fn the_reference_point_moves_with_the_box() {
|
||||
// Corner scale down: the centre stays the centre.
|
||||
let mut s = session();
|
||||
drag(&mut s, [100.0, 50.0], [50.0, 25.0], egui::Modifiers::NONE);
|
||||
assert!(close(s.quad, corners([0.0, 0.0, 50.0, 25.0])), "{:?}", s.quad);
|
||||
assert!(near(s.pivot, [25.0, 12.5]), "{:?}", s.pivot);
|
||||
// A reference point set to a corner stays at that corner; an edge drag carries it too.
|
||||
let mut s = session();
|
||||
s.pivot = [100.0, 0.0];
|
||||
drag(&mut s, [50.0, 50.0], [50.0, 80.0], egui::Modifiers::NONE);
|
||||
assert!(near(s.pivot, [100.0, 0.0]), "{:?}", s.pivot);
|
||||
drag(&mut s, [100.0, 40.0], [150.0, 40.0], egui::Modifiers::NONE);
|
||||
assert!(near(s.pivot, s.quad[1]), "{:?} {:?}", s.pivot, s.quad);
|
||||
// Skew: the centre follows the box's centre.
|
||||
let mut s = session();
|
||||
drag(&mut s, [50.0, 0.0], [70.0, 0.0], egui::Modifiers::COMMAND);
|
||||
assert!(near(s.pivot, [60.0, 25.0]), "{:?}", s.pivot);
|
||||
// Distort: a corner reference point stays on its corner.
|
||||
let mut s = session();
|
||||
s.pivot = [0.0, 50.0];
|
||||
drag(&mut s, [0.0, 50.0], [-20.0, 70.0], egui::Modifiers::COMMAND);
|
||||
assert!(near(s.pivot, [-20.0, 70.0]), "{:?}", s.pivot);
|
||||
// Perspective: the centre stays inside the box, on the box's own centre lines.
|
||||
let mut s = session();
|
||||
drag(&mut s, [100.0, 0.0], [120.0, 0.0], egui::Modifiers { command: true, alt: true, shift: true, ..Default::default() });
|
||||
let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], s.quad).unwrap();
|
||||
let (x, y) = h.apply(0.5, 0.5);
|
||||
assert!(near(s.pivot, [x, y]), "{:?} vs {:?}", s.pivot, [x, y]);
|
||||
// ⌥ scales about the reference point: it stays where it is.
|
||||
let mut s = session();
|
||||
drag(&mut s, [100.0, 50.0], [150.0, 75.0], egui::Modifiers::ALT);
|
||||
assert!(near(s.pivot, [50.0, 25.0]), "{:?}", s.pivot);
|
||||
// Rotation turns about it.
|
||||
let mut s = session();
|
||||
drag(&mut s, [150.0, 25.0], [50.0, 125.0], egui::Modifiers::NONE);
|
||||
assert!(near(s.pivot, [50.0, 25.0]), "{:?}", s.pivot);
|
||||
// From a box collapsed to a point there is no place within it to keep: it stays put.
|
||||
assert_eq!(carry_pivot([[0.0, 0.0]; 4], corners([0.0, 0.0, 10.0, 10.0]), [5.0, 5.0]), [5.0, 5.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_rotate_and_distort() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! The Type tool's searchable font menu (#1369): it keeps its height and focuses its search field.
|
||||
|
||||
use super::font_picker_in;
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Key, Modifiers, Rect, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
struct State {
|
||||
current: String,
|
||||
families: Vec<String>,
|
||||
popup: egui::Id,
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, State> {
|
||||
// Enough families that the unfiltered menu hits its maximum height and scrolls.
|
||||
let mut families: Vec<String> = (0..60).map(|i| format!("Family {i:02}")).collect();
|
||||
families.push("Zeta Sans".into());
|
||||
let state = State { current: "Family 00".into(), families, popup: egui::Id::NULL };
|
||||
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
// `ComboBox::from_id_salt` wraps its salt in an `IdSalt`; the popup id is the button's + "popup".
|
||||
s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup");
|
||||
let fams = s.families.clone();
|
||||
font_picker_in(ui, &mut s.current, 170.0, &fams);
|
||||
},
|
||||
state,
|
||||
);
|
||||
h.run();
|
||||
h
|
||||
}
|
||||
|
||||
fn popup_rect(h: &Harness<'static, State>) -> Option<Rect> {
|
||||
let id = h.state().popup;
|
||||
let open = egui::Popup::is_id_open(&h.ctx, id);
|
||||
if open { h.ctx.memory(|m| m.area_rect(id)) } else { None }
|
||||
}
|
||||
|
||||
fn open(h: &mut Harness<'static, State>) {
|
||||
h.get_by_role(Role::ComboBox).click();
|
||||
h.run();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_the_query_restores_the_menu_height() {
|
||||
let mut h = harness();
|
||||
open(&mut h);
|
||||
let full = popup_rect(&h).expect("menu open").height();
|
||||
h.get_by_role(Role::TextInput).type_text("zeta");
|
||||
h.run();
|
||||
assert!(h.query_by_label("Family 01").is_none(), "the query filters the list");
|
||||
let searching = popup_rect(&h).expect("still open").height();
|
||||
for _ in 0.."zeta".len() {
|
||||
h.key_press(Key::Backspace);
|
||||
}
|
||||
h.run();
|
||||
assert!(h.query_by_label("Family 01").is_some(), "the cleared query lists every family");
|
||||
let cleared = popup_rect(&h).expect("still open").height();
|
||||
assert!((cleared - full).abs() < 0.5, "height after clearing {cleared} != original {full}");
|
||||
assert!((searching - full).abs() < 0.5, "the menu keeps its height while searching: {searching} vs {full}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_focuses_the_search_field_and_clicking_it_keeps_the_menu_open() {
|
||||
let mut h = harness();
|
||||
open(&mut h);
|
||||
assert!(h.get_by_role(Role::TextInput).is_focused(), "search is focused on first open");
|
||||
h.get_by_role(Role::TextInput).type_text("zeta");
|
||||
h.run();
|
||||
// Close without choosing, then reopen: the query stays and the field is focused again.
|
||||
h.key_press(Key::Escape);
|
||||
h.run();
|
||||
assert!(popup_rect(&h).is_none(), "Escape closes the menu");
|
||||
open(&mut h);
|
||||
let search = h.get_by_role(Role::TextInput);
|
||||
assert_eq!(search.value().as_deref(), Some("zeta"));
|
||||
assert!(search.is_focused(), "search is focused on reopen with a query");
|
||||
// Clicking the search field (to edit the query) must not close the menu.
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
assert!(popup_rect(&h).is_some(), "clicking the search field keeps the menu open");
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.get_by_role(Role::TextInput).type_text("Zeta S");
|
||||
h.run();
|
||||
assert!(popup_rect(&h).is_some());
|
||||
// Picking a font still closes the menu.
|
||||
h.get_by_label("Zeta Sans").click();
|
||||
h.run();
|
||||
assert_eq!(h.state().current, "Zeta Sans");
|
||||
assert!(popup_rect(&h).is_none(), "choosing a font closes the menu");
|
||||
}
|
||||
@@ -726,23 +726,62 @@ pub fn style_label(style: &str) -> String {
|
||||
|
||||
/// Searchable font-family combo box.
|
||||
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32) -> bool {
|
||||
font_picker_in(ui, current, width, families())
|
||||
}
|
||||
|
||||
/// Maximum height of the font menu.
|
||||
const FONT_MENU_HEIGHT: f32 = 460.0;
|
||||
|
||||
/// [`font_picker`] over a given family list (tests pass their own).
|
||||
fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families: &[String]) -> bool {
|
||||
let mut changed = false;
|
||||
let search_id = ui.id().with("font-search");
|
||||
egui::ComboBox::from_id_salt("type-font").selected_text(current.as_str()).width(width).height(460.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
|
||||
let combo = egui::ComboBox::from_id_salt("type-font")
|
||||
.selected_text(current.as_str())
|
||||
.width(width)
|
||||
.height(FONT_MENU_HEIGHT)
|
||||
.icon(crate::widgets::chevron_icon)
|
||||
// Clicks inside the menu (the search field, the scroll bar) must not close it (#1369);
|
||||
// picking a font closes it explicitly below.
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
|
||||
combo.show_ui(ui, |ui| {
|
||||
let (pass_id, focus_id, height_id) = (search_id.with("pass"), search_id.with("focus"), search_id.with("height"));
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
let last_pass: Option<u64> = ui.data(|d| d.get_temp(pass_id));
|
||||
// Freshly opened (not drawn last pass): focus the search field whatever the query, and keep
|
||||
// asking until it has focus (the first pass is egui's invisible sizing pass) (#1369).
|
||||
let opened = last_pass.is_none_or(|p| p.saturating_add(1) < pass);
|
||||
let mut focus = opened || ui.data(|d| d.get_temp(focus_id)).unwrap_or(false);
|
||||
let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search fonts")).desired_width(200.0));
|
||||
if !r.has_focus() && q.is_empty() {
|
||||
if r.has_focus() {
|
||||
focus = false;
|
||||
} else if focus {
|
||||
r.request_focus();
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(search_id, q.clone()));
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(pass_id, pass);
|
||||
d.insert_temp(focus_id, focus);
|
||||
d.insert_temp(search_id, q.clone());
|
||||
});
|
||||
let ql = q.to_lowercase();
|
||||
for f in families().iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)) {
|
||||
for f in families.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)) {
|
||||
if ui.selectable_label(f == current, f).clicked() {
|
||||
*current = f.clone();
|
||||
changed = true;
|
||||
ui.data_mut(|d| d.remove::<String>(search_id));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
// egui measures a popup only when it opens and never lets it grow back, so a filtered
|
||||
// list would leave the menu short after the query is cleared (#1369). Keep the unfiltered
|
||||
// height while searching.
|
||||
if ql.is_empty() {
|
||||
let h = ui.min_rect().height();
|
||||
ui.data_mut(|d| d.insert_temp(height_id, h));
|
||||
} else if let Some(h) = ui.data(|d| d.get_temp::<f32>(height_id)) {
|
||||
ui.set_min_height(h.min(FONT_MENU_HEIGHT));
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
@@ -1393,6 +1432,10 @@ pub fn cancel(app: &mut PhotocraftApp) {
|
||||
#[path = "type_tool_tests.rs"]
|
||||
mod canvas_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "type_font_picker_tests.rs"]
|
||||
mod font_picker_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+201
-22
@@ -18,8 +18,14 @@ use crate::theme::Tokens;
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PenPath {
|
||||
pub knots: Vec<[[f64; 2]; 3]>,
|
||||
/// Indices of cusp knots whose incoming and outgoing handles are not linked.
|
||||
#[serde(default)]
|
||||
pub unlinked: Vec<usize>,
|
||||
#[serde(skip)]
|
||||
pub dragging: bool,
|
||||
/// Re-dragging the final anchor changes its outgoing control only; the incoming curve stays put.
|
||||
#[serde(skip)]
|
||||
pub adjusting_last: bool,
|
||||
}
|
||||
|
||||
pub fn is_shape_tool(t: Tool) -> bool {
|
||||
@@ -120,10 +126,11 @@ pub fn draw_shape_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &Vie
|
||||
// Pen
|
||||
|
||||
fn pen_to_json(pen: &PenPath, closed: bool) -> Value {
|
||||
json!({"subpaths": [{"closed": closed, "knots": pen.knots.iter().map(|k| json!({"anchor": k[0], "in": k[1], "out": k[2], "smooth": k[1] != k[0] || k[2] != k[0]})).collect::<Vec<_>>()}]})
|
||||
json!({"subpaths": [{"closed": closed, "knots": pen.knots.iter().enumerate().map(|(i, k)| json!({"anchor": k[0], "in": k[1], "out": k[2], "smooth": !pen.unlinked.contains(&i) && (k[1] != k[0] || k[2] != k[0])})).collect::<Vec<_>>()}]})
|
||||
}
|
||||
|
||||
/// Pen press: close on the first anchor, else add an anchor (dragging pulls smooth handles).
|
||||
/// Pen press: close on the first anchor, reshape the last anchor's outgoing handle,
|
||||
/// or add a new anchor (dragging a new anchor pulls symmetrical handles).
|
||||
pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
let tol = 6.0 / app.current_zoom().max(0.01) as f64;
|
||||
let pen = app.ui.pen.get_or_insert_with(PenPath::default);
|
||||
@@ -134,24 +141,44 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
pen_commit(app, true);
|
||||
return;
|
||||
}
|
||||
// Clicking the last anchor breaks its outgoing handle without throwing away the incoming
|
||||
// curve. Dragging from that anchor then sets the outgoing handle independently (#1482).
|
||||
// This must precede adding a point, or a re-click creates a zero-length segment.
|
||||
if let Some((i, last)) = pen.knots.len().checked_sub(1).zip(pen.knots.last_mut())
|
||||
&& (last[0][0] - x).hypot(last[0][1] - y) < tol
|
||||
{
|
||||
last[2] = last[0];
|
||||
if !pen.unlinked.contains(&i) {
|
||||
pen.unlinked.push(i);
|
||||
}
|
||||
pen.dragging = true;
|
||||
pen.adjusting_last = true;
|
||||
return;
|
||||
}
|
||||
pen.knots.push([[x, y]; 3]);
|
||||
pen.dragging = true;
|
||||
pen.adjusting_last = false;
|
||||
}
|
||||
|
||||
pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
if let Some(pen) = app.ui.pen.as_mut()
|
||||
&& pen.dragging
|
||||
&& let Some(k) = pen.knots.last_mut()
|
||||
{
|
||||
let a = k[0];
|
||||
k[2] = [x, y];
|
||||
k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y];
|
||||
let adjusting_last = pen.adjusting_last;
|
||||
if let Some(k) = pen.knots.last_mut() {
|
||||
let a = k[0];
|
||||
k[2] = [x, y];
|
||||
if !adjusting_last {
|
||||
k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pen_up(app: &mut PhotocraftApp) {
|
||||
if let Some(pen) = app.ui.pen.as_mut() {
|
||||
pen.dragging = false;
|
||||
pen.adjusting_last = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,6 +602,16 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, tip: &str) ->
|
||||
out
|
||||
}
|
||||
|
||||
/// One corner of the live rectangle, preserving the other three radii.
|
||||
/// The engine takes the full [top-left, top-right, bottom-right, bottom-left] array.
|
||||
fn corner_radii_patch(radii: [f64; 4], corner: usize, radius: f32) -> Value {
|
||||
let mut next = radii;
|
||||
if let Some(r) = next.get_mut(corner) {
|
||||
*r = f64::from(radius.max(0.0));
|
||||
}
|
||||
json!({"radii": next})
|
||||
}
|
||||
|
||||
/// Properties panel for a shape layer: Appearance (fill, stroke) and live shape geometry.
|
||||
pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocraft_doc::LayerId) {
|
||||
let Some(sh) = app.session.active().and_then(|s| s.doc.layer(id)).and_then(|l| match &l.content {
|
||||
@@ -642,28 +679,49 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
|
||||
}
|
||||
photocraft_doc::vector::LiveShape::Line { .. } => {}
|
||||
}
|
||||
ui.horizontal(|ui| match live {
|
||||
ui.vertical(|ui| match live {
|
||||
photocraft_doc::vector::LiveShape::Rect { radii, .. } => {
|
||||
let mut r = radii[0] as f32;
|
||||
if num(ui, tl!("Corner radius"), &mut r, 0.0..=100000.0, "px") {
|
||||
edit = Some(json!({"radii": r, "coalesce": key("radius")}));
|
||||
// Keep the uniform workflow available after independent corner edits too.
|
||||
ui.horizontal(|ui| {
|
||||
let mut all = radii[0] as f32;
|
||||
if num(ui, tl!("Corner radius"), &mut all, 0.0..=100000.0, "px") {
|
||||
edit = Some(json!({"radii": all, "coalesce": key("radius-all")}));
|
||||
}
|
||||
});
|
||||
// Separate rows also fit the narrow Properties dock. The labels use the
|
||||
// localized corner names, rather than untranslated abbreviations.
|
||||
for (index, label) in [(0, tl!("Top Left")), (1, tl!("Top Right")), (3, tl!("Bottom Left")), (2, tl!("Bottom Right"))] {
|
||||
ui.horizontal(|ui| {
|
||||
let mut radius = radii[index] as f32;
|
||||
if num(ui, label, &mut radius, 0.0..=100000.0, "px") {
|
||||
let mut patch = corner_radii_patch(*radii, index, radius);
|
||||
patch["coalesce"] = json!(key(&format!("radius-{index}")));
|
||||
edit = Some(patch);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, .. } => {
|
||||
let mut n = *sides as f32;
|
||||
if num(ui, tl!("Sides"), &mut n, 3.0..=100.0, "") {
|
||||
edit = Some(json!({"sides": n.round() as u32, "coalesce": key("sides")}));
|
||||
}
|
||||
let mut sr = (*star_ratio * 100.0) as f32;
|
||||
if num(ui, tl!("Star ratio"), &mut sr, 1.0..=100.0, "%") {
|
||||
edit = Some(json!({"starRatio": sr as f64 / 100.0, "coalesce": key("star")}));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let mut n = *sides as f32;
|
||||
if num(ui, tl!("Sides"), &mut n, 3.0..=100.0, "") {
|
||||
edit = Some(json!({"sides": n.round() as u32, "coalesce": key("sides")}));
|
||||
}
|
||||
let mut sr = (*star_ratio * 100.0) as f32;
|
||||
if num(ui, tl!("Star ratio"), &mut sr, 1.0..=100.0, "%") {
|
||||
edit = Some(json!({"starRatio": sr as f64 / 100.0, "coalesce": key("star")}));
|
||||
}
|
||||
})
|
||||
.inner
|
||||
}
|
||||
photocraft_doc::vector::LiveShape::Line { weight, .. } => {
|
||||
let mut w = *weight as f32;
|
||||
if num(ui, tl!("Weight"), &mut w, 1.0..=10000.0, "px") {
|
||||
edit = Some(json!({"weight": w, "coalesce": key("weight")}));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let mut w = *weight as f32;
|
||||
if num(ui, tl!("Weight"), &mut w, 1.0..=10000.0, "px") {
|
||||
edit = Some(json!({"weight": w, "coalesce": key("weight")}));
|
||||
}
|
||||
})
|
||||
.inner
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
@@ -1097,6 +1155,127 @@ mod tests {
|
||||
assert_eq!(app.ui.selected_path.as_deref(), Some("work"));
|
||||
}
|
||||
|
||||
/// #1482: clicking an existing final Pen anchor breaks its outgoing handle without
|
||||
/// deleting the preceding curve or creating another knot; dragging changes only that handle.
|
||||
#[test]
|
||||
fn pen_last_anchor_can_be_broken_and_reshaped_while_drawing() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
let down = |app: &mut PhotocraftApp, x, y| tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
let move_to = |app: &mut PhotocraftApp, x, y| tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
let up = |app: &mut PhotocraftApp, x, y| tool_event(app, ToolEvent::Up { x, y }, egui::Modifiers::NONE);
|
||||
|
||||
down(&mut app, 20.0, 20.0);
|
||||
up(&mut app, 20.0, 20.0);
|
||||
down(&mut app, 100.0, 20.0);
|
||||
move_to(&mut app, 120.0, 40.0);
|
||||
up(&mut app, 120.0, 40.0);
|
||||
let original = app.ui.pen.as_ref().unwrap().knots[1];
|
||||
assert_eq!(original, [[100.0, 20.0], [80.0, 0.0], [120.0, 40.0]]);
|
||||
|
||||
// Bare click breaks the outgoing handle, preserving the preceding segment's tangent.
|
||||
down(&mut app, 100.0, 20.0);
|
||||
up(&mut app, 100.0, 20.0);
|
||||
let pen = app.ui.pen.as_ref().unwrap();
|
||||
assert_eq!(pen.knots.len(), 2);
|
||||
assert_eq!(pen.knots[1], [[100.0, 20.0], [80.0, 0.0], [100.0, 20.0]]);
|
||||
assert!(!pen.dragging && !pen.adjusting_last);
|
||||
|
||||
// Re-dragging the same final anchor moves only its outgoing handle (a cusp).
|
||||
down(&mut app, 100.0, 20.0);
|
||||
move_to(&mut app, 110.0, 60.0);
|
||||
up(&mut app, 110.0, 60.0);
|
||||
let pen = app.ui.pen.as_ref().unwrap();
|
||||
assert_eq!(pen.knots.len(), 2);
|
||||
assert_eq!(pen.knots[1], [[100.0, 20.0], [80.0, 0.0], [110.0, 60.0]]);
|
||||
|
||||
// Drawing on still adds a separate next anchor and keeps the cusp in the work path.
|
||||
down(&mut app, 150.0, 100.0);
|
||||
up(&mut app, 150.0, 100.0);
|
||||
assert_eq!(app.ui.pen.as_ref().unwrap().knots.len(), 3);
|
||||
pen_commit(&mut app, false);
|
||||
let work = app.session.active().unwrap().doc.work_path.as_ref().unwrap();
|
||||
let k = &work.subpaths[0].knots[1];
|
||||
assert_eq!([k.in_ctrl.x, k.in_ctrl.y], [80.0, 0.0]);
|
||||
assert_eq!([k.out_ctrl.x, k.out_ctrl.y], [110.0, 60.0]);
|
||||
assert!(!k.smooth, "breaking the handle must persist as an unlinked PSD/vector knot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_last_anchor_hit_tolerance_tracks_zoom_and_draft_round_trips() {
|
||||
for zoom in [0.25, 1.0, 4.0] {
|
||||
let mut app = app();
|
||||
app.ui.views[0].zoom = zoom;
|
||||
pen_down(&mut app, 20.0, 20.0);
|
||||
pen_up(&mut app);
|
||||
pen_down(&mut app, 100.0, 100.0);
|
||||
pen_move(&mut app, 110.0, 120.0);
|
||||
pen_up(&mut app);
|
||||
let incoming = app.ui.pen.as_ref().unwrap().knots[1][1];
|
||||
pen_down(&mut app, 100.0 + 5.0 / f64::from(zoom), 100.0);
|
||||
pen_up(&mut app);
|
||||
let pen = app.ui.pen.as_ref().unwrap();
|
||||
assert_eq!(pen.knots.len(), 2);
|
||||
assert_eq!(pen.knots[1][1], incoming);
|
||||
assert_eq!(pen.knots[1][2], pen.knots[1][0]);
|
||||
let saved = serde_json::to_value(pen).unwrap();
|
||||
let restored: PenPath = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(restored, *pen);
|
||||
assert_eq!(pen_to_json(&restored, false)["subpaths"][0]["knots"][1]["smooth"], false);
|
||||
pen_down(&mut app, 100.0 + 7.0 / f64::from(zoom), 100.0);
|
||||
assert_eq!(app.ui.pen.as_ref().unwrap().knots.len(), 3);
|
||||
}
|
||||
let legacy: PenPath = serde_json::from_value(json!({"knots": [[[10, 10], [10, 10], [10, 10]]]})).unwrap();
|
||||
assert!(legacy.unlinked.is_empty());
|
||||
assert!(!legacy.dragging && !legacy.adjusting_last);
|
||||
}
|
||||
|
||||
/// #1520: changing one corner through the Properties command must keep the other three
|
||||
/// independent, remain a live rectangle, and undo/redo as one geometry edit.
|
||||
#[test]
|
||||
fn live_rectangle_corner_radius_edit_preserves_other_corners_and_history() {
|
||||
let mut app = app();
|
||||
let created = app.run("shape.create", json!({"kind": "roundedRect", "rect": [10, 10, 150, 100], "radii": [5, 10, 15, 20]})).unwrap();
|
||||
let id = created["layer"].as_u64().unwrap();
|
||||
let get_radii = |app: &PhotocraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
let shape = st.doc.layer(photocraft_doc::LayerId(id)).unwrap();
|
||||
match &shape.content {
|
||||
LayerContent::Shape(sh) => match &sh.live {
|
||||
Some(photocraft_doc::vector::LiveShape::Rect { radii, .. }) => *radii,
|
||||
_ => panic!("rectangle must remain a live shape"),
|
||||
},
|
||||
_ => panic!("expected shape layer"),
|
||||
}
|
||||
};
|
||||
assert_eq!(get_radii(&app), [5.0, 10.0, 15.0, 20.0]);
|
||||
let mut patch = corner_radii_patch(get_radii(&app), 1, 30.0);
|
||||
patch["layer"] = json!(id);
|
||||
app.run("shape.edit", patch).unwrap();
|
||||
assert_eq!(get_radii(&app), [5.0, 30.0, 15.0, 20.0]);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get_radii(&app), [5.0, 10.0, 15.0, 20.0]);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(get_radii(&app), [5.0, 30.0, 15.0, 20.0]);
|
||||
for corner in 0..4 {
|
||||
let before = get_radii(&app);
|
||||
let radius = 35.0 + corner as f32;
|
||||
let mut expected = before;
|
||||
expected[corner] = f64::from(radius);
|
||||
let mut patch = corner_radii_patch(before, corner, radius);
|
||||
patch["layer"] = json!(id);
|
||||
app.run("shape.edit", patch).unwrap();
|
||||
assert_eq!(get_radii(&app), expected);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get_radii(&app), before);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(get_radii(&app), expected);
|
||||
}
|
||||
app.run("shape.edit", json!({"layer": id, "radii": 12})).unwrap();
|
||||
assert_eq!(get_radii(&app), [12.0; 4]);
|
||||
}
|
||||
|
||||
/// #534: dragging in a shape's fill picker, opened from the Properties panel at the right edge
|
||||
/// of the window, keeps the picker in place (it flipped from side to side as its width
|
||||
/// followed the colour readouts, which moved it under the pointer).
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Crop tool: while a frame is edited, the canvas shows the layers' pixels past its edges, as
|
||||
//! Photoshop's crop preview does.
|
||||
//!
|
||||
//! A crop with Delete Cropped Pixels off keeps what it cut away, but PhotoCraft drew only the
|
||||
//! canvas, so a frame couldn't be pulled back out over those pixels by eye. Photoshop (measured on
|
||||
//! 25.4) grows the view to every layer's bounds and the frame once the frame is pressed: the
|
||||
//! hidden pixels under the shield, transparency as the checkerboard, until ↵ or Esc.
|
||||
//!
|
||||
//! The real app runs offscreen on wgpu with real egui pointer events; the test reads the rendered
|
||||
//! pixels. Skips when no GPU adapter exists (like `drag_preview_canvas.rs`).
|
||||
|
||||
use egui::{Key, Modifiers, PointerButton, Pos2};
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::canvas::ViewXform;
|
||||
use photocraft_ui_egui::control::{ControlRequest, handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
|
||||
/// A rendered frame: RGBA8, physical pixels.
|
||||
struct Image {
|
||||
w: u32,
|
||||
h: u32,
|
||||
px: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
fn rgb(&self, p: Pos2) -> [u8; 3] {
|
||||
let (x, y) = (p.x.round() as i64, p.y.round() as i64);
|
||||
assert!(x >= 0 && y >= 0 && x < i64::from(self.w) && y < i64::from(self.h), "{p:?} is off the frame");
|
||||
let i = (y as usize * self.w as usize + x as usize) * 4;
|
||||
[self.px[i], self.px[i + 1], self.px[i + 2]]
|
||||
}
|
||||
}
|
||||
|
||||
fn harness() -> Option<Harness> {
|
||||
let built = std::panic::catch_unwind(|| {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_max_steps(64).wgpu().build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app
|
||||
})
|
||||
});
|
||||
match built {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn control(h: &mut Harness, method: &str, params: Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
handle(h.state_mut(), &ctx, &req);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn screen(h: &Harness, x: f32, y: f32) -> Pos2 {
|
||||
let app = h.state();
|
||||
let v = &app.ui.views[0];
|
||||
let xf = ViewXform {
|
||||
rect: photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect),
|
||||
zoom: v.zoom,
|
||||
center: v.center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
};
|
||||
xf.to_screen(x, y)
|
||||
}
|
||||
|
||||
fn button(h: &mut Harness, x: f32, y: f32, pressed: bool) {
|
||||
let p = screen(h, x, y);
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness, from: [f32; 2], to: [f32; 2]) {
|
||||
button(h, from[0], from[1], true);
|
||||
for i in 1..=6 {
|
||||
let t = i as f32 / 6.0;
|
||||
h.event(egui::Event::PointerMoved(screen(h, from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t)));
|
||||
h.step();
|
||||
}
|
||||
button(h, to[0], to[1], false);
|
||||
}
|
||||
|
||||
fn render(h: &mut Harness) -> Image {
|
||||
let img = h.render().expect("render");
|
||||
Image { w: img.width(), h: img.height(), px: img.into_raw() }
|
||||
}
|
||||
|
||||
/// Mostly `c` (one channel well above the other two), dimmed or not.
|
||||
fn tinted(p: [u8; 3], c: usize) -> bool {
|
||||
(0..3).filter(|&i| i != c).all(|i| p[c] > 60 && p[i] < p[c] / 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_crop_frame_shows_the_pixels_past_the_canvas() {
|
||||
let Some(mut h) = harness() else { return };
|
||||
h.run_steps(4);
|
||||
{
|
||||
let app = h.state_mut();
|
||||
// 300 × 120: red down the left 60 px and blue down the right 60 px of a new layer, then
|
||||
// cropped to (50, 0) 200 × 100 keeping the pixels: 50 px of red past the left edge, 50 of
|
||||
// blue past the right, and 20 rows of the white Background below.
|
||||
app.run("file.new", json!({"width": 300, "height": 120, "background": "white"})).expect("new");
|
||||
app.run("layer.new.layer", json!({})).expect("layer");
|
||||
for (x, color) in [(0, "#ff0000"), (240, "#0000ff")] {
|
||||
app.run("select.rect", json!({"x": x, "y": 0, "width": 60, "height": 120})).expect("select");
|
||||
app.run("edit.fill", json!({"color": color})).expect("fill");
|
||||
}
|
||||
app.run("select.deselect", json!({})).expect("deselect");
|
||||
app.run("image.crop", json!({"x": 50, "y": 0, "width": 200, "height": 100, "deleteCroppedPixels": false})).expect("crop");
|
||||
app.sync_views();
|
||||
app.ui.extras.rulers = false;
|
||||
app.ui.extras.snap = false;
|
||||
}
|
||||
control(&mut h, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [100, 50]}));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0]), "the default frame");
|
||||
let (red, blue, white, beyond) = ([-25.0, 50.0], [225.0, 50.0], [100.0, 110.0], [-80.0, 50.0]);
|
||||
let at = |h: &Harness, img: &Image, p: [f32; 2]| img.rgb(screen(h, p[0], p[1]));
|
||||
|
||||
// Picking the tool shows only the canvas (and the pasteboard around it, dimmed by the shield).
|
||||
let before = render(&mut h);
|
||||
let shown = |h: &Harness, img: &Image| [red, blue, white, beyond].map(|p| at(h, img, p));
|
||||
let was = shown(&h, &before);
|
||||
assert!(!tinted(was[0], 0) && !tinted(was[1], 2), "nothing past the canvas yet: {was:?}");
|
||||
|
||||
// A press on the frame starts editing, before any move: the pixels past the canvas show,
|
||||
// under the shield (the press asks for the frame that draws them).
|
||||
button(&mut h, 100.0, 50.0, true);
|
||||
h.step();
|
||||
let img = render(&mut h);
|
||||
let [r, b, w, past] = shown(&h, &img);
|
||||
assert!(tinted(r, 0), "hidden red: {r:?}");
|
||||
assert!(tinted(b, 2), "hidden blue: {b:?}");
|
||||
assert!(w.iter().all(|&c| c > 90 && c < 200) && w[0] == w[1] && w[1] == w[2] && w != was[2], "hidden white, dimmed: {w:?}");
|
||||
assert_eq!(past, was[3], "past every layer and the frame: the pasteboard");
|
||||
// Inside the frame nothing changed.
|
||||
assert_eq!(at(&h, &img, [150.0, 30.0]), at(&h, &before, [150.0, 30.0]));
|
||||
// Releasing (a click on the untouched frame) keeps the crop being edited.
|
||||
button(&mut h, 100.0, 50.0, false);
|
||||
let img = render(&mut h);
|
||||
assert!(tinted(at(&h, &img, red), 0), "still shown after the click");
|
||||
|
||||
// The left edge pulled out past the red: the red inside the frame is undimmed, and where
|
||||
// there are no pixels the frame shows transparency.
|
||||
drag(&mut h, [0.0, 50.0], [-80.0, 50.0]);
|
||||
assert_eq!(h.state().ui.crop_rect, Some([-80.0, 0.0, 200.0, 100.0]));
|
||||
let img = render(&mut h);
|
||||
let r = at(&h, &img, red);
|
||||
assert!(r[0] > 230 && r[1] < 40 && r[2] < 40, "red inside the frame at full strength: {r:?}");
|
||||
// (egui_kittest samples textures through its own clamped bilinear filter, so the checkerboard
|
||||
// shows as a light, neutral grey here rather than as squares.)
|
||||
for p in [[-70.0, 50.0], [-62.0, 80.0]] {
|
||||
let c = at(&h, &img, p);
|
||||
assert!(c.iter().all(|&v| v >= 190) && c[0] == c[1] && c[1] == c[2], "transparency past the layers at {p:?}: {c:?}");
|
||||
}
|
||||
// The red runs on across the canvas edge: no canvas border line between.
|
||||
for x in [-2.0, -1.0, 0.0, 1.0] {
|
||||
let c = at(&h, &img, [x, 50.0]);
|
||||
assert!(c[0] > 230 && c[1] < 40, "at x = {x}: {c:?}");
|
||||
}
|
||||
|
||||
// Esc cancels: back to the canvas alone.
|
||||
h.event(egui::Event::PointerGone);
|
||||
h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let img = render(&mut h);
|
||||
assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0]));
|
||||
assert_eq!(shown(&h, &img), was, "hidden again after Esc");
|
||||
}
|
||||
@@ -28,6 +28,8 @@ Each reply is one JSON line with the same `id`:
|
||||
{"id": 2, "ok": false, "error": "unknown tool `foo`"}
|
||||
```
|
||||
|
||||
The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started.
|
||||
|
||||
The transport is `apps/photocraft/src/control_server.rs`, and the handlers are in `crates/ui-egui/src/control.rs`. The MCP server (`photocraft-cli mcp --bridge 127.0.0.1:<port>`, crate `photocraft-automation`) wraps this same protocol. See [MCP bridge](#mcp-bridge) below.
|
||||
|
||||
## Methods
|
||||
@@ -49,6 +51,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven.
|
||||
- Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape.
|
||||
- `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
|
||||
- `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing)
|
||||
- `ui.resize {width, height}`: resize the main window
|
||||
@@ -170,7 +173,7 @@ magenta alignment lines. `ui.pointer` drives the same code, so agents get identi
|
||||
- **Headless** (`photocraft-cli mcp`): an in-process `photocraft_engine::Session`. There is no window.
|
||||
- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file <path>`): every tool is forwarded to a running `photocraft --control 7878 --control-token-file <path>` over this protocol, so agents see and drive the live app.
|
||||
|
||||
The bridge keeps one authenticated TCP connection open. It reconnects and authenticates once if a request fails, and it skips reply lines whose `id` doesn't match the request (for example, stale replies to requests that timed out). It only accepts loopback addresses, because the app only listens on loopback. Supply its bearer token with `--control-token-file`, `--control-token`, `PHOTOCRAFT_CONTROL_TOKEN_FILE`, or `PHOTOCRAFT_CONTROL_TOKEN`:
|
||||
The bridge keeps one authenticated TCP connection open. A transport failure while sending a request or waiting for its reply, including a reply timeout, drops the connection and reports that the operation may have completed; inspect the document before retrying an edit. It never automatically resends the failed request. The next separate call reconnects and authenticates, and reply lines whose `id` does not match the request are skipped. It only accepts loopback addresses, because the app only listens on loopback. Supply its bearer token with `--control-token-file`, `--control-token`, `PHOTOCRAFT_CONTROL_TOKEN_FILE`, or `PHOTOCRAFT_CONTROL_TOKEN`:
|
||||
|
||||
```sh
|
||||
photocraft-cli mcp --bridge 127.0.0.1:7878 \
|
||||
@@ -196,6 +199,8 @@ How each MCP tool maps onto control methods in bridge mode:
|
||||
| `ui_set {fields}` | `ui.set` |
|
||||
| `control_call {method, params}` | any method, passed through unchanged |
|
||||
|
||||
Bridge previews capture the app window; passing `index` is an error. Headless `doc_render_preview` supports `index` without changing the active document.
|
||||
|
||||
`doc_select` and `doc_close` work only in headless mode. The `ui_*` tools and `control_call` work only in bridge mode; in headless mode they return a tool error that explains how to start bridge mode.
|
||||
|
||||
**Security note:** TCP control uses a bearer token, not client identity or general per-method
|
||||
|
||||
+4
-1
@@ -93,7 +93,10 @@ exports. All integers are `i32` unless noted; pointers are offsets into the modu
|
||||
|
||||
- `id`: 1–64 characters from `A-Z a-z 0-9 . _ -` (reverse-DNS recommended). Installing a module
|
||||
with the same id replaces the old one.
|
||||
- `name`: the menu label (1–64 printable characters). `kind`: `"filter"` (the only kind in v1).
|
||||
- `name`: the menu label (1–64 printable characters). Any script works, but control characters and
|
||||
Unicode format characters (bidi overrides and marks, zero-width spaces, soft hyphens) are rejected,
|
||||
except the zero-width joiner and non-joiner, and at least one character must be visible.
|
||||
`kind`: `"filter"` (the only kind in v1).
|
||||
- `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, not `id`/`layer`.
|
||||
The host validates the user's values before calling: missing values take the default, numbers are
|
||||
clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error.
|
||||
|
||||
Reference in new issue
Block a user