mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 21:54:05 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e1c10c30 | ||
|
|
4a1acca879 | ||
|
|
a95b3f0a85 | ||
|
|
b7eab1a377 | ||
|
|
25a8200110 | ||
|
|
6443f5fb4c | ||
|
|
af54f4aed1 | ||
|
|
1a4167936e | ||
|
|
54f4baf6f9 | ||
|
|
3cd0f6c954 | ||
|
|
04db697b5d | ||
|
|
c11ef015b7 | ||
|
|
77a258b9a0 | ||
|
|
e4b2868a02 | ||
|
|
64520f645e | ||
|
|
b24c0360f1 | ||
|
|
d68a1cbc2d | ||
|
|
bcf2cefd4b | ||
|
|
4f6e617555 | ||
|
|
40a7c20430 | ||
|
|
caa28cb7ab | ||
|
|
e22c4ec7ab |
No files matched your search
+10
-1
@@ -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"
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -16,7 +16,7 @@ synth = []
|
||||
[dependencies]
|
||||
crc32fast = "1.5"
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = "0.9"
|
||||
ruzstd = "0.9.1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -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 _};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
})?;
|
||||
|
||||
@@ -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, ¶ms).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, ¶ms) {
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
let run_params = self.admit(id, spec, ¶ms)?;
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:#?}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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");
|
||||
}
|
||||
@@ -1044,6 +1044,21 @@ impl PhotocraftApp {
|
||||
{
|
||||
authorize(id, ¶ms)?;
|
||||
}
|
||||
// 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, ¶ms).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, ¶ms) {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user