Compare commits

...
Author SHA1 Message Date
Claude 17ff15802a Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/ui-egui/src/vector_ui.rs
2026-10-09 03:32:29 +00:00
Claude 7406c26bd4 automation test: as_chunks for the preview pixel check (clippy 1.99 chunks_exact_to_as_chunks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 03:32:19 +00:00
Claude 2c82295e4c fmt: batch 7 conflict resolution in vector_ui.rs tests 2026-10-09 02:55:10 +00:00
Claude 3ffc1b658e automation test: new fill and adjustment layers' path is a vector path (#1419)
#1539 gives layer.newFillLayer.* and layer.newAdjustmentLayer.* a "path" parameter (the
vector mask), which registry_filesystem_path_params_are_classified took for a file path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
(cherry picked from commit b37dbd8ee0)
2026-10-09 02:45:05 +00:00
Claude cc3626305d Merge pr/1509
# Conflicts:
#	contributors/people.toml
2026-10-09 02:22:03 +00:00
Claude 106d581ce7 Merge pr/1511
# Conflicts:
#	contributors/people.toml
2026-10-09 02:22:03 +00:00
Claude 11e4249f5a Merge pr/1541
# Conflicts:
#	crates/ui-egui/src/vector_ui.rs
2026-10-09 02:22:03 +00:00
Claude 074dc28b6a Merge pr/1538
# Conflicts:
#	crates/ui-egui/src/vector_ui.rs
2026-10-09 02:22:02 +00:00
Claude f1d9f71b10 Merge pr/1440 2026-10-09 02:21:47 +00:00
Claude 929a525d4f Merge pr/1446 2026-10-09 02:21:46 +00:00
Claude af50169892 Merge pr/1445 2026-10-09 02:21:46 +00:00
Claude 6d324d3f0d Merge pr/1444 2026-10-09 02:21:45 +00:00
Claude 601ae7b9b4 Merge pr/1443 2026-10-09 02:21:45 +00:00
Claude 8ad3f085f2 Merge pr/1441 2026-10-09 02:21:44 +00:00
Claude 3e04181088 Merge pr/1504 2026-10-09 02:21:44 +00:00
Claude f8d23085b7 Merge pr/1505 2026-10-09 02:21:43 +00:00
Claude fa56acf278 Merge pr/1507 2026-10-09 02:21:43 +00:00
Claude 0b0f4b72e6 Merge pr/1508 2026-10-09 02:21:42 +00:00
Claude 5bd228e5b5 Merge pr/1513 2026-10-09 02:21:42 +00:00
Claude df8ac3d764 Merge pr/1515 2026-10-09 02:21:41 +00:00
Claude e6f7b42c30 Merge pr/1523 2026-10-09 02:21:41 +00:00
Claude b287b7ff3c Merge pr/1525 2026-10-09 02:21:40 +00:00
Claude 1fb8d054a9 Merge pr/1527 2026-10-09 02:21:39 +00:00
Claude ddb45174fc Merge pr/1533 2026-10-09 02:21:39 +00:00
Claude 266a473de3 Merge pr/1535 2026-10-09 02:21:38 +00:00
Claude e866443bd7 Merge pr/1539 2026-10-09 02:21:37 +00:00
Nigel Ihza Firdausy 960959f8a6 fix(pen): reshape the final outgoing handle while preserving incoming geometry and linkage 2026-10-09 08:57:13 +07:00
Nigel Ihza Firdausy b31a98c3d8 feat(ui): edit independent rectangle corner radii with narrow-panel controls and history tests 2026-10-09 08:55:20 +07:00
David MannandClaude Opus 5.5 2a282119d6 Fill and adjustment layers take the active path as their vector mask (#1419)
In Photoshop, when a path is selected in the Paths panel (the work path
just drawn with the Pen or made from a selection, or a saved path), Layer
> New Fill Layer > Solid Color/Gradient/Pattern and every new adjustment
layer get a vector mask made from that path: a Solid Color fill with a
vector mask is a shape layer, editable as a vector. The path wins over an
active selection; with no path selected the selection becomes a layer
mask (#1250), else there is no mask. PhotoCraft always used the selection
and ignored the path, so Solid Color filled the whole canvas.

Engine: the new fill and adjustment layer commands take an optional
"path" param ("work", a saved path's name or a path object). The shared
helper mask_new_layer turns it into the layer's vector mask and falls back
to the selection mask without it; an unknown name is an error, not a
panic.

UI: run_command passes the path selected in the Paths panel as "path"
(callers can opt out with "path": null). Drawing a work path with the Pen
and Make Work Path select it, as in Photoshop, and clicking the panel's
empty area deselects it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 03:53:09 +02:00
David MannandClaude Opus 5.5 4623ab24b1 Free Transform: the reference point moves with the box when scaling (#1329)
In Photoshop the reference point is attached to the bounding box: scaling
from a corner or edge, skewing, distorting and perspective carry it along
so it keeps its place within the box (the centre stays the centre, a
corner reference point stays on its corner). PhotoCraft left it at its old
canvas position for all of those drags.

After each of these drags the reference point is now mapped through the
box: its position in the old box's unit frame is mapped onto the new box
(carry_pivot). Alt-scaling, which scales about the reference point, and
rotating about it still leave it put; moving and the numeric options bar
already behaved correctly and are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 03:43:46 +02:00
harrisonoconnorhover 07dafe23e0 Reject unsupported document indices in MCP screenshots 2026-10-08 21:41:04 -04:00
harrisonoconnorhover b9eb9cfb62 Do not replay bridge requests after an uncertain result 2026-10-08 21:27:52 -04:00
harrisonoconnorhover eb6cb73938 Reject control requests that expire before dispatch 2026-10-08 21:03:31 -04:00
David MannandClaude Opus 5.5 98a24d1269 Undo: a paint stroke's undo keeps the painted layer active (#1356)
Selecting a layer is not a history step, so each state kept the target it
had when it was created. Undoing a step therefore jumped back to whatever
was selected when the previous step was taken: rasterize a shape with the
Background active, select the rasterized layer, paint, Ctrl+Z, and the
Background became active again.

The state a step leaves behind now targets what was selected just before
the step (Session::edit and compound steps set the current state's target
before recording), as in Photoshop: undo of a stroke or fill keeps the
painted layer, undo of New Layer or Merge goes back to the layers selected
before it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 02:59:29 +02:00
David MannandClaude Opus 5.5 6ab334d3b8 PSD: warn that Color Lookup tetrahedral interpolation is saved as trilinear (#1347)
Photoshop's clrL descriptor (lookupType, NM, Dthr, profile, LUTFormat,
dataOrder, tableOrder, LUT3DFileData, LUT3DFileName) has no interpolation
setting, so a tetrahedral Color Lookup layer reopens trilinear and renders
differently. PSD save now reports that in its warnings instead of changing
the composite silently. Trilinear lookups save without a warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 02:34:23 +02:00
David MannandClaude Opus 5.5 b5b2e380d6 TIFF: alpha after an unspecified extra sample opens transparent (#1349)
TIFF 6.0 allows the alpha at any ExtraSamples position, but the decoder
only looked at the first extra sample, and for RGB/CMYK the tiff crate
drops every sample after its colour type. With ExtraSamples [0, 2] the
alpha was silently lost (and gray [0, 2] opened as plain gray).

The alpha is now the first extra sample marked associated (1) or
unassociated (2), wherever it is. When it is not the first extra sample
of an RGB or CMYK image, the crate is asked for every sample (the
photometric is patched to multiband gray, as for WhiteIsZero) and the
colour and alpha samples are picked per chunk; planar files read the
alpha's own plane. Associated alpha in that position is made straight.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 02:32:39 +02:00
Willy Foo 42dd44e78a perf(algo): specialize photo blur and resample channel loops 2026-10-09 08:26:28 +08:00
Willy Foo 130aed626a fix(ui): release closed-document canvas and navigator textures 2026-10-09 08:21:56 +08:00
David MannandClaude Opus 5.5 6d9a603d9c Help › Search: keep the field focused after a click opens Help (#1447, #1438)
Both reports were filed against v0.5.0, which predates e63ff519 (#1027):
there the Help menu used egui's CloseOnClick, so any click inside it,
including on the search field, closed it. The egui menu bar is what
Windows (and Linux) show; macOS uses the native menu bar, hence "on
Windows". That close is fixed on main but not yet released.

What remained on main: menus open on the press, and the search field
asks for focus then, but the release a frame later completes a click on
the Help title, outside the field, so egui (SurrenderFocusOn::Clicks)
took the focus away again. After clicking Help, typing went nowhere
until the field was clicked. The field now asks for focus again on that
release, as on a fresh opening.

The new whole-app kittest drives real clicks (press and release in
separate frames) with and without the custom title bar and at 1.25x and
1.5x scale. It checks the field is focused after Help opens, that typing
searches at once, and that clicking the field and typing keeps Help
open. It fails without this change (focus) and with v0.5.0's
CloseOnClick for Help (menu closes).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 02:13:24 +02:00
David MannandClaude Opus 5.5 31f4896405 Font menu keeps its height and focuses its search field (#1369)
egui measures a popup once, when it opens, and never lets it grow back,
so filtering the font list left the menu short after the query was
cleared. While searching, the list now keeps the height it measured
unfiltered.

The search field was only focused while the query was empty, so a
reopened menu with a leftover query had no focus; it is now focused on
every open. The ComboBox closed on any click, including on the search
field; it now closes on clicks outside, and picking a font closes it.

Fixes #1369

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 02:10:43 +02:00
Claude Opus 5.5 00fe0967fd Divide Slice: one division makes at most 10000 parts (#1020)
slice.divide clamped each axis to 1..1000 but checked no total, so one
request made up to a million slices, and resolving the slice list
(slices::resolve, used by slice.list and export) is quadratic in the slice
count: about 5e11 rectangle visits at a million.

A division over 10000 parts (100 x 100) is now refused with a bad-params
error before any part is created; the params doc says so. Resolving 10000
slices is about 5e7 visits, and the test lists them in well under a second.

The test refuses 101 x 100, 100 x 101 and 1000 x 1000 with the slices and
history unchanged, then divides 100 x 100 into 10000 parts and lists them.
Before the fix 101 x 100 was accepted (10100 parts).
2026-10-08 20:09:11 -04:00
UnemarsandClaude Opus 5.5 914b55c4c5 Crop tool: show the pixels past the canvas while a frame is edited, as Photoshop does
A crop with Delete Cropped Pixels off keeps what it cuts away (and a moved layer can reach past
the edges), but the Crop tool drew only the canvas, so a frame couldn't be pulled back out over
those pixels by eye. Photoshop 25.4 (observed) grows the view to every layer's bounds and the
frame from the first press on the frame: hidden pixels under the shield, transparency as the
checkerboard, no canvas border or shadow, until the crop is committed or cancelled.

- crop_ui: `CropState::editing`, set by the press (before egui's drag threshold) and cleared when
  a new default frame is made (commit, Esc) or the tool is left; `shows_beyond_canvas`.
- canvas: `draw_beyond_canvas` composites only the ring between the canvas and every layer's
  pixels (`extra_cmds::reveal_all_bounds`, shared with Image > Reveal All), once per document
  state, at most MAX_TEXTURE texels per side (from a downsampled proxy past that), and draws it
  with the checkerboard out to the frame under the canvas, reaching a point under its edge so the
  edge's anti-aliasing has no seam. The drop shadow and the Line border are off meanwhile.
- compose: `render_reduced_rect`, the reduced render of any area of the document plane.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 02:07:34 +02:00
MarekSpodBiedry 5c8d0ad735 Control channel: ui.click rejects a count above 256 instead of queueing unbounded input (#982) 2026-10-08 23:47:33 +02:00
MarekSpodBiedry 25641e432f Opening a file with different horizontal and vertical resolutions says the vertical one is not kept (#1018) 2026-10-08 23:47:23 +02:00
MarekSpodBiedry 9fcbd61253 Plug-in names reject bidi controls, zero-width spaces and other invisible format characters, and need a visible character (#1011) 2026-10-08 23:47:14 +02:00
MarekSpodBiedry 644fdcfe9c Plug-ins: a missing parameter's default is clamped to the declared min..max (#1012) 2026-10-08 23:47:06 +02:00
Claude Opus 5.5 008595d2d4 Canvas: a huge view centre draws the selection outline without overflow (#980)
`ui.set` takes any finite centre, and the selection outline quantised the
visible document region with unchecked i32 arithmetic. The float-to-i32
casts saturate, then the padding overflowed: `attempt to multiply with
overflow` for a large positive centre and `attempt to subtract with
overflow` for a large negative one, panicking the canvas frame outside any
dispatch guard. Release builds wrapped to an inverted, empty region.

The visible corners are now clamped to ±2³⁰ before the cast. No document
reaches that far, and the padding (at most 2 · 256 · 64 px when zoomed far
out) can't overflow i32 from there. The fix stays in canvas.rs, so ui.set
(which #820 rewrites) is unchanged.

The test draws a canvas frame with a selection after `ui.set` centres of
±1e30 and (3e9, -3e9), at 100 % and at 1/64 zoom (the largest quantisation
step), and checks that a centre on the selection still traces the outline.
It panicked at the multiply before the fix.
2026-10-08 17:42:51 -04:00
harrisonoconnorhover 7f863587b5 Record Transform Again as one concrete action step 2026-10-08 17:39:46 -04:00
56 changed files with 2025 additions and 168 deletions

No files matched your search

+6 -3
View File
@@ -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");
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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] {
+45 -48
View File
@@ -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, &params)).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, &params)).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"))));
+15 -1
View File
@@ -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"),
+107
View File
@@ -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());
+32 -6
View File
@@ -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);
}
}
}
+42
View File
@@ -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.
+12 -6
View File
@@ -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);
+34 -4
View File
@@ -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
+73 -4
View File
@@ -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();
+3 -1
View File
@@ -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;
+9 -3
View File
@@ -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);
+2 -1
View File
@@ -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);
}
+5 -1
View File
@@ -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 {
+9 -1
View File
@@ -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
),
+21
View File
@@ -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());
}
+2 -1
View File
@@ -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()));
+8
View File
@@ -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 {
+8
View File
@@ -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);
}
}
+4 -2
View File
@@ -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())),
+4 -2
View File
@@ -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()));
+22
View File
@@ -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 {
+92
View File
@@ -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);
}
+4 -3
View File
@@ -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>,
+113 -5
View File
@@ -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());
}
}
+244 -9
View File
@@ -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
+79 -7
View File
@@ -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();
}
}
}
+55
View File
@@ -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];
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
17
18
19
20
21
22
23
24
25
26
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -17,6 +17,10 @@
Layer 图层
Type 文字
Select 选择
Top Left 左上
Top Right 右上
Bottom Left 左下
Bottom Right 右下
Filter 滤镜
View 视图
Window 窗口
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
17
18
19
20
21
22
23
24
25
26
+4
View File
@@ -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.
+25
View File
@@ -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();
+6 -4
View File
@@ -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");
}
}
+9
View File
@@ -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);
+6 -1
View File
@@ -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");
+62 -3
View File
@@ -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};
+4 -2
View File
@@ -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());
+66 -2
View File
@@ -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");
}
+47 -4
View File
@@ -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
View File
@@ -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).
+178
View File
@@ -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");
}
+6 -1
View File
@@ -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
View File
@@ -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.