Compare commits

...
Author SHA1 Message Date
Tanish Manku d9e1c10c30 Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines) 2026-10-15 15:22:09 -07:00
Tanish Manku 4a1acca879 Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute 2026-10-15 15:15:11 -07:00
Tanish Manku a95b3f0a85 Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied 2026-10-15 15:15:11 -07:00
Tanish Manku b7eab1a377 Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal 2026-10-15 15:15:11 -07:00
Tanish Manku 25a8200110 New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order 2026-10-15 15:15:10 -07:00
Tanish Manku 6443f5fb4c Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)
Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.
2026-10-15 15:15:01 -07:00
skylar taylor-barrick af54f4aed1 ci: limit the font pass to font-consuming crates (#6127)
Keep the full workspace and feature build selection while running the craft-fonts pass only for text, io and ui-egui. Ordinary workspace test coverage remains in the first test pass.
2026-10-15 16:03:39 -06:00
skylar taylor-barrick 1a4167936e ci: run tests with cargo nextest (#6118)
Run workspace tests with pinned cargo-nextest, a CI fail-fast profile and a ten-minute per-test hang limit. Keep cheap checks first and doctests in a separate step so failures propagate on Windows.
2026-10-15 16:02:57 -06:00
Tanish Manku 54f4baf6f9 Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names (#5856)
* Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names

With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls
changed only the active layer, silently (the layers audit's top panel gap). They now change
every selected layer, as in Photoshop, in one undo step. layer.setProps takes
"layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a
name for several layers is an error that changes nothing.

History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity
Change, Blending Change, Rename Layer (a mix stays Layer Properties).

* Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes

* Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856)

* Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856)
2026-10-15 14:33:51 -07:00
Tanish Manku 3cd0f6c954 Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) (#5349)
* Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a)

A key that another command holds now offers Photoshop's two answers under the warning.
Accept and Go to Conflict takes the key from the other command at once (its main key or
an alternate) and selects it for a new one. Undo Changes puts the edited command's main and
further keys back as they were. OK without either still accepts, as in Photoshop.

Keys the system takes before the app sees them are refused with a message: Alt+Tab,
Alt+F4, Ctrl+Alt+Delete, Ctrl+Esc and Ctrl+Shift+Esc off the Mac; Cmd+Tab, Cmd+` and
Cmd+Opt+Esc on it.

* Keyboard Shortcuts: a conflict goes stale when the bindings change after it (Reset All, Use Default, Delete, ×, Escape, a set switch); Accept takes the key from every holder; Linux reserves its own keys; doc comment back on shortcut_text
2026-10-15 14:18:14 -07:00
Tanish Manku 04db697b5d Paste Layer Style: a group's Pass Through pastes onto other layers as Normal; style clipboard comment (#6310) 2026-10-15 14:17:22 -07:00
Tanish Manku c11ef015b7 Paste Layer Style carries Blend If and channels; align to an active selection with several layers (#5881)
* Paste Layer Style carries Blend If and channels; align to an active selection with several layers

From the layers audit (L2):
- Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but
  dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its
  Blending Options. Across colour modes only Blend If's Gray range carries over and the
  channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named
  struct (CopiedStyle) instead of a tuple.
- With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to
  their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to
  Selection), as it already did for one layer.

* Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests
2026-10-15 13:10:28 -07:00
Tanish Manku 77a258b9a0 Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles (#5868)
* Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles

From the layers audit (L2, silently wrong results):
- Release Clipping Mask on a clipped layer also releases the clipped layers above it, as
  Adobe documents ("removes the selected layer and any layers above it from the clipping
  mask"). Before, they stayed clipped to the released layer.
- Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a
  layer still clipped to their base (it was always unclipped). Merge Down's result also keeps
  the lower layer's colour label and link; Merge Layers' keeps the top layer's label.
- Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule
  3, as single Delete already does), instead of leaving them clipped to the layer below.

* Merge Layers keeps the result clipped only when the clipped layers share one clip chain

* fmt
2026-10-15 13:10:22 -07:00
e4b2868a02 Check deep EXR tile count tables against max_alloc (#5632)
* Check deep EXR tile count tables against max_alloc

A deep tile's sample-count table was decompressed at the header's full tile size with no
budget check, so a 20 KB file could build a 4 MiB table and a scratch copy under a 1 MiB
max_alloc. Try that size only when max_alloc has room for both; otherwise read the clipped size.

Fixes #4958.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep deep EXR decompression within the size it was admitted at

The count-table check admits the table and its scratch copy, 2x the table, but Vec doubling
gave a 4 MiB ZIPS table 8 MiB of capacity (a 3 MiB RLE table 4 MiB), so an admitted table
and its copy could take 3x. Grow the decompressed buffer by doubling capped at the expected
size, inflating straight into it, and allocate it and the scratch copy fallibly.

Tests: decompressed buffers never reserve past the expected size; a near-maximal tile with a
clipped table decodes under every budget; per-tile admission; half and uint channels after
full-size tables; the 8 MiB boundary for uncompressed tables too.

Refs #4958.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Tests: show deep tile tables are admitted one tile at a time

The 17x17 image in 16x16 tiles had an interior tile, which never reaches the max_alloc check,
so only three edge tables (6 KiB) were checked against the 8000-byte budget and a check that
summed admissions across tiles still passed. Use a 48x1 image instead: its three tiles are all
edge tiles, and under 4096 bytes a summed check fails at the third tile.

Refs #4958.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Complete deep EXR count-table allocation admission

Account for scratch space on clipped and interior compressed tables while preserving raw-table budgets. Borrow packed input before admission and make initial RLE reservations and raw copies fallible.

Add single-channel admission boundaries, raw fallback controls, and hostile-size borrowed-read regressions.

* ci: run deep EXR regressions independently

* Drop the separate deep EXR CI job

It was added so these tests could run while main's workspace suite was
failing. Main is green again, and the regular test jobs run them.

This reverts commit 5aa4869b45.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: jonathnwxh <[email protected]>
2026-10-15 13:40:50 -06:00
Michael HigginsandClaude Opus 5.5 64520f645e Control channel: connections must authenticate within 10 s, however active (#4965) (#5229)
The only limit on an unauthenticated control connection was the 30 s
idle timeout, which starts again with every read. A local client that
kept sending bytes or blank lines without ever completing `auth` kept
its connection, and one of the 16 connection permits, for as long as it
liked; sixteen of them locked every other client out.

AuthDeadlineStream wraps the read half of a connection: until the
connection authenticates, reads fail once AUTH_DEADLINE (10 s) has
passed since it connected, and a blocking read waits no longer than
what is left. Both transports use it (the desktop app's control server
and the headless `photocraft-cli serve`). After `auth` succeeds only
the idle timeout applies, as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:39:19 -06:00
Michael HigginsandClaude Opus 5.5 b24c0360f1 Dependencies: require ruzstd 0.9.1 for RUSTSEC-2026-0344 (#4952) (#5225)
ruzstd before 0.9.1 lets a crafted zstd input under 128 KB allocate
several gigabytes at once. PhotoCraft decodes zstd when it opens .pcraft
and Affinity files, so such a file could end the process with an
out-of-memory abort.

Raise the minimum in both crates that use it and update the lockfile
from 0.9.0 to 0.9.1.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:37:56 -06:00
skylartaylor d68a1cbc2d ci: preserve separate nextest and doctest results 2026-10-15 19:56:43 +02:00
skylartaylor bcf2cefd4b ci: preserve nextest failures on Windows 2026-10-15 19:56:43 +02:00
skylartaylor 4f6e617555 ci: retain the font filter with integrated nextest checks 2026-10-15 19:44:21 +02:00
skylartaylor 40a7c20430 ci: integrate nextest with early checks and runtime limits 2026-10-15 19:44:21 +02:00
David MannandClaude Opus 5.5 caa28cb7ab CI: run only the font tests in the craft-fonts pass (#6120)
The pass reran every test in the workspace, but CRAFT_FONTS is only read in photocraft-text,
photocraft-io (svg) and photocraft-ui-egui. The build selection stays the same (the disk warning
above still applies); a nextest filter runs those three packages' tests: ~3,800 of ~8,900.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:41:12 +02:00
David MannandClaude Opus 5.5 e22c4ec7ab CI: run the tests with cargo nextest (#6117)
cargo test runs the ~258 test binaries one at a time; nextest runs them in parallel. Its new `ci`
profile stops at the first failure (a red PR frees its runner early), fails a test that hangs for
10 minutes instead of letting it hold the runner, and repeats every failure at the end of the log;
the GPU test group still applies. Same --workspace --locked --features heif selection, so the build
is unchanged. Doctests stay on cargo test --doc.

postscript_only_style_shows_actual_subfamily relied on another test in the same process having
started the font scan; under nextest it runs alone, so it starts the scan itself (as #2933 does).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:40:15 +02:00
52 changed files with 1985 additions and 166 deletions

No files matched your search

+10 -1
View File
@@ -1,5 +1,6 @@
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
# keeps working (#2204).
#
# cargo nextest run --workspace # all tests except doctests
# cargo test --doc --workspace # nextest does not run doctests
@@ -13,3 +14,11 @@ gpu = { max-threads = 4 }
[[profile.default.overrides]]
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
test-group = 'gpu'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
# at the end of the log. The default profile's GPU override still applies.
[profile.ci]
fail-fast = true
slow-timeout = { period = "60s", terminate-after = 10 }
failure-output = "immediate-final"
+18 -3
View File
@@ -131,21 +131,34 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
# Separate native commands into steps so Windows reports either command's failure.
- name: Test
run: cargo test --workspace --locked --features heif
run: cargo nextest run --workspace --locked --features heif --profile ci
- name: Doctests
run: cargo test --doc --workspace --locked --features heif
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: runner.os == 'Linux'
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
# not what is built); engine and the apps only pass text on to them (#6120).
- name: Check out craft-fonts
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
@@ -159,7 +172,9 @@ jobs:
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace --locked --features heif
run: >-
cargo nextest run --workspace --locked --features heif --profile ci
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
timeout-minutes: 30
Generated
+2 -2
View File
@@ -6113,9 +6113,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
+41 -4
View File
@@ -5,11 +5,12 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Instant;
use std::time::{Duration, Instant};
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
@@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut reader = BufReader::new(read);
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok && reader.get_mut().authenticated().is_err() {
break;
}
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
@@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
mod tests {
use super::*;
use std::io::BufRead;
use std::time::Duration;
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
started.elapsed()
});
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
// the idle timeout.
let mut stream = TcpStream::connect(addr).unwrap();
for i in 0..150 {
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
if stream.write_all(byte).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let served = server.join().unwrap();
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
assert!(rx.try_recv().is_err(), "nothing was dispatched");
}
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
+1 -1
View File
@@ -16,7 +16,7 @@ synth = []
[dependencies]
crc32fast = "1.5"
flate2 = { workspace = true }
ruzstd = "0.9"
ruzstd = "0.9.1"
[dev-dependencies]
proptest = { workspace = true }
+64 -5
View File
@@ -29,8 +29,8 @@ use serde_json::{Value, json};
use crate::budgets::{BatchReplyBudget, write_reply};
use crate::security::{
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
read_bounded_line, repeat_auth_reply,
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply,
configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply,
};
use crate::{AutomationError, Headless};
@@ -262,10 +262,12 @@ pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write)
}
}
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> {
/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however
/// much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> {
configure_stream(&stream)?;
let read = stream.try_clone()?;
let mut reader = std::io::BufReader::new(read);
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -290,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
} else {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok {
reader.get_mut().authenticated()?;
}
reply
};
write_reply(&mut out, &reply)?;
@@ -322,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl
let token = Arc::clone(&token);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token);
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
});
}
Ok(())
@@ -814,6 +819,60 @@ mod tests {
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline,
/// and one that authenticated in time keeps being served after it.
#[test]
fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() {
use std::io::{BufReader, Write as _};
use std::time::{Duration, Instant};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let h = Mutex::new(Headless::new());
let mut results = Vec::new();
for _ in 0..2 {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300));
results.push((r.map_err(|e| e.kind()), started.elapsed()));
}
results
});
// Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout.
let mut slow = std::net::TcpStream::connect(addr).unwrap();
for _ in 0..150 {
if slow.write_all(b" ").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
drop(slow);
// Authenticates at once, then asks again 300 ms after the deadline.
let mut s = std::net::TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
std::thread::sleep(Duration::from_millis(600));
writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], "a");
drop(reader);
drop(s);
let results = server.join().unwrap();
let (slow_result, slow_time) = &results[0];
assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}");
assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}");
assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client");
}
#[test]
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
use std::io::{BufReader, Write as _};
+108 -2
View File
@@ -1,11 +1,11 @@
//! Shared security primitives for the loopback JSON control transports.
use std::fs::OpenOptions;
use std::io::{BufRead, Write};
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
@@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16;
pub const MAX_BATCH_STEPS: usize = 256;
/// Idle/read and write timeout for loopback TCP connections.
pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts
/// again with every read, so a client that kept sending without ever completing `auth` held one
/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965).
pub const AUTH_DEADLINE: Duration = Duration::from_secs(10);
const TOKEN_BYTES: usize = 32;
const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
@@ -242,6 +246,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> {
stream.set_write_timeout(Some(IO_TIMEOUT))
}
/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail
/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime;
/// the caller then drops the connection and its [`ConnectionPermit`].
pub struct AuthDeadlineStream {
stream: std::net::TcpStream,
/// `None` once authenticated.
deadline: Option<Instant>,
}
impl AuthDeadlineStream {
/// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]).
pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self {
// A limit too large to represent expires at once rather than never.
let now = Instant::now();
Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) }
}
/// The connection authenticated: only the idle timeout applies from here on.
pub fn authenticated(&mut self) -> std::io::Result<()> {
self.deadline = None;
self.stream.set_read_timeout(Some(IO_TIMEOUT))
}
}
impl Read for AuthDeadlineStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if let Some(deadline) = self.deadline {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time"));
}
// Wait no longer than what is left, so a silent client is dropped at the deadline too.
self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?;
}
self.stream.read(buf)
}
}
/// Environment-aware token inputs shared by the desktop app and CLI.
pub fn token_inputs(supplied: Option<String>, token_file: Option<PathBuf>) -> (Option<String>, Option<PathBuf>) {
let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok());
@@ -327,6 +369,70 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
/// A connected loopback pair: the accepted (server) end and the client end.
fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server, _) = listener.accept().unwrap();
configure_stream(&server).unwrap();
(server, client)
}
/// #4965: activity must not extend the time a connection may stay unauthenticated.
#[test]
fn an_active_connection_is_dropped_at_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
// A byte every 20 ms and never a newline: each read succeeds long before the idle timeout.
let trickle = std::thread::spawn(move || {
for _ in 0..150 {
if client.write_all(b"x").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300)));
let mut line = String::new();
let started = Instant::now();
let err = read_bounded_line(&mut reader, &mut line).unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}");
assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed());
assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed());
// It stays expired.
assert!(read_bounded_line(&mut reader, &mut line).is_err());
drop(reader);
trickle.join().unwrap();
}
#[test]
fn a_silent_connection_is_dropped_at_the_authentication_deadline() {
let (server, _client) = tcp_pair();
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
let started = Instant::now();
assert!(read_bounded_line(&mut reader, &mut line).is_err());
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
}
#[test]
fn an_authenticated_connection_outlives_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
let sender = std::thread::spawn(move || {
client.write_all(b"one\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
client.write_all(b"two\n").unwrap();
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "one\n");
reader.get_mut().authenticated().unwrap();
// Sent 300 ms after the deadline: only the idle timeout applies now.
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "two\n");
sender.join().unwrap();
}
#[test]
fn connection_limiter_releases_capacity() {
let limiter = ConnectionLimiter::new(1);
+174 -21
View File
@@ -23,7 +23,7 @@ use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
use crate::options::Limits;
use crate::options::{Limits, alloc_zeroed};
const F: Format = Format::OpenExr;
@@ -39,9 +39,15 @@ fn unsup(reason: impl Into<String>) -> CodecError {
// Every length and offset comes from the file, so every read is bounds-checked and a short
// read is a malformed file, never a panic.
fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut v = vec![0u8; n];
cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?;
fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> {
// Borrow packed payloads from the input. Copying them here allocates before count-table
// admission and needlessly duplicates the table even when the second pass just skips it.
let truncated = || err(format!("truncated file while reading {what}"));
let start = usize::try_from(cur.position()).map_err(|_| truncated())?;
let end = start.checked_add(n).ok_or_else(truncated)?;
let bytes = *cur.get_ref();
let v = bytes.get(start..end).ok_or_else(truncated)?;
cur.set_position(end as u64);
Ok(v)
}
@@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) {
}
/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half
/// holding every second byte of the original; weave them back together.
fn interleave_byte_blocks(buf: &mut [u8]) {
/// holding every second byte of the original; weave them back together. The scratch copy is
/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error.
fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> {
let half = buf.len().div_ceil(2);
let mut out = vec![0u8; buf.len()];
let mut out = alloc_zeroed::<u8>(buf.len())?;
let mut k = 0usize;
for i in 0..buf.len() / 2 {
let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied());
@@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) {
}
}
buf.copy_from_slice(&out);
Ok(())
}
/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`,
/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and
/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error.
fn reserve_up_to(out: &mut Vec<u8>, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> {
let need = out.len().saturating_add(extra);
if need > limit {
return Err(err(format!("{what}: data expands past {limit} bytes")));
}
if need > out.capacity() {
let target = out.capacity().saturating_mul(2).max(need).min(limit);
out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?;
}
Ok(())
}
/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative
/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate).
fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut out = Vec::with_capacity(expected.min(1 << 20));
let mut out = Vec::new();
reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?;
let mut i = 0;
while i < src.len() {
let count = src[i] as i8;
@@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
if out.len().saturating_add(n) > expected {
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
}
reserve_up_to(&mut out, n, expected, what)?;
if count < 0 {
let end = i.saturating_add(n);
let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
@@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
/// has the uncompressed length is stored raw whatever the declared compression.
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
if packed.len() == expected {
return Ok(packed.to_vec());
let mut out = Vec::new();
reserve_up_to(&mut out, expected, expected, what)?;
out.extend_from_slice(packed);
return Ok(out);
}
match compression {
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
@@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s
// Like ZIPS, RLE runs over the predicted, byte-split stream.
let mut out = rle_decode(packed, expected, what)?;
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
interleave_byte_blocks(&mut out)?;
Ok(out)
}
Compression::ZIP1 => {
let mut out = Vec::with_capacity(expected.min(1 << 20));
flate2::read::ZlibDecoder::new(packed)
.take(expected as u64 + 1)
.read_to_end(&mut out)
.map_err(|_| err(format!("{what}: zlib data is malformed")))?;
// Inflate straight into `out`, grown with `reserve_up_to` rather than by
// `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are
// in, a one-byte read checks that the stream ends there.
let malformed = || err(format!("{what}: zlib data is malformed"));
let mut zlib = flate2::read::ZlibDecoder::new(packed);
let mut out = Vec::new();
let mut filled = 0;
while filled < expected {
if filled == out.len() {
let grow = out.len().max(1 << 13).min(expected - filled);
reserve_up_to(&mut out, grow, expected, what)?;
out.resize(filled + grow, 0);
}
let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?;
if n == 0 {
break;
}
filled += n;
}
out.truncate(filled);
if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 {
return Err(err(format!("{what}: zlib data expands past {expected} bytes")));
}
if out.len() != expected {
return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len())));
}
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
interleave_byte_blocks(&mut out)?;
Ok(out)
}
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
@@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
return Err(err("deep chunk sizes point past the end of the file"));
}
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let what = "the sample-count table";
let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
// A compressed clipped table also needs its scratch copy. The data-window check only
// budgets four bytes per channel per pixel, insufficient for a one-channel image.
// An equal-sized payload takes decompress's raw path, which allocates no scratch.
let buffers = if table_bytes.len() == expected_table { 1 } else { 2 };
let clipped_need = (expected_table as u64).saturating_mul(buffers);
if clipped_need > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!(
"{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}",
limits.max_alloc
)));
}
// An edge tile's table may still be stored at the full tile size (its pixels first, in
// rows of the clipped width, then unused entries), as the OpenEXR library writes it.
let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table);
let raw = decompress(compression, &table_bytes, full_table, "the sample-count table")
.or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?;
// That size comes from the header's tile size alone, which nothing ties to the data
// window, so it is tried only when `max_alloc` has room for the table and for the copy
// that undoes its byte split. Otherwise only the clipped size is read.
let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4));
let raw = match full_table {
// Scan lines and interior tiles: the full size is the clipped size.
Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?,
Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => {
decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))?
}
_ => decompress(compression, table_bytes, expected_table, what).map_err(|_| {
// Four bytes per pixel of the tile, twice: the table and its scratch copy.
let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8);
CodecError::LimitExceeded(format!(
"{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}",
limits.max_alloc
))
})?,
};
// The table is cumulative within each row of the block (a tile restarts at every row);
// `prefix` is cumulative over the whole block, and each pixel's own count is the difference.
@@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
// The count table sits between the sizes and the sample data; skip its bytes.
read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;
let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
// Row by row (a scan line block is one row), and within a row channel by channel, as
// flat tiles are laid out: each pixel's samples are contiguous within its channel.
let mut in_off = 0usize;
@@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
Ok(img)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[test]
fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() {
let bytes = [1u8, 2, 3, 4];
let mut cur = Cursor::new(bytes.as_slice());
let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes");
assert_eq!(prefix, &[1, 2, 3]);
assert_eq!(prefix.as_ptr(), bytes.as_ptr());
assert_eq!(cur.position(), 3);
assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. })));
assert_eq!(cur.position(), 3);
assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]);
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
cur.set_position(u64::MAX);
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
}
/// What the writer stores before compressing: the byte split, then the +128 predictor.
fn predicted(raw: &[u8]) -> Vec<u8> {
let half = raw.len().div_ceil(2);
let mut out = vec![0u8; raw.len()];
for (i, &v) in raw.iter().enumerate() {
out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v;
}
let mut prev = 0i16;
for (i, b) in out.iter_mut().enumerate() {
let v = i16::from(*b);
if i > 0 {
*b = (v - prev + 128) as u8;
}
prev = v;
}
out
}
/// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count
/// table, with the copy that undoes its byte split), so decompression must not reserve
/// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table
/// 4 MiB, so the "admitted" table and its copy took up to three times the table size.
#[test]
fn decompressed_buffers_reserve_no_more_than_expected() {
for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] {
let raw = vec![0u8; n];
let p = predicted(&raw);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
z.write_all(&p).expect("write to a Vec");
let zips = z.finish().expect("finish a Vec");
// RLE: the first byte as a literal, then runs of up to 128 copies of the next.
let mut rle = vec![0xFF, p[0]];
for run in p[1..].chunks(128) {
rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]);
}
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
let out = decompress(compression, packed, n, "the table").expect("decompresses");
assert_eq!(out, raw, "{compression:?}, {n} bytes");
assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity());
}
// One byte short, the same data expands past the expected size and is refused.
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes");
}
}
}
/// A size past the limit is malformed data, and a reservation the allocator refuses is an
/// error rather than an abort.
#[test]
fn reservations_past_the_limit_or_the_allocator_are_errors() {
let mut v = Vec::new();
assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. })));
assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_))));
assert_eq!(v.capacity(), 0);
}
}
+151 -1
View File
@@ -118,6 +118,23 @@ fn gen_deep(
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
) -> Vec<u8> {
gen_deep_tables(w, h, channels, compression, tile, counts, sample, false)
}
/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile
/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its
/// clipped width), then zeros.
#[allow(clippy::too_many_arguments)]
fn gen_deep_tables(
w: usize,
h: usize,
channels: &[Chan],
compression: u8,
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
full_tile_tables: bool,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x0131_2f76u32.to_le_bytes());
@@ -216,6 +233,10 @@ fn gen_deep(
table.extend_from_slice(&acc.to_le_bytes());
}
}
assert_eq!(table.len(), table_len(b));
if let (true, Some((tw, th))) = (full_tile_tables, tile) {
table.resize(tw * th * 4, 0);
}
let mut data = Vec::new();
for row in b.pixels.chunks(b.row_w) {
for (ci, ch) in channels.iter().enumerate() {
@@ -237,7 +258,6 @@ fn gen_deep(
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
c.extend_from_slice(&ct);
c.extend_from_slice(&cd);
assert_eq!(table.len(), table_len(b));
assert_eq!(data.len(), data_len(b));
chunks.push((bi, c));
}
@@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() {
assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. })));
}
/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes
/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4
/// image, its table stored at that size, makes a compressed file of tens of KB whose table
/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a
/// budget smaller than both the table must be refused before it is allocated; with room for
/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of
/// ordinary tiles, still decode under a small budget.
#[test]
fn deep_tile_count_table_is_admitted_against_max_alloc() {
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
let tight = budget(1 << 20);
let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
for compression in [0u8, 1, 2] {
let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true);
assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}");
let got = decode_deep_exr(&big, &tight);
assert!(
matches!(got, Err(CodecError::LimitExceeded(_))),
"compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}",
got.map(|d| d.total_samples())
);
let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() });
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width()));
// The same tile with its table stored at the clipped size fits the small budget.
let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba);
assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}");
if compression != 0 {
assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len());
}
// The table and its scratch copy are admitted together: 8 MiB.
assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}");
let got = decode_deep_exr(&big, &budget(2 * table - 1));
assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples()));
}
// Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones.
let counts: Vec<u32> = (0..35).map(|p| (p * 3) % 4).collect();
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() };
for compression in [0u8, 1, 2] {
let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true);
let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample);
assert_eq!(
decode_deep_exr(&full, &tight).expect("full-size edge tables"),
decode_deep_exr(&clipped, &tight).expect("clipped edge tables"),
"compression {compression}"
);
}
}
/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side,
/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits,
/// where its full size passes the check and is only tried as the data arrives. Each tile is
/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over
/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's
/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for
/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint
/// channels decode after full-size tables like float ones.
#[test]
fn deep_tile_table_admission_edges() {
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
let side = (1 << 30) - 2;
for compression in [0u8, 1, 2] {
let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba);
for limits in [Limits::none(), Limits::default(), budget(1 << 20)] {
assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}");
}
}
let counts: Vec<u32> = (0..48).map(|p| (p % 3) as u32).collect();
let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c });
let [a, b, g, r, z] = RGBAZ;
let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }];
for channels in [&RGBAZ[..], &half[..], &with_id[..]] {
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() };
for compression in [0u8, 1, 2] {
let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true);
let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample);
let what = format!("{} channels, compression {compression}", channels.len());
let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables");
assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}");
}
}
}
/// A one-channel data-window check admits only four bytes per pixel, but a compressed count
/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback;
/// the existing multichannel cases have enough pixel allowance to hide this gap.
#[test]
fn clipped_count_tables_include_scratch_in_the_budget() {
let channels = [Chan { name: "Y", ty: 2 }];
let counts = vec![0u32; 64];
let sample = |_: usize, _: usize| Vec::new();
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
for tile in [None, Some((64, 1)), Some((128, 2))] {
for compression in [0u8, 1, 2] {
let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample);
let what = format!("tile {tile:?}, compression {compression}");
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
let required = if compression == 0 { 256 } else { 512 };
assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}");
assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}");
let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() });
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}");
if compression == 0 {
assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}");
}
}
}
}
/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's
/// compression tag, decides whether a scratch buffer is needed.
#[test]
fn raw_count_tables_need_no_scratch_even_with_compression() {
let channels = [Chan { name: "Y", ty: 2 }];
let limits = Limits { max_alloc: 4, ..Limits::none() };
for tile in [None, Some((1, 1)), Some((2, 2))] {
for compression in [0u8, 1, 2] {
let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new());
let header_end = header_end_of(&bytes);
let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize;
let table_size = chunk + if tile.is_some() { 16 } else { 4 };
let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size"));
assert_eq!(packed, 4, "the four-byte count table is stored raw");
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}");
}
}
}
/// Walk the attributes like the format prescribes: the offset table starts after the
/// header's terminating zero byte.
fn header_end_of(bytes: &[u8]) -> usize {
+13
View File
@@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec<LayerId> {
if group.len() > 1 { group } else { Vec::new() }
}
/// The base `id` clips to (itself when it is a base), when it is in a clip group.
pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option<LayerId> {
group_of(doc, id).first().copied()
}
/// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself
/// clipped (it is not a base) or nothing is clipped to it.
fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
@@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
}
}
/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them
/// with it).
pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec<LayerId> {
let Some(path) = doc.path_of(id) else { return Vec::new() };
let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() };
list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect()
}
/// The clip groups of layers about to move or be deleted, read before they leave their slots.
#[derive(Default)]
pub(crate) struct Before {
+97 -41
View File
@@ -253,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
}
/// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in
/// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which
/// becomes the base; Release releases every selected layer. One history step either way.
/// becomes the base; Release releases every selected layer. Releasing a clipped layer also
/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected
/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer
/// that is no longer clipped. One history step either way.
fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" };
let ids: Vec<LayerId> = if p.get("layer").is_some() {
@@ -267,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
(false, all) => all.to_vec(),
}
};
let ids = if clip {
ids
} else {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let mut all = ids.clone();
for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) {
for above in crate::clip_group::clipped_above(doc, *id) {
if !all.contains(&above) {
all.push(above);
}
}
}
all
};
s.edit(label, |doc, _| {
for id in &ids {
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip;
@@ -724,10 +741,32 @@ fn build() -> Vec<CommandSpec> {
"Layer Properties",
[],
None,
r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
has_layer,
|s, p| {
let id = layer_param(s, p)?;
// Several layers at once (the Layers panel with several selected, as in Photoshop),
// else the given or active one.
let ids: Vec<LayerId> = match p.get("layers") {
None | Some(Value::Null) => vec![layer_param(s, p)?],
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let mut ids = Vec::with_capacity(a.len());
for v in a {
let id = v.as_u64().map(LayerId).ok_or_else(|| bad("layer.setProps", format!("`layers` holds layer ids, got {v}")))?;
if doc.layer(id).is_none() {
return Err(EngineError::NoLayer(id));
}
if !ids.contains(&id) {
ids.push(id);
}
}
ids
}
Some(v) => return Err(bad("layer.setProps", format!("`layers` is a non-empty list of layer ids, got {v}"))),
};
if ids.len() > 1 && p.get("name").is_some() {
return Err(bad("layer.setProps", "`name` renames one layer, not several"));
}
// Opacity and Fill are fractions here, while the Layer Style commands take
// percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242).
let unit = |key: &str| -> Result<Option<f32>> {
@@ -738,9 +777,19 @@ fn build() -> Vec<CommandSpec> {
}
};
let (opacity, fill) = (unit("opacity")?, unit("fill")?);
let only_visibility =
p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce")));
let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" };
// Photoshop's History names for a single kind of change; a mix is "Layer Properties".
let changed: Vec<&str> = p
.as_object()
.map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect())
.unwrap_or_default();
let label = match changed.as_slice() {
["visible"] => "Layer Visibility",
["opacity"] => "Opacity Change",
["fill"] => "Fill Opacity Change",
["blend"] => "Blending Change",
["name"] => "Rename Layer",
_ => "Layer Properties",
};
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
// Clipping and channel changes reach the layers around it: recomposite everything.
let local = p.get("clipped").is_none() && p.get("channels").is_none();
@@ -749,47 +798,54 @@ fn build() -> Vec<CommandSpec> {
// one that changes its look turns it into a normal layer ("Layer 0") first.
let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0);
let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal);
if num("opacity") || num("fill") || blends {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let Some(v) = p.get("name").and_then(Value::as_str) {
l.name = v.to_string();
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
l.visible = v;
}
if let Some(v) = opacity {
l.opacity = v;
}
if let Some(v) = fill {
l.fill_opacity = v;
}
if let Some(v) = p.get("blend").and_then(Value::as_str) {
l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
}
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
l.clipped = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
l.locks.all = v;
}
if let Some(Value::Array(ch)) = p.get("channels") {
// Blending Options › Advanced Blending › Channels: unchecked = left out.
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
}
if let Some(Value::Object(m)) = p.get("locks") {
for (k, v) in m {
let v = v.as_bool().unwrap_or(false);
if !l.locks.set(k, v) {
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
for &id in &ids {
if num("opacity") || num("fill") || blends {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let Some(v) = p.get("name").and_then(Value::as_str) {
l.name = v.to_string();
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
l.visible = v;
}
if let Some(v) = opacity {
l.opacity = v;
}
if let Some(v) = fill {
l.fill_opacity = v;
}
if let Some(v) = p.get("blend").and_then(Value::as_str) {
let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
// Pass Through is a group's mode only, as in Photoshop.
if b == photocraft_color::BlendMode::PassThrough && !l.is_group() {
return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0)));
}
l.blend = b;
}
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
l.clipped = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
l.locks.all = v;
}
if let Some(Value::Array(ch)) = p.get("channels") {
// Blending Options › Advanced Blending › Channels: unchecked = left out.
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
}
if let Some(Value::Object(m)) = p.get("locks") {
for (k, v) in m {
let v = v.as_bool().unwrap_or(false);
if !l.locks.set(k, v) {
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
}
}
}
}
Ok(())
})?;
if local {
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
crate::layer_multi_cmds::note_damage(s, &before, &ids);
}
Ok(Value::Null)
}
+41 -6
View File
@@ -522,11 +522,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul
n
}
let backup = doc.layers.clone();
// The clip groups of the doomed layers, read before they go (clip_group rule 3: layers
// clipped to a deleted base are released).
let doomed: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect();
let before = crate::clip_group::Before::read(doc, &doomed);
let n = rec(&mut doc.layers, pred);
if doc.layers.is_empty() {
doc.layers = backup;
return Err(EngineError::Other("a document must keep at least one layer".into()));
}
crate::clip_group::settle(doc, &[], &before, false);
if active.is_none_or(|id| doc.layer(id).is_none()) {
*active = doc.top_layer();
}
@@ -878,8 +883,17 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
let id = layer_param(s, p)?;
let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?;
let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced);
let d = s.active().ok_or(EngineError::NoDocument)?;
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let style = crate::CopiedStyle {
effects: l.effects.clone(),
blend: l.blend,
fill: l.fill_opacity,
advanced: l.advanced,
blend_if: l.blend_if.clone(),
excluded_channels: l.excluded_channels,
mode: d.doc.mode,
};
s.style_clipboard = Some(style);
Ok(Value::Null)
}),
@@ -895,17 +909,38 @@ pub fn specs() -> Vec<CommandSpec> {
},
|s, p| {
let ids = style_targets(s, p, |_| true)?;
let (mut fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
let mut fx = style.effects;
s.edit("Paste Layer Style", |doc, _| {
// A style copied from a document in another mode takes this one's colours.
let mode = doc.mode;
fx.items = std::mem::take(&mut fx.items).into_iter().map(|e| e.in_mode(mode)).collect();
// Blend If's Gray range carries over to any mode; its channel ranges and the
// channel restrictions only to the mode they were set in.
let same_mode = style.mode == mode;
let blend_if = if same_mode {
style.blend_if.clone()
} else {
// Through `set`, which keeps the normalised form (no trailing full
// entries), so a full Gray range pastes as the default.
let mut gray = photocraft_doc::BlendIf::default();
gray.set(0, style.blend_if.get(0));
gray
};
for id in ids {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects = fx.clone();
l.blend = blend;
l.fill_opacity = fill;
l.advanced = advanced;
// Pass Through is a group's mode: a group's style pastes onto other layers as
// Normal, as a merged group's Pass Through becomes Normal.
l.blend = if style.blend == photocraft_color::BlendMode::PassThrough && !l.is_group() {
photocraft_color::BlendMode::Normal
} else {
style.blend
};
l.fill_opacity = style.fill;
l.advanced = style.advanced;
l.blend_if = blend_if.clone();
l.excluded_channels = if same_mode { style.excluded_channels } else { 0 };
}
Ok(())
})?;
+1 -11
View File
@@ -671,17 +671,7 @@ impl Session {
return Ok(Started::Done(v));
}
// Pixel commands follow the Channels panel target unless the caller names one.
let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone()));
if let Err(why) = self.explicit_layer_enabled(spec, &params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
if let Some(why) = crate::hidden_target::refusal(self, id, &params) {
return Err(EngineError::Other(why.into()));
}
let run_params = self.admit(id, spec, &params)?;
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
self.jobs.spawn = background;
+21 -5
View File
@@ -263,7 +263,8 @@ fn can_align(s: &Session) -> std::result::Result<(), String> {
let d = doc_state(s)?;
match d.selected_layers().len() {
0 => Err("no layer selected".into()),
// Photoshop aligns a single layer to the canvas (or to an active selection); 2+ align to each other.
// Photoshop aligns to an active selection however many layers are selected; without one,
// a single layer aligns to the canvas and 2+ align to each other.
_ => Ok(()),
}
}
@@ -961,8 +962,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
"canvas" => doc.bounds(),
"selection" => doc.selection.as_ref().map(|m| m.content_bounds()).ok_or_else(|| EngineError::Other("there is no selection to align to".into()))?,
"auto" | "layers" => match (&doc.selection, sel.len()) {
// Photoshop: one layer plus an active selection aligns to the selection bounds.
(Some(m), 1) if to == "auto" => m.content_bounds(),
// Photoshop: with an active selection, the layers align to its bounds (Layer › Align
// Layers to Selection), however many are selected.
(Some(m), _) if to == "auto" && !m.content_bounds().is_empty() => m.content_bounds(),
// One layer with no selection aligns to the canvas (otherwise it would align to itself).
(None, 1) if to == "auto" => doc.bounds(),
_ => items.iter().fold(Rect::EMPTY, |a, (_, b)| a.union(b)),
@@ -1312,6 +1314,12 @@ fn merge_layers(s: &mut Session) -> Result<Value> {
let fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
let mut merged = Layer::raster(top.name.clone(), fmt);
merged.locks = top.locks;
merged.label = top.label;
// Clipped layers of one chain merge into a layer still clipped to their base. Clipped
// layers of different chains don't: the result, at the top one's slot, would clip the
// lower chain's pixels to the upper chain's base.
let base = crate::clip_group::base_of(doc, top_id);
merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base);
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt);
let mid = merged.id;
// Hidden selected layers are discarded, as in Photoshop.
@@ -1431,7 +1439,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run }
};
}
const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the selection bounds with one layer and an active selection, else the selected layers' bounds)"##;
const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the active selection's bounds when there is one, however many layers are selected; else one layer aligns to the canvas and several to their combined bounds)"##;
vec![
spec!("select.allLayers", "All Layers", &["Select"], Some("Cmd+Alt+A"), "{}", has_doc, |s, _| select_all_layers(s)),
spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_doc, |s, _| {
@@ -1479,7 +1487,15 @@ pub fn specs() -> Vec<CommandSpec> {
link_layers(s)
}),
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers),
spec!(
"layer.new.groupFromLayers",
"Group from Layers…",
&["Layer", "New"],
None,
r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##,
has_layer,
group_layers
),
spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)),
spec!(
"layer.lockLayers",
+71 -3
View File
@@ -406,6 +406,27 @@ impl Default for ToolState {
}
}
/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options, as Photoshop
/// copies them (Paste Layer Style puts them all on each target).
#[derive(Clone, Debug)]
pub struct CopiedStyle {
pub effects: photocraft_doc::Effects,
pub blend: photocraft_color::BlendMode,
pub fill: f32,
pub advanced: photocraft_doc::AdvancedBlending,
pub blend_if: photocraft_doc::BlendIf,
pub excluded_channels: u32,
/// The colour mode it was copied in: Blend If's channel ranges and the channel restrictions
/// only carry over to a document in the same mode.
pub mode: photocraft_color::ColorMode,
}
/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does
/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and
/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group
/// or fill layer) is known only when they run.
pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"];
#[derive(Default)]
pub struct Session {
docs: Vec<DocState>,
@@ -417,9 +438,9 @@ pub struct Session {
coalesce_request: Option<String>,
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
pub clipboard: Option<edit_cmds::Clip>,
/// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced
/// Blending switches.
pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>,
/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options
/// ([`CopiedStyle`]: blend mode, fill opacity, Advanced Blending, Blend If, channels).
pub style_clipboard: Option<CopiedStyle>,
/// Shape path context menu: copied vector fill and stroke styles.
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
@@ -644,6 +665,53 @@ impl Session {
self.live_commit = None;
}
/// Check a call without running it: the command exists, its params have the shape
/// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does
/// something first only when the command will go through: Free Transform applies its box
/// before a new layer is made.
///
/// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute`
/// accepts (a test holds the two together). For others it checks only what they share.
pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> {
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if !(params.is_object() || params.is_null()) {
return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() });
}
crate::param_check::check(spec, params)?;
crate::filters::validate_params(id, params)?;
if id == "layer.new.layer" {
crate::new_layer::parse(params)?;
}
// A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the
// document doesn't have.
if spec.params.contains("\"layer\":id")
&& let Some(l) = commands::explicit_layer(self, params)?
&& self.active().is_none_or(|st| st.doc.layer(l).is_none())
{
return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) });
}
self.admit(id, spec, params).map(|_| ())
}
/// What `dispatch` checks once the params are valid and a floating selection has dropped:
/// the precondition (on a named layer for commands that take one), a job running on the
/// document, a hidden target. The run params (the Channels panel's target filled in) when it
/// may run.
pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result<Value> {
let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone()));
if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
if let Some(why) = hidden_target::refusal(self, id, params) {
return Err(EngineError::Other(why.into()));
}
Ok(run_params)
}
/// Is the command currently runnable? (drives menu enablement)
pub fn is_enabled(&self, id: &str) -> bool {
self.is_enabled_with(id, &Value::Null)
+41 -23
View File
@@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
}
}
/// A New Layer call's params, checked.
pub(crate) struct NewLayer {
clip: bool,
mode: BlendMode,
opacity: f32,
label: LabelColor,
neutral: Option<[f32; 3]>,
below: bool,
name: Option<String>,
}
/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the
/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?;
let id = s.edit("New Layer", |doc, active| {
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
let mut layer = Layer::raster(name, doc.pixel_format());
layer.blend = mode;
layer.opacity = opacity;
layer.label = label;
if let Some(rgb) = neutral {
let bounds = doc.bounds();
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
surface.fill_rect(bounds, &px);
}
// Nothing may sit under the Background: below it the layer lands above it instead.
let id = match *active {
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
_ => doc.insert_above(*active, layer),
};
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
*active = Some(id);
Ok(id)
})?;
crate::layer_multi_cmds::note_inert_insert(s, id);
Ok(json!({ "layer": id.0 }))
}
/// Check a New Layer call's params without running it (Free Transform applies its box before a
/// new layer only when the new layer can be made).
pub(crate) fn parse(p: &Value) -> Result<NewLayer> {
let flag = |k: &str| match p.get(k) {
None | Some(Value::Null) => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))),
@@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
};
let id = s.edit("New Layer", |doc, active| {
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
let mut layer = Layer::raster(name, doc.pixel_format());
layer.blend = mode;
layer.opacity = opacity;
layer.label = label;
if let Some(rgb) = neutral {
let bounds = doc.bounds();
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
surface.fill_rect(bounds, &px);
}
// Nothing may sit under the Background: below it the layer lands above it instead.
let id = match *active {
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
_ => doc.insert_above(*active, layer),
};
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
*active = Some(id);
Ok(id)
})?;
crate::layer_multi_cmds::note_inert_insert(s, id);
Ok(json!({ "layer": id.0 }))
Ok(NewLayer { clip, mode, opacity, label, neutral, below, name })
}
#[cfg(test)]
+5
View File
@@ -415,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer {
// Merge Down replaces the lower layer, so keep its editing restrictions.
// In particular, a merged Background must remain opaque and position-locked.
merged.locks = lower.locks;
// And its place: two layers clipped in one chain merge into a layer still clipped to the base,
// with the lower layer's colour label and link.
merged.clipped = lower.clipped && upper.clipped;
merged.label = lower.label;
merged.link_group = lower.link_group;
merged
}
+75
View File
@@ -0,0 +1,75 @@
//! Clip groups through Release Clipping Mask, Merge Down, Merge Layers and Delete Hidden Layers,
//! as in Photoshop (layers audit L2). The stack: Background, B, C1*, C2* (C1 and C2 clipped to B).
use photocraft_engine::Session;
use serde_json::json;
fn stack() -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
let mut ids = [0u64; 3];
for (i, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() {
ids[i] = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 4 * i, "y": 4, "width": 12, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": color})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
}
for c in &ids[1..] {
s.execute("layer.setProps", json!({"layer": c, "clipped": true})).unwrap();
}
(s, ids)
}
fn clipped(s: &Session, id: u64) -> bool {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped
}
#[test]
fn releasing_a_middle_layer_releases_the_ones_above_it() {
let (mut s, [_, c1, c2]) = stack();
let r = s.execute("layer.releaseClippingMask", json!({"layer": c1})).unwrap();
assert!(!clipped(&s, c1) && !clipped(&s, c2), "C2 isn't left clipped to the released C1: {r}");
// Releasing the top one leaves the one below it alone.
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.releaseClippingMask", json!({"layer": c2})).unwrap();
assert!(clipped(&s, c1) && !clipped(&s, c2));
}
#[test]
fn merging_clipped_layers_keeps_the_result_clipped() {
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.select", json!({"layer": c2})).unwrap();
let r = s.execute("layer.mergeDown", json!({})).unwrap();
let merged = r["layer"].as_u64().unwrap_or(c1);
assert!(clipped(&s, merged), "C2 merged down into C1 stays clipped to B");
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.select", json!({"layer": c1})).unwrap();
s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(clipped(&s, r["layer"].as_u64().unwrap()), "C1 + C2 merged stay clipped");
// Clipped layers of two chains (Background, B, C1*, E, D*: C1 clipped to B, D to E) merge
// into an ordinary layer: kept clipped at D's slot, C1's pixels would clip to E.
let (mut s, [_, c1, e]) = stack();
s.execute("layer.setProps", json!({"layer": e, "clipped": false})).unwrap();
let d = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
s.execute("layer.setProps", json!({"layer": d, "clipped": true})).unwrap();
s.execute("layer.select", json!({"layer": c1})).unwrap();
s.execute("layer.select", json!({"layer": d, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(!clipped(&s, r["layer"].as_u64().unwrap()), "C1 + D come from two clip chains");
// Merging the base with its clipped layers gives an ordinary layer.
let (mut s, [b, _, c2]) = stack();
s.execute("layer.select", json!({"layer": b})).unwrap();
s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(!clipped(&s, r["layer"].as_u64().unwrap()));
}
#[test]
fn deleting_a_hidden_base_releases_its_clipped_layers() {
let (mut s, [b, c1, c2]) = stack();
s.execute("layer.setProps", json!({"layer": b, "visible": false})).unwrap();
s.execute("layer.delete.hiddenLayers", json!({})).unwrap();
assert!(!clipped(&s, c1) && !clipped(&s, c2), "they don't clip to the layer below the deleted base");
}
@@ -0,0 +1,72 @@
//! `layer.setProps` on several layers at once (the Layers panel with several selected, as in
//! Photoshop), and Photoshop's History names for each kind of change.
use photocraft_engine::Session;
use serde_json::json;
fn doc() -> (Session, Vec<u64>) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
let ids = (0..3).map(|_| s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()).collect();
(s, ids)
}
fn layer(s: &Session, id: u64) -> photocraft_doc::Layer {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
}
fn last_step(s: &Session) -> String {
s.active().unwrap().history.entries().last().cloned().unwrap_or_default()
}
#[test]
fn one_change_reaches_every_listed_layer_in_one_step() {
let (mut s, ids) = doc();
let steps = s.active().unwrap().history.entries().len();
s.execute("layer.setProps", json!({"layers": [ids[0], ids[2]], "opacity": 0.4})).unwrap();
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one step");
assert_eq!(last_step(&s), "Opacity Change");
assert_eq!((layer(&s, ids[0]).opacity, layer(&s, ids[1]).opacity, layer(&s, ids[2]).opacity), (0.4, 1.0, 0.4));
s.execute("layer.setProps", json!({"layers": ids, "blend": "Multiply"})).unwrap();
assert_eq!(last_step(&s), "Blending Change");
assert!(ids.iter().all(|id| layer(&s, *id).blend == photocraft_color::BlendMode::Multiply));
s.execute("layer.setProps", json!({"layers": [ids[1], ids[1], ids[2]], "locks": {"all": true}})).unwrap();
assert!(layer(&s, ids[1]).locks.all && layer(&s, ids[2]).locks.all && !layer(&s, ids[0]).locks.all, "duplicates are harmless");
s.execute("layer.setProps", json!({"layers": [ids[0]], "fill": 0.5})).unwrap();
assert_eq!(last_step(&s), "Fill Opacity Change");
s.execute("layer.setProps", json!({"layer": ids[0], "name": "Sky"})).unwrap();
assert_eq!(last_step(&s), "Rename Layer");
s.execute("layer.setProps", json!({"layer": ids[0], "opacity": 0.3, "fill": 0.3})).unwrap();
assert_eq!(last_step(&s), "Layer Properties", "a mix keeps the general name");
}
#[test]
fn bad_layer_lists_change_nothing() {
let (mut s, ids) = doc();
let before = s.active().unwrap().doc.clone();
for p in [
json!({"layers": [], "opacity": 0.5}),
json!({"layers": "all", "opacity": 0.5}),
json!({"layers": ["x"], "opacity": 0.5}),
json!({"layers": [ids[0], 999_999], "opacity": 0.5}),
json!({"layers": [ids[0], ids[1]], "name": "Both"}),
json!({"layers": [ids[0], ids[1]], "opacity": 2.0}),
] {
assert!(s.execute("layer.setProps", p.clone()).is_err(), "{p}");
assert_eq!(s.active().unwrap().doc.layers, before.layers, "{p}");
}
}
#[test]
fn pass_through_is_for_groups_only() {
let (mut s, ids) = doc();
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
// A pixel layer, alone or with a group, can't take Pass Through; nothing changes.
assert!(s.execute("layer.setProps", json!({"layer": ids[0], "blend": "Pass Through"})).is_err());
let before = (layer(&s, ids[0]).blend, layer(&s, g).blend);
assert!(s.execute("layer.setProps", json!({"layers": [g, ids[0]], "blend": "Pass Through"})).is_err());
assert_eq!((layer(&s, ids[0]).blend, layer(&s, g).blend), before);
s.execute("layer.setProps", json!({"layers": [g], "blend": "Normal"})).unwrap();
s.execute("layer.setProps", json!({"layer": g, "blend": "Pass Through"})).unwrap();
assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough);
}
+74
View File
@@ -0,0 +1,74 @@
//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers
//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859).
use photocraft_engine::{PRECHECKED, Session};
use serde_json::{Value, json};
/// Documents in different states: a Background only, a pixel layer active, a group active, a
/// selection, no document.
fn states() -> Vec<(&'static str, Session)> {
let mut out = Vec::new();
out.push(("no document", Session::new()));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
out.push(("background only", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
out.push(("pixel layer", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
s.execute("layer.new.group", json!({})).unwrap();
out.push(("group, no background", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
out.push(("selection", s));
out
}
fn params() -> Vec<Value> {
vec![
Value::Null,
json!({}),
json!([1]),
json!("x"),
json!({"mode": "Bogus"}),
json!({"mode": "Multiply"}),
json!({"fillNeutral": true}),
json!({"mode": "Overlay", "fillNeutral": true}),
json!({"position": "x"}),
json!({"position": "below"}),
json!({"opacity": "half"}),
json!({"name": 5}),
json!({"name": "ok"}),
json!({"color": "chartreuse"}),
json!({"layer": 999_999}),
json!({"layer": "Layer 1"}),
json!({"layer": "no such layer"}),
json!({"layer": true}),
json!({"ifPresent": true}),
]
}
#[test]
fn precheck_agrees_with_execute() {
let mut disagreements = Vec::new();
for id in PRECHECKED {
for p in params() {
for (state, _) in states() {
// A fresh copy of the state for each call: execute changes it.
let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap();
let pre = s.precheck(id, &p).is_ok();
let ran = s.execute(id, p.clone()).is_ok();
if pre != ran {
disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}"));
}
}
}
}
assert!(disagreements.is_empty(), "{disagreements:#?}");
}
+115
View File
@@ -0,0 +1,115 @@
//! Copy / Paste Layer Style carries the whole style, Blending Options included (Blend If and the
//! channel restrictions); align with an active selection aligns to it however many layers are
//! selected (layers audit L2).
use photocraft_engine::Session;
use serde_json::json;
fn layer(s: &Session, id: u64) -> photocraft_doc::Layer {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
}
fn new_doc(s: &mut Session, mode: &str) -> (u64, u64) {
s.execute("file.new", json!({"width": 64, "height": 64, "mode": mode, "background": "white"})).unwrap();
let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
(a, b)
}
#[test]
fn paste_layer_style_carries_blend_if_and_channels() {
let mut s = Session::new();
let (a, b) = new_doc(&mut s, "rgb");
s.execute(
"layer.layerStyle.blendingOptions",
json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}, {"channel": "red", "thisLayer": [10, 200]}]}),
)
.unwrap();
s.execute("layer.setProps", json!({"layer": a, "channels": [true, false, true]})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": b})).unwrap();
let (la, lb) = (layer(&s, a), layer(&s, b));
assert_eq!(lb.blend_if, la.blend_if, "Blend If, Gray and channel ranges");
assert_eq!(lb.excluded_channels, la.excluded_channels, "the unchecked G channel");
// Into a CMYK document: only the Gray range carries over; channels reset to all.
let (c, _) = new_doc(&mut s, "cmyk");
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap();
let lc = layer(&s, c);
assert_eq!(lc.blend_if.get(0), la.blend_if.get(0));
assert_eq!(lc.blend_if.ranges.len(), 1, "no RGB channel range on CMYK");
assert_eq!(lc.excluded_channels, 0);
}
#[test]
fn paste_layer_style_into_another_mode_keeps_blend_if_normalised() {
// A full Gray range and only a Red range: in CMYK nothing is left, so it is the default
// (which compares equal, so the paste isn't seen as a Blend If change).
let mut s = Session::new();
let (a, _) = new_doc(&mut s, "rgb");
s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "red", "thisLayer": [10, 200]}]})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
let (c, _) = new_doc(&mut s, "cmyk");
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap();
assert_eq!(layer(&s, c).blend_if, photocraft_doc::BlendIf::default());
}
#[test]
fn paste_layer_style_on_several_layers_is_one_undo_step() {
// 16-bit, onto a pixel layer and a group: one History step, and one Undo restores both.
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64, "depth": 16, "background": "white"})).unwrap();
let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}]})).unwrap();
s.execute("layer.setProps", json!({"layer": a, "fill": 0.5, "blend": "multiply"})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
let before = (layer(&s, b), layer(&s, g));
s.execute("layer.select", json!({"layer": b})).unwrap();
s.execute("layer.select", json!({"layer": g, "mode": "add"})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap();
let la = layer(&s, a);
for id in [b, g] {
let l = layer(&s, id);
assert_eq!((l.blend_if.clone(), l.fill_opacity, l.blend), (la.blend_if.clone(), la.fill_opacity, la.blend), "layer {id}");
}
s.execute("edit.undo", json!({})).unwrap();
let after = (layer(&s, b), layer(&s, g));
assert_eq!((after.0.blend_if, after.0.blend, after.1.blend_if, after.1.blend), (before.0.blend_if, before.0.blend, before.1.blend_if, before.1.blend));
assert_eq!((after.0.fill_opacity, after.1.fill_opacity), (before.0.fill_opacity, before.1.fill_opacity));
}
#[test]
fn several_layers_align_to_an_active_selection() {
let mut s = Session::new();
let (a, b) = new_doc(&mut s, "rgb");
for (id, x) in [(a, 4), (b, 30)] {
s.execute("layer.select", json!({"layer": id})).unwrap();
s.execute("select.rect", json!({"x": x, "y": 10, "width": 8, "height": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
}
s.execute("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.select", json!({"layer": b, "mode": "add"})).unwrap();
s.execute("layer.align.leftEdges", json!({})).unwrap();
for id in [a, b] {
let x0 = layer(&s, id).surface().unwrap().content_bounds().x0;
assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge");
}
}
#[test]
fn a_groups_pass_through_pastes_onto_a_pixel_layer_as_normal() {
let mut s = Session::new();
let (a, _) = new_doc(&mut s, "rgb");
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough);
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": g})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": a})).unwrap();
assert_eq!(layer(&s, a).blend, photocraft_color::BlendMode::Normal, "Pass Through isn't a pixel layer's mode");
// Onto another group it stays Pass Through.
let g2 = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": g2, "blend": "Multiply"})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": g2})).unwrap();
assert_eq!(layer(&s, g2).blend, photocraft_color::BlendMode::PassThrough);
}
+1 -1
View File
@@ -22,7 +22,7 @@ flate2 = { workspace = true }
# Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2.
crc32fast = "1.5"
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
ruzstd = "0.9"
ruzstd = "0.9.1"
# Unix directory cleanup uses no-follow descriptor-relative operations; the same
# dependency provides the plain-fsync fallback on Apple file systems.
+31 -15
View File
@@ -1,5 +1,5 @@
//! Layers panel: hovering a mode in the open blend-mode list previews it on the active layer
//! (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
//! Layers panel: hovering a mode in the open blend-mode list previews it on the layers it would
//! change, the active one or every selected one (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
//! closing it without a choice, shows the document again, and choosing a mode commits it through
//! `layer.setProps` (one history step). The canvas re-renders once per hovered mode, and only
//! where the layer's blending can change pixels.
@@ -15,17 +15,17 @@ use crate::PhotocraftApp;
/// Preview keys (`canvas::display_doc`) are `BASE` plus a hash of the revision and the mode.
const BASE: u64 = 1 << 37;
/// A blend mode hovered for a layer of a document at a revision.
/// A blend mode hovered for layers of a document at a revision.
pub(crate) struct BlendPreview {
doc: DocId,
revision: u64,
layer: LayerId,
layers: Vec<LayerId>,
/// The mode under the pointer; `None` once it left the list (the rest stays for the
/// canvas's damage on its way back to the document).
mode: Option<BlendMode>,
/// The frame the list was last drawn in: a list no longer drawn previews nothing.
frame: u64,
/// Where the layer's blending can change pixels; `None` for anywhere.
/// Where the layers' blending can change pixels; `None` for anywhere.
area: Option<Rect>,
/// The document shown for `mode`, with its key.
shown: Option<(u64, Arc<Document>)>,
@@ -33,11 +33,11 @@ pub(crate) struct BlendPreview {
/// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the
/// pointer in its open list, if any.
pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendMode>) {
pub(crate) fn hover(app: &mut PhotocraftApp, layers: &[LayerId], mode: Option<BlendMode>) {
let frame = app.frame;
let Some(st) = app.session.active() else { return };
let (doc, revision) = (st.doc.id, st.revision);
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layer == layer) {
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layers == layers) {
p.frame = frame;
if mode.is_none() {
p.shown = None;
@@ -46,8 +46,15 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendM
return;
}
app.blend_preview = mode.and_then(|mode| {
let area = photocraft_compose::change_bounds(st.doc.layer(layer)?, st.doc.bounds());
Some(BlendPreview { doc, revision, layer, mode: Some(mode), frame, area, shown: None })
// Anywhere when any layer can change anything; else the union of their areas.
let mut area: Option<Rect> = Some(Rect::EMPTY);
for &id in layers {
area = match (area, photocraft_compose::change_bounds(st.doc.layer(id)?, st.doc.bounds())) {
(Some(a), Some(b)) => Some(if a.is_empty() { b } else { a.union(&b) }),
_ => None,
};
}
Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None })
});
}
@@ -57,15 +64,23 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
let st = app.session.documents().get(idx)?;
let p = app.blend_preview.as_mut().filter(|p| p.doc == st.doc.id && p.revision == st.revision && p.frame + 1 >= frame)?;
let mode = p.mode?;
let key = preview_key(p.revision, p.layer, mode);
let key = preview_key(p.revision, &p.layers, mode);
if let Some((k, shown)) = &p.shown
&& *k == key
{
return Some((shown.clone(), key));
}
let mut doc = (*st.doc).clone();
let l = doc.layer_mut(p.layer).filter(|l| l.blend != mode)?;
l.blend = mode;
let mut changed = false;
for &id in &p.layers {
if let Some(l) = doc.layer_mut(id).filter(|l| l.blend != mode) {
l.blend = mode;
changed = true;
}
}
if !changed {
return None;
}
// Duotone documents display through their inks.
let doc = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc));
p.shown = Some((key, doc.clone()));
@@ -73,7 +88,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
}
/// What changed between preview (or document) key `seen` and `now` of document `doc` at
/// `revision`: the hovered layer's area. `None` when either key is another kind of preview, or
/// `revision`: the hovered layers' area. `None` when either key is another kind of preview, or
/// the layer can change anything (the canvas then recomposites everything).
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<Rect> {
let p = app.blend_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?;
@@ -85,8 +100,9 @@ fn is_preview_key(key: u64) -> bool {
(BASE..BASE << 1).contains(&key)
}
fn preview_key(revision: u64, layer: LayerId, mode: BlendMode) -> u64 {
let h = mode.label().bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
fn preview_key(revision: u64, layers: &[LayerId], mode: BlendMode) -> u64 {
let seed = layers.iter().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, id| h.wrapping_mul(0x100_0000_01b3) ^ id.0);
let h = mode.label().bytes().fold(seed, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
(h & (BASE - 1)) | BASE
}
+19 -5
View File
@@ -36,7 +36,7 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
let (s, top) = session();
let mut app = PhotocraftApp::new(s, crate::Services::default());
let (undo, revision) = (steps(&app), app.session.active().unwrap().revision);
hover(&mut app, top, Some(BlendMode::Multiply));
hover(&mut app, &[top], Some(BlendMode::Multiply));
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
assert_eq!(shown.layer(top).unwrap().blend, BlendMode::Multiply);
let (again, same) = display_doc(&mut app, 0).unwrap();
@@ -45,17 +45,17 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
// From the document to the preview and between previews: only the layer's area redraws.
let doc = app.session.active().unwrap().doc.id;
assert_eq!(damage(&app, doc, revision, 0, key).map(|r| r.width()), Some(16));
hover(&mut app, top, Some(BlendMode::Screen));
hover(&mut app, &[top], Some(BlendMode::Screen));
let (_, screen) = display_doc(&mut app, 0).unwrap();
assert_ne!(screen, key);
assert!(damage(&app, doc, revision, key, screen).is_some());
// The current mode, or no mode, shows the document; and so does a list no longer drawn.
hover(&mut app, top, Some(BlendMode::Normal));
hover(&mut app, &[top], Some(BlendMode::Normal));
assert!(display_doc(&mut app, 0).is_none());
hover(&mut app, top, None);
hover(&mut app, &[top], None);
assert!(display_doc(&mut app, 0).is_none());
assert!(damage(&app, doc, revision, screen, 0).is_some(), "back to the document: the layer's area");
hover(&mut app, top, Some(BlendMode::Multiply));
hover(&mut app, &[top], Some(BlendMode::Multiply));
app.frame += 2;
assert!(display_doc(&mut app, 0).is_none(), "a stale hover previews nothing");
}
@@ -124,3 +124,17 @@ fn moving_between_modes_keeps_a_preview_across_the_gaps_between_them() {
}
assert_eq!(previewed(&mut h), Some(BlendMode::Multiply));
}
#[test]
fn a_hovered_mode_previews_on_every_selected_layer() {
let (mut s, top) = session();
let other = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap();
let mut app = PhotocraftApp::new(s, crate::Services::default());
hover(&mut app, &[top, other], Some(BlendMode::Multiply));
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
assert_eq!((shown.layer(top).unwrap().blend, shown.layer(other).unwrap().blend), (BlendMode::Multiply, BlendMode::Multiply));
// A different set of layers is a different preview.
hover(&mut app, &[top], Some(BlendMode::Multiply));
assert_ne!(display_doc(&mut app, 0).unwrap().1, key);
}
+7
View File
@@ -3797,3 +3797,10 @@ navigator Zoom Zvětšení
There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte.
set name název sady
{set} (modified) {set} (změněno)
Accept and Go to Conflict Přijmout a přejít ke konfliktu
Changes undone. Změny vráceny.
Choose a new shortcut for this command, or leave it without one. Zvolte pro tento příkaz novou zkratku, nebo ho nechte bez ní.
Put this command's shortcuts back as they were Vrátit zkratky tohoto příkazu do původního stavu
Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho
Undo Changes Vrátit změny
{shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -3191,3 +3191,10 @@ curves Smooth Glätten
There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen.
set name Name des Satzes
{set} (modified) {set} (geändert)
Accept and Go to Conflict Übernehmen und zum Konflikt
Changes undone. Änderungen rückgängig gemacht.
Choose a new shortcut for this command, or leave it without one. Wählen Sie einen neuen Kurzbefehl für diesen Befehl oder lassen Sie ihn ohne.
Put this command's shortcuts back as they were Die Kurzbefehle dieses Befehls wiederherstellen
Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen
Undo Changes Änderungen rückgängig machen
{shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -3889,3 +3889,10 @@ curves Smooth Εξομάλυνση
There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα.
set name όνομα συνόλου
{set} (modified) {set} (τροποποιημένο)
Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση
Changes undone. Οι αλλαγές αναιρέθηκαν.
Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς.
Put this command's shortcuts back as they were Επαναφορά των συντομεύσεων αυτής της εντολής όπως ήταν
Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της
Undo Changes Αναίρεση αλλαγών
{shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί.
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+7
View File
@@ -4151,3 +4151,10 @@ navigator Zoom Zoom
There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero.
set name nombre del conjunto
{set} (modified) {set} (modificado)
Accept and Go to Conflict Aceptar e ir al conflicto
Changes undone. Cambios deshechos.
Choose a new shortcut for this command, or leave it without one. Elija un nuevo método abreviado para este comando o déjelo sin ninguno.
Put this command's shortcuts back as they were Restaurar los métodos abreviados de este comando como estaban
Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo
Undo Changes Deshacer cambios
{shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+7
View File
@@ -3194,3 +3194,10 @@ curves Smooth Lisser
set name nom du jeu
{set} (modified) {set} (modifié)
Random Aléatoire
Accept and Go to Conflict Accepter et aller au conflit
Changes undone. Modifications annulées.
Choose a new shortcut for this command, or leave it without one. Choisissez un nouveau raccourci pour cette commande ou laissez-la sans raccourci.
Put this command's shortcuts back as they were Rétablir les raccourcis de cette commande
Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner
Undo Changes Annuler les modifications
{shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -4108,3 +4108,10 @@ cameraRaw Yellow Sárga
There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet.
set name készlet neve
{set} (modified) {set} (módosítva)
Accept and Go to Conflict Elfogadás és ugrás az ütközéshez
Changes undone. Változások visszavonva.
Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle.
Put this command's shortcuts back as they were A parancs billentyűparancsainak visszaállítása
Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése
Undo Changes Változások visszavonása
{shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -3181,3 +3181,10 @@ curves Smooth Haluskan
set name nama set
{set} (modified) {set} (diubah)
Random Acak
Accept and Go to Conflict Terima dan Buka Konflik
Changes undone. Perubahan dibatalkan.
Choose a new shortcut for this command, or leave it without one. Pilih pintasan baru untuk perintah ini, atau biarkan tanpa pintasan.
Put this command's shortcuts back as they were Kembalikan pintasan perintah ini seperti semula
Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu
Undo Changes Batalkan Perubahan
{shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+7
View File
@@ -4310,3 +4310,10 @@ shape Appearance Aspetto
There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno.
set name nome del set
{set} (modified) {set} (modificato)
Accept and Go to Conflict Accetta e vai al conflitto
Changes undone. Modifiche annullate.
Choose a new shortcut for this command, or leave it without one. Scegli una nuova scorciatoia per questo comando o lascialo senza.
Put this command's shortcuts back as they were Ripristina le scorciatoie di questo comando com'erano
Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo
Undo Changes Annulla modifiche
{shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata.
Can't render this file because it contains an unexpected character in line 2654 and column 53.
+7
View File
@@ -3865,3 +3865,10 @@ curves Smooth スムーズ
There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。
set name セット名
{set} (modified) {set}(変更あり)
Accept and Go to Conflict 適用して競合へ移動
Changes undone. 変更を取り消しました。
Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。
Put this command's shortcuts back as they were このコマンドのショートカットを元に戻す
Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択
Undo Changes 変更を取り消し
{shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -3367,3 +3367,10 @@ curves Smooth 매끄럽게
There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요.
set name 세트 이름
{set} (modified) {set}(수정됨)
Accept and Go to Conflict 적용 후 충돌 항목으로 이동
Changes undone. 변경 사항을 취소했습니다.
Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요.
Put this command's shortcuts back as they were 이 명령의 단축키를 원래대로 되돌리기
Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택
Undo Changes 변경 사항 취소
{shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다.
Can't render this file because it contains an unexpected character in line 3166 and column 53.
+7
View File
@@ -3949,3 +3949,10 @@ curves Smooth Vloeiend maken
There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een.
set name naam van de set
{set} (modified) {set} (gewijzigd)
Accept and Go to Conflict Accepteren en naar conflict
Changes undone. Wijzigingen ongedaan gemaakt.
Choose a new shortcut for this command, or leave it without one. Kies een nieuwe sneltoets voor deze opdracht of laat hem zonder.
Put this command's shortcuts back as they were De sneltoetsen van deze opdracht herstellen
Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren
Undo Changes Wijzigingen ongedaan maken
{shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+7
View File
@@ -3787,3 +3787,10 @@ curves Smooth Wygładź
There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden.
set name nazwa zestawu
{set} (modified) {set} (zmieniony)
Accept and Go to Conflict Akceptuj i przejdź do konfliktu
Changes undone. Zmiany cofnięte.
Choose a new shortcut for this command, or leave it without one. Wybierz nowy skrót dla tego polecenia lub zostaw je bez skrótu.
Put this command's shortcuts back as they were Przywróć skróty tego polecenia
Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je
Undo Changes Cofnij zmiany
{shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -3979,3 +3979,10 @@ layerLabel No Color Sem cor
There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro.
set name nome do conjunto
{set} (modified) {set} (modificado)
Accept and Go to Conflict Aceitar e ir para o conflito
Changes undone. Alterações desfeitas.
Choose a new shortcut for this command, or leave it without one. Escolha um novo atalho para este comando ou deixe-o sem atalho.
Put this command's shortcuts back as they were Restaurar os atalhos deste comando como estavam
Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo
Undo Changes Desfazer alterações
{shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -3159,3 +3159,10 @@ curves Smooth Сгладить
set name имя набора
{set} (modified) {set} (изменён)
Random Случайно
Accept and Go to Conflict Принять и перейти к конфликту
Changes undone. Изменения отменены.
Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации.
Put this command's shortcuts back as they were Вернуть комбинации этой команды
Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её
Undo Changes Отменить изменения
{shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -4262,3 +4262,10 @@ shape Appearance Izgled
There are too many sets: delete one first. Previše skupova: prvo obrišite jedan.
set name ime skupa
{set} (modified) {set} (izmenjen)
Accept and Go to Conflict Prihvati i idi na sukob
Changes undone. Izmene su poništene.
Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice.
Put this command's shortcuts back as they were Vrati prečice ove komande kakve su bile
Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je
Undo Changes Poništi izmene
{shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -3919,3 +3919,10 @@ cameraData Lens Lens
There are too many sets: delete one first. Çok fazla küme var: önce birini silin.
set name küme adı
{set} (modified) {set} (değiştirildi)
Accept and Go to Conflict Kabul Et ve Çakışmaya Git
Changes undone. Değişiklikler geri alındı.
Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın.
Put this command's shortcuts back as they were Bu komutun kısayollarını eski haline getir
Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç
Undo Changes Değişiklikleri Geri Al
{shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+7
View File
@@ -3888,3 +3888,10 @@ curves Smooth Згладити
There are too many sets: delete one first. Забагато наборів: спершу видаліть один.
set name назва набору
{set} (modified) {set} (змінено)
Accept and Go to Conflict Прийняти й перейти до конфлікту
Changes undone. Зміни скасовано.
Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього.
Put this command's shortcuts back as they were Повернути клавіатурні скорочення цієї команди
Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її
Undo Changes Скасувати зміни
{shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити.
Can't render this file because it contains an unexpected character in line 1388 and column 18.
+7
View File
@@ -3675,3 +3675,10 @@ curves Smooth Làm mượt
There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước.
set name tên bộ
{set} (modified) {set} (đã sửa đổi)
Accept and Go to Conflict Chấp nhận và đến xung đột
Changes undone. Đã hoàn tác thay đổi.
Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt.
Put this command's shortcuts back as they were Khôi phục phím tắt của lệnh này như cũ
Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó
Undo Changes Hoàn tác thay đổi
{shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -2805,3 +2805,10 @@ curves Smooth 平滑
set name 组名称
{set} (modified) {set}(已修改)
Random 随机
Accept and Go to Conflict 接受并转到冲突
Changes undone. 已撤消更改。
Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。
Put this command's shortcuts back as they were 将此命令的快捷键恢复原样
Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它
Undo Changes 撤消更改
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。
Can't render this file because it contains an unexpected character in line 2600 and column 53.
+7
View File
@@ -2790,3 +2790,10 @@ curves Smooth 平滑
set name 組合名稱
{set} (modified) {set}(已修改)
Random 隨機
Accept and Go to Conflict 接受並前往衝突
Changes undone. 已還原變更。
Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。
Put this command's shortcuts back as they were 將此指令的快速鍵恢復原狀
Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它
Undo Changes 還原變更
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。
Can't render this file because it contains an unexpected character in line 6 and column 47.
@@ -0,0 +1,78 @@
//! The Layers panel's blend mode, Opacity, Fill and lock controls change every selected layer,
//! as in Photoshop, not only the active one.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::PhotocraftApp;
fn app() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app
});
h.run_steps(6);
h
}
/// The Layers panel's lock buttons change every selected layer (as do blend mode, Opacity and
/// Fill), not only the active one.
#[test]
fn the_layers_panel_locks_every_selected_layer() {
let mut h = app();
let ids: Vec<u64> = h.state().session.active().unwrap().doc.layers.iter().map(|l| l.id.0).collect();
let extra = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().run("layer.select", json!({"layer": ids[1], "mode": "add"})).unwrap();
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().selected_layers().len(), 2);
h.query_by_label_contains("Lock all").expect("the Lock all button").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all;
assert!(locked(extra) && locked(ids[1]), "both selected layers lock");
assert!(!locked(ids[0]), "the unselected Background doesn't");
}
/// The Basic layout's single lock toggle also locks every selected layer.
#[test]
fn the_basic_lock_toggle_locks_every_selected_layer() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::Studio);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("prefs.set", json!({"path": "interface.theme", "value": crate::theme::ThemeKind::Studio.id()})).unwrap();
app.ui.theme = crate::theme::ThemeKind::Studio;
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
app
});
let a = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().run("layer.select", json!({"layer": a, "mode": "add"})).unwrap();
h.run_steps(4);
h.get_by_label("Lock layer").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all;
assert!(locked(a) && locked(b), "both selected layers lock");
}
/// The Background, selected with other layers, is left out of their change (Opacity here)
/// until Photoshop's behaviour is confirmed (#5856): it stays the Background.
#[test]
fn a_selected_background_is_left_out_of_a_several_layer_change() {
let mut h = app();
let st = h.state().session.active().unwrap();
let (bg, top) = (st.doc.layers[0].id.0, st.active_layer.unwrap().0);
h.state_mut().run("layer.select", json!({"layer": bg})).unwrap();
h.state_mut().run("layer.select", json!({"layer": top, "mode": "add"})).unwrap();
h.run_steps(3);
h.get_by_label("Lock all").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let l = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().clone();
assert!(l(top).locks.all, "the layer locks");
assert!(st.doc.is_background(photocraft_doc::LayerId(bg)) && !l(bg).locks.all, "the Background is left as it was");
}
+19
View File
@@ -1044,6 +1044,21 @@ impl PhotocraftApp {
{
authorize(id, &params)?;
}
// A new layer while Free Transform is open applies the transform first, as Photoshop does
// (#5667): History reads the transform (or Place Embedded), then New Layer. Only for a
// command that can run; a transform refused or still being applied keeps the box open and
// the command doesn't run.
if photocraft_engine::PRECHECKED.contains(&id) && self.ui.transform.is_some() {
// Bad params or a command that can't run leave the box as it is.
self.session.precheck(id, &params).map_err(|e| e.to_string())?;
transform_tool::commit(self);
if transform_tool::committing(self).is_some() {
return Err("the transform is still being applied; try again when it has finished".into());
}
if self.ui.transform.is_some() {
return Err(if self.ui.status.is_empty() { "the transform couldn't be applied".into() } else { self.ui.status.clone() });
}
}
if let Some(result) = panel_docking::command(self, id, &params) {
return result;
}
@@ -2638,6 +2653,10 @@ mod pixel_aspect_tests;
#[cfg(test)]
mod transform_fx_tests;
#[cfg(test)]
mod layer_props_ui_tests;
#[cfg(test)]
mod transform_switch_tests;
#[cfg(test)]
mod transform_undo_tests;
+38 -14
View File
@@ -1518,8 +1518,9 @@ const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W;
/// The `layer.setProps` edit of a Layers panel Opacity or Fill (`key`) field at `pct` percent. A
/// drag of the pop-up slider (`drag`) coalesces into one history step, as in Photoshop.
fn pct_action(l: &Layer, key: &str, pct: f32, drag: Option<u64>) -> (String, Value) {
let mut p = json!({"layer": l.id.0, key: pct / 100.0});
fn pct_action(target: &Value, key: &str, pct: f32, drag: Option<u64>) -> (String, Value) {
let mut p = target.clone();
p[key] = json!(pct / 100.0);
if let Some(n) = drag {
p["coalesce"] = json!(format!("layer-{key}-slider:{n}"));
}
@@ -2159,13 +2160,17 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
/// any is set, and locks all otherwise. Toggling only "lock all" would leave the Background's
/// transparency lock (still showing as locked), and a second click would lock its pixels,
/// shutting out the Eraser and every other paint tool (#76).
fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) {
/// The Basic layout's lock toggle: the active layer's state decides, and every target follows it
/// (`targets`: `{"layer": id}` or `{"layers": [ids]}`).
fn simple_lock_toggle(background: bool, l: &Layer, targets: &Value) -> (String, Value) {
if background {
return ("layer.new.layerFromBackground".into(), json!({}));
}
let locks =
if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) };
("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks}))
let mut p = targets.clone();
p["locks"] = locks;
("layer.setProps".into(), p)
}
/// The Layers panel's blend modes: Photoshop's, plus the layer's own Paint.NET mode when it has one
@@ -2231,8 +2236,21 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(4.0);
}
if let Some(l) = active_layer {
// Photoshop greys blend mode, Opacity and Fill for the Background layer.
// With several layers selected, blend mode, Opacity, Fill and the locks change every one
// of them, as in Photoshop; the controls show the active layer's values.
// The Background stays out of a several-layer change until Photoshop's behaviour is
// confirmed (#5856): Opacity, Fill or a blend mode would turn it into Layer 0.
let ids: Vec<LayerId> = if selection.len() > 1 && selection.contains(&l.id) {
selection.iter().copied().filter(|id| *id == l.id || !doc.layer(*id).is_some_and(|x| crate::doc_props_ui::is_background(&doc, x))).collect()
} else {
vec![l.id]
};
let targets = if ids.len() > 1 { json!({"layers": ids.iter().map(|id| id.0).collect::<Vec<_>>()}) } else { json!({"layer": l.id.0}) };
// Photoshop greys blend mode, Opacity and Fill for the Background layer. (With the
// Background among several selected, what Photoshop does awaits a check: #5856.)
let bg = crate::doc_props_ui::is_background(&doc, l);
// Pass Through is a group's mode: offered only when every target is a group.
let all_groups = ids.iter().all(|id| doc.layer(*id).is_some_and(|x| x.is_group()));
ui.horizontal(|ui| {
ui.add_enabled_ui(!bg, |ui| {
let mut m = l.blend;
@@ -2242,13 +2260,15 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") };
let (blend_width, show_opacity_label) =
opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width);
let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(all_groups, l.blend), blend_width);
// One step per choice: a click, an arrow key or each wheel notch (#1747).
for m in &chosen {
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()})));
let mut p = targets.clone();
p["blend"] = json!(m.label());
actions.push(("layer.setProps".into(), p));
}
// Hovering a mode previews it on the canvas (#970).
crate::blend_preview::hover(app, l.id, hovered.filter(|_| chosen.is_empty()));
crate::blend_preview::hover(app, &ids, hovered.filter(|_| chosen.is_empty()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut o = l.opacity * 100.0;
let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
@@ -2259,7 +2279,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
widgets::PopupFieldResponse::default()
};
if field.changed || scrub.changed {
actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag)));
actions.push(pct_action(&targets, "opacity", o, scrub.drag.or(field.drag)));
}
});
});
@@ -2295,7 +2315,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892).
let enabled = key != "transparency" || !lk.pixels;
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() {
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": { key: !on }})));
let mut p = targets.clone();
p["locks"] = json!({ key: !on });
actions.push(("layer.setProps".into(), p));
}
}
});
@@ -2303,7 +2325,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if !t.pro {
let locked = l.locks.any();
if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() {
actions.push(simple_lock_toggle(bg, l));
actions.push(simple_lock_toggle(crate::doc_props_ui::is_background(&doc, l), l, &targets));
}
}
ui.add_enabled_ui(!bg, |ui| {
@@ -2312,7 +2334,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W);
let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width());
if field.changed || scrub.changed {
actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag)));
actions.push(pct_action(&targets, "fill", f, scrub.drag.or(field.drag)));
}
})
});
@@ -2877,7 +2899,7 @@ fn layer_row(
if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock)
&& ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked()
{
actions.push(simple_lock_toggle(doc.is_background(l.id), l));
actions.push(simple_lock_toggle(doc.is_background(l.id), l, &json!({"layer": l.id.0})));
}
let name_color = if l.visible { t.text } else { t.text_faint };
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
@@ -3139,6 +3161,8 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let icon = match e.as_str() {
s if s.contains("Brush") || s.contains("Eraser") => "brush",
// The Layers panel's controls (layer.setProps).
"Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers",
s if s.contains("Layer") => "layers",
s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed",
s if s.contains("Fill") => "paint-bucket",
@@ -4992,7 +5016,7 @@ mod lock_tests {
fn click_lock(app: &mut PhotocraftApp) {
let st = app.session.active().unwrap();
let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap();
let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l);
let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l, &json!({"layer": l.id.0}));
app.run(&cmd, p).unwrap();
}
+121 -1
View File
@@ -804,6 +804,47 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec<String>,
out
}
/// A fingerprint of the bindings a shortcut conflict describes.
fn conflict_state(overrides: &BTreeMap<String, String>, alternates: &BTreeMap<String, Vec<String>>, selected: &str, set: Option<&Value>) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(overrides, alternates, selected, set.map(Value::to_string)).hash(&mut h);
h.finish()
}
/// Keys the system takes before the app sees them (switching apps, closing the window, force
/// quit): Edit › Keyboard Shortcuts won't assign them, as Photoshop won't. `sc` is in our
/// notation, where `Cmd` is Ctrl off the Mac.
pub fn reserved_shortcut(sc: &str) -> bool {
let Some(n) = prefs::normalize_shortcut(sc) else { return false };
let reserved: &[&str] = if cfg!(target_os = "macos") {
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
} else if cfg!(target_os = "windows") {
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
} else {
// Linux and the BSDs: window switching and closing, and the virtual consoles.
&[
"Alt+Tab",
"Alt+Shift+Tab",
"Alt+F4",
"Cmd+Alt+Delete",
"Cmd+Alt+F1",
"Cmd+Alt+F2",
"Cmd+Alt+F3",
"Cmd+Alt+F4",
"Cmd+Alt+F5",
"Cmd+Alt+F6",
"Cmd+Alt+F7",
"Cmd+Alt+F8",
"Cmd+Alt+F9",
"Cmd+Alt+F10",
"Cmd+Alt+F11",
"Cmd+Alt+F12",
]
};
reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n)
}
/// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers.
pub fn shortcut_text(key: egui::Key, m: egui::Modifiers) -> Option<String> {
if is_modifier_key(key) {
@@ -1687,6 +1728,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
let alternates_ok = parsed_alts.as_ref().is_none_or(Result::is_ok);
let mut alt_overrides = parsed_alts.and_then(Result::ok).unwrap_or_default();
let mut capture_alt = f.get("captureAlt").and_then(Value::as_bool).unwrap_or(false);
// A key just assigned that another command holds: Photoshop's Accept and Go to Conflict /
// Undo Changes (an object: the key, the other command, and this one's keys before).
let mut conflict: Option<Value> = f.get("conflict").filter(|c| c.is_object()).cloned();
let mut hidden: Vec<String> = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let mut colors: BTreeMap<String, String> = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string();
@@ -1739,6 +1783,7 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
});
if key == egui::Key::Escape {
capture = false;
conflict = None;
} else if matches!(key, egui::Key::Backspace | egui::Key::Delete) && m.is_none() {
if capture_alt {
alt_overrides.insert(selected.clone(), Vec::new());
@@ -1748,8 +1793,12 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
capture = false;
message = tl!("Shortcut removed.").into();
message_warning = false;
} else if let Some(sc) = shortcut_text(key, m).filter(|sc| reserved_shortcut(sc)) {
message = crate::i18n::fmt(tl!("{shortcut} is reserved by the system and can't be assigned."), &[("shortcut", &crate::shortcuts::pretty(&sc))]);
message_warning = true;
capture = false;
} else if let Some(sc) = shortcut_text(key, m) {
let clash: Vec<String> = items
let clash_ids: Vec<&str> = items
.iter()
.filter(|(id, _, _, def)| {
let cur = eff(&overrides, id, def);
@@ -1757,11 +1806,30 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
&& (cur.as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone())
|| eff_alts(&alt_overrides, id, cur.as_deref()).contains(&sc))
})
.map(|(id, ..)| id.as_str())
.collect();
let clash: Vec<String> = items
.iter()
.filter(|(id, ..)| clash_ids.contains(&id.as_str()))
.map(|(id, label, path, _)| {
let (path, label) = shown(id, label, path);
format!("{} › {}", path.join(" › "), label.trim_end_matches('…'))
})
.collect();
// What Undo Changes puts back: the command's main key and further keys as they were.
if let Some(first) = clash_ids.first() {
conflict = Some(json!({
"shortcut": sc,
"with": first,
// Every command holding the key: Accept takes it from all of them.
"holders": clash_ids,
"command": selected,
"main": overrides.get(&selected),
"alternates": alt_overrides.get(&selected),
}));
} else {
conflict = None;
}
if capture_alt {
let def = items.iter().find(|i| i.0 == selected).and_then(|i| i.3.clone());
let cur = eff(&overrides, &selected, &def);
@@ -1925,6 +1993,53 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
if !message.is_empty() {
ui.label(RichText::new(&message).color(if message_warning { t.warning } else { t.text_dim }));
}
// The conflict describes the bindings as the capture left them: any later change (Use
// Default, Delete, ×, Reset All, another command or set) makes it stale, and it goes.
let state = conflict_state(&overrides, &alt_overrides, &selected, f.get("activeSet"));
if conflict.as_ref().and_then(|c| c.get("state")).is_some_and(|s| s.as_u64() != Some(state)) {
conflict = None;
}
if tab == 0
&& let Some(c) = conflict.clone()
{
let (key, with, command) =
(c["shortcut"].as_str().unwrap_or_default(), c["with"].as_str().unwrap_or_default(), c["command"].as_str().unwrap_or_default());
ui.horizontal(|ui| {
if ui.button(tl!("Accept and Go to Conflict")).on_hover_text(tl!("Take the shortcut from the other command and select it")).clicked() {
let holders: Vec<String> = serde_json::from_value(c["holders"].clone()).unwrap_or_else(|_| vec![with.to_string()]);
for holder in &holders {
let def = items.iter().find(|i| i.0 == *holder).and_then(|i| i.3.clone());
let cur = eff(&overrides, holder, &def);
if cur.as_deref().and_then(prefs::normalize_shortcut).as_deref() == Some(key) {
overrides.insert(holder.clone(), String::new());
}
let keys = eff_alts(&alt_overrides, holder, eff(&overrides, holder, &def).as_deref());
if keys.iter().any(|k| k == key) {
alt_overrides.insert(holder.clone(), keys.into_iter().filter(|k| k != key).collect());
}
}
selected = with.to_string();
filter.clear();
message = tl!("Choose a new shortcut for this command, or leave it without one.").into();
message_warning = false;
conflict = None;
}
if ui.button(tl!("Undo Changes")).on_hover_text(tl!("Put this command's shortcuts back as they were")).clicked() {
match c["main"].as_str() {
Some(m) => overrides.insert(command.to_string(), m.to_string()),
None => overrides.remove(command),
};
match serde_json::from_value::<Vec<String>>(c["alternates"].clone()) {
Ok(list) => alt_overrides.insert(command.to_string(), list),
Err(_) => alt_overrides.remove(command),
};
selected = command.to_string();
message = tl!("Changes undone.").into();
message_warning = false;
conflict = None;
}
});
}
if overrides_ok {
f.insert("overrides".into(), json!(overrides));
}
@@ -1932,6 +2047,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
f.insert("alternates".into(), json!(alt_overrides));
}
f.insert("captureAlt".into(), json!(capture && capture_alt));
// A conflict just found is stamped with the bindings it describes.
if let Some(c) = conflict.as_mut().filter(|c| c.get("state").is_none()) {
c["state"] = json!(conflict_state(&overrides, &alt_overrides, &selected, f.get("activeSet")));
}
f.insert("conflict".into(), conflict.unwrap_or(Value::Null));
f.insert("hidden".into(), json!(hidden));
f.insert("colors".into(), json!(colors));
f.insert("selected".into(), json!(selected));
@@ -230,3 +230,119 @@ fn a_user_shortcut_wins_over_a_default_it_was_not_moved_from() {
let bound: Vec<String> = crate::shortcut_dispatch::bindings(&app).into_iter().filter(|(_, sc)| *sc == f7).map(|(id, _)| id).collect();
assert_eq!(bound, ["select.modify.feather"]);
}
/// Capture `k` with `m` for the selected command.
fn capture_key(h: &mut Harness<'_, PhotocraftApp>, id: u64, k: Key, m: Modifiers) {
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true));
h.run_steps(2);
key(h, k, true, m);
h.run_steps(2);
key(h, k, false, m);
h.run_steps(2);
}
fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) {
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
h.run_steps(3);
}
#[test]
fn a_conflict_offers_accept_and_go_to_conflict() {
// Photoshop: the key is taken from Copy now, and Copy is selected to get a new one.
let (mut h, id) = dialog(1.0, "edit.cut", "");
capture_key(&mut h, id, Key::C, Modifiers::COMMAND);
assert_eq!(field(&h, id, "overrides")["edit.cut"], json!("Cmd+C"));
assert_eq!(field(&h, id, "conflict")["with"], json!("edit.copy"));
click(&mut h, "Accept and Go to Conflict");
assert_eq!(field(&h, id, "selected"), json!("edit.copy"));
assert_eq!(field(&h, id, "overrides")["edit.copy"], json!(""), "Copy no longer holds Cmd+C");
assert!(field(&h, id, "conflict").is_null());
crate::dialogs::confirm(h.state_mut(), id).unwrap();
assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.cut", Some("Cmd+X")).as_deref(), Some("Cmd+C"));
assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")), None);
}
#[test]
fn undo_changes_puts_the_command_back() {
let (mut h, id) = dialog(1.0, "edit.cut", "");
capture_key(&mut h, id, Key::C, Modifiers::COMMAND);
click(&mut h, "Undo Changes");
assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides"));
assert!(field(&h, id, "conflict").is_null());
// An alternate added with Add Shortcut is undone the same way.
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("captureAlt".into(), json!(true));
capture_key(&mut h, id, Key::C, Modifiers::COMMAND);
assert_eq!(field(&h, id, "alternates")["edit.cut"], json!(["F2", "Cmd+C"]), "beside Photoshop's F2");
click(&mut h, "Undo Changes");
assert!(field(&h, id, "alternates").get("edit.cut").is_none(), "{}", field(&h, id, "alternates"));
crate::dialogs::confirm(h.state_mut(), id).unwrap();
assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C"));
}
#[test]
fn a_system_key_is_refused() {
let (k, m) = if cfg!(target_os = "macos") { (Key::Tab, Modifiers::MAC_CMD | Modifiers::COMMAND) } else { (Key::F4, Modifiers::ALT) };
let (mut h, id) = dialog(1.0, "edit.cut", "");
capture_key(&mut h, id, k, m);
assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides"));
assert!(field(&h, id, "message").as_str().is_some_and(|s| s.contains("reserved")), "{}", field(&h, id, "message"));
assert_eq!(field(&h, id, "messageWarning"), json!(true));
assert!(crate::prefs_ui::reserved_shortcut(if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" }));
assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Shift+F4"));
assert!(!crate::prefs_ui::reserved_shortcut("garbage+"));
}
/// The conflict describes the bindings as the capture left them: Reset All, Use Default or
/// Escape after it makes it stale, so Accept can't take a key nothing holds any more.
#[test]
fn a_conflict_goes_stale_when_the_bindings_change() {
for undo in ["Reset All to Defaults", "Use Default", "escape"] {
let (mut h, id) = dialog(1.0, "edit.cut", "");
capture_key(&mut h, id, Key::C, Modifiers::COMMAND);
assert!(field(&h, id, "conflict").is_object(), "{undo}");
if undo == "escape" {
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true));
h.run_steps(2);
key(&mut h, Key::Escape, true, Modifiers::NONE);
h.run_steps(2);
key(&mut h, Key::Escape, false, Modifiers::NONE);
h.run_steps(2);
} else {
click(&mut h, undo);
}
assert!(field(&h, id, "conflict").is_null(), "{undo}: {}", field(&h, id, "conflict"));
assert!(h.query_by_label("Accept and Go to Conflict").is_none(), "{undo}");
crate::dialogs::confirm(h.state_mut(), id).unwrap();
if undo != "escape" {
assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C"), "{undo}: Copy keeps ⌘C");
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn linux_reserves_its_own_system_keys() {
assert!(crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2"));
assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Escape"));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_reserves_its_own_system_keys() {
assert!(crate::prefs_ui::reserved_shortcut("Cmd+Escape"));
assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2"));
}
/// A key two commands hold (Cut's ⌘X, given to Copy too): Accept takes it from both.
#[test]
fn accept_takes_the_key_from_every_command_holding_it() {
let (mut h, id) = dialog(1.0, "edit.paste", "");
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({"edit.copy": "Cmd+X"}));
h.run_steps(2);
capture_key(&mut h, id, Key::X, Modifiers::COMMAND);
let holders = field(&h, id, "conflict")["holders"].clone();
assert!(holders.as_array().is_some_and(|a| a.len() == 2), "{holders}");
click(&mut h, "Accept and Go to Conflict");
let o = field(&h, id, "overrides");
assert_eq!((o["edit.cut"].clone(), o["edit.copy"].clone(), o["edit.paste"].clone()), (json!(""), json!(""), json!("Cmd+X")), "{o}");
}
@@ -0,0 +1,223 @@
//! Free Transform with the whole app: picking another tool or layer, or New Layer, while the box
//! is open applies the transform as it stands, as Photoshop does (#5441, #5665, #5667).
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::PhotocraftApp;
/// The app with a 400 × 300 document: a red 100 × 60 rectangle at (100, 100) on its own layer
/// above the Background.
fn app() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
app
});
h.run_steps(6);
h
}
/// Open Free Transform and move the box 50 px right, 40 px down.
fn transform_and_move(h: &mut Harness<'static, PhotocraftApp>) {
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
h.run_steps(2);
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
for p in &mut t.quad {
p[0] += 50.0;
p[1] += 40.0;
}
h.run_steps(2);
}
fn red_bounds(h: &Harness<'static, PhotocraftApp>) -> photocraft_geom::Rect {
let st = h.state().session.active().unwrap();
let top = st.doc.layers.last().unwrap();
top.surface().unwrap().content_bounds()
}
#[test]
fn picking_another_tool_in_the_toolbar_applies_the_transform() {
let mut h = app();
transform_and_move(&mut h);
let hand = h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool");
hand.click();
h.run_steps(4);
assert!(h.state().ui.transform.is_none(), "the box closed");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "the move was applied, not cancelled");
}
/// The app with the red rectangle on a layer the last step made (Layer via Copy, which deselects in
/// the same step), as Place Embedded leaves it for the box `begin_placed` opens: cancelling the
/// place takes that step back.
fn placed_app() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("layer.new.layerViaCopy", json!({})).unwrap();
app
});
h.run_steps(6);
assert!(h.state().session.active().unwrap().doc.selection.is_none(), "Layer via Copy deselects in its own step");
h
}
fn history(h: &Harness<'static, PhotocraftApp>) -> Vec<String> {
h.state().session.active().unwrap().history.entries().to_vec()
}
/// The box on a just-placed layer (a file dropped on the canvas), moved.
fn placed_and_moved(h: &mut Harness<'static, PhotocraftApp>) {
let ctx = h.ctx.clone();
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
h.run_steps(2);
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
for p in &mut t.quad {
p[0] += 50.0;
p[1] += 40.0;
}
h.run_steps(2);
}
#[test]
fn a_placed_image_is_applied_when_another_tool_is_picked() {
let mut h = placed_app();
placed_and_moved(&mut h);
h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool").click();
h.run_steps(4);
assert!(h.state().ui.transform.is_none());
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200));
}
#[test]
fn picking_another_layer_in_the_panel_applies_the_transform() {
for placed in [false, true] {
let mut h = if placed { placed_app() } else { app() };
if placed {
placed_and_moved(&mut h);
} else {
transform_and_move(&mut h);
}
h.get_by_label("Background").click();
h.run_steps(4);
assert!(h.state().ui.transform.is_none(), "placed {placed}");
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "placed {placed}");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}");
}
}
#[test]
fn new_layer_applies_the_transform_first() {
for placed in [false, true] {
let mut h = if placed { placed_app() } else { app() };
let before = history(&h);
if placed {
placed_and_moved(&mut h);
} else {
transform_and_move(&mut h);
}
// The Layers panel's New Layer button (the menu item opens the New Layer dialog).
h.get_by_label("Create a new layer").click();
h.run_steps(4);
assert!(h.state().ui.transform.is_none(), "placed {placed}");
let st = h.state().session.active().unwrap();
assert_eq!(st.doc.layers.len(), 3, "placed {placed}");
let red = st.doc.layers[1].surface().unwrap().content_bounds();
assert_eq!(red, photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}: the moved image, not the original");
// History: the transform (folded into Place Embedded for a placed layer), then New Layer.
let after = history(&h);
let added: Vec<&str> = after.iter().skip(before.len().saturating_sub(usize::from(placed))).map(String::as_str).collect();
let first = if placed { photocraft_engine::file_cmds::PLACE_EMBEDDED } else { "Free Transform" };
assert_eq!(added, [first, "New Layer"], "placed {placed}: {after:?}");
}
}
#[test]
fn an_untouched_placed_image_stays_when_another_tool_or_layer_is_picked() {
for via_layer in [false, true] {
let mut h = placed_app();
let ctx = h.ctx.clone();
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
h.run_steps(2);
if via_layer {
let bg = h.state().session.active().unwrap().doc.layers[0].id;
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
} else {
h.query_by_label_contains("Hand Tool").expect("Hand Tool").click();
}
h.run_steps(4);
let st = h.state().session.active().unwrap();
assert_eq!(st.doc.layers.len(), 2, "via layer {via_layer}: the placed image stays");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160));
}
}
/// The placed fixture is a real place: cancelling the box takes the layer back, so the tests
/// above would see a switch that cancelled it.
#[test]
fn cancelling_the_placed_fixture_removes_its_layer() {
let mut h = placed_app();
placed_and_moved(&mut h);
crate::transform_tool::cancel(h.state_mut());
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1);
}
/// A new-layer command that can't run (Artboard from Group with a pixel layer active) leaves the box and the
/// placed layer as they were.
#[test]
fn a_new_layer_command_that_cant_run_leaves_the_box_open() {
let mut h = placed_app();
placed_and_moved(&mut h);
let before = history(&h);
assert!(h.state_mut().run("layer.new.artboardFromGroup", json!({})).is_err());
h.run_steps(2);
assert!(h.state().ui.transform.is_some(), "the box stays open");
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
assert_eq!(history(&h), before);
}
/// A transform the engine refuses (a result far over the pixel budget, #3773) keeps the box open
/// with the placed layer, and the new layer isn't made: the refusal comes back.
#[test]
fn a_refused_transform_keeps_the_placed_layer_and_refuses_the_new_layer() {
let mut h = placed_app();
placed_and_moved(&mut h);
let t = h.state_mut().ui.transform.as_mut().unwrap();
t.quad = [[0.0, 0.0], [4.0e6, 0.0], [4.0e6, 4.0e6], [0.0, 4.0e6]];
h.run_steps(2);
let before = history(&h);
let r = h.state_mut().run("layer.new.layer", json!({}));
h.run_steps(2);
assert!(r.is_err(), "{r:?}");
assert!(h.state().ui.transform.is_some(), "the box stays open");
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays, no new layer");
assert_eq!(history(&h), before);
}
/// New Layer with bad params, while a moved box is open, is refused before the transform is
/// applied: the box, the layer and History stay as they were.
#[test]
fn new_layer_with_bad_params_leaves_the_box_open() {
for bad in [json!({"mode": "Bogus"}), json!({"fillNeutral": true}), json!({"position": "x"}), json!([1])] {
let mut h = app();
transform_and_move(&mut h);
let before = history(&h);
assert!(h.state_mut().run("layer.new.layer", bad.clone()).is_err(), "{bad}");
h.run_steps(2);
assert!(h.state().ui.transform.is_some(), "{bad}: the box stays open");
assert_eq!(history(&h), before, "{bad}: nothing applied");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160), "{bad}");
}
}
+3
View File
@@ -1288,6 +1288,9 @@ fn font_styles_keep_metadata_names_and_numeric_labels() {
#[test]
fn postscript_only_style_shows_actual_subfamily() {
// selected_style reads the engine only once the font scan is done (try_shared). Under nextest
// this test runs in a process of its own, where nothing else has started the scan, so start it.
let _ = photocraft_text::shared();
let style = photocraft_doc::text::CharStyle { font_family: "Inter".into(), postscript_name: Some("Inter-SemiBold".into()), ..Default::default() };
assert_eq!(super::selected_style(&style), "SemiBold");
}
+1 -1
View File
@@ -59,7 +59,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins
cargo test --doc --workspace # nextest does not run doctests
```
[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI still runs `cargo test`, and a full `cargo test` stays the final check before a PR.
[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR.
## Fonts (craft-fonts)