mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 03:17:05 -06:00
Merge pull request #8094 from aiongg/integration-6
Integration batch: 26 PRs from the known-issues list (#7973) on main
This commit is contained in:
@@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
crates/
|
||||
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
|
||||
psd codecs L0 standalone format crates (no workspace deps; publishable)
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate
|
||||
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
|
||||
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
|
||||
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
|
||||
@@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
|
||||
|
||||
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
|
||||
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
|
||||
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
|
||||
|
||||
Generated
+34
-5
@@ -1234,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
"winsafe",
|
||||
@@ -2116,7 +2116,7 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2141,9 +2141,17 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"linebender_resource_handle",
|
||||
"memmap2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parlance",
|
||||
"read-fonts",
|
||||
"roxmltree 0.21.1",
|
||||
"smallvec",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"yeslogic-fontconfig-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4824,13 +4832,14 @@ dependencies = [
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"wat",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4912,7 +4921,7 @@ dependencies = [
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -5960,6 +5969,15 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -7460,7 +7478,7 @@ dependencies = [
|
||||
"kurbo 0.11.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"rustybuzz",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
@@ -8706,6 +8724,17 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return Ok(());
|
||||
}
|
||||
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
|
||||
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
|
||||
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
|
||||
// the print command checks for it first and reports an error instead.
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
|
||||
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
|
||||
|
||||
@@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
|
||||
// writes, after the decode warning.
|
||||
let cases = [
|
||||
("whole.jpg", &jpeg[..], None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
|
||||
("whole.jpg", &jpeg[..], None, None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
|
||||
];
|
||||
for (name, bytes, warning) in cases {
|
||||
for (name, bytes, warning, export) in cases {
|
||||
let file = d.join(name);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||||
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
|
||||
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
|
||||
assert_eq!(
|
||||
err.trim(),
|
||||
expected.join(
|
||||
"
|
||||
"
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ flate2 = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
//! one part, however large the app grows.
|
||||
//!
|
||||
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
|
||||
//! In one that can't, this module inflates them itself, a little slower.
|
||||
//! In one that can't, this module inflates them itself, a little slower. Some servers recognise
|
||||
//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic`
|
||||
//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then
|
||||
//! taken as it is, by its first bytes.
|
||||
//!
|
||||
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
|
||||
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
|
||||
@@ -36,6 +39,34 @@ fn parts(list: &str) -> Option<Vec<String>> {
|
||||
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
|
||||
}
|
||||
|
||||
/// What the joined parts hold, told by their first bytes.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Payload {
|
||||
/// The gzip packaging made, still to be inflated.
|
||||
Gzip,
|
||||
/// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser
|
||||
/// already undid.
|
||||
Wasm,
|
||||
}
|
||||
|
||||
/// Enough leading bytes to tell the payloads apart.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
const HEAD: usize = 4;
|
||||
|
||||
/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or
|
||||
/// `None` when it is neither, such as a web page or nothing at all.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn payload(head: &[u8]) -> Option<Payload> {
|
||||
if head.starts_with(&[0x1f, 0x8b]) {
|
||||
Some(Payload::Gzip)
|
||||
} else if head.starts_with(b"\0asm") {
|
||||
Some(Payload::Wasm)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
|
||||
@@ -69,11 +100,11 @@ mod boot {
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{
|
||||
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
|
||||
TransformStreamDefaultController, Transformer, WritableStream,
|
||||
Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
|
||||
TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
|
||||
};
|
||||
|
||||
use super::Inflater;
|
||||
use super::{HEAD, Inflater, Payload};
|
||||
|
||||
/// Where packaging puts the app, next to this module's own files.
|
||||
const PARTS: &str = "./app/parts.txt";
|
||||
@@ -172,11 +203,71 @@ mod boot {
|
||||
Ok(compressed.pipe_through(&pair))
|
||||
}
|
||||
|
||||
/// The next chunk `reader` gives, or `None` at the end of the stream.
|
||||
async fn read(reader: &ReadableStreamDefaultReader) -> Result<Option<js_sys::Uint8Array>, JsValue> {
|
||||
let result = JsFuture::from(reader.read()).await?;
|
||||
if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?))
|
||||
}
|
||||
|
||||
/// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up
|
||||
/// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending.
|
||||
async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
|
||||
if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await {
|
||||
let _ = reader.cancel_with_reason(&e);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it.
|
||||
async fn pass_on(held: Vec<js_sys::Uint8Array>, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
|
||||
for chunk in &held {
|
||||
write(chunk, reader, writer).await?;
|
||||
}
|
||||
while let Some(chunk) = read(reader).await? {
|
||||
write(&chunk, reader, writer).await?;
|
||||
}
|
||||
JsFuture::from(writer.close()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the
|
||||
/// browser has already done that.
|
||||
async fn unpacked(joined: &ReadableStream) -> Result<ReadableStream, JsValue> {
|
||||
let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into();
|
||||
let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new());
|
||||
while head.len() < HEAD {
|
||||
let Some(chunk) = read(&reader).await? else { break };
|
||||
let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length());
|
||||
head.extend(chunk.subarray(0, wanted).to_vec());
|
||||
held.push(chunk);
|
||||
}
|
||||
let Some(payload) = super::payload(&head) else {
|
||||
let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly");
|
||||
// Stops the rest of the download, which nothing will read.
|
||||
let _ = reader.cancel_with_reason(&e);
|
||||
return Err(e);
|
||||
};
|
||||
let rest = TransformStream::new()?;
|
||||
let writer = rest.writable().get_writer()?;
|
||||
spawn_local(async move {
|
||||
if let Err(e) = pass_on(held, &reader, &writer).await {
|
||||
let _ = writer.abort_with_reason(&e);
|
||||
}
|
||||
});
|
||||
match payload {
|
||||
Payload::Gzip => inflated(&rest.readable()),
|
||||
Payload::Wasm => Ok(rest.readable()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(fresh: bool) -> Result<(), JsValue> {
|
||||
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
|
||||
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
|
||||
let joined = TransformStream::new()?;
|
||||
let wasm = inflated(&joined.readable())?;
|
||||
// Why the download stopped: the app's loader only reports that its stream broke.
|
||||
let cause = Rc::new(RefCell::new(None));
|
||||
let failed = cause.clone();
|
||||
@@ -187,6 +278,10 @@ mod boot {
|
||||
failed.replace(Some(e));
|
||||
}
|
||||
});
|
||||
let wasm = match unpacked(&joined.readable()).await {
|
||||
Ok(wasm) => wasm,
|
||||
Err(e) => return Err(cause.take().unwrap_or(e)),
|
||||
};
|
||||
let headers = Headers::new()?;
|
||||
headers.set("Content-Type", "application/wasm")?;
|
||||
let init = ResponseInit::new();
|
||||
@@ -258,7 +353,7 @@ fn main() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Inflater, MARKER, parts};
|
||||
use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
@@ -317,6 +412,22 @@ mod tests {
|
||||
assert!(run(b"").is_err(), "nothing at all");
|
||||
}
|
||||
|
||||
/// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with
|
||||
/// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts
|
||||
/// are then the app's WebAssembly, which must start as it is, not be inflated a second time.
|
||||
#[test]
|
||||
fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() {
|
||||
let mut wasm = b"\0asm\x01\0\0\0".to_vec();
|
||||
wasm.extend(sample());
|
||||
let packed = gzip(&wasm);
|
||||
assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged");
|
||||
assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser");
|
||||
assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD");
|
||||
assert_eq!(payload(b"<!DOCTYPE html>"), None, "a web page");
|
||||
assert_eq!(payload(b"\0as"), None, "a cut-off wasm header");
|
||||
assert_eq!(payload(b""), None, "nothing at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parts_list_gives_its_names_in_order() {
|
||||
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
|
||||
|
||||
@@ -11,6 +11,14 @@ fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
|
||||
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
|
||||
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
|
||||
// the print command checks for it first and reports an error instead.
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("../../assets/app-icon/photocraft.ico")
|
||||
.set("ProductName", "PhotoCraft")
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
|
||||
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
|
||||
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
|
||||
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
|
||||
//! for one launch.
|
||||
//!
|
||||
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
|
||||
@@ -204,6 +206,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor
|
||||
/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An
|
||||
/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's
|
||||
/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]'
|
||||
/// choice.
|
||||
pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec<u32>) -> Option<wgpu::Backends> {
|
||||
let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows;
|
||||
(auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL)
|
||||
}
|
||||
|
||||
/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver)
|
||||
/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]).
|
||||
fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec<u32> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::DX12,
|
||||
flags: desc.flags,
|
||||
backend_options: desc.backend_options.clone(),
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect()
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
|
||||
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
|
||||
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
|
||||
@@ -228,6 +252,8 @@ pub struct Candidate {
|
||||
pub backend: wgpu::Backend,
|
||||
/// Can present to the window.
|
||||
pub surface_ok: bool,
|
||||
/// Its name matches the adapter the user asked for ([`adapter_matches`]).
|
||||
pub wanted: bool,
|
||||
}
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
@@ -259,19 +285,59 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
(fallback, ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for
|
||||
/// win over the rest when any of them can present; otherwise the ranking decides (see
|
||||
/// [`wanted_missed`]).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
let usable = |a: &&Candidate| a.surface_ok;
|
||||
let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted);
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive
|
||||
/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds
|
||||
/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing.
|
||||
pub fn adapter_matches(name: &str, wanted: &str) -> bool {
|
||||
photocraft_engine::prefs::gpu_adapter_matches(name, wanted)
|
||||
}
|
||||
|
||||
/// The user asked for an adapter and none of `adapters` that can present is it.
|
||||
pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool {
|
||||
wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted)
|
||||
}
|
||||
|
||||
/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a
|
||||
/// single Metal path, and neither is tested with a requested adapter.
|
||||
pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux");
|
||||
|
||||
/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode,
|
||||
/// which wants a software adapter whatever the preference says.
|
||||
pub fn applicable_adapter(wanted: Option<String>, backend: GpuBackend, linux: bool) -> Option<String> {
|
||||
wanted.filter(|_| linux && backend != GpuBackend::Cpu)
|
||||
}
|
||||
|
||||
/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act
|
||||
/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the
|
||||
/// launch may still be forwarded to the running window.
|
||||
pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool {
|
||||
env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu
|
||||
}
|
||||
|
||||
/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else
|
||||
/// `performance.gpuAdapter`; `None` is automatic.
|
||||
pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
|
||||
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
|
||||
/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
|
||||
/// the instance).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
|
||||
}
|
||||
|
||||
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
|
||||
@@ -285,7 +351,7 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
|
||||
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
|
||||
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
@@ -298,7 +364,13 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
let env = std::env::var("WGPU_DX12_COMPILER").ok();
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
|
||||
}
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor));
|
||||
if let Some(b) = without_vulkan {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
let vulkan_left_out = without_vulkan.is_some();
|
||||
let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE);
|
||||
let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
|
||||
@@ -311,15 +383,20 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
device_type: i.device_type,
|
||||
backend: i.backend,
|
||||
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
|
||||
wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice"));
|
||||
}
|
||||
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
|
||||
}
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
}
|
||||
@@ -443,6 +520,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
|
||||
/// opens; empty (automatic) when the file or value is missing.
|
||||
pub fn read_gpu_adapter(path: Option<&Path>) -> String {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
|
||||
/// turned on), read before the window opens: it picks the surface's present mode.
|
||||
pub fn read_low_latency(path: Option<&Path>) -> bool {
|
||||
@@ -604,7 +688,82 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
|
||||
Candidate { vendor, device_type, backend, surface_ok: true }
|
||||
Candidate { vendor, device_type, backend, surface_ok: true, wanted: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_adapter_beats_the_automatic_ranking() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
// The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU.
|
||||
let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc");
|
||||
a[2].wanted = true;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(2));
|
||||
a[2].wanted = false;
|
||||
a[0].wanted = true;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for");
|
||||
// A wanted adapter that can't present is skipped, not an error.
|
||||
a[0].surface_ok = false;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(1));
|
||||
assert!(wanted_missed(&a, Some("iris")));
|
||||
assert!(!wanted_missed(&a, None));
|
||||
a[0].surface_ok = true;
|
||||
assert!(!wanted_missed(&a, Some("iris")));
|
||||
// Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides.
|
||||
let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
gl.iter_mut().for_each(|c| c.wanted = true);
|
||||
assert_eq!(pick(&gl, Auto, Os::Other), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adapter_request_applies_on_linux_outside_cpu_mode() {
|
||||
let w = || Some("nvidia".to_string());
|
||||
assert_eq!(applicable_adapter(w(), Auto, true), w());
|
||||
assert_eq!(applicable_adapter(w(), Vulkan, true), w());
|
||||
assert_eq!(applicable_adapter(w(), Cpu, true), None);
|
||||
assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection");
|
||||
assert_eq!(applicable_adapter(None, Auto, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adapter_override_keeps_its_own_process_only_when_it_applies() {
|
||||
let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux);
|
||||
assert!(on(Some("nvidia"), RenderingMode::Auto, true));
|
||||
assert!(on(Some("nvidia"), RenderingMode::Gpu, true));
|
||||
assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode");
|
||||
assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux");
|
||||
assert!(!on(Some(" "), RenderingMode::Auto, true));
|
||||
assert!(!on(None, RenderingMode::Auto, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_names_match_loosely_and_the_environment_wins() {
|
||||
assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
|
||||
assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M "));
|
||||
assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc"));
|
||||
assert!(!adapter_matches("anything", ""));
|
||||
assert!(!adapter_matches("anything", " "));
|
||||
assert_eq!(wanted_adapter(None, ""), None);
|
||||
assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into()));
|
||||
assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into()));
|
||||
assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_adapter_is_read_before_the_window_opens() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_gpu_adapter(Some(&file))
|
||||
};
|
||||
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
|
||||
assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
|
||||
assert_eq!(read(r#"{"performance":{}}"#), "");
|
||||
assert_eq!(read("not json"), "");
|
||||
assert_eq!(read_gpu_adapter(None), "");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -613,13 +772,13 @@ mod tests {
|
||||
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&intel, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(intel[i].backend, B::Dx12);
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows));
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
|
||||
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
|
||||
let hybrid = [intel[0], intel[1], nv[1]];
|
||||
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
|
||||
@@ -630,6 +789,45 @@ mod tests {
|
||||
assert_eq!(pick(&[], Auto, Os::Windows), None);
|
||||
}
|
||||
|
||||
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
|
||||
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
|
||||
/// Windows creates the instance without Vulkan.
|
||||
#[test]
|
||||
fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
const NVIDIA: u32 = 0x10de;
|
||||
const AMD: u32 = 0x1002;
|
||||
const WARP: u32 = 0x1414;
|
||||
let auto = plan(Auto, None, None, false, Os::Windows);
|
||||
let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl);
|
||||
// Intel iGPU + discrete GPU: the discrete GPU is then used on DX12.
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl);
|
||||
// No Intel adapter: egui's default backends, Vulkan included.
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None);
|
||||
// `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms
|
||||
// don't list the DX12 adapters at all.
|
||||
let not_listed = || -> Vec<u32> { panic!("DX12 adapters listed") };
|
||||
for (p, os) in [
|
||||
(plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, None, None, true, Os::Windows), Os::Windows),
|
||||
(plan(Vulkan, None, None, false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, None, None, false, Os::Other), Os::Other),
|
||||
(plan(Auto, None, None, false, Os::Mac), Os::Mac),
|
||||
] {
|
||||
assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}");
|
||||
}
|
||||
// Without Vulkan adapters to compare with, System Info still names the Intel default.
|
||||
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&listed, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(i, 0);
|
||||
assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
|
||||
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_prefers_software_adapters() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
@@ -894,7 +1092,7 @@ mod tests {
|
||||
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let plan = plan(Auto, None, None, false, Os::Windows);
|
||||
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
|
||||
@@ -918,13 +1116,13 @@ mod tests {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
|
||||
}
|
||||
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
|
||||
@@ -71,8 +71,7 @@ mod services;
|
||||
// macOS gets one instance from LaunchServices (open-documents Apple events).
|
||||
#[cfg(any(not(target_os = "macos"), test))]
|
||||
mod single_instance;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
|
||||
mod tablet;
|
||||
mod ui_state;
|
||||
mod window_frame;
|
||||
@@ -496,7 +495,7 @@ fn main() -> eframe::Result {
|
||||
unreadable_paths,
|
||||
safe_gpu,
|
||||
new_instance,
|
||||
launch_options,
|
||||
mut launch_options,
|
||||
present_mode_arg,
|
||||
frame_latency_arg,
|
||||
stroke_trace_arg,
|
||||
@@ -516,6 +515,11 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
|
||||
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
|
||||
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
|
||||
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
|
||||
}
|
||||
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
|
||||
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
|
||||
// shared between users), and not for automation, `--new-instance` or per-process options.
|
||||
@@ -627,6 +631,11 @@ fn main() -> eframe::Result {
|
||||
let tablet_monitor = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
let tablet_monitor = &tablet_monitor;
|
||||
// The Windows pointer message hook; dropping it unhooks when the event loop returns.
|
||||
#[cfg(target_os = "windows")]
|
||||
let tablet_monitor_win = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "windows")]
|
||||
let tablet_monitor_win = &tablet_monitor_win;
|
||||
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
@@ -678,7 +687,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
let wanted_adapter =
|
||||
gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone());
|
||||
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
|
||||
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
|
||||
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
|
||||
@@ -774,9 +785,16 @@ fn main() -> eframe::Result {
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
app.perf.gpu_info.set_available(&rs.available_adapters);
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
|
||||
// Keep an earlier note, such as a requested adapter that couldn't be used.
|
||||
let software = "No compatible hardware graphics adapter; using software graphics.";
|
||||
let fallback = &mut app.perf.gpu_info.fallback;
|
||||
*fallback = Some(match fallback.take() {
|
||||
Some(earlier) => format!("{earlier}. {software}"),
|
||||
None => software.into(),
|
||||
});
|
||||
}
|
||||
if !software_window
|
||||
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
@@ -853,8 +871,13 @@ fn main() -> eframe::Result {
|
||||
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
|
||||
let _ = tablet_monitor.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(monitor) = tablet::install_windows() {
|
||||
let _ = tablet_monitor_win.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, display);
|
||||
app.services.right_button_down = tablet::right_button_service();
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
if !unreadable_paths.is_empty() {
|
||||
|
||||
@@ -274,7 +274,7 @@ fn path_of(file: &rfd::FileHandle) -> String {
|
||||
}
|
||||
|
||||
/// Runs `future` to completion on this thread, sleeping until it is woken.
|
||||
fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
pub(crate) fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
struct Unpark(std::thread::Thread);
|
||||
impl std::task::Wake for Unpark {
|
||||
fn wake(self: Arc<Self>) {
|
||||
@@ -571,7 +571,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let clip = clip.clone();
|
||||
let unreadable = clip_unreadable.clone();
|
||||
Box::new(move || {
|
||||
let mut slot = clip.try_borrow_mut().ok()?;
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
@@ -580,21 +580,33 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
// `--socket=fallback-x11` gives no X server while a compositor runs.
|
||||
let opened = arboard::Clipboard::new();
|
||||
unreadable.set(opened.is_err());
|
||||
slot.insert(opened.ok()?)
|
||||
slot.insert(opened.map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
// Copied files first: Finder also puts the file's icon on the clipboard as an
|
||||
// image, which would otherwise paste instead of the file.
|
||||
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
|
||||
return Some(img);
|
||||
match cb.get().file_list() {
|
||||
Ok(paths) => {
|
||||
if let Some(img) = image_from_files(&paths) {
|
||||
return Ok(Some(img));
|
||||
}
|
||||
}
|
||||
Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
|
||||
Err(_) => {}
|
||||
}
|
||||
if let Ok(img) = cb.get_image() {
|
||||
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
|
||||
match cb.get_image() {
|
||||
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
|
||||
Err(error) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(image) = wayland_clipboard_image() {
|
||||
return Ok(Some(image));
|
||||
}
|
||||
match error {
|
||||
arboard::Error::ContentNotAvailable => Ok(None),
|
||||
_ => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
return wayland_clipboard_image();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
})
|
||||
}),
|
||||
clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
|
||||
@@ -736,6 +748,57 @@ mod tests {
|
||||
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
|
||||
}
|
||||
|
||||
/// Regression using the real clipboard on an isolated Windows CI runner.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
#[ignore = "requires exclusive use of the Windows clipboard"]
|
||||
fn busy_native_clipboard_preserves_copied_shape() {
|
||||
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
|
||||
let mut native = native(None);
|
||||
let services = Services {
|
||||
clipboard_set_image: native.clipboard_set_image.take(),
|
||||
clipboard_get_image: native.clipboard_get_image.take(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
|
||||
app.run("edit.copy", json!({})).unwrap();
|
||||
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
|
||||
assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded");
|
||||
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||
|
||||
// Another thread holds the OS clipboard open beyond arboard's bounded retry window.
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let holder = std::thread::spawn(move || {
|
||||
// A non-null window distinguishes this owner from arboard's null-window opens.
|
||||
let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
drop(guard);
|
||||
});
|
||||
ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
let contention = arboard::Clipboard::new().unwrap().get_image().err();
|
||||
let first = app.run(id, json!({}));
|
||||
release_tx.send(()).unwrap();
|
||||
holder.join().unwrap();
|
||||
assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}");
|
||||
eprintln!("paste while clipboard occupied: {first:?}");
|
||||
assert!(first.is_err(), "report contention rather than a successful no-op");
|
||||
assert!(
|
||||
app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()),
|
||||
"a temporary OS read failure must not discard the copied editable shape"
|
||||
);
|
||||
app.run(id, json!({})).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
assert!(
|
||||
matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)),
|
||||
"retry keeps the shape editable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
|
||||
//! barrel button on Windows (#5905).
|
||||
//!
|
||||
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
|
||||
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
|
||||
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
|
||||
//! `WM_POINTER` pressure as touch force.
|
||||
//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
|
||||
//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
|
||||
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
|
||||
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
|
||||
//! button through a thread-local message hook beside winit.
|
||||
//!
|
||||
//! Wayland compositors give a pen only to clients that bind the tablet protocol
|
||||
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
|
||||
@@ -19,15 +21,36 @@ use photocraft_tablet::Sample;
|
||||
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// A tablet sample as the UI's pen sample.
|
||||
#[allow(dead_code)]
|
||||
pub fn pen_sample(s: Sample) -> PenSample {
|
||||
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
|
||||
}
|
||||
|
||||
/// Writes tablet samples into `feed` (`None` = a mouse).
|
||||
#[allow(dead_code)]
|
||||
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
|
||||
move |s| feed.set(s.map(pen_sample))
|
||||
}
|
||||
|
||||
/// Install the Windows pointer message hook (main thread; keep the result until the event loop
|
||||
/// returns). On failure this logs why: the pen barrel button is then missed.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
|
||||
photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
|
||||
}
|
||||
|
||||
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
|
||||
/// reports the logical secondary button (swapped buttons included) for the app's own window only.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
|
||||
Some(Box::new(photocraft_tablet::windows::is_barrel_button_down))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
|
||||
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
|
||||
/// returns. On failure this logs why: pens then paint like a mouse.
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
|
||||
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
//!
|
||||
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
|
||||
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
|
||||
//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard
|
||||
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
|
||||
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
|
||||
|
||||
@@ -29,7 +34,20 @@ pub use platform::service;
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::WindowFrameFn;
|
||||
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
|
||||
use winsafe::prelude::Handle;
|
||||
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
|
||||
type Rect = (i32, i32, i32, i32);
|
||||
|
||||
/// A monitor edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Edge {
|
||||
Left,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
|
||||
/// system title bar (the window is decorated) or its handle can't be found.
|
||||
@@ -99,8 +117,11 @@ mod platform {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let Some((left, top, right, bottom)) =
|
||||
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
|
||||
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
|
||||
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
|
||||
// covers the whole monitor, which a trimmed maximize no longer does.
|
||||
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
|
||||
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -153,6 +174,69 @@ mod platform {
|
||||
(corrected != rect).then_some(corrected)
|
||||
}
|
||||
|
||||
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
|
||||
fn covers(rect: Rect, monitor: Rect) -> bool {
|
||||
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
|
||||
}
|
||||
|
||||
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
|
||||
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
|
||||
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
|
||||
let mut edges = Vec::new();
|
||||
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
|
||||
let mut after: Option<HWND> = None;
|
||||
// One taskbar per monitor; the bound only guards against a misbehaving shell.
|
||||
for _ in 0..64 {
|
||||
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
|
||||
break;
|
||||
};
|
||||
if let Ok(r) = tray.GetWindowRect() {
|
||||
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
|
||||
}
|
||||
after = Some(tray);
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
|
||||
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
|
||||
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
|
||||
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
|
||||
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
|
||||
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
|
||||
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
|
||||
return None;
|
||||
}
|
||||
// Doubled centre and distances, so nothing rounds.
|
||||
let (cx, cy) = (tl + tr, tt + tb);
|
||||
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
|
||||
.into_iter()
|
||||
.min_by_key(|&(_, distance)| distance)
|
||||
.map(|(edge, _)| edge)
|
||||
}
|
||||
|
||||
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
|
||||
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
|
||||
/// `None` when there is nothing to trim.
|
||||
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
|
||||
if !covers(rect, monitor) {
|
||||
return None;
|
||||
}
|
||||
let (mut l, mut t, mut r, mut b) = rect;
|
||||
for edge in taskbars {
|
||||
match edge {
|
||||
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
|
||||
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
|
||||
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
|
||||
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let trimmed = (l, t, r, b);
|
||||
(l < r && t < b && trimmed != rect).then_some(trimmed)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
|
||||
/// keep working, as they did with winit 0.27's undecorated windows.
|
||||
@@ -162,7 +246,7 @@ mod platform {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{borderless, clamped_rect};
|
||||
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
@@ -221,6 +305,35 @@ mod platform {
|
||||
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
|
||||
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
|
||||
// #4845: an auto-hidden taskbar reserves no work area.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (edge, expected) in
|
||||
[(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))]
|
||||
{
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
|
||||
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
|
||||
}
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
|
||||
// A taskbar that reserves its work area is the clamp's job.
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
|
||||
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
|
||||
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
|
||||
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
|
||||
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
|
||||
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
|
||||
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
|
||||
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,3 +185,9 @@ sync_github_name = true
|
||||
[people.guillehr2]
|
||||
real_name = "Guillem Hermida"
|
||||
sync_github_name = true
|
||||
|
||||
[people.fjamesprice]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ha1des]
|
||||
sync_github_name = true
|
||||
|
||||
+29
-12
@@ -124,12 +124,13 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
|
||||
let ks: f32 = k.iter().sum();
|
||||
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
|
||||
// Horizontal pass over the window rows extended vertically by r.
|
||||
// Horizontal pass over the window rows extended vertically by r. Rows are independent, so
|
||||
// both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426).
|
||||
let th = oh + 2 * r as usize;
|
||||
let stride = ow * ch;
|
||||
let mut tmp = vec![0.0f32; stride * th];
|
||||
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
|
||||
for ty in 0..th {
|
||||
let scratch = || vec![0.0f32; (ow + 2 * r as usize) * ch];
|
||||
let horizontal = |row: &mut Vec<f32>, (ty, dst): (usize, &mut [f32])| {
|
||||
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
|
||||
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
|
||||
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
|
||||
@@ -145,7 +146,6 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
}
|
||||
}
|
||||
}
|
||||
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
|
||||
// Contiguous accumulation can be vectorized for any channel layout. Each output still
|
||||
// sums the same products in the same kernel order as the scalar convolution.
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
@@ -153,26 +153,43 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
||||
*d += s * kv;
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
tmp.par_chunks_mut(stride).enumerate().for_each_init(scratch, horizontal);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut row = scratch();
|
||||
tmp.chunks_mut(stride).enumerate().for_each(|item| horizontal(&mut row, item));
|
||||
}
|
||||
let mut out = vec![0.0f32; ow * oh * ch];
|
||||
for oy in 0..oh {
|
||||
let dst = &mut out[oy * stride..(oy + 1) * stride];
|
||||
let vertical = |(oy, dst): (usize, &mut [f32])| {
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
|
||||
*d += s * kv;
|
||||
}
|
||||
}
|
||||
for ox in 0..ow {
|
||||
let o = (oy * ow + ox) * ch;
|
||||
if let Some(a) = alpha {
|
||||
let av = out[o + a];
|
||||
for c in 0..ch {
|
||||
if let Some(a) = alpha {
|
||||
for (ox, px) in dst.chunks_exact_mut(ch).enumerate() {
|
||||
let av = px[a];
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
if c != a {
|
||||
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
|
||||
*v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(stride).enumerate().for_each(vertical);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out.chunks_mut(stride).enumerate().for_each(vertical);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
* `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain.
|
||||
* `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).
|
||||
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
|
||||
* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for
|
||||
display shaders. Float tables retain near-black detail; unsupported chains return `None`
|
||||
for the 3D LUT fallback. No GPU dependency is required by this crate.
|
||||
* `transform::cached(src, dst, opts)`: process-wide transform cache.
|
||||
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
|
||||
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
|
||||
@@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely
|
||||
analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon
|
||||
(native only).
|
||||
* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus
|
||||
offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated
|
||||
separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB
|
||||
33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
|
||||
* Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute
|
||||
colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero,
|
||||
negative or non-finite white is refused with `CmsError::Invalid`); BPC uses
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Separable RGB display transforms without a coarse 3D interpolation near black.
|
||||
|
||||
/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
|
||||
/// transform. Values are floats independent of the document's pixel depth.
|
||||
///
|
||||
/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
|
||||
/// [`Self::matrix_rows`] to that result, then sample the output table at
|
||||
/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
|
||||
/// separately between its two adjacent table entries. These nonuniform output samples
|
||||
/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
|
||||
///
|
||||
/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
|
||||
/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
|
||||
/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
|
||||
/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
|
||||
/// [`crate::Lut3d`] fallback.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GpuMatrixShaper {
|
||||
matrix: [[f32; 4]; 3],
|
||||
input: Vec<[f32; 4]>,
|
||||
output: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl GpuMatrixShaper {
|
||||
/// The input table has one more entry than this number of equal intervals.
|
||||
pub const INPUT_INTERVALS: usize = 4096;
|
||||
/// Output entries are spaced uniformly in the fourth root of the linear value.
|
||||
pub const OUTPUT_ENTRIES: usize = 4096;
|
||||
|
||||
/// Row-major matrix: each row contains three RGB coefficients followed by its offset.
|
||||
pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
|
||||
self.matrix
|
||||
}
|
||||
|
||||
/// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
|
||||
pub fn input_table(&self) -> &[[f32; 4]] {
|
||||
&self.input
|
||||
}
|
||||
|
||||
/// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
|
||||
pub fn output_table(&self) -> &[[f32; 4]] {
|
||||
&self.output
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec<f32>], output: Option<&[Vec<f32>]>) -> Option<Self> {
|
||||
if !matrix.iter().flatten().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
|
||||
let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
|
||||
let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
|
||||
for row in matrix {
|
||||
let magnitude =
|
||||
row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::<f64>() + f64::from(row[3]).abs();
|
||||
if magnitude > f64::from(f32::MAX) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { matrix, input, output })
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_table(tables: Option<&[Vec<f32>]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option<Vec<[f32; 4]>> {
|
||||
if let Some(tables) = tables
|
||||
&& (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut packed: Vec<[f32; 4]> = Vec::new();
|
||||
// Both lengths are fixed constants, but a failed allocation still means the caller
|
||||
// should retain its existing fallback rather than abort while opening a document.
|
||||
packed.try_reserve_exact(entries).ok()?;
|
||||
for i in 0..entries {
|
||||
let rgb = match tables {
|
||||
Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
|
||||
None => [identity(i as f32 / (entries - 1) as f32); 3],
|
||||
};
|
||||
if !rgb.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(previous) = packed.last()
|
||||
&& rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
}
|
||||
Some(packed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_tables_and_nonfinite_values() {
|
||||
let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
|
||||
let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
|
||||
let mut invalid = inputs.clone();
|
||||
invalid[0].pop();
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
|
||||
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
let mut invalid = inputs.clone();
|
||||
invalid[1][123] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
|
||||
let mut invalid = outputs.clone();
|
||||
invalid[2][321] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
|
||||
let mut invalid = IDENTITY;
|
||||
invalid[1][3] = v;
|
||||
assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_finite_data_that_overflows_shader_arithmetic() {
|
||||
let mut matrix = IDENTITY;
|
||||
matrix[0] = [f32::MAX; 4];
|
||||
assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
|
||||
let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
|
||||
inputs[0][100] = -f32::MAX;
|
||||
inputs[0][101] = f32::MAX;
|
||||
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
//! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible,
|
||||
//! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile.
|
||||
//! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
|
||||
//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms.
|
||||
//!
|
||||
//! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,
|
||||
//! CIE 15, and Adobe's published black point compensation paper. This crate depends on no
|
||||
@@ -19,6 +20,7 @@ pub mod builtin;
|
||||
pub mod clut;
|
||||
pub mod curve;
|
||||
pub mod gamut;
|
||||
mod gpu_matrix_shaper;
|
||||
pub mod lut3d;
|
||||
pub mod lutfile;
|
||||
pub mod math;
|
||||
@@ -32,6 +34,7 @@ pub use builtin::Builtin;
|
||||
pub use clut::Clut;
|
||||
pub use curve::Curve;
|
||||
pub use gamut::GamutCheck;
|
||||
pub use gpu_matrix_shaper::GpuMatrixShaper;
|
||||
pub use lut3d::Lut3d;
|
||||
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
|
||||
pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::curve::Curve;
|
||||
use crate::math;
|
||||
use crate::pipeline::{Pipeline, Stage};
|
||||
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
|
||||
use crate::{CmsError, Intent};
|
||||
use crate::{CmsError, GpuMatrixShaper, Intent};
|
||||
|
||||
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -39,9 +39,9 @@ impl Default for TransformOptions {
|
||||
/// Grid sizes of the device-link tables built for integer (and fast float) evaluation.
|
||||
pub const GRID_3D: usize = 33;
|
||||
pub const GRID_4D: usize = 17;
|
||||
const TABLE_1D: usize = 4096;
|
||||
const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS;
|
||||
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
|
||||
const OUT_TABLE: usize = 4096;
|
||||
const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
|
||||
|
||||
/// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ.
|
||||
const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287];
|
||||
@@ -226,6 +226,31 @@ impl Transform {
|
||||
&self.pipeline
|
||||
}
|
||||
|
||||
/// A separable RGB transform for accurate GPU display conversion, when available.
|
||||
///
|
||||
/// Reuses the input/matrix/output acceleration of the integer conversion path instead
|
||||
/// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other
|
||||
/// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers
|
||||
/// can keep their existing display-LUT fallback. This is an SDR representation; see
|
||||
/// [`GpuMatrixShaper`] for the precise sampling and clamping contract.
|
||||
pub fn gpu_matrix_shaper(&self) -> Option<GpuMatrixShaper> {
|
||||
if self.inputs != 3 || self.outputs != 3 {
|
||||
return None;
|
||||
}
|
||||
let Core::Shaper { curves, matrix, .. } = &self.core else { return None };
|
||||
let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
if let Some((nr, nc, coefficients, offsets)) = matrix {
|
||||
if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) {
|
||||
let [r, g, b] = coefficients;
|
||||
*row = [*r, *g, *b, *offset];
|
||||
}
|
||||
}
|
||||
GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice()))
|
||||
}
|
||||
|
||||
/// Exact evaluation of one colour (`inputs` → `outputs` normalised values).
|
||||
#[inline]
|
||||
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Independent ICC matrix/TRC reference for display shadows.
|
||||
//!
|
||||
//! Fixtures are generated here from the published sRGB equation and a synthetic monitor,
|
||||
//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3
|
||||
//! directly in f64, without Transform, Curve evaluation, or photocraft_cms::math:
|
||||
//! https://www.color.org/specification/ICC1v43_2010-12.pdf
|
||||
|
||||
use photocraft_cms::pipeline::{Pipeline, Stage};
|
||||
use photocraft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions};
|
||||
|
||||
const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];
|
||||
// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile.
|
||||
const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]];
|
||||
const GAMMA: f64 = 2.2;
|
||||
const SOURCE_ENTRIES: usize = 1024;
|
||||
const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0;
|
||||
|
||||
fn fixed16(x: f64) -> f64 {
|
||||
(x * 65536.0).round() / 65536.0
|
||||
}
|
||||
|
||||
fn srgb_sample(i: usize) -> f64 {
|
||||
let x = i as f64 / (SOURCE_ENTRIES - 1) as f64;
|
||||
let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
|
||||
(linear * 65535.0).round() / 65535.0
|
||||
}
|
||||
|
||||
fn synthetic_profiles() -> (Profile, Profile) {
|
||||
let mut source = Builtin::Srgb.profile().clone();
|
||||
source.description = "Synthetic sampled sRGB for display regression".into();
|
||||
source.matrix = Some(SOURCE_MATRIX);
|
||||
source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect())));
|
||||
let source = source.with_encoded_bytes();
|
||||
let mut monitor = Builtin::Srgb.profile().clone();
|
||||
monitor.description = "Synthetic gamma 2.2 display for regression".into();
|
||||
monitor.matrix = Some(MONITOR_MATRIX);
|
||||
monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }));
|
||||
let monitor = monitor.with_encoded_bytes();
|
||||
// Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values.
|
||||
(Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap())
|
||||
}
|
||||
|
||||
fn synthetic_transform(bpc: bool) -> Transform {
|
||||
let (source, monitor) = synthetic_profiles();
|
||||
Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap()
|
||||
}
|
||||
|
||||
fn reference(rgb: [f32; 3]) -> [f64; 3] {
|
||||
let linear = rgb.map(|x| {
|
||||
let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64;
|
||||
let i = (p as usize).min(SOURCE_ENTRIES - 2);
|
||||
srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64)
|
||||
});
|
||||
let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::<f64>());
|
||||
// Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production
|
||||
// matrix inverse/multiplication implementation.
|
||||
let mut a: [[f64; 4]; 3] = std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]);
|
||||
for pivot in 0..3 {
|
||||
let divisor = a[pivot][pivot];
|
||||
for value in &mut a[pivot][pivot..] {
|
||||
*value /= divisor;
|
||||
}
|
||||
let normalized = a[pivot];
|
||||
for (r, row) in a.iter_mut().enumerate() {
|
||||
if r != pivot {
|
||||
let factor = row[pivot];
|
||||
for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) {
|
||||
*value -= factor * coefficient;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA)))
|
||||
}
|
||||
|
||||
/// CPU interpretation of the public GPU data layout; expected colours come only from
|
||||
/// `reference`, so this tests the sampled representation, not Transform against itself.
|
||||
fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] {
|
||||
fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 {
|
||||
let i = (coordinate as usize).min(table.len() - 2);
|
||||
let f = coordinate - i as f32;
|
||||
table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f
|
||||
}
|
||||
let linear: [f32; 3] = std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32));
|
||||
let rows = gpu.matrix_rows();
|
||||
std::array::from_fn(|c| {
|
||||
let row = rows[c];
|
||||
let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3];
|
||||
let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32;
|
||||
interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) {
|
||||
let actual = sample_gpu(gpu, rgb);
|
||||
let expected = reference(rgb);
|
||||
for c in 0..3 {
|
||||
assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() {
|
||||
for bpc in [false, true] {
|
||||
let transform = synthetic_transform(bpc);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1);
|
||||
assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES);
|
||||
assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0));
|
||||
for v in 0..=255 {
|
||||
check_reference(&gpu, [v as f32 / 255.0; 3]);
|
||||
}
|
||||
// Includes coloured shadows within the old LUT's very first 0..8/255 cell.
|
||||
for r in 0..=16 {
|
||||
for g in 0..=16 {
|
||||
for b in 0..=16 {
|
||||
check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] {
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() {
|
||||
let transform = synthetic_transform(false);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
let coarse = Lut3d::from_transform(&transform, 33);
|
||||
let rgb = [2.0 / 255.0; 3];
|
||||
let expected = reference(rgb);
|
||||
let old = coarse.sample(rgb);
|
||||
// Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This
|
||||
// guards the cause of the original bug rather than a particular observed screenshot.
|
||||
assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}");
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_display_representation_accepts_u8_u16_and_float_samples() {
|
||||
let transform = synthetic_transform(false);
|
||||
let gpu = transform.gpu_matrix_shaper().unwrap();
|
||||
for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] {
|
||||
check_reference(&gpu, [v as f32 / 255.0; 3]);
|
||||
}
|
||||
for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] {
|
||||
check_reference(&gpu, [v as f32 / 65535.0; 3]);
|
||||
}
|
||||
for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] {
|
||||
check_reference(&gpu, rgb);
|
||||
}
|
||||
assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]);
|
||||
// White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16.
|
||||
check_reference(&gpu, [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_curves_and_matrix_use_identity_without_losing_offsets() {
|
||||
let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()), TransformOptions::default()).gpu_matrix_shaper().unwrap();
|
||||
for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] {
|
||||
for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) {
|
||||
assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] };
|
||||
let transform = Transform::from_pipeline(Pipeline::new(3, vec![matrix]), TransformOptions::default()).gpu_matrix_shaper().unwrap();
|
||||
let input = [0.1, 0.2, 0.3];
|
||||
let output = sample_gpu(&transform, input);
|
||||
for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) {
|
||||
assert!((actual - expected).abs() < 1e-6, "{output:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() {
|
||||
let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input));
|
||||
let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]), TransformOptions::default());
|
||||
assert!(clut.gpu_matrix_shaper().is_none());
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(lab.gpu_matrix_shaper().is_none());
|
||||
let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(gray.gpu_matrix_shaper().is_none());
|
||||
let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap();
|
||||
assert!(proof.gpu_matrix_shaper().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_matrix_storage_is_not_exposed_to_the_gpu() {
|
||||
for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] {
|
||||
let transform =
|
||||
Transform::from_pipeline(Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]), TransformOptions::default());
|
||||
assert!(transform.gpu_matrix_shaper().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! The colour table of a GIF, read from its blocks without decoding pixels (GIF89a
|
||||
//! specification: Logical Screen Descriptor, Global and Local Color Tables, Graphic Control
|
||||
//! Extension).
|
||||
//!
|
||||
//! A GIF is always palette-based, so it opens in Indexed Color with the table of the frame that
|
||||
//! [`crate::decode`] keeps, the first: its Local Color Table when it has one, else the Global
|
||||
//! one, and the transparent entry that frame's Graphic Control Extension names.
|
||||
|
||||
/// Colours of the table the first frame uses, and its transparent entry. `None` for anything
|
||||
/// that isn't a well-formed GIF up to its first image, or a first frame without a table.
|
||||
pub(crate) fn palette(b: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
|
||||
if !(b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a")) {
|
||||
return None;
|
||||
}
|
||||
// Logical Screen Descriptor: width, height, packed fields, background, aspect.
|
||||
let packed = *b.get(10)?;
|
||||
let mut at = 13;
|
||||
let table = |at: usize, packed: u8| -> Option<Vec<[u8; 3]>> {
|
||||
let n = 2usize << (packed & 0x07);
|
||||
let bytes = b.get(at..at.checked_add(n.checked_mul(3)?)?)?;
|
||||
Some(bytes.as_chunks::<3>().0.to_vec())
|
||||
};
|
||||
let global = if packed & 0x80 != 0 {
|
||||
let t = table(at, packed)?;
|
||||
at += t.len() * 3;
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut transparent = None;
|
||||
loop {
|
||||
match *b.get(at)? {
|
||||
// Extension: label, then data sub-blocks up to a zero-length one.
|
||||
0x21 => {
|
||||
let label = *b.get(at + 1)?;
|
||||
at += 2;
|
||||
// Graphic Control Extension: block size 4, packed (bit 0: transparent colour
|
||||
// given), delay (2 bytes), transparent colour index.
|
||||
if label == 0xF9 && *b.get(at)? == 4 {
|
||||
let flags = *b.get(at + 1)?;
|
||||
transparent = (flags & 0x01 != 0).then_some(*b.get(at + 4)?);
|
||||
}
|
||||
loop {
|
||||
let size = usize::from(*b.get(at)?);
|
||||
at = at.checked_add(1 + size)?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Image Descriptor: position and size (8 bytes), packed fields, then the Local Color
|
||||
// Table if bit 7 is set. This is the first frame: its table is the one used.
|
||||
0x2C => {
|
||||
let packed = *b.get(at + 9)?;
|
||||
let colors = if packed & 0x80 != 0 { table(at + 10, packed)? } else { global? };
|
||||
let transparent = transparent.filter(|i| usize::from(*i) < colors.len());
|
||||
return Some((colors, transparent));
|
||||
}
|
||||
// Trailer, or anything else: no image.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::palette;
|
||||
|
||||
/// A GIF up to its first image: `global` table entries, optional GCE transparency, optional
|
||||
/// local table.
|
||||
fn gif(global: &[[u8; 3]], transparent: Option<u8>, local: Option<&[[u8; 3]]>) -> Vec<u8> {
|
||||
let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8;
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
b.extend([4, 0, 4, 0]);
|
||||
b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) });
|
||||
b.extend([0, 0]);
|
||||
let pad = |b: &mut Vec<u8>, t: &[[u8; 3]]| {
|
||||
let n = 2usize << size_bits(t.len());
|
||||
for k in 0..n {
|
||||
b.extend(t.get(k).copied().unwrap_or([0, 0, 0]));
|
||||
}
|
||||
};
|
||||
if !global.is_empty() {
|
||||
pad(&mut b, global);
|
||||
}
|
||||
// A comment extension first, to skip.
|
||||
b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]);
|
||||
if let Some(i) = transparent {
|
||||
b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]);
|
||||
}
|
||||
b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]);
|
||||
match local {
|
||||
Some(t) => {
|
||||
b.push(0x80 | size_bits(t.len()));
|
||||
pad(&mut b, t);
|
||||
}
|
||||
None => b.push(0),
|
||||
}
|
||||
b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_frames_table_and_transparent_entry() {
|
||||
let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]];
|
||||
assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None)));
|
||||
assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2))));
|
||||
// A local table wins over the global one.
|
||||
let l = [[1, 2, 3], [4, 5, 6]];
|
||||
assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1))));
|
||||
// A transparent index past the table is no transparent entry.
|
||||
assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_table_or_not_a_gif_is_none() {
|
||||
assert_eq!(palette(&gif(&[], None, None)), None, "no table at all");
|
||||
assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None);
|
||||
assert_eq!(palette(b""), None);
|
||||
let whole = gif(&[[9, 9, 9], [8, 8, 8]], Some(0), None);
|
||||
for cut in 0..whole.len() - 6 {
|
||||
// Cut short anywhere before the image descriptor's fields: never a panic.
|
||||
let _ = palette(&whole[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub(crate) mod exr;
|
||||
pub(crate) mod exr_cryptomatte;
|
||||
pub(crate) mod fits;
|
||||
pub(crate) mod gif_frames;
|
||||
pub(crate) mod gif_palette;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod pcx;
|
||||
|
||||
@@ -128,6 +128,12 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> {
|
||||
Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) })
|
||||
}
|
||||
|
||||
/// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the
|
||||
/// entry its Graphic Control Extension makes transparent, read without decoding pixels.
|
||||
pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
|
||||
crate::codecs::gif_palette::palette(bytes)
|
||||
}
|
||||
|
||||
/// Returns the embedded colour table for an indexed BMP, if it has one.
|
||||
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
via_image::bmp_palette(bytes)
|
||||
|
||||
+141
-15
@@ -31,6 +31,7 @@ pub mod pattern;
|
||||
pub mod proxy;
|
||||
pub mod psblend;
|
||||
pub mod shape_split;
|
||||
mod uniform;
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern};
|
||||
@@ -431,6 +432,38 @@ pub fn render_reduced_damage(doc: &Document, w: u32, h: u32, damage: Rect) -> Bu
|
||||
/// [`render_reduced_rect`] (or, with `damage`, [`render_reduced_damage`]) of area `b` with an
|
||||
/// explicit band height (see [`render_bands`]).
|
||||
fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Option<Rect>, band_rows: i32) -> Buffer {
|
||||
render_reduced_with(doc, b, w, h, damage, band_rows, true, &mut ReducedStats::default())
|
||||
}
|
||||
|
||||
/// What a reduced render composited (tests check that one-colour areas are not composited).
|
||||
#[derive(Debug, Default)]
|
||||
struct ReducedStats {
|
||||
/// Whether one-colour areas were looked for ([`uniform::UniformAreas`]).
|
||||
uniform: bool,
|
||||
/// Document pixels composited one by one.
|
||||
composited: u64,
|
||||
}
|
||||
|
||||
/// A tile of a reduced render: one colour (premultiplied), or its composited pixels.
|
||||
enum Part {
|
||||
Solid([f32; 4]),
|
||||
Px(Buffer),
|
||||
}
|
||||
|
||||
/// A column of document tiles in a reduced render (`b`-relative source columns).
|
||||
struct TileColumn {
|
||||
/// The columns rendered: the tile's, within the source area.
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
/// The whole tile's columns within `b`, whose values decide whether it is one colour.
|
||||
whole: (i64, i64),
|
||||
/// Runs of (output column, how many of the rendered columns it averages).
|
||||
runs: Vec<(usize, f32)>,
|
||||
}
|
||||
|
||||
/// [`render_reduced_in_bands`]; `uniform` off composites every pixel (the reference in tests).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option<Rect>, band_rows: i32, uniform: bool, stats: &mut ReducedStats) -> Buffer {
|
||||
let (fw, fh) = (b.width() as usize, b.height() as usize);
|
||||
let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize);
|
||||
let full = Rect::from_xywh(0, 0, w as u32, h as u32);
|
||||
@@ -474,22 +507,14 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti
|
||||
let (cols, rows) = (map(fw, w), map(fh, h));
|
||||
let cols = &cols[sx0..sx1];
|
||||
let mut acc = vec![[0.0f32; 4]; ow * oh];
|
||||
let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| {
|
||||
let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize);
|
||||
// Each output row sums its source rows in a band (in order, top to bottom), each row's pixels
|
||||
// left to right: `row(y, out)` adds source row `y` (band-relative) to output row `out`.
|
||||
let sum_band = |acc: &mut [[f32; 4]], by0: usize, by1: usize, row: &(dyn Fn(usize, &mut [[f32; 4]]) + Sync)| {
|
||||
let (t0, t1) = (rows[by0] as usize, rows[by1 - 1] as usize + 1);
|
||||
let sw = sx1 - sx0;
|
||||
// Each output row sums its source rows in this band (in order, top to bottom).
|
||||
let sum_row = |t: usize, out: &mut [[f32; 4]]| {
|
||||
let (a, z) = span(t, fh, h);
|
||||
for y in a.max(by0)..z.min(by1) {
|
||||
let src = &band.px[(y - by0) * sw..(y - by0 + 1) * sw];
|
||||
for (p, &c) in src.iter().zip(cols) {
|
||||
let o = &mut out[c as usize - ox0];
|
||||
for i in 0..3 {
|
||||
o[i] += p[i] * p[3];
|
||||
}
|
||||
o[3] += p[3];
|
||||
}
|
||||
row(y - by0, out);
|
||||
}
|
||||
};
|
||||
let part = &mut acc[(t0 - oy0) * ow..(t1 - oy0) * ow];
|
||||
@@ -502,9 +527,110 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti
|
||||
for (i, out) in part.chunks_mut(ow).enumerate() {
|
||||
sum_row(t0 + i, out);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
debug_assert!(ok.is_ok());
|
||||
};
|
||||
let add = |o: &mut [f32; 4], p: &[f32; 4]| {
|
||||
for i in 0..3 {
|
||||
o[i] += p[i] * p[3];
|
||||
}
|
||||
o[3] += p[3];
|
||||
};
|
||||
if let Some(uniform) = uniform.then(|| uniform::UniformAreas::new(doc)).flatten() {
|
||||
stats.uniform = true;
|
||||
// One-colour tiles (a new document's background, the empty parts of a sparse one) are
|
||||
// composited once per colour and summed per run of source columns, not per pixel
|
||||
// (#4017). Tiles lie on the document's tile grid whatever `damage` is, so a damage
|
||||
// render sums exactly what the whole reduction does.
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
|
||||
let cx = Ctx::for_doc(doc, &patterns);
|
||||
let t = RENDER_TILE as usize;
|
||||
// The document tile grid (the surfaces' own), in `b`-relative coordinates: the start of
|
||||
// the tile holding `v` (negative when `b` starts inside a tile).
|
||||
let grid = |v: usize, o: i32| (i64::from(o) + v as i64).div_euclid(t as i64) * t as i64 - i64::from(o);
|
||||
let tile_cols: Vec<TileColumn> = (grid(sx0, b.x0)..sx1 as i64)
|
||||
.step_by(t)
|
||||
.map(|gx| {
|
||||
let (x0, x1) = ((gx.max(0) as usize).max(sx0), ((gx + t as i64) as usize).min(sx1));
|
||||
let mut runs: Vec<(usize, f32)> = Vec::new();
|
||||
for &c in &cols[x0 - sx0..x1 - sx0] {
|
||||
match runs.last_mut() {
|
||||
Some((last, n)) if *last == c as usize - ox0 => *n += 1.0,
|
||||
_ => runs.push((c as usize - ox0, 1.0)),
|
||||
}
|
||||
}
|
||||
TileColumn { x0, x1, whole: (gx.max(0), (gx + t as i64).min(fw as i64)), runs }
|
||||
})
|
||||
.collect();
|
||||
let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize;
|
||||
let mut y = grid(sy0, b.y0);
|
||||
while y < sy1 as i64 {
|
||||
let y1 = (y + band_h as i64).min(sy1 as i64);
|
||||
let tiles: Vec<(Rect, Rect)> = (y..y1)
|
||||
.step_by(t)
|
||||
.flat_map(|gy| {
|
||||
tile_cols.iter().map(move |&TileColumn { x0, x1, whole: (gx0, gx1), .. }| {
|
||||
let at = |x0: i64, y0: i64, x1: i64, y1: i64| Rect::new(b.x0 + x0 as i32, b.y0 + y0 as i32, b.x0 + x1 as i32, b.y0 + y1 as i32);
|
||||
let (gy0, gy1) = (gy.max(0), (gy + t as i64).min(fh as i64));
|
||||
(at(gx0, gy0, gx1, gy1), at(x0 as i64, gy0.max(sy0 as i64), x1 as i64, gy1.min(sy1 as i64)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let one = |(whole, part): &(Rect, Rect)| -> Part {
|
||||
let render = |r: Rect| render_tiled_with(doc, r, RENDER_TILE, &cx);
|
||||
match uniform.color(*whole, |r| render(r).px.first().copied()) {
|
||||
Some(p) => Part::Solid([p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]),
|
||||
None => Part::Px(render(*part)),
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let parts: Vec<Part> = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().map(one).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let parts: Vec<Part> = tiles.iter().map(one).collect();
|
||||
stats.composited += parts.iter().map(|p| if let Part::Px(b) = p { b.px.len() as u64 } else { 0 }).sum::<u64>();
|
||||
let (by0, by1) = (y.max(sy0 as i64) as usize, y1 as usize);
|
||||
let n = tile_cols.len();
|
||||
let band_y = y;
|
||||
sum_band(&mut acc, by0, by1, &|row: usize, out: &mut [[f32; 4]]| {
|
||||
let y = (by0 + row) as i64;
|
||||
let j = (y - band_y) as usize / t;
|
||||
let ty = (y - (band_y + (j * t) as i64).max(sy0 as i64)) as usize;
|
||||
for (TileColumn { x0, x1, runs, .. }, part) in tile_cols.iter().zip(parts.iter().skip(j * n).take(n)) {
|
||||
match part {
|
||||
Part::Solid(pa) => {
|
||||
for &(c, k) in runs {
|
||||
let o = &mut out[c];
|
||||
for i in 0..4 {
|
||||
o[i] += pa[i] * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
Part::Px(buf) => {
|
||||
let tw = x1 - x0;
|
||||
for (p, &c) in buf.px.iter().skip(ty * tw).take(tw).zip(&cols[x0 - sx0..x1 - sx0]) {
|
||||
add(&mut out[c as usize - ox0], p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
y = y1;
|
||||
}
|
||||
} else {
|
||||
let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| {
|
||||
stats.composited += band.px.len() as u64;
|
||||
let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize);
|
||||
let sw = sx1 - sx0;
|
||||
sum_band(&mut acc, by0, by1, &|y: usize, out: &mut [[f32; 4]]| {
|
||||
for (p, &c) in band.px[y * sw..(y + 1) * sw].iter().zip(cols) {
|
||||
add(&mut out[c as usize - ox0], p);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
debug_assert!(ok.is_ok());
|
||||
}
|
||||
for (i, p) in acc.iter_mut().enumerate() {
|
||||
let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h));
|
||||
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
|
||||
|
||||
@@ -3116,6 +3116,108 @@ fn effect_cache_follows_a_group_childs_blend_if() {
|
||||
assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed");
|
||||
}
|
||||
|
||||
/// A document whose one-colour areas are found: shared background tiles, a painted patch over
|
||||
/// tile edges, a uniform mask tile, an adjustment, a group, a 16-bit layer and a hidden one.
|
||||
fn sparse_doc(w: u32, h: u32) -> Document {
|
||||
let mut d = doc_white(w, h);
|
||||
let mut paint = Layer::raster("paint", PixelFormat::RGBA8);
|
||||
let s = paint.surface_mut().unwrap();
|
||||
for y in 200..330 {
|
||||
s.fill_rect(Rect::new(240, y, 520, y + 1), &[(y - 200) as f32 / 130.0, 0.4, 0.7, 0.8]);
|
||||
}
|
||||
s.fill_rect(Rect::new(0, 600, w as i32 / 2, 900), &[0.2, 0.9, 0.1, 0.5]);
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::new(0, 512, 256, 768), &[0.25]);
|
||||
m.surface.fill_rect(Rect::new(300, 700, 310, 710), &[0.0]);
|
||||
paint.mask = Some(m);
|
||||
paint.blend = BlendMode::Multiply;
|
||||
let mut deep = solid_layer("deep", Rect::new(100, 100, 700, 400), [0.3, 0.3, 0.9, 0.6]);
|
||||
deep.content = LayerContent::Raster(deep.surface().unwrap().convert(PixelFormat::new(ColorMode::Rgb, SampleType::U16, true)));
|
||||
let mut hidden = solid_layer("hidden", Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0]);
|
||||
hidden.visible = false;
|
||||
d.layers.push(Layer::group("g", vec![paint, deep, hidden]));
|
||||
d.layers.push(Layer::new("levels", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.0, gamma: 1.1 })));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_colour_areas_reduce_like_composited_ones() {
|
||||
// #4017: one-colour tiles are composited once and summed per run of columns: the same
|
||||
// averages as compositing every pixel (to float rounding), and a damage render gives exactly
|
||||
// the whole reduction's pixels, so a reduced canvas updated in parts shows no seams.
|
||||
let d = sparse_doc(2000, 2100);
|
||||
for (b, w, h, band) in [
|
||||
(d.bounds(), 37, 41, 0),
|
||||
(d.bounds(), 250, 2100, 256),
|
||||
(d.bounds(), 1999, 3, 512),
|
||||
(Rect::new(-300, -77, 2200, 2300), 61, 29, 0),
|
||||
(Rect::new(13, 5, 2000, 2100), 1, 1, 256),
|
||||
] {
|
||||
let reference = render_reduced_with(&d, b, w, h, None, band, false, &mut ReducedStats::default());
|
||||
let mut stats = ReducedStats::default();
|
||||
let all = render_reduced_with(&d, b, w, h, None, band, true, &mut stats);
|
||||
assert!(stats.uniform);
|
||||
assert!(stats.composited < u64::from(b.width()) * u64::from(b.height()) / 2, "{b:?}: {stats:?}");
|
||||
assert_eq!(all.rect, reference.rect);
|
||||
for (p, q) in all.px.iter().zip(&reference.px) {
|
||||
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 1e-4), "{b:?} {w}x{h} band {band}: {p:?} vs {q:?}");
|
||||
}
|
||||
for dmg in [Rect::new(250, 210, 260, 220), Rect::new(0, 0, 2000, 2100), Rect::new(-50, 640, 3000, 641), Rect::new(1500, 1500, 1600, 1900)] {
|
||||
let part = render_reduced_with(&d, b, w, h, Some(dmg), band, true, &mut ReducedStats::default());
|
||||
for y in part.rect.y0..part.rect.y1 {
|
||||
for x in part.rect.x0..part.rect.x1 {
|
||||
assert_eq!(part.get(x, y), all.get(x, y), "{b:?} {w}x{h} band {band} {dmg:?} at {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_that_read_other_pixels_are_composited_pixel_by_pixel() {
|
||||
let base = sparse_doc(300, 300);
|
||||
let edits: [fn(&mut Layer); 4] = [
|
||||
|l| l.blend = BlendMode::Dissolve,
|
||||
|l| l.mask.get_or_insert_with(LayerMask::reveal_all).feather = 3.0,
|
||||
|l| l.effects.items.push(stroke(2.0, StrokePosition::Outside)),
|
||||
|l| l.content = LayerContent::Fill(Fill::Solid(Color::BLACK)),
|
||||
];
|
||||
for edit in edits {
|
||||
let mut d = base.clone();
|
||||
edit(&mut d.layers[0]);
|
||||
let mut stats = ReducedStats::default();
|
||||
render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats);
|
||||
assert!(!stats.uniform);
|
||||
assert_eq!(stats.composited, 300 * 300);
|
||||
}
|
||||
let mut d = base.clone();
|
||||
d.layers.push(Layer::new("map", LayerContent::Adjustment(Adjustment::GradientMap { stops: vec![], reverse: false, dither: true })));
|
||||
let mut stats = ReducedStats::default();
|
||||
render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats);
|
||||
assert!(!stats.uniform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gigapixel_new_document_reduces_without_compositing_its_canvas() {
|
||||
// #4017: the reduced canvas of a 10 x 5 m, 200 ppi document (3.1 GP) composited every pixel
|
||||
// (25 s on 32 threads, all cores busy). Only the partial tiles at its right and bottom edges
|
||||
// hold two values; the rest is one shared white tile.
|
||||
let (w, h) = (78_740u32, 39_370u32);
|
||||
for fill in [Some(Color::WHITE), Some(Color::rgba(0.2, 0.4, 0.6, 1.0)), None] {
|
||||
let mut d = match fill {
|
||||
Some(c) => Document::with_background("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8, c),
|
||||
None => Document::new("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8),
|
||||
};
|
||||
d.layers.push(Layer::raster("Layer 1", PixelFormat::RGBA8));
|
||||
let mut stats = ReducedStats::default();
|
||||
let r = render_reduced_with(&d, d.bounds(), 3937, 1969, None, 0, true, &mut stats);
|
||||
let edge_tiles = u64::from(w.div_ceil(256) + h.div_ceil(256) - 1);
|
||||
assert!(stats.composited <= edge_tiles * 256 * 256, "{fill:?}: {stats:?}");
|
||||
let want = fill.map_or([0.0; 4], |c| [c.c[0], c.c[1], c.c[2], 1.0]);
|
||||
assert!(r.px.iter().all(|p| close4(*p, want)), "{fill:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_effects() {
|
||||
let canvas = Rect::new(0, 0, 100, 100);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! One-colour areas of a composite, known without compositing them pixel by pixel.
|
||||
//!
|
||||
//! A new document is a canvas of one shared white tile (or no tiles at all), and a sparse one is
|
||||
//! mostly that. When every layer composites pixel by pixel from its own pixels (no effects,
|
||||
//! feathered masks, dissolve, dithering or layer kinds laid out on the canvas), an area over which
|
||||
//! every surface holds one value composites to one colour: one pixel of it is composited and
|
||||
//! stands for the rest. The reduced canvas of a gigapixel document (#4017) then costs what its
|
||||
//! painted tiles cost, not a composite of every document pixel.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::{Adjustment, Document, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// The surfaces a document composites from, when its composite at each pixel depends only on
|
||||
/// their values at that pixel.
|
||||
pub(crate) struct UniformAreas<'a> {
|
||||
surfaces: Vec<&'a Surface>,
|
||||
/// One value per tile (by address), or `None` when its pixels differ: each tile is scanned once.
|
||||
tile_values: Mutex<HashMap<usize, Option<Box<[u8]>>>>,
|
||||
/// The composite colour of each combination of surface values met so far.
|
||||
colors: Mutex<HashMap<Vec<u8>, [f32; 4]>>,
|
||||
}
|
||||
|
||||
impl<'a> UniformAreas<'a> {
|
||||
/// `None` when some layer's composite depends on more than its pixels at the same place.
|
||||
pub(crate) fn new(doc: &'a Document) -> Option<Self> {
|
||||
if doc.mode == photocraft_color::ColorMode::Multichannel {
|
||||
return None;
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
for (_, _, l) in doc.walk() {
|
||||
let effects = l.effects.enabled && !l.effects.items.is_empty();
|
||||
if effects || l.vector_mask.is_some() || l.video.is_some() || l.blend == BlendMode::Dissolve {
|
||||
return None;
|
||||
}
|
||||
if let Some(m) = &l.mask {
|
||||
if m.feather != 0.0 {
|
||||
return None;
|
||||
}
|
||||
surfaces.push(&m.surface);
|
||||
}
|
||||
match &l.content {
|
||||
LayerContent::Raster(s) => surfaces.push(s),
|
||||
LayerContent::Group(g) if g.artboard.is_none() => {}
|
||||
LayerContent::Adjustment(a) if per_pixel(a) => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(Self { surfaces, tile_values: Mutex::default(), colors: Mutex::default() })
|
||||
}
|
||||
|
||||
/// The colour every pixel of `r` composites to, when every surface holds one value over it;
|
||||
/// `composite` renders one pixel the first time a combination of values is met.
|
||||
pub(crate) fn color(&self, r: Rect, composite: impl FnOnce(Rect) -> Option<[f32; 4]>) -> Option<[f32; 4]> {
|
||||
if r.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut key = Vec::new();
|
||||
for s in &self.surfaces {
|
||||
key.extend_from_slice(&self.value_over(s, r)?);
|
||||
}
|
||||
if let Some(c) = self.colors.lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||
return Some(*c);
|
||||
}
|
||||
let c = composite(Rect::from_xywh(r.x0, r.y0, 1, 1))?;
|
||||
self.colors.lock().unwrap_or_else(PoisonError::into_inner).insert(key, c);
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// The one encoded value `s` holds over `r`, if it holds one.
|
||||
fn value_over(&self, s: &Surface, r: Rect) -> Option<Box<[u8]>> {
|
||||
let mut found: Option<Box<[u8]>> = None;
|
||||
for c in r.tiles() {
|
||||
let v = match s.tile(c) {
|
||||
None => Some(Box::from(s.default_bytes())),
|
||||
Some(t) => {
|
||||
let id = std::sync::Arc::as_ptr(t) as usize;
|
||||
let cached = self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).get(&id).cloned();
|
||||
match cached {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let bpp = s.default_bytes().len().max(1);
|
||||
let mut px = t.bytes().chunks_exact(bpp);
|
||||
let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from);
|
||||
self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone());
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}?;
|
||||
if found.as_ref().is_some_and(|f| *f != v) {
|
||||
return None;
|
||||
}
|
||||
found = Some(v);
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether adjustment `a` maps each pixel on its own (dithered ones vary with the position).
|
||||
fn per_pixel(a: &Adjustment) -> bool {
|
||||
!matches!(a, Adjustment::GradientMap { dither: true, .. } | Adjustment::ColorLookup { dither: true, .. })
|
||||
}
|
||||
@@ -47,6 +47,9 @@ tempfile = "3"
|
||||
# file is that file (std's `MetadataExt::file_index` is unstable).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
same-file = "1"
|
||||
# `file.print` loads Windows.Data.Pdf.dll before calling into it (#5884): the safe binding
|
||||
# ui-egui and craft-print already use, pinned to the same version.
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] }
|
||||
|
||||
# Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print.
|
||||
[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies]
|
||||
|
||||
@@ -644,13 +644,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mix: pct(p, "mix", bm.mix * 100.0),
|
||||
flow: pct(p, "flow", bm.flow * 100.0),
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
|
||||
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
|
||||
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
|
||||
};
|
||||
let sample_all = m.sample_all_layers;
|
||||
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
|
||||
let paint = color(p.get("color"), s.tools.foreground);
|
||||
let mut state = s.tools.mixer.clone();
|
||||
if load_after || state.reservoir.is_none() {
|
||||
state.load(color(p.get("color"), s.tools.foreground));
|
||||
}
|
||||
state.begin_stroke(paint);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
|
||||
let stroke = Stroke { brush, points: pts };
|
||||
let dmg = s.edit("Mixer Brush", |doc, _| {
|
||||
@@ -672,13 +672,43 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock))
|
||||
})?;
|
||||
if clean {
|
||||
state.clean();
|
||||
}
|
||||
state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke);
|
||||
s.tools.mixer = state;
|
||||
Ok(damage_json(s, dmg))
|
||||
}
|
||||
|
||||
/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke
|
||||
/// keeps that paint unless a different foreground is chosen first. Returns the paint the brush now
|
||||
/// carries.
|
||||
fn mixer_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.mixer.load";
|
||||
let paint = match p.get("color").filter(|v| !v.is_null()) {
|
||||
None => Some(s.tools.foreground),
|
||||
Some(Value::String(h)) => crate::commands::parse_hex(h),
|
||||
Some(Value::Array(a)) => {
|
||||
let c: Option<Vec<f32>> = a.iter().map(|x| x.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect();
|
||||
match c.as_deref() {
|
||||
Some(&[r, g, b]) => Some([r, g, b, 1.0]),
|
||||
Some(&[r, g, b, a]) => Some([r, g, b, a]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Some(_) => None,
|
||||
};
|
||||
let paint = paint.ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b, a?] in 0..1"))?;
|
||||
s.tools.mixer.load(paint);
|
||||
s.tools.mixer.color = Some(s.tools.foreground);
|
||||
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `brush.mixer.clean`: Clean Brush empties the Mixer Brush. The next stroke starts empty unless
|
||||
/// a different color is chosen first. Returns the paint the brush now carries.
|
||||
fn mixer_clean(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.tools.mixer.clean();
|
||||
s.tools.mixer.color = Some(s.tools.foreground);
|
||||
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour
|
||||
/// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha
|
||||
/// channel or Quick Mask `target` is refused rather than recolouring the layer instead.
|
||||
@@ -1135,11 +1165,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"paint.mixerBrush",
|
||||
"Mixer Brush",
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##,
|
||||
has_paintable,
|
||||
mixer_brush,
|
||||
true
|
||||
),
|
||||
spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true),
|
||||
spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true),
|
||||
spec!(
|
||||
"paint.colorReplacement",
|
||||
"Color Replacement",
|
||||
|
||||
@@ -814,6 +814,101 @@ fn mixer_brush_uses_the_brush_mixer_settings() {
|
||||
assert!(rgba(&s, 190, 5)[3] > 0.95);
|
||||
}
|
||||
|
||||
/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all
|
||||
/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off.
|
||||
fn mixer_over_red() -> Session {
|
||||
let mut s = session(200, 30);
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap();
|
||||
let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false});
|
||||
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] {
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap();
|
||||
rgba(s, 100, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.load", json!({})).unwrap();
|
||||
let state = s.execute("brush.mixer.clean", json!({})).unwrap();
|
||||
assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null));
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_automatic_clean_empties_the_reservoir() {
|
||||
let mut s = mixer_over_red();
|
||||
// The brush was never loaded, so the first stroke takes the foreground.
|
||||
assert!(mixer_row(&mut s, 5)[2] > 0.2);
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None));
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}");
|
||||
// With the automatic Load on as well (the default), the brush is cleaned and then refilled
|
||||
// after the stroke, so the next one starts with a full reservoir and no pickup.
|
||||
s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap();
|
||||
mixer_row(&mut s, 25);
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_brush_after_a_clean_paints_the_foreground_again() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.clean", json!({})).unwrap();
|
||||
assert!(mixer_row(&mut s, 5)[2] < 0.01);
|
||||
let state = s.execute("brush.mixer.load", json!({})).unwrap();
|
||||
assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0]));
|
||||
assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue");
|
||||
// A color param loads that color instead of the foreground, and the next stroke paints it.
|
||||
s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap();
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0));
|
||||
assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0]));
|
||||
let c = mixer_row(&mut s, 25);
|
||||
assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() {
|
||||
let mut s = mixer_over_red();
|
||||
assert!(mixer_row(&mut s, 5)[2] > 0.2);
|
||||
s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap();
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}");
|
||||
// The automatic Clean emptied it again, and the foreground did not change.
|
||||
let c = mixer_row(&mut s, 25);
|
||||
assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap();
|
||||
let before = s.tools.mixer.clone();
|
||||
for bad in [
|
||||
json!({"color": 5}),
|
||||
json!({"color": "#zz0000"}),
|
||||
json!({"color": "#ff"}),
|
||||
json!({"color": [2, 0, 0]}),
|
||||
json!({"color": [0, 0]}),
|
||||
json!({"color": [0, 0, 0, 0, 0]}),
|
||||
json!({"color": {"r": 1}}),
|
||||
json!({"color": ["a", 0, 0]}),
|
||||
] {
|
||||
assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}");
|
||||
assert_eq!(s.tools.mixer, before, "{bad}");
|
||||
}
|
||||
// Clean Brush takes no params, so anything extra is ignored, without a document too.
|
||||
let mut empty = Session::new();
|
||||
empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap();
|
||||
empty.execute("brush.mixer.load", json!({"color": null})).unwrap();
|
||||
assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_set_brush_calls_journal_once_per_gesture() {
|
||||
let mut s = session(20, 20);
|
||||
|
||||
@@ -459,6 +459,21 @@ impl ColorState {
|
||||
self.canvas_lut_with(doc, size, false, display)
|
||||
}
|
||||
|
||||
/// Accurate GPU representation of RGB matrix/TRC display transforms. Gamut warnings,
|
||||
/// proof simulations beyond profile conversion, and transforms containing a CLUT retain
|
||||
/// the existing 3D LUT path. The shader applies the 32-bit preview before this transform,
|
||||
/// just as it does before the display LUT.
|
||||
pub fn gpu_canvas_matrix_shaper_for(&self, doc: &Document, display: Option<u32>) -> Result<Option<photocraft_cms::GpuMatrixShaper>> {
|
||||
let proof = self.proof(doc.id);
|
||||
if proof.gamut_warning || (proof.enabled && proof.setup.kind != crate::proof_sim::ProofKind::Profile) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !proof.enabled && self.canvas_display_for(doc, display)?.transform.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(self.display_transform_for(doc, display)?.gpu_matrix_shaper())
|
||||
}
|
||||
|
||||
fn canvas_lut_with(&self, doc: &Document, size: usize, hdr: bool, on: Option<u32>) -> Result<Option<Vec<u8>>> {
|
||||
let size = size.max(2);
|
||||
let pv = self.proof(doc.id);
|
||||
@@ -1338,6 +1353,44 @@ mod tests {
|
||||
assert_eq!(sh.color.mode, ColorMode::Cmyk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_matrix_shaper_keeps_special_proof_simulations_on_the_lut_path() {
|
||||
let mut s = session("rgb", 8);
|
||||
let d = doc(&s).clone();
|
||||
// These valid synthetic RGB profiles make the ordinary profile-proof pipeline
|
||||
// separable. Thus the test cannot pass just because a typical proof happens to
|
||||
// contain unsupported stages; the simulation kind must be excluded explicitly.
|
||||
let mut proof = Builtin::LinearSrgb.profile().clone();
|
||||
proof.description = "Synthetic identity-matrix proof".into();
|
||||
proof.matrix = Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
proof.trc = Some([photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity]);
|
||||
let proof = proof.with_encoded_bytes();
|
||||
let mut monitor = proof.clone();
|
||||
monitor.description = "Synthetic identity-matrix gamma monitor".into();
|
||||
monitor.trc = Some(std::array::from_fn(|_| photocraft_cms::Curve::Gamma(2.2)));
|
||||
let monitor = monitor.with_encoded_bytes();
|
||||
s.color.set_displays(Ok(vec![crate::display_color::Display {
|
||||
id: 1,
|
||||
name: "Synthetic monitor".into(),
|
||||
frame: [0.0, 0.0, 800.0, 600.0],
|
||||
profile_name: None,
|
||||
icc: Some(monitor.to_bytes()),
|
||||
}]));
|
||||
let pv = s.color.proof_mut(d.id);
|
||||
pv.setup.profile = Arc::new(Profile::parse(&proof.to_bytes()).unwrap());
|
||||
pv.enabled = true;
|
||||
assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "ordinary profile conversion is separable");
|
||||
for command in ["view.proofSetup.legacyMacintoshRgb", "view.proofSetup.colorBlindnessProtanopia", "view.proofSetup.colorBlindnessDeuteranopia"] {
|
||||
// These commands retain the previously selected custom profile.
|
||||
s.execute(command, json!({})).unwrap();
|
||||
assert!(s.color.display_transform_for(&d, Some(1)).unwrap().gpu_matrix_shaper().is_some(), "fixture must exercise the shortcut");
|
||||
assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_none(), "{command} must retain its simulation");
|
||||
assert!(s.color.gpu_canvas_lut_for(&d, 9, Some(1)).unwrap().is_some(), "{command} still has its LUT fallback");
|
||||
}
|
||||
s.execute("view.proofColors", json!({"on": false})).unwrap();
|
||||
assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "disabled simulations do not block ordinary display conversion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proofing_state_and_gamut_warning() {
|
||||
let mut s = session("rgb", 8);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Colour-managed canvas display: the document's composite shown through document profile →
|
||||
//! monitor profile, on the GPU canvas (folded into the display 3D LUT) and the CPU canvas
|
||||
//! monitor profile, on the GPU canvas (matrix/shaper or display 3D LUT) and the CPU canvas
|
||||
//! (an 8-bit transform of the composite).
|
||||
//!
|
||||
//! * The composite is in [`composite_profile`] (the document profile for RGB, its gray curve as
|
||||
|
||||
@@ -1138,13 +1138,37 @@ pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save:
|
||||
/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide.
|
||||
pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
|
||||
let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| {
|
||||
let ext =
|
||||
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
(path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path))))
|
||||
});
|
||||
process_outputs(outputs, save, f)
|
||||
}
|
||||
|
||||
/// The extension a batch output takes with Format "Same": the input's own, except for formats
|
||||
/// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on
|
||||
/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. An input
|
||||
/// whose file name has no extension (a dot in a folder name doesn't count) is saved as PNG.
|
||||
fn same_format(path: &str) -> String {
|
||||
const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm"];
|
||||
let ext = extension(path).unwrap_or_else(|| "png".into());
|
||||
if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn same_format_takes_the_file_names_extension() {
|
||||
assert_eq!(same_format("/in/a.PNG"), "png");
|
||||
assert_eq!(same_format("/in/IMG_0001.ARW"), "psd");
|
||||
assert_eq!(same_format("/in/a.kra"), "psd");
|
||||
assert_eq!(same_format("/in/IMG_0001.HEIC"), "psd");
|
||||
assert_eq!(same_format("/in/logo.svg"), "psd");
|
||||
assert_eq!(same_format("/in/scan.cin"), "psd");
|
||||
// No extension in the file name: PNG, not the path after a dot in a folder name.
|
||||
assert_eq!(same_format("/in/v1.2/scan"), "png");
|
||||
assert_eq!(same_format("C:\\in.d\\scan"), "png");
|
||||
assert_eq!(same_format("/in/.hidden"), "png");
|
||||
}
|
||||
|
||||
fn process_outputs<'a>(outputs: impl IntoIterator<Item = (&'a str, String)>, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
|
||||
let mut files = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
@@ -87,8 +87,7 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V
|
||||
if base.is_empty() || base.ends_with(['.', ' ']) {
|
||||
return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space"));
|
||||
}
|
||||
let ext =
|
||||
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
let ext = if format == "same" { super::same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
let name = format!("{base}.{ext}");
|
||||
if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) {
|
||||
return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters"));
|
||||
|
||||
@@ -65,6 +65,33 @@ fn camera_raw_opens_as_16_bit_with_notes() {
|
||||
assert!(open_bytes_as(&mut s, "bad.dng", &bad, None, None).is_err());
|
||||
}
|
||||
|
||||
/// #5799: Batch with Format "Same" over a folder of camera raws saved nothing (every output was
|
||||
/// a raw PhotoCraft can't write). Raws are saved as PSD; other inputs keep their format.
|
||||
#[test]
|
||||
fn batch_same_format_saves_camera_raws_as_psd() {
|
||||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||||
let dir = tmp("batch-same-raw");
|
||||
let (w, h) = (24, 16);
|
||||
std::fs::write(join(&dir, "IMG_0001.dng"), DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
|
||||
png(&dir, "b.png", 8, 8, "#ff0000");
|
||||
// Also with a file naming template, which picks the output extension separately.
|
||||
for (out, naming) in [("out", None), ("named", Some("{name}_{serial}"))] {
|
||||
let out = join(&dir, out);
|
||||
let mut p = json!({"steps": [["image.adjustments.invert", {}]], "input": dir, "output": out, "format": "same"});
|
||||
if let Some(naming) = naming {
|
||||
p["naming"] = json!(naming);
|
||||
}
|
||||
let r = Session::new().execute("file.automate.batch", p).unwrap();
|
||||
assert!(r["errors"].as_array().unwrap().is_empty(), "{naming:?}: {r}");
|
||||
let mut files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
|
||||
files.sort();
|
||||
assert_eq!(files.len(), 2, "{r}");
|
||||
assert!(files[0].contains("/IMG_0001") && files[0].ends_with(".psd") && files[1].contains("/b") && files[1].ends_with(".png"), "{files:?}");
|
||||
let psd = photocraft_io::import("IMG_0001.psd", &std::fs::read(&files[0]).unwrap()).unwrap().document;
|
||||
assert_eq!((psd.size.width, psd.size.height), (w as u32, h as u32));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_reloads_as_one_undoable_step() {
|
||||
let dir = tmp("revert");
|
||||
|
||||
@@ -530,6 +530,11 @@ pub struct Performance {
|
||||
pub rendering_mode: Option<RenderingMode>,
|
||||
/// Graphics backend (applies at next launch; see [`GpuBackend`]).
|
||||
pub gpu_backend: GpuBackend,
|
||||
/// Which graphics adapter the window and canvas use on a machine with several (applies at
|
||||
/// next launch): part of the adapter's name, matched case-insensitively ("nvidia", "arc"). Empty
|
||||
/// is automatic. `PHOTOCRAFT_GPU_ADAPTER` overrides it for one launch. If nothing matches or the
|
||||
/// match can't present to the window, startup falls back to automatic and says so.
|
||||
pub gpu_adapter: String,
|
||||
/// Live previews of large documents (adjustment and filter dialogs, an adjustment layer's
|
||||
/// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off:
|
||||
/// they render at full resolution.
|
||||
@@ -575,6 +580,7 @@ impl Default for Performance {
|
||||
use_gpu: true,
|
||||
rendering_mode: None,
|
||||
gpu_backend: GpuBackend::Auto,
|
||||
gpu_adapter: String::new(),
|
||||
low_resolution_previews: true,
|
||||
low_latency_drawing: false,
|
||||
linux_display_server: LinuxDisplayServer::Auto,
|
||||
@@ -1064,7 +1070,16 @@ pub fn is_hidden(path: &str) -> bool {
|
||||
|
||||
/// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They
|
||||
/// still load, save and round-trip on every platform.
|
||||
pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer"];
|
||||
pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer", "performance.gpuAdapter"];
|
||||
|
||||
/// Whether a graphics adapter called `name` is the one `performance.gpuAdapter` (`wanted`) asks
|
||||
/// for: every whitespace-separated word of `wanted` appears in the name, ignoring case ("nvidia",
|
||||
/// "arc a370m"). An empty `wanted` matches nothing. Startup and Preferences share this.
|
||||
pub fn gpu_adapter_matches(name: &str, wanted: &str) -> bool {
|
||||
let name = name.to_lowercase();
|
||||
let mut words = wanted.split_whitespace().peekable();
|
||||
words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase()))
|
||||
}
|
||||
|
||||
/// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`).
|
||||
pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
|
||||
@@ -493,6 +493,32 @@ fn right_click_with_painting_tools_pref() {
|
||||
assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_adapter_round_trips_and_defaults_to_automatic() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.prefs().performance.gpu_adapter, "");
|
||||
s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": "NVIDIA"})).unwrap();
|
||||
assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA");
|
||||
assert!(s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": 3})).is_err());
|
||||
assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA");
|
||||
let mut t = Session::new();
|
||||
t.load_prefs_json(&s.prefs_to_json()).unwrap();
|
||||
assert_eq!(t.prefs().performance.gpu_adapter, "NVIDIA");
|
||||
let mut u = Session::new();
|
||||
u.load_prefs_json(r#"{"performance": {"useGpu": true}}"#).unwrap();
|
||||
assert_eq!(u.prefs().performance.gpu_adapter, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_adapter_names_match_by_word_ignoring_case() {
|
||||
let arc = "Intel(R) Arc(tm) A370M Graphics (DG2)";
|
||||
assert!(gpu_adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
|
||||
assert!(gpu_adapter_matches(arc, " ARC A370M "));
|
||||
assert!(!gpu_adapter_matches(arc, "arc a380"));
|
||||
assert!(!gpu_adapter_matches(arc, ""));
|
||||
assert!(!gpu_adapter_matches(arc, " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_backend_round_trips_and_validates() {
|
||||
let mut s = session();
|
||||
@@ -553,7 +579,9 @@ fn notification_autohide_preferences() {
|
||||
fn linux_only_preferences_show_only_on_linux() {
|
||||
assert_eq!(is_hidden("performance.linuxDisplayServer"), !cfg!(target_os = "linux"));
|
||||
assert!(!is_hidden("performance.gpuBackend"));
|
||||
assert!(LINUX_ONLY.iter().all(|p| choices(p).is_some()), "every Linux-only preference is a real one");
|
||||
assert!(LINUX_ONLY.contains(&"performance.gpuAdapter"));
|
||||
let free_text = ["performance.gpuAdapter"];
|
||||
assert!(LINUX_ONLY.iter().all(|p| free_text.contains(p) || choices(p).is_some()), "every Linux-only preference is a real one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,12 +27,21 @@ fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// Largest accepted file (Photoshop's biggest bundled sets are well under this).
|
||||
pub const MAX_FILE_BYTES: u64 = 512 << 20;
|
||||
/// Largest accepted `.abr` file: brush libraries of large sampled tips outgrow [`MAX_FILE_BYTES`].
|
||||
/// Tied to the decoder's sample budget (1 GiB, 512 MiB on 32-bit targets) so it never refuses
|
||||
/// what this accepts.
|
||||
pub const MAX_ABR_FILE_BYTES: u64 = photocraft_psd::abr::MAX_TOTAL_BYTES as u64;
|
||||
|
||||
/// File bytes from `data` (base64) or `path`, plus a default group name.
|
||||
pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, String)> {
|
||||
file_bytes_within(p, cmd, what, MAX_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// [`file_bytes`] with a size cap of `max` bytes.
|
||||
fn file_bytes_within(p: &Value, cmd: &str, what: &str, max: u64) -> Result<(Vec<u8>, String)> {
|
||||
if let Some(d) = p.get("data") {
|
||||
let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?;
|
||||
if s.len() as u64 > MAX_FILE_BYTES / 3 * 4 + 4 {
|
||||
if s.len() as u64 > max / 3 * 4 + 4 {
|
||||
return Err(bad(cmd, "`data` is too large"));
|
||||
}
|
||||
let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?;
|
||||
@@ -41,7 +50,7 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, S
|
||||
let path =
|
||||
p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?;
|
||||
let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len();
|
||||
if len > MAX_FILE_BYTES {
|
||||
if len > max {
|
||||
return Err(bad(cmd, format!("{path}: file too large ({len} bytes)")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
@@ -91,7 +100,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move |ctx| {
|
||||
ctx.progress(0.0, "Importing brushes");
|
||||
let p = ¶ms;
|
||||
let (bytes, stem) = file_bytes(p, cmd, ".abr file")?;
|
||||
let (bytes, stem) = file_bytes_within(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?;
|
||||
let group = match p.get("group") {
|
||||
Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(),
|
||||
None if !stem.is_empty() => stem,
|
||||
@@ -588,6 +597,29 @@ mod tests {
|
||||
assert_eq!(r["imported"], json!(["Speckle", "SPECKLE 2", "speckle 4", "Speckle 2 2", "Speckle 5", "hard round 2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abr_files_get_a_larger_size_cap() {
|
||||
// 1 GiB on 64-bit; 32-bit (wasm) keeps 512 MiB so a big file fails cleanly instead of aborting.
|
||||
const WANT: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 };
|
||||
const _: () = assert!(MAX_ABR_FILE_BYTES == WANT && MAX_ABR_FILE_BYTES >= MAX_FILE_BYTES);
|
||||
let dir = std::env::temp_dir().join(format!("pc-abr-cap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("cap.abr");
|
||||
std::fs::write(&path, [0u8; 10]).unwrap();
|
||||
let p = json!({"path": path.to_string_lossy()});
|
||||
assert!(file_bytes_within(&p, "t", "f", 10).is_ok());
|
||||
assert!(file_bytes_within(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading");
|
||||
// A sparse file between the two caps: refused as a .grd, which is checked before reading.
|
||||
let big = dir.join("big.grd");
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap();
|
||||
let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err();
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
let data = json!({"data": "A".repeat(16)});
|
||||
assert!(file_bytes_within(&data, "t", "f", 12).is_ok());
|
||||
assert!(file_bytes_within(&data, "t", "f", 8).is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names() {
|
||||
let mut taken = UniqueNames::new(["A", "a 2"]);
|
||||
|
||||
@@ -90,6 +90,7 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
|
||||
return app(sp);
|
||||
}
|
||||
if let Some(name) = system_spooler() {
|
||||
require_windows_pdf()?;
|
||||
return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other);
|
||||
}
|
||||
}
|
||||
@@ -645,6 +646,34 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spool
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows prints a PDF through Windows.Data.Pdf.dll, which the apps delay-load (see their
|
||||
/// `build.rs`) so a Windows image without it still starts. Calling into it when it cannot load
|
||||
/// would abort the process, so check that it loads first (#5884).
|
||||
fn require_windows_pdf() -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows"));
|
||||
if !windows_pdf_loads(&windows_pdf_dll(std::path::Path::new(&root))) {
|
||||
return Err(other(
|
||||
"printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is missing or cannot load. Save as PDF and print it from another app, or print an RGB image without page text or labels to print directly.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_pdf_dll(system_root: &std::path::Path) -> std::path::PathBuf {
|
||||
system_root.join("System32").join("Windows.Data.Pdf.dll")
|
||||
}
|
||||
|
||||
/// Loads the DLL rather than checking the file exists: one that is present but cannot load (a
|
||||
/// dependency stripped too) would still abort at the delay-load stub.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_pdf_loads(dll: &std::path::Path) -> bool {
|
||||
dll.to_str().is_some_and(|p| winsafe::HINSTANCE::LoadLibrary(p).is_ok())
|
||||
}
|
||||
|
||||
/// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently
|
||||
/// draws RGB/gray and vector marks only; it must never silently omit requested page content.
|
||||
fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool {
|
||||
@@ -708,6 +737,61 @@ fn printer_settings(p: &Value) -> Result<Option<craft_print::rgb::DevMode>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `dmFields` and `dmOrientation` in DEVMODEW (wingdi.h: after the 32-wchar device name and four
|
||||
/// header words), and the `dmFields` bit that says `dmOrientation` is set.
|
||||
const DM_FIELDS_AT: usize = 72;
|
||||
const DM_ORIENTATION_AT: usize = 76;
|
||||
const DM_ORIENTATION: u32 = 0x1;
|
||||
/// `DMORIENT_PORTRAIT` and `DMORIENT_LANDSCAPE`.
|
||||
const DMORIENT_PORTRAIT: i16 = 1;
|
||||
const DMORIENT_LANDSCAPE: i16 = 2;
|
||||
|
||||
/// Driver settings turned to portrait or landscape, as Photoshop's Layout buttons turn them:
|
||||
/// `dmOrientation` set and flagged in `dmFields`, every other byte (the driver's private settings
|
||||
/// included) kept. The driver validates the result when it lays out the page, as it does for
|
||||
/// settings from Print Settings….
|
||||
pub fn turned_settings(settings: &craft_print::rgb::DevMode, landscape: bool) -> Result<craft_print::rgb::DevMode> {
|
||||
let mut b = settings.as_bytes().to_vec();
|
||||
let short = || other("printer settings are too short to hold an orientation");
|
||||
let fields: [u8; 4] = b.get(DM_FIELDS_AT..DM_FIELDS_AT + 4).and_then(|s| s.try_into().ok()).ok_or_else(short)?;
|
||||
let fields = u32::from_le_bytes(fields) | DM_ORIENTATION;
|
||||
b.get_mut(DM_FIELDS_AT..DM_FIELDS_AT + 4).ok_or_else(short)?.copy_from_slice(&fields.to_le_bytes());
|
||||
let orientation = if landscape { DMORIENT_LANDSCAPE } else { DMORIENT_PORTRAIT };
|
||||
b.get_mut(DM_ORIENTATION_AT..DM_ORIENTATION_AT + 2).ok_or_else(short)?.copy_from_slice(&orientation.to_le_bytes());
|
||||
craft_print::rgb::DevMode::from_bytes(b).map_err(other)
|
||||
}
|
||||
|
||||
/// Whether a driver page of `driver` points is the layout's `paper` turned the other way round
|
||||
/// (within the 2 pt the driver and layout may differ by): then turning the driver settings, not
|
||||
/// choosing another paper, makes them agree. A square paper is never turned.
|
||||
fn turned_paper(driver: (f64, f64), paper: (f64, f64)) -> bool {
|
||||
let near = |a: f64, b: f64| (a - b).abs() <= 2.0;
|
||||
let same = near(driver.0, paper.0) && near(driver.1, paper.1);
|
||||
!same && near(driver.0, paper.1) && near(driver.1, paper.0)
|
||||
}
|
||||
|
||||
/// The driver settings and page for printing a layout of `paper` points on `printer`: the given
|
||||
/// settings (or the printer's own) as they are, or turned to the layout's orientation when the
|
||||
/// driver has that paper the other way round. A landscape layout so prints landscape with portrait
|
||||
/// Print Settings, as a script or another app asking for landscape expects.
|
||||
fn settings_for_layout(
|
||||
printer: &str,
|
||||
settings: Option<craft_print::rgb::DevMode>,
|
||||
paper: (f64, f64),
|
||||
) -> Result<(Option<craft_print::rgb::DevMode>, craft_print::rgb::Page)> {
|
||||
let page = craft_print::rgb::page(printer, settings.as_ref()).map_err(other)?;
|
||||
if !turned_paper(page.paper, paper) {
|
||||
return Ok((settings, page));
|
||||
}
|
||||
let base = match settings {
|
||||
Some(s) => s,
|
||||
None => craft_print::rgb::default_settings(printer).map_err(other)?,
|
||||
};
|
||||
let turned = turned_settings(&base, paper.0 > paper.1)?;
|
||||
let page = craft_print::rgb::page(printer, Some(&turned)).map_err(other)?;
|
||||
Ok((Some(turned), page))
|
||||
}
|
||||
|
||||
/// The page's image as 8-bit RGB rows (gray expanded), mirrored for Emulsion Down.
|
||||
pub fn page_rgb(page: &PrintPage) -> Result<Vec<u8>> {
|
||||
let img = &page.image;
|
||||
@@ -742,8 +826,7 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) {
|
||||
/// PDF-only (no font/text layout in the GDI path yet).
|
||||
fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Result<Value> {
|
||||
let printer = printer_name(p)?;
|
||||
let settings = printer_settings(p)?;
|
||||
let device_page = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?;
|
||||
let (settings, device_page) = settings_for_layout(&printer, printer_settings(p)?, page.paper)?;
|
||||
if (device_page.paper.0 - page.paper.0).abs() > 2.0 || (device_page.paper.1 - page.paper.1).abs() > 2.0 {
|
||||
return Err(other("the driver paper or orientation differs from the print layout; choose the matching paper in Print Settings before printing"));
|
||||
}
|
||||
@@ -780,11 +863,31 @@ fn printers(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn printer_page(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Checked before the printer, so a bad value is the error whether or not a printer answers.
|
||||
let landscape = match p.get("orientation").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("portrait") => Some(false),
|
||||
Some("landscape") => Some(true),
|
||||
_ => return Err(bad("file.print.page", "orientation is \"portrait\" or \"landscape\"")),
|
||||
},
|
||||
};
|
||||
let printer = printer_name(p)?;
|
||||
let pg = craft_print::rgb::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?;
|
||||
Ok(
|
||||
json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}),
|
||||
)
|
||||
let mut settings = printer_settings(p)?;
|
||||
if let Some(landscape) = landscape {
|
||||
let base = match settings {
|
||||
Some(s) => s,
|
||||
None => craft_print::rgb::default_settings(&printer).map_err(other)?,
|
||||
};
|
||||
settings = Some(turned_settings(&base, landscape)?);
|
||||
}
|
||||
let pg = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?;
|
||||
let mut out = json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]});
|
||||
// The turned settings, to pass on as `printerSettings` (the Print dialog's Orientation does).
|
||||
if let (Some(_), Some(s), Some(o)) = (landscape, settings, out.as_object_mut()) {
|
||||
o.insert("printerSettings".into(), json!(s.to_hex()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer.
|
||||
@@ -1014,7 +1117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Printer Page",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"printer":name? (default printer),"printerSettings":hex?} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y]} (Windows)"##,
|
||||
params: r##"{"printer":name? (default printer),"printerSettings":hex?,"orientation":"portrait|landscape"? (turn the settings, or the printer's own, that way)} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y], printerSettings: hex (only with orientation: the turned settings)} (Windows)"##,
|
||||
enabled: |_| Ok(()),
|
||||
journal: false,
|
||||
run: printer_page,
|
||||
|
||||
@@ -207,6 +207,79 @@ fn printer_commands_answer_without_a_document_and_reject_bad_settings() {
|
||||
assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err());
|
||||
}
|
||||
|
||||
/// Synthetic driver settings: a DEVMODEW of `size` bytes plus `extra` private driver bytes, every
|
||||
/// byte a pattern so a stray write shows, with `dmFields` saying only the paper size is set.
|
||||
fn devmode(size: u16, extra: u16) -> Vec<u8> {
|
||||
let mut b: Vec<u8> = (0..usize::from(size) + usize::from(extra)).map(|i| (i % 251) as u8).collect();
|
||||
b[68..70].copy_from_slice(&size.to_le_bytes());
|
||||
b[70..72].copy_from_slice(&extra.to_le_bytes());
|
||||
b[72..76].copy_from_slice(&0x2u32.to_le_bytes()); // DM_PAPERSIZE
|
||||
b
|
||||
}
|
||||
|
||||
/// Photoshop's Layout buttons: driver settings turned to landscape or portrait set `dmOrientation`
|
||||
/// and its `dmFields` bit, and keep every other byte, the driver's private settings included.
|
||||
#[test]
|
||||
fn turned_settings_set_the_orientation_and_keep_the_rest() {
|
||||
for (size, extra) in [(220u16, 0u16), (220, 48)] {
|
||||
let before = devmode(size, extra);
|
||||
let dm = craft_print::rgb::DevMode::from_bytes(before.clone()).unwrap();
|
||||
for (landscape, orient) in [(true, 2i16), (false, 1i16)] {
|
||||
let after = turned_settings(&dm, landscape).unwrap().as_bytes().to_vec();
|
||||
assert_eq!(after.len(), before.len());
|
||||
assert_eq!(i16::from_le_bytes([after[76], after[77]]), orient, "{size}+{extra} landscape={landscape}");
|
||||
assert_eq!(u32::from_le_bytes([after[72], after[73], after[74], after[75]]), 0x3, "DM_ORIENTATION added, DM_PAPERSIZE kept");
|
||||
for (i, (a, b)) in after.iter().zip(&before).enumerate() {
|
||||
if !(72..76).contains(&i) && !(76..78).contains(&i) {
|
||||
assert_eq!(a, b, "byte {i} changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver's paper is turned only when it is the layout's paper the other way round (within
|
||||
/// the 2 pt a driver's paper may differ by): not for the same paper, another paper or a square one.
|
||||
#[test]
|
||||
fn only_the_same_paper_the_other_way_round_is_turned() {
|
||||
let (a3, a3_wide) = ((842.29, 1191.14), (1190.55, 841.89));
|
||||
assert!(turned_paper(a3, a3_wide), "portrait driver, landscape layout");
|
||||
assert!(turned_paper((1191.14, 842.29), (841.89, 1190.55)), "landscape driver, portrait layout");
|
||||
assert!(!turned_paper(a3, (841.89, 1190.55)), "same orientation");
|
||||
assert!(!turned_paper((595.32, 841.92), a3_wide), "an A4 driver for an A3 layout");
|
||||
assert!(!turned_paper((612.0, 612.0), (612.0, 612.0)), "square");
|
||||
}
|
||||
|
||||
/// `file.print.page` takes an orientation and refuses any other value before asking a printer, so
|
||||
/// the error is the same with or without one.
|
||||
#[test]
|
||||
fn printer_page_refuses_an_unknown_orientation() {
|
||||
let mut s = Session::new();
|
||||
for bad in [json!("sideways"), json!(2), json!(true)] {
|
||||
let e = s.execute("file.print.page", json!({"printer": "Anything", "orientation": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("orientation"), "{bad}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A real driver turns: Windows' "Microsoft Print to PDF" on its default (portrait) settings, for a
|
||||
/// landscape A4 layout, gets settings turned to landscape and a wide page; a portrait layout keeps
|
||||
/// the driver's own settings. Needs that printer, so it runs by hand:
|
||||
/// `cargo test -p photocraft-engine --lib a_portrait_driver -- --ignored`.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn a_portrait_driver_turns_for_a_landscape_layout() {
|
||||
let printer = "Microsoft Print to PDF";
|
||||
let defaults = craft_print::rgb::default_settings(printer).unwrap();
|
||||
let a4 = craft_print::rgb::page(printer, Some(&defaults)).unwrap().paper;
|
||||
let (settings, page) = settings_for_layout(printer, None, (a4.1, a4.0)).unwrap();
|
||||
assert!(settings.is_some(), "turned settings come back");
|
||||
assert!((page.paper.0 - a4.1).abs() <= 2.0 && (page.paper.1 - a4.0).abs() <= 2.0, "wide: {:?}", page.paper);
|
||||
let (settings, page) = settings_for_layout(printer, None, a4).unwrap();
|
||||
assert!(settings.is_none(), "nothing to turn");
|
||||
assert!((page.paper.0 - a4.0).abs() <= 2.0, "{:?}", page.paper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_profile_can_be_an_installed_icc_file() {
|
||||
let dir = tmp("icc");
|
||||
@@ -772,3 +845,27 @@ mod cups {
|
||||
assert_eq!(jobs, 2, "both prints reached the fake CUPS");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_pdf_component_is_detected_by_loading_its_dll() {
|
||||
// #5884: a Windows image without a loadable Windows.Data.Pdf.dll reports an error instead of
|
||||
// aborting at the delay-load stub.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dll = windows_pdf_dll(dir.path());
|
||||
assert!(!windows_pdf_loads(&dll));
|
||||
// Present but not loadable counts as missing.
|
||||
std::fs::create_dir(dir.path().join("System32")).unwrap();
|
||||
std::fs::write(&dll, b"MZ").unwrap();
|
||||
assert!(!windows_pdf_loads(&dll));
|
||||
let root = std::env::var_os("SystemRoot").unwrap();
|
||||
// The real DLL may be missing or unloadable on a stripped image; the check follows the loader.
|
||||
let system = windows_pdf_dll(std::path::Path::new(&root));
|
||||
assert_eq!(require_windows_pdf().is_ok(), windows_pdf_loads(&system));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn the_windows_pdf_check_is_a_no_op_elsewhere() {
|
||||
assert!(require_windows_pdf().is_ok());
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use photocraft_geom::Rect;
|
||||
use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke};
|
||||
use photocraft_paint::{BrushSettings, Stroke, StrokePoint};
|
||||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||||
use rayon::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, blend_from_str};
|
||||
@@ -1042,19 +1043,26 @@ impl DabTool {
|
||||
let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize);
|
||||
let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) };
|
||||
let rw = r.width() as usize;
|
||||
for y in r.y0..r.y1 {
|
||||
// Row-parallel, like the blur itself (#5426).
|
||||
let (wx0, wy0) = (work.rect.x0, work.rect.y0);
|
||||
work.data.par_chunks_mut(w * n).enumerate().for_each(|(row, line)| {
|
||||
let y = wy0 + row as i32;
|
||||
if y < r.y0 || y >= r.y1 {
|
||||
return;
|
||||
}
|
||||
for x in r.x0..r.x1 {
|
||||
let k = per_dab(fp.at(x, y) * strength, sp);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n;
|
||||
let px = work.px_mut(x, y);
|
||||
let i = (x - wx0) as usize * n;
|
||||
let px = &mut line[i..i + n];
|
||||
for c in 0..n {
|
||||
px[c] += (out[o + c] - px[c]) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
Self::Smudge { strength, finger, max_size } => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//!
|
||||
//! Options (defaults in brackets): `--brushes N` [600], `--edge PX` largest tip edge [2000],
|
||||
//! `--min-edge PX` smallest tip edge [800], `--depth 8|16` [16], `--per-file N` presets per file [100],
|
||||
//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's 512 MB decoded cap.
|
||||
//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's decoded cap (1 GiB; 512 MiB on 32-bit).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use photocraft_paint::procedural::*;
|
||||
|
||||
@@ -22,11 +22,12 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
return import_tiff_page(name, bytes, None);
|
||||
}
|
||||
let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?;
|
||||
// A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop
|
||||
// (#3802, #4804).
|
||||
// A palette BMP, PNG or GIF opens in Indexed Color mode with its colour table, as in
|
||||
// Photoshop (#3802, #4804, #7886). A GIF is always palette-based: the first frame's table.
|
||||
let palette = match format {
|
||||
Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)),
|
||||
Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)),
|
||||
Some(Format::Gif) => codecs::gif_palette(bytes),
|
||||
_ => None,
|
||||
};
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
|
||||
+25
-15
@@ -1597,26 +1597,36 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
ex.used_ids = used;
|
||||
// Text objects' number in the generated `Txt2` (#1348): a preserved `TySh` keeps the
|
||||
// number it already names — its `Txt2` object must sit at that slot for `apply_txt2` —
|
||||
// and new layers get the next free number.
|
||||
// and new layers get numbers past every kept one and every object in the file's `Txt2`.
|
||||
// Kept numbers are claimed first: a layer added below others must not take a number a
|
||||
// layer above it keeps, or each of those would be renumbered onto its neighbour's object
|
||||
// and take its style (#7773); nor may it land on a deleted layer's object.
|
||||
let mut used_txt = std::collections::HashSet::new();
|
||||
let mut next_txt = 0i32;
|
||||
ex.text_index = Default::default();
|
||||
let mut fresh = Vec::new();
|
||||
for (_, _, l) in &walk {
|
||||
let LayerContent::Text(t) = &l.content else {
|
||||
continue;
|
||||
};
|
||||
let kept = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d));
|
||||
let index = match kept {
|
||||
Some(i) if used_txt.insert(i) => i,
|
||||
_ => {
|
||||
while used_txt.contains(&next_txt) {
|
||||
next_txt += 1;
|
||||
}
|
||||
used_txt.insert(next_txt);
|
||||
next_txt
|
||||
match t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)) {
|
||||
Some(i) if used_txt.insert(i) => {
|
||||
ex.text_index.insert(l.id, i);
|
||||
}
|
||||
};
|
||||
ex.text_index.insert(l.id, index);
|
||||
_ => fresh.push(l.id),
|
||||
}
|
||||
}
|
||||
let objects =
|
||||
doc.metadata.psd_global_blocks.iter().find(|(_, key, _)| key == b"Txt2").map_or(0, |(_, _, d)| photocraft_text::psd::txt2_object_count(d));
|
||||
let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1).max(i32::try_from(objects).unwrap_or(i32::MAX));
|
||||
if next_txt.saturating_add(fresh.len() as i32) > photocraft_text::psd::MAX_TEXT_INDEX + 1 {
|
||||
next_txt = 0;
|
||||
}
|
||||
for id in fresh {
|
||||
while used_txt.contains(&next_txt) {
|
||||
next_txt += 1;
|
||||
}
|
||||
used_txt.insert(next_txt);
|
||||
ex.text_index.insert(id, next_txt);
|
||||
}
|
||||
}
|
||||
// Progress: one unit per layer record, then the merged composite (#3389).
|
||||
@@ -1745,8 +1755,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
// writes its empty `<< /0 << >> >>` stub — readers such as Photopea reject it (#3514). Keep the
|
||||
// block when the file had one (round-trip its pen positions/extras) or a layer is Optical (the
|
||||
// one mode EngineData can't carry; #1348).
|
||||
let txt2 =
|
||||
(previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)).then(|| photocraft_text::psd::build_txt2(&text_layers, previous.as_deref()));
|
||||
let txt2 = (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers))
|
||||
.then(|| photocraft_text::psd::build_txt2_at(&text_layers, previous.as_deref(), ex.dpi));
|
||||
for (sig, key, data) in preserved {
|
||||
if txt2.is_some() && key == *b"Txt2" {
|
||||
continue;
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use photocraft_cms::{Builtin, Intent, Profile, Transform};
|
||||
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType};
|
||||
use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance};
|
||||
pub use photocraft_raw::{Tone, retone};
|
||||
pub use photocraft_raw::{EXTENSIONS, Tone, retone};
|
||||
|
||||
use crate::flat::image_to_document;
|
||||
use crate::{ImportResult, IoError};
|
||||
|
||||
@@ -118,6 +118,60 @@ fn indexed_png_opens_with_its_color_table() {
|
||||
assert_eq!(s.rgba(0, 1)[3], 0.0, "the tRNS entry is transparent");
|
||||
}
|
||||
|
||||
/// A 2 × 2 GIF89a of palette indices `idx` over `pal` (padded to 4 entries), with `transparent`
|
||||
/// set through a Graphic Control Extension. Its LZW stream clears before every pixel, so every
|
||||
/// code stays 3 bits: the simplest valid stream any decoder reads.
|
||||
fn indexed_gif(pal: &[[u8; 3]; 4], idx: [u8; 4], transparent: Option<u8>) -> Vec<u8> {
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
b.extend([2, 0, 2, 0, 0x81, 0, 0]); // 2 × 2, global table of 4
|
||||
pal.iter().for_each(|c| b.extend(c));
|
||||
if let Some(t) = transparent {
|
||||
b.extend([0x21, 0xF9, 4, 0x01, 0, 0, t, 0]);
|
||||
}
|
||||
b.extend([0x2C, 0, 0, 0, 0, 2, 0, 2, 0, 0]);
|
||||
let (clear, end) = (4u32, 5u32);
|
||||
let codes: Vec<u32> = idx.iter().flat_map(|&i| [clear, u32::from(i)]).chain([end]).collect();
|
||||
let (mut bits, mut n, mut data) = (0u32, 0u32, Vec::new());
|
||||
for c in codes {
|
||||
bits |= c << n;
|
||||
n += 3;
|
||||
while n >= 8 {
|
||||
data.push((bits & 0xFF) as u8);
|
||||
bits >>= 8;
|
||||
n -= 8;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
data.push(bits as u8);
|
||||
}
|
||||
b.push(2); // LZW minimum code size
|
||||
b.push(data.len() as u8);
|
||||
b.extend(&data);
|
||||
b.extend([0, 0x3B]);
|
||||
b
|
||||
}
|
||||
|
||||
/// An indexed GIF opens in Indexed Color mode with its colour table and transparent entry, as a
|
||||
/// palette PNG does (#7886); it used to open as RGB.
|
||||
#[test]
|
||||
fn indexed_gif_opens_with_its_color_table() {
|
||||
let pal = [[10, 20, 30], [200, 100, 50], [7, 8, 9], [0, 255, 0]];
|
||||
let doc = import("indexed.gif", &indexed_gif(&pal, [1, 0, 2, 1], Some(2))).unwrap().document;
|
||||
assert_eq!(doc.mode, ColorMode::Indexed);
|
||||
assert_eq!(doc.depth, SampleType::U8);
|
||||
let table = doc.color_table.as_ref().unwrap();
|
||||
assert_eq!(table.colors, pal, "every entry, used or not");
|
||||
assert_eq!(table.transparent, Some(2));
|
||||
let s = doc.layers[0].surface().unwrap();
|
||||
let px = s.rgba(0, 0);
|
||||
assert!((px[0] - 200.0 / 255.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6, "{px:?}");
|
||||
assert_eq!(s.rgba(0, 1)[3], 0.0, "the transparent entry is transparent");
|
||||
// Without a transparent entry, the table alone.
|
||||
let doc = import("opaque.gif", &indexed_gif(&pal, [3, 3, 0, 1], None)).unwrap().document;
|
||||
assert_eq!(doc.mode, ColorMode::Indexed);
|
||||
assert_eq!(doc.color_table.as_ref().unwrap().transparent, None);
|
||||
}
|
||||
|
||||
/// FITS header cards travel as the document's text metadata, so an astro frame keeps its
|
||||
/// OBJECT, EXPTIME and the rest through an edit.
|
||||
#[test]
|
||||
|
||||
@@ -144,6 +144,154 @@ fn duplicate_preserved_text_indices_get_distinct_txt2_objects() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A type layer added below preserved ones takes a number past theirs: claiming the next free
|
||||
/// number on the way up renumbered every layer above it onto its neighbour's `Txt2` object
|
||||
/// (#7773, 70 of 74 layers on the #7698 file).
|
||||
#[test]
|
||||
fn new_text_layer_below_keeps_the_preserved_text_indices() {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
for (i, text) in ["AV", "BC"].into_iter().enumerate() {
|
||||
let mut t = layer(text, &[(2, Kerning::Optical, 0.0)]);
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, i as i32).unwrap()));
|
||||
doc.layers.push(Layer::new(text, LayerContent::Text(t)));
|
||||
}
|
||||
let mut t = layer("new", &[(3, Kerning::Metrics, 0.0)]);
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
doc.layers.insert(0, Layer::new("new", LayerContent::Text(t)));
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("k.psd", &bytes).unwrap().document;
|
||||
let indices: Vec<_> = back
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let LayerContent::Text(t) = &l.content else { panic!("not text") };
|
||||
(l.name.as_str(), t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(indices, [("new", Some(2)), ("AV", Some(0)), ("BC", Some(1))]);
|
||||
}
|
||||
|
||||
/// A Photoshop-shaped `Txt2` block: one object per `(text, style keys)` pair, each with its view
|
||||
/// naming its own frame.
|
||||
fn photoshop_txt2(objects: &[(&str, &str)]) -> Vec<u8> {
|
||||
let bom = "\u{fe}\u{ff}";
|
||||
let frame = "<< /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ";
|
||||
let mut frames = String::new();
|
||||
let mut body = String::new();
|
||||
for (i, (text, style)) in objects.iter().enumerate() {
|
||||
let utf16: String = format!("{text}\r").chars().flat_map(|c| ['\0', c]).collect();
|
||||
let len = text.len() + 1;
|
||||
frames.push_str(frame);
|
||||
body.push_str(&format!(
|
||||
"<< /0 << /0 ({bom}{utf16}) /5 << /0 [ << /0 << /0 << /0 ({bom}) /5 << /7 1.2 >> >> >> /1 {len} >> ] >> /6 << /0 [ << /0 << /0 << /0 ({bom}) /6 << /0 0 /1 12.0 /11 1 /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 0.0 ] >> >> {style} >> >> >> /1 {len} >> ] >> >> /1 << /0 [ << /0 {i} >> ] /1 << /0 [ << /0 << /1 1 >> /1 {len} >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 {i} >> ] >> ] >> >> "
|
||||
));
|
||||
}
|
||||
let font: String = "MyriadPro-Regular".chars().flat_map(|c| ['\0', c]).collect();
|
||||
format!("\n\n/98 << /0 7 >> /0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 ({bom}{font}) /2 1 >> >> >> ] >> /8 << /0 [ {frames}] >> >> /1 << /0 << /0 1 >> /1 [ {body}] /2 << /0 7 >> >>").chars().map(|c| c as u32 as u8).collect()
|
||||
}
|
||||
|
||||
/// A reopened type layer: its name, `TextIndex` and font features.
|
||||
type Reopened = (String, Option<i32>, Vec<String>);
|
||||
|
||||
/// A Photoshop file whose `Txt2` holds `objects` and whose type layers are `kept` (text and
|
||||
/// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one
|
||||
/// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's
|
||||
/// index and font features (but `calt`, on by default and written on as Photoshop does), and
|
||||
/// "Hi"'s style run in the saved `Txt2`.
|
||||
fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec<Reopened>, E) {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
for (text, index) in kept {
|
||||
let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]);
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, *index).unwrap()));
|
||||
doc.layers.push(Layer::new(*text, LayerContent::Text(t)));
|
||||
}
|
||||
doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))];
|
||||
let mut t = layer("Hi", &[(2, Kerning::Metrics, 0.0)]);
|
||||
t.runs[0].style.size_pt = 30.0;
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None)));
|
||||
doc.layers.insert(at, Layer::new("Hi", LayerContent::Text(t)));
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap();
|
||||
let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).unwrap();
|
||||
let back = photocraft_io::import("k.psd", &bytes).unwrap().document;
|
||||
let layers: Vec<_> = back
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let LayerContent::Text(t) = &l.content else { panic!("not text") };
|
||||
let index = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d));
|
||||
(l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect())
|
||||
})
|
||||
.collect();
|
||||
let hi = layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap() as usize;
|
||||
let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone();
|
||||
(layers, run)
|
||||
}
|
||||
|
||||
/// The layer `name` in `add_type_layer`'s result.
|
||||
fn named<'a>(layers: &'a [Reopened], name: &str) -> &'a Reopened {
|
||||
layers.iter().find(|l| l.0 == name).unwrap()
|
||||
}
|
||||
|
||||
/// A new type layer takes the structure of one of the file's objects but not its style: the
|
||||
/// template's style runs carried its font features (here `salt` and `ss01`) into the new layer.
|
||||
/// Its style run is the template's complete one (Photoshop quit on a run holding only the
|
||||
/// auto-kern mode, and reads the character style from it) with the layer's own size.
|
||||
#[test]
|
||||
fn new_text_layer_takes_no_style_from_the_txt2_template() {
|
||||
let (layers, run) = add_type_layer(&[("AV", ""), ("BC", "/28 true /85 1")], &[("AV", 0), ("BC", 1)], 2);
|
||||
assert_eq!(named(&layers, "Hi").1..=named(&layers, "Hi").1, Some(2)..=Some(2));
|
||||
assert!(named(&layers, "Hi").2.is_empty(), "{layers:?}");
|
||||
let style = run.path(&["0", "0", "6"]).unwrap();
|
||||
assert_eq!(style.get("1").and_then(E::as_f64), Some(30.0), "the layer's own size");
|
||||
assert_eq!(style.get("28"), Some(&E::Bool(false)));
|
||||
assert_eq!(style.path(&["53", "0", "0"]), Some(&E::Int(1)), "the layer's fill colour, RGB");
|
||||
assert_eq!(style.get("11"), Some(&E::Int(1)));
|
||||
}
|
||||
|
||||
/// A new type layer is numbered past the file's `Txt2` objects, not only past the kept indices:
|
||||
/// a deleted type layer's object ("Gone") is not reused with its style, whether it held the
|
||||
/// highest index or index 0 (the Type tool's layer used to bring a `TextIndex` 0 of its own).
|
||||
#[test]
|
||||
fn new_text_layer_skips_a_deleted_layers_txt2_object() {
|
||||
let (layers, _) = add_type_layer(&[("AV", ""), ("BC", ""), ("Gone", "/28 true")], &[("AV", 0), ("BC", 1)], 2);
|
||||
assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(3), true));
|
||||
let (layers, _) = add_type_layer(&[("Gone", "/28 true"), ("AV", ""), ("BC", "")], &[("AV", 1), ("BC", 2)], 2);
|
||||
assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(3), true));
|
||||
}
|
||||
|
||||
/// A type layer added below the one holding index 0 neither takes that index nor its style.
|
||||
#[test]
|
||||
fn new_text_layer_at_the_bottom_keeps_index_0_where_it_was() {
|
||||
let (layers, _) = add_type_layer(&[("AV", "/28 true"), ("BC", "")], &[("AV", 0), ("BC", 1)], 0);
|
||||
assert_eq!(named(&layers, "AV").1, Some(0));
|
||||
assert_eq!(named(&layers, "AV").2, ["salt"]);
|
||||
assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(2), true));
|
||||
}
|
||||
|
||||
/// An edited object takes the layer's style, not the old text's: "Hello world" with Stylistic
|
||||
/// Alternates on "Hello " edited to "world" without them comes back without `salt`.
|
||||
#[test]
|
||||
fn edited_text_layer_takes_its_own_style_runs() {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let mut t = layer("world", &[(5, Kerning::Metrics, 0.0)]);
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, 0).unwrap()));
|
||||
doc.layers.push(Layer::new("edited", LayerContent::Text(t)));
|
||||
doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(&[("Hello world", "/28 true")])))];
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("k.psd", &bytes).unwrap().document;
|
||||
let LayerContent::Text(t) = &back.layers[0].content else { panic!("not text") };
|
||||
assert_eq!(t.text, "world");
|
||||
assert!(t.char_runs().iter().all(|r| r.style.features.iter().all(|f| f.tag == "calt")), "the old text's salt came back");
|
||||
}
|
||||
|
||||
/// A file-controlled `TextIndex` must not size the save: `i32::MAX` is ignored (the regenerated
|
||||
/// `Txt2` is sized by real text objects, never by a file's numbers) and the document still saves
|
||||
/// and reopens with its text.
|
||||
@@ -186,6 +334,9 @@ fn txt2_extras_survive_a_resave_of_unchanged_text() {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let mut r = t.clone();
|
||||
photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format());
|
||||
// The file's layer: its `TySh` names object 0.
|
||||
let raw = photocraft_text::psd::build_tysh(&r, doc.resolution_dpi, None);
|
||||
r.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, 0).unwrap()));
|
||||
doc.layers.push(Layer::new("kept", LayerContent::Text(r)));
|
||||
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"Txt2", std::sync::Arc::new(prev.clone())));
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
|
||||
@@ -26,11 +26,15 @@ pub struct MixerSettings {
|
||||
pub flow: f32,
|
||||
/// Pick up colour from all visible layers instead of the target layer only.
|
||||
pub sample_all_layers: bool,
|
||||
/// Load the brush with the stroke color after each stroke (the automatic Load option).
|
||||
pub load_after_stroke: bool,
|
||||
/// Empty the brush after each stroke (the automatic Clean option).
|
||||
pub clean_after_stroke: bool,
|
||||
}
|
||||
|
||||
impl Default for MixerSettings {
|
||||
fn default() -> Self {
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false }
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,18 +48,47 @@ pub struct MixerState {
|
||||
pub amount: f32,
|
||||
/// Colour picked up from the canvas (straight RGBA), None = clean.
|
||||
pub pickup: Option<[f32; 4]>,
|
||||
/// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush
|
||||
/// (the foreground). None = no stroke, load or clean yet.
|
||||
pub color: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl MixerState {
|
||||
/// Load the reservoir with a colour (full).
|
||||
/// Load Brush: fill the reservoir with a color (full).
|
||||
pub fn load(&mut self, color: [f32; 4]) {
|
||||
self.reservoir = Some(color);
|
||||
self.amount = 1.0;
|
||||
}
|
||||
/// Clean the brush (drop the picked-up colour).
|
||||
/// Clean Brush: empty the reservoir and the pickup well.
|
||||
pub fn clean(&mut self) {
|
||||
self.reservoir = None;
|
||||
self.amount = 0.0;
|
||||
self.pickup = None;
|
||||
}
|
||||
/// Before a stroke in `color`: choosing a color other than [`MixerState::color`] loads the
|
||||
/// reservoir with it.
|
||||
pub fn begin_stroke(&mut self, color: [f32; 4]) {
|
||||
if self.color != Some(color) {
|
||||
self.load(color);
|
||||
self.color = Some(color);
|
||||
}
|
||||
}
|
||||
/// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load
|
||||
/// fills the reservoir again.
|
||||
pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) {
|
||||
if clean {
|
||||
self.clean();
|
||||
}
|
||||
if load {
|
||||
self.load(color);
|
||||
}
|
||||
self.color = Some(color);
|
||||
}
|
||||
/// The reservoir color the next stroke in `color` starts with (the Current Brush Load
|
||||
/// swatch); None = a clean brush.
|
||||
pub fn next_load(&self, color: [f32; 4]) -> Option<[f32; 4]> {
|
||||
if self.color != Some(color) { Some(color) } else { self.reservoir }
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself
|
||||
@@ -209,6 +242,48 @@ mod tests {
|
||||
assert!(st.pickup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_brush_paints_only_the_canvas_color_it_picks_up() {
|
||||
let mut st = MixerState::default();
|
||||
st.load([0.0, 0.0, 1.0, 1.0]);
|
||||
st.pickup = Some([0.0, 1.0, 0.0, 1.0]);
|
||||
st.clean();
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (None, 0.0, None));
|
||||
// On red, the clean wet brush smears red and lays down none of the blue it was loaded with.
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false);
|
||||
let c = s.rgba(100, 10);
|
||||
assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_color_loads_the_brush_and_the_automatic_options_run_after_the_stroke() {
|
||||
let (blue, green) = ([0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
||||
let mut st = MixerState::default();
|
||||
// A brush never loaded takes the stroke's color.
|
||||
assert_eq!(st.next_load(blue), Some(blue));
|
||||
st.begin_stroke(blue);
|
||||
assert_eq!((st.reservoir, st.amount), (Some(blue), 1.0));
|
||||
// Clean on, Load off: the next stroke in the same color starts empty.
|
||||
st.end_stroke(blue, true, false);
|
||||
assert_eq!(st.next_load(blue), None);
|
||||
st.begin_stroke(blue);
|
||||
assert_eq!((st.reservoir, st.pickup), (None, None));
|
||||
// Choosing another color loads it.
|
||||
assert_eq!(st.next_load(green), Some(green));
|
||||
st.begin_stroke(green);
|
||||
assert_eq!((st.reservoir, st.amount), (Some(green), 1.0));
|
||||
// Load on, Clean off: the reservoir is full again and the pickup stays.
|
||||
st.amount = 0.1;
|
||||
st.pickup = Some(blue);
|
||||
st.end_stroke(green, false, true);
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, Some(blue)));
|
||||
// Both on: a fresh load with no pickup.
|
||||
st.end_stroke(green, true, true);
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_empty_brush_paints_nothing() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
|
||||
@@ -43,8 +43,11 @@ use crate::patterns::{PatternBudget, PsdPattern, parse_pattern_block_with, write
|
||||
pub const MAX_EDGE: u32 = 8192;
|
||||
/// Most brushes read from one file.
|
||||
pub const MAX_BRUSHES: usize = 20_000;
|
||||
/// Cap on decoded sample bytes over the whole file.
|
||||
pub const MAX_TOTAL_BYTES: usize = 512 << 20;
|
||||
/// Cap on decoded sample bytes over the whole file. 32-bit targets (wasm) keep 512 MiB: the file,
|
||||
/// its base64 copies and the decoded tips must all fit a 4 GiB address space, and running out
|
||||
/// there aborts instead of returning an error.
|
||||
pub const MAX_TOTAL_BYTES: usize = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 };
|
||||
const _: () = assert!(cfg!(target_pointer_width = "64") || MAX_TOTAL_BYTES == 512 << 20);
|
||||
|
||||
/// A sampled tip: one grayscale plane where the maximum value is full paint.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
||||
@@ -29,7 +29,7 @@ const MAX_EDGE: u32 = 30_000;
|
||||
/// Budget for zero-padded channel planes summed over every pattern of one parse (a block, a `.pat`
|
||||
/// file, an `.abr`). Photoshop writes every channel at the pattern rectangle, so a smaller channel
|
||||
/// rectangle is only padding; without this cap a few hundred bytes (1x1 channels, a huge pattern
|
||||
/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`.
|
||||
/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros.
|
||||
const MAX_PADDED_PATTERN_BYTES: u64 = 512 << 20;
|
||||
// A PackBits run packet emits at most 128 bytes from a 2-byte packet.
|
||||
const MAX_RLE_EXPANSION: u64 = 64;
|
||||
|
||||
@@ -3,11 +3,12 @@ name = "photocraft-tablet"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2)"
|
||||
description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2), and barrel button on Windows"
|
||||
|
||||
# The one isolated `unsafe` helper crate (craftrules never-crash, AGENTS.md): the AppKit event
|
||||
# monitor needs Objective-C interop. Only `src/macos.rs` allows `unsafe`; everything else is
|
||||
# denied, and every other crate keeps the workspace's `unsafe_code = "forbid"`.
|
||||
# monitor on macOS and the WH_GETMESSAGE pointer hook on Windows need platform interop. Only
|
||||
# `src/macos.rs` and `src/windows.rs` allow `unsafe`; everything else is denied, and every other
|
||||
# crate keeps the workspace's `unsafe_code = "forbid"`.
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
//! - **macOS** ([`macos::Monitor`]): an AppKit local event monitor reads the tablet fields of
|
||||
//! each `NSEvent` (`pressure`, `tilt`, `rotation`, `subtype`, and `pointingDeviceType` from
|
||||
//! proximity events) before winit sees the event, so every pointer event the UI handles already
|
||||
//! has its pen sample. AppKit's block-based monitor API needs Objective-C interop, so
|
||||
//! `macos.rs` is the only module in the workspace that allows `unsafe` (craftrules
|
||||
//! never-crash: an isolated, tested helper crate).
|
||||
//! has its pen sample.
|
||||
//! - **Windows** ([`windows::Monitor`]): a thread-local message hook inspects `WM_POINTER*`
|
||||
//! messages on the UI thread's message queue to read `PEN_FLAG_BARREL` (`GetPointerPenInfo`),
|
||||
//! which winit 0.30 drops by mapping pen inputs to touch events.
|
||||
//! AppKit's monitor API and Windows' thread hook need OS interop, so `macos.rs` and `windows.rs`
|
||||
//! allow `unsafe` (craftrules never-crash: an isolated, tested helper crate with `SAFETY:`
|
||||
//! comments on every block).
|
||||
//! - **Linux X11** ([`x11::spawn`]): a second, pure-Rust X connection (x11rb) selects XInput2
|
||||
//! raw events on the root window and maps the tablet device's valuators ("Abs Pressure",
|
||||
//! "Abs Tilt X/Y", "Abs Rotary Z") to samples. Raw events reach every client that asks, also
|
||||
@@ -31,6 +35,8 @@ pub mod xi;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod x11;
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Windows pointer hook for tablet data dropped by winit (such as the pen barrel button).
|
||||
//!
|
||||
//! winit 0.30 drops `POINTER_PEN_INFO::penFlags` (`PEN_FLAG_BARREL`) on Windows and turns pen
|
||||
//! inputs into `WindowEvent::Touch`, mapping them to `PointerButton::Primary`. Windows Ink also
|
||||
//! does not update `GetAsyncKeyState(VK_RBUTTON)` for stylus barrel buttons. This thread-local
|
||||
//! `WH_GETMESSAGE` hook inspects `WM_POINTER*` messages on the UI thread's message queue to
|
||||
//! track the physical barrel button state.
|
||||
|
||||
#![allow(unsafe_code, clippy::upper_case_acronyms)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static BARREL_DOWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[repr(C)]
|
||||
struct POINT {
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct MSG {
|
||||
hwnd: *mut std::ffi::c_void,
|
||||
message: u32,
|
||||
w_param: usize,
|
||||
l_param: isize,
|
||||
time: u32,
|
||||
pt: POINT,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct POINTER_INFO {
|
||||
pointer_type: u32,
|
||||
pointer_id: u32,
|
||||
frame_id: u32,
|
||||
pointer_flags: u32,
|
||||
source_device: *mut std::ffi::c_void,
|
||||
hwnd_target: *mut std::ffi::c_void,
|
||||
pt_pixel_location: POINT,
|
||||
pt_himetric_location: POINT,
|
||||
pt_pixel_location_raw: POINT,
|
||||
pt_himetric_location_raw: POINT,
|
||||
dw_time: u32,
|
||||
history_count: u32,
|
||||
input_data: i32,
|
||||
dw_key_states: u32,
|
||||
performance_count: u64,
|
||||
button_change_type: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct POINTER_PEN_INFO {
|
||||
pointer_info: POINTER_INFO,
|
||||
pen_flags: u32,
|
||||
pen_mask: u32,
|
||||
pressure: u32,
|
||||
rotation: u32,
|
||||
tilt_x: i32,
|
||||
tilt_y: i32,
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
unsafe extern "system" {
|
||||
fn SetWindowsHookExW(
|
||||
id_hook: i32,
|
||||
lpfn: unsafe extern "system" fn(i32, usize, isize) -> isize,
|
||||
h_mod: *mut std::ffi::c_void,
|
||||
dw_thread_id: u32,
|
||||
) -> *mut std::ffi::c_void;
|
||||
fn UnhookWindowsHookEx(hhk: *mut std::ffi::c_void) -> i32;
|
||||
fn CallNextHookEx(hhk: *mut std::ffi::c_void, n_code: i32, w_param: usize, l_param: isize) -> isize;
|
||||
fn GetCurrentThreadId() -> u32;
|
||||
fn GetPointerPenInfo(pointer_id: u32, pen_info: *mut POINTER_PEN_INFO) -> i32;
|
||||
}
|
||||
|
||||
const WH_GETMESSAGE: i32 = 3;
|
||||
const WM_POINTERUPDATE: u32 = 0x0245;
|
||||
const WM_POINTERDOWN: u32 = 0x0246;
|
||||
const WM_POINTERUP: u32 = 0x0247;
|
||||
const WM_POINTERLEAVE: u32 = 0x024A;
|
||||
|
||||
/// `PEN_FLAG_BARREL` (winuser.h): the barrel button of the pen is pressed.
|
||||
pub const PEN_FLAG_BARREL: u32 = 0x00000001;
|
||||
|
||||
/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (winuser.h): the secondary action button is pressed.
|
||||
pub const POINTER_FLAG_SECONDBUTTON: u32 = 0x00000020;
|
||||
|
||||
/// Pure decode of whether the stylus barrel button is pressed.
|
||||
///
|
||||
/// Plain tip contact sets `POINTER_FLAG_INCONTACT` (`0x0004`) and optionally `POINTER_FLAG_FIRSTBUTTON`
|
||||
/// (`0x0010`), which must never be confused with the barrel button (`POINTER_FLAG_SECONDBUTTON`, `0x0020`).
|
||||
pub fn is_barrel_pressed(pen_flags: Option<u32>, pointer_message_flags: u32) -> bool {
|
||||
let second_button = (pointer_message_flags & POINTER_FLAG_SECONDBUTTON) != 0;
|
||||
match pen_flags {
|
||||
Some(pen) => (pen & PEN_FLAG_BARREL) != 0 || second_button,
|
||||
None => second_button,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn get_msg_proc(code: i32, w_param: usize, l_param: isize) -> isize {
|
||||
if code >= 0 && l_param != 0 {
|
||||
// SAFETY: Win32 guarantees that for code >= 0, l_param points to a valid MSG struct
|
||||
// dispatched to this thread's message queue.
|
||||
let msg = unsafe { &*(l_param as *const MSG) };
|
||||
match msg.message {
|
||||
WM_POINTERDOWN | WM_POINTERUPDATE => {
|
||||
let pointer_id = (msg.w_param & 0xFFFF) as u32;
|
||||
let pointer_message_flags = (msg.w_param >> 16) as u32;
|
||||
// SAFETY: `pen_info` is a stack-allocated buffer initialized to zero, and `pointer_id`
|
||||
// is the pointer identifier from the valid WM_POINTER message.
|
||||
let pen_flags = unsafe {
|
||||
let mut info: POINTER_PEN_INFO = std::mem::zeroed();
|
||||
(GetPointerPenInfo(pointer_id, &mut info) != 0).then_some(info.pen_flags)
|
||||
};
|
||||
let is_barrel = is_barrel_pressed(pen_flags, pointer_message_flags);
|
||||
BARREL_DOWN.store(is_barrel, Ordering::Relaxed);
|
||||
}
|
||||
WM_POINTERUP | WM_POINTERLEAVE => {
|
||||
BARREL_DOWN.store(false, Ordering::Relaxed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// SAFETY: CallNextHookEx passes the parameters down the hook chain. MSDN notes the first
|
||||
// parameter is ignored and can be null.
|
||||
unsafe { CallNextHookEx(std::ptr::null_mut(), code, w_param, l_param) }
|
||||
}
|
||||
|
||||
/// Keeps the thread-local pointer message hook installed; dropping it removes the hook.
|
||||
/// Main-thread only (not `Send`).
|
||||
pub struct Monitor {
|
||||
hook: *mut std::ffi::c_void,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// Installs the thread-local pointer message hook on the UI thread.
|
||||
pub fn install() -> Result<Self, crate::Error> {
|
||||
// SAFETY: GetCurrentThreadId has no preconditions. SetWindowsHookExW installs
|
||||
// a thread-specific WH_GETMESSAGE hook on the calling thread's message queue
|
||||
// using our extern "system" callback.
|
||||
let hook = unsafe {
|
||||
let thread_id = GetCurrentThreadId();
|
||||
SetWindowsHookExW(WH_GETMESSAGE, get_msg_proc, std::ptr::null_mut(), thread_id)
|
||||
};
|
||||
if hook.is_null() {
|
||||
return Err(crate::Error::Platform("SetWindowsHookExW returned null".into()));
|
||||
}
|
||||
Ok(Self { hook })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Monitor {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.hook` is the non-null handle returned by `SetWindowsHookExW`,
|
||||
// unhooked exactly once here on the thread that created it.
|
||||
unsafe {
|
||||
UnhookWindowsHookEx(self.hook);
|
||||
}
|
||||
BARREL_DOWN.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the stylus barrel button is physically pressed.
|
||||
pub fn is_barrel_button_down() -> bool {
|
||||
BARREL_DOWN.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const POINTER_FLAG_INCONTACT: u32 = 0x0004;
|
||||
const POINTER_FLAG_FIRSTBUTTON: u32 = 0x0010;
|
||||
|
||||
#[test]
|
||||
fn plain_pen_contact_is_not_treated_as_barrel() {
|
||||
// Plain tip touch on digitizer: INCONTACT is set, first button may be set, no barrel flag.
|
||||
assert!(!is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT));
|
||||
assert!(!is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_FIRSTBUTTON));
|
||||
assert!(!is_barrel_pressed(None, POINTER_FLAG_INCONTACT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_barrel_flag_is_detected() {
|
||||
assert!(is_barrel_pressed(Some(PEN_FLAG_BARREL), 0));
|
||||
assert!(is_barrel_pressed(Some(PEN_FLAG_BARREL), POINTER_FLAG_INCONTACT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_message_second_button_is_detected() {
|
||||
assert!(is_barrel_pressed(None, POINTER_FLAG_SECONDBUTTON));
|
||||
assert!(is_barrel_pressed(Some(0), POINTER_FLAG_SECONDBUTTON));
|
||||
assert!(is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_SECONDBUTTON));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,14 @@ flate2 = { workspace = true }
|
||||
parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] }
|
||||
rayon = { workspace = true }
|
||||
|
||||
# macOS only: the `system` feature adds fontique's Core Text backend, which lists every font the
|
||||
# system offers wherever its file lives (fonts a font manager or Adobe Fonts activated, Office's
|
||||
# own, Apple's on-demand downloads). `fonts.rs` reads only its list of files and registers them
|
||||
# like the folder scan's, so our own collection keeps its behaviour (generic families, merging
|
||||
# installed faces into the bundled families). It uses no fontconfig, unlike `system` on Linux.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts", "system"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Deflates the bundled fonts (build.rs); `flate2` above inflates them.
|
||||
flate2 = { workspace = true }
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use parley::FontContext;
|
||||
#[cfg(any(target_os = "macos", all(test, not(target_arch = "wasm32"))))]
|
||||
use parley::fontique::SourceKind;
|
||||
use parley::fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, FontStyle, FontWeight, FontWidth, GenericFamily, SourceCache};
|
||||
use skrifa::raw::{FileRef, TableProvider, types::Tag};
|
||||
use skrifa::{MetadataProvider, string::StringId};
|
||||
@@ -254,6 +256,10 @@ impl FontDb {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
files.extend(fontconfig_font_paths());
|
||||
// Core Text knows every font macOS offers, also those activated from outside the
|
||||
// font folders (#6683).
|
||||
#[cfg(target_os = "macos")]
|
||||
files.extend(coretext_font_paths());
|
||||
// PingFang (the macOS Chinese UI font) lives outside the font folders.
|
||||
if cfg!(target_os = "macos") {
|
||||
files.extend(crate::cjk::mac_pingfang());
|
||||
@@ -761,6 +767,49 @@ mod fontconfig_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The font files Core Text offers: the installed fonts and those activated from any other folder
|
||||
/// (a font manager, Adobe Fonts, Office, Apple's on-demand downloads). A scratch collection with
|
||||
/// fontique's Core Text backend lists them; its fonts are not used, only their files, so our own
|
||||
/// collection registers them like any scanned file.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn coretext_font_paths() -> Vec<std::path::PathBuf> {
|
||||
// Reading arbitrary fonts must not take the scan down: no files is the folder scan's result.
|
||||
std::panic::catch_unwind(|| {
|
||||
let mut system = Collection::new(CollectionOptions { shared: false, system_fonts: true });
|
||||
let mut files = font_files_of(&mut system);
|
||||
files.retain(|f| !f.starts_with(MACOS_PRIVATE_FONTS));
|
||||
files
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fonts reserved for the system UI (PingFangUI.ttc among them). Core Text offers them, but
|
||||
/// fontique can't draw their outlines (it works around PingFangUI for the same reason), so they
|
||||
/// are not listed.
|
||||
#[cfg(target_os = "macos")]
|
||||
const MACOS_PRIVATE_FONTS: &str = "/System/Library/PrivateFrameworks";
|
||||
|
||||
/// The files `collection`'s fonts are read from, each once and in a fixed order. Fonts
|
||||
/// registered from memory have no file.
|
||||
#[cfg(any(target_os = "macos", all(test, not(target_arch = "wasm32"))))]
|
||||
fn font_files_of(collection: &mut Collection) -> Vec<std::path::PathBuf> {
|
||||
let names: Vec<String> = collection.family_names().map(str::to_string).collect();
|
||||
let mut files = HashSet::new();
|
||||
for name in names {
|
||||
let Some(family) = collection.family_by_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
for font in family.fonts() {
|
||||
if let SourceKind::Path(path) = font.source().kind() {
|
||||
files.insert(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files: Vec<_> = files.into_iter().collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
use std::path::PathBuf;
|
||||
@@ -903,6 +952,63 @@ mod tests {
|
||||
assert_eq!(super::localized_name("X", &[]), None);
|
||||
}
|
||||
|
||||
/// The files a collection's fonts come from (what Core Text's fonts are loaded from, #6683):
|
||||
/// each once, none for fonts held in memory.
|
||||
#[test]
|
||||
fn font_files_of_lists_each_font_file_once() {
|
||||
use super::{Blob, Collection, CollectionOptions};
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-font-files-{}", std::process::id()));
|
||||
let activated = dir.join("activated");
|
||||
std::fs::create_dir_all(&activated).unwrap();
|
||||
let (inter, mono) = (dir.join("inter.ttf"), activated.join("mono.ttf"));
|
||||
std::fs::write(&inter, super::INTER_REGULAR.as_slice()).unwrap();
|
||||
std::fs::write(&mono, super::JETBRAINS_MONO_REGULAR.as_slice()).unwrap();
|
||||
let mut collection = Collection::new(CollectionOptions { shared: false, system_fonts: false });
|
||||
collection.load_fonts_from_paths([&dir]);
|
||||
// The same file again and a font from memory add no files.
|
||||
collection.load_fonts_from_paths([&inter]);
|
||||
collection.register_fonts(Blob::new(std::sync::Arc::new(super::INTER_MEDIUM.clone())), None);
|
||||
let files = super::font_files_of(&mut collection);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut expected = vec![inter, mono];
|
||||
expected.sort();
|
||||
assert_eq!(files, expected);
|
||||
}
|
||||
|
||||
/// Core Text lists the system's font files, all readable, none of the reserved UI fonts.
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn coretext_lists_the_system_font_files() {
|
||||
let files = super::coretext_font_paths();
|
||||
assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len());
|
||||
assert!(files.iter().all(|f| f.is_file() && !f.starts_with("/System/Library/PrivateFrameworks")));
|
||||
}
|
||||
|
||||
/// Every family macOS offers is in the font list, not only those in the font folders (#6683).
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn every_family_coretext_offers_is_listed() {
|
||||
use super::{Collection, CollectionOptions, SourceKind};
|
||||
let mut system = Collection::new(CollectionOptions { shared: false, system_fonts: true });
|
||||
// Its names include localized aliases of a family ("Systemschrift"); the family has one name.
|
||||
let aliases: Vec<String> = system.family_names().map(str::to_string).collect();
|
||||
let mut offered = Vec::new();
|
||||
for family in aliases.iter().filter_map(|n| system.family_by_name(n)) {
|
||||
// A family only the reserved UI fonts provide (PingFang before its download) is not listed.
|
||||
let listed_file =
|
||||
|font: &parley::fontique::FontInfo| matches!(font.source().kind(), SourceKind::Path(p) if !p.starts_with(super::MACOS_PRIVATE_FONTS));
|
||||
if !super::is_hidden_family(family.name()) && family.fonts().iter().any(listed_file) {
|
||||
offered.push(family.name().to_string());
|
||||
}
|
||||
}
|
||||
offered.sort();
|
||||
offered.dedup();
|
||||
assert!(!offered.is_empty());
|
||||
let listed = super::FontDb::with_system_fonts().families();
|
||||
let missing: Vec<&String> = offered.iter().filter(|n| !listed.contains(n)).collect();
|
||||
assert!(missing.is_empty(), "macOS offers these families, the font list lacks them: {missing:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_font_dirs_are_skipped() {
|
||||
let mut files = Vec::new();
|
||||
|
||||
+301
-22
@@ -820,21 +820,21 @@ pub const OPENTYPE_KEYS: [(&str, &str); 8] = [
|
||||
("Fractions", "frac"),
|
||||
];
|
||||
|
||||
pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
|
||||
let c = &s.color;
|
||||
let values = match c.mode {
|
||||
ColorMode::Cmyk => vec![real(c.alpha), real(c.c[0]), real(c.c[1]), real(c.c[2]), real(c.c[3])],
|
||||
ColorMode::Grayscale => vec![real(c.alpha), real(c.c[0])],
|
||||
/// A fill colour as EngineData and `Txt2` store it: the colour type (0 gray, 1 RGB, 2 CMYK) and
|
||||
/// its values, alpha first.
|
||||
fn fill_color(c: &Color) -> (i64, Vec<E>) {
|
||||
match c.mode {
|
||||
ColorMode::Cmyk => (2, vec![real(c.alpha), real(c.c[0]), real(c.c[1]), real(c.c[2]), real(c.c[3])]),
|
||||
ColorMode::Grayscale => (0, vec![real(c.alpha), real(c.c[0])]),
|
||||
_ => {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
vec![real(c.alpha), real(r), real(g), real(b)]
|
||||
(1, vec![real(c.alpha), real(r), real(g), real(b)])
|
||||
}
|
||||
};
|
||||
let color_type = match c.mode {
|
||||
ColorMode::Cmyk => 2,
|
||||
ColorMode::Grayscale => 0,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
|
||||
let (color_type, values) = fill_color(&s.color);
|
||||
let mut opentype: Vec<(String, E)> = OPENTYPE_KEYS
|
||||
.iter()
|
||||
.filter(|(_, tag)| s.features.iter().any(|f| f.tag == *tag && f.value > 0))
|
||||
@@ -938,6 +938,90 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs,
|
||||
/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set
|
||||
/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which
|
||||
/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking
|
||||
/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode
|
||||
/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit
|
||||
/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is
|
||||
/// larger than a template may be (it is copied once per run).
|
||||
fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) -> Option<Vec<E>> {
|
||||
if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) {
|
||||
return None;
|
||||
}
|
||||
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
let runs = layer.char_runs();
|
||||
let ed_text = format!("{}\r", layer.text.replace('\n', "\r"));
|
||||
let mut spans: Vec<(usize, i64)> = Vec::new();
|
||||
let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len));
|
||||
for (b, ch) in ed_text.char_indices() {
|
||||
while b >= run_end && ri + 1 < runs.len() {
|
||||
ri += 1;
|
||||
run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len));
|
||||
}
|
||||
let n = ch.len_utf16() as i64;
|
||||
match spans.last_mut() {
|
||||
Some(last) if last.0 == ri => last.1 += n,
|
||||
_ => spans.push((ri, n)),
|
||||
}
|
||||
}
|
||||
let default = CharStyle::default();
|
||||
let mut out = Vec::with_capacity(spans.len());
|
||||
let mut looked_up = std::collections::HashMap::new();
|
||||
for (ri, n) in spans {
|
||||
let s = runs.get(ri).map_or(&default, |r| &r.style);
|
||||
let font = *looked_up.entry(postscript_for(s)).or_insert_with_key(|name| txt2_font(fonts, name));
|
||||
let mut run = base.clone();
|
||||
let style = run.get_mut("0")?.get_mut("0")?.get_mut("6")?;
|
||||
if let Some(font) = font {
|
||||
style.set("0", E::Int(font));
|
||||
}
|
||||
style.set("1", real(s.size_pt * k));
|
||||
style.set("2", E::Bool(s.faux_bold));
|
||||
style.set("3", E::Bool(s.faux_italic));
|
||||
style.set("4", E::Bool(s.leading_pt.is_none()));
|
||||
style.set("5", real(s.leading_pt.unwrap_or(0.0) * k));
|
||||
style.set("6", real(s.horizontal_scale));
|
||||
style.set("7", real(s.vertical_scale));
|
||||
style.set("8", E::Int(s.tracking.round() as i64));
|
||||
if let Some(paint @ E::Dict(_)) = style.get_mut("53").and_then(|p| p.get_mut("0")) {
|
||||
let (kind, values) = fill_color(&s.color);
|
||||
paint.set("0", E::Int(kind));
|
||||
paint.set("1", E::Array(values));
|
||||
}
|
||||
let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0);
|
||||
for (key, tag) in TXT2_FEATURES {
|
||||
style.set(key, E::Bool(feature(tag).unwrap_or(tag == "calt")));
|
||||
}
|
||||
let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n);
|
||||
style.set(TXT2_STYLISTIC_SETS, E::Int(sets));
|
||||
style.set("11", E::Int(txt2_mode(s)));
|
||||
run.set("1", E::Int(n));
|
||||
out.push(run);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont
|
||||
/// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry)
|
||||
/// when missing. `None` for an empty list: there is no entry to shape a new one on.
|
||||
fn txt2_font(fonts: &mut Vec<E>, name: &str) -> Option<i64> {
|
||||
let named = |f: &E| f.path(&["0", "0", "0"]).and_then(E::as_str) == Some(name);
|
||||
if let Some(i) = fonts.iter().position(named) {
|
||||
return i64::try_from(i).ok();
|
||||
}
|
||||
let mut entry = fonts.first()?.clone();
|
||||
let font = entry.get_mut("0")?.get_mut("0")?;
|
||||
let kind = font.get("2").cloned();
|
||||
*font = E::Dict(vec![("0".into(), E::String(name.to_string()))]);
|
||||
if let Some(kind) = kind {
|
||||
font.set("2", kind);
|
||||
}
|
||||
fonts.push(entry);
|
||||
i64::try_from(fonts.len() - 1).ok()
|
||||
}
|
||||
|
||||
/// The highest text-object number honoured here (`TextIndex`, and the slot array [`build_txt2`]
|
||||
/// sizes). PSD text objects are numbered densely from 0; a crafted file can name object
|
||||
/// `i32::MAX`, and sizing the array by it would make every save allocate gigabytes.
|
||||
@@ -964,15 +1048,30 @@ pub fn needs_txt2(objects: &[(i32, &TextLayer)]) -> bool {
|
||||
/// unchanged — keeps everything the file held beyond the two regenerated keys (its model's text
|
||||
/// and style runs): Photoshop stores glyph pen positions under `/21 /1` there, and a save with
|
||||
/// no text edit must not drop them. Its own style runs keep every style key too: only their
|
||||
/// auto-kern mode is set (split where a mode changes inside a run). A changed text invalidates
|
||||
/// those extras along with the old runs, so they are dropped rather than left stale. Everything
|
||||
/// auto-kern mode is set (split where a mode changes inside a run). An edited object — the
|
||||
/// file's object at that slot, its text changed — keeps the object too, with its text replaced
|
||||
/// and its run arrays refitted to the new text (#6968: a bare stub crashed Photoshop); only its
|
||||
/// glyph pen positions, which belong to the old text, are dropped. Everything
|
||||
/// else the file's block held — the other keys beside the text objects, objects no layer names —
|
||||
/// is kept, and a block whose content comes out unchanged is returned byte for byte (#2374:
|
||||
/// Photoshop refused files whose `Txt2` an unedited save had stripped).
|
||||
pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec<u8> {
|
||||
build_txt2_at(objects, previous, 72.0)
|
||||
}
|
||||
|
||||
/// [`build_txt2`] for a document of `dpi` (the style runs of new and edited objects carry sizes
|
||||
/// in the document's points, as EngineData does).
|
||||
pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi: f32) -> Vec<u8> {
|
||||
let prev = previous.and_then(parse_txt2);
|
||||
let prev_objects: &[E] = prev.as_ref().and_then(|p| p.path(&["1", "1"]).and_then(E::as_array)).unwrap_or(&[]);
|
||||
let mut slots: Vec<E> = prev_objects.to_vec();
|
||||
let mut fonts: Vec<E> = prev.as_ref().and_then(|p| p.path(&["0", "1", "0"]).and_then(E::as_array)).map(<[E]>::to_vec).unwrap_or_default();
|
||||
let fonts_before = fonts.len();
|
||||
// Looked up once per save, not per layer: a crafted block with many objects must not cost
|
||||
// a pass over all of them for every type layer.
|
||||
let all_stubs = prev_objects.iter().all(is_stub);
|
||||
// A template is copied once per new layer, so a large one is skipped (the stub is written).
|
||||
let template = prev_objects.iter().rev().find(|o| names_one_frame(o) && fits(o, &mut { TEMPLATE_NODES }) && edited_object(o, "\r").is_some());
|
||||
for (index, layer) in objects {
|
||||
if !(0..=MAX_TEXT_INDEX).contains(index) {
|
||||
continue;
|
||||
@@ -980,11 +1079,34 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
|
||||
let text = format!("{}\r", layer.text.replace('\n', "\r"));
|
||||
let modes = txt2_mode_runs(layer);
|
||||
let at = *index as usize;
|
||||
let kept = prev_objects.get(at).filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()));
|
||||
let mut object = kept.cloned().unwrap_or_else(E::dict);
|
||||
// A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so
|
||||
// it is replaced like a missing one (below) and re-saving such a file repairs it, whether
|
||||
// or not the layer's text changed.
|
||||
let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs);
|
||||
let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()));
|
||||
// An edited object (#6968): the file's object at this slot with its text changed. It keeps
|
||||
// the file's structure — paragraph runs, the view naming its frame, the rest — with every
|
||||
// run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in
|
||||
// Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file.
|
||||
let edited = kept.is_none().then(|| slot.and_then(|o| edited_object(o, &text))).flatten();
|
||||
// A new object (#7698): a layer the file's block has no object for (a type layer added
|
||||
// here) takes the shape of one of the file's own objects, refitted the same way, with its
|
||||
// view naming its own frame. A bare stub beside Photoshop's objects, its slot without a
|
||||
// frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file.
|
||||
let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten();
|
||||
let reuse = kept.is_some() || edited.is_some();
|
||||
let unchanged = kept.is_some();
|
||||
let mut object = kept.cloned().or(edited).or(fresh).unwrap_or_else(E::dict);
|
||||
let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict);
|
||||
let file_runs = kept.and_then(|_| model.path(&["6", "0"]).and_then(E::as_array)).and_then(|runs| with_modes(runs, &modes));
|
||||
let runs = file_runs.unwrap_or_else(|| {
|
||||
// A new or edited object takes the layer's own style: Photoshop reads the character style
|
||||
// from these runs, so the template's (or the old text's) would show, and come back on
|
||||
// reopen. They are built on one of the object's own runs, every key Photoshop wrote kept.
|
||||
let styled = (!unchanged && !is_stub(&object))
|
||||
.then(|| model.path(&["6", "0"]).and_then(E::as_array).and_then(<[E]>::first).cloned())
|
||||
.flatten()
|
||||
.and_then(|base| txt2_style_runs(layer, &base, &mut fonts, dpi));
|
||||
let file_runs = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes));
|
||||
let runs = styled.or(file_runs).unwrap_or_else(|| {
|
||||
modes
|
||||
.iter()
|
||||
.map(|&(mode, len)| {
|
||||
@@ -1011,6 +1133,12 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
|
||||
// The outer dictionary keeps whatever the block held (`/98` version, block extras, …), and
|
||||
// `/1` whatever it holds beside the text objects.
|
||||
let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())]));
|
||||
pad_frames(&mut outer, slots.len());
|
||||
if fonts.len() != fonts_before
|
||||
&& let Some(list @ E::Dict(_)) = outer.get_mut("0").and_then(|v| v.get_mut("1"))
|
||||
{
|
||||
list.set("0", E::Array(fonts));
|
||||
}
|
||||
match outer.get_mut("1") {
|
||||
Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)),
|
||||
_ => outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))])),
|
||||
@@ -1028,6 +1156,149 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
|
||||
}
|
||||
}
|
||||
|
||||
/// The most values a `Txt2` object may hold to serve as the template for new objects. Photoshop's
|
||||
/// own objects hold a few hundred (611 at most in the file from #7698).
|
||||
const TEMPLATE_NODES: usize = 4096;
|
||||
|
||||
/// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over).
|
||||
fn fits(v: &E, budget: &mut usize) -> bool {
|
||||
let Some(left) = budget.checked_sub(1) else { return false };
|
||||
*budget = left;
|
||||
match v {
|
||||
E::Dict(items) => items.iter().all(|(_, x)| fits(x, budget)),
|
||||
E::Array(items) => items.iter().all(|x| fits(x, budget)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as
|
||||
/// Photoshop writes it.
|
||||
fn names_one_frame(object: &E) -> bool {
|
||||
object.path(&["1", "0"]).and_then(E::as_array).is_some_and(|f| f.len() == 1 && f.first().and_then(|x| x.get("0")).is_some())
|
||||
}
|
||||
|
||||
/// A text object for the slot `at`, which the file's block has none for: the file's object
|
||||
/// `template` refitted to `text` by [`edited_object`], its view now naming frame `at` and its view
|
||||
/// runs (`/1 /1 /0`, one per paragraph) remade for the new paragraphs. `None` when the template
|
||||
/// can't be refitted.
|
||||
fn new_object(template: &E, at: usize, text: &str) -> Option<E> {
|
||||
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
|
||||
let frame = template.path(&["1", "0"]).and_then(E::as_array).and_then(|f| f.first()).and_then(|f| f.get("0")).cloned();
|
||||
let at = i64::try_from(at).ok()?;
|
||||
let mut object = edited_object(template, text)?;
|
||||
let view = object.get_mut("1")?;
|
||||
view.set("0", E::Array(vec![E::Dict(vec![("0".into(), E::Int(at))])]));
|
||||
// The rendered strikes' frame strike (`/F`) names the template's frame too.
|
||||
if let Some(E::Array(strikes)) = view.get_mut("2") {
|
||||
for s in strikes.iter_mut() {
|
||||
let Some(E::Array(parts)) = s.get_mut("6") else { continue };
|
||||
for p in parts.iter_mut().filter(|p| p.get("99").and_then(E::as_str) == Some("F") && p.get("10") == frame.as_ref()) {
|
||||
p.set("10", E::Int(at));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) {
|
||||
let fitted = runs_per(runs, ¶graphs)?;
|
||||
view.get_mut("1")?.set("0", E::Array(fitted));
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
/// Whether `object` is the bare `<< /0 << /0 text /6 runs >> >>` object [`build_txt2`] writes
|
||||
/// when it has nothing to take a shape from: no view, no paragraph runs.
|
||||
fn is_stub(object: &E) -> bool {
|
||||
let only = |v: &E, keys: &[&str]| matches!(v, E::Dict(items) if items.iter().all(|(k, _)| keys.contains(&k.as_str())));
|
||||
only(object, &["0"]) && object.get("0").is_some_and(|m| only(m, &["0", "6"]))
|
||||
}
|
||||
|
||||
/// Gives the block `outer` a frame (`/0 /8 /0`) for each of its `count` text objects: Photoshop
|
||||
/// writes one per object, and a reader indexes them by object number. Missing frames are the
|
||||
/// small point-text frame Photoshop writes (`<< /0 << /2 << /6 [ -1.0 -1.0 ] /11 << /4 -1 /7
|
||||
/// false >> >> >> >>`), never copies of a file's frame: the slot count reaches
|
||||
/// [`MAX_TEXT_INDEX`], and a large frame copied that often would blow up the save. A block
|
||||
/// without a frame list is left as it is.
|
||||
fn pad_frames(outer: &mut E, count: usize) {
|
||||
let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) else { return };
|
||||
let count = count.min(MAX_TEXT_INDEX as usize + 1);
|
||||
if frames.len() < count {
|
||||
let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]);
|
||||
let data = E::Dict(vec![("6".into(), E::Array(vec![E::Real(-1.0), E::Real(-1.0)])), ("11".into(), kind)]);
|
||||
let frame = E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]);
|
||||
frames.resize(count, frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's `Txt2` text object `object` (`<< /0 model /1 view … >>`) for the changed engine
|
||||
/// text `text` (`\r` breaks, trailing `\r`): the model's text replaced, its paragraph runs (`/5`)
|
||||
/// one per paragraph of the new text (each keeping the style of the file's paragraph at that
|
||||
/// position, else its last), and every other run array of the model (style runs `/6`, …) cut or
|
||||
/// extended at its end to the new length. The view and the other keys are kept, except the glyph
|
||||
/// pen positions (`/21`), which belong to the old text. `None` when the object or one of its run
|
||||
/// arrays isn't shaped as Photoshop writes it: the caller writes a fresh object instead.
|
||||
fn edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let mut object = object.clone();
|
||||
let E::Dict(items) = &mut object else { return None };
|
||||
items.retain(|(k, _)| k != "21");
|
||||
let model = object.get_mut("0").filter(|m| matches!(m, E::Dict(_)))?;
|
||||
let total = utf16_len(text) as usize;
|
||||
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
|
||||
let E::Dict(entries) = model else { return None };
|
||||
for (key, value) in entries.iter_mut() {
|
||||
if key.as_str() == "0" {
|
||||
*value = E::String(text.to_string());
|
||||
continue;
|
||||
}
|
||||
let Some(runs) = value.get("0").and_then(E::as_array).filter(|r| r.iter().all(|x| matches!(x, E::Dict(_)) && x.get("1").is_some())) else {
|
||||
continue;
|
||||
};
|
||||
let fitted = if key.as_str() == "5" { runs_per(runs, ¶graphs)? } else { runs_to_length(runs, total)? };
|
||||
value.set("0", E::Array(fitted));
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
/// `runs` (`<< … /1 length >>`) remade with the lengths `lens`: run i keeps the content of the
|
||||
/// file's run i, else of its last one. `None` for an empty `runs`.
|
||||
fn runs_per(runs: &[E], lens: &[usize]) -> Option<Vec<E>> {
|
||||
let last = runs.last()?;
|
||||
lens.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &len)| {
|
||||
let mut r = runs.get(i).unwrap_or(last).clone();
|
||||
r.set("1", E::Int(i64::try_from(len).ok()?));
|
||||
Some(r)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `runs` (`<< … /1 length >>`) covering `total` UTF-16 units: the leading runs as they were, cut
|
||||
/// where `total` ends, the last kept run extended when `total` is longer. Empty runs are dropped.
|
||||
/// `None` when `runs` is empty or a length isn't a count.
|
||||
fn runs_to_length(runs: &[E], total: usize) -> Option<Vec<E>> {
|
||||
let mut out: Vec<(E, usize)> = Vec::new();
|
||||
let mut left = total;
|
||||
for r in runs {
|
||||
let len = r.get("1").and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?;
|
||||
let take = len.min(left);
|
||||
if take > 0 {
|
||||
out.push((r.clone(), take));
|
||||
left -= take;
|
||||
}
|
||||
}
|
||||
if left > 0 {
|
||||
match out.last_mut() {
|
||||
Some(last) => last.1 = last.1.checked_add(left)?,
|
||||
None => out.push((runs.last()?.clone(), left)),
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|(mut r, len)| {
|
||||
r.set("1", E::Int(i64::try_from(len).ok()?));
|
||||
Some(r)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The file's own `Txt2` style runs `runs` with the auto-kern modes `modes` (`(mode, UTF-16
|
||||
/// length)`, see [`txt2_mode_runs`]) set at `/0 /0 /6 /11`, keeping every other style key. A
|
||||
/// run a mode change falls inside is split there. `None` when the runs don't cover the text or
|
||||
@@ -1085,13 +1356,24 @@ pub fn text_index(tysh: &[u8]) -> Option<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many text objects the document `Txt2` block `data` holds (`/1 /1`), named by a layer or
|
||||
/// not; 0 when it doesn't parse. PSD export numbers new type layers past them, so a new layer
|
||||
/// never lands on a deleted layer's object.
|
||||
pub fn txt2_object_count(data: &[u8]) -> usize {
|
||||
parse_txt2(data).and_then(|t| t.path(&["1", "1"]).and_then(E::as_array).map(<[E]>::len)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Rewrites a `TySh`'s `TextIndex`: the layer's text-object number in the document's `Txt2`
|
||||
/// block ([`build_txt2`] writes them in this order). `None` when `tysh` doesn't parse.
|
||||
pub fn set_text_index(tysh: &[u8], index: i32) -> Option<Vec<u8>> {
|
||||
let mut t = parse_tysh(tysh)?;
|
||||
match t.text.items.iter_mut().find(|(k, _)| k.is("TextIndex")) {
|
||||
Some(e) => e.1 = D::Integer(index),
|
||||
None => t.text.items.push((Id::new("TextIndex"), D::Integer(index))),
|
||||
// Where Photoshop writes it: right before `EngineData`.
|
||||
None => {
|
||||
let at = t.text.items.iter().position(|(k, _)| k.is("EngineData")).unwrap_or(t.text.items.len());
|
||||
t.text.items.insert(at, (Id::new("TextIndex"), D::Integer(index)));
|
||||
}
|
||||
}
|
||||
Some(write_tysh(&t))
|
||||
}
|
||||
@@ -1322,9 +1604,6 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8>
|
||||
};
|
||||
set(&mut text, "bounds", rect("bounds"));
|
||||
set(&mut text, "boundingBox", rect("boundingBox"));
|
||||
if text.get("TextIndex").is_none() {
|
||||
set(&mut text, "TextIndex", D::Integer(0));
|
||||
}
|
||||
set(&mut text, "EngineData", D::RawData(ed::write(&e)));
|
||||
let warp = match (&layer.warp, old.as_ref().and_then(|o| o.warp.clone())) {
|
||||
(Some(w), _) => Some(
|
||||
|
||||
@@ -1129,6 +1129,143 @@ fn edited_txt2_keeps_the_files_styles_and_objects() {
|
||||
assert_eq!(modes, vec![(1, Kerning::Optical), (2, Kerning::Metrics)]);
|
||||
}
|
||||
|
||||
/// #6968: editing the text of a Photoshop type layer keeps its `Txt2` object — paragraph runs,
|
||||
/// the view naming its frame, the model's other run arrays and its style keys — refitted to the
|
||||
/// new text. It was replaced by a bare `<< /0 << /0 text /6 runs >> >>` stub, and Photoshop quit
|
||||
/// while opening the file.
|
||||
#[test]
|
||||
fn edited_text_keeps_the_files_txt2_object() {
|
||||
use crate::engine_data::Value as E;
|
||||
let run = |extra: &str, len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 {extra} >> >> >> /1 {len} >> ");
|
||||
// "AB\rCD\r": two paragraphs, two style runs, a kern run, a view and pen positions.
|
||||
let prev = format!(
|
||||
"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}{}] >> /7 << /0 [ << /0 << >> /1 6 >> ] >> >> /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>",
|
||||
run("", 3),
|
||||
run("", 3),
|
||||
run("/11 1", 2),
|
||||
run("/11 1 /53 (red)", 4)
|
||||
);
|
||||
// Every character above is below U+0100: one raw byte each (the BOM `FE FF` included).
|
||||
let prev: Vec<u8> = prev.chars().map(|c| c as u32 as u8).collect();
|
||||
let before = crate::psd::parse_txt2(&prev).unwrap();
|
||||
let before_object = before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].clone();
|
||||
assert_eq!(before_object.path(&["0", "0"]).and_then(E::as_str), Some("AB\rCD\r"));
|
||||
let lens = |o: &E, key: &str| -> Vec<i64> {
|
||||
o.path(&["0", key, "0"]).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect()
|
||||
};
|
||||
for (text, paragraphs, styles) in
|
||||
[("Bevex\nCD", vec![6, 3], vec![9]), ("AB\nCD\nEFG", vec![3, 3, 4], vec![10]), ("X", vec![2], vec![2]), ("AB\nCZ", vec![3, 3], vec![6])]
|
||||
{
|
||||
let t = runs_of(text, &[(text.len(), CharStyle::default())]);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
|
||||
for key in [&["0"][..], &["98"], &["1", "0"], &["1", "2"]] {
|
||||
assert_eq!(out.path(key), before.path(key), "{text:?}: /{} survives", key.join(" /"));
|
||||
}
|
||||
let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0];
|
||||
let engine = format!("{}\r", text.replace('\n', "\r"));
|
||||
let total = engine.encode_utf16().count() as i64;
|
||||
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some(engine.as_str()));
|
||||
assert_eq!(object.get("1"), before_object.get("1"), "{text:?}: the view (its frame) is kept");
|
||||
assert!(object.get("21").is_none(), "{text:?}: pen positions of the old text are dropped");
|
||||
assert_eq!(lens(object, "5"), paragraphs, "{text:?}: one paragraph run per paragraph");
|
||||
// Photoshop reads the character style from these runs: they follow the layer's own runs
|
||||
// (one here), built on the file's first run.
|
||||
assert_eq!(lens(object, "6"), styles, "{text:?}: style runs cover the new text");
|
||||
assert_eq!(lens(object, "7"), vec![total], "{text:?}: other run arrays cover the new text");
|
||||
for r in object.path(&["0", "5", "0"]).and_then(E::as_array).unwrap() {
|
||||
assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: paragraph style survives");
|
||||
}
|
||||
for r in object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap() {
|
||||
assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(f64::from(CharStyle::default().size_pt)), "{text:?}: the layer's font size");
|
||||
assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: the file's run keeps its other keys");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an
|
||||
/// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`,
|
||||
/// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out.
|
||||
#[test]
|
||||
fn new_text_layer_gets_a_txt2_object_and_frame() {
|
||||
use crate::engine_data::Value as E;
|
||||
let run = |len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 >> >> >> /1 {len} >> ");
|
||||
let prev = format!(
|
||||
"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}] >> >> /1 << /0 [ << /0 0 >> ] /1 << /0 [ << /0 << /1 1 >> /1 3 >> << /0 << /1 1 >> /1 3 >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 0 >> ] >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>",
|
||||
run(3),
|
||||
run(3),
|
||||
run(6)
|
||||
);
|
||||
// The same block after an earlier save wrote a bare stub for "v1.0" (the file in #7698).
|
||||
let stub = "<< /0 << /0 (\u{fe}\u{ff}\0v\x001\0.\x000\0\r) /6 << /0 [ << /0 << /0 << /0 () /6 << /0 0 /11 1 >> >> >> /1 5 >> ] >> >> >> ";
|
||||
let broken = prev.replace(" >> ] /2 << /0 7 >>", &format!(" >> {stub}] /2 << /0 7 >>"));
|
||||
let broken: Vec<u8> = broken.chars().map(|c| c as u32 as u8).collect();
|
||||
let prev: Vec<u8> = prev.chars().map(|c| c as u32 as u8).collect();
|
||||
let before = crate::psd::parse_txt2(&prev).unwrap();
|
||||
let old = runs_of("AB\nCD", &[(5, CharStyle::default())]);
|
||||
let new = runs_of("v1.0", &[(4, CharStyle::default())]);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&prev))).unwrap();
|
||||
let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap();
|
||||
assert_eq!(objects.len(), 2);
|
||||
let kept = &before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0];
|
||||
assert_eq!(objects[0].get("1"), kept.get("1"), "the file's object keeps its view");
|
||||
assert_eq!(objects[0].path(&["0", "5"]), kept.path(&["0", "5"]), "the file's object keeps its paragraphs");
|
||||
let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap();
|
||||
assert_eq!(frames.len(), 2, "one frame per object");
|
||||
let object = &objects[1];
|
||||
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v1.0\r"));
|
||||
assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "the view names its own frame");
|
||||
assert!(object.get("21").is_none());
|
||||
assert_eq!(
|
||||
object.path(&["1", "2"]).and_then(E::as_array).and_then(|s| s[0].get("6")).and_then(E::as_array).and_then(|f| f[0].get("10")),
|
||||
Some(&E::Int(1)),
|
||||
"the frame strike names its own frame"
|
||||
);
|
||||
let lens =
|
||||
|path: &[&str]| -> Vec<i64> { object.path(path).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() };
|
||||
assert_eq!(lens(&["0", "5", "0"]), vec![5]);
|
||||
assert_eq!(lens(&["0", "6", "0"]), vec![5]);
|
||||
assert_eq!(lens(&["1", "1", "0"]), vec![5], "view runs follow the new paragraphs");
|
||||
|
||||
// Re-saving the file with the stub repairs it: the stub is replaced like a missing object.
|
||||
assert_eq!(crate::psd::parse_txt2(&broken).unwrap().path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2);
|
||||
let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&broken))).unwrap();
|
||||
assert_eq!(repaired, out, "a stub from an earlier save is replaced");
|
||||
// Also when the stub's layer is edited before the re-save.
|
||||
let edited = runs_of("v2.0", &[(4, CharStyle::default())]);
|
||||
let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &edited)], Some(&broken))).unwrap();
|
||||
let object = &repaired.path(&["1", "1"]).and_then(E::as_array).unwrap()[1];
|
||||
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v2.0\r"));
|
||||
assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "an edited stub is replaced too");
|
||||
|
||||
// No object to take the shape from: the fresh object as before, with the frames still padded.
|
||||
let bare = b"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 1 >> ] >> >> /1 << /1 [ << >> ] >> ";
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(1, &new)], Some(bare))).unwrap();
|
||||
assert_eq!(out.path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2);
|
||||
assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2);
|
||||
|
||||
// An oversized object is never the template (it would be copied once per new layer).
|
||||
let filler = "0 ".repeat(5000);
|
||||
let big = prev.iter().map(|&b| b as char).collect::<String>().replace("/2 [ << /99 /PC", &format!("/9 [ {filler}] /2 [ << /99 /PC"));
|
||||
let big: Vec<u8> = big.chars().map(|c| c as u32 as u8).collect();
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&big))).unwrap();
|
||||
let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[1];
|
||||
assert!(object.get("1").is_none(), "no copy of an oversized template");
|
||||
assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// The frame padding is bounded like the slots a layer can name: a crafted block of many empty
|
||||
/// objects and no frames must not make the save write a frame for each of them.
|
||||
#[test]
|
||||
fn txt2_frame_padding_is_bounded() {
|
||||
use crate::engine_data::Value as E;
|
||||
let objects = "<< >> ".repeat(70_000);
|
||||
let prev = format!("\n\n/98 << /0 7 >> /0 << /8 << /0 [ ] >> >> /1 << /1 [ {objects}] >> ");
|
||||
let t = runs_of("AV", &[(2, CharStyle::default())]);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(prev.as_bytes()))).unwrap();
|
||||
let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len();
|
||||
assert_eq!(frames, crate::psd::MAX_TEXT_INDEX as usize + 1);
|
||||
}
|
||||
|
||||
/// The file's style runs are only reused when they cover the text: runs of another length (or
|
||||
/// hostile lengths) fall back to generated runs, without panicking.
|
||||
#[test]
|
||||
|
||||
@@ -254,6 +254,8 @@ struct Sizes {
|
||||
big_photo: (u32, u32),
|
||||
/// A4 at 300 ppi.
|
||||
a4: (u32, u32),
|
||||
/// A new 10 × 5 m document at 200 ppi (3.1 GP, #4017).
|
||||
huge_new: (u32, u32),
|
||||
}
|
||||
|
||||
const FULL: Sizes = Sizes {
|
||||
@@ -266,6 +268,7 @@ const FULL: Sizes = Sizes {
|
||||
ten_mp: (3872, 2592),
|
||||
big_photo: (6000, 4000),
|
||||
a4: (2480, 3508),
|
||||
huge_new: (78_740, 39_370),
|
||||
};
|
||||
|
||||
const QUICK: Sizes = Sizes {
|
||||
@@ -278,6 +281,7 @@ const QUICK: Sizes = Sizes {
|
||||
ten_mp: (1200, 800),
|
||||
big_photo: (1600, 1066),
|
||||
a4: (1240, 1754),
|
||||
huge_new: (20_000, 10_000),
|
||||
};
|
||||
|
||||
/// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke,
|
||||
@@ -932,6 +936,7 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
}
|
||||
}
|
||||
liquify_preview_scenario(b);
|
||||
huge_new_scenario(b, sz);
|
||||
let cmyk = "brush dab on A4 300 ppi CMYK + refresh";
|
||||
if b.wanted(cmyk) {
|
||||
let (w, h) = sz.a4;
|
||||
@@ -995,6 +1000,30 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
}
|
||||
}
|
||||
|
||||
/// #4017: File > New at 10 × 5 m, 200 ppi (3.1 GP), and the first refresh of the canvas that
|
||||
/// draws a document too large for GPU textures (the reduced CPU canvas, `canvas::ensure_texture`).
|
||||
fn huge_new_scenario(b: &mut Bench, sz: &Sizes) {
|
||||
let name = "new 3 GP document + first reduced canvas refresh";
|
||||
if !b.wanted(name) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = sz.huge_new;
|
||||
let reps = b.reps.min(3);
|
||||
b.time(name, &mut Session::new(), reps, false, |_, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "file.new", json!({"width": w, "height": h, "resolution": 200, "background": "white"}))?;
|
||||
let doc = &s.active().ok_or("no document")?.doc;
|
||||
let factor = w.max(h).div_ceil(photocraft_ui_egui::canvas::MAX_TEXTURE).max(1);
|
||||
let reduced = photocraft_compose::render_reduced(doc, w / factor, h / factor);
|
||||
if reduced.px.first() != Some(&[1.0; 4]) {
|
||||
return Err("the new document is not white".into());
|
||||
}
|
||||
let v = ms(t);
|
||||
exec(s, "file.close", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
}
|
||||
|
||||
/// A source-resolution Liquify brush update on the visible part of a 3500×2200 layer.
|
||||
/// Reports the old whole-document proxy cost alongside the full-resolution cropped update.
|
||||
fn liquify_preview_scenario(b: &mut Bench) {
|
||||
|
||||
@@ -97,7 +97,15 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
|
||||
b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1);
|
||||
b.transfer.mix = dynamic(0.45, Control::Fade, 0.2);
|
||||
b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true };
|
||||
b.mixer = paint::MixerSettings {
|
||||
wet: 0.9,
|
||||
load: 0.15,
|
||||
mix: 0.7,
|
||||
flow: 0.8,
|
||||
sample_all_layers: true,
|
||||
load_after_stroke: false,
|
||||
clean_after_stroke: false,
|
||||
};
|
||||
}
|
||||
7 => {
|
||||
let p = &mut b.pose;
|
||||
@@ -488,6 +496,56 @@ fn mixer_brush_options_update_the_persisted_mixer_settings() {
|
||||
assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_clean_brush_and_the_automatic_load_keep_the_foreground_out() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = options_bar_harness(crate::state::Tool::MixerBrush);
|
||||
// A red image, an empty layer above it, a blue foreground, and a brush that samples all layers.
|
||||
for (id, p) in [
|
||||
("file.new", json!({"width": 200, "height": 40, "background": "transparent"})),
|
||||
("edit.fill", json!({"color": "#ff0000"})),
|
||||
("layer.new.layer", json!({})),
|
||||
("tools.setColors", json!({"foreground": "#0000ff"})),
|
||||
("tools.setBrush", json!({"brush": {"size": 8, "pressureSize": false, "mixer": {"sampleAllLayers": true}}})),
|
||||
] {
|
||||
h.state_mut().run(id, p).unwrap();
|
||||
}
|
||||
h.run_steps(3);
|
||||
for label in ["Current Brush Load", "Load the brush after each stroke", "Clean the brush after each stroke"] {
|
||||
assert!(h.get_by_label(label).rect().is_positive(), "missing Mixer Brush option {label}");
|
||||
}
|
||||
|
||||
// Turn the automatic Load off: one tools.setBrush call.
|
||||
h.get_by_label("Load the brush after each stroke").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.tools.brush.mixer.load_after_stroke);
|
||||
let (id, params) = last_journal(h.state()).unwrap();
|
||||
assert_eq!((id.as_str(), ¶ms["brush"]["mixer"]["loadAfterStroke"]), ("tools.setBrush", &json!(false)));
|
||||
|
||||
// Current Brush Load › Clean Brush empties the brush.
|
||||
h.get_by_label("Current Brush Load").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Clean Brush").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(last_journal(h.state()).map(|(id, _)| id), Some("brush.mixer.clean".to_string()));
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, None);
|
||||
|
||||
// The stroke the canvas commits on release lays down only the red it picks up.
|
||||
crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE);
|
||||
let d = h.state().session.active().unwrap();
|
||||
let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(100, 20);
|
||||
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the stroke carries no foreground: {c:?}");
|
||||
// With the automatic Load off, the stroke did not refill the brush either.
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, None);
|
||||
|
||||
// Load Brush fills it with the foreground again.
|
||||
h.get_by_label("Current Brush Load").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Load Brush").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_targets_and_actions_reorder_presets() {
|
||||
use crate::brushes_tab::{Action, apply, drop_target, group_key};
|
||||
|
||||
@@ -535,4 +535,33 @@ mod tests {
|
||||
app.session.prefs.edit(|p| p.cursors.brush_preview_color = "nonsense".into());
|
||||
assert_eq!(preview_tint(&app), Color32::from_rgba_unmultiplied(255, 0, 0, 110), "unreadable values keep the red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylus_barrel_button_resizes_with_alt_and_never_samples_color() {
|
||||
let mut a = app(Tool::Brush);
|
||||
a.services.right_button_down = Some(Box::new(|| true));
|
||||
a.sync_views();
|
||||
let mut h = canvas(a);
|
||||
let size0 = h.state().session.tools.brush.size;
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::ALT));
|
||||
h.event(egui::Event::PointerMoved(c));
|
||||
h.run_steps(1);
|
||||
button(&mut h, c, PointerButton::Primary, true, Modifiers::ALT);
|
||||
let mut p = c;
|
||||
for _ in 0..6 {
|
||||
p += vec2(10.0, 8.0);
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
}
|
||||
let r = h.state().brush_resize.expect("resizing via stylus barrel button");
|
||||
assert!(r.secondary);
|
||||
assert!(h.state().session.tools.brush.size > size0, "right grows the brush");
|
||||
assert!(h.state().session.tools.brush.hardness > 0.2, "down hardens the brush");
|
||||
assert!(!h.state().alt_sampling, "eyedropper never triggered");
|
||||
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke"), "nothing painted");
|
||||
button(&mut h, p, PointerButton::Primary, false, Modifiers::ALT);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().brush_resize.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +565,8 @@ fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
pct(ui, tl!("Mix"), &mut m.mix, 1.0);
|
||||
pct(ui, tl!("Flow"), &mut m.flow, 1.0);
|
||||
widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers");
|
||||
widgets::checkbox(ui, &mut m.load_after_stroke, tl!("Load the brush after each stroke"));
|
||||
widgets::checkbox(ui, &mut m.clean_after_stroke, tl!("Clean the brush after each stroke"));
|
||||
}
|
||||
|
||||
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//!
|
||||
//! The preview runs [`photocraft_algo::camera_raw::develop`] on a CPU proxy of the layer
|
||||
//! (≤ 900 px, `pixel_scale` keeps pixel radii true to the full image), recomputed when a
|
||||
//! control changes. OK runs `filter.cameraRaw` with the non-default settings, so the result is
|
||||
//! control changes: on a worker for the dialog's own controls, so a drag never waits for it, and
|
||||
//! on the UI thread for the control channel, so a reply describes its own settings. OK runs `filter.cameraRaw` with the non-default settings, so the result is
|
||||
//! one history step (or a smart filter on a smart object) and exactly replayable.
|
||||
//!
|
||||
//! Control channel: `ui.menu.invoke {"id":"filter.cameraRaw","params":{"ui":{"set":{…},
|
||||
@@ -38,8 +39,8 @@ pub struct CameraRawDialog {
|
||||
curve_state: crate::state::PointCurveState,
|
||||
curve_rect: Option<ERect>,
|
||||
/// Proxy pixels (straight RGBA) and size.
|
||||
pub(crate) proxy: Vec<[f32; 4]>,
|
||||
pub(crate) selection: Option<Vec<f32>>,
|
||||
pub(crate) proxy: std::sync::Arc<Vec<[f32; 4]>>,
|
||||
pub(crate) selection: Option<std::sync::Arc<Vec<f32>>>,
|
||||
pub(crate) pw: usize,
|
||||
pub(crate) ph: usize,
|
||||
full_w: usize,
|
||||
@@ -68,7 +69,11 @@ pub struct CameraRawDialog {
|
||||
raw_open: Option<RawOpen>,
|
||||
/// The open-time dialog's camera tone curve: Shadows / Highlights act on the raw data there,
|
||||
/// in linear light before this curve, and the preview undoes it to show the same.
|
||||
raw_curve: Option<Vec<[f32; 2]>>,
|
||||
raw_curve: Option<std::sync::Arc<Vec<[f32; 2]>>>,
|
||||
/// The proxy develop running on a worker ([`Self::request_render`]).
|
||||
pending: Option<std::sync::mpsc::Receiver<Result<ProxyFrame, String>>>,
|
||||
/// The last worker failure the status bar shows, with the preview revision it failed on.
|
||||
preview_error: Option<(String, u64)>,
|
||||
/// Settings before each finished edit, so Ctrl+Z works inside the dialog (#4100).
|
||||
undo: Vec<CameraRaw>,
|
||||
redo: Vec<CameraRaw>,
|
||||
@@ -91,6 +96,7 @@ impl CameraRawDialog {
|
||||
result["openingRaw"] = json!(self.raw_open.as_ref().map(|r| json!({"name": r.name, "path": r.path})));
|
||||
result["view"] = json!(navigation);
|
||||
result["sourceSize"] = json!([self.full_w, self.full_h]);
|
||||
result["previewPending"] = json!(self.render_pending());
|
||||
result["previewApproximate"] = json!(!self.detail.ready && (self.pw != self.full_w || self.ph != self.full_h));
|
||||
result["detailPending"] = json!(self.detail.pending());
|
||||
result["detailError"] = json!(self.detail.error);
|
||||
@@ -139,45 +145,150 @@ impl CameraRawDialog {
|
||||
})
|
||||
}
|
||||
|
||||
/// Develops the proxy with the current settings and shows it, on this thread. The control
|
||||
/// channel and the first frame use this, so a reply always describes its own settings; the
|
||||
/// dialog's own controls go through [`Self::request_render`].
|
||||
pub(crate) fn render(&mut self, ctx: &egui::Context) {
|
||||
// A render still running on the worker has older settings than this one.
|
||||
self.pending = None;
|
||||
let frame = self.proxy_job().run();
|
||||
self.publish(ctx, frame);
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
fn proxy_job(&self) -> ProxyJob {
|
||||
ProxyJob {
|
||||
proxy: self.proxy.clone(),
|
||||
selection: self.selection.clone(),
|
||||
raw_curve: self.raw_curve.clone(),
|
||||
params: self.params.clone(),
|
||||
float: self.float,
|
||||
size: (self.pw, self.ph),
|
||||
full_w: self.full_w,
|
||||
display: self.display.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(&mut self, ctx: &egui::Context, frame: ProxyFrame) {
|
||||
match &mut self.tex {
|
||||
Some(t) => t.set(frame.image, egui::TextureOptions::LINEAR),
|
||||
None => self.tex = Some(ctx.load_texture("camera-raw-after", frame.image, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
if self.before_display_key != Some(frame.display.key) {
|
||||
let before = Self::image(&self.proxy, self.pw, self.ph, &frame.display);
|
||||
match &mut self.before_tex {
|
||||
Some(t) => t.set(before, egui::TextureOptions::LINEAR),
|
||||
None => self.before_tex = Some(ctx.load_texture("camera-raw-before", before, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
self.before_display_key = Some(frame.display.key);
|
||||
}
|
||||
self.after_histogram = frame.histogram;
|
||||
self.histogram_ms = frame.histogram_ms;
|
||||
self.processed = frame.px;
|
||||
self.scope.cache = None;
|
||||
self.render_ms = frame.ms;
|
||||
self.preview_revision = self.preview_revision.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Starts developing the proxy with the current settings on a worker, unless one is already
|
||||
/// running: then [`Self::poll_render`] starts the next one with the settings of that moment.
|
||||
/// A slider drag never waits for the develop, which takes hundreds of milliseconds with
|
||||
/// Shadows or Highlights set (#5964); the preview shows the latest finished frame.
|
||||
fn request_render(&mut self, ctx: &egui::Context) {
|
||||
if !self.dirty || self.pending.is_some() {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let job = self.proxy_job();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<ProxyFrame, String>>();
|
||||
let repaint = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("camera-raw-proxy".into()).spawn(move || {
|
||||
let frame = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run())).map_err(|_| "Camera Raw preview failed".to_string());
|
||||
// The dialog may have closed or rendered on its own meanwhile; then nobody listens.
|
||||
if tx.send(frame).is_ok() {
|
||||
repaint.request_repaint();
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => {
|
||||
self.pending = Some(rx);
|
||||
self.dirty = false;
|
||||
}
|
||||
// No thread to spare: develop here rather than show stale settings.
|
||||
Err(_) => self.render(ctx),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.render(ctx);
|
||||
}
|
||||
|
||||
/// Shows the worker's frame once it is done. `Err` when the develop failed; the preview keeps
|
||||
/// the last frame then.
|
||||
fn poll_render(&mut self, ctx: &egui::Context) -> Result<(), String> {
|
||||
let Some(rx) = &self.pending else { return Ok(()) };
|
||||
let frame = match rx.try_recv() {
|
||||
Ok(frame) => frame,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("Camera Raw preview worker stopped".to_string()),
|
||||
};
|
||||
self.pending = None;
|
||||
self.publish(ctx, frame?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn render_pending(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one develop of the proxy reads, owned, so it can run off the UI thread.
|
||||
struct ProxyJob {
|
||||
proxy: std::sync::Arc<Vec<[f32; 4]>>,
|
||||
selection: Option<std::sync::Arc<Vec<f32>>>,
|
||||
raw_curve: Option<std::sync::Arc<Vec<[f32; 2]>>>,
|
||||
params: CameraRaw,
|
||||
float: bool,
|
||||
size: (usize, usize),
|
||||
full_w: usize,
|
||||
display: std::sync::Arc<photocraft_engine::display_color::CanvasDisplay>,
|
||||
}
|
||||
|
||||
/// A developed proxy, ready to show.
|
||||
struct ProxyFrame {
|
||||
px: Vec<[f32; 4]>,
|
||||
histogram: RgbHistogram,
|
||||
histogram_ms: f64,
|
||||
image: egui::ColorImage,
|
||||
display: std::sync::Arc<photocraft_engine::display_color::CanvasDisplay>,
|
||||
ms: f64,
|
||||
}
|
||||
|
||||
impl ProxyJob {
|
||||
fn run(self) -> ProxyFrame {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let mut px = self.proxy.clone();
|
||||
let mut p = self.params.clone();
|
||||
p.pixel_scale = (self.pw as f32 / self.full_w.max(1) as f32).min(1.0);
|
||||
let (pw, ph) = self.size;
|
||||
let mut px = self.proxy.to_vec();
|
||||
let mut p = self.params;
|
||||
p.pixel_scale = (pw as f32 / self.full_w.max(1) as f32).min(1.0);
|
||||
if let Some(curve) = &self.raw_curve {
|
||||
let tone = raw_stage(&p);
|
||||
photocraft_io::raw::retone(&mut px, self.pw, self.ph, curve, tone);
|
||||
photocraft_io::raw::retone(&mut px, pw, ph, curve, tone);
|
||||
(p.shadows, p.highlights, p.clarity, p.texture, p.dehaze) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
photocraft_algo::camera_raw::develop(&mut px, self.pw, self.ph, &p, self.float);
|
||||
photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float);
|
||||
if let Some(mask) = &self.selection {
|
||||
for ((out, original), coverage) in px.iter_mut().zip(&self.proxy).zip(mask) {
|
||||
for ((out, original), coverage) in px.iter_mut().zip(self.proxy.iter()).zip(mask.iter()) {
|
||||
for (out, original) in out.iter_mut().zip(original) {
|
||||
*out = *original + (*out - *original) * coverage;
|
||||
}
|
||||
}
|
||||
}
|
||||
let histogram_start = crate::gpu_canvas::now_ms();
|
||||
self.after_histogram = RgbHistogram::from_rgba(&px);
|
||||
self.histogram_ms = crate::gpu_canvas::now_ms() - histogram_start;
|
||||
let img = Self::image(&px, self.pw, self.ph, &self.display);
|
||||
match &mut self.tex {
|
||||
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
|
||||
None => self.tex = Some(ctx.load_texture("camera-raw-after", img, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
if self.before_display_key != Some(self.display.key) {
|
||||
let before = Self::image(&self.proxy, self.pw, self.ph, &self.display);
|
||||
match &mut self.before_tex {
|
||||
Some(t) => t.set(before, egui::TextureOptions::LINEAR),
|
||||
None => self.before_tex = Some(ctx.load_texture("camera-raw-before", before, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
self.before_display_key = Some(self.display.key);
|
||||
}
|
||||
self.processed = px;
|
||||
self.scope.cache = None;
|
||||
self.render_ms = crate::gpu_canvas::now_ms() - t0;
|
||||
self.preview_revision = self.preview_revision.saturating_add(1);
|
||||
self.dirty = false;
|
||||
let histogram = RgbHistogram::from_rgba(&px);
|
||||
let histogram_ms = crate::gpu_canvas::now_ms() - histogram_start;
|
||||
let image = CameraRawDialog::image(&px, pw, ph, &self.display);
|
||||
ProxyFrame { px, histogram, histogram_ms, image, display: self.display, ms: crate::gpu_canvas::now_ms() - t0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +356,7 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul
|
||||
};
|
||||
if let Some(d) = app.camera_raw.as_mut() {
|
||||
d.layer_name = raw.name.clone();
|
||||
d.raw_curve = curve;
|
||||
d.raw_curve = curve.map(std::sync::Arc::new);
|
||||
d.raw_open = Some(raw);
|
||||
}
|
||||
Ok(())
|
||||
@@ -611,8 +722,8 @@ fn open_pixels(
|
||||
after_histogram: RgbHistogram::default(),
|
||||
preview_revision: 0,
|
||||
histogram_ms: 0.0,
|
||||
proxy,
|
||||
selection,
|
||||
proxy: std::sync::Arc::new(proxy),
|
||||
selection: selection.map(std::sync::Arc::new),
|
||||
pw,
|
||||
ph,
|
||||
full_w: w,
|
||||
@@ -643,6 +754,8 @@ fn open_pixels(
|
||||
render_ms: 0.0,
|
||||
raw_open: None,
|
||||
raw_curve: None,
|
||||
pending: None,
|
||||
preview_error: None,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
edit_origin: None,
|
||||
@@ -1073,8 +1186,22 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
|
||||
}
|
||||
d.curve_rect = None;
|
||||
d.scope.vectorscope_rect = None;
|
||||
if d.dirty {
|
||||
d.render(ctx);
|
||||
if let Err(e) = d.poll_render(ctx) {
|
||||
d.preview_error = Some((e.clone(), d.preview_revision));
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
d.request_render(ctx);
|
||||
// A newer frame means the failure is over: take its message down, unless something else
|
||||
// has reported since.
|
||||
if let Some((e, revision)) = &d.preview_error
|
||||
&& *revision != d.preview_revision
|
||||
{
|
||||
if app.ui.status_error && app.ui.status == *e {
|
||||
app.ui.status.clear();
|
||||
app.ui.status_error = false;
|
||||
}
|
||||
d.preview_error = None;
|
||||
}
|
||||
let (full, _) = ui.allocate_exact_size(screen.size(), Sense::click());
|
||||
let painter = ui.painter().clone();
|
||||
@@ -1247,6 +1374,10 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
|
||||
if d.dirty {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if d.render_pending() {
|
||||
// The worker asks for a repaint when it is done; this catches a missed wake-up.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
// Footer.
|
||||
let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max);
|
||||
painter.rect_filled(foot, 0.0, t.dock);
|
||||
@@ -1398,6 +1529,148 @@ mod tests {
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
/// A layer with tonal variation, a selection over part of it and the dialog open on it.
|
||||
fn worker_app() -> (PhotocraftApp, egui::Context) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#406080"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
||||
open(&mut app, &ctx).unwrap();
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
fn finish_worker(d: &mut CameraRawDialog, ctx: &egui::Context) {
|
||||
for _ in 0..2000 {
|
||||
d.poll_render(ctx).unwrap();
|
||||
if !d.render_pending() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the Camera Raw preview worker never finished");
|
||||
}
|
||||
|
||||
/// The dialog's controls develop the proxy on a worker (#5964): the frame it shows is the one
|
||||
/// the UI thread would have developed, and settings changed while it runs are developed next.
|
||||
#[test]
|
||||
fn worker_preview_matches_the_synchronous_render_and_ends_on_the_latest_settings() {
|
||||
let (mut app, ctx) = worker_app();
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
assert!(d.selection.is_some());
|
||||
let revision = d.preview_revision;
|
||||
d.params.exposure = 1.0;
|
||||
d.dirty = true;
|
||||
d.request_render(&ctx);
|
||||
assert!(d.render_pending() && !d.dirty);
|
||||
// A drag moves on while the first develop runs; the request waits for it.
|
||||
d.params.highlights = -60.0;
|
||||
d.params.shadows = 45.0;
|
||||
d.dirty = true;
|
||||
d.request_render(&ctx);
|
||||
finish_worker(d, &ctx);
|
||||
assert_eq!(d.preview_revision, revision + 1);
|
||||
assert!(d.dirty, "the newer settings are still to be developed");
|
||||
d.request_render(&ctx);
|
||||
finish_worker(d, &ctx);
|
||||
assert_eq!(d.preview_revision, revision + 2);
|
||||
let (from_worker, histogram) = (d.processed.clone(), d.after_histogram.channels);
|
||||
d.render(&ctx);
|
||||
assert_eq!(d.processed, from_worker, "the worker develops exactly what the UI thread does");
|
||||
assert_eq!(d.after_histogram.channels, histogram);
|
||||
assert!(d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)["previewPending"] == false);
|
||||
}
|
||||
|
||||
/// The control channel renders on its own thread so its reply describes its settings; a worker
|
||||
/// still running with older settings must not replace that frame afterwards.
|
||||
#[test]
|
||||
fn a_stale_worker_never_replaces_a_newer_synchronous_render() {
|
||||
let (mut app, ctx) = worker_app();
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
d.params.highlights = -80.0;
|
||||
d.dirty = true;
|
||||
d.request_render(&ctx);
|
||||
assert!(d.render_pending());
|
||||
let reply = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"highlights": 0, "exposure": -1.0}}})).unwrap().unwrap();
|
||||
assert_eq!(reply["previewPending"], false);
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
let (revision, shown) = (d.preview_revision, d.processed.clone());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
d.poll_render(&ctx).unwrap();
|
||||
assert_eq!((d.preview_revision, &d.processed), (revision, &shown));
|
||||
d.render(&ctx);
|
||||
assert_eq!(d.processed, shown, "the frame shown is the one for the control channel's settings");
|
||||
}
|
||||
|
||||
/// Dragging a slider in the dialog itself never develops on the UI thread: the frame that
|
||||
/// takes the change returns with the develop still running, and the preview catches up.
|
||||
#[test]
|
||||
fn a_dialog_edit_develops_off_the_ui_thread() {
|
||||
let (mut app, ctx) = worker_app();
|
||||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
|
||||
let frame = |app: &mut PhotocraftApp| {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())).textures_delta.clear();
|
||||
};
|
||||
frame(&mut app);
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
let revision = d.preview_revision;
|
||||
d.params.highlights = -60.0;
|
||||
d.dirty = true;
|
||||
frame(&mut app);
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
assert!(d.render_pending() && d.preview_revision == revision, "the frame returned before the develop finished");
|
||||
finish_worker(d, &ctx);
|
||||
assert_eq!(d.preview_revision, revision + 1);
|
||||
assert!(!d.dirty);
|
||||
frame(&mut app);
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
}
|
||||
|
||||
/// A failed worker develop shows its error until a later frame is published, and never takes
|
||||
/// down an error something else reported meanwhile.
|
||||
#[test]
|
||||
fn a_worker_failure_is_reported_until_the_next_frame() {
|
||||
let (mut app, ctx) = worker_app();
|
||||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
|
||||
let frame = |app: &mut PhotocraftApp| {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())).textures_delta.clear();
|
||||
};
|
||||
let fail = |app: &mut PhotocraftApp| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
tx.send(Err("Camera Raw preview failed".to_string())).unwrap();
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
d.pending = Some(rx);
|
||||
d.dirty = false;
|
||||
};
|
||||
let edit = |app: &mut PhotocraftApp, exposure: f32| {
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
d.params.exposure = exposure;
|
||||
d.dirty = true;
|
||||
};
|
||||
frame(&mut app);
|
||||
fail(&mut app);
|
||||
frame(&mut app);
|
||||
assert!(app.ui.status_error && app.ui.status == "Camera Raw preview failed", "{}", app.ui.status);
|
||||
// No new frame yet: the error stays.
|
||||
frame(&mut app);
|
||||
assert!(app.ui.status_error);
|
||||
edit(&mut app, 0.5);
|
||||
frame(&mut app);
|
||||
finish_worker(app.camera_raw.as_mut().unwrap(), &ctx);
|
||||
frame(&mut app);
|
||||
assert!(!app.ui.status_error && app.ui.status.is_empty(), "{}", app.ui.status);
|
||||
|
||||
fail(&mut app);
|
||||
frame(&mut app);
|
||||
app.ui.status = "something else failed".into();
|
||||
edit(&mut app, 1.0);
|
||||
frame(&mut app);
|
||||
finish_worker(app.camera_raw.as_mut().unwrap(), &ctx);
|
||||
frame(&mut app);
|
||||
assert!(app.ui.status_error && app.ui.status == "something else failed", "{}", app.ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_filter_double_click_edits_the_stored_camera_raw_filter_in_its_own_dialog() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
|
||||
@@ -3013,7 +3013,7 @@ const DISPLAY_LUT: usize = 33;
|
||||
/// Keep the GPU display LUT under `key` (the document's texture or a preview of it) in step with
|
||||
/// `doc`'s colour management: document → monitor profile and View › Proof Colors / Gamut
|
||||
/// Warning (the 32-bit preview is applied by the canvas shader, see [`hdr_preview`]). Returns the canvas `display` mode (0 none — the identity, e.g. sRGB on
|
||||
/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning).
|
||||
/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning, 3 matrix/shaper).
|
||||
fn sync_display_lut(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, key: u64, display: Option<u32>) -> u8 {
|
||||
let Some(gpu) = app.gpu.clone() else { return 0 };
|
||||
let output = display.unwrap_or(0);
|
||||
@@ -3024,6 +3024,13 @@ fn sync_display_lut(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, key
|
||||
{
|
||||
return mode;
|
||||
}
|
||||
// Keep the curve/matrix/curve stages separate: interpolating a finished display
|
||||
// transform on a 33³ lattice crushes shadows when the monitor curve is steep near zero.
|
||||
if let Ok(Some(shaper)) = app.session.color.gpu_canvas_matrix_shaper_for(doc, display) {
|
||||
gpu.set_display_matrix_shaper(key, output, &shaper);
|
||||
gpu.cache_display_lut_signature(key, output, sig, 3);
|
||||
return 3;
|
||||
}
|
||||
let gamut = app.session.color.proof(doc.id).gamut_warning;
|
||||
let mode = match app.session.color.gpu_canvas_lut_for(doc, DISPLAY_LUT, display) {
|
||||
Ok(Some(bytes)) => {
|
||||
@@ -3735,7 +3742,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// Live painting tools start on the press, not once the pointer passes egui's click
|
||||
// distance: the first dab shows at once. The drag recognised later continues that stroke,
|
||||
// and a click (or a long press that never moved) just ends it.
|
||||
let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f());
|
||||
let right_down = ui.input(|i| i.pointer.secondary_down()) || physical_right;
|
||||
if strokes_live(tool)
|
||||
&& !right_down
|
||||
&& app.drag.is_none()
|
||||
&& response.is_pointer_button_down_on()
|
||||
&& ui.input(|i| i.pointer.primary_pressed())
|
||||
@@ -3785,7 +3795,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
};
|
||||
let press_this_frame = events.iter().any(|e| matches!(e, egui::Event::PointerButton { button: b, pressed: true, .. } if *b == button));
|
||||
let down_at_start = !press_this_frame && (gesture_active_before || buttons.started);
|
||||
let mut positions = if freehand_tool(tool) {
|
||||
let mut positions = if app.brush_resize.is_some() {
|
||||
response.interact_pointer_pos().into_iter().collect()
|
||||
} else if freehand_tool(tool) {
|
||||
pointer_moves(&events, button, down_at_start)
|
||||
} else if buttons.dragged {
|
||||
response.interact_pointer_pos().into_iter().collect()
|
||||
@@ -4099,7 +4111,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None,
|
||||
// ⌥ turns a painting tool into the Eyedropper (`alt_eyedropper`): its cursor too,
|
||||
// unless Preferences › Cursors › Other Cursors asks for the precise crosshair.
|
||||
t if app.alt_sampling || (app.drag.is_none() && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => {
|
||||
t if app.alt_sampling
|
||||
|| (app.drag.is_none()
|
||||
&& !app.services.right_button_down.as_ref().is_some_and(|f| f())
|
||||
&& alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) =>
|
||||
{
|
||||
// While it samples (button held), the comparison ring replaces the cursor (#213).
|
||||
if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) {
|
||||
icon
|
||||
@@ -5183,7 +5199,8 @@ pub(crate) fn paint_bucket_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorI
|
||||
/// painting and sampling.
|
||||
fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
||||
if matches!(ev, ToolEvent::Down { .. }) {
|
||||
app.alt_sampling = alt_samples(app.active_tool(), mods);
|
||||
let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f());
|
||||
app.alt_sampling = !physical_right && alt_samples(app.active_tool(), mods);
|
||||
}
|
||||
if !app.alt_sampling {
|
||||
return false;
|
||||
@@ -6863,7 +6880,7 @@ mod tests {
|
||||
let key = doc.id.0;
|
||||
// sRGB on the sRGB display needs no LUT; on the P3 display it does.
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(1)), 0);
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 1);
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 3);
|
||||
let gpu = app.gpu.clone().unwrap();
|
||||
let (s1, s4) = (gpu.display_lut_signature(key, 1).unwrap(), gpu.display_lut_signature(key, 4).unwrap());
|
||||
assert_ne!(s1.0, s4.0);
|
||||
@@ -6871,7 +6888,7 @@ mod tests {
|
||||
// Drawing one window leaves the other's LUT alone.
|
||||
for _ in 0..3 {
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(1)), 0);
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 1);
|
||||
assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 3);
|
||||
}
|
||||
assert_eq!((gpu.display_lut_signature(key, 1), gpu.display_lut_signature(key, 4)), (Some(s1), Some(s4)));
|
||||
assert!(gpu.has_display_lut(key, 4));
|
||||
|
||||
@@ -1717,7 +1717,7 @@ mod tests {
|
||||
let image = clipboard.clone();
|
||||
let mut app = PhotocraftApp::new(
|
||||
photocraft_engine::Session::new(),
|
||||
crate::Services { clipboard_get_image: Some(Box::new(move || image.lock().ok()?.clone())), ..Default::default() },
|
||||
crate::Services { clipboard_get_image: Some(Box::new(move || Ok(image.lock().unwrap().clone()))), ..Default::default() },
|
||||
);
|
||||
let ctx = egui::Context::default();
|
||||
let opened = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"newDocument"}));
|
||||
|
||||
@@ -772,6 +772,44 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 {
|
||||
app.ui.open_dialog(DialogKind::Command, f)
|
||||
}
|
||||
|
||||
/// Whether the driver's paper, as `driver_page` took it, is wider than tall: the Orientation
|
||||
/// buttons show landscape then.
|
||||
fn driver_paper_is_wide(f: &Map<String, Value>) -> bool {
|
||||
let side = |i: usize| f.get("paper").and_then(Value::as_array).and_then(|a| a.get(i)).and_then(Value::as_f64);
|
||||
matches!((side(0), side(1)), (Some(w), Some(h)) if w > h)
|
||||
}
|
||||
|
||||
/// The Orientation buttons: the driver settings (or the printer's own) turned portrait or
|
||||
/// landscape through `file.print.page`. An error stays on the dialog, with the settings left as
|
||||
/// they were.
|
||||
fn turn_driver(app: &mut PhotocraftApp, f: &mut Map<String, Value>, landscape: bool) {
|
||||
let mut q = json!({"printer": s(f, "printer", ""), "orientation": if landscape { "landscape" } else { "portrait" }});
|
||||
if let (Some(h), Some(o)) = (f.get("printerSettings").cloned(), q.as_object_mut()) {
|
||||
o.insert("printerSettings".into(), h);
|
||||
}
|
||||
match app.session.execute("file.print.page", q) {
|
||||
Ok(r) => take_turned_page(f, &r),
|
||||
Err(e) => {
|
||||
f.insert("__pageError".into(), json!(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The turned settings and the paper they give, both from the one `file.print.page` answer, as
|
||||
/// `driver_page` takes a page: asking the driver again could fail and leave new settings with the
|
||||
/// old paper. An answer without both leaves the dialog as it was, with the reason shown.
|
||||
fn take_turned_page(f: &mut Map<String, Value>, r: &Value) {
|
||||
let paper = r.get("paper").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let (Some(settings), Some((w, h))) = (r.get("printerSettings").and_then(Value::as_str), paper) else {
|
||||
f.insert("__pageError".into(), json!("the printer did not report the turned page"));
|
||||
return;
|
||||
};
|
||||
f.insert("printerSettings".into(), json!(settings));
|
||||
f.insert("paper".into(), json!([w, h]));
|
||||
f.insert("orientation".into(), json!("portrait"));
|
||||
f.remove("__pageError");
|
||||
}
|
||||
|
||||
/// Takes the paper from the printer's driver settings (Windows), as Photoshop does.
|
||||
fn driver_page(f: &mut Map<String, Value>) {
|
||||
let printer = s(f, "printer", "");
|
||||
@@ -1006,6 +1044,17 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
|
||||
}
|
||||
}
|
||||
});
|
||||
// Photoshop's Layout: Portrait / Landscape turn the driver settings, so the paper
|
||||
// and the preview turn with them without opening Print Settings….
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Orientation")).color(t.text_dim));
|
||||
let wide = driver_paper_is_wide(f);
|
||||
for (landscape, label) in [(false, "Portrait"), (true, "Landscape")] {
|
||||
if crate::widgets::pill_tab(ui, label, wide == landscape).clicked() && wide != landscape {
|
||||
turn_driver(app, f, landscape);
|
||||
}
|
||||
}
|
||||
});
|
||||
let paper = p.get("paper").and_then(Value::as_array).map(|a| (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)));
|
||||
let text = match paper {
|
||||
Some((Some(w), Some(h))) => format!("{:.0} × {:.0} mm · {}", w / 72.0 * 25.4, h / 72.0 * 25.4, tl!("set in Print Settings…")),
|
||||
@@ -1530,6 +1579,65 @@ mod tests {
|
||||
std::fs::remove_file(out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The Orientation buttons show the driver paper's shape: landscape when it is wider than tall.
|
||||
#[test]
|
||||
fn orientation_follows_the_driver_paper() {
|
||||
let with = |paper: Value| {
|
||||
let mut f = Map::new();
|
||||
f.insert("paper".into(), paper);
|
||||
f
|
||||
};
|
||||
assert!(driver_paper_is_wide(&with(json!([1191.14, 842.29]))));
|
||||
assert!(!driver_paper_is_wide(&with(json!([842.29, 1191.14]))));
|
||||
assert!(!driver_paper_is_wide(&with(json!("a3"))), "a named paper is not a driver page");
|
||||
assert!(!driver_paper_is_wide(&Map::new()));
|
||||
}
|
||||
|
||||
/// Turning a printer whose driver can't be asked keeps its settings and says why on the dialog.
|
||||
#[test]
|
||||
fn turning_without_a_driver_keeps_the_settings_and_shows_the_error() {
|
||||
// Valid driver settings (a minimal DEVMODEW: 220 bytes, no private part), so the command
|
||||
// gets past reading them and fails where it asks the (missing) printer for its page.
|
||||
let mut dm = vec![0u8; 220];
|
||||
dm[68..70].copy_from_slice(&220u16.to_le_bytes());
|
||||
let hex: String = dm.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert!(craft_print::rgb::DevMode::from_hex(&hex).is_ok());
|
||||
let (mut app, _) = app();
|
||||
let mut f = Map::new();
|
||||
f.insert("printer".into(), json!("photocraft-no-such-printer"));
|
||||
f.insert("printerSettings".into(), json!(hex));
|
||||
f.insert("paper".into(), json!([595.28, 841.89]));
|
||||
turn_driver(&mut app, &mut f, true);
|
||||
assert_eq!(f.get("printerSettings"), Some(&json!(hex)));
|
||||
assert_eq!(f.get("paper"), Some(&json!([595.28, 841.89])));
|
||||
let error = f.get("__pageError").and_then(Value::as_str).unwrap_or_default();
|
||||
// Reading bad settings fails with "printer settings are too short / damaged"; this must not.
|
||||
assert!(!error.is_empty() && !error.contains("printer settings are"), "failed at the printer, not at reading the settings: {error}");
|
||||
}
|
||||
|
||||
/// A turn takes the settings and the paper from the same answer, never one without the other.
|
||||
#[test]
|
||||
fn a_turn_takes_settings_and_paper_together() {
|
||||
let fields = || {
|
||||
let mut f = Map::new();
|
||||
f.insert("printerSettings".into(), json!("old"));
|
||||
f.insert("paper".into(), json!([595.28, 841.89]));
|
||||
f.insert("__pageError".into(), json!("earlier"));
|
||||
f
|
||||
};
|
||||
let mut f = fields();
|
||||
take_turned_page(&mut f, &json!({"printerSettings": "new", "paper": [841.89, 595.28]}));
|
||||
assert_eq!((f.get("printerSettings"), f.get("paper")), (Some(&json!("new")), Some(&json!([841.89, 595.28]))));
|
||||
assert_eq!(f.get("orientation"), Some(&json!("portrait")), "the driver paper is already turned");
|
||||
assert!(f.get("__pageError").is_none());
|
||||
for partial in [json!({"printerSettings": "new"}), json!({"paper": [841.89, 595.28]}), json!({"printerSettings": "new", "paper": [841.89]})] {
|
||||
let mut f = fields();
|
||||
take_turned_page(&mut f, &partial);
|
||||
assert_eq!((f.get("printerSettings"), f.get("paper")), (Some(&json!("old")), Some(&json!([595.28, 841.89]))), "{partial}");
|
||||
assert!(f.get("__pageError").is_some(), "{partial}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -79,7 +79,7 @@ pub struct ViewParams {
|
||||
/// Distinguishes views painted in the same frame (main canvas, extra windows).
|
||||
pub view_key: u64,
|
||||
/// Display transform: 0 none, 1 the document's display LUT (document → monitor profile,
|
||||
/// Proof Colors), 2 LUT plus the gamut warning (see [`GpuCanvas::set_display_lut`]).
|
||||
/// Proof Colors), 2 LUT plus the gamut warning, 3 matrix/shaper (see [`GpuCanvas::set_display_lut`]).
|
||||
pub display: u8,
|
||||
/// The display the view is on (its LUT is per document and display, #569; 0 = unknown).
|
||||
pub output: u32,
|
||||
@@ -798,12 +798,25 @@ impl GpuCanvas {
|
||||
log::warn!("set_display_lut: {} bytes for a {size}³ LUT; ignored", bytes.len());
|
||||
return;
|
||||
}
|
||||
let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes);
|
||||
let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes, None);
|
||||
res.luts.insert((doc, output), bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a separately sampled curve/matrix/curve display transform. CMS validates this
|
||||
/// representation and provides bounded tables; no profile interpretation lives in the UI.
|
||||
pub fn set_display_matrix_shaper(&self, doc: u64, output: u32, shaper: &photocraft_cms::GpuMatrixShaper) {
|
||||
if !self.health.is_ok() {
|
||||
return;
|
||||
}
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
||||
let bg = lut_bind_group(&self.rs.device, &self.rs.queue, &res.lut_bgl, 2, &identity_lut_bytes(), Some(shaper));
|
||||
res.display_lut_signatures.remove(&(doc, output));
|
||||
res.luts.insert((doc, output), bg);
|
||||
}
|
||||
|
||||
/// Read back level 0 of document `key`'s canvas texture: its format, size and the stored
|
||||
/// (premultiplied) texels as `f32` RGBA, row-major. Waits for the GPU; for tests, benchmarks
|
||||
/// and debugging.
|
||||
@@ -1062,6 +1075,9 @@ pub struct GpuInfo {
|
||||
pub video_memory_mb: Option<u64>,
|
||||
/// `performance.gpuBackend` at launch.
|
||||
pub preference: String,
|
||||
/// Every graphics adapter the window could have used, by name (Preferences › Performance ›
|
||||
/// Graphics Adapter lists them). Empty in hosts that don't set it.
|
||||
pub adapters: Vec<String>,
|
||||
/// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`).
|
||||
pub selected: String,
|
||||
/// Why startup didn't use the preference as is (previous start crashed, `--safe-gpu`,
|
||||
@@ -1077,6 +1093,17 @@ pub struct GpuInfo {
|
||||
}
|
||||
|
||||
impl GpuInfo {
|
||||
/// Record the adapters the instance found, without duplicates (one GPU shows up once per backend).
|
||||
pub fn set_available(&mut self, adapters: &[wgpu::Adapter]) {
|
||||
self.adapters.clear();
|
||||
for a in adapters {
|
||||
let name = a.get_info().name;
|
||||
if !name.is_empty() && !self.adapters.contains(&name) {
|
||||
self.adapters.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the adapter fields from `info`.
|
||||
pub fn set_adapter(&mut self, info: &wgpu::AdapterInfo) {
|
||||
self.adapter = info.name.clone();
|
||||
@@ -1595,8 +1622,16 @@ impl Default for CanvasStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `size`³ RGBA8 3D texture bound for the canvas shader.
|
||||
fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGroupLayout, size: u32, bytes: &[u8]) -> wgpu::BindGroup {
|
||||
/// A display transform: either the existing 3D LUT or separate full-float shapers and
|
||||
/// matrix. Pack curves into short rows to stay within WebGL2's texture-size limits.
|
||||
fn lut_bind_group(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
bgl: &wgpu::BindGroupLayout,
|
||||
size: u32,
|
||||
bytes: &[u8],
|
||||
shaper: Option<&photocraft_cms::GpuMatrixShaper>,
|
||||
) -> wgpu::BindGroup {
|
||||
let extent = wgpu::Extent3d { width: size, height: size, depth_or_array_layers: size };
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("pc_display_lut"),
|
||||
@@ -1615,10 +1650,48 @@ fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGr
|
||||
extent,
|
||||
);
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let (width, height, curves, matrix) = if let Some(s) = shaper {
|
||||
let width = 1024usize;
|
||||
let count = s.input_table().len() + s.output_table().len();
|
||||
let height = count.div_ceil(width);
|
||||
let mut curves = Vec::with_capacity(width * height * 16);
|
||||
for value in s.input_table().iter().chain(s.output_table()).flatten() {
|
||||
curves.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
curves.resize(width * height * 16, 0);
|
||||
(width as u32, height as u32, curves, s.matrix_rows())
|
||||
} else {
|
||||
(1, 1, vec![0; 16], [[0.0; 4]; 3])
|
||||
};
|
||||
let extent = wgpu::Extent3d { width, height, depth_or_array_layers: 1 };
|
||||
let curves_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("pc_display_shapers"),
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
// textureLoad + explicit interpolation needs no optional float-filtering feature.
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &curves_texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
&curves,
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 16), rows_per_image: Some(height) },
|
||||
extent,
|
||||
);
|
||||
let curves_view = curves_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let matrix_bytes: Vec<u8> = matrix.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let matrix_buffer = init_buffer(device, "pc_display_matrix", &matrix_bytes, wgpu::BufferUsages::UNIFORM);
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_display_lut"),
|
||||
layout: bgl,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }],
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&curves_view) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: matrix_buffer.as_entire_binding() },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1731,7 +1804,20 @@ impl Resources {
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_display_lut"), entries: &[lut_entry] });
|
||||
let curves_entry = wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
multisampled: false,
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("pc_display_lut"),
|
||||
entries: &[lut_entry, curves_entry, uniform_entry(2, 48, wgpu::ShaderStages::FRAGMENT)],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("pc_canvas"),
|
||||
bind_group_layouts: &[Some(&view_bgl), Some(&tile_bgl), Some(&lut_bgl)],
|
||||
@@ -1802,7 +1888,7 @@ impl Resources {
|
||||
});
|
||||
let encode_pipeline = pipeline(device, "pc_encode", &encode_layout, &encode_module, ("vs", "fs"), FORMAT, None);
|
||||
let encode_pipeline_high = high.then(|| pipeline(device, "pc_encode_16f", &encode_layout, &encode_module, ("vs", "fs"), FORMAT_HIGH, None));
|
||||
let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes());
|
||||
let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes(), None);
|
||||
Self {
|
||||
lut_bgl,
|
||||
luts: HashMap::new(),
|
||||
@@ -1881,11 +1967,34 @@ fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu:
|
||||
|
||||
impl DocTextures {
|
||||
/// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit
|
||||
/// the canvas budget.
|
||||
/// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB
|
||||
/// of textures, #4017) lowers the budget below it, so that canvas and larger ones are drawn
|
||||
/// on the CPU canvas instead of the out-of-memory error disabling the GPU for the session.
|
||||
fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option<Self> {
|
||||
if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget.load(Ordering::Relaxed) {
|
||||
let bytes = canvas_texture_bytes(size, tile, texel_bytes(format));
|
||||
if bytes > res.canvas_budget.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let set = Self::create(device, res, size, tile, format);
|
||||
// Native wgpu reports these synchronously; where they arrive later (WebGPU) none is seen.
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
let mut error = None;
|
||||
for pop in [memory.pop(), validation.pop()] {
|
||||
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
if let Some(e) = error {
|
||||
log::warn!("{} MB of canvas textures failed ({e}); larger canvases are drawn on the CPU", bytes >> 20);
|
||||
res.canvas_budget.fetch_min(bytes.saturating_sub(1), Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
Some(set)
|
||||
}
|
||||
|
||||
fn create(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self {
|
||||
let mut tiles = Vec::new();
|
||||
for ty in (0..size[1]).step_by(tile as usize) {
|
||||
for tx in (0..size[0]).step_by(tile as usize) {
|
||||
@@ -1920,7 +2029,7 @@ impl DocTextures {
|
||||
tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform });
|
||||
}
|
||||
}
|
||||
Some(Self { size, format, tiles })
|
||||
Self { size, format, tiles }
|
||||
}
|
||||
|
||||
/// Write `size` pixels at document `origin` from `texels` (in the texture format, row stride
|
||||
@@ -2236,7 +2345,7 @@ struct View {
|
||||
a: vec4<f32>, // screen_w, screen_h, scale (device px per doc px), pixels_per_point
|
||||
b: vec4<f32>, // doc origin x, y (device px), doc w, h (doc px)
|
||||
c: vec4<f32>, // filter mode, lod, grid alpha, checker square (device px)
|
||||
d: vec4<f32>, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut), output linear
|
||||
d: vec4<f32>, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut, 3 matrix/shaper), output linear
|
||||
e: vec4<f32>, // checker light rgb, gamut warning opacity
|
||||
f: vec4<f32>, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off)
|
||||
g: vec4<f32>, // gamut warning rgb, 32-bit preview 1 / gamma
|
||||
@@ -2249,6 +2358,36 @@ struct Tile { r: vec4<f32> }; // x, y, w, h in doc px
|
||||
@group(1) @binding(0) var tex: texture_2d<f32>;
|
||||
@group(1) @binding(1) var<uniform> tile: Tile;
|
||||
@group(2) @binding(0) var lut: texture_3d<f32>;
|
||||
@group(2) @binding(1) var display_curves: texture_2d<f32>;
|
||||
struct DisplayMatrix { r: vec4<f32>, g: vec4<f32>, b: vec4<f32> };
|
||||
@group(2) @binding(2) var<uniform> display_matrix: DisplayMatrix;
|
||||
|
||||
fn curve_entry(index: u32) -> vec3<f32> {
|
||||
let width = textureDimensions(display_curves).x;
|
||||
return textureLoad(display_curves, vec2<i32>(i32(index % width), i32(index / width)), 0).rgb;
|
||||
}
|
||||
|
||||
fn display_curve(c: vec3<f32>, offset: u32, intervals: f32) -> vec3<f32> {
|
||||
let p = clamp(c, vec3(0.0), vec3(1.0)) * intervals;
|
||||
let lo = vec3<u32>(floor(p));
|
||||
let hi = min(lo + vec3(1u), vec3(u32(intervals)));
|
||||
let f = fract(p);
|
||||
return vec3(
|
||||
mix(curve_entry(offset + lo.r).r, curve_entry(offset + hi.r).r, f.r),
|
||||
mix(curve_entry(offset + lo.g).g, curve_entry(offset + hi.g).g, f.g),
|
||||
mix(curve_entry(offset + lo.b).b, curve_entry(offset + hi.b).b, f.b)
|
||||
);
|
||||
}
|
||||
|
||||
fn matrix_display(c: vec3<f32>) -> vec3<f32> {
|
||||
// Layout matches cms::GpuMatrixShaper: 4097 input entries, then 4096 inverse-curve
|
||||
// entries indexed by the fourth root. This preserves near-black precision without
|
||||
// interpolating across clipped corners of a coarse RGB cube.
|
||||
let v = vec4(display_curve(c, 0u, 4096.0), 1.0);
|
||||
let linear = vec3(dot(display_matrix.r, v), dot(display_matrix.g, v), dot(display_matrix.b, v));
|
||||
let u = sqrt(sqrt(clamp(linear, vec3(0.0), vec3(1.0))));
|
||||
return display_curve(u, 4097u, 4095.0);
|
||||
}
|
||||
|
||||
struct VOut { @builtin(position) pos: vec4<f32> };
|
||||
|
||||
@@ -2393,10 +2532,15 @@ fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
|
||||
if (view.d.z > 0.5 && col.a > 0.0) {
|
||||
// Colour management, Proof Colors, Gamut Warning: the document's display LUT (alpha flags
|
||||
// out-of-gamut colours).
|
||||
let n = f32(textureDimensions(lut).x);
|
||||
let c = clamp(col.rgb / col.a, vec3(0.0), vec3(1.0));
|
||||
let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0);
|
||||
let shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5);
|
||||
var shown: vec3<f32>;
|
||||
if (view.d.z > 2.5) {
|
||||
shown = matrix_display(c);
|
||||
} else {
|
||||
let n = f32(textureDimensions(lut).x);
|
||||
let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0);
|
||||
shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5);
|
||||
}
|
||||
col = vec4(shown * col.a, col.a);
|
||||
}
|
||||
var rgb = col.rgb + checker(d) * (1.0 - col.a);
|
||||
|
||||
@@ -64,6 +64,31 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() {
|
||||
gpu.rs.renderer.write().callback_resources.get_mut::<Resources>().unwrap().separate_mip_targets = separate;
|
||||
check_mips(&gpu);
|
||||
}
|
||||
// On the same device (see the top of the file).
|
||||
canvas_textures_the_gpu_refuses_leave_it_working(&gpu);
|
||||
}
|
||||
|
||||
/// #4017: a 3 GP canvas's 16 GB of textures failed with Out of Memory, and the uncaptured error
|
||||
/// disabled the GPU for the session. A refused set is now caught: nothing is kept, the budget
|
||||
/// drops below it (that canvas goes to the CPU canvas) and the device stays usable. A texture
|
||||
/// wider than the device allows is refused the same way, synchronously, on any adapter.
|
||||
fn canvas_textures_the_gpu_refuses_leave_it_working(gpu: &GpuCanvas) {
|
||||
let max = gpu.rs.device.limits().max_texture_dimension_2d;
|
||||
let (size, tile) = ([max + 1, 1], max + 1);
|
||||
let bytes = canvas_texture_bytes(size, tile, texel_bytes(FORMAT));
|
||||
{
|
||||
let mut renderer = gpu.rs.renderer.write();
|
||||
let res = renderer.callback_resources.get_mut::<Resources>().unwrap();
|
||||
res.canvas_budget.store(u64::MAX, Ordering::Relaxed);
|
||||
assert!(DocTextures::new(&gpu.rs.device, res, size, tile, FORMAT).is_none());
|
||||
let budget = res.canvas_budget.load(Ordering::Relaxed);
|
||||
assert!(budget < bytes, "{budget} >= {bytes}");
|
||||
}
|
||||
assert!(gpu.health.is_ok(), "{:?}", gpu.fault());
|
||||
// Smaller canvases still get GPU textures.
|
||||
gpu.upload_buffer_full(3, &Buffer::filled(Rect::from_xywh(0, 0, 64, 64), [0.5, 0.5, 0.5, 1.0]), SampleType::U8);
|
||||
assert!(gpu.rs.renderer.read().callback_resources.get::<Resources>().unwrap().docs.contains_key(&3));
|
||||
assert!(gpu.health.is_ok(), "{:?}", gpu.fault());
|
||||
}
|
||||
|
||||
fn check_mips(gpu: &GpuCanvas) {
|
||||
|
||||
@@ -2171,6 +2171,11 @@
|
||||
Color Replacement Tool Nástroj Nahrazení barvy
|
||||
Color Replacement Nahrazení barvy
|
||||
Wet Vlhkost
|
||||
Current Brush Load Aktuální náplň štětce
|
||||
Load Brush Naplnit štětec
|
||||
Clean Brush Vyčistit štětec
|
||||
Load the brush after each stroke Naplnit štětec po každém tahu
|
||||
Clean the brush after each stroke Vyčistit štětec po každém tahu
|
||||
Mix Míchání
|
||||
Wetness Jitter Kolísání vlhkosti
|
||||
Mix Jitter Kolísání míchání
|
||||
@@ -3856,3 +3861,6 @@ navigator Zoom Zvětšení
|
||||
# DDS Options (#1327)
|
||||
DDS Options Možnosti DDS
|
||||
Generate Mipmaps Generovat mipmapy
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptér GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2140,6 +2140,11 @@
|
||||
Mixer Brush Tool Mischer-Pinsel-Werkzeug
|
||||
Color Replacement Tool Farbersatz-Werkzeug
|
||||
Wet Feucht
|
||||
Current Brush Load Aktuelle Pinselladung
|
||||
Load Brush Pinsel laden
|
||||
Clean Brush Pinsel reinigen
|
||||
Load the brush after each stroke Pinsel nach jedem Strich laden
|
||||
Clean the brush after each stroke Pinsel nach jedem Strich reinigen
|
||||
Mix Mischen
|
||||
Wetness Jitter Feuchtigkeitsschwankung
|
||||
Mix Jitter Mischschwankung
|
||||
@@ -2772,6 +2777,7 @@ curves Smooth Glätten
|
||||
Light Red Hellrot
|
||||
Medium Blue Mittelblau
|
||||
Light Gray Hellgrau
|
||||
Stop Stopp
|
||||
# Status messages, system info and tool names
|
||||
Join the ArtCraft Discord ({url}) Dem ArtCraft-Discord beitreten ({url})
|
||||
Nothing to paste: the clipboard holds no image Nichts einzufügen: Die Zwischenablage enthält kein Bild
|
||||
@@ -3250,3 +3256,6 @@ curves Smooth Glätten
|
||||
DDS Options DDS-Optionen
|
||||
Compression Komprimierung
|
||||
Generate Mipmaps Mipmaps erzeugen
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU-Adapter
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1984,6 +1984,11 @@
|
||||
Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool)
|
||||
Color Replacement Αντικατάσταση χρώματος
|
||||
Wet Υγρασία
|
||||
Current Brush Load Τρέχουσα φόρτωση πινέλου
|
||||
Load Brush Φόρτωση πινέλου
|
||||
Clean Brush Καθαρισμός πινέλου
|
||||
Load the brush after each stroke Φόρτωση του πινέλου μετά από κάθε πινελιά
|
||||
Clean the brush after each stroke Καθαρισμός του πινέλου μετά από κάθε πινελιά
|
||||
Load Φόρτωση
|
||||
Mix Ανάμειξη
|
||||
8 bit 8 bit ανά κανάλι
|
||||
@@ -3948,3 +3953,6 @@ curves Smooth Εξομάλυνση
|
||||
# DDS Options (#1327)
|
||||
DDS Options Επιλογές DDS
|
||||
Generate Mipmaps Δημιουργία mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Προσαρμογέας GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1030 and column 18.
|
@@ -2158,6 +2158,11 @@
|
||||
Mixer Brush Tool Herramienta Pincel mezclador
|
||||
Color Replacement Tool Herramienta Sustitución de color
|
||||
Wet Húmedo
|
||||
Current Brush Load Carga actual del pincel
|
||||
Load Brush Cargar pincel
|
||||
Clean Brush Limpiar pincel
|
||||
Load the brush after each stroke Cargar el pincel después de cada trazo
|
||||
Clean the brush after each stroke Limpiar el pincel después de cada trazo
|
||||
Mix Mezcla
|
||||
Wetness Jitter Variación de humedad
|
||||
Mix Jitter Variación de mezcla
|
||||
@@ -4210,3 +4215,6 @@ navigator Zoom Zoom
|
||||
# DDS Options (#1327)
|
||||
DDS Options Opciones de DDS
|
||||
Generate Mipmaps Generar mipmaps
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptador de GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2118,6 +2118,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Appliquer l'inclinaison et la rotation du stylet à la forme de la pointe
|
||||
Mixer Brush Pinceau mélangeur
|
||||
Wet Humidité
|
||||
Current Brush Load Charge actuelle du pinceau
|
||||
Load Brush Charger le pinceau
|
||||
Clean Brush Nettoyer le pinceau
|
||||
Load the brush after each stroke Charger le pinceau après chaque trait
|
||||
Clean the brush after each stroke Nettoyer le pinceau après chaque trait
|
||||
Mix Mélange
|
||||
Wetness Jitter Variation de l'humidité
|
||||
Mix Jitter Variation du mélange
|
||||
@@ -3257,3 +3262,6 @@ curves Smooth Lisser
|
||||
DDS Options Options DDS
|
||||
Compression Compression
|
||||
Generate Mipmaps Générer les mipmaps
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptateur GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1407,6 +1407,11 @@
|
||||
Warp: Hajlítás:
|
||||
Weight Vastagság
|
||||
Wet Nedvesség
|
||||
Current Brush Load Az ecset jelenlegi töltete
|
||||
Load Brush Ecset feltöltése
|
||||
Clean Brush Ecset tisztítása
|
||||
Load the brush after each stroke Az ecset feltöltése minden ecsetvonás után
|
||||
Clean the brush after each stroke Az ecset tisztítása minden ecsetvonás után
|
||||
Wet Edges Nedves szélek
|
||||
Wetness Jitter Nedvesség ingadozása
|
||||
Width Szélesség
|
||||
@@ -4167,3 +4172,6 @@ cameraRaw Yellow Sárga
|
||||
# DDS Options (#1327)
|
||||
DDS Options DDS-beállítások
|
||||
Generate Mipmaps Mipmapek létrehozása
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU-adapter
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2112,6 +2112,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Terapkan kemiringan dan rotasi pena ke bentuk ujung
|
||||
Mixer Brush Kuas Pencampur
|
||||
Wet Basah
|
||||
Current Brush Load Muatan Kuas Saat Ini
|
||||
Load Brush Muat Kuas
|
||||
Clean Brush Bersihkan Kuas
|
||||
Load the brush after each stroke Muat kuas setelah setiap goresan
|
||||
Clean the brush after each stroke Bersihkan kuas setelah setiap goresan
|
||||
Mix Campur
|
||||
Wetness Jitter Jitter Kebasahan
|
||||
Mix Jitter Jitter Campuran
|
||||
@@ -3244,3 +3249,6 @@ curves Smooth Haluskan
|
||||
DDS Options Opsi DDS
|
||||
Compression Kompresi
|
||||
Generate Mipmaps Buat mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptor GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2153,6 +2153,11 @@
|
||||
Mixer Brush Tool Strumento Pennello miscelatore
|
||||
Color Replacement Tool Strumento Sostituzione colore
|
||||
Wet Bagnato
|
||||
Current Brush Load Carico attuale del pennello
|
||||
Load Brush Carica pennello
|
||||
Clean Brush Pulisci pennello
|
||||
Load the brush after each stroke Carica il pennello dopo ogni tratto
|
||||
Clean the brush after each stroke Pulisci il pennello dopo ogni tratto
|
||||
Mix Miscela
|
||||
Wetness Jitter Variazione bagnato
|
||||
Mix Jitter Variazione miscela
|
||||
@@ -4365,3 +4370,6 @@ shape Appearance Aspetto
|
||||
# DDS Options (#1327)
|
||||
DDS Options Opzioni DDS
|
||||
Generate Mipmaps Genera mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adattatore GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2658 and column 53.
|
@@ -2153,6 +2153,11 @@
|
||||
Mixer Brush Tool 混合ブラシツール
|
||||
Color Replacement Tool 色の置き換えツール
|
||||
Wet ウェット
|
||||
Current Brush Load 現在のブラシの絵の具
|
||||
Load Brush ブラシに補充
|
||||
Clean Brush ブラシを洗浄
|
||||
Load the brush after each stroke ストロークごとにブラシに補充
|
||||
Clean the brush after each stroke ストロークごとにブラシを洗浄
|
||||
Mix 混合
|
||||
Wetness Jitter ウェットのジッター
|
||||
Mix Jitter 混合のジッター
|
||||
@@ -3926,3 +3931,6 @@ curves Smooth スムーズ
|
||||
DDS Options DDS オプション
|
||||
Compression 圧縮
|
||||
Generate Mipmaps ミップマップを生成
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU アダプター
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2114,6 +2114,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape 펜의 기울기와 회전을 브러시 끝 모양에 적용
|
||||
Mixer Brush 혼합 브러시
|
||||
Wet 젖음
|
||||
Current Brush Load 현재 브러시 물감
|
||||
Load Brush 브러시 채우기
|
||||
Clean Brush 브러시 세척
|
||||
Load the brush after each stroke 획마다 브러시 채우기
|
||||
Clean the brush after each stroke 획마다 브러시 세척
|
||||
Mix 혼합
|
||||
Wetness Jitter 젖음 지터
|
||||
Mix Jitter 혼합 지터
|
||||
@@ -3303,6 +3308,7 @@ curves Smooth 매끄럽게
|
||||
Light Red 연한 빨강
|
||||
Medium Blue 중간 파랑
|
||||
Light Gray 연한 회색
|
||||
Stop 정지
|
||||
Object Finder 개체 찾기
|
||||
No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요
|
||||
Remove after each stroke 획마다 제거
|
||||
@@ -3431,3 +3437,6 @@ curves Smooth 매끄럽게
|
||||
DDS Options DDS 옵션
|
||||
Compression 압축
|
||||
Generate Mipmaps 밉맵 생성
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU 어댑터
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3172 and column 53.
|
@@ -1272,7 +1272,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_ui_strings_have_translations_in_every_registered_language() {
|
||||
const STRINGS: &[&str] = &["Mixer Brush", "Mixer Brush Tool", "Wet", "Load", "Mix", "Flow", "Sample All Layers"];
|
||||
const STRINGS: &[&str] = &[
|
||||
"Mixer Brush",
|
||||
"Mixer Brush Tool",
|
||||
"Wet",
|
||||
"Load",
|
||||
"Mix",
|
||||
"Flow",
|
||||
"Sample All Layers",
|
||||
"Current Brush Load",
|
||||
"Load Brush",
|
||||
"Clean Brush",
|
||||
"Load the brush after each stroke",
|
||||
"Clean the brush after each stroke",
|
||||
];
|
||||
for lang in Lang::all() {
|
||||
for source in STRINGS {
|
||||
let translated = tr(lang, source);
|
||||
|
||||
@@ -2118,6 +2118,11 @@
|
||||
Mixer Brush Tool Mengpenseelgereedschap
|
||||
Color Replacement Tool Kleurvervangingsgereedschap
|
||||
Wet Nat
|
||||
Current Brush Load Huidige penseellading
|
||||
Load Brush Penseel laden
|
||||
Clean Brush Penseel reinigen
|
||||
Load the brush after each stroke Penseel laden na elke streek
|
||||
Clean the brush after each stroke Penseel reinigen na elke streek
|
||||
Mix Mengen
|
||||
Wetness Jitter Nattigheidsvariatie
|
||||
Mix Jitter Mengvariatie
|
||||
@@ -4008,3 +4013,6 @@ curves Smooth Vloeiend maken
|
||||
# DDS Options (#1327)
|
||||
DDS Options DDS-opties
|
||||
Generate Mipmaps Mipmaps genereren
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU-adapter
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2137,6 +2137,11 @@
|
||||
Mixer Brush Tool Narzędzie Pędzel mieszający
|
||||
Color Replacement Tool Narzędzie Zamiana koloru
|
||||
Wet Wilgotność
|
||||
Current Brush Load Bieżący ładunek pędzla
|
||||
Load Brush Napełnij pędzel
|
||||
Clean Brush Wyczyść pędzel
|
||||
Load the brush after each stroke Napełniaj pędzel po każdym pociągnięciu
|
||||
Clean the brush after each stroke Czyść pędzel po każdym pociągnięciu
|
||||
Mix Mieszanie
|
||||
Wetness Jitter Rozrzut wilgotności
|
||||
Mix Jitter Rozrzut mieszania
|
||||
@@ -3848,3 +3853,6 @@ curves Smooth Wygładź
|
||||
DDS Options Opcje DDS
|
||||
Compression Kompresja
|
||||
Generate Mipmaps Generuj mipmapy
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Karta GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2112,6 +2112,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Aplicar a inclinação e a rotação da caneta à forma da ponta
|
||||
Mixer Brush Pincel misturador
|
||||
Wet Molhado
|
||||
Current Brush Load Carga atual do pincel
|
||||
Load Brush Carregar pincel
|
||||
Clean Brush Limpar pincel
|
||||
Load the brush after each stroke Carregar o pincel após cada traço
|
||||
Clean the brush after each stroke Limpar o pincel após cada traço
|
||||
Mix Mistura
|
||||
Wetness Jitter Variação da umidade
|
||||
Mix Jitter Variação da mistura
|
||||
@@ -4038,3 +4043,6 @@ layerLabel No Color Sem cor
|
||||
# DDS Options (#1327)
|
||||
DDS Options Opções de DDS
|
||||
Generate Mipmaps Gerar mipmaps
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptador de GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1023,6 +1023,11 @@
|
||||
Mix Смешивание
|
||||
Auto Clean Brush Автоочистка кисти
|
||||
Wet Влага
|
||||
Current Brush Load Текущая загрузка кисти
|
||||
Load Brush Загрузить кисть
|
||||
Clean Brush Очистить кисть
|
||||
Load the brush after each stroke Загружать кисть после каждого штриха
|
||||
Clean the brush after each stroke Очищать кисть после каждого штриха
|
||||
Rate Скорость
|
||||
Airbrush: keeps depositing paint while the pointer rests. Аэрограф: продолжает наносить краску, пока курсор неподвижен.
|
||||
Pulled String Mode Режим натянутой нити
|
||||
@@ -3223,3 +3228,6 @@ curves Smooth Сгладить
|
||||
DDS Options Параметры DDS
|
||||
Compression Сжатие
|
||||
Generate Mipmaps Создать MIP-карты
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Адаптер GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2095,6 +2095,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha
|
||||
Mixer Brush Četkica za mešanje
|
||||
Wet Vlažnost
|
||||
Current Brush Load Trenutno punjenje četkice
|
||||
Load Brush Napuni četkicu
|
||||
Clean Brush Očisti četkicu
|
||||
Load the brush after each stroke Napuni četkicu posle svakog poteza
|
||||
Clean the brush after each stroke Očisti četkicu posle svakog poteza
|
||||
Mix Mešanje
|
||||
Wetness Jitter Kolebanje vlažnosti
|
||||
Mix Jitter Kolebanje mešanja
|
||||
@@ -4321,3 +4326,6 @@ shape Appearance Izgled
|
||||
# DDS Options (#1327)
|
||||
DDS Options DDS opcije
|
||||
Generate Mipmaps Generiši mipmape
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU adapter
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2092,6 +2092,11 @@
|
||||
Mixer Brush Tool Karıştırıcı Fırça Aracı
|
||||
Color Replacement Tool Renk Değiştirme Aracı
|
||||
Wet Islaklık
|
||||
Current Brush Load Geçerli Fırça Yükü
|
||||
Load Brush Fırçayı Yükle
|
||||
Clean Brush Fırçayı Temizle
|
||||
Load the brush after each stroke Her vuruştan sonra fırçayı yükle
|
||||
Clean the brush after each stroke Her vuruştan sonra fırçayı temizle
|
||||
Mix Karışım
|
||||
Wetness Jitter Islaklık Titreme
|
||||
Mix Jitter Karıştırma Titreşimi
|
||||
@@ -3978,3 +3983,6 @@ cameraData Lens Lens
|
||||
# DDS Options (#1327)
|
||||
DDS Options DDS Seçenekleri
|
||||
Generate Mipmaps Mipmap oluştur
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU bağdaştırıcısı
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -1000,6 +1000,11 @@
|
||||
Mix Змішування
|
||||
Auto Clean Brush Автоматично очищувати пензель
|
||||
Wet Вологість
|
||||
Current Brush Load Поточне завантаження пензля
|
||||
Load Brush Завантажити пензель
|
||||
Clean Brush Очистити пензель
|
||||
Load the brush after each stroke Завантажувати пензель після кожного штриха
|
||||
Clean the brush after each stroke Очищати пензель після кожного штриха
|
||||
Rate Частота
|
||||
Airbrush: keeps depositing paint while the pointer rests. Аерограф: продовжує наносити фарбу, коли вказівник нерухомий.
|
||||
Pulled String Mode Режим натягнутої нитки
|
||||
@@ -3957,3 +3962,6 @@ curves Smooth Згладити
|
||||
DDS Options Параметри DDS
|
||||
Compression Стиснення
|
||||
Generate Mipmaps Створити MIP-карти
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Адаптер GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1393 and column 18.
|
@@ -1296,6 +1296,11 @@
|
||||
Mixer Brush Cọ trộn
|
||||
Mixer Brush Tool Công cụ Cọ trộn
|
||||
Wet Ướt
|
||||
Current Brush Load Lượng sơn hiện tại của cọ
|
||||
Load Brush Nạp sơn cho cọ
|
||||
Clean Brush Làm sạch cọ
|
||||
Load the brush after each stroke Nạp sơn cho cọ sau mỗi nét vẽ
|
||||
Clean the brush after each stroke Làm sạch cọ sau mỗi nét vẽ
|
||||
Load Tải
|
||||
Mix Trộn
|
||||
Flow Lưu lượng
|
||||
@@ -3736,3 +3741,6 @@ curves Smooth Làm mượt
|
||||
DDS Options Tùy chọn DDS
|
||||
Compression Nén
|
||||
Generate Mipmaps Tạo mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Bộ điều hợp GPU
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2221,6 +2221,11 @@ cameraRaw Blending 混合
|
||||
Apply the pen's tilt and rotation to the tip shape 将笔的倾斜和旋转应用于笔尖形状
|
||||
Mixer Brush 混合器画笔
|
||||
Wet 湿润
|
||||
Current Brush Load 当前画笔载入
|
||||
Load Brush 载入画笔
|
||||
Clean Brush 清理画笔
|
||||
Load the brush after each stroke 每次描边后载入画笔
|
||||
Clean the brush after each stroke 每次描边后清理画笔
|
||||
Mix 混合
|
||||
Wetness Jitter 湿润度抖动
|
||||
Mix Jitter 混合抖动
|
||||
@@ -2743,6 +2748,7 @@ curves Smooth 平滑
|
||||
Light Red 浅红色
|
||||
Medium Blue 中蓝色
|
||||
Light Gray 浅灰色
|
||||
Stop 停止
|
||||
Object Finder 对象查找程序
|
||||
No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形
|
||||
Remove after each stroke 每次描边后移除
|
||||
@@ -2873,3 +2879,6 @@ curves Smooth 平滑
|
||||
DDS Options DDS 选项
|
||||
Compression 压缩
|
||||
Generate Mipmaps 生成 Mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU 适配器
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2606 and column 53.
|
@@ -2160,6 +2160,11 @@
|
||||
Color Replacement Tool 顏色取代工具
|
||||
Color Replacement 顏色取代
|
||||
Wet 濕度
|
||||
Current Brush Load 目前筆刷載入
|
||||
Load Brush 載入筆刷
|
||||
Clean Brush 清理筆刷
|
||||
Load the brush after each stroke 每次描邊後載入筆刷
|
||||
Clean the brush after each stroke 每次描邊後清理筆刷
|
||||
Mix 混合
|
||||
Wetness Jitter 濕度快速變換
|
||||
Mix Jitter 混合快速變換
|
||||
@@ -2728,6 +2733,7 @@ curves Smooth 平滑
|
||||
Light Red 淺紅色
|
||||
Medium Blue 中藍色
|
||||
Light Gray 淺灰色
|
||||
Stop 停止
|
||||
Object Finder 物件尋找器
|
||||
No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形
|
||||
Remove after each stroke 每次描邊後移除
|
||||
@@ -2858,3 +2864,6 @@ curves Smooth 平滑
|
||||
DDS Options DDS 選項
|
||||
Compression 壓縮
|
||||
Generate Mipmaps 產生 Mipmap
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU 介面卡
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1137,3 +1137,31 @@ fn move_tool_controls_rotate_from_just_outside_the_box() {
|
||||
let tilt = (end[1][1] - end[0][1]).atan2(end[1][0] - end[0][0]).to_degrees();
|
||||
assert!((tilt - 90.0).abs() < 8.0 || (tilt + 270.0).abs() < 8.0, "rotated about a quarter turn, got {tilt}° (zoom {zoom})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_drag_after_hiding_extras_restores_selection_visibility() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = crate::state::Tool::RectMarquee;
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap();
|
||||
assert!(!h.state().ui.view.extras);
|
||||
let start = h.state().last_canvas_rect.center() - vec2(30.0, 20.0);
|
||||
let end = start + vec2(60.0, 40.0);
|
||||
h.hover_at(start);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=4 {
|
||||
h.hover_at(start + (end - start) * (i as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "the pointer drag created a selection");
|
||||
assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges), "a new marquee must become visible after hiding Extras");
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.view.extras, "the user can hide the existing selection again");
|
||||
h.state_mut().run("edit.fill", json!({"color": "#22cc44"})).unwrap();
|
||||
assert!(!h.state().ui.view.extras, "painting an existing selection must preserve the user's hide choice");
|
||||
}
|
||||
|
||||
+286
-24
@@ -438,8 +438,9 @@ pub type OpenUrlFn = Box<dyn Fn(&str) -> Result<(), String>>;
|
||||
pub type EncodePngFn = Box<dyn Fn(u32, u32, &[u8]) -> Result<Vec<u8>, String>>;
|
||||
/// Put an RGBA8 image (width, height, pixels) on the OS clipboard.
|
||||
pub type ClipboardSetFn = Box<dyn FnMut(u32, u32, &[u8]) -> Result<(), String>>;
|
||||
/// Read an RGBA8 image from the OS clipboard.
|
||||
pub type ClipboardGetFn = Box<dyn FnMut() -> Option<(u32, u32, Vec<u8>)>>;
|
||||
/// Read an RGBA8 image from the OS clipboard. `Ok(None)` means no image is available;
|
||||
/// `Err` means the contents could not be read and must not invalidate the internal copy.
|
||||
pub type ClipboardGetFn = Box<dyn FnMut() -> Result<Option<(u32, u32, Vec<u8>)>, String>>;
|
||||
/// Whether the last clipboard read found no way to reach the OS clipboard at all.
|
||||
pub type ClipboardUnreadableFn = Box<dyn Fn() -> bool>;
|
||||
/// Shared queue of (file name, bytes) delivered asynchronously.
|
||||
@@ -491,6 +492,8 @@ pub type CapsLockFn = Box<dyn FnMut() -> bool>;
|
||||
pub type WindowFrameFn = Box<dyn FnMut()>;
|
||||
/// The caption buttons' current style ([`Services::caption_style`]).
|
||||
pub type CaptionStyleFn = Box<dyn Fn(&egui::Context) -> CaptionStyle>;
|
||||
/// Physical right mouse button or stylus barrel button down (Windows; read for brush resize).
|
||||
pub type RightButtonDownFn = Box<dyn Fn() -> bool + Send + Sync>;
|
||||
|
||||
/// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of
|
||||
/// I/O dependencies.
|
||||
@@ -587,6 +590,8 @@ pub struct Services {
|
||||
/// style and button layout). Read every frame, with the context to wake when it changes;
|
||||
/// `None` keeps Windows' buttons at the right end.
|
||||
pub caption_style: Option<CaptionStyleFn>,
|
||||
/// Physical right mouse button or pen barrel button currently down.
|
||||
pub right_button_down: Option<RightButtonDownFn>,
|
||||
/// The persistent brush preset store, loading in the background (desktop; see
|
||||
/// `photocraft_engine::preset_store`). Attached to the session once it arrives; without
|
||||
/// one, brush presets are session-only unless the shell attached a store before startup.
|
||||
@@ -814,8 +819,11 @@ pub struct PhotocraftApp {
|
||||
/// The filter a "Rasterize?" prompt answered for, run after the dialog closes, where the
|
||||
/// egui context is available again (rasterize_prompt).
|
||||
pub(crate) pending_after_prompt: Option<(String, serde_json::Value)>,
|
||||
/// Signature of the image we last put on the OS clipboard (to tell ours from other apps').
|
||||
/// Last mirrored or imported OS image; this can predate the current internal copy.
|
||||
os_clip_sig: Option<u64>,
|
||||
/// The session copy, if any, need not track the OS clipboard. An older OS signature is kept
|
||||
/// to recognize unchanged system contents when exporting a newer copy failed or was disabled.
|
||||
clip_unmirrored: bool,
|
||||
/// The session clipboard currently holds an image imported from the OS clipboard: it has no
|
||||
/// original position, so Paste centres it (see `menus` "edit.paste").
|
||||
pub(crate) clip_external: bool,
|
||||
@@ -998,6 +1006,7 @@ impl PhotocraftApp {
|
||||
clone_preview: None,
|
||||
info_sample: None,
|
||||
os_clip_sig: None,
|
||||
clip_unmirrored: true,
|
||||
clip_external: false,
|
||||
clip_read_for_paste: false,
|
||||
transform_preview: None,
|
||||
@@ -1058,6 +1067,9 @@ impl PhotocraftApp {
|
||||
let tile = self.session.prefs().performance.cache_tile_size;
|
||||
// Escaped driver/setup panics must leave the session and CPU canvas alive.
|
||||
self.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
// The browser has no adapter list (egui-wgpu only has it natively).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.perf.gpu_info.set_available(&rs.available_adapters);
|
||||
let gpu = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile)))) {
|
||||
Ok(gpu) => gpu,
|
||||
Err(payload) => {
|
||||
@@ -1170,15 +1182,11 @@ impl PhotocraftApp {
|
||||
if let Some(r) = crate::crop_ui::intercept(self, id) {
|
||||
return r;
|
||||
}
|
||||
let suppress_events = self.automation_input && self.session.prefs().script_events.enabled;
|
||||
if suppress_events {
|
||||
self.session.edit_prefs(|prefs| prefs.script_events.enabled = false);
|
||||
}
|
||||
let t0 = gpu_canvas::now_ms();
|
||||
// The OS clipboard is read only on an explicit paste, never in the background (privacy, CPU).
|
||||
if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "file.newFromClipboard") {
|
||||
if !clip_read {
|
||||
self.import_os_clipboard();
|
||||
self.import_os_clipboard()?;
|
||||
}
|
||||
if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() {
|
||||
if self.services.clipboard_unreadable.as_ref().is_some_and(|unreadable| unreadable()) {
|
||||
@@ -1193,6 +1201,10 @@ impl PhotocraftApp {
|
||||
return Ok(serde_json::json!({"pasted": false}));
|
||||
}
|
||||
}
|
||||
let suppress_events = self.automation_input && self.session.prefs().script_events.enabled;
|
||||
if suppress_events {
|
||||
self.session.edit_prefs(|prefs| prefs.script_events.enabled = false);
|
||||
}
|
||||
// Long commands become background jobs when enabled (`jobs_ui`); the rest run inline.
|
||||
let params = self.with_mask_target(id, params);
|
||||
let params = vector_ui::with_active_path(self, id, params);
|
||||
@@ -1238,6 +1250,26 @@ impl PhotocraftApp {
|
||||
dock::reveal(self, dock::Group::Properties);
|
||||
}
|
||||
}
|
||||
// Making a new selection reveals its outline after View > Extras was hidden (#5597).
|
||||
// These interactive selection commands complete inline; a failed command, deselection,
|
||||
// or painting inside an existing selection must leave the user's view choice alone.
|
||||
if r.as_ref().is_ok_and(|v| v.get("changed") != Some(&Value::Bool(false)) && v.get("pending") != Some(&Value::Bool(true)))
|
||||
&& matches!(
|
||||
id,
|
||||
"select.rect"
|
||||
| "select.lasso"
|
||||
| "select.magneticLasso"
|
||||
| "select.magicWand"
|
||||
| "select.quick"
|
||||
| "select.object"
|
||||
| "select.loadSelection"
|
||||
| "select.loadLuminosity"
|
||||
| "path.toSelection"
|
||||
)
|
||||
&& self.session.active().is_some_and(|st| st.doc.selection.is_some())
|
||||
{
|
||||
self.ui.view.extras = true;
|
||||
}
|
||||
if r.is_ok() && id == "select.toWorkPath" {
|
||||
// Make Work Path selects the new work path in the Paths panel, as in Photoshop.
|
||||
self.ui.selected_path = Some("work".into());
|
||||
@@ -2743,6 +2775,7 @@ fn channel_tint(mode: photocraft_doc::ColorMode, k: usize, byte: u8, in_color: b
|
||||
impl PhotocraftApp {
|
||||
/// Mirror the session clipboard onto the OS clipboard (RGBA8).
|
||||
fn export_os_clipboard(&mut self) {
|
||||
self.clip_unmirrored = true;
|
||||
if !self.session.prefs().general.export_clipboard {
|
||||
return;
|
||||
}
|
||||
@@ -2756,6 +2789,7 @@ impl PhotocraftApp {
|
||||
let bytes: Vec<u8> = px.into_iter().flatten().collect();
|
||||
if set(b.width(), b.height(), &bytes).is_ok() {
|
||||
self.os_clip_sig = Some(clip_signature(b.width(), b.height(), &bytes));
|
||||
self.clip_unmirrored = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2772,7 +2806,9 @@ impl PhotocraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.import_os_clipboard();
|
||||
if self.import_os_clipboard().is_err() {
|
||||
return f;
|
||||
}
|
||||
if let Some(c) = self.session.clipboard.as_ref().filter(|c| !c.bounds.is_empty()) {
|
||||
crate::new_doc_ui::set_clipboard(&mut f, c.bounds.width(), c.bounds.height());
|
||||
}
|
||||
@@ -2781,22 +2817,44 @@ impl PhotocraftApp {
|
||||
|
||||
/// If the OS clipboard holds an image that isn't the one we put there, make it the session
|
||||
/// clipboard (so ⌘V pastes screenshots and images copied in other apps, like Photoshop).
|
||||
/// Returns true when a new external image was imported. Once the OS clipboard no longer holds
|
||||
/// Returns `Ok(true)` when a new external image was imported. Once the OS clipboard no longer holds
|
||||
/// the image we mirrored or imported (text or a file was copied since), that image is stale:
|
||||
/// it is dropped rather than pasted.
|
||||
pub(crate) fn import_os_clipboard(&mut self) -> bool {
|
||||
let Some(get) = self.services.clipboard_get_image.as_mut() else { return false };
|
||||
let image = get().filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4);
|
||||
/// it is dropped rather than pasted. Unexported internal copies survive unavailable or empty
|
||||
/// system clipboards.
|
||||
pub(crate) fn import_os_clipboard(&mut self) -> Result<bool, String> {
|
||||
let Some(get) = self.services.clipboard_get_image.as_mut() else { return Ok(false) };
|
||||
// A read failure says nothing about the clipboard contents. Keep our editable copy and
|
||||
// its signature rather than treating the failure as an empty image.
|
||||
let image = match get() {
|
||||
Ok(image) => image,
|
||||
Err(error) => {
|
||||
// An unexported copy remains usable without an OS clipboard (for example, in
|
||||
// a Wayland sandbox). Mirrored/imported copies still require a successful read
|
||||
// before pasting, because their system contents may have changed.
|
||||
if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.session.clipboard.is_none() && self.services.clipboard_unreadable.as_ref().is_some_and(|unreadable| unreadable()) {
|
||||
// Let paste explain the unavailable backend without treating a saved copy as empty.
|
||||
return Ok(false);
|
||||
}
|
||||
self.ui.status = error.clone();
|
||||
self.ui.status_error = true;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4);
|
||||
let Some((w, h, bytes)) = image else {
|
||||
if self.os_clip_sig.take().is_some() {
|
||||
if self.os_clip_sig.take().is_some() && !self.clip_unmirrored {
|
||||
self.session.clipboard = None;
|
||||
self.clip_external = false;
|
||||
self.clip_unmirrored = true;
|
||||
}
|
||||
return false;
|
||||
return Ok(false);
|
||||
};
|
||||
let sig = clip_signature(w, h, &bytes);
|
||||
if self.os_clip_sig == Some(sig) && self.session.clipboard.is_some() {
|
||||
return false;
|
||||
return Ok(false);
|
||||
}
|
||||
let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
|
||||
let mut surface = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::RGBA8, r, &bytes);
|
||||
@@ -2804,7 +2862,8 @@ impl PhotocraftApp {
|
||||
self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None });
|
||||
self.os_clip_sig = Some(sig);
|
||||
self.clip_external = true;
|
||||
true
|
||||
self.clip_unmirrored = false;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2952,7 +3011,7 @@ mod clipboard_tests {
|
||||
*a.lock().unwrap() = Some((w, h, px.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
clipboard_get_image: Some(Box::new(move || b.lock().unwrap().clone())),
|
||||
clipboard_get_image: Some(Box::new(move || Ok(b.lock().unwrap().clone()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
@@ -2963,10 +3022,10 @@ mod clipboard_tests {
|
||||
let (w, h, px) = os.lock().unwrap().clone().unwrap();
|
||||
assert_eq!((w, h, px.len()), (8, 4, 8 * 4 * 4));
|
||||
// Our own image comes back unchanged (no re-import, keeps the original position).
|
||||
assert!(!app.import_os_clipboard());
|
||||
assert!(!app.import_os_clipboard().unwrap());
|
||||
// Text copied elsewhere replaces our image: nothing to paste, not the stale image.
|
||||
let ours = os.lock().unwrap().take();
|
||||
assert!(!app.import_os_clipboard());
|
||||
assert!(!app.import_os_clipboard().unwrap());
|
||||
assert!(app.session.clipboard.is_none());
|
||||
*os.lock().unwrap() = ours;
|
||||
app.run("edit.copy", serde_json::json!({})).unwrap();
|
||||
@@ -3000,12 +3059,215 @@ mod clipboard_tests {
|
||||
|
||||
type OsClip = Arc<Mutex<Option<(u32, u32, Vec<u8>)>>>;
|
||||
|
||||
#[test]
|
||||
fn failed_clipboard_reads_preserve_editable_contents_until_retry() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] {
|
||||
let os = OsClip::default();
|
||||
let busy = Arc::new(AtomicBool::new(false));
|
||||
let (write, read, unavailable) = (os.clone(), os.clone(), busy.clone());
|
||||
let services = Services {
|
||||
clipboard_set_image: Some(Box::new(move |w, h, bytes| {
|
||||
*write.lock().unwrap() = Some((w, h, bytes.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
clipboard_get_image: Some(Box::new(move || {
|
||||
if unavailable.load(Ordering::SeqCst) { Err("clipboard is busy".into()) } else { Ok(read.lock().unwrap().clone()) }
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap();
|
||||
app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
|
||||
app.run("edit.copy", serde_json::json!({})).unwrap();
|
||||
app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap();
|
||||
let signature = app.os_clip_sig;
|
||||
assert!(signature.is_some());
|
||||
let before = app.session.active().unwrap().doc.walk().len();
|
||||
busy.store(true, Ordering::SeqCst);
|
||||
let result = crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({}));
|
||||
assert_eq!(result.unwrap_err(), "clipboard is busy", "{id}");
|
||||
assert!(app.ui.status_error);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.session.active().unwrap().doc.walk().len(), before);
|
||||
assert_eq!(app.os_clip_sig, signature);
|
||||
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
|
||||
// The New dialog also reads the clipboard; a failed preset probe must preserve it.
|
||||
app.new_document_fields();
|
||||
assert_eq!(app.os_clip_sig, signature);
|
||||
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
|
||||
// An automation error must not leave script events disabled by the dispatch guard.
|
||||
app.automation_input = true;
|
||||
app.session.edit_prefs(|prefs| prefs.script_events.enabled = true);
|
||||
assert!(app.run(id, serde_json::json!({})).is_err());
|
||||
assert!(app.session.prefs().script_events.enabled);
|
||||
app.automation_input = false;
|
||||
busy.store(false, Ordering::SeqCst);
|
||||
app.run("edit.pasteSpecial.pasteInPlace", serde_json::json!({})).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)));
|
||||
assert!(!app.ui.status_error);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_only_clipboard_remains_usable_when_native_access_fails() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
for export in [true, false] {
|
||||
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] {
|
||||
let os = OsClip::default();
|
||||
let unavailable = Arc::new(AtomicBool::new(true));
|
||||
let (read, failed) = (os.clone(), unavailable.clone());
|
||||
let services = Services {
|
||||
clipboard_set_image: Some(Box::new(|_, _, _| Err("clipboard unavailable".into()))),
|
||||
clipboard_get_image: Some(Box::new(move || {
|
||||
if failed.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(read.lock().unwrap().clone()) }
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.session.edit_prefs(|prefs| prefs.general.export_clipboard = export);
|
||||
app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap();
|
||||
app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
|
||||
app.run("edit.copy", serde_json::json!({})).unwrap();
|
||||
app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap();
|
||||
assert!(app.os_clip_sig.is_none() && !app.clip_external);
|
||||
let bounds = app.session.clipboard.as_ref().unwrap().bounds;
|
||||
assert_eq!(app.new_document_fields()["__preset"], "Clipboard");
|
||||
crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({})).unwrap();
|
||||
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
|
||||
assert!(!app.ui.status_error);
|
||||
if id == "file.newFromClipboard" {
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
assert_eq!((doc.size.width, doc.size.height), (bounds.width(), bounds.height()));
|
||||
} else {
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
let state = app.session.active().unwrap();
|
||||
assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)));
|
||||
}
|
||||
app.run("edit.purge.clipboard", serde_json::json!({})).unwrap();
|
||||
assert!(app.session.clipboard.is_none());
|
||||
assert!(app.run("edit.paste", serde_json::json!({})).is_err(), "purged content must not be a fallback");
|
||||
// The fallback is only for failed reads: a later successful external image wins.
|
||||
*os.lock().unwrap() = Some((3, 2, [0u8, 0, 255, 255].repeat(6)));
|
||||
unavailable.store(false, Ordering::SeqCst);
|
||||
app.run("edit.paste", serde_json::json!({})).unwrap();
|
||||
assert!(app.clip_external && app.os_clip_sig.is_some());
|
||||
let clip = app.session.clipboard.as_ref().unwrap();
|
||||
assert!(clip.layers.is_none());
|
||||
assert_eq!((clip.bounds.width(), clip.bounds.height()), (3, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_unexported_copies_do_not_track_an_older_os_signature() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
for imported in [true, false] {
|
||||
for disable_export in [true, false] {
|
||||
for copy_id in ["edit.copy", "edit.cut", "edit.copyMerged"] {
|
||||
let os = OsClip::default();
|
||||
let write_fails = Arc::new(AtomicBool::new(false));
|
||||
let read_fails = Arc::new(AtomicBool::new(false));
|
||||
let (write, read, failed_write, failed_read) = (os.clone(), os.clone(), write_fails.clone(), read_fails.clone());
|
||||
let services = Services {
|
||||
clipboard_set_image: Some(Box::new(move |w, h, bytes| {
|
||||
if failed_write.load(Ordering::SeqCst) {
|
||||
return Err("clipboard unavailable".into());
|
||||
}
|
||||
*write.lock().unwrap() = Some((w, h, bytes.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
clipboard_get_image: Some(Box::new(move || {
|
||||
if failed_read.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(read.lock().unwrap().clone()) }
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap();
|
||||
if imported {
|
||||
*os.lock().unwrap() = Some((2, 2, [255u8, 0, 0, 255].repeat(4)));
|
||||
assert!(app.import_os_clipboard().unwrap());
|
||||
} else {
|
||||
app.run("edit.fill", serde_json::json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("edit.copy", serde_json::json!({})).unwrap();
|
||||
}
|
||||
let old_signature = app.os_clip_sig;
|
||||
assert!(old_signature.is_some() && !app.clip_unmirrored);
|
||||
app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap();
|
||||
app.run("edit.fill", serde_json::json!({"color": "#0000ff"})).unwrap();
|
||||
write_fails.store(true, Ordering::SeqCst);
|
||||
app.session.edit_prefs(|prefs| prefs.general.export_clipboard = !disable_export);
|
||||
app.run(copy_id, serde_json::json!({})).unwrap();
|
||||
let copied = app.session.clipboard.clone().unwrap();
|
||||
assert!(app.clip_unmirrored && !app.clip_external);
|
||||
assert_eq!(app.os_clip_sig, old_signature);
|
||||
assert_eq!(copied.surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]);
|
||||
// An old successful export/import must not block the newer internal copy.
|
||||
read_fails.store(true, Ordering::SeqCst);
|
||||
app.run("edit.pasteSpecial.pasteInPlace", serde_json::json!({})).unwrap();
|
||||
assert_eq!(app.os_clip_sig, old_signature);
|
||||
assert!(app.clip_unmirrored && !app.clip_external);
|
||||
read_fails.store(false, Ordering::SeqCst);
|
||||
assert!(!app.import_os_clipboard().unwrap(), "unchanged old OS image must not overwrite the newer copy");
|
||||
assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), copied.surface.rgba(0, 0));
|
||||
*os.lock().unwrap() = None;
|
||||
assert!(!app.import_os_clipboard().unwrap(), "empty OS clipboard must not discard an unexported copy");
|
||||
assert!(app.os_clip_sig.is_none() && app.clip_unmirrored);
|
||||
assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), copied.surface.rgba(0, 0));
|
||||
// A different external image supersedes the internal copy and disables fallback.
|
||||
*os.lock().unwrap() = Some((3, 2, [0u8, 255, 0, 255].repeat(6)));
|
||||
assert!(app.import_os_clipboard().unwrap());
|
||||
assert!(app.clip_external && !app.clip_unmirrored);
|
||||
let clip = app.session.clipboard.as_ref().unwrap();
|
||||
assert_eq!((clip.bounds.width(), clip.bounds.height()), (3, 2));
|
||||
read_fails.store(true, Ordering::SeqCst);
|
||||
assert!(app.run("edit.paste", serde_json::json!({})).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_clipboard_reads_do_not_paste_an_external_or_missing_copy() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
for external in [true, false] {
|
||||
let unavailable = Arc::new(AtomicBool::new(false));
|
||||
let failed = unavailable.clone();
|
||||
let services = Services {
|
||||
clipboard_get_image: Some(Box::new(move || {
|
||||
if failed.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(Some((3, 2, [255u8, 0, 0, 255].repeat(6)))) }
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap();
|
||||
if external {
|
||||
assert!(app.import_os_clipboard().unwrap());
|
||||
}
|
||||
let signature = app.os_clip_sig;
|
||||
let before = app.session.active().unwrap().doc.walk().len();
|
||||
unavailable.store(true, Ordering::SeqCst);
|
||||
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] {
|
||||
let result = crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({}));
|
||||
assert_eq!(result.unwrap_err(), "clipboard unavailable", "{id}");
|
||||
assert!(app.ui.status_error);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert_eq!(app.session.active().unwrap().doc.walk().len(), before);
|
||||
assert_eq!(app.os_clip_sig, signature);
|
||||
assert_eq!(app.session.clipboard.is_some(), external);
|
||||
assert_eq!(app.clip_external, external);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An app whose OS clipboard is `os`, counting every read in `reads`.
|
||||
fn app_with_os_clipboard(os: &OsClip, reads: &Arc<std::sync::atomic::AtomicUsize>) -> PhotocraftApp {
|
||||
let (b, n) = (os.clone(), reads.clone());
|
||||
let get = move || {
|
||||
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
b.lock().unwrap().clone()
|
||||
Ok(b.lock().unwrap().clone())
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() });
|
||||
app.session.execute("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap();
|
||||
@@ -3041,7 +3303,7 @@ mod clipboard_tests {
|
||||
let (b, n) = (os.clone(), Arc::clone(&reads));
|
||||
let get = move || {
|
||||
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
b.lock().unwrap().clone()
|
||||
Ok(b.lock().unwrap().clone())
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() });
|
||||
let ctx = egui::Context::default();
|
||||
@@ -3091,7 +3353,7 @@ mod clipboard_tests {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let get = move || {
|
||||
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
b.lock().unwrap().clone()
|
||||
Ok(b.lock().unwrap().clone())
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() });
|
||||
app.session.execute("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap();
|
||||
|
||||
@@ -502,7 +502,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
"edit.paste" if params.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
// Photoshop: paste in place when the copied area is visible, else centred in the view;
|
||||
// images from other apps are always centred.
|
||||
app.import_os_clipboard();
|
||||
app.import_os_clipboard()?;
|
||||
app.clip_read_for_paste = true;
|
||||
let external = app.clip_external;
|
||||
let visible = !external
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! #2561: the Layers panel New Group button. Like Photoshop a plain click adds an empty group
|
||||
//! and Shift-click groups the selected layers.
|
||||
//! The Layers panel New Group button groups multiple selected layers on a plain click.
|
||||
//! Shift-click also groups a single selected layer; otherwise it creates an empty group.
|
||||
|
||||
use egui::Modifiers;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
@@ -42,8 +42,42 @@ fn shift_click_groups_the_selected_layers_in_order() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_click_still_adds_an_empty_group() {
|
||||
fn plain_click_groups_all_selected_layers_in_order_and_undoes_in_one_step() {
|
||||
for active in ["A", "B"] {
|
||||
let (mut h, a, b) = two_selected();
|
||||
let id = if active == "A" { a } else { b };
|
||||
h.state_mut().session.execute("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Create a new group").click();
|
||||
h.run_steps(4);
|
||||
let groups = groups(&h);
|
||||
assert_eq!(groups.len(), 1);
|
||||
let group = &groups[0];
|
||||
let children: Vec<LayerId> = group.children().unwrap().iter().map(|l| l.id).collect();
|
||||
assert_eq!(children, vec![a, b]);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 2, "only the background and group remain at the top level");
|
||||
assert_eq!(st.selected_layers(), vec![group.id]);
|
||||
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 3);
|
||||
assert_eq!(st.doc.layers[1].id, a);
|
||||
assert_eq!(st.doc.layers[2].id, b);
|
||||
assert_eq!(st.active_layer, Some(id));
|
||||
|
||||
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
|
||||
let st = h.state().session.active().unwrap();
|
||||
let children: Vec<LayerId> = st.doc.layer(group.id).unwrap().children().unwrap().iter().map(|l| l.id).collect();
|
||||
assert_eq!(children, vec![a, b]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_click_with_one_selected_layer_still_adds_an_empty_group() {
|
||||
let (mut h, a, b) = two_selected();
|
||||
h.state_mut().session.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Create a new group").click();
|
||||
h.run_steps(4);
|
||||
let groups = groups(&h);
|
||||
@@ -51,5 +85,6 @@ fn plain_click_still_adds_an_empty_group() {
|
||||
assert!(groups[0].children().unwrap().is_empty());
|
||||
let doc = &h.state().session.active().unwrap().doc;
|
||||
let top: Vec<LayerId> = doc.layers.iter().map(|l| l.id).collect();
|
||||
assert_eq!(top[1..3], [a, b], "the selected layers stay where they were");
|
||||
assert_eq!(top[1], a, "the selected layer stays where it was");
|
||||
assert_eq!(top[3], b, "the unselected layer stays above the new group");
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ mod tests {
|
||||
fn unreadable_clipboard_paste_explains_the_sandbox() {
|
||||
let command = "flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft";
|
||||
let services = |unreadable: bool| Services {
|
||||
clipboard_get_image: Some(Box::new(|| None)),
|
||||
clipboard_get_image: Some(Box::new(move || if unreadable { Err("no clipboard backend".into()) } else { Ok(None) })),
|
||||
clipboard_unreadable: Some(Box::new(move || unreadable)),
|
||||
xwayland_command: Some(command.to_string()),
|
||||
..Default::default()
|
||||
|
||||
@@ -100,21 +100,32 @@ pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool {
|
||||
/// brush with Alt held (#297), erases (Erase preference) or opens the Brush Preset picker. Arms
|
||||
/// `secondary_erase` or `brush_resize_armed` for this frame's `Down`.
|
||||
pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) -> Buttons {
|
||||
let (mods, right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down()));
|
||||
let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f());
|
||||
let (mods, mut right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down()));
|
||||
right_down |= physical_right;
|
||||
let right_started = response.drag_started_by(PointerButton::Secondary) || (physical_right && response.drag_started());
|
||||
let right_dragged = response.dragged_by(PointerButton::Secondary) || (physical_right && response.dragged());
|
||||
let resizing = app.brush_resize.is_some_and(|r| r.secondary);
|
||||
let right_stopped = response.drag_stopped_by(PointerButton::Secondary) || (resizing && response.drag_stopped());
|
||||
|
||||
if physical_right && crate::brush_resize::is_right_gesture(crate::workspace_ui::sticky_mods(app, mods)) {
|
||||
app.alt_sampling = false;
|
||||
}
|
||||
|
||||
// Alt+right-drag resizes the brush (brush_resize.rs); its events reach `tool_event` like a
|
||||
// left drag's, and nothing paints. A resize whose release was missed ends here.
|
||||
crate::brush_resize::release_stale(app, right_down || response.drag_stopped_by(PointerButton::Secondary));
|
||||
crate::brush_resize::release_stale(app, right_down || response.drag_stopped_by(PointerButton::Secondary) || response.drag_stopped());
|
||||
let resize_start = crate::brush_resize::applies(tool)
|
||||
&& app.drag.is_none()
|
||||
&& crate::brush_resize::is_right_gesture(crate::workspace_ui::sticky_mods(app, mods))
|
||||
&& response.drag_started_by(PointerButton::Secondary);
|
||||
&& (right_started || (physical_right && response.dragged() && app.brush_resize.is_none()));
|
||||
let resizing = app.brush_resize.is_some_and(|r| r.secondary);
|
||||
app.brush_resize_armed = resize_start;
|
||||
app.brush_resize_armed = resize_start || (physical_right && resizing);
|
||||
// ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307).
|
||||
let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods);
|
||||
let erase = right_erases(app, tool) && !resize_start && !resizing && !layer_menu;
|
||||
let right_stroke = erase && app.drag.is_some();
|
||||
let right_start = erase && response.drag_started_by(PointerButton::Secondary);
|
||||
let right_start = erase && right_started;
|
||||
let right_click = response.secondary_clicked();
|
||||
if right_click
|
||||
&& !erase
|
||||
@@ -129,8 +140,8 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool)
|
||||
let right_drag = right_stroke || resizing;
|
||||
Buttons {
|
||||
started: response.drag_started_by(PointerButton::Primary) || right_start || resize_start,
|
||||
dragged: response.dragged_by(PointerButton::Primary) || (right_drag && response.dragged_by(PointerButton::Secondary)),
|
||||
stopped: response.drag_stopped_by(PointerButton::Primary) || (right_drag && response.drag_stopped_by(PointerButton::Secondary)),
|
||||
dragged: response.dragged_by(PointerButton::Primary) || (right_drag && right_dragged),
|
||||
stopped: response.drag_stopped_by(PointerButton::Primary) || (right_drag && right_stopped),
|
||||
clicked: response.clicked() || erase_click,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,6 +1122,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
brush_preset_chip(ui, b, &mut app.ui);
|
||||
crate::brush_picker::settings_toggle(app, ui);
|
||||
widgets::vline(ui, 22.0);
|
||||
mixer_load_swatch(app, ui);
|
||||
let m = &mut b.mixer;
|
||||
for (icon, on, tip) in [
|
||||
("brush", &mut m.load_after_stroke, tl!("Load the brush after each stroke")),
|
||||
("brush-cleaning", &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")),
|
||||
] {
|
||||
if icons::button(ui, icon, 24.0, *on, tip).clicked() {
|
||||
*on = !*on;
|
||||
}
|
||||
}
|
||||
widgets::vline(ui, 22.0);
|
||||
for (label, value) in [
|
||||
(tl!("Wet"), &mut b.mixer.wet),
|
||||
(tl!("Load"), &mut b.mixer.load),
|
||||
@@ -2727,10 +2738,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate"));
|
||||
let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group"));
|
||||
if group.clicked() {
|
||||
// Like Photoshop a plain click adds an empty group; Shift-click groups the selected
|
||||
// layers, the same as Layer › Group Layers (#2561).
|
||||
// Multiple selected layers share one group. Shift also groups a single selection.
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let cmd = if shift && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" };
|
||||
let cmd = if (shift || selection.len() > 1) && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" };
|
||||
actions.push((cmd.into(), json!({})));
|
||||
}
|
||||
actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers"));
|
||||
@@ -4125,6 +4135,47 @@ fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings
|
||||
});
|
||||
}
|
||||
|
||||
/// Mixer Brush: the Current Brush Load swatch shows the paint the next stroke starts with (an
|
||||
/// empty swatch when the brush is clean). Its menu holds Load Brush and Clean Brush.
|
||||
fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::hover());
|
||||
let resp = ui.interact(r, ui.id().with("mixer-brush-load"), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let chip = Rect::from_min_size(pos2(r.left() + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
|
||||
let p = ui.painter();
|
||||
match app.session.tools.mixer.next_load(app.session.tools.foreground) {
|
||||
Some(c) => {
|
||||
p.rect_filled(chip, 2.0, c32(c));
|
||||
}
|
||||
None => {
|
||||
p.rect_filled(chip, 2.0, t.field);
|
||||
p.line_segment([chip.left_bottom(), chip.right_top()], Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
}
|
||||
p.rect_stroke(chip, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Current Brush Load")));
|
||||
let resp = resp.on_hover_text(tl!("Current Brush Load"));
|
||||
let mut run = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
if ui.button(tl!("Load Brush")).clicked() {
|
||||
run = Some("brush.mixer.load");
|
||||
}
|
||||
if ui.button(tl!("Clean Brush")).clicked() {
|
||||
run = Some("brush.mixer.clean");
|
||||
}
|
||||
});
|
||||
if let Some(id) = run
|
||||
&& let Err(e) = app.run(id, json!({}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Options-bar brush chip: a click shows or hides Photoshop's Brush Preset picker below it (size,
|
||||
/// hardness, the preset library), the one a right-click on the canvas opens.
|
||||
fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, state: &mut crate::state::UiState) {
|
||||
|
||||
@@ -331,3 +331,37 @@ fn path_overlay_drawn_for_non_vector_tools() {
|
||||
the path must be drawn regardless of the active tool",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_selection_after_hiding_extras_restores_outline() {
|
||||
let mut h = harness();
|
||||
let shape =
|
||||
h.state_mut().run("shape.create", json!({"kind":"rect","rect":[40,20,60,40],"fill":"#cc3333","name":"Badge"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let active = h.state_mut().run("layer.new.layer", json!({"name":"Other"})).unwrap()["layer"].as_u64().unwrap();
|
||||
panel(&mut h, 0);
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap();
|
||||
assert!(!h.state().ui.view.extras);
|
||||
let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == shape).unwrap();
|
||||
click(&mut h, row.name.unwrap().center(), command());
|
||||
assert!(!h.state().ui.view.extras, "selecting layers must not reveal Extras");
|
||||
h.state_mut().run("layer.select", json!({"layer": active})).unwrap();
|
||||
h.run_steps(2);
|
||||
click(&mut h, egui::pos2(row.row.left() + 46.0, row.row.center().y), command());
|
||||
assert_eq!(selection(&h), Some([40, 20, 60, 40]));
|
||||
assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges), "Ctrl-clicking the thumbnail reveals the new selection");
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(active)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_a_path_after_hiding_extras_restores_outline() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(10.0, 10.0, 50.0, 40.0)})).unwrap();
|
||||
h.state_mut().ui.tool = Tool::Pen;
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap();
|
||||
assert!(!h.state().ui.view.extras);
|
||||
press(&mut h, Key::Enter, command());
|
||||
assert_eq!(selection(&h), Some([10, 10, 50, 40]));
|
||||
assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges));
|
||||
}
|
||||
|
||||
@@ -1093,6 +1093,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
let mut info = app.perf.gpu_info.lines();
|
||||
info.extend(gpu_memory_lines(app));
|
||||
f.insert("__gpuInfo".into(), json!(info));
|
||||
f.insert("__gpuAdapters".into(), json!(app.perf.gpu_info.adapters));
|
||||
let system = system_theme(app, ui.ctx());
|
||||
prefs_body(ui, f, system);
|
||||
}
|
||||
@@ -1322,7 +1323,7 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>, system: Option<egui
|
||||
);
|
||||
}
|
||||
if section == "performance" {
|
||||
gpu_status_rows(ui, f.get("__gpuInfo"), obj);
|
||||
gpu_status_rows(ui, f.get("__gpuInfo"), f.get("__gpuAdapters"), obj);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
@@ -1347,7 +1348,7 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>, system: Option<egui
|
||||
|
||||
/// Preferences › Performance: what the app renders with now, and a reset of the GPU backend
|
||||
/// (a crashed start may have moved it to a safer choice).
|
||||
fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, obj: &mut Map<String, Value>) {
|
||||
fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, adapters: Option<&Value>, obj: &mut Map<String, Value>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(10.0);
|
||||
ui.label(RichText::new(tl!("Graphics")).font(crate::theme::semibold(12.5)).color(t.text));
|
||||
@@ -1366,9 +1367,37 @@ fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, obj: &mut Map<String
|
||||
crate::widgets::dropdown(ui, "graphics-backend", &mut current, &pairs, 220.0);
|
||||
obj.insert("gpuBackend".into(), json!(current));
|
||||
});
|
||||
if !prefs::is_hidden("performance.gpuAdapter") {
|
||||
graphics_adapter_row(ui, adapters, obj);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Graphics Adapter: which GPU draws on a machine with several. The
|
||||
/// value is the adapter's name; empty is automatic. A saved adapter that isn't present now (an
|
||||
/// unplugged eGPU) stays listed, marked ⚠, so opening the dialog doesn't silently change the choice.
|
||||
fn graphics_adapter_row(ui: &mut egui::Ui, adapters: Option<&Value>, obj: &mut Map<String, Value>) {
|
||||
let mut current = obj.get("gpuAdapter").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let mut names: Vec<String> = adapters.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
|
||||
// A saved name fragment ("nvidia") isn't an adapter's full name but still selects one; only a
|
||||
// value no listed adapter matches is marked.
|
||||
let kept = !current.is_empty() && !names.contains(¤t);
|
||||
let missing = kept && !names.iter().any(|n| prefs::gpu_adapter_matches(n, ¤t));
|
||||
if kept {
|
||||
names.push(current.clone());
|
||||
}
|
||||
let automatic = tl!("Automatic");
|
||||
let labels: Vec<String> = names.iter().map(|n| if missing && *n == current { format!("⚠ {n}") } else { n.clone() }).collect();
|
||||
let mut pairs: Vec<(String, &str)> = vec![(String::new(), automatic)];
|
||||
pairs.extend(names.iter().cloned().zip(labels.iter().map(String::as_str)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("GPU adapter"));
|
||||
crate::widgets::dropdown_literal(ui, "graphics-adapter", &mut current, &pairs, 320.0);
|
||||
});
|
||||
ui.label(RichText::new(tl!("Applies at next launch.")).color(Tokens::get(ui.ctx()).text_faint));
|
||||
obj.insert("gpuAdapter".into(), json!(current));
|
||||
}
|
||||
|
||||
/// One user-facing choice; legacy flags remain compatible with older settings.
|
||||
fn rendering_mode_row(ui: &mut egui::Ui, obj: &mut Map<String, Value>) {
|
||||
let mut current = rendering_mode_value(obj);
|
||||
@@ -1601,7 +1630,7 @@ fn field_visible(section: &str, obj: &Map<String, Value>, k: &str, query: &str)
|
||||
let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes"))
|
||||
// View › Rulers (⌘R) sets it, as in Photoshop.
|
||||
|| path == "unitsAndRulers.showRulers"
|
||||
|| (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode" | "memoryUsageMb"))
|
||||
|| (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "gpuAdapter" | "renderingMode" | "memoryUsageMb"))
|
||||
// The browser presents frames itself: Low-Latency Drawing does nothing there.
|
||||
|| (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing")
|
||||
|| (section == "export" && !export_field_visible(obj, k))
|
||||
|
||||
@@ -44,7 +44,7 @@ pub struct ActiveSnap {
|
||||
pub gesture: Gesture,
|
||||
pub start: [f64; 2],
|
||||
pub targets: SnapTargets,
|
||||
/// Layer alignments only (smart guides), used when View › Snap is off.
|
||||
/// Layer alignments only (smart guides), when View › Snap and Smart Guides are on.
|
||||
pub smart: SnapTargets,
|
||||
pub disabled: bool,
|
||||
}
|
||||
@@ -254,7 +254,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
|
||||
// A moving selection never snaps back to where it started.
|
||||
targets = targets.filtered(|k| k != SnapKind::Selection);
|
||||
}
|
||||
let smart = if matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() };
|
||||
let smart = if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() };
|
||||
app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false });
|
||||
}
|
||||
|
||||
@@ -653,6 +653,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_snap_off_blocks_smart_guide_snapping() {
|
||||
let mut app = app_with_box();
|
||||
app.ui.tool = Tool::Move;
|
||||
app.ui.view.show.smart_guides = true;
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.tool_options.move_auto_select = false;
|
||||
let m = egui::Modifiers::NONE;
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m);
|
||||
assert_eq!(mover_bounds(&app).x0, 303, "View › Snap off must not snap via smart guides");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_or_snap_off_disables_snapping() {
|
||||
let mut app = app_with_box();
|
||||
|
||||
@@ -49,6 +49,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let mut act: Option<(&str, Value)> = None;
|
||||
let mut close = false;
|
||||
let mut toggle_play = false;
|
||||
let mut stop_play = false;
|
||||
let playing = app.ui.timeline.playing;
|
||||
let t = Tokens::get(ctx);
|
||||
|
||||
@@ -84,7 +85,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if crate::icons::button(ui, play_icon, 22.0, playing, if playing { tl!("Pause") } else { tl!("Play") }).clicked() {
|
||||
toggle_play = true;
|
||||
}
|
||||
if crate::icons::button(ui, "square", 22.0, false, tl!("Stop")).clicked() {
|
||||
if !playing {
|
||||
act = Some(("timeline.setFrame", json!({ "frame": 0 })));
|
||||
}
|
||||
stop_play = true;
|
||||
}
|
||||
if crate::icons::button(ui, "chevron-left", 22.0, false, tl!("Previous frame")).clicked() {
|
||||
stop_play = true;
|
||||
act = Some(("timeline.previousFrame", json!({})));
|
||||
}
|
||||
ui.label(
|
||||
@@ -96,12 +104,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
.monospace(),
|
||||
);
|
||||
if crate::icons::button(ui, "chevron-right", 22.0, false, tl!("Next frame")).clicked() {
|
||||
stop_play = true;
|
||||
act = Some(("timeline.nextFrame", json!({})));
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(RichText::new(format!("{:.2}s", tl.time())).color(t.text_dim).monospace());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::icons::button(ui, "trash", 20.0, false, tl!("Delete Timeline")).clicked() {
|
||||
stop_play = true;
|
||||
act = Some(("timeline.delete", json!({})));
|
||||
}
|
||||
ui.label(RichText::new(format!("{:.0} fps", tl.fps)).color(t.text_dim));
|
||||
@@ -113,6 +123,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let mut frame = tl.current as f64;
|
||||
let resp = ui.add(egui::Slider::new(&mut frame, 0.0..=(tl.duration - 1) as f64).integer().show_value(false).text("playhead"));
|
||||
if resp.changed() {
|
||||
stop_play = true;
|
||||
act = Some(("timeline.setFrame", json!({ "frame": frame as u64 })));
|
||||
}
|
||||
}
|
||||
@@ -124,7 +135,9 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
app.ui.timeline.open = false;
|
||||
app.ui.timeline.playing = false;
|
||||
}
|
||||
if toggle_play {
|
||||
if stop_play {
|
||||
app.ui.timeline.playing = false;
|
||||
} else if toggle_play {
|
||||
app.ui.timeline.playing = !app.ui.timeline.playing;
|
||||
app.ui.timeline.last_time = ctx.input(|i| i.time);
|
||||
}
|
||||
@@ -147,3 +160,67 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
#[test]
|
||||
fn stop_step_and_scrub_pause_playback_and_second_stop_rewinds() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("timeline.create", json!({"duration": 10, "fps": 30.0})).unwrap();
|
||||
app.ui.timeline.open = true;
|
||||
app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0));
|
||||
|
||||
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.001).build_ui_state(|ui, app| windows(app, ui.ctx()), app);
|
||||
h.run_steps(2);
|
||||
|
||||
// Start playback via Play button.
|
||||
h.get_by_label("Play").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.timeline.playing);
|
||||
|
||||
// Move to frame 5 while playing; first Stop pauses at the current frame without rewinding.
|
||||
h.state_mut().run("timeline.setFrame", json!({"frame": 5})).unwrap();
|
||||
h.get_by_label("Stop").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing, "first Stop pauses playback");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 5);
|
||||
|
||||
// Second Stop while already paused rewinds to frame 0.
|
||||
h.get_by_label("Stop").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0);
|
||||
|
||||
// Next frame and Previous frame pause playback.
|
||||
h.state_mut().ui.timeline.playing = true;
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Next frame").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing, "Next frame pauses playback");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 1);
|
||||
|
||||
h.state_mut().ui.timeline.playing = true;
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Previous frame").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing, "Previous frame pauses playback");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0);
|
||||
|
||||
// Scrubbing the playhead slider pauses playback and updates the current frame.
|
||||
h.state_mut().ui.timeline.playing = true;
|
||||
h.run_steps(1);
|
||||
let slider = h.get_by_role(egui::accesskit::Role::Slider).rect();
|
||||
let target = egui::pos2(slider.right() - 8.0, slider.center().y);
|
||||
h.event(egui::Event::PointerMoved(target));
|
||||
h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
h.step();
|
||||
h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing, "scrubbing pauses playback");
|
||||
assert!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2068,9 +2068,20 @@ fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnc
|
||||
if on {
|
||||
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside);
|
||||
}
|
||||
paint(ui, r, if on { t.text } else { t.text_dim });
|
||||
let enabled = ui.is_enabled();
|
||||
paint(
|
||||
ui,
|
||||
r,
|
||||
if !enabled {
|
||||
t.text_faint
|
||||
} else if on {
|
||||
t.text
|
||||
} else {
|
||||
t.text_dim
|
||||
},
|
||||
);
|
||||
let tip = tip.to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, on, &tip));
|
||||
resp.on_hover_text(tl!(&tip)).clicked()
|
||||
}
|
||||
|
||||
@@ -2363,14 +2374,32 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
("justifyAll", tl!("Justify all"), A::JustifyAll),
|
||||
];
|
||||
let cw = cell_width(full, items.len());
|
||||
// Justification needs a paragraph box: on point text Photoshop greys the four justify
|
||||
// buttons (#7713).
|
||||
// The panel formats the layer being typed in, else every selected type layer.
|
||||
let point = match formatting_params(app) {
|
||||
Some(p) => p.get("layers").and_then(serde_json::Value::as_array).map_or_else(
|
||||
|| p.get("layer").and_then(serde_json::Value::as_u64).into_iter().collect::<Vec<_>>(),
|
||||
|ids| ids.iter().filter_map(serde_json::Value::as_u64).collect(),
|
||||
),
|
||||
None => Vec::new(),
|
||||
}
|
||||
.into_iter()
|
||||
.any(|layer| box_shape(app, LayerId(layer)).is_none());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (key, tip, a) in items {
|
||||
let clicked = toggle_cell(ui, cw, para.align == a, tip, |ui, r, col| {
|
||||
// The glyph keeps its size; the cell grows around it.
|
||||
let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 10.0));
|
||||
align_glyph(ui.painter(), g, a, if para.align == a { col } else { t.icon });
|
||||
});
|
||||
let justify = !matches!(a, A::Left | A::Center | A::Right);
|
||||
let clicked = ui
|
||||
.add_enabled_ui(!(point && justify), |ui| {
|
||||
toggle_cell(ui, cw, para.align == a, tip, |ui, r, col| {
|
||||
// The glyph keeps its size; the cell grows around it.
|
||||
let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 10.0));
|
||||
let col = if !ui.is_enabled() || para.align == a { col } else { t.icon };
|
||||
align_glyph(ui.painter(), g, a, col);
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
if clicked {
|
||||
apply(app, ui.ctx(), json!({"align": key}));
|
||||
}
|
||||
|
||||
@@ -2162,3 +2162,52 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() {
|
||||
assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs");
|
||||
assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest");
|
||||
}
|
||||
|
||||
/// #7713: justification needs a paragraph box. On point text the Paragraph panel's four justify
|
||||
/// buttons are greyed, as in Photoshop; on paragraph text they work.
|
||||
#[test]
|
||||
fn justify_buttons_are_greyed_on_point_text() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let justify = ["Justify last left", "Justify last centered", "Justify last right", "Justify all"];
|
||||
for (paragraph, enabled) in [(false, false), (true, true)] {
|
||||
let mut app = new_app();
|
||||
let mut p = json!({"text": "one two three four", "size": 24, "x": 300, "y": 420});
|
||||
if paragraph {
|
||||
p["box"] = json!([300, 300, 240, 180]);
|
||||
}
|
||||
app.run("type.create", p).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(320.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
crate::type_tool::character_panel(app, ui, true);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.run_steps(4);
|
||||
for tip in justify {
|
||||
assert_eq!(!h.get_by_label(tip).accesskit_node().is_disabled(), enabled, "{tip} on {}", if paragraph { "paragraph text" } else { "point text" });
|
||||
}
|
||||
assert!(!h.get_by_label("Center text").accesskit_node().is_disabled(), "alignment itself stays available");
|
||||
}
|
||||
// A selection of a paragraph and a point type layer formats both: justify is greyed.
|
||||
let mut app = new_app();
|
||||
let point = app.run("type.create", json!({"text": "point", "size": 24, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap();
|
||||
let boxed =
|
||||
app.run("type.create", json!({"text": "boxed text", "size": 24, "x": 300, "y": 300, "box": [300, 300, 240, 180]})).unwrap()["layer"].as_u64().unwrap();
|
||||
app.run("layer.select", json!({"layer": point})).unwrap();
|
||||
app.run("layer.select", json!({"layer": boxed, "mode": "add"})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().selected_layers().len(), 2);
|
||||
let mut h = Harness::builder().with_size(vec2(320.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
crate::type_tool::character_panel(app, ui, true);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.run_steps(4);
|
||||
assert!(h.get_by_label("Justify all").accesskit_node().is_disabled(), "one of the formatted layers is point text");
|
||||
}
|
||||
|
||||
@@ -978,6 +978,14 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a
|
||||
painter.circle_filled(handle, 3.0, accent);
|
||||
}
|
||||
|
||||
/// Does `tool` draw the anchor squares of the paths it can edit? Path Selection does, and so do
|
||||
/// the Pen tools: as in Photoshop, a path finished with Enter or by closing it keeps its anchors
|
||||
/// on screen, so it can still be edited (⌘/Ctrl-drag, Auto Add/Delete) (#6884, #4716).
|
||||
pub(crate) fn shows_anchors(tool: Tool) -> bool {
|
||||
// Add/Delete Anchor Point and Convert Point draw Direct Selection's overlay instead.
|
||||
matches!(tool, Tool::PathSelection | Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen)
|
||||
}
|
||||
|
||||
/// Work path / active shape path outlines, anchors, and the pen path in progress.
|
||||
pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) {
|
||||
let tool = app.ui.tool;
|
||||
@@ -1001,20 +1009,21 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
|
||||
crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers));
|
||||
}
|
||||
} else if paths {
|
||||
// Paths remain visible as outlines with any tool. Path Selection adds anchor squares; Direct
|
||||
// Selection has its own overlay above.
|
||||
// Paths remain visible as outlines with any tool. Path Selection and the Pen tools add
|
||||
// anchor squares; Direct Selection has its own overlay above.
|
||||
let anchors = shows_anchors(tool);
|
||||
if let Some(wp) = &doc.work_path {
|
||||
draw_path(wp, tool == Tool::PathSelection);
|
||||
draw_path(wp, anchors);
|
||||
}
|
||||
let st = app.session.active();
|
||||
if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id)))
|
||||
&& let LayerContent::Shape(sh) = &l.content
|
||||
{
|
||||
draw_path(&sh.path, tool == Tool::PathSelection);
|
||||
draw_path(&sh.path, anchors);
|
||||
}
|
||||
// A targeted vector mask shows its path with any tool (#196).
|
||||
if let Some((_, p)) = targeted_vector_mask(app) {
|
||||
draw_path(&p, tool == Tool::PathSelection);
|
||||
draw_path(&p, anchors);
|
||||
}
|
||||
crate::corner_radius::draw(app, painter, &to_scr, accent);
|
||||
}
|
||||
@@ -2036,6 +2045,30 @@ mod tests {
|
||||
out.shapes.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_keeps_anchors_of_a_finished_path() {
|
||||
// #6884: a path finished with Enter, or by closing it, lost its anchor squares while the
|
||||
// Pen stayed active, so it looked gone and could not be edited.
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
let knots = vec![[[10.0, 10.0]; 3], [[80.0, 10.0]; 3], [[80.0, 80.0]; 3]];
|
||||
app.ui.pen = Some(PenPath { knots, ..Default::default() });
|
||||
pen_commit(&mut app, true);
|
||||
assert!(app.ui.pen.is_none());
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_some());
|
||||
let with_pen = overlay_shapes(&app, &ctx);
|
||||
app.ui.tool = Tool::Brush;
|
||||
let outline_only = overlay_shapes(&app, &ctx);
|
||||
assert!(outline_only > 0, "the outline is drawn with any tool");
|
||||
// Each of the three anchors adds its square (fill and stroke).
|
||||
assert!(with_pen >= outline_only + 3, "Pen draws the anchors: {with_pen} vs {outline_only}");
|
||||
for tool in [Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::PathSelection] {
|
||||
assert!(shows_anchors(tool), "{tool:?}");
|
||||
}
|
||||
assert!(!shows_anchors(Tool::Brush) && !shows_anchors(Tool::Move));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_target_path_and_extras_hide_the_path_outlines() {
|
||||
// #1119: View › Show › Target Path only flipped its checkmark; the canvas kept drawing the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user