M037: reconcile current libraries and preserve native IME reset acknowledgements
No files matched your search
@@ -0,0 +1,37 @@
|
||||
name: Shared JPEG
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: shared-jpeg-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
timeout-minutes: 10
|
||||
name: JPEG lint, tests, WASM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-jpeg
|
||||
- run: cargo fmt -p craft-jpeg --check
|
||||
- run: cargo clippy -p craft-jpeg --all-targets --locked -- -D warnings
|
||||
- run: cargo test -p craft-jpeg --locked
|
||||
- run: cargo check -p craft-jpeg --locked --target wasm32-unknown-unknown
|
||||
@@ -106,5 +106,5 @@ jobs:
|
||||
python scripts/test-fetch-pdf-test-fonts.py
|
||||
python scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts"
|
||||
echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV"
|
||||
- run: cargo clippy -p craft-plate-test -p craft-prepress --all-targets --locked -- -D warnings
|
||||
- run: cargo test -p craft-plate-test -p craft-prepress --locked
|
||||
- run: cargo clippy -p craft-plate-test -p craft-prepress --all-targets --all-features --locked -- -D warnings
|
||||
- run: cargo test -p craft-plate-test -p craft-prepress --all-features --locked
|
||||
@@ -108,6 +108,15 @@ name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "as-raw-xcb-connection"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
@@ -516,6 +525,15 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "cgl"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
@@ -784,6 +802,15 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-jpeg"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"thiserror 2.0.21",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
@@ -860,7 +887,11 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"craft-pdf",
|
||||
"craft-plate-test",
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
"thiserror 2.0.21",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -901,10 +932,15 @@ dependencies = [
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1147,6 +1183,9 @@ dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"egui_glow",
|
||||
"glutin",
|
||||
"glutin-winit",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -1213,6 +1252,7 @@ version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
@@ -1240,6 +1280,19 @@ dependencies = [
|
||||
"resvg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"glow",
|
||||
"log",
|
||||
"profiling",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.37.0"
|
||||
@@ -1674,6 +1727,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1767,6 +1830,62 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin"
|
||||
version = "0.32.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg_aliases",
|
||||
"cgl",
|
||||
"dispatch2",
|
||||
"glutin_egl_sys",
|
||||
"glutin_glx_sys",
|
||||
"glutin_wgl_sys",
|
||||
"libloading",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"once_cell",
|
||||
"raw-window-handle",
|
||||
"windows-sys 0.52.0",
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin-winit"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"glutin",
|
||||
"raw-window-handle",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin_egl_sys"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
|
||||
dependencies = [
|
||||
"gl_generator",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin_glx_sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
|
||||
dependencies = [
|
||||
"gl_generator",
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin_wgl_sys"
|
||||
version = "0.6.1"
|
||||
@@ -2449,6 +2568,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-encoder"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
@@ -2544,6 +2669,7 @@ dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"polycool",
|
||||
"serde",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -2587,6 +2713,19 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
|
||||
[[package]]
|
||||
name = "linesweeper"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"kurbo 0.13.1",
|
||||
"polycool",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -5968,6 +6107,7 @@ dependencies = [
|
||||
"atomic-waker",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
@@ -5983,6 +6123,7 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-ui-kit 0.2.2",
|
||||
"orbclient",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
@@ -5995,6 +6136,8 @@ dependencies = [
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"windows-sys 0.52.0",
|
||||
"x11-dl",
|
||||
"x11rb",
|
||||
"xkbcommon-dl",
|
||||
]
|
||||
|
||||
@@ -6073,6 +6216,38 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"as-raw-xcb-connection",
|
||||
"gethostname",
|
||||
"libc",
|
||||
"libloading",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x509-cert"
|
||||
version = "0.2.5"
|
||||
@@ -6415,11 +6590,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
@@ -21,12 +21,14 @@ hayro = { version = "=0.7.1", default-features = false }
|
||||
hayro-interpret = { version = "=0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
|
||||
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
|
||||
bytemuck = "1"
|
||||
kurbo = "0.13"
|
||||
thiserror = "2"
|
||||
craft-atomic = { path = "crates/atomic" }
|
||||
craft-cms = { path = "crates/cms" }
|
||||
craft-docpath = { path = "crates/docpath" }
|
||||
craft-fonts-build = { path = "crates/fonts-build" }
|
||||
craft-i18n = { path = "crates/i18n" }
|
||||
craft-jpeg = { path = "crates/jpeg" }
|
||||
craft-keymap = { path = "crates/keymap" }
|
||||
craft-media = { path = "crates/media" }
|
||||
craft-prepress = { path = "crates/prepress" }
|
||||
|
||||
@@ -44,6 +44,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
lookup with fallback (`pt-br` → `pt` → English), plurals, language names, the system locale and
|
||||
a pseudo locale. The catalogs stay a fully optional build input; apps keep `tl!` and their
|
||||
language setting.
|
||||
- [`craft-jpeg`](crates/jpeg/README.md): clean-room decoder for 12-bit and arithmetic-coded
|
||||
JPEG (T.81 SOF0/SOF1 at 12 bits, SOF9, SOF10), from PhotoCraft, and a frame probe for routing.
|
||||
Metadata stays in each app.
|
||||
- [`craft-keymap`](crates/keymap/README.md): keyboard bindings: chords and their text forms,
|
||||
scopes, conflicts, user overrides stored through craft-settings and import of other
|
||||
applications' shortcut sets. Command catalogs, file readers and the shortcut editor UI stay
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "craft-jpeg"
|
||||
description = "Clean-room decoder for 12-bit and arithmetic-coded JPEG (ITU-T T.81)."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Reference encoder and baseline decoder for the comparison tests.
|
||||
jpeg-encoder = "0.6"
|
||||
zune-jpeg = "0.5"
|
||||
@@ -0,0 +1,145 @@
|
||||
# craft-jpeg
|
||||
|
||||
A clean-room JPEG decoder for the Craft apps, written from ITU-T T.81 (ISO/IEC 10918-1). It
|
||||
covers the processes that common Rust JPEG decoders skip: 12-bit samples and arithmetic coding.
|
||||
No `unsafe`, no panics on malformed input, and it builds for `wasm32-unknown-unknown`. The only
|
||||
dependency is `thiserror`.
|
||||
|
||||
```toml
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "<commit>" }
|
||||
```
|
||||
|
||||
## What it decodes
|
||||
|
||||
| Frame | Process | Precision |
|
||||
|---|---|---|
|
||||
| SOF0, SOF1 | sequential DCT, Huffman coded | 8 or 12-bit |
|
||||
| SOF9 | extended sequential DCT, arithmetic coded | 8 or 12-bit |
|
||||
| SOF10 | progressive DCT, arithmetic coded | 8 or 12-bit |
|
||||
|
||||
Every other frame returns `Error::Unsupported`: progressive Huffman (SOF2), lossless (SOF3,
|
||||
SOF11), hierarchical (SOF5 to SOF7, SOF13 to SOF15), other precisions, more than one frame, and
|
||||
a height given only in a DNL marker. Images have 1, 3 or 4 components with any sampling factors
|
||||
and restart intervals.
|
||||
|
||||
## Use
|
||||
|
||||
```rust
|
||||
use craft_jpeg::{decode, Limits, Samples};
|
||||
|
||||
let img = decode(&bytes, &Limits::default(), adobe_transform)?;
|
||||
match img.samples {
|
||||
Samples::U8(px) => { /* 8-bit frames */ }
|
||||
Samples::U16(px) => { /* 12-bit frames, scaled to 16 bits */ }
|
||||
}
|
||||
if img.damaged { /* warn: part of the image is missing */ }
|
||||
```
|
||||
|
||||
- `decode(bytes, &Limits, adobe_transform: Option<u8>) -> Result<Decoded, Error>`.
|
||||
`adobe_transform` is the transform byte of the APP14 `Adobe` segment, when the app found one.
|
||||
- `frame(bytes) -> Option<FrameKind>` reads the headers up to the first SOFn marker and returns
|
||||
that marker and the declared sample precision, without decoding.
|
||||
- `Error` is `Malformed`, `Unsupported` or `LimitExceeded`, each with a message.
|
||||
|
||||
## Output
|
||||
|
||||
`Decoded` has `width`, `height`, `components`, `samples` and `damaged`.
|
||||
|
||||
- `samples` is interleaved by pixel, rows top to bottom: `Samples::U8` for 8-bit frames,
|
||||
`Samples::U16` for 12-bit frames, scaled to the full 16-bit range
|
||||
(`round(v × 65535 / 4095)`).
|
||||
- `components` is 1 (gray), 3 (RGB) or 4 (CMYK).
|
||||
- 3 components are converted from YCbCr to RGB (JFIF, T.871), unless the Adobe transform is
|
||||
0 or the component IDs are `R`, `G`, `B`.
|
||||
- 4 components are ink amounts (0 = no ink). The stored values are taken to follow Adobe's
|
||||
inverted convention (maximum = no ink) and are inverted back. With Adobe transform 2 (YCCK),
|
||||
C, M and Y are converted from YCbCr, which yields ink amounts directly, and K is inverted.
|
||||
- `damaged` is set when entropy-coded data was cut short or corrupt after at least one MCU had
|
||||
decoded, or its scans would decode more than 64 passes over the frame's blocks. The rest of
|
||||
that scan, and every scan past the budget, is left out; those blocks keep what earlier scans
|
||||
decoded. A stream with no decodable image data is an error. Arithmetic-coded data that stops
|
||||
early at a marker reads as the zero bytes encoders drop at the end of a scan (libjpeg-turbo
|
||||
treats it the same way), so for arithmetic scans only data that runs to the end of the file
|
||||
and a missing restart marker count as damage.
|
||||
|
||||
## Limits
|
||||
|
||||
`Limits` (fields `max_pixels`, `max_alloc`) is checked against the frame header before anything
|
||||
is allocated: `width × height` against `max_pixels`, and the output buffer plus the decoder's
|
||||
own coefficient and sample buffers, together, against `max_alloc`. The defaults are 2³⁰ pixels
|
||||
and 8 GiB on 64-bit targets, 1 GiB on 32-bit ones (wasm32). The struct is `#[non_exhaustive]`:
|
||||
start from the defaults and set the fields you change:
|
||||
|
||||
```rust
|
||||
let mut limits = craft_jpeg::Limits::default();
|
||||
limits.max_alloc = 512 << 20;
|
||||
```
|
||||
|
||||
A file of under a hundred bytes can declare a frame that fills the default limits, and
|
||||
decoding it still renders every sample. Callers that decode for a UI or for previews should
|
||||
pass a `max_alloc` well below the default.
|
||||
|
||||
## Routing
|
||||
|
||||
An app that decodes baseline JPEG with another decoder keeps its own rule for which files come
|
||||
here, built on `frame`. PhotoCraft sends arithmetic-coded files (SOF9 to SOF11) and SOF0/SOF1
|
||||
files whose precision is not 8 bits:
|
||||
|
||||
```rust
|
||||
fn use_craft_jpeg(bytes: &[u8]) -> bool {
|
||||
match craft_jpeg::frame(bytes) {
|
||||
Some(f) if (0xC9..=0xCB).contains(&f.marker) => true,
|
||||
Some(f) if matches!(f.marker, 0xC0 | 0xC1) => f.precision != 8,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
SOF11 (lossless) falls in that range and returns `Unsupported`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- In scope: decoding the frames above into samples.
|
||||
- Out of scope: metadata. The decoder skips every APPn and COM segment. ICC profiles, EXIF,
|
||||
XMP, Photoshop resources, resolution and the APP14 `Adobe` segment are the app's to read.
|
||||
Color management and encoding also stay in the apps.
|
||||
|
||||
## Testing
|
||||
|
||||
`cargo test -p craft-jpeg` runs:
|
||||
|
||||
- unit tests in `src/tests.rs`: arithmetic-coded streams from a test-only encoder must decode to
|
||||
the coefficients that went in, and Huffman streams are compared against `zune-jpeg`.
|
||||
- `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how
|
||||
the derived files were made: `tests/data/SOURCE.md`):
|
||||
- `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode.
|
||||
- jpegtran's arithmetic and progressive arithmetic transcodes of `testorig.jpg` (one with
|
||||
restarts) and `testorig12.jpg` decode to exactly the samples of their sources.
|
||||
- `whitetail-ari.jpg` (SOF9 from cjpeg, a white lower part that decodes from the zeros
|
||||
supplied at EOI) decodes undamaged and equal to its Huffman transcode.
|
||||
- `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit
|
||||
decode, within the float-against-integer IDCT difference (mean under 0.75, max 4).
|
||||
- `tests/mutation.rs`: a seeded mutation run (truncations, bit flips, byte and frame-header
|
||||
overwrites, deleted and repeated spans) over the test images and the fuzz seeds. Decoding must
|
||||
not panic and must stay within small `Limits`.
|
||||
|
||||
`.github/workflows/jpeg.yml` runs fmt, clippy, the tests and a `wasm32-unknown-unknown` check on
|
||||
Linux.
|
||||
|
||||
### Fuzzing
|
||||
|
||||
`fuzz/` is a cargo-fuzz crate outside the workspace, with one target, `decode`. Its seeds in
|
||||
`fuzz/corpus/decode` are the test images plus small SOF9 (with restarts), 12-bit SOF9, SOF10
|
||||
and 12-bit SOF1 streams. With nightly and `cargo install cargo-fuzz`:
|
||||
|
||||
```sh
|
||||
cd crates/jpeg/fuzz
|
||||
cargo +nightly fuzz run decode
|
||||
```
|
||||
|
||||
Running it adds the inputs it finds to `fuzz/corpus/decode`; commit only seeds that cover
|
||||
something new.
|
||||
|
||||
## Credits
|
||||
|
||||
The decoder was written by Michael Higgins (Higgs32584) for PhotoCraft.
|
||||
@@ -0,0 +1,4 @@
|
||||
target
|
||||
artifacts
|
||||
coverage
|
||||
Cargo.lock
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "craft-jpeg-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2024"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
craft-jpeg = { path = ".." }
|
||||
|
||||
# Its own workspace: cargo-fuzz needs nightly and sanitizer flags, so this crate stays out of the
|
||||
# craft-libs workspace and its lockfile.
|
||||
[workspace]
|
||||
|
||||
[[bin]]
|
||||
name = "decode"
|
||||
path = "fuzz_targets/decode.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
After Width: | Height: | Size: 138 B |
|
After Width: | Height: | Size: 394 B |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,29 @@
|
||||
#![no_main]
|
||||
|
||||
use craft_jpeg::{Limits, Samples, decode, frame};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fn fuzz_limits() -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
limits.max_pixels = 1 << 20;
|
||||
limits.max_alloc = 64 << 20;
|
||||
limits
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let limits = fuzz_limits();
|
||||
let _ = frame(data);
|
||||
// The input length picks the Adobe transform, so every color path is reached without
|
||||
// changing the stream.
|
||||
let adobe = [None, Some(0), Some(1), Some(2)][data.len() % 4];
|
||||
if let Ok(img) = decode(data, &limits, adobe) {
|
||||
let pixels = u64::from(img.width) * u64::from(img.height);
|
||||
let (len, width) = match &img.samples {
|
||||
Samples::U8(v) => (v.len() as u64, 1),
|
||||
Samples::U16(v) => (v.len() as u64, 2),
|
||||
};
|
||||
assert!(pixels <= limits.max_pixels);
|
||||
assert!(len * width <= limits.max_alloc);
|
||||
assert_eq!(len, pixels * u64::from(img.components));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,833 @@
|
||||
use super::*;
|
||||
|
||||
/// The test image decoded by `zune-jpeg`, as interleaved 8-bit samples, and its size.
|
||||
fn zune_decode(bytes: &[u8]) -> (Vec<u8>, (u32, u32)) {
|
||||
let mut dec = zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(bytes));
|
||||
let px = dec.decode().unwrap();
|
||||
let (w, h) = dec.dimensions().unwrap();
|
||||
(px, (w as u32, h as u32))
|
||||
}
|
||||
|
||||
fn decode_default(bytes: &[u8]) -> Result<Decoded, Error> {
|
||||
decode(bytes, &Limits::default(), None)
|
||||
}
|
||||
|
||||
fn u16_samples(img: &Decoded) -> &[u16] {
|
||||
match &img.samples {
|
||||
Samples::U16(v) => v,
|
||||
Samples::U8(_) => panic!("8-bit samples"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- A test-only arithmetic encoder (T.81 D.1, F.1.4 and G.1.3), mirroring the decoder ---
|
||||
|
||||
struct Enc {
|
||||
out: Vec<u8>,
|
||||
c: u32,
|
||||
a: u32,
|
||||
ct: u32,
|
||||
stacked: u32,
|
||||
b: Option<u8>,
|
||||
}
|
||||
|
||||
impl Enc {
|
||||
fn new() -> Self {
|
||||
Enc { out: Vec::new(), c: 0, a: 0x10000, ct: 11, stacked: 0, b: None }
|
||||
}
|
||||
|
||||
fn emit(&mut self, v: u8) {
|
||||
self.out.push(v);
|
||||
if v == 0xFF {
|
||||
self.out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn byte_out(&mut self) {
|
||||
let t = self.c >> 19;
|
||||
if t > 0xFF {
|
||||
let b = self.b.unwrap() + 1;
|
||||
self.emit(b);
|
||||
for _ in 0..self.stacked {
|
||||
self.emit(0);
|
||||
}
|
||||
self.stacked = 0;
|
||||
self.b = Some((t & 0xFF) as u8);
|
||||
} else if t == 0xFF {
|
||||
self.stacked += 1;
|
||||
} else {
|
||||
if let Some(b) = self.b {
|
||||
self.emit(b);
|
||||
}
|
||||
for _ in 0..self.stacked {
|
||||
self.emit(0xFF);
|
||||
}
|
||||
self.stacked = 0;
|
||||
self.b = Some(t as u8);
|
||||
}
|
||||
self.c &= 0x7FFFF;
|
||||
}
|
||||
|
||||
fn renorm(&mut self) {
|
||||
while self.a < 0x8000 {
|
||||
self.a <<= 1;
|
||||
self.c <<= 1;
|
||||
self.ct -= 1;
|
||||
if self.ct == 0 {
|
||||
self.byte_out();
|
||||
self.ct = 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&mut self, st: &mut u8, d: u8) {
|
||||
let mps = *st >> 7;
|
||||
let (qe, nlps, nmps, switch) = QE[usize::from(*st & 0x7F)];
|
||||
self.a -= qe;
|
||||
if d == mps {
|
||||
if self.a < 0x8000 {
|
||||
if self.a < qe {
|
||||
self.c += self.a;
|
||||
self.a = qe;
|
||||
}
|
||||
*st = nmps | (mps << 7);
|
||||
self.renorm();
|
||||
}
|
||||
} else {
|
||||
if self.a >= qe {
|
||||
self.c += self.a;
|
||||
self.a = qe;
|
||||
}
|
||||
*st = nlps | ((mps ^ u8::from(switch)) << 7);
|
||||
self.renorm();
|
||||
}
|
||||
}
|
||||
|
||||
fn code_fixed(&mut self, d: u8) {
|
||||
let mut st = 0;
|
||||
self.code(&mut st, d);
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
let mut t = (self.c + self.a - 1) & 0xFFFF_0000;
|
||||
if t < self.c {
|
||||
t += 0x8000;
|
||||
}
|
||||
self.c = t << self.ct;
|
||||
self.byte_out();
|
||||
self.c <<= 8;
|
||||
self.byte_out();
|
||||
if let Some(b) = self.b {
|
||||
self.emit(b);
|
||||
}
|
||||
for _ in 0..self.stacked {
|
||||
self.emit(0xFF);
|
||||
}
|
||||
// Trailing zero bytes are dropped, as libjpeg's encoder does; the decoder supplies
|
||||
// zeros at the marker. A zero right after `FF` is stuffing and stays.
|
||||
while self.out.last() == Some(&0) && self.out.len().checked_sub(2).map(|i| self.out[i]) != Some(0xFF) {
|
||||
self.out.pop();
|
||||
}
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
/// Arithmetic conditioning of one entropy table: DC (L, U) and AC Kx (F.1.4.4).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct Cond {
|
||||
l: u8,
|
||||
u: u8,
|
||||
kx: u8,
|
||||
}
|
||||
|
||||
/// The conditioning in force without a DAC segment (B.2.4.3).
|
||||
const DEFAULT_COND: Cond = Cond { l: 0, u: 1, kx: 5 };
|
||||
|
||||
/// Statistics per entropy table, DC prediction and context per scan component.
|
||||
struct EncScan {
|
||||
dc: [[u8; 64]; 4],
|
||||
ac: [[u8; 256]; 4],
|
||||
ctx: [usize; 4],
|
||||
pred: [i32; 4],
|
||||
cond: [Cond; 4],
|
||||
}
|
||||
|
||||
impl EncScan {
|
||||
fn new(cond: [Cond; 4]) -> Self {
|
||||
EncScan { dc: [[0; 64]; 4], ac: [[0; 256]; 4], ctx: [0; 4], pred: [0; 4], cond }
|
||||
}
|
||||
|
||||
/// Encode_DC_DIFF of scan component `sc` with DC table `t`.
|
||||
fn dc(&mut self, e: &mut Enc, sc: usize, t: usize, value: i32) {
|
||||
let diff = value - self.pred[sc];
|
||||
self.pred[sc] = value;
|
||||
let st = &mut self.dc[t];
|
||||
let s0 = self.ctx[sc];
|
||||
if diff == 0 {
|
||||
e.code(&mut st[s0], 0);
|
||||
self.ctx[sc] = 0;
|
||||
return;
|
||||
}
|
||||
e.code(&mut st[s0], 1);
|
||||
let sign = u8::from(diff < 0);
|
||||
e.code(&mut st[s0 + 1], sign);
|
||||
let sp = s0 + 2 + usize::from(sign);
|
||||
let sz = diff.unsigned_abs() - 1;
|
||||
let mut m = 0u32;
|
||||
if sz == 0 {
|
||||
e.code(&mut st[sp], 0);
|
||||
} else {
|
||||
e.code(&mut st[sp], 1);
|
||||
m = 1;
|
||||
let mut s = 20;
|
||||
while m << 1 <= sz {
|
||||
e.code(&mut st[s], 1);
|
||||
m <<= 1;
|
||||
s += 1;
|
||||
}
|
||||
e.code(&mut st[s], 0);
|
||||
let mut bit = m >> 1;
|
||||
while bit != 0 {
|
||||
e.code(&mut st[s + 14], u8::from(sz & bit != 0));
|
||||
bit >>= 1;
|
||||
}
|
||||
}
|
||||
// Conditioning category as libjpeg's jcarith.c computes it, from the magnitude
|
||||
// category `m` rather than the difference.
|
||||
let Cond { l, u, .. } = self.cond[t];
|
||||
self.ctx[sc] = if m < (1 << l) >> 1 {
|
||||
0
|
||||
} else if m > (1 << u) >> 1 {
|
||||
12 + 4 * usize::from(sign)
|
||||
} else {
|
||||
4 + 4 * usize::from(sign)
|
||||
};
|
||||
}
|
||||
|
||||
/// Encode_AC_coefficients with AC table `t` over `ss..=se` of the coefficients scaled down
|
||||
/// by `al`.
|
||||
fn ac(&mut self, e: &mut Enc, t: usize, blk: &[i32; 64], ss: usize, se: usize, al: u8) {
|
||||
let kx = usize::from(self.cond[t].kx);
|
||||
let st = &mut self.ac[t];
|
||||
let v = |k: usize| if blk[k] < 0 { -((-blk[k]) >> al) } else { blk[k] >> al };
|
||||
let last = (ss..=se).rev().find(|&k| v(k) != 0);
|
||||
let mut k = ss;
|
||||
while k <= se {
|
||||
if last.is_none_or(|l| k > l) {
|
||||
e.code(&mut st[3 * (k - 1)], 1);
|
||||
return;
|
||||
}
|
||||
e.code(&mut st[3 * (k - 1)], 0);
|
||||
while v(k) == 0 {
|
||||
e.code(&mut st[3 * (k - 1) + 1], 0);
|
||||
k += 1;
|
||||
}
|
||||
e.code(&mut st[3 * (k - 1) + 1], 1);
|
||||
e.code_fixed(u8::from(v(k) < 0));
|
||||
let sz = v(k).unsigned_abs() - 1;
|
||||
let sp = 3 * (k - 1) + 2;
|
||||
if sz == 0 {
|
||||
e.code(&mut st[sp], 0);
|
||||
} else {
|
||||
e.code(&mut st[sp], 1);
|
||||
if sz == 1 {
|
||||
e.code(&mut st[sp], 0);
|
||||
} else {
|
||||
e.code(&mut st[sp], 1);
|
||||
let mut m = 2u32;
|
||||
let mut s = if k <= kx { 189 } else { 217 };
|
||||
while m << 1 <= sz {
|
||||
e.code(&mut st[s], 1);
|
||||
m <<= 1;
|
||||
s += 1;
|
||||
}
|
||||
e.code(&mut st[s], 0);
|
||||
let mut bit = m >> 1;
|
||||
while bit != 0 {
|
||||
e.code(&mut st[s + 14], u8::from(sz & bit != 0));
|
||||
bit >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Successive approximation of AC coefficients with table `t`: bit `al` of each (G.1.3.3).
|
||||
fn ac_refine(&mut self, e: &mut Enc, t: usize, blk: &[i32; 64], ss: usize, se: usize, al: u8) {
|
||||
let st = &mut self.ac[t];
|
||||
let prior = |k: usize| blk[k].unsigned_abs() >> (al + 1) != 0;
|
||||
let now = |k: usize| blk[k].unsigned_abs() >> al != 0;
|
||||
let eob_prev = (ss..=se).rev().find(|&k| prior(k)).map_or(ss, |k| k + 1);
|
||||
let eob_now = (ss..=se).rev().find(|&k| now(k)).map_or(ss, |k| k + 1);
|
||||
let mut k = ss;
|
||||
while k <= se {
|
||||
if k >= eob_prev {
|
||||
if k >= eob_now {
|
||||
e.code(&mut st[3 * (k - 1)], 1);
|
||||
return;
|
||||
}
|
||||
e.code(&mut st[3 * (k - 1)], 0);
|
||||
}
|
||||
loop {
|
||||
if prior(k) {
|
||||
e.code(&mut st[3 * (k - 1) + 2], ((blk[k].unsigned_abs() >> al) & 1) as u8);
|
||||
break;
|
||||
}
|
||||
if now(k) {
|
||||
e.code(&mut st[3 * (k - 1) + 1], 1);
|
||||
e.code_fixed(u8::from(blk[k] < 0));
|
||||
break;
|
||||
}
|
||||
e.code(&mut st[3 * (k - 1) + 1], 0);
|
||||
k += 1;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random coefficients: mostly small, zero runs, a few large.
|
||||
fn coefficients(n: usize, seed: u32, big: i32) -> Vec<[i32; 64]> {
|
||||
let mut s = seed;
|
||||
let mut next = move || {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
s >> 8
|
||||
};
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
let mut b = [0i32; 64];
|
||||
b[0] = (next() % (2 * big as u32 + 1)) as i32 - big;
|
||||
for v in b.iter_mut().skip(1) {
|
||||
let mag = match next() % 100 {
|
||||
0..=59 => 0,
|
||||
60..=89 => 1 + (next() % 4) as i32,
|
||||
90..=97 => 1 + (next() % 60) as i32,
|
||||
_ => 1 + (next() % big as u32) as i32,
|
||||
};
|
||||
*v = if next() % 2 == 0 { mag } else { -mag };
|
||||
}
|
||||
if next() % 3 == 0 {
|
||||
for v in &mut b[20..] {
|
||||
*v = 0;
|
||||
}
|
||||
}
|
||||
b
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn segment(out: &mut Vec<u8>, marker: u8, body: &[u8]) {
|
||||
out.extend_from_slice(&[0xFF, marker]);
|
||||
out.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
|
||||
/// A component of a test frame: id, sampling factors, quantization table slot, and the
|
||||
/// coefficients of its block grid padded to whole MCUs, row-major.
|
||||
struct TestComp {
|
||||
id: u8,
|
||||
h: usize,
|
||||
v: usize,
|
||||
tq: u8,
|
||||
blocks: Vec<[i32; 64]>,
|
||||
}
|
||||
|
||||
/// A scan of a test frame: frame component indexes, `(ss, se, ah, al)`, and segments
|
||||
/// written just before its header.
|
||||
struct TestScan {
|
||||
comps: Vec<usize>,
|
||||
band: (usize, usize, u8, u8),
|
||||
before: Vec<u8>,
|
||||
}
|
||||
|
||||
/// An arithmetic-coded frame (SOF9, or SOF10 when `progressive`). Component `ci` uses entropy
|
||||
/// tables `min(ci, 1)`, as libjpeg's defaults do; a DAC segment is written for the tables
|
||||
/// whose `cond` isn't the default, and quantization slot 0 holds all ones until a scan's
|
||||
/// `before` replaces it.
|
||||
struct TestFrame {
|
||||
precision: u8,
|
||||
progressive: bool,
|
||||
width: usize,
|
||||
height: usize,
|
||||
comps: Vec<TestComp>,
|
||||
cond: [Cond; 4],
|
||||
ri: usize,
|
||||
scans: Vec<TestScan>,
|
||||
}
|
||||
|
||||
impl TestFrame {
|
||||
fn encode(&self) -> Vec<u8> {
|
||||
let mut s = vec![0xFF, 0xD8];
|
||||
let mut dqt = vec![0u8];
|
||||
dqt.extend([1u8; 64]);
|
||||
segment(&mut s, 0xDB, &dqt);
|
||||
let mut sof = vec![self.precision];
|
||||
sof.extend((self.height as u16).to_be_bytes());
|
||||
sof.extend((self.width as u16).to_be_bytes());
|
||||
sof.push(self.comps.len() as u8);
|
||||
for c in &self.comps {
|
||||
sof.extend([c.id, (c.h << 4 | c.v) as u8, c.tq]);
|
||||
}
|
||||
segment(&mut s, if self.progressive { 0xCA } else { 0xC9 }, &sof);
|
||||
let mut dac = Vec::new();
|
||||
for (t, c) in self.cond.iter().enumerate().filter(|(_, c)| **c != DEFAULT_COND) {
|
||||
dac.extend([t as u8, c.l | c.u << 4, 0x10 | t as u8, c.kx]);
|
||||
}
|
||||
if !dac.is_empty() {
|
||||
segment(&mut s, 0xCC, &dac);
|
||||
}
|
||||
if self.ri > 0 {
|
||||
segment(&mut s, 0xDD, &(self.ri as u16).to_be_bytes());
|
||||
}
|
||||
let hmax = self.comps.iter().map(|c| c.h).max().unwrap();
|
||||
let vmax = self.comps.iter().map(|c| c.v).max().unwrap();
|
||||
let mcux = self.width.div_ceil(8 * hmax);
|
||||
let mcuy = self.height.div_ceil(8 * vmax);
|
||||
for scan in &self.scans {
|
||||
s.extend(&scan.before);
|
||||
let (ss, se, ah, al) = scan.band;
|
||||
let mut sos = vec![scan.comps.len() as u8];
|
||||
for &ci in &scan.comps {
|
||||
let t = ci.min(1) as u8;
|
||||
sos.extend([self.comps[ci].id, t << 4 | t]);
|
||||
}
|
||||
sos.extend([ss as u8, se as u8, ah << 4 | al]);
|
||||
segment(&mut s, 0xDA, &sos);
|
||||
// Blocks in coding order (A.2): `(scan component, block)` for each MCU.
|
||||
let mut mcus: Vec<Vec<(usize, &[i32; 64])>> = Vec::new();
|
||||
if let [ci] = scan.comps[..] {
|
||||
let c = &self.comps[ci];
|
||||
let bw = mcux * c.h;
|
||||
let (cw, ch) = ((self.width * c.h).div_ceil(hmax), (self.height * c.v).div_ceil(vmax));
|
||||
for by in 0..ch.div_ceil(8) {
|
||||
for bx in 0..cw.div_ceil(8) {
|
||||
mcus.push(vec![(0, &c.blocks[by * bw + bx])]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for my in 0..mcuy {
|
||||
for mx in 0..mcux {
|
||||
let mut mcu = Vec::new();
|
||||
for (sc, &ci) in scan.comps.iter().enumerate() {
|
||||
let c = &self.comps[ci];
|
||||
for by in my * c.v..(my + 1) * c.v {
|
||||
for bx in mx * c.h..(mx + 1) * c.h {
|
||||
mcu.push((sc, &c.blocks[by * mcux * c.h + bx]));
|
||||
}
|
||||
}
|
||||
}
|
||||
mcus.push(mcu);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut e = Enc::new();
|
||||
let mut st = EncScan::new(self.cond);
|
||||
let mut rst = 0u8;
|
||||
for (n, mcu) in mcus.iter().enumerate() {
|
||||
if self.ri > 0 && n > 0 && n % self.ri == 0 {
|
||||
s.extend(std::mem::replace(&mut e, Enc::new()).finish());
|
||||
s.extend([0xFF, 0xD0 + rst]);
|
||||
rst = (rst + 1) & 7;
|
||||
st = EncScan::new(self.cond);
|
||||
}
|
||||
for &(sc, blk) in mcu {
|
||||
let t = scan.comps[sc].min(1);
|
||||
match (self.progressive, ss, ah) {
|
||||
(false, _, _) => {
|
||||
st.dc(&mut e, sc, t, blk[0]);
|
||||
st.ac(&mut e, t, blk, 1, 63, 0);
|
||||
}
|
||||
(true, 0, 0) => st.dc(&mut e, sc, t, blk[0] >> al),
|
||||
(true, 0, _) => e.code_fixed(((blk[0] >> al) & 1) as u8),
|
||||
(true, _, 0) => st.ac(&mut e, t, blk, ss, se, al),
|
||||
(true, _, _) => st.ac_refine(&mut e, t, blk, ss, se, al),
|
||||
}
|
||||
}
|
||||
}
|
||||
s.extend(e.finish());
|
||||
}
|
||||
s.extend([0xFF, 0xD9]);
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// A gray SOF9 stream (SOF10 when there are several scans) holding `blocks` (`bw` × `bh`),
|
||||
/// with the scans `(ss, se, ah, al)` and restart interval `ri` (in MCUs).
|
||||
fn arith_stream(precision: u8, bw: usize, bh: usize, blocks: &[[i32; 64]], scans: &[(usize, usize, u8, u8)], ri: usize) -> Vec<u8> {
|
||||
TestFrame {
|
||||
precision,
|
||||
progressive: scans.len() > 1,
|
||||
width: bw * 8,
|
||||
height: bh * 8,
|
||||
comps: vec![TestComp { id: 1, h: 1, v: 1, tq: 0, blocks: blocks.to_vec() }],
|
||||
cond: [DEFAULT_COND; 4],
|
||||
ri,
|
||||
scans: scans.iter().map(|&band| TestScan { comps: vec![0], band, before: Vec::new() }).collect(),
|
||||
}
|
||||
.encode()
|
||||
}
|
||||
|
||||
fn decoded_blocks(stream: &[u8]) -> Vec<[i32; 64]> {
|
||||
let (mut frame, _, damaged) = decode_coefficients(stream, &Limits::default()).unwrap();
|
||||
assert!(!damaged);
|
||||
std::mem::take(&mut frame.comps[0].coefs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_sequential_round_trips_coefficients() {
|
||||
for (precision, big, ri) in [(8u8, 1000, 0usize), (12, 16000, 0), (8, 300, 1), (12, 3000, 5)] {
|
||||
let blocks = coefficients(12, u32::from(precision) * 7 + ri as u32, big);
|
||||
let stream = arith_stream(precision, 4, 3, &blocks, &[(0, 63, 0, 0)], ri);
|
||||
assert_eq!(decoded_blocks(&stream), blocks, "P={precision} ri={ri}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_progressive_round_trips_coefficients() {
|
||||
let blocks = coefficients(6, 99, 2000);
|
||||
let scans = [(0, 0, 0, 1), (1, 5, 0, 2), (6, 63, 0, 1), (1, 5, 2, 1), (0, 0, 1, 0), (1, 5, 1, 0), (6, 63, 1, 0)];
|
||||
for ri in [0, 2] {
|
||||
let stream = arith_stream(8, 3, 2, &blocks, &scans, ri);
|
||||
assert_eq!(decoded_blocks(&stream), blocks, "ri={ri}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every component's coefficients, and whether the stream was damaged.
|
||||
fn decoded_components(stream: &[u8]) -> (Vec<Vec<[i32; 64]>>, bool) {
|
||||
let (frame, _, damaged) = decode_coefficients(stream, &Limits::default()).unwrap();
|
||||
(frame.comps.into_iter().map(|c| c.coefs).collect(), damaged)
|
||||
}
|
||||
|
||||
/// Non-default DC conditioning (L, U) and AC Kx from a DAC segment round-trip, and the decoder
|
||||
/// applies them: the same data read with the default conditioning comes out different.
|
||||
#[test]
|
||||
fn arithmetic_conditioning_from_dac_round_trips() {
|
||||
let blocks = coefficients(12, 21, 1000);
|
||||
for (l, u, kx) in [(2, 4, 2), (1, 1, 20), (0, 0, 1), (3, 7, 63), (5, 15, 40)] {
|
||||
let cond = Cond { l, u, kx };
|
||||
for progressive in [false, true] {
|
||||
let bands: &[(usize, usize, u8, u8)] = if progressive { &[(0, 0, 0, 0), (1, 63, 0, 0)] } else { &[(0, 63, 0, 0)] };
|
||||
let stream = TestFrame {
|
||||
precision: 8,
|
||||
progressive,
|
||||
width: 32,
|
||||
height: 24,
|
||||
comps: vec![TestComp { id: 1, h: 1, v: 1, tq: 0, blocks: blocks.clone() }],
|
||||
cond: [cond, DEFAULT_COND, DEFAULT_COND, DEFAULT_COND],
|
||||
ri: 0,
|
||||
scans: bands.iter().map(|&band| TestScan { comps: vec![0], band, before: Vec::new() }).collect(),
|
||||
}
|
||||
.encode();
|
||||
assert_eq!(decoded_blocks(&stream), blocks, "L={l} U={u} Kx={kx} progressive={progressive}");
|
||||
let dac = stream.windows(2).position(|p| p == [0xFF, 0xCC]).unwrap();
|
||||
let len = usize::from(u16::from_be_bytes([stream[dac + 2], stream[dac + 3]]));
|
||||
let mut without = stream.clone();
|
||||
without.drain(dac..dac + 2 + len);
|
||||
let differs = decode_coefficients(&without, &Limits::default()).map_or(true, |(f, _, _)| f.comps[0].coefs != blocks);
|
||||
assert!(differs, "L={l} U={u} Kx={kx} progressive={progressive}: DAC ignored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved scans of a subsampled frame (2×2 luma, two 1×1 chroma components sharing
|
||||
/// entropy table 1 with non-default conditioning), sequential with and without restarts, and
|
||||
/// progressive.
|
||||
#[test]
|
||||
fn arithmetic_interleaved_subsampled_round_trips() {
|
||||
let comps = |seed: u32| {
|
||||
vec![
|
||||
TestComp { id: 1, h: 2, v: 2, tq: 0, blocks: coefficients(8, seed, 800) },
|
||||
TestComp { id: 2, h: 1, v: 1, tq: 0, blocks: coefficients(2, seed + 1, 300) },
|
||||
TestComp { id: 3, h: 1, v: 1, tq: 0, blocks: coefficients(2, seed + 2, 300) },
|
||||
]
|
||||
};
|
||||
let cond = [DEFAULT_COND, Cond { l: 1, u: 3, kx: 8 }, DEFAULT_COND, DEFAULT_COND];
|
||||
let scan = |comps: &[usize], band| TestScan { comps: comps.to_vec(), band, before: Vec::new() };
|
||||
let progressive = vec![
|
||||
scan(&[0, 1, 2], (0, 0, 0, 1)),
|
||||
scan(&[0], (1, 63, 0, 0)),
|
||||
scan(&[2], (1, 63, 0, 0)),
|
||||
scan(&[1], (1, 63, 0, 0)),
|
||||
scan(&[0, 1, 2], (0, 0, 1, 0)),
|
||||
];
|
||||
for (seed, ri, scans) in [(1, 0, vec![scan(&[0, 1, 2], (0, 63, 0, 0))]), (2, 1, vec![scan(&[0, 1, 2], (0, 63, 0, 0))]), (3, 0, progressive)] {
|
||||
let frame = TestFrame { precision: 8, progressive: scans.len() > 1, width: 32, height: 16, comps: comps(seed), cond, ri, scans };
|
||||
let (got, damaged) = decoded_components(&frame.encode());
|
||||
assert!(!damaged, "seed {seed}");
|
||||
for (c, want) in got.iter().zip(&frame.comps) {
|
||||
assert_eq!(c, &want.blocks, "seed {seed} component {}", want.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each component dequantizes with the table in its slot at its first scan, not the one the
|
||||
/// slot holds when the stream ends.
|
||||
#[test]
|
||||
fn quantization_tables_are_latched_at_the_first_scan() {
|
||||
// DC-only blocks: sample = DC × q₀ / 8 + 128.
|
||||
let mut blk = [0i32; 64];
|
||||
blk[0] = 40;
|
||||
let dqt = |q0: u8| {
|
||||
let mut body = vec![0u8, q0];
|
||||
body.extend([1u8; 63]);
|
||||
let mut seg = Vec::new();
|
||||
segment(&mut seg, 0xDB, &body);
|
||||
seg
|
||||
};
|
||||
let stream = TestFrame {
|
||||
precision: 8,
|
||||
progressive: false,
|
||||
width: 16,
|
||||
height: 16,
|
||||
comps: b"RGB".iter().map(|&id| TestComp { id, h: 1, v: 1, tq: 0, blocks: vec![blk; 4] }).collect(),
|
||||
cond: [DEFAULT_COND; 4],
|
||||
ri: 0,
|
||||
scans: [2, 4, 8].iter().enumerate().map(|(ci, &q0)| TestScan { comps: vec![ci], band: (0, 63, 0, 0), before: dqt(q0) }).collect(),
|
||||
}
|
||||
.encode();
|
||||
let img = decode_default(&stream).unwrap();
|
||||
assert!(!img.damaged);
|
||||
let Samples::U8(px) = &img.samples else { panic!("16-bit samples") };
|
||||
assert!(px.chunks(3).all(|p| p == [138, 148, 168]), "{:?}", &px[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_twelve_bit_flat_block_is_sixteen_bit() {
|
||||
// DC only: sample = DC / 8 + 2048 with a quantizer of 1; 1000 → 2173.
|
||||
let mut blk = [0i32; 64];
|
||||
blk[0] = 1000;
|
||||
let stream = arith_stream(12, 1, 1, &[blk], &[(0, 63, 0, 0)], 0);
|
||||
assert_eq!(frame(&stream), Some(FrameKind { marker: 0xC9, precision: 12 }));
|
||||
let img = decode_default(&stream).unwrap();
|
||||
assert_eq!((img.width, img.height, img.components), (8, 8, 1));
|
||||
let want = (2173.0f32 * 65535.0 / 4095.0).round() as u16;
|
||||
assert!(u16_samples(&img).iter().all(|&v| v.abs_diff(want) <= 1));
|
||||
}
|
||||
|
||||
/// A 12-bit SOF1 stream built by hand: one 8×8 gray block with only a DC coefficient. The DC
|
||||
/// table has one code (`0`, category 13), the AC table one (`0`, end of block).
|
||||
fn twelve_bit_huffman_block() -> Vec<u8> {
|
||||
let mut s = vec![0xFF, 0xD8];
|
||||
let mut dqt = vec![0u8];
|
||||
dqt.extend([1u8; 64]);
|
||||
segment(&mut s, 0xDB, &dqt);
|
||||
segment(&mut s, 0xC1, &[12, 0, 8, 0, 8, 1, 1, 0x11, 0]);
|
||||
let mut dht = vec![0x00, 1];
|
||||
dht.extend([0u8; 15]);
|
||||
dht.push(13);
|
||||
dht.extend([0x10, 1]);
|
||||
dht.extend([0u8; 15]);
|
||||
dht.push(0x00);
|
||||
segment(&mut s, 0xC4, &dht);
|
||||
segment(&mut s, 0xDA, &[1, 1, 0x00, 0, 63, 0]);
|
||||
// DC = 7616 → sample 7616 / 8 + 2048 = 3000. Bits: `0`, 7616 in 13 bits, `0`, one pad `1`.
|
||||
let word: u16 = (7616 << 2) | 1;
|
||||
s.extend(word.to_be_bytes());
|
||||
s.extend([0xFF, 0xD9]);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twelve_bit_huffman_block_decodes_to_sixteen_bit() {
|
||||
let stream = twelve_bit_huffman_block();
|
||||
assert_eq!(frame(&stream), Some(FrameKind { marker: 0xC1, precision: 12 }));
|
||||
let img = decode_default(&stream).unwrap();
|
||||
assert_eq!((img.width, img.height), (8, 8));
|
||||
let want = (3000.0f32 * 65535.0 / 4095.0).round() as u16;
|
||||
let px = u16_samples(&img);
|
||||
assert!(px.iter().all(|&v| v.abs_diff(want) <= 1), "{:?} vs {want}", &px[..4]);
|
||||
}
|
||||
|
||||
/// APPn segments before the frame (here an APP13 `8BIM` block right after SOI) are skipped by
|
||||
/// the probe and the decoder alike; reading them is the caller's job.
|
||||
#[test]
|
||||
fn twelve_bit_jpeg_with_photoshop_resources_decodes() {
|
||||
let resources = b"8BIM\x04\x04\0\0\0\0\0\0".to_vec();
|
||||
let mut app13 = b"Photoshop 3.0\0".to_vec();
|
||||
app13.extend_from_slice(&resources);
|
||||
let stream = twelve_bit_huffman_block();
|
||||
let mut with = stream[..2].to_vec();
|
||||
segment(&mut with, 0xED, &app13);
|
||||
with.extend_from_slice(&stream[2..]);
|
||||
assert_eq!(frame(&with), frame(&stream));
|
||||
assert_eq!(decode_default(&with).unwrap(), decode_default(&stream).unwrap());
|
||||
}
|
||||
|
||||
/// Where the entropy-coded data of the last scan starts.
|
||||
fn last_scan_data(s: &[u8]) -> usize {
|
||||
let sos = s.windows(2).rposition(|p| p == [0xFF, 0xDA]).unwrap();
|
||||
sos + 2 + usize::from(u16::from_be_bytes([s[sos + 2], s[sos + 3]]))
|
||||
}
|
||||
|
||||
/// A Huffman scan whose entropy data runs out is not a clean image, even with EOI present:
|
||||
/// the zero padding supplied at the marker must not be decoded as real coefficients.
|
||||
#[test]
|
||||
fn huffman_scan_out_of_data_is_not_clean() {
|
||||
let full = twelve_bit_huffman_block();
|
||||
let data = last_scan_data(&full);
|
||||
for keep in 0..2 {
|
||||
let mut s = full[..data + keep].to_vec();
|
||||
s.extend([0xFF, 0xD9]);
|
||||
assert!(decode_default(&s).is_err(), "keep {keep}");
|
||||
}
|
||||
// A multi-MCU scan cut in half, EOI kept, decodes as damaged.
|
||||
let (w, h) = (64usize, 64usize);
|
||||
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 4) as u8, (i * 7) as u8]).collect();
|
||||
let mut out = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut out, 90).encode(&rgb, w as u16, h as u16, jpeg_encoder::ColorType::Rgb).unwrap();
|
||||
let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap();
|
||||
out[sof + 1] = 0xC1;
|
||||
let data = last_scan_data(&out);
|
||||
let end = out.len() - 2;
|
||||
let mut cut = out[..data + (end - data) / 2].to_vec();
|
||||
if cut.last() == Some(&0xFF) {
|
||||
cut.pop();
|
||||
}
|
||||
cut.extend([0xFF, 0xD9]);
|
||||
assert!(!decode(&out, &Limits::default(), None).unwrap().damaged);
|
||||
assert!(decode(&cut, &Limits::default(), None).unwrap().damaged);
|
||||
}
|
||||
|
||||
/// Arithmetic data that runs to the end of the file, or loses a restart marker, is reported
|
||||
/// damaged, and the blocks before the cut are kept. Data cut short before EOI with no restart
|
||||
/// interval reads as dropped trailing zeros and can't be detected.
|
||||
#[test]
|
||||
fn arithmetic_scan_out_of_data_is_damaged() {
|
||||
let blocks = coefficients(64, 11, 500);
|
||||
for ri in [0, 2] {
|
||||
let full = arith_stream(8, 8, 8, &blocks, &[(0, 63, 0, 0)], ri);
|
||||
assert_eq!(decoded_blocks(&full), blocks, "ri={ri}");
|
||||
let data = last_scan_data(&full);
|
||||
let end = full.len() - 2;
|
||||
for cut in [data + (end - data) / 2, data + 1] {
|
||||
let mut s = full[..cut].to_vec();
|
||||
if s.last() == Some(&0xFF) {
|
||||
s.pop();
|
||||
}
|
||||
for eoi in [false, true] {
|
||||
if eoi && ri == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut s = s.clone();
|
||||
if eoi {
|
||||
s.extend([0xFF, 0xD9]);
|
||||
}
|
||||
// With one byte of data the first MCU fails, which is an error, not damage.
|
||||
match decode_coefficients(&s, &Limits::default()) {
|
||||
Ok((frame, _, damaged)) => {
|
||||
assert!(damaged, "ri={ri} eoi={eoi} cut at {}", cut - data);
|
||||
if cut > data + 1 {
|
||||
assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri} eoi={eoi}");
|
||||
}
|
||||
}
|
||||
Err(_) => assert_eq!(cut, data + 1, "ri={ri} eoi={eoi}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A baseline file from `jpeg-encoder`, relabeled SOF1, decodes like zune decodes the original.
|
||||
#[test]
|
||||
fn extended_huffman_matches_zune_on_baseline_data() {
|
||||
let (w, h) = (37u16, 29u16);
|
||||
let rgb: Vec<u8> = (0..usize::from(w) * usize::from(h))
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % usize::from(w), i / usize::from(w));
|
||||
[(x * 7) as u8, (y * 9) as u8, ((x + y) * 3) as u8]
|
||||
})
|
||||
.collect();
|
||||
for (sampling, restart) in
|
||||
[(jpeg_encoder::SamplingFactor::R_4_2_0, 0u16), (jpeg_encoder::SamplingFactor::R_4_4_4, 3), (jpeg_encoder::SamplingFactor::R_4_2_2, 1)]
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
let mut enc = jpeg_encoder::Encoder::new(&mut out, 90);
|
||||
enc.set_sampling_factor(sampling);
|
||||
if restart > 0 {
|
||||
enc.set_restart_interval(restart);
|
||||
}
|
||||
enc.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb).unwrap();
|
||||
let (zune, _) = zune_decode(&out);
|
||||
let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap();
|
||||
out[sof + 1] = 0xC1;
|
||||
let ours = decode(&out, &Limits::default(), None).unwrap();
|
||||
assert_eq!((ours.width, ours.height, ours.components), (u32::from(w), u32::from(h), 3));
|
||||
assert!(!ours.damaged);
|
||||
let Samples::U8(data) = &ours.samples else { panic!("16-bit samples") };
|
||||
let mad = data.iter().zip(&zune).map(|(&a, &b)| f32::from(a.abs_diff(b))).sum::<f32>() / data.len() as f32;
|
||||
assert!(mad < 1.5, "{sampling:?} restart {restart}: mean abs diff {mad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_streams_return_errors_not_panics() {
|
||||
let blocks = coefficients(6, 5, 500);
|
||||
let mut streams = vec![
|
||||
arith_stream(12, 3, 2, &blocks, &[(0, 63, 0, 0)], 2),
|
||||
arith_stream(8, 3, 2, &blocks, &[(0, 0, 0, 1), (1, 63, 0, 0), (0, 0, 1, 0)], 0),
|
||||
twelve_bit_huffman_block(),
|
||||
];
|
||||
// Header damage: zero sizes, bad component counts, sampling factors and tables, bad DAC.
|
||||
let base = arith_stream(8, 1, 1, &blocks[..1], &[(0, 63, 0, 0)], 0);
|
||||
let sof = base.windows(2).position(|p| p == [0xFF, 0xC9]).unwrap();
|
||||
for (offset, value) in [(4, 16u8), (6, 0), (8, 0), (9, 7), (10, 9), (11, 0x50), (12, 9)] {
|
||||
let mut s = base.clone();
|
||||
s[sof + offset] = value;
|
||||
assert!(decode_default(&s).is_err(), "SOF byte {offset} = {value}");
|
||||
streams.push(s);
|
||||
}
|
||||
let mut dac = base.clone();
|
||||
dac.splice(2..2, [0xFF, 0xCC, 0, 4, 0x00, 0x13]);
|
||||
assert!(decode_default(&dac).is_err());
|
||||
streams.push(dac);
|
||||
for s in &streams {
|
||||
for cut in 0..s.len() {
|
||||
let _ = decode_default(&s[..cut]);
|
||||
}
|
||||
for i in 2..s.len() {
|
||||
for v in [0x00, 0xFF, 0x5A] {
|
||||
let mut t = s.clone();
|
||||
t[i] = v;
|
||||
let _ = decode_default(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(decode_default(&streams[0][..40]).is_err());
|
||||
}
|
||||
|
||||
/// Scans past the block budget (`SCAN_PASSES` passes over a one-block frame) aren't decoded:
|
||||
/// the image keeps what the earlier scans decoded and is reported damaged.
|
||||
#[test]
|
||||
fn scans_past_the_block_budget_are_not_decoded() {
|
||||
let blocks = coefficients(1, 3, 500);
|
||||
for (count, damaged) in [(SCAN_PASSES, false), (SCAN_PASSES + 1, true)] {
|
||||
let stream = arith_stream(8, 1, 1, &blocks, &vec![(0, 0, 0, 0); count], 0);
|
||||
let (frame, _, d) = decode_coefficients(&stream, &Limits::default()).unwrap();
|
||||
assert_eq!(d, damaged, "{count} scans");
|
||||
assert_eq!(frame.comps[0].coefs[0][0], blocks[0][0], "{count} scans");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_are_checked_before_allocating() {
|
||||
let mut s = vec![0xFF, 0xD8];
|
||||
segment(&mut s, 0xC9, &[8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x44, 0, 2, 0x11, 0, 3, 0x11, 0]);
|
||||
let small = Limits { max_alloc: 1 << 20, ..Limits::default() };
|
||||
assert!(matches!(decode(&s, &small, None), Err(Error::LimitExceeded(_))));
|
||||
// 64×64 gray: 32 KiB of coefficients and plane plus 4 KiB of output, each under the limit
|
||||
// alone but not together.
|
||||
let stream = arith_stream(8, 8, 8, &coefficients(64, 1, 100), &[(0, 63, 0, 0)], 0);
|
||||
for (max_alloc, ok) in [(36 << 10, true), ((36 << 10) - 1, false), (32 << 10, false)] {
|
||||
let limits = Limits { max_alloc, ..Limits::default() };
|
||||
assert_eq!(decode(&stream, &limits, None).is_ok(), ok, "max_alloc {max_alloc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_reports_the_marker_and_precision() {
|
||||
let kind = |marker, precision| Some(FrameKind { marker, precision });
|
||||
assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC0, 8));
|
||||
assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC9, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC9, 8));
|
||||
assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC1, 12));
|
||||
assert_eq!(frame(&[0xFF, 0xD8]), None);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Decodes of the libjpeg-turbo test images (tests/data, see SOURCE.md there).
|
||||
|
||||
use craft_jpeg::{Decoded, Limits, Samples, decode};
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
let path = format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn decode_clean(name: &str) -> Decoded {
|
||||
let img = decode(&fixture(name), &Limits::default(), None).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(!img.damaged, "{name}");
|
||||
img
|
||||
}
|
||||
|
||||
fn decode_fixture(name: &str) -> Decoded {
|
||||
let img = decode_clean(name);
|
||||
assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}");
|
||||
img
|
||||
}
|
||||
|
||||
/// Mean and maximum absolute difference.
|
||||
fn diff(a: impl Iterator<Item = f64>, b: impl Iterator<Item = f64>) -> (f64, f64) {
|
||||
let (mut sum, mut max, mut n) = (0.0, 0.0f64, 0usize);
|
||||
for (a, b) in a.zip(b) {
|
||||
let d = (a - b).abs();
|
||||
sum += d;
|
||||
max = max.max(d);
|
||||
n += 1;
|
||||
}
|
||||
(sum / n.max(1) as f64, max)
|
||||
}
|
||||
|
||||
/// testimgari-huffman.jpg is testimgari.jpg transcoded by jpegtran: the same coefficients, only
|
||||
/// the entropy coding differs, so the samples must be identical.
|
||||
#[test]
|
||||
fn arithmetic_coded_image_matches_its_huffman_transcode() {
|
||||
let huffman = decode_fixture("testimgari-huffman.jpg");
|
||||
let arithmetic = decode_fixture("testimgari.jpg");
|
||||
assert!(matches!(huffman.samples, Samples::U8(_)));
|
||||
assert!(huffman.samples == arithmetic.samples, "testimgari.jpg decodes differently from testimgari-huffman.jpg");
|
||||
}
|
||||
|
||||
/// The arithmetic and progressive arithmetic transcodes jpegtran made of testorig.jpg and
|
||||
/// testorig12.jpg hold their coefficients, so they decode to exactly the same samples.
|
||||
#[test]
|
||||
fn arithmetic_transcodes_match_their_huffman_sources() {
|
||||
for (source, transcodes) in [
|
||||
("testorig.jpg", &["testorig-ari-progressive.jpg", "testorig-ari-progressive-restart.jpg"]),
|
||||
("testorig12.jpg", &["testorig12-ari.jpg", "testorig12-ari-progressive.jpg"]),
|
||||
] {
|
||||
let reference = decode_fixture(source);
|
||||
for name in transcodes {
|
||||
assert!(decode_fixture(name).samples == reference.samples, "{name} decodes differently from {source}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// libjpeg's arithmetic encoder drops the trailing zero bytes of a scan, so the white lower
|
||||
/// part of whitetail-ari.jpg decodes from zeros supplied at the EOI marker. It must decode
|
||||
/// whole and match its Huffman transcode.
|
||||
#[test]
|
||||
fn arithmetic_white_tail_decodes_from_implied_zeros() {
|
||||
let arithmetic = decode_clean("whitetail-ari.jpg");
|
||||
assert_eq!((arithmetic.width, arithmetic.height, arithmetic.components), (512, 512, 1));
|
||||
assert!(decode_clean("whitetail-huffman.jpg").samples == arithmetic.samples, "whitetail-ari.jpg decodes differently from whitetail-huffman.jpg");
|
||||
}
|
||||
|
||||
/// The 8-bit path against zune-jpeg. Both use centered linear chroma upsampling; the residual is
|
||||
/// float against integer IDCT and color conversion.
|
||||
#[test]
|
||||
fn baseline_image_matches_zune_jpeg() {
|
||||
let bytes = fixture("testorig.jpg");
|
||||
let mut zune = zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(&bytes[..]));
|
||||
let reference = zune.decode().unwrap_or_else(|e| panic!("zune-jpeg: {e:?}"));
|
||||
let img = decode_fixture("testorig.jpg");
|
||||
let Samples::U8(px) = &img.samples else { panic!("testorig.jpg: expected 8-bit samples") };
|
||||
assert_eq!(px.len(), reference.len());
|
||||
let (mean, max) = diff(px.iter().map(|&v| f64::from(v)), reference.iter().map(|&v| f64::from(v)));
|
||||
eprintln!("testorig.jpg vs zune-jpeg: mean {mean:.3}, max {max}");
|
||||
assert!(mean < MAX_MEAN_8 && max <= MAX_ABS_8, "mean {mean}, max {max}");
|
||||
}
|
||||
|
||||
// Observed: mean 0.52, max 3.
|
||||
const MAX_MEAN_8: f64 = 0.75;
|
||||
const MAX_ABS_8: f64 = 4.0;
|
||||
|
||||
/// Parse a binary PPM with 16-bit big-endian samples.
|
||||
fn ppm16(bytes: &[u8]) -> (u32, u32, u32, Vec<u16>) {
|
||||
let mut fields = Vec::new();
|
||||
let mut at = 0;
|
||||
while fields.len() < 4 {
|
||||
while bytes.get(at).is_some_and(u8::is_ascii_whitespace) {
|
||||
at += 1;
|
||||
}
|
||||
let start = at;
|
||||
while bytes.get(at).is_some_and(|b| !b.is_ascii_whitespace()) {
|
||||
at += 1;
|
||||
}
|
||||
fields.push(std::str::from_utf8(&bytes[start..at]).unwrap_or_default().to_owned());
|
||||
}
|
||||
assert_eq!(fields[0], "P6");
|
||||
let num = |s: &str| s.parse::<u32>().unwrap_or_else(|e| panic!("PPM header {s}: {e}"));
|
||||
let (w, h, maxval) = (num(&fields[1]), num(&fields[2]), num(&fields[3]));
|
||||
let data = &bytes[at + 1..];
|
||||
let px: Vec<u16> = data.as_chunks::<2>().0.iter().map(|&c| u16::from_be_bytes(c)).collect();
|
||||
assert_eq!(px.len(), (w * h * 3) as usize);
|
||||
(w, h, maxval, px)
|
||||
}
|
||||
|
||||
/// testorig12.jpg against libjpeg-turbo's decode (testorig12.ppm, 12-bit RGB; SOURCE.md says how
|
||||
/// it was made). libjpeg-turbo uses an integer IDCT and integer color conversion, craft-jpeg
|
||||
/// floats; the comparison is on the 12-bit scale.
|
||||
#[test]
|
||||
fn twelve_bit_image_matches_libjpeg_turbo() {
|
||||
let (w, h, maxval, reference) = ppm16(&fixture("testorig12.ppm"));
|
||||
assert_eq!((w, h, maxval), (227, 149, 4095));
|
||||
let img = decode_fixture("testorig12.jpg");
|
||||
let Samples::U16(px) = &img.samples else { panic!("testorig12.jpg: expected 16-bit samples") };
|
||||
assert_eq!(px.len(), reference.len());
|
||||
let (mean, max) = diff(px.iter().map(|&v| f64::from(v) * 4095.0 / 65535.0), reference.iter().map(|&v| f64::from(v)));
|
||||
eprintln!("testorig12.jpg vs libjpeg-turbo (12-bit units): mean {mean:.3}, max {max:.2}");
|
||||
assert!(mean < MAX_MEAN_12 && max <= MAX_ABS_12, "mean {mean}, max {max}");
|
||||
}
|
||||
|
||||
// Observed: mean 0.50, max 2.46 (of 4095).
|
||||
const MAX_MEAN_12: f64 = 0.75;
|
||||
const MAX_ABS_12: f64 = 4.0;
|
||||
@@ -0,0 +1,258 @@
|
||||
libjpeg-turbo note: This file has been modified by The libjpeg-turbo Project
|
||||
to include only information relevant to libjpeg-turbo, to wordsmith certain
|
||||
sections, and to remove impolitic language that existed in the libjpeg v8
|
||||
README. It is included only for reference. Please see README.md for
|
||||
information specific to libjpeg-turbo.
|
||||
|
||||
|
||||
The Independent JPEG Group's JPEG software
|
||||
==========================================
|
||||
|
||||
This distribution contains a release of the Independent JPEG Group's free JPEG
|
||||
software. You are welcome to redistribute this software and to use it for any
|
||||
purpose, subject to the conditions under LEGAL ISSUES, below.
|
||||
|
||||
This software is the work of Tom Lane, Guido Vollbeding, Philip Gladstone,
|
||||
Bill Allombert, Jim Boucher, Lee Crocker, Bob Friesenhahn, Ben Jackson,
|
||||
Julian Minguillon, Luis Ortiz, George Phillips, Davide Rossi, Ge' Weijers,
|
||||
and other members of the Independent JPEG Group.
|
||||
|
||||
IJG is not affiliated with the ISO/IEC JTC1/SC29/WG1 standards committee
|
||||
(also known as JPEG, together with ITU-T SG16).
|
||||
|
||||
|
||||
DOCUMENTATION ROADMAP
|
||||
=====================
|
||||
|
||||
This file contains the following sections:
|
||||
|
||||
OVERVIEW General description of JPEG and the IJG software.
|
||||
LEGAL ISSUES Copyright, lack of warranty, terms of distribution.
|
||||
REFERENCES Where to learn more about JPEG.
|
||||
ARCHIVE LOCATIONS Where to find newer versions of this software.
|
||||
FILE FORMAT WARS Software *not* to get.
|
||||
TO DO Plans for future IJG releases.
|
||||
|
||||
Other documentation files in the distribution are:
|
||||
|
||||
User documentation:
|
||||
usage.txt Usage instructions for cjpeg, djpeg, jpegtran,
|
||||
rdjpgcom, and wrjpgcom.
|
||||
*.1 Unix-style man pages for programs (same info as usage.txt).
|
||||
wizard.txt Advanced usage instructions for JPEG wizards only.
|
||||
change.log Version-to-version change highlights.
|
||||
Programmer and internal documentation:
|
||||
libjpeg.txt How to use the JPEG library in your own programs.
|
||||
example.c Sample code for calling the JPEG library.
|
||||
structure.txt Overview of the JPEG library's internal structure.
|
||||
coderules.txt Coding style rules --- please read if you contribute code.
|
||||
|
||||
Please read at least usage.txt. Some information can also be found in the JPEG
|
||||
FAQ (Frequently Asked Questions) article. See ARCHIVE LOCATIONS below to find
|
||||
out where to obtain the FAQ article.
|
||||
|
||||
If you want to understand how the JPEG code works, we suggest reading one or
|
||||
more of the REFERENCES, then looking at the documentation files (in roughly
|
||||
the order listed) before diving into the code.
|
||||
|
||||
|
||||
OVERVIEW
|
||||
========
|
||||
|
||||
This package contains C software to implement JPEG image encoding, decoding,
|
||||
and transcoding. JPEG (pronounced "jay-peg") is a standardized compression
|
||||
method for full-color and grayscale images. JPEG's strong suit is compressing
|
||||
photographic images or other types of images that have smooth color and
|
||||
brightness transitions between neighboring pixels. Images with sharp lines or
|
||||
other abrupt features may not compress well with JPEG, and a higher JPEG
|
||||
quality may have to be used to avoid visible compression artifacts with such
|
||||
images.
|
||||
|
||||
JPEG is normally lossy, meaning that the output pixels are not necessarily
|
||||
identical to the input pixels. However, on photographic content and other
|
||||
"smooth" images, very good compression ratios can be obtained with no visible
|
||||
compression artifacts, and extremely high compression ratios are possible if
|
||||
you are willing to sacrifice image quality (by reducing the "quality" setting
|
||||
in the compressor.)
|
||||
|
||||
This software implements JPEG baseline, extended-sequential, progressive, and
|
||||
lossless compression processes. Provision is made for supporting all variants
|
||||
of these processes, although some uncommon parameter settings aren't
|
||||
implemented yet. We have made no provision for supporting the hierarchical
|
||||
processes defined in the standard.
|
||||
|
||||
We provide a set of library routines for reading and writing JPEG image files,
|
||||
plus two sample applications "cjpeg" and "djpeg", which use the library to
|
||||
perform conversion between JPEG and some other popular image file formats.
|
||||
The library is intended to be reused in other applications.
|
||||
|
||||
In order to support file conversion and viewing software, we have included
|
||||
considerable functionality beyond the bare JPEG coding/decoding capability;
|
||||
for example, the color quantization modules are not strictly part of JPEG
|
||||
decoding, but they are essential for output to colormapped file formats or
|
||||
colormapped displays. These extra functions can be compiled out of the
|
||||
library if not required for a particular application.
|
||||
|
||||
We have also included "jpegtran", a utility for lossless transcoding between
|
||||
different JPEG processes, and "rdjpgcom" and "wrjpgcom", two simple
|
||||
applications for inserting and extracting textual comments in JFIF files.
|
||||
|
||||
The emphasis in designing this software has been on achieving portability and
|
||||
flexibility, while also making it fast enough to be useful. In particular,
|
||||
the software is not intended to be read as a tutorial on JPEG. (See the
|
||||
REFERENCES section for introductory material.) Rather, it is intended to
|
||||
be reliable, portable, industrial-strength code. We do not claim to have
|
||||
achieved that goal in every aspect of the software, but we strive for it.
|
||||
|
||||
We welcome the use of this software as a component of commercial products.
|
||||
No royalty is required, but we do ask for an acknowledgement in product
|
||||
documentation, as described under LEGAL ISSUES.
|
||||
|
||||
|
||||
LEGAL ISSUES
|
||||
============
|
||||
|
||||
In plain English:
|
||||
|
||||
1. We don't promise that this software works. (But if you find any bugs,
|
||||
please let us know!)
|
||||
2. You can use this software for whatever you want. You don't have to pay us.
|
||||
3. You may not pretend that you wrote this software. If you use it in a
|
||||
program, you must acknowledge somewhere in your documentation that
|
||||
you've used the IJG code.
|
||||
|
||||
In legalese:
|
||||
|
||||
The authors make NO WARRANTY or representation, either express or implied,
|
||||
with respect to this software, its quality, accuracy, merchantability, or
|
||||
fitness for a particular purpose. This software is provided "AS IS", and you,
|
||||
its user, assume the entire risk as to its quality and accuracy.
|
||||
|
||||
This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding.
|
||||
All Rights Reserved except as specified below.
|
||||
|
||||
Permission is hereby granted to use, copy, modify, and distribute this
|
||||
software (or portions thereof) for any purpose, without fee, subject to these
|
||||
conditions:
|
||||
(1) If any part of the source code for this software is distributed, then this
|
||||
README file must be included, with this copyright and no-warranty notice
|
||||
unaltered; and any additions, deletions, or changes to the original files
|
||||
must be clearly indicated in accompanying documentation.
|
||||
(2) If only executable code is distributed, then the accompanying
|
||||
documentation must state that "this software is based in part on the work of
|
||||
the Independent JPEG Group".
|
||||
(3) Permission for use of this software is granted only if the user accepts
|
||||
full responsibility for any undesirable consequences; the authors accept
|
||||
NO LIABILITY for damages of any kind.
|
||||
|
||||
These conditions apply to any software derived from or based on the IJG code,
|
||||
not just to the unmodified library. If you use our work, you ought to
|
||||
acknowledge us.
|
||||
|
||||
Permission is NOT granted for the use of any IJG author's name or company name
|
||||
in advertising or publicity relating to this software or products derived from
|
||||
it. This software may be referred to only as "the Independent JPEG Group's
|
||||
software".
|
||||
|
||||
We specifically permit and encourage the use of this software as the basis of
|
||||
commercial products, provided that all warranty or liability claims are
|
||||
assumed by the product vendor.
|
||||
|
||||
|
||||
REFERENCES
|
||||
==========
|
||||
|
||||
We recommend reading one or more of these references before trying to
|
||||
understand the innards of the JPEG software.
|
||||
|
||||
The best short technical introduction to the JPEG compression algorithm is
|
||||
Wallace, Gregory K. "The JPEG Still Picture Compression Standard",
|
||||
Communications of the ACM, April 1991 (vol. 34 no. 4), pp. 30-44.
|
||||
(Adjacent articles in that issue discuss MPEG motion picture compression,
|
||||
applications of JPEG, and related topics.) If you don't have the CACM issue
|
||||
handy, a PDF file containing a revised version of Wallace's article is
|
||||
available at http://www.ijg.org/files/Wallace.JPEG.pdf. The file (actually
|
||||
a preprint for an article that appeared in IEEE Trans. Consumer Electronics)
|
||||
omits the sample images that appeared in CACM, but it includes corrections
|
||||
and some added material. Note: the Wallace article is copyright ACM and IEEE,
|
||||
and it may not be used for commercial purposes.
|
||||
|
||||
A somewhat less technical, more leisurely introduction to JPEG can be found in
|
||||
"The Data Compression Book" by Mark Nelson and Jean-loup Gailly, published by
|
||||
M&T Books (New York), 2nd ed. 1996, ISBN 1-55851-434-1. This book provides
|
||||
good explanations and example C code for a multitude of compression methods
|
||||
including JPEG. It is an excellent source if you are comfortable reading C
|
||||
code but don't know much about data compression in general. The book's JPEG
|
||||
sample code is far from industrial-strength, but when you are ready to look
|
||||
at a full implementation, you've got one here...
|
||||
|
||||
The best currently available description of JPEG is the textbook "JPEG Still
|
||||
Image Data Compression Standard" by William B. Pennebaker and Joan L.
|
||||
Mitchell, published by Van Nostrand Reinhold, 1993, ISBN 0-442-01272-1.
|
||||
Price US$59.95, 638 pp. The book includes the complete text of the ISO JPEG
|
||||
standards (DIS 10918-1 and draft DIS 10918-2).
|
||||
|
||||
The original JPEG standard is divided into two parts, Part 1 being the actual
|
||||
specification, while Part 2 covers compliance testing methods. Part 1 is
|
||||
titled "Digital Compression and Coding of Continuous-tone Still Images,
|
||||
Part 1: Requirements and guidelines" and has document numbers ISO/IEC IS
|
||||
10918-1, ITU-T T.81. Part 2 is titled "Digital Compression and Coding of
|
||||
Continuous-tone Still Images, Part 2: Compliance testing" and has document
|
||||
numbers ISO/IEC IS 10918-2, ITU-T T.83.
|
||||
|
||||
The JPEG standard does not specify all details of an interchangeable file
|
||||
format. For the omitted details, we follow the "JFIF" conventions, revision
|
||||
1.02. JFIF version 1 has been adopted as ISO/IEC 10918-5 (05/2013) and
|
||||
Recommendation ITU-T T.871 (05/2011): Information technology - Digital
|
||||
compression and coding of continuous-tone still images: JPEG File Interchange
|
||||
Format (JFIF). It is available as a free download in PDF file format from
|
||||
https://www.iso.org/standard/54989.html and http://www.itu.int/rec/T-REC-T.871.
|
||||
A PDF file of the older JFIF 1.02 specification is available at
|
||||
http://www.w3.org/Graphics/JPEG/jfif3.pdf.
|
||||
|
||||
The TIFF 6.0 file format specification can be obtained from
|
||||
http://mirrors.ctan.org/graphics/tiff/TIFF6.ps.gz. The JPEG incorporation
|
||||
scheme found in the TIFF 6.0 spec of 3-June-92 has a number of serious
|
||||
problems. IJG does not recommend use of the TIFF 6.0 design (TIFF Compression
|
||||
tag 6). Instead, we recommend the JPEG design proposed by TIFF Technical Note
|
||||
#2 (Compression tag 7). Copies of this Note can be obtained from
|
||||
http://www.ijg.org/files/. It is expected that the next revision
|
||||
of the TIFF spec will replace the 6.0 JPEG design with the Note's design.
|
||||
Although IJG's own code does not support TIFF/JPEG, the free libtiff library
|
||||
uses our library to implement TIFF/JPEG per the Note.
|
||||
|
||||
|
||||
ARCHIVE LOCATIONS
|
||||
=================
|
||||
|
||||
The "official" archive site for this software is www.ijg.org.
|
||||
The most recent released version can always be found there in
|
||||
directory "files".
|
||||
|
||||
The JPEG FAQ (Frequently Asked Questions) article is a source of some
|
||||
general information about JPEG. It is available at
|
||||
http://www.faqs.org/faqs/jpeg-faq.
|
||||
|
||||
|
||||
FILE FORMAT COMPATIBILITY
|
||||
=========================
|
||||
|
||||
This software implements ITU T.81 | ISO/IEC 10918 with some extensions from
|
||||
ITU T.871 | ISO/IEC 10918-5 (JPEG File Interchange Format-- see REFERENCES).
|
||||
Informally, the term "JPEG image" or "JPEG file" most often refers to JFIF or
|
||||
a subset thereof, but there are other formats containing the name "JPEG" that
|
||||
are incompatible with the original JPEG standard or with JFIF (for instance,
|
||||
JPEG 2000 and JPEG XR). This software therefore does not support these
|
||||
formats. Indeed, one of the original reasons for developing this free software
|
||||
was to help force convergence on a common, interoperable format standard for
|
||||
JPEG files.
|
||||
|
||||
JFIF is a minimal or "low end" representation. TIFF/JPEG (TIFF revision 6.0 as
|
||||
modified by TIFF Technical Note #2) can be used for "high end" applications
|
||||
that need to record a lot of additional data about an image.
|
||||
|
||||
|
||||
TO DO
|
||||
=====
|
||||
|
||||
Please send bug reports, offers of help, etc. to jpeg-info@jpegclub.org.
|
||||
@@ -0,0 +1,11 @@
|
||||
4672c7f08864cd0a8c73a4fa4b66ca32b635d38464551c1ecf06564ae8c89b38 testimgari.jpg
|
||||
491679b8057739b3c8e5bacd1e918efb1691d271cbbd69820ff8d480dcb90963 testimgari-huffman.jpg
|
||||
acc6ec555d41d15b368320edaa3b20958ee6fa97cb6e4a18d1213d5ae8bec73b testorig.jpg
|
||||
34790770f76db8e60a7765b52ca4edf5f16bc21bcb8c6045ca2efef39a8a013e testorig12.jpg
|
||||
62d182ed042e243544d3d753c440ada1a6992464717311fe6efd546632f44dc4 testorig12.ppm
|
||||
0031b82a06300b5b5d19ef5553cc39d3fafafa4fbc474199e6cfbc7818312a87 whitetail-ari.jpg
|
||||
0f6e17a44be7a005d6610e66f91fa119388dc6b8438e95cb8816b6db58f8c365 whitetail-huffman.jpg
|
||||
7e7edd9d9825c2eca1e6ff6dfafeaddec39e2464fc5d6d1ce4a378a59ec3f716 testorig-ari-progressive.jpg
|
||||
cb0a2c60e6a89c68ef8cf48854dc09c77c26ffb27ac62e17d343d98f1424cc77 testorig-ari-progressive-restart.jpg
|
||||
c4942c4b704d20e27c4cd3e613c6ef975dd7cfe53a756e2c209fa3ad033d8b51 testorig12-ari.jpg
|
||||
18df0394115d9f8751939fb9f45a0d330df2ca849e9f8b07d0af76c09b90fcdb testorig12-ari-progressive.jpg
|
||||
@@ -0,0 +1,81 @@
|
||||
# Test images
|
||||
|
||||
The JPEG files come from libjpeg-turbo 3.0.0, `testimages/`
|
||||
(https://github.com/libjpeg-turbo/libjpeg-turbo/tree/3.0.0/testimages), under the IJG License
|
||||
(README.ijg in this directory). The other files are derived from them, or made with
|
||||
libjpeg-turbo's tools, as described below.
|
||||
`SHA256SUMS` lists every file.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `testorig.jpg` | 227×149 YCbCr, 2×2 luma sampling, baseline Huffman (SOF0) |
|
||||
| `testimgari.jpg` | the same image, arithmetic coded (SOF9), one interleaved scan |
|
||||
| `testimgari-huffman.jpg` | `testimgari.jpg` losslessly transcoded to baseline Huffman (SOF0) |
|
||||
| `testorig12.jpg` | 227×149 YCbCr, 12-bit extended sequential Huffman (SOF1) |
|
||||
| `testorig12.ppm` | libjpeg-turbo's decode of `testorig12.jpg`: 12-bit RGB, binary PPM, maxval 4095 |
|
||||
| `testorig-ari-progressive.jpg` | `testorig.jpg` transcoded to progressive arithmetic (SOF10) |
|
||||
| `testorig-ari-progressive-restart.jpg` | the same with a restart interval of 3 MCU rows |
|
||||
| `testorig12-ari.jpg` | `testorig12.jpg` transcoded to 12-bit arithmetic (SOF9) |
|
||||
| `testorig12-ari-progressive.jpg` | `testorig12.jpg` transcoded to 12-bit progressive arithmetic (SOF10) |
|
||||
| `whitetail-ari.jpg` | 512×512 gray, arithmetic coded (SOF9), noise in the top 16 rows, white below |
|
||||
| `whitetail-huffman.jpg` | `whitetail-ari.jpg` losslessly transcoded to baseline Huffman (SOF0) |
|
||||
|
||||
`testimgari.jpg` and `testorig.jpg` share their quantization tables but not their coefficients:
|
||||
libjpeg-turbo decodes them to different pixels. `testimgari-huffman.jpg` holds exactly the
|
||||
coefficients of `testimgari.jpg`, so the two must decode to identical samples. The same holds
|
||||
for each jpegtran transcode below and its source.
|
||||
|
||||
## How the derived files were made
|
||||
|
||||
`testimgari-huffman.jpg`, with jpegtran from libjpeg-turbo 3.1.0 (Alpine 3.22,
|
||||
`libjpeg-turbo-utils-3.1.0-r0`):
|
||||
|
||||
```sh
|
||||
jpegtran -copy none testimgari.jpg > testimgari-huffman.jpg
|
||||
```
|
||||
|
||||
The arithmetic transcodes of `testorig.jpg` and `testorig12.jpg`, with jpegtran from
|
||||
libjpeg-turbo 3.1.3 (Alpine 3.24, `libjpeg-turbo-utils`):
|
||||
|
||||
```sh
|
||||
jpegtran -copy none -arithmetic -progressive testorig.jpg > testorig-ari-progressive.jpg
|
||||
jpegtran -copy none -arithmetic -progressive -restart 3 testorig.jpg > testorig-ari-progressive-restart.jpg
|
||||
jpegtran -copy none -arithmetic testorig12.jpg > testorig12-ari.jpg
|
||||
jpegtran -copy none -arithmetic -progressive testorig12.jpg > testorig12-ari-progressive.jpg
|
||||
```
|
||||
|
||||
`testorig12.ppm`, with libjpeg-turbo 3.2.0 through imagecodecs 2026.10.10 (default settings:
|
||||
integer IDCT `JDCT_ISLOW`, fancy upsampling), run as
|
||||
`uv run --with imagecodecs --with numpy python make.py testorig12.jpg testorig12.ppm`:
|
||||
|
||||
```python
|
||||
import sys, imagecodecs, numpy as np
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
a = imagecodecs.jpeg8_decode(open(src, "rb").read())
|
||||
h, w, c = a.shape
|
||||
assert c == 3 and a.dtype == np.uint16 and a.max() <= 4095
|
||||
with open(dst, "wb") as f:
|
||||
f.write(b"P6\n%d %d\n4095\n" % (w, h))
|
||||
f.write(a.astype(">u2").tobytes())
|
||||
```
|
||||
|
||||
`djpeg -dct int testorig12.jpg` from libjpeg-turbo 3.1.0 writes a byte-identical file.
|
||||
|
||||
`whitetail-ari.jpg` and `whitetail-huffman.jpg`, with cjpeg and jpegtran from libjpeg-turbo 3.1.3
|
||||
(Alpine 3.24, `libjpeg-turbo-utils`). libjpeg's arithmetic encoder drops the trailing zero bytes
|
||||
of a scan, so the white rows decode almost entirely from zeros supplied at the EOI marker.
|
||||
`python3 whitetail.py whitetail.pgm` writes the source image:
|
||||
|
||||
```python
|
||||
import sys
|
||||
w, h, top = 512, 512, 16
|
||||
px = bytes(((x * 3 + y * 5) ^ (x * y)) & 0xFF if y < top else 255 for y in range(h) for x in range(w))
|
||||
open(sys.argv[1], "wb").write(b"P5\n%d %d\n255\n" % (w, h) + px)
|
||||
```
|
||||
|
||||
```sh
|
||||
cjpeg -arithmetic -quality 75 whitetail.pgm > whitetail-ari.jpg
|
||||
jpegtran -copy none whitetail-ari.jpg > whitetail-huffman.jpg
|
||||
```
|
||||
|
||||
"This software is based in part on the work of the Independent JPEG Group."
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,131 @@
|
||||
//! Deterministic mutation run over the test images and fuzz seeds: decoding corrupted input never panics and
|
||||
//! stays within the caller's limits.
|
||||
|
||||
use craft_jpeg::{Limits, Samples, decode, frame};
|
||||
|
||||
/// Inputs and mutation counts. The 227×149 test images take tens of milliseconds each to decode
|
||||
/// in a debug build; the small fuzz seeds (SOF9 with restarts, 12-bit SOF9, SOF10, 12-bit SOF1)
|
||||
/// take microseconds and get most of the cases.
|
||||
const INPUTS: [(&str, usize); 8] = [
|
||||
("tests/data/testorig.jpg", 12),
|
||||
("tests/data/testimgari.jpg", 12),
|
||||
("tests/data/testimgari-huffman.jpg", 12),
|
||||
("tests/data/testorig12.jpg", 12),
|
||||
("fuzz/corpus/decode/sof9-restart.jpg", 75),
|
||||
("fuzz/corpus/decode/sof9-12bit.jpg", 75),
|
||||
("fuzz/corpus/decode/sof10-progressive.jpg", 75),
|
||||
("fuzz/corpus/decode/sof1-12bit.jpg", 75),
|
||||
];
|
||||
|
||||
/// Room for the 227×149 fixtures, too little for a mutated frame header that grows them much.
|
||||
fn test_limits() -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
limits.max_pixels = 1 << 16;
|
||||
limits.max_alloc = 1 << 20;
|
||||
limits
|
||||
}
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
// xorshift64*
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
|
||||
fn byte(&mut self) -> u8 {
|
||||
self.next() as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset of the first SOFn segment's body, if any.
|
||||
fn sof_body(b: &[u8]) -> Option<usize> {
|
||||
(2..b.len().saturating_sub(1))
|
||||
.find(|&i| b[i] == 0xFF && matches!(b[i + 1], 0xC0..=0xCF) && !matches!(b[i + 1], 0xC4 | 0xC8 | 0xCC))
|
||||
.map(|i| i + 4)
|
||||
}
|
||||
|
||||
fn mutate(rng: &mut Rng, original: &[u8]) -> Vec<u8> {
|
||||
let mut b = original.to_vec();
|
||||
match rng.below(6) {
|
||||
0 => b.truncate(rng.below(original.len())),
|
||||
1 => {
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let at = rng.below(b.len());
|
||||
b[at] ^= 1 << rng.below(8);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
for _ in 0..1 + rng.below(4) {
|
||||
let at = rng.below(b.len());
|
||||
b[at] = rng.byte();
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// Frame header fields: precision, height, width, component count and the
|
||||
// per-component sampling and table selectors.
|
||||
if let Some(body) = sof_body(&b) {
|
||||
for _ in 0..1 + rng.below(3) {
|
||||
let at = body + rng.below(15);
|
||||
if let Some(v) = b.get_mut(at) {
|
||||
*v = rng.byte();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
let at = rng.below(b.len());
|
||||
let len = rng.below(64).min(b.len() - at);
|
||||
b.drain(at..at + len);
|
||||
}
|
||||
_ => {
|
||||
// Repeat a span, e.g. a marker segment or part of the entropy-coded data.
|
||||
let at = rng.below(b.len());
|
||||
let len = rng.below(256).min(b.len() - at);
|
||||
let span = b[at..at + len].to_vec();
|
||||
let to = rng.below(b.len());
|
||||
b.splice(to..to, span);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_streams_never_panic_and_stay_within_limits() {
|
||||
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let limits = test_limits();
|
||||
let (mut ok, mut errors) = (0, 0);
|
||||
for (name, cases) in INPUTS {
|
||||
let path = format!("{}/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
let original = std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
assert!(decode(&original, &limits, None).is_ok(), "{name} must decode within the test limits");
|
||||
for case in 0..cases {
|
||||
let bytes = mutate(&mut rng, &original);
|
||||
let _ = frame(&bytes);
|
||||
match decode(&bytes, &limits, None) {
|
||||
Ok(img) => {
|
||||
let pixels = u64::from(img.width) * u64::from(img.height);
|
||||
let (len, width) = match &img.samples {
|
||||
Samples::U8(v) => (v.len() as u64, 1),
|
||||
Samples::U16(v) => (v.len() as u64, 2),
|
||||
};
|
||||
assert!(pixels <= limits.max_pixels, "{name} case {case}: {pixels} pixels");
|
||||
assert!(len * width <= limits.max_alloc, "{name} case {case}: {} bytes", len * width);
|
||||
assert_eq!(len, pixels * u64::from(img.components), "{name} case {case}");
|
||||
ok += 1;
|
||||
}
|
||||
Err(_) => errors += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{ok} decoded, {errors} rejected");
|
||||
// A mutation run that only produces errors would no longer reach the entropy decoders.
|
||||
assert!(ok > 50 && errors > 50, "{ok} decoded, {errors} rejected");
|
||||
}
|
||||
@@ -146,7 +146,8 @@ let writer = Writer::new(SerializeSettings { configuration, ..settings }, metada
|
||||
|
||||
For another profile, fill `PrintColor` with its condition identifier (`Custom` when it is
|
||||
not registered) and `info`. The settings claim no PDF/X version and are not checked against
|
||||
PDF/X; apps that claim conformance set `pdfx_version` and keep their own checks. Writing these
|
||||
PDF/X; apps that claim conformance set `pdfx_version` (and `pdfx_conformance` for PDF/X-1a
|
||||
and X-3) and keep their own checks. Writing these
|
||||
settings needs the patched Krilla (`CRAFT_PRINT_COLOR`, see
|
||||
[`vendor/krilla/README.craft.md`](../../vendor/krilla/README.craft.md)).
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ pub struct Metadata {
|
||||
pub description: Option<String>,
|
||||
pub keywords: Vec<String>,
|
||||
pub created: Option<backend::metadata::DateTime>,
|
||||
/// Written as the modification date; `created` is written there when this is `None`.
|
||||
pub modified: Option<backend::metadata::DateTime>,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
@@ -99,6 +101,9 @@ impl Metadata {
|
||||
if let Some(created) = self.created {
|
||||
metadata = metadata.creation_date(created);
|
||||
}
|
||||
if let Some(modified) = self.modified {
|
||||
metadata = metadata.modification_date(modified);
|
||||
}
|
||||
metadata
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,6 +630,33 @@ fn device_n_mixing_spots_and_process_writes_nchannel_attributes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// NChannel's `/Subtype` and `/Process` are PDF 1.6: a 1.4 file (PDF/X-1a) gets only
|
||||
/// `/Colorants`, a 1.7 file the NChannel attributes.
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn device_n_attributes_follow_the_pdf_version() {
|
||||
for version in [PdfVersion::Pdf14, PdfVersion::Pdf17] {
|
||||
let settings = SerializeSettings {
|
||||
configuration: craft_pdf::write::configuration(version, None).unwrap(),
|
||||
compress_content_streams: false,
|
||||
..cmyk_settings()
|
||||
};
|
||||
let color = device_n(vec![Colorant::Process(Process::Cyan), spot("Gold", gold())], vec![255, 128]);
|
||||
let bytes = device_n_sample(settings, vec![color]).unwrap();
|
||||
let nchannel = version >= PdfVersion::Pdf16;
|
||||
assert_eq!(bytes.windows(8).any(|w| w == b"NChannel"), nchannel, "{version:?}");
|
||||
let mut found = 0;
|
||||
device_n_spaces(&bytes, |_, _, _, attrs| {
|
||||
found += 1;
|
||||
let attrs = attrs.expect("attributes");
|
||||
assert!(attrs.get::<Dict<'_>>(b"Colorants".as_slice()).unwrap().get::<Array<'_>>(b"Gold".as_slice()).is_some());
|
||||
assert_eq!(attrs.contains_key(b"Subtype".as_slice()), nchannel, "{version:?}");
|
||||
assert_eq!(attrs.contains_key(b"Process".as_slice()), nchannel, "{version:?}");
|
||||
});
|
||||
assert_eq!(found, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A spot name keeps one alternate across Separation and DeviceN; PDF/A-2 rejects a second.
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
@@ -1084,16 +1111,23 @@ fn print_settings_reject_a_profile_that_is_not_cmyk() {
|
||||
}
|
||||
|
||||
/// `/Trapped` (`Unknown` included) and `GTS_PDFXVersion` go to the information dictionary and
|
||||
/// to XMP; without settings neither appears.
|
||||
/// to XMP, `GTS_PDFXConformance` to the dictionary only; without settings none appears.
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn trapped_and_pdfx_version_are_written_to_info_and_xmp() {
|
||||
let settings = SerializeSettings { trapped: Some(Trapped::True), pdfx_version: Some("PDF/X-4".to_owned()), ..Default::default() };
|
||||
let settings = SerializeSettings {
|
||||
trapped: Some(Trapped::True),
|
||||
pdfx_version: Some("PDF/X-1:2003".to_owned()),
|
||||
pdfx_conformance: Some("PDF/X-1a:2003".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let marked = String::from_utf8_lossy(&sample(settings, None).unwrap()).into_owned();
|
||||
assert!(marked.contains("/Trapped/True"), "Info /Trapped");
|
||||
assert!(marked.contains("/GTS_PDFXVersion(PDF/X-4)"), "Info /GTS_PDFXVersion");
|
||||
assert!(marked.contains("/GTS_PDFXVersion(PDF/X-1:2003)"), "Info /GTS_PDFXVersion");
|
||||
assert!(marked.contains("/GTS_PDFXConformance(PDF/X-1a:2003)"), "Info /GTS_PDFXConformance");
|
||||
assert!(!marked.contains("pdfxid:GTS_PDFXConformance"), "no XMP conformance property");
|
||||
assert!(marked.contains("<pdf:Trapped>True</pdf:Trapped>"), "XMP pdf:Trapped");
|
||||
assert!(marked.contains("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>"), "XMP GTS_PDFXVersion");
|
||||
assert!(marked.contains("<pdfxid:GTS_PDFXVersion>PDF/X-1:2003</pdfxid:GTS_PDFXVersion>"), "XMP GTS_PDFXVersion");
|
||||
|
||||
let unknown = SerializeSettings { trapped: Some(Trapped::Unknown), ..Default::default() };
|
||||
let unknown = String::from_utf8_lossy(&sample(unknown, None).unwrap()).into_owned();
|
||||
|
||||
@@ -16,6 +16,7 @@ fn document(compress: bool) -> Vec<u8> {
|
||||
description: Some("Original synthetic artwork".into()),
|
||||
keywords: vec!["craft".into(), "pdf".into()],
|
||||
created: Some(backend::metadata::DateTime::new(2026).month(10).day(9)),
|
||||
modified: Some(backend::metadata::DateTime::new(2026).month(10).day(12)),
|
||||
};
|
||||
let settings = backend::SerializeSettings {
|
||||
compress_content_streams: compress,
|
||||
@@ -49,9 +50,25 @@ fn writes_pages_metadata_and_selected_version_without_a_renderer() {
|
||||
let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap();
|
||||
let sizes: Vec<_> = pdf.pages().iter().map(|page| page.render_dimensions()).collect();
|
||||
assert_eq!(sizes, vec![(80.0, 40.0), (30.0, 60.0)]);
|
||||
let day = |d: &Option<hayro_syntax::object::DateTime>| d.as_ref().map(|d| (d.year, d.month, d.day));
|
||||
assert_eq!(day(&pdf.metadata().creation_date), Some((2026, 10, 9)));
|
||||
assert_eq!(day(&pdf.metadata().modification_date), Some((2026, 10, 12)));
|
||||
assert!(text.contains("<xmp:CreateDate>2026-10-09") && text.contains("<xmp:ModifyDate>2026-10-12"), "XMP dates");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a modification date, the creation date is written as both.
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn the_creation_date_is_the_modification_date_when_none_is_given() {
|
||||
let created = Some(backend::metadata::DateTime::new(2026).month(10).day(9));
|
||||
let mut writer = Writer::new(Default::default(), Metadata { created, ..Default::default() });
|
||||
writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish();
|
||||
let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(writer.finish().unwrap())).unwrap();
|
||||
let day = |d: &Option<hayro_syntax::object::DateTime>| d.as_ref().map(|d| (d.year, d.month, d.day));
|
||||
assert_eq!(day(&pdf.metadata().modification_date), Some((2026, 10, 9)));
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn fixed_input_does_not_read_a_clock_or_change_pdf_bytes() {
|
||||
|
||||
@@ -17,6 +17,18 @@ and the PDF structure a correct print export has.
|
||||
`tests/scenes.rs` parses every scene, writes every scene without `pending`, and checks the
|
||||
structure. It runs in CI in seconds, with the unit tests of the TIFF reader and the judge.
|
||||
|
||||
## Scenes
|
||||
|
||||
The file names say what each scene tests; `title` and `source` say more.
|
||||
|
||||
| IDs | Cases |
|
||||
|---|---|
|
||||
| `S01`–`S17` | Print-color research cases: overprint, spots, blending spaces, knockout and isolated groups, masks, placed PDFs, gradients |
|
||||
| `D01`–`D10` | DesignCraft print cases (its DC list) and a GWG idea (D04) |
|
||||
| `M1`, `M2`, `N1` | Luminosity mask backdrop and transfer; DeviceN overprint |
|
||||
| `F01` | Flattener: a semi-transparent image over a flat fill; where the image's alpha is 0 the fill prints unchanged (vectorcraft#2160) |
|
||||
| `F02` | Flattener: art hidden under opaque fills, with transparency over them, leaves no trace (vectorcraft#2161) |
|
||||
|
||||
## Scene format
|
||||
|
||||
Coordinates are points from the page's top-left corner; rectangles are `[x, y, width, height]`.
|
||||
@@ -57,7 +69,9 @@ Paints:
|
||||
Images are synthetic: `{"px": [w, h], "ramp": [paint, paint]}` (left to right),
|
||||
`"split": [paint, paint]` (left and right halves) or `"feather": pt` (a gray soft-mask image,
|
||||
white inside and fading to black at the edges). Shadows and feathers are luminosity masks over
|
||||
such an image.
|
||||
such an image. A ramp or split image can carry `"alpha": {"rect": [x, y, w, h], "opacity": 0.6}`,
|
||||
written as its `/SMask` like a PNG with transparency: pixels whose centers lie in `rect` (page
|
||||
pt) have that opacity, the rest 0.
|
||||
|
||||
Tolerances: ±2 for device, spot and overprint values; ±6 for blends and ICC; ±10 for shadows and
|
||||
gradients.
|
||||
@@ -131,6 +145,13 @@ is reported as such: its plates don't show the missing feature under Ghostscript
|
||||
`structure` block is the check that does. The test fails when a scene without `pending` fails or
|
||||
the harness errors.
|
||||
|
||||
`scripts/plate-check.sh --flatten` runs the same check on each scene's page flattened by
|
||||
craft-prepress (`flatten_page` at 300 dpi; the test is `flatten_plates` in craft-prepress, since
|
||||
craft-prepress dev-depends on this crate). The flattened page must also have no transparency
|
||||
left (`structure::transparency`: transparency groups, soft masks in graphics states, blend modes
|
||||
other than Normal, `ca`/`CA` below 1). Its report goes to `plate-test/flattened/` and shows both
|
||||
pages side by side, with the flattener's time per scene.
|
||||
|
||||
The results go to `plate-test/report.md` and `results.json` in Cargo's target directory
|
||||
(`CARGO_TARGET_DIR` is respected). The script sets `CRAFT_PDF_TEST_FONT_DIR` to
|
||||
`~/.cache/craft-pdf-test-fonts` when the font is there; without the font, text points are skipped.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "F01",
|
||||
"title": "Semi-transparent image over a flat process fill: where the image's alpha is 0, the fill prints unchanged",
|
||||
"source": "vectorcraft#2160 (X-1a flattening washed out a fill behind a transparent PNG)",
|
||||
"page": [360, 216],
|
||||
"settings": {"group_cs": "cmyk"},
|
||||
"objects": [
|
||||
{"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 6, 41, 3]}},
|
||||
{
|
||||
"type": "image",
|
||||
"rect": [60, 48, 290, 120],
|
||||
"image": {
|
||||
"px": [290, 120],
|
||||
"split": [{"cmyk": [0, 100, 0, 0]}, {"cmyk": [0, 0, 0, 100]}],
|
||||
"alpha": {"rect": [100, 78, 160, 60], "opacity": 0.6}
|
||||
},
|
||||
"note": "image alpha 0.6 inside x 100–260, y 78–138; 0 in the rest of the image, which reaches past the fill on the right"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"plates": ["Cyan", "Magenta", "Yellow", "Black"],
|
||||
"points": [
|
||||
{"at": [40, 108], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "fill clear of the image"},
|
||||
{"at": [80, 108], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"},
|
||||
{"at": [180, 60], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"},
|
||||
{"at": [300, 150], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"},
|
||||
{"at": [345, 108], "inks": {}, "tol": 0.5, "note": "image alpha 0 over paper"},
|
||||
{
|
||||
"at": [150, 108],
|
||||
"inks": {"Cyan": 40, "Magenta": 62.4, "Yellow": 16.4, "Black": 1.2},
|
||||
"tol": 6,
|
||||
"note": "M100 at 60% over the fill, CMYK blending"
|
||||
},
|
||||
{
|
||||
"at": [230, 108],
|
||||
"inks": {"Cyan": 40, "Magenta": 2.4, "Yellow": 16.4, "Black": 61.2},
|
||||
"tol": 6,
|
||||
"note": "K100 at 60% over the fill, CMYK blending"
|
||||
}
|
||||
]
|
||||
},
|
||||
"structure": {
|
||||
"page_group": "DeviceCMYK",
|
||||
"color_spaces": ["DeviceCMYK"],
|
||||
"device_rgb": false,
|
||||
"output_intents": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"id": "F02",
|
||||
"title": "Art hidden under opaque fills, with transparency over them: nothing of it shows",
|
||||
"source": "vectorcraft#2161 (old art under opaque layers showed through an X-1a export)",
|
||||
"page": [360, 216],
|
||||
"settings": {"group_cs": "cmyk"},
|
||||
"objects": [
|
||||
{
|
||||
"type": "group",
|
||||
"note": "an old copy of the page, still visible and fully covered",
|
||||
"objects": [
|
||||
{"type": "rect", "rect": [190, 30, 140, 156], "fill": {"cmyk": [0, 30, 0, 0]}},
|
||||
{"type": "text", "at": [200, 80], "size": 40, "text": "HHH", "fill": {"cmyk": [0, 0, 0, 100]}},
|
||||
{"type": "rect", "rect": [200, 100, 120, 6], "fill": {"cmyk": [0, 0, 0, 100]}},
|
||||
{"type": "rect", "rect": [200, 115, 120, 0.5], "fill": {"cmyk": [0, 0, 0, 100]}},
|
||||
{"type": "rect", "rect": [200, 130, 12, 12], "stroke": {"paint": {"cmyk": [0, 0, 0, 100]}, "width": 1}},
|
||||
{
|
||||
"type": "image",
|
||||
"rect": [260, 140, 60, 40],
|
||||
"image": {"px": [6, 4], "split": [{"cmyk": [100, 0, 0, 0]}, {"cmyk": [0, 0, 100, 0]}]}
|
||||
},
|
||||
{"type": "rect", "rect": [200, 150, 50, 30], "fill": {"cmyk": [0, 100, 0, 0]}, "opacity": 0.5},
|
||||
{"type": "text", "at": [30, 80], "size": 40, "text": "HHH", "fill": {"cmyk": [0, 0, 0, 100]}},
|
||||
{"type": "rect", "rect": [30, 100, 120, 6], "fill": {"cmyk": [0, 0, 0, 100]}}
|
||||
]
|
||||
},
|
||||
{"type": "rect", "rect": [20, 20, 150, 176], "fill": {"cmyk": [0, 0, 0, 0]}, "note": "white card"},
|
||||
{"type": "rect", "rect": [180, 20, 160, 176], "fill": {"cmyk": [100, 6, 41, 3]}, "note": "teal box"},
|
||||
{"type": "rect", "rect": [100, 160, 200, 20], "fill": {"cmyk": [0, 0, 0, 100]}, "opacity": 0.3, "note": "a 30% black band over both"}
|
||||
],
|
||||
"expect": {
|
||||
"plates": ["Cyan", "Magenta", "Yellow", "Black"],
|
||||
"points": [
|
||||
{"at": [230, 103], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden K bar"},
|
||||
{"at": [215, 70], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over hidden text"},
|
||||
{"at": [290, 150], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden image"},
|
||||
{"at": [206, 136], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden checkbox"},
|
||||
{"at": [335, 25], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over nothing"},
|
||||
{"at": [60, 103], "inks": {}, "tol": 0.5, "note": "white card over a hidden K bar"},
|
||||
{"at": [45, 70], "inks": {}, "tol": 0.5, "note": "white card over hidden text"},
|
||||
{"at": [175, 108], "inks": {}, "tol": 0.5, "note": "paper between the card and the box"},
|
||||
{
|
||||
"at": [220, 170],
|
||||
"inks": {"Cyan": 70, "Magenta": 4.2, "Yellow": 28.7, "Black": 32.1},
|
||||
"tol": 2,
|
||||
"note": "band over teal over a hidden 50% magenta fill"
|
||||
},
|
||||
{
|
||||
"at": [290, 170],
|
||||
"inks": {"Cyan": 70, "Magenta": 4.2, "Yellow": 28.7, "Black": 32.1},
|
||||
"tol": 2,
|
||||
"note": "band over teal over a hidden image"
|
||||
},
|
||||
{"at": [130, 170], "inks": {"Black": 30}, "tol": 2, "note": "band over the white card"}
|
||||
]
|
||||
},
|
||||
"structure": {
|
||||
"page_group": "DeviceCMYK",
|
||||
"color_spaces": ["DeviceCMYK"],
|
||||
"device_rgb": false,
|
||||
"output_intents": []
|
||||
}
|
||||
}
|
||||
@@ -411,7 +411,7 @@ impl Resolver<'_, '_> {
|
||||
DeviceNSpace::new(colorants).ok_or_else(|| self.bad("bad DeviceN ink list"))
|
||||
}
|
||||
|
||||
fn image(&self, source: &ImageSource, [_, _, w, h]: [f32; 4]) -> Result<Image, Error> {
|
||||
fn image(&self, source: &ImageSource, [x0, y0, w, h]: [f32; 4]) -> Result<Image, Error> {
|
||||
let [px, py] = source.px;
|
||||
let (width, height) = (px as usize, py as usize);
|
||||
let image = if let Some(radius) = source.feather {
|
||||
@@ -423,7 +423,7 @@ impl Resolver<'_, '_> {
|
||||
data.push(channel(255.0 * (edge / radius.max(f32::EPSILON)).min(1.0)));
|
||||
}
|
||||
}
|
||||
Synthetic { data, size: (px, py), space: ImageColorspace::Luma, device_n: None }
|
||||
Synthetic { data, alpha: None, size: (px, py), space: ImageColorspace::Luma, device_n: None }
|
||||
} else {
|
||||
let (pair, ramp) = match (&source.ramp, &source.split) {
|
||||
(Some(pair), _) => (pair, true),
|
||||
@@ -456,7 +456,20 @@ impl Resolver<'_, '_> {
|
||||
data.extend(a.iter().zip(&b).map(|(a, b)| channel(a + (b - a) * t)));
|
||||
}
|
||||
}
|
||||
Synthetic { data, size: (px, py), space, device_n }
|
||||
let alpha = source.alpha.as_ref().map(|a| {
|
||||
let [ax, ay, aw, ah] = a.rect;
|
||||
let value = channel(a.opacity * 255.0);
|
||||
let mut alpha = Vec::with_capacity(width * height);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let (fx, fy) = (x0 + (x as f32 + 0.5) * w / px as f32, y0 + (y as f32 + 0.5) * h / py as f32);
|
||||
let inside = (ax..ax + aw).contains(&fx) && (ay..ay + ah).contains(&fy);
|
||||
alpha.push(if inside { value } else { 0 });
|
||||
}
|
||||
}
|
||||
alpha
|
||||
});
|
||||
Synthetic { data, alpha, size: (px, py), space, device_n }
|
||||
};
|
||||
Image::from_custom(image, false).map_err(|e| self.bad(&e))
|
||||
}
|
||||
@@ -574,6 +587,7 @@ fn ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option<Path> {
|
||||
#[derive(Clone, Hash)]
|
||||
struct Synthetic {
|
||||
data: Vec<u8>,
|
||||
alpha: Option<Vec<u8>>,
|
||||
size: (u32, u32),
|
||||
space: ImageColorspace,
|
||||
device_n: Option<DeviceNSpace>,
|
||||
@@ -584,7 +598,7 @@ impl CustomImage for Synthetic {
|
||||
&self.data
|
||||
}
|
||||
fn alpha_channel(&self) -> Option<&[u8]> {
|
||||
None
|
||||
self.alpha.as_deref()
|
||||
}
|
||||
fn bits_per_component(&self) -> BitsPerComponent {
|
||||
BitsPerComponent::Eight
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
//!
|
||||
//! Pending scenes are written as far as the writer can ([`reference::write_partial`]) and are
|
||||
//! expected to fail; their results show what the writer prints today.
|
||||
//!
|
||||
//! [`run_flattened`] also flattens each scene's PDF with a caller's flattener and checks the
|
||||
//! flattened page the same way, plus [`structure::transparency`]. The flattener comes from the
|
||||
//! caller so that this crate doesn't depend on it.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::judge::{self, Judgement, Verdict};
|
||||
use crate::reference::{self, Context};
|
||||
use crate::scene::{Level, PROCESS, Scene};
|
||||
use crate::{Error, gs};
|
||||
use crate::{Error, gs, structure};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -57,8 +62,26 @@ pub struct SceneResult {
|
||||
/// Why the scene was not written or judged.
|
||||
pub error: Option<String>,
|
||||
pub judgement: Option<Judgement>,
|
||||
/// The flattened page's result, from [`run_flattened`].
|
||||
pub flattened: Option<FlatResult>,
|
||||
}
|
||||
|
||||
/// One scene's flattened page.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FlatResult {
|
||||
/// As for the source page; a pass also needs `transparency` empty.
|
||||
pub status: Status,
|
||||
/// What [`structure::transparency`] found in the flattened page.
|
||||
pub transparency: Vec<String>,
|
||||
/// Time spent in the flattener.
|
||||
pub millis: f64,
|
||||
pub error: Option<String>,
|
||||
pub judgement: Option<Judgement>,
|
||||
}
|
||||
|
||||
/// Flattens a scene's PDF: the flattened PDF, or why it failed.
|
||||
pub type Flatten<'a> = &'a dyn Fn(&[u8]) -> Result<Vec<u8>, String>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Report {
|
||||
pub ghostscript: String,
|
||||
@@ -66,13 +89,40 @@ pub struct Report {
|
||||
pub scenes: Vec<SceneResult>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Scenes whose source or flattened result is not what their `pending` field says.
|
||||
pub fn unexpected(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for r in &self.scenes {
|
||||
if !r.status.as_expected() {
|
||||
out.push(format!("{} {:?}{}", r.id, r.status, r.error.as_deref().map(|e| format!(": {e}")).unwrap_or_default()));
|
||||
}
|
||||
if let Some(f) = r.flattened.as_ref().filter(|f| !f.status.as_expected()) {
|
||||
let why = f.error.clone().or_else(|| (!f.transparency.is_empty()).then(|| f.transparency.join(", ")));
|
||||
out.push(format!("{} flattened {:?}{}", r.id, f.status, why.map(|e| format!(": {e}")).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Write, separate and judge every scene in `ctx.scenes`.
|
||||
pub fn run(ctx: &Context<'_>, ghostscript: String) -> Report {
|
||||
Report { ghostscript, resolution: gs::RESOLUTION, scenes: ctx.scenes.iter().map(|scene| check_scene(scene, ctx)).collect() }
|
||||
}
|
||||
|
||||
/// [`run`], and each scene's page flattened by `flatten`, separated and judged as well.
|
||||
pub fn run_flattened(ctx: &Context<'_>, ghostscript: String, flatten: Flatten<'_>) -> Report {
|
||||
let scenes = ctx.scenes.iter().map(|scene| check_scene_with(scene, ctx, Some(flatten))).collect();
|
||||
Report { ghostscript, resolution: gs::RESOLUTION, scenes }
|
||||
}
|
||||
|
||||
/// Write, separate and judge one scene.
|
||||
pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult {
|
||||
check_scene_with(scene, ctx, None)
|
||||
}
|
||||
|
||||
fn check_scene_with(scene: &Scene, ctx: &Context<'_>, flatten: Option<Flatten<'_>>) -> SceneResult {
|
||||
let mut result = SceneResult {
|
||||
id: scene.id.clone(),
|
||||
title: scene.title.clone(),
|
||||
@@ -81,6 +131,7 @@ pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult {
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
judgement: None,
|
||||
flattened: None,
|
||||
};
|
||||
let written = if scene.pending.is_some() { reference::write_partial(scene, ctx) } else { reference::write(scene, ctx) };
|
||||
let output = match written {
|
||||
@@ -96,22 +147,58 @@ pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult {
|
||||
}
|
||||
};
|
||||
result.notes = output.notes;
|
||||
let plates = match gs::separate(&output.pdf, &scene.id) {
|
||||
Ok(plates) => plates,
|
||||
result.flattened = flatten.map(|f| check_flattened(scene, ctx, &output.pdf, f));
|
||||
match separate_and_judge(scene, ctx, &output.pdf, &scene.id) {
|
||||
Ok(judgement) => {
|
||||
result.status = status(scene, judgement.passed());
|
||||
result.judgement = Some(judgement);
|
||||
}
|
||||
Err(e) => result.error = Some(e.to_string()),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn check_flattened(scene: &Scene, ctx: &Context<'_>, pdf: &[u8], flatten: Flatten<'_>) -> FlatResult {
|
||||
let mut result = FlatResult { status: Status::Error, transparency: Vec::new(), millis: 0.0, error: None, judgement: None };
|
||||
let start = Instant::now();
|
||||
let flat = flatten(pdf);
|
||||
result.millis = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let flat = match flat {
|
||||
Ok(flat) => flat,
|
||||
Err(e) => {
|
||||
result.error = Some(format!("flatten: {e}"));
|
||||
return result;
|
||||
}
|
||||
};
|
||||
match structure::summarize(&flat) {
|
||||
Ok(summary) => result.transparency = structure::transparency(&summary),
|
||||
Err(e) => {
|
||||
result.error = Some(e.to_string());
|
||||
return result;
|
||||
}
|
||||
};
|
||||
let judgement = judge::judge(scene, &plates, gs::RESOLUTION, ctx.font.is_some());
|
||||
result.status = match (scene.pending.is_some(), judgement.passed()) {
|
||||
}
|
||||
match separate_and_judge(scene, ctx, &flat, &format!("{}-flat", scene.id)) {
|
||||
Ok(judgement) => {
|
||||
result.status = status(scene, judgement.passed() && result.transparency.is_empty());
|
||||
result.judgement = Some(judgement);
|
||||
}
|
||||
Err(e) => result.error = Some(e.to_string()),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn separate_and_judge(scene: &Scene, ctx: &Context<'_>, pdf: &[u8], label: &str) -> Result<Judgement, Error> {
|
||||
let plates = gs::separate(pdf, label)?;
|
||||
Ok(judge::judge(scene, &plates, gs::RESOLUTION, ctx.font.is_some()))
|
||||
}
|
||||
|
||||
fn status(scene: &Scene, passed: bool) -> Status {
|
||||
match (scene.pending.is_some(), passed) {
|
||||
(false, true) => Status::Pass,
|
||||
(false, false) => Status::Fail,
|
||||
(true, false) => Status::ExpectedFail,
|
||||
(true, true) => Status::PendingPasses,
|
||||
};
|
||||
result.judgement = Some(judgement);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Cargo's target directory: `CARGO_TARGET_DIR` (relative to the workspace root) or `target`.
|
||||
@@ -143,6 +230,7 @@ pub fn write(report: &Report, dir: &Path) -> Result<PathBuf, Error> {
|
||||
|
||||
/// The report as Markdown.
|
||||
pub fn markdown(report: &Report) -> String {
|
||||
let flat = report.scenes.iter().any(|s| s.flattened.is_some());
|
||||
let mut md = String::new();
|
||||
let _ = writeln!(
|
||||
md,
|
||||
@@ -151,20 +239,36 @@ pub fn markdown(report: &Report) -> String {
|
||||
they wait for and are expected to fail. Generated by `scripts/plate-check.sh`.\n",
|
||||
report.ghostscript, report.resolution
|
||||
);
|
||||
md.push_str("| Scene | Status | Pending | Failing points | Plates |\n|---|---|---|---|---|\n");
|
||||
if flat {
|
||||
md.push_str(
|
||||
"Each scene's page is also flattened and separated the same way. \"Transparency left\" lists transparency \
|
||||
groups, graphics-state soft masks, blend modes and `ca`/`CA` below 1 in the flattened page.\n\n",
|
||||
);
|
||||
md.push_str(
|
||||
"| Scene | Status | Pending | Failing points | Plates | Flattened | Failing points | Plates | Transparency left | Flatten ms |\n\
|
||||
|---|---|---|---|---|---|---|---|---|---|\n",
|
||||
);
|
||||
} else {
|
||||
md.push_str("| Scene | Status | Pending | Failing points | Plates |\n|---|---|---|---|---|\n");
|
||||
}
|
||||
for scene in &report.scenes {
|
||||
let (points, plates) = match &scene.judgement {
|
||||
Some(j) => (format!("{}/{}", j.failed_points(), j.judged_points()), plate_problems(j).unwrap_or_else(|| "ok".into())),
|
||||
None => ("–".into(), "–".into()),
|
||||
};
|
||||
let (points, plates) = summary_cells(scene.judgement.as_ref());
|
||||
let pending = scene.pending.as_deref().unwrap_or("");
|
||||
let _ = writeln!(
|
||||
let _ = write!(
|
||||
md,
|
||||
"| [{id}](#{anchor}) | {} | {pending} | {points} | {plates} |",
|
||||
scene.status.label(),
|
||||
id = scene.id,
|
||||
anchor = scene.id.to_lowercase()
|
||||
);
|
||||
if let Some(f) = &scene.flattened {
|
||||
let (points, plates) = summary_cells(f.judgement.as_ref());
|
||||
let left = if f.transparency.is_empty() { "none".to_owned() } else { f.transparency.join(", ") };
|
||||
let _ = write!(md, " {} | {points} | {plates} | {left} | {:.0} |", f.status.label(), f.millis);
|
||||
} else if flat {
|
||||
md.push_str(" – | – | – | – | – |");
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
for scene in &report.scenes {
|
||||
let _ = writeln!(md, "\n## {}\n\n{}. Status: {}.", scene.id, scene.title, scene.status.label());
|
||||
@@ -177,34 +281,71 @@ pub fn markdown(report: &Report) -> String {
|
||||
for note in &scene.notes {
|
||||
let _ = writeln!(md, "- {note}");
|
||||
}
|
||||
let flat_judgement = scene.flattened.as_ref().and_then(|f| f.judgement.as_ref());
|
||||
if let Some(f) = &scene.flattened {
|
||||
let _ = writeln!(md, "\nFlattened: {}, {:.0} ms.", f.status.label(), f.millis);
|
||||
if let Some(error) = &f.error {
|
||||
let _ = writeln!(md, "\n{error}");
|
||||
}
|
||||
if !f.transparency.is_empty() {
|
||||
let _ = writeln!(md, "Transparency left: {}.", f.transparency.join(", "));
|
||||
}
|
||||
if let Some(j) = flat_judgement {
|
||||
let _ = writeln!(md, "Flattened plates: {}.{}", j.plates.join(", "), plate_problems(j).map(|p| format!(" {p}.")).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
let Some(judgement) = &scene.judgement else { continue };
|
||||
let _ = writeln!(md, "\nPlates: {}.{}", judgement.plates.join(", "), plate_problems(judgement).map(|p| format!(" {p}.")).unwrap_or_default());
|
||||
md.push_str("\n| # | Point | Expected | Measured | Verdict |\n|---|---|---|---|---|\n");
|
||||
if scene.flattened.is_some() {
|
||||
md.push_str("\n| # | Point | Expected | Measured | Verdict | Flattened | Verdict |\n|---|---|---|---|---|---|---|\n");
|
||||
} else {
|
||||
md.push_str("\n| # | Point | Expected | Measured | Verdict |\n|---|---|---|---|---|\n");
|
||||
}
|
||||
for (i, point) in judgement.points.iter().enumerate() {
|
||||
let verdict = match point.verdict {
|
||||
Verdict::Pass => "pass".to_owned(),
|
||||
Verdict::Info => "info".to_owned(),
|
||||
Verdict::Skipped => "skipped".to_owned(),
|
||||
Verdict::Fail if point.off.is_empty() => "**fail**".to_owned(),
|
||||
Verdict::Fail => format!("**fail** ({})", point.off.iter().map(|p| short(p)).collect::<Vec<_>>().join(" ")),
|
||||
};
|
||||
let how = point.how.as_deref().map(|h| format!("; {h}")).unwrap_or_default();
|
||||
let note = point.note.as_deref().map(|n| format!("{n} ")).unwrap_or_default();
|
||||
let _ = writeln!(
|
||||
let _ = write!(
|
||||
md,
|
||||
"| {} | {note}({}, {}){how} | {} ±{} | {} | {verdict} |",
|
||||
"| {} | {note}({}, {}){how} | {} ±{} | {} | {} |",
|
||||
i + 1,
|
||||
point.at[0],
|
||||
point.at[1],
|
||||
expected(&point.expected),
|
||||
point.tol,
|
||||
measured(&point.measured),
|
||||
verdict(point),
|
||||
);
|
||||
if scene.flattened.is_some() {
|
||||
match flat_judgement.and_then(|j| j.points.get(i)) {
|
||||
Some(p) => {
|
||||
let _ = write!(md, " {} | {} |", measured(&p.measured), verdict(p));
|
||||
}
|
||||
None => md.push_str(" – | – |"),
|
||||
}
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
fn summary_cells(judgement: Option<&Judgement>) -> (String, String) {
|
||||
match judgement {
|
||||
Some(j) => (format!("{}/{}", j.failed_points(), j.judged_points()), plate_problems(j).unwrap_or_else(|| "ok".into())),
|
||||
None => ("–".into(), "–".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn verdict(point: &judge::PointResult) -> String {
|
||||
match point.verdict {
|
||||
Verdict::Pass => "pass".to_owned(),
|
||||
Verdict::Info => "info".to_owned(),
|
||||
Verdict::Skipped => "skipped".to_owned(),
|
||||
Verdict::Fail if point.off.is_empty() => "**fail**".to_owned(),
|
||||
Verdict::Fail => format!("**fail** ({})", point.off.iter().map(|p| short(p)).collect::<Vec<_>>().join(" ")),
|
||||
}
|
||||
}
|
||||
|
||||
fn plate_problems(judgement: &Judgement) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
if !judgement.missing_plates.is_empty() {
|
||||
|
||||
@@ -232,6 +232,17 @@ pub struct ImageSource {
|
||||
/// Gray image for soft masks: white inside, fading to black over this many pt at the edges.
|
||||
#[serde(default)]
|
||||
pub feather: Option<f32>,
|
||||
/// An alpha channel, written as the image's `/SMask` (a PNG with transparency).
|
||||
#[serde(default)]
|
||||
pub alpha: Option<ImageAlpha>,
|
||||
}
|
||||
|
||||
/// Image alpha: `opacity` (0–1) for pixels whose centers lie inside `rect` (page pt), 0 for the rest.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImageAlpha {
|
||||
pub rect: [f32; 4],
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
@@ -463,6 +474,9 @@ impl Scene {
|
||||
if image.px[0] == 0 || image.px[1] == 0 || image.px[0] > 4096 || image.px[1] > 4096 {
|
||||
out.push("image px must be 1–4096".into());
|
||||
}
|
||||
if image.alpha.as_ref().is_some_and(|a| image.feather.is_some() || !(0.0..=1.0).contains(&a.opacity)) {
|
||||
out.push("image alpha needs a color image and an opacity of 0–1".into());
|
||||
}
|
||||
}
|
||||
if (kind == Kind::Pdf) != object.scene.is_some() {
|
||||
out.push(format!("{kind:?}: scene belongs to (and is required by) pdf objects"));
|
||||
|
||||
@@ -52,6 +52,12 @@ pub struct ExtGState {
|
||||
pub opm: Option<i32>,
|
||||
#[serde(rename = "BM", skip_serializing_if = "Option::is_none")]
|
||||
pub bm: Option<String>,
|
||||
/// `/ca` (fill and text alpha).
|
||||
#[serde(rename = "ca", skip_serializing_if = "Option::is_none")]
|
||||
pub alpha_fill: Option<f32>,
|
||||
/// `/CA` (stroke alpha).
|
||||
#[serde(rename = "CA", skip_serializing_if = "Option::is_none")]
|
||||
pub alpha_stroke: Option<f32>,
|
||||
/// The soft mask's `/S`, when the state sets a mask.
|
||||
#[serde(rename = "SMask", skip_serializing_if = "Option::is_none")]
|
||||
pub smask: Option<String>,
|
||||
@@ -208,6 +214,8 @@ impl Walk {
|
||||
op_stroke: gs.get::<bool>(b"OP".as_slice()),
|
||||
op_fill: gs.get::<bool>(b"op".as_slice()),
|
||||
opm: gs.get::<i32>(b"OPM".as_slice()),
|
||||
alpha_fill: gs.get::<f32>(b"ca".as_slice()),
|
||||
alpha_stroke: gs.get::<f32>(b"CA".as_slice()),
|
||||
bm: gs.get::<Object<'_>>(b"BM".as_slice()).and_then(|bm| match bm {
|
||||
Object::Name(name) => Some(name.as_str().to_owned()),
|
||||
Object::Array(names) => names.iter::<Name<'_>>().next().map(|n| n.as_str().to_owned()),
|
||||
@@ -308,6 +316,29 @@ fn describe(cs: &Object<'_>, depth: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Live transparency left in a page that should have none, as a flattener's output must: transparency
|
||||
/// groups, soft masks in graphics states, blend modes other than Normal, and `ca` or `CA` below 1.
|
||||
/// Soft masks of images are not graphics states and are allowed. Empty when there is none.
|
||||
pub fn transparency(summary: &Summary) -> Vec<String> {
|
||||
let mut out: Vec<String> = summary.groups.iter().filter(|g| g.s == "Transparency").map(|g| format!("transparency group ({})", g.at)).collect();
|
||||
for gs in &summary.ext_g_states {
|
||||
if let Some(mask) = gs.smask.as_deref().filter(|m| !m.is_empty() && *m != "None") {
|
||||
out.push(format!("soft mask /{mask}"));
|
||||
}
|
||||
if let Some(bm) = gs.bm.as_deref().filter(|b| *b != "Normal" && *b != "Compatible") {
|
||||
out.push(format!("blend mode /{bm}"));
|
||||
}
|
||||
for (key, alpha) in [("ca", gs.alpha_fill), ("CA", gs.alpha_stroke)] {
|
||||
if let Some(a) = alpha.filter(|a| *a < 1.0) {
|
||||
out.push(format!("/{key} {a}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// A scene's `structure` block. Every field is optional; only the fields present are checked.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
||||
@@ -26,12 +26,7 @@ fn every_scene_separates_as_expected() {
|
||||
println!("{} {:?}{pending}", result.id, result.status);
|
||||
}
|
||||
println!("report: {}", path.display());
|
||||
let wrong: Vec<String> = report
|
||||
.scenes
|
||||
.iter()
|
||||
.filter(|r| !r.status.as_expected())
|
||||
.map(|r| format!("{} {:?}{}", r.id, r.status, r.error.as_deref().map(|e| format!(": {e}")).unwrap_or_default()))
|
||||
.collect();
|
||||
let wrong = report.unexpected();
|
||||
assert!(wrong.is_empty(), "see {}:\n{}", path.display(), wrong.join("\n"));
|
||||
assert!(report.scenes.iter().any(|r| r.status == Status::Pass));
|
||||
}
|
||||
@@ -14,3 +14,14 @@ workspace = true
|
||||
craft-pdf = { workspace = true, features = ["raster", "write"] }
|
||||
craft-cms = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
kurbo = { workspace = true, optional = true }
|
||||
linesweeper = { version = "0.5", optional = true }
|
||||
ttf-parser = { version = "=0.25.1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
craft-plate-test = { workspace = true }
|
||||
ttf-parser = "=0.25.1"
|
||||
|
||||
[features]
|
||||
# The transparency flattener.
|
||||
flatten = ["craft-pdf/write-pdf", "dep:kurbo", "dep:linesweeper", "dep:ttf-parser"]
|
||||
@@ -14,7 +14,24 @@ Prepress for the Craft apps: separations, ink limits and the ink manager.
|
||||
- `composite`: an sRGB preview of chosen plates.
|
||||
- `cms`: process color conversion into a CMYK printing condition through craft-cms.
|
||||
|
||||
A transparency flattener is planned as a module behind a `flatten` feature.
|
||||
- `flatten` (feature `flatten`): the display list a transparency flattener reads and writes:
|
||||
fills, text runs, images and transparency groups in page space, with ink colors and overprint
|
||||
per item. Text runs keep their font (bytes, face index, a dedup key), glyph ids and positions,
|
||||
and Unicode text, so text without transparency stays live text; outlines are kept as the
|
||||
fallback for fonts that can't be embedded again. `flatten` turns the transparent parts into
|
||||
opaque region fills, compositing one value per ink (process and spot) with blend modes, group
|
||||
opacity, isolated and knockout groups, and overprint per plate. Items without transparency
|
||||
come back unchanged (JPEG images in DeviceGray or DeviceCMYK keep their DCT data; other images
|
||||
are written as 8-bit Flate samples); regions a gradient, image or soft mask reaches are listed
|
||||
for rasterizing. `flatten::raster` rasterizes them: each region's items are written alone,
|
||||
separated with `plates` and written back as a DeviceN image of the page's inks, clipped to the
|
||||
region, snapped to the device grid and a pixel larger than the clip so no paper shows at its
|
||||
edges. `flatten_document` flattens every page of a PDF, or a range of pages, into one PDF with
|
||||
the source's output intent, page boxes, title, author, creation date and modification date; a
|
||||
font used on several pages is embedded once. The caller sets the PDF version and the PDF/X
|
||||
version and conformance keys (for PDF/X-1a:2003, 1.4, `PDF/X-1:2003` and `PDF/X-1a:2003`);
|
||||
krilla has no PDF/X validator, so the app keeps its own PDF/X checks. `flatten_page` is its
|
||||
one-page case.
|
||||
|
||||
## Plates from an exported PDF
|
||||
|
||||
@@ -193,5 +210,15 @@ if let Some(color) = to_pdf_color(&printed, &inks, &cms) {
|
||||
(guillehr2), with craft-cms in place of moxcms.
|
||||
- Knockout groups in the renderer come from
|
||||
[storytold/pdfcraft#2974](https://github.com/storytold/pdfcraft/pull/2974) by mtlcl410.
|
||||
- The flattener's planar regions (`flatten::geom`) are ported from VectorCraft at commit
|
||||
`c2b15982` by the VectorCraft contributors: `crates/pathops/src/pathfinder.rs`,
|
||||
`crates/pathops/src/boolean.rs` and `crates/engine/src/cmd/flatten.rs`.
|
||||
- The flattener's compositor (`flatten::compose`) is ported from VectorCraft at commit
|
||||
`c2b15982` by the VectorCraft contributors: `Composer`, `region_fill` and the raster/vector
|
||||
balance in `crates/engine/src/cmd/flatten.rs`, and the blend formulas in
|
||||
`crates/color/src/blend.rs`.
|
||||
- The flattener's raster step (`flatten::raster`) follows VectorCraft's `render_raster` in
|
||||
`crates/engine/src/cmd/flatten.rs` at commit `c2b15982`: the image reaching a pixel past its
|
||||
area, and the coverage of the art drawn opaque as the image's alpha.
|
||||
|
||||
Both MIT OR Apache-2.0, as this crate.
|
||||
All MIT OR Apache-2.0, as this crate.
|
||||
@@ -0,0 +1,387 @@
|
||||
//! [`flatten_document`]: every page of a PDF flattened into one PDF.
|
||||
//!
|
||||
//! - Output intent: written once, the first flattened page's as [`flatten_page`] picks it (the
|
||||
//! page's own `/OutputIntents`, PDF 2.0, else the catalog's), else [`ReadOptions::fallback`]'s
|
||||
//! profile, else FOGRA39. Each page is read and its complex regions separated with its own
|
||||
//! intent; krilla writes no page-level `/OutputIntents`, so pages whose intent differs from the
|
||||
//! one written are listed in [`FlattenDocumentReport::other_intents`].
|
||||
//! - Page boxes: the page is the source's visible area (CropBox within MediaBox, rotated), written
|
||||
//! as the MediaBox. TrimBox, BleedBox and ArtBox carry over, mapped onto it and clipped to it.
|
||||
//! - Metadata: title, author, creation date and modification date carry over; the producer is
|
||||
//! the caller's.
|
||||
//! - Version: [`FlattenDocumentOptions::version`], [`FlattenDocumentOptions::pdfx_version`]
|
||||
//! and [`FlattenDocumentOptions::pdfx_conformance`] set the header, `pdf:PDFVersion` and the
|
||||
//! PDF/X version and conformance keys. krilla has no PDF/X validator, so a caller claiming
|
||||
//! PDF/X runs its own conformance checks.
|
||||
//! - Fonts: prepared per page ([`FontCache`](super::font::FontCache)); pages share a krilla font
|
||||
//! only when the font bytes are equal ([`FontMap`]).
|
||||
//! - The source is opened once, and each output intent and printing condition parsed once.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use craft_pdf::interpret::TransformExt as _;
|
||||
use craft_pdf::syntax::Pdf;
|
||||
use craft_pdf::syntax::object::dict::keys::{ART_BOX, BLEED_BOX, OUTPUT_INTENTS, TRIM_BOX};
|
||||
use craft_pdf::syntax::object::{Array, DateTime as PdfDate, Dict, Rect as PdfRect};
|
||||
use craft_pdf::syntax::page::Page;
|
||||
use craft_pdf::write::backend::SerializeSettings;
|
||||
use craft_pdf::write::backend::configure::PdfVersion;
|
||||
use craft_pdf::write::backend::geom::{Rect as KRect, Size, Transform};
|
||||
use craft_pdf::write::backend::metadata::DateTime;
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::backend::pdf::PdfDocument;
|
||||
use craft_pdf::write::print::{self as print_settings, PrintColor};
|
||||
use craft_pdf::write::{self as pdf_write, Metadata, Writer};
|
||||
use craft_pdf::{OpenError, open_slice};
|
||||
use kurbo::Rect;
|
||||
|
||||
use super::pdf::{ReadColor, ReadError, ReadOptions, read_color, read_page_in};
|
||||
use super::raster::{FlattenPageReport, Rasterizer};
|
||||
use super::write::{FontMap, WriteError, write_flattened_with_fonts};
|
||||
use super::{DisplayList, FlattenError, FlattenOptions, flatten};
|
||||
use crate::cms::CmsConversion;
|
||||
use crate::plates::{self, Fallback, pdf_text};
|
||||
|
||||
/// How [`flatten_document`] reads, flattens and writes.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FlattenDocumentOptions {
|
||||
pub read: ReadOptions,
|
||||
pub flatten: FlattenOptions,
|
||||
/// The pages to flatten, from 0; all when `None`.
|
||||
pub pages: Option<Range<usize>>,
|
||||
/// Written as `/Producer`.
|
||||
pub producer: String,
|
||||
pub unreadable: Unreadable,
|
||||
/// The output's PDF version. Below 1.6, DeviceN spaces get no NChannel attributes; at 1.4,
|
||||
/// krilla also enforces the 1.4 implementation limits (reals, array and dictionary sizes),
|
||||
/// and a DeviceN space of more than 8 colorants is a [`WriteError::Colorants`].
|
||||
pub version: PdfVersion,
|
||||
/// The PDF/X version claimed: `/GTS_PDFXVersion` in the information dictionary and
|
||||
/// `pdfxid:GTS_PDFXVersion` in XMP. For PDF/X-1a:2003 it is `PDF/X-1:2003`. Not checked.
|
||||
pub pdfx_version: Option<String>,
|
||||
/// The PDF/X conformance level claimed, `/GTS_PDFXConformance` in the information
|
||||
/// dictionary: `PDF/X-1a:2003` for PDF/X-1a:2003. Not checked.
|
||||
pub pdfx_conformance: Option<String>,
|
||||
}
|
||||
|
||||
/// What [`flatten_document`] does with a page the PDF interpreter fails on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Unreadable {
|
||||
/// Fail with [`FlattenDocumentError::Read`].
|
||||
#[default]
|
||||
Fail,
|
||||
/// Copy the page unflattened, as a form XObject, and list it in
|
||||
/// [`FlattenDocumentReport::unflattened`]. Its transparency stays.
|
||||
Keep,
|
||||
}
|
||||
|
||||
/// What [`flatten_document`] did.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct FlattenDocumentReport {
|
||||
/// Each flattened page (from 0) and its report.
|
||||
pub pages: Vec<(usize, FlattenPageReport)>,
|
||||
/// Pages copied unflattened ([`Unreadable::Keep`]) and why they didn't read.
|
||||
pub unflattened: Vec<(usize, String)>,
|
||||
/// Pages read and separated with an output intent other than the one written.
|
||||
pub other_intents: Vec<usize>,
|
||||
pub totals: FlattenTotals,
|
||||
}
|
||||
|
||||
/// [`FlattenDocumentReport::pages`] summed.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct FlattenTotals {
|
||||
/// Top-level items cut into regions.
|
||||
pub flattened: usize,
|
||||
pub complex_regions: usize,
|
||||
/// Groups rasterized whole.
|
||||
pub complex_groups: usize,
|
||||
pub raster_images: usize,
|
||||
pub raster_pixels: u64,
|
||||
/// The lowest resolution a complex region was rasterized at, when one needed less than
|
||||
/// [`FlattenOptions::raster_dpi`].
|
||||
pub lowest_dpi: Option<f32>,
|
||||
/// Raster images read.
|
||||
pub images: usize,
|
||||
pub unreadable_images: usize,
|
||||
pub dropped_tilings: usize,
|
||||
pub dropped_inks: usize,
|
||||
pub dropped_annotations: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum FlattenDocumentError {
|
||||
#[error(transparent)]
|
||||
Open(#[from] OpenError),
|
||||
#[error("pages {start}..{end} are not pages of the document's {count}")]
|
||||
PageRange { start: usize, end: usize, count: usize },
|
||||
#[error("page {page} (from 0): {source}")]
|
||||
Read { page: usize, source: ReadError },
|
||||
#[error("page {page} (from 0): {source}")]
|
||||
Flatten { page: usize, source: FlattenError },
|
||||
#[error("page {page} (from 0): {source}")]
|
||||
Write { page: usize, source: WriteError },
|
||||
/// Serializing the document failed.
|
||||
#[error(transparent)]
|
||||
Serialize(#[from] pdf_write::WriteError),
|
||||
}
|
||||
|
||||
/// `pdf` with the transparency of every page in [`FlattenDocumentOptions::pages`] flattened, as
|
||||
/// one PDF. See the [module docs](self) for what carries over.
|
||||
pub fn flatten_document(pdf: &[u8], opts: &FlattenDocumentOptions) -> Result<(Vec<u8>, FlattenDocumentReport), FlattenDocumentError> {
|
||||
flatten_document_with_progress(pdf, opts, &mut |_, _| {})
|
||||
}
|
||||
|
||||
/// [`flatten_document`], calling `progress(done, total)` after each page.
|
||||
pub fn flatten_document_with_progress(
|
||||
pdf: &[u8],
|
||||
opts: &FlattenDocumentOptions,
|
||||
progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Result<(Vec<u8>, FlattenDocumentReport), FlattenDocumentError> {
|
||||
let doc = Arc::new(open_slice(pdf, None)?);
|
||||
let count = doc.pages().len();
|
||||
let range = opts.pages.clone().unwrap_or(0..count);
|
||||
if range.is_empty() || range.end > count {
|
||||
return Err(FlattenDocumentError::PageRange { start: range.start, end: range.end, count });
|
||||
}
|
||||
let mut colors = Colors { doc: &doc, fallback: &opts.read.fallback, cache: HashMap::new() };
|
||||
let first = colors.get(range.start)?;
|
||||
let mut report = FlattenDocumentReport::default();
|
||||
// The page being written, to name it when krilla panics.
|
||||
let current = Cell::new(None);
|
||||
let written = pdf_write::guard(|| {
|
||||
let settings = SerializeSettings {
|
||||
configuration: pdf_write::configuration(opts.version, None)?,
|
||||
pdfx_version: opts.pdfx_version.clone(),
|
||||
pdfx_conformance: opts.pdfx_conformance.clone(),
|
||||
..first.settings.clone()
|
||||
};
|
||||
let mut writer = Writer::new(settings, metadata(&doc, &opts.producer));
|
||||
let mut fonts = FontMap::new();
|
||||
let mut source = None;
|
||||
let total = range.len();
|
||||
for (done, page) in range.clone().enumerate() {
|
||||
current.set(Some(page));
|
||||
let color = colors.get(page)?;
|
||||
if !Rc::ptr_eq(&color, &first) && !same_condition(&color.condition, &first.condition) {
|
||||
report.other_intents.push(page);
|
||||
}
|
||||
let p = doc.pages().get(page).ok_or(FlattenDocumentError::Read { page, source: ReadError::NoPage(page) })?;
|
||||
match read_page_in(&doc, page, &opts.read, color.read.clone()) {
|
||||
Ok((list, read)) => {
|
||||
let flat = flatten(&list, &opts.flatten).map_err(|source| FlattenDocumentError::Flatten { page, source })?;
|
||||
let mut raster = Rasterizer::new(&list, color.settings.clone(), color.condition.clone(), &color.cms, opts.flatten.raster_dpi);
|
||||
let at = |source| FlattenDocumentError::Write { page, source };
|
||||
let settings = page_settings(p, list.page).ok_or(at(WriteError::Page))?;
|
||||
write_page(&mut writer, settings, &list, |surface| {
|
||||
write_flattened_with_fonts(surface, &list, &flat, &color.cms, opts.version, &mut fonts, &mut |r| raster.region(r))
|
||||
})
|
||||
.map_err(at)?;
|
||||
report.pages.push((page, FlattenPageReport { read, flatten: flat.report, raster: raster.report().clone() }));
|
||||
}
|
||||
Err(e) if opts.unreadable == Unreadable::Keep => {
|
||||
let (w, h) = p.render_dimensions();
|
||||
let bbox = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
|
||||
let at = |source| FlattenDocumentError::Write { page, source };
|
||||
let settings = page_settings(p, bbox).ok_or(at(WriteError::Page))?;
|
||||
let size = Size::from_wh(w, h).ok_or(at(WriteError::Page))?;
|
||||
let source = source.get_or_insert_with(|| PdfDocument::new(doc.clone()));
|
||||
let mut page_builder = writer.document_mut().start_page_with(settings);
|
||||
let mut surface = page_builder.surface();
|
||||
surface.draw_pdf_page(source, size, page);
|
||||
surface.finish();
|
||||
page_builder.finish();
|
||||
report.unflattened.push((page, e.to_string()));
|
||||
}
|
||||
Err(source) => return Err(FlattenDocumentError::Read { page, source }),
|
||||
}
|
||||
progress(done + 1, total);
|
||||
}
|
||||
current.set(None);
|
||||
Ok(writer.finish()?)
|
||||
});
|
||||
let bytes = written.map_err(|e| match (e, current.get()) {
|
||||
(FlattenDocumentError::Serialize(source), Some(page)) => FlattenDocumentError::Write { page, source: WriteError::Pdf(source) },
|
||||
(e, _) => e,
|
||||
})?;
|
||||
report.totals = totals(&report.pages);
|
||||
Ok((bytes, report))
|
||||
}
|
||||
|
||||
/// Draws one page of `list.page`'s size with `draw`, in page space.
|
||||
fn write_page(
|
||||
writer: &mut Writer,
|
||||
settings: PageSettings,
|
||||
list: &DisplayList,
|
||||
draw: impl FnOnce(&mut craft_pdf::write::backend::surface::Surface<'_>) -> Result<(), WriteError>,
|
||||
) -> Result<(), WriteError> {
|
||||
let box_ = list.page;
|
||||
let mut page_builder = writer.document_mut().start_page_with(settings);
|
||||
let mut surface = page_builder.surface();
|
||||
let shifted = box_.x0 != 0.0 || box_.y0 != 0.0;
|
||||
if shifted {
|
||||
surface.push_transform(&Transform::from_translate(-box_.x0 as f32, -box_.y0 as f32));
|
||||
}
|
||||
let drawn = draw(&mut surface);
|
||||
if shifted {
|
||||
surface.pop();
|
||||
}
|
||||
surface.finish();
|
||||
page_builder.finish();
|
||||
drawn
|
||||
}
|
||||
|
||||
/// A page of `bbox`'s size (page space) with the source page's TrimBox, BleedBox and ArtBox,
|
||||
/// mapped into page space and clipped to `bbox`.
|
||||
fn page_settings(p: &Page<'_>, bbox: Rect) -> Option<PageSettings> {
|
||||
let size = PageSettings::from_wh(bbox.width() as f32, bbox.height() as f32).filter(|_| bbox.is_finite())?;
|
||||
let to_page = p.initial_transform(true).to_kurbo();
|
||||
let mapped = |key: &[u8]| {
|
||||
let r = p.raw().get::<PdfRect>(key)?;
|
||||
let r = to_page.transform_rect_bbox(Rect::new(r.x0, r.y0, r.x1, r.y1)).intersect(bbox);
|
||||
if !(r.is_finite() && r.width() > 0.0 && r.height() > 0.0) {
|
||||
return None;
|
||||
}
|
||||
KRect::from_ltrb((r.x0 - bbox.x0) as f32, (r.y0 - bbox.y0) as f32, (r.x1 - bbox.x0) as f32, (r.y1 - bbox.y0) as f32)
|
||||
};
|
||||
Some(size.with_trim_box(mapped(TRIM_BOX)).with_bleed_box(mapped(BLEED_BOX)).with_art_box(mapped(ART_BOX)))
|
||||
}
|
||||
|
||||
/// The source's title, author, creation and modification dates, and `producer`.
|
||||
fn metadata(doc: &Pdf, producer: &str) -> Metadata {
|
||||
let info = doc.metadata();
|
||||
let text = |bytes: &Option<Vec<u8>>| bytes.as_deref().map(|b| pdf_text(b).trim().to_string()).filter(|s| !s.is_empty());
|
||||
Metadata {
|
||||
producer: producer.to_string(),
|
||||
title: text(&info.title),
|
||||
authors: text(&info.author).into_iter().collect(),
|
||||
created: info.creation_date.as_ref().map(date),
|
||||
modified: info.modification_date.as_ref().map(date),
|
||||
..Metadata::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn date(d: &PdfDate) -> DateTime {
|
||||
DateTime::new(d.year)
|
||||
.month(d.month)
|
||||
.day(d.day)
|
||||
.hour(d.hour)
|
||||
.minute(d.minute)
|
||||
.second(d.second)
|
||||
.utc_offset_hour(d.utc_offset_hour)
|
||||
.utc_offset_minute(d.utc_offset_minute)
|
||||
}
|
||||
|
||||
fn totals(pages: &[(usize, FlattenPageReport)]) -> FlattenTotals {
|
||||
let mut t = FlattenTotals::default();
|
||||
for (_, r) in pages {
|
||||
t.flattened += r.flatten.flattened;
|
||||
t.complex_regions += r.flatten.complex_regions;
|
||||
t.complex_groups += r.flatten.complex_groups.len();
|
||||
t.raster_images += r.raster.images;
|
||||
t.raster_pixels += r.raster.pixels;
|
||||
t.lowest_dpi = match (t.lowest_dpi, r.raster.lowest_dpi) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
t.images += r.read.images;
|
||||
t.unreadable_images += r.read.unreadable_images;
|
||||
t.dropped_tilings += r.read.dropped_tilings;
|
||||
t.dropped_inks += r.read.dropped_inks;
|
||||
t.dropped_annotations += r.read.dropped_annotations;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// A page's printing condition, for reading, separating and writing.
|
||||
struct PageColor {
|
||||
/// With the output intent.
|
||||
settings: SerializeSettings,
|
||||
cms: CmsConversion,
|
||||
condition: Fallback,
|
||||
read: ReadColor,
|
||||
}
|
||||
|
||||
/// The [`PageColor`]s of a document: one for the pages without their own output intents, one per
|
||||
/// page with them.
|
||||
struct Colors<'d> {
|
||||
doc: &'d Pdf,
|
||||
fallback: &'d Fallback,
|
||||
cache: HashMap<Option<usize>, Rc<PageColor>>,
|
||||
}
|
||||
|
||||
impl Colors<'_> {
|
||||
fn get(&mut self, page: usize) -> Result<Rc<PageColor>, FlattenDocumentError> {
|
||||
let own =
|
||||
self.doc.pages().get(page).and_then(|p| p.raw().get::<Array<'_>>(OUTPUT_INTENTS)).is_some_and(|a| a.iter::<Dict<'_>>().next().is_some());
|
||||
let key = own.then_some(page);
|
||||
if let Some(color) = self.cache.get(&key) {
|
||||
return Ok(color.clone());
|
||||
}
|
||||
let read = read_color(self.doc, page, self.fallback).map_err(|source| FlattenDocumentError::Read { page, source })?;
|
||||
let (settings, cms, condition) = printing(self.doc, page, self.fallback).map_err(|source| FlattenDocumentError::Write { page, source })?;
|
||||
let color = Rc::new(PageColor { settings, cms, condition, read });
|
||||
self.cache.insert(key, color.clone());
|
||||
Ok(color)
|
||||
}
|
||||
}
|
||||
|
||||
fn same_condition(a: &Fallback, b: &Fallback) -> bool {
|
||||
match (a, b) {
|
||||
(Fallback::Profile(a), Fallback::Profile(b)) => a == b,
|
||||
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// The output's serialize settings and printing condition for page `page`: its output intent
|
||||
/// written again, else `fallback`'s profile, else FOGRA39.
|
||||
fn printing(doc: &Pdf, page: usize, fallback: &Fallback) -> Result<(SerializeSettings, CmsConversion, Fallback), WriteError> {
|
||||
let fogra39 = || -> Result<_, WriteError> {
|
||||
// Reading the profile is the slow part of writing a small page; it is the usual one.
|
||||
static SETTINGS: OnceLock<Option<SerializeSettings>> = OnceLock::new();
|
||||
let settings = SETTINGS.get_or_init(|| print_settings::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).ok());
|
||||
let settings = settings.clone().ok_or(WriteError::Pdf(pdf_write::WriteError::InvalidProfile))?;
|
||||
Ok((settings, CmsConversion::fogra39(), Fallback::Fogra39))
|
||||
};
|
||||
let custom = |icc: &[u8], color: PrintColor<'_>| {
|
||||
if icc == craft_cms::builtin::COATED_FOGRA39_ICC {
|
||||
return fogra39().ok();
|
||||
}
|
||||
let cms = CmsConversion::new(icc).ok()?;
|
||||
let settings = print_settings::settings(color).ok()?;
|
||||
Some((settings, cms, Fallback::Profile(icc.into())))
|
||||
};
|
||||
let found = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plates::output_intent_profile(doc, page))).ok().flatten();
|
||||
if let Some(intent) = found {
|
||||
let color = PrintColor {
|
||||
profile: &intent.icc,
|
||||
condition_identifier: intent.identifier.as_deref().unwrap_or("Custom"),
|
||||
condition: intent.condition.as_deref(),
|
||||
registry_name: intent.registry_name.as_deref(),
|
||||
info: intent.info.as_deref().or(intent.condition.as_deref()),
|
||||
};
|
||||
if let Some(found) = custom(&intent.icc, color) {
|
||||
return Ok(found);
|
||||
}
|
||||
}
|
||||
if let Fallback::Profile(icc) = fallback {
|
||||
let color = PrintColor {
|
||||
profile: icc,
|
||||
condition_identifier: "Custom",
|
||||
condition: None,
|
||||
registry_name: None,
|
||||
info: Some("Custom CMYK printing condition"),
|
||||
};
|
||||
if let Some(found) = custom(icc, color) {
|
||||
return Ok(found);
|
||||
}
|
||||
}
|
||||
fogra39()
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Source fonts prepared for embedding again. The PDF-in adapter notes every outline glyph it
|
||||
//! reads; after the page, [`FontCache::finish`] gives one [`TextFont`] per source font, or
|
||||
//! `None` where the font's runs are drawn from their outlines.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::interpret::font::OutlineGlyph;
|
||||
use craft_pdf::write::backend::geom::Point;
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph};
|
||||
use craft_pdf::write::backend::{Data, SerializeSettings};
|
||||
use craft_pdf::write::{Metadata, WriteError, Writer, guard};
|
||||
|
||||
use super::{FontBytes, TextFont};
|
||||
|
||||
/// The fonts of one page, collected glyph by glyph.
|
||||
#[derive(Default)]
|
||||
pub struct FontCache {
|
||||
fonts: HashMap<u128, Noted>,
|
||||
}
|
||||
|
||||
struct Noted {
|
||||
/// The font's own bytes and face index; `None` when the PDF doesn't embed the font or
|
||||
/// hayro has no bytes for it.
|
||||
source: Option<(FontBytes, u32)>,
|
||||
/// Glyph id to advance in glyph space (1000 units per em), from the PDF's widths. Ordered,
|
||||
/// so the trial embedding is deterministic.
|
||||
advances: BTreeMap<u32, f32>,
|
||||
}
|
||||
|
||||
impl FontCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Records a glyph seen on the page (font key, glyph id, PDF width) and returns the key.
|
||||
pub fn note(&mut self, glyph: &OutlineGlyph) -> u128 {
|
||||
let key = glyph.font_cache_key();
|
||||
let noted = self.fonts.entry(key).or_insert_with(|| Noted {
|
||||
// A substitute from the font resolver is not the file's font.
|
||||
source: glyph.font_data().filter(|d| d.embedded).map(|d| (d.data, d.index)),
|
||||
advances: BTreeMap::new(),
|
||||
});
|
||||
if noted.source.is_some() {
|
||||
noted.advances.insert(glyph.glyph_id().to_u32(), glyph.advance_width().unwrap_or(0.0));
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// After the page is read: sniff, wrap bare CFF, trial-embed. `None` means: draw from
|
||||
/// outlines. Every noted key is present.
|
||||
pub fn finish(self) -> HashMap<u128, Option<Arc<TextFont>>> {
|
||||
self.fonts.into_iter().map(|(key, noted)| (key, noted.prepare(key).map(Arc::new))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Noted {
|
||||
fn prepare(self, key: u128) -> Option<TextFont> {
|
||||
let (data, index) = self.source?;
|
||||
let magic: [u8; 4] = (*data).as_ref().get(..4)?.try_into().ok()?;
|
||||
let font = match &magic {
|
||||
[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf" => TextFont { data, index, key },
|
||||
// The CFF wrap needs the widths of all glyphs used, hence once per page.
|
||||
[1, _, _, _] => TextFont { data: Arc::new(wrap_cff((*data).as_ref(), &self.advances)?), index: 0, key },
|
||||
_ => return None,
|
||||
};
|
||||
embeds(&font, self.advances.keys().copied()).then_some(font)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether krilla embeds `glyphs` of `font`. `Font::new` accepts fonts whose subsetting fails
|
||||
/// later, in `Writer::finish`, which is too late to fall back for one font; a throwaway
|
||||
/// one-page document finds those failures here.
|
||||
fn embeds(font: &TextFont, glyphs: impl Iterator<Item = u32>) -> bool {
|
||||
let glyphs: Vec<KrillaGlyph> = glyphs.map(|id| KrillaGlyph::new(GlyphId::new(id), 0.0, 0.0, 0.0, 0.0, 0..0, None)).collect();
|
||||
let trial = || {
|
||||
let font = Font::new(Data::from(font.data.clone()), font.index).ok_or(WriteError::InvalidFont)?;
|
||||
let mut writer = Writer::new(SerializeSettings::default(), Metadata::default());
|
||||
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(10.0, 10.0).ok_or(WriteError::InvalidFont)?);
|
||||
let mut surface = page.surface();
|
||||
surface.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, "", 10.0, false);
|
||||
surface.finish();
|
||||
page.finish();
|
||||
writer.finish()
|
||||
};
|
||||
guard(trial).is_ok()
|
||||
}
|
||||
|
||||
/// Wraps bare CFF (`FontFile3`) in the smallest OpenType font krilla loads: `CFF `, `head`,
|
||||
/// `hhea`, `hmtx`, `maxp` 0.5 and `post` 3. `hmtx` holds `advances` (glyph space, 1000 units
|
||||
/// per em) and 0 for the glyphs not listed; `unitsPerEm` follows the CFF `FontMatrix`.
|
||||
pub(crate) fn wrap_cff(cff: &[u8], advances: &BTreeMap<u32, f32>) -> Option<Vec<u8>> {
|
||||
let table = ttf_parser::cff::Table::parse(cff)?;
|
||||
let glyphs = table.number_of_glyphs();
|
||||
let upem = (1.0 / table.matrix().sx).round().clamp(16.0, 16384.0) as u16;
|
||||
let scale = f32::from(upem) / 1000.0;
|
||||
let be16 = |v: &mut Vec<u8>, x: u16| v.extend_from_slice(&x.to_be_bytes());
|
||||
let be32 = |v: &mut Vec<u8>, x: u32| v.extend_from_slice(&x.to_be_bytes());
|
||||
|
||||
let mut head = Vec::new();
|
||||
be32(&mut head, 0x0001_0000);
|
||||
be32(&mut head, 0x0001_0000);
|
||||
be32(&mut head, 0);
|
||||
be32(&mut head, 0x5F0F_3CF5);
|
||||
be16(&mut head, 0x000B);
|
||||
be16(&mut head, upem);
|
||||
head.extend_from_slice(&[0; 16]);
|
||||
for v in [0, 0, upem, upem] {
|
||||
be16(&mut head, v);
|
||||
}
|
||||
for v in [0, 8, 2, 0, 0] {
|
||||
be16(&mut head, v);
|
||||
}
|
||||
|
||||
let mut hmtx = Vec::new();
|
||||
let mut max_advance = 0;
|
||||
for gid in 0..u32::from(glyphs) {
|
||||
let advance = advances.get(&gid).map_or(0, |a| (a * scale).round().clamp(0.0, 65535.0) as u16);
|
||||
max_advance = max_advance.max(advance);
|
||||
be16(&mut hmtx, advance);
|
||||
be16(&mut hmtx, 0);
|
||||
}
|
||||
|
||||
// krilla tells fonts apart by their metrics and `checksumAdjustment`, and two pages' wraps
|
||||
// of one CFF differ only in `hmtx`: a hash of it keeps them apart in one document.
|
||||
let hash = hmtx.iter().fold(0x811C_9DC5_u32, |h, b| (h ^ u32::from(*b)).wrapping_mul(0x0100_0193));
|
||||
head[8..12].copy_from_slice(&hash.to_be_bytes());
|
||||
|
||||
let mut hhea = Vec::new();
|
||||
be32(&mut hhea, 0x0001_0000);
|
||||
be16(&mut hhea, (u32::from(upem) * 4 / 5) as u16);
|
||||
be16(&mut hhea, (-(i32::from(upem) / 5)) as i16 as u16);
|
||||
be16(&mut hhea, 0);
|
||||
be16(&mut hhea, max_advance);
|
||||
hhea.extend_from_slice(&[0; 4]);
|
||||
be16(&mut hhea, max_advance);
|
||||
be16(&mut hhea, 1);
|
||||
hhea.extend_from_slice(&[0; 14]);
|
||||
be16(&mut hhea, glyphs);
|
||||
|
||||
let mut maxp = Vec::new();
|
||||
be32(&mut maxp, 0x0000_5000);
|
||||
be16(&mut maxp, glyphs);
|
||||
|
||||
let mut post = Vec::new();
|
||||
be32(&mut post, 0x0003_0000);
|
||||
post.extend_from_slice(&[0; 28]);
|
||||
|
||||
// Tables in tag order, as the sfnt directory requires.
|
||||
let tables: [(&[u8; 4], &[u8]); 6] = [(b"CFF ", cff), (b"head", &head), (b"hhea", &hhea), (b"hmtx", &hmtx), (b"maxp", &maxp), (b"post", &post)];
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"OTTO");
|
||||
for v in [6, 64, 2, 32] {
|
||||
be16(&mut out, v);
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
out.extend_from_slice(*tag);
|
||||
be32(&mut out, 0);
|
||||
be32(&mut out, u32::try_from(offset).ok()?);
|
||||
be32(&mut out, u32::try_from(data.len()).ok()?);
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
out.extend_from_slice(data);
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_pdf::interpret::font::StandardFont;
|
||||
|
||||
#[test]
|
||||
fn bare_cff_is_wrapped_in_an_opentype_font_with_the_pdf_widths() {
|
||||
// hayro's Foxit Sans substitute is bare CFF, as `FontFile3/Type1C` holds it.
|
||||
let (data, _) = StandardFont::Helvetica.get_font_data();
|
||||
let cff = (*data).as_ref();
|
||||
assert_eq!(cff[0], 1);
|
||||
assert!(Font::new(Data::from(cff.to_vec()), 0).is_none(), "krilla takes OpenType only");
|
||||
|
||||
let advances = BTreeMap::from([(1, 278.0), (36, 667.4)]);
|
||||
let wrapped = wrap_cff(cff, &advances).unwrap();
|
||||
let face = ttf_parser::Face::parse(&wrapped, 0).unwrap();
|
||||
assert_eq!(face.units_per_em(), 1000);
|
||||
assert_eq!(face.number_of_glyphs(), ttf_parser::cff::Table::parse(cff).unwrap().number_of_glyphs());
|
||||
let advance = |id| face.glyph_hor_advance(ttf_parser::GlyphId(id));
|
||||
assert_eq!((advance(1), advance(36), advance(2)), (Some(278), Some(667), Some(0)));
|
||||
|
||||
let font = TextFont { data: Arc::new(wrapped.clone()), index: 0, key: 1 };
|
||||
assert!(embeds(&font, advances.keys().copied()));
|
||||
|
||||
// Another page's glyphs with the same width sum: krilla must not take it for the same font.
|
||||
let other = wrap_cff(cff, &BTreeMap::from([(1, 278.0), (37, 667.4)])).unwrap();
|
||||
assert_ne!(Font::new(Data::from(wrapped), 0), Font::new(Data::from(other), 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_bytes_draw_from_outlines() {
|
||||
let noted = Noted { source: Some((Arc::new(b"%!PS-AdobeFont-1.0".to_vec()), 0)), advances: BTreeMap::from([(1, 500.0)]) };
|
||||
assert!(noted.prepare(7).is_none());
|
||||
let noted = Noted { source: Some((Arc::new(vec![1, 0, 4, 1, 0, 0]), 0)), advances: BTreeMap::new() };
|
||||
assert!(noted.prepare(7).is_none(), "broken CFF");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! The display list the transparency flattener reads and writes.
|
||||
//!
|
||||
//! Coordinates are page space in points, origin at the top left and y down, as krilla draws.
|
||||
//! Opacity, blend modes, soft masks and clips live on [`GroupItem`]s only: a transparent or
|
||||
//! clipped paint arrives as a group with one child. Strokes arrive outlined as fills.
|
||||
//! Overprint stays a per-item attribute; the flattener applies it per plate.
|
||||
|
||||
use std::fmt;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use kurbo;
|
||||
use kurbo::{Affine, BezPath, Point, Rect};
|
||||
|
||||
use crate::ink::{InkColor, InkId, InkSet, Overprint};
|
||||
|
||||
pub use compose::{ComplexGroup, ComplexReason, ComplexRegion, FlattenError, FlattenOptions, FlattenReport, Flattened, flatten};
|
||||
pub use document::{
|
||||
FlattenDocumentError, FlattenDocumentOptions, FlattenDocumentReport, FlattenTotals, Unreadable, flatten_document, flatten_document_with_progress,
|
||||
};
|
||||
pub use geom::FillRule;
|
||||
pub use raster::{FlattenPageError, FlattenPageOptions, FlattenPageReport, flatten_page};
|
||||
|
||||
mod compose;
|
||||
mod document;
|
||||
pub mod font;
|
||||
pub mod geom;
|
||||
pub mod pdf;
|
||||
pub mod raster;
|
||||
mod shade;
|
||||
pub mod write;
|
||||
|
||||
/// One page's content as the flattener sees it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DisplayList {
|
||||
/// The page box in page space.
|
||||
pub page: Rect,
|
||||
/// The inks the items' colors refer to.
|
||||
pub inks: InkSet,
|
||||
/// Items in painting order.
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// A painted element.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Item {
|
||||
Fill(FillItem),
|
||||
Text(TextItem),
|
||||
Image(ImageItem),
|
||||
Group(GroupItem),
|
||||
}
|
||||
|
||||
/// A filled path.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FillItem {
|
||||
pub path: BezPath,
|
||||
pub rule: FillRule,
|
||||
pub paint: Paint,
|
||||
pub overprint: Overprint,
|
||||
}
|
||||
|
||||
/// A run of glyphs from one font at one size and transform, kept as live text when
|
||||
/// written out.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextItem {
|
||||
/// The font to embed again; `None` when the source font can't be re-embedded, and the
|
||||
/// run is then written as `outlines`.
|
||||
pub font: Option<Arc<TextFont>>,
|
||||
/// The em size in text space units.
|
||||
pub size: f32,
|
||||
/// Text space to page space. Text space is y up, as in a PDF text matrix.
|
||||
pub transform: Affine,
|
||||
pub glyphs: Vec<PlacedGlyph>,
|
||||
/// The Unicode text of the run, when the source had it; [`PlacedGlyph::text`] indexes it.
|
||||
pub text: Option<String>,
|
||||
/// The glyphs' filled outlines in page space. Always present: the flattener's geometry for
|
||||
/// the run, and what is drawn when `font` is `None`.
|
||||
pub outlines: BezPath,
|
||||
pub paint: Paint,
|
||||
pub overprint: Overprint,
|
||||
}
|
||||
|
||||
/// A glyph of a [`TextItem`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PlacedGlyph {
|
||||
/// The glyph id in [`TextFont::data`].
|
||||
pub id: u32,
|
||||
/// The glyph origin in text space.
|
||||
pub origin: Point,
|
||||
/// The bytes of [`TextItem::text`] this glyph shows; empty when it shows none.
|
||||
pub text: Range<usize>,
|
||||
}
|
||||
|
||||
/// Font bytes shared without copying, as hayro-interpret and krilla hold them.
|
||||
pub type FontBytes = Arc<dyn AsRef<[u8]> + Send + Sync>;
|
||||
|
||||
/// An OpenType (TrueType or CFF outlines) font that krilla can embed.
|
||||
#[derive(Clone)]
|
||||
pub struct TextFont {
|
||||
pub data: FontBytes,
|
||||
/// The face index in a font collection; 0 for a single font.
|
||||
pub index: u32,
|
||||
/// Identifies the source font: runs with equal keys embed one font, so equal keys mean
|
||||
/// equal bytes.
|
||||
pub key: u128,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TextFont {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("TextFont")
|
||||
.field("bytes", &(*self.data).as_ref().len())
|
||||
.field("index", &self.index)
|
||||
.field("key", &format_args!("{:032x}", self.key))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for TextFont {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.key == other.key && self.index == other.index
|
||||
}
|
||||
}
|
||||
|
||||
/// A sampled image, 8 bits per component, rows top to bottom.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ImageItem {
|
||||
pub space: ImageSpace,
|
||||
/// Width and height in samples.
|
||||
pub size: (u32, u32),
|
||||
/// Interleaved components, `size.0 * size.1 * components` bytes.
|
||||
pub samples: Arc<[u8]>,
|
||||
/// The unit square to page space; (0, 0) is the top left sample.
|
||||
pub transform: Affine,
|
||||
/// Alpha, one byte per sample, at `size`.
|
||||
pub smask: Option<Arc<[u8]>>,
|
||||
pub overprint: Overprint,
|
||||
/// The source's JPEG (DCTDecode) data, when `samples` are its decoding and the writer can
|
||||
/// write it as it is: [`ImageSpace::Gray`] or [`ImageSpace::Cmyk`], no `smask`.
|
||||
pub jpeg: Option<Arc<[u8]>>,
|
||||
}
|
||||
|
||||
/// The components of an [`ImageItem`]'s samples.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ImageSpace {
|
||||
Gray,
|
||||
Rgb,
|
||||
Cmyk,
|
||||
/// Tints of these inks, 255 = 100% ink, in this order.
|
||||
Inks(Vec<InkId>),
|
||||
}
|
||||
|
||||
/// A transparency group, or a plain clip when it is opaque, Normal and unmasked.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GroupItem {
|
||||
pub opacity: f32,
|
||||
pub blend: BlendMode,
|
||||
pub isolated: bool,
|
||||
pub knockout: bool,
|
||||
/// Nonzero clip in page space.
|
||||
pub clip: Option<BezPath>,
|
||||
pub mask: Option<Box<Mask>>,
|
||||
/// The Unicode text the children show, for content drawn as items that is text in the
|
||||
/// source, such as a colored Type 3 glyph.
|
||||
pub actual_text: Option<String>,
|
||||
pub children: Vec<Item>,
|
||||
}
|
||||
|
||||
/// A soft mask (ISO 32000-2 §11.6.5).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Mask {
|
||||
pub kind: MaskKind,
|
||||
pub group: GroupItem,
|
||||
/// The luminosity mask's backdrop (`/BC`), what the group composites over; `None` for an
|
||||
/// alpha mask.
|
||||
pub backdrop: Option<InkColor>,
|
||||
/// The transfer function (`/TR`) sampled at 256 evenly spaced mask values from 0 to 1, as
|
||||
/// bytes; `None` for the identity.
|
||||
pub transfer: Option<Arc<[u8]>>,
|
||||
}
|
||||
|
||||
/// Which value of the mask group masks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MaskKind {
|
||||
Alpha,
|
||||
Luminosity,
|
||||
}
|
||||
|
||||
/// What an item paints with.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Paint {
|
||||
Solid(InkColor),
|
||||
/// Stops are offsets `0.0..=1.0` in increasing order. Function-based and mesh shadings
|
||||
/// arrive as images.
|
||||
Gradient {
|
||||
stops: Vec<(f32, InkColor)>,
|
||||
geometry: GradientGeometry,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where a gradient runs, in gradient space.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GradientGeometry {
|
||||
Axial { start: Point, end: Point, extend: [bool; 2], transform: Affine },
|
||||
Radial { start: Point, start_radius: f64, end: Point, end_radius: f64, extend: [bool; 2], transform: Affine },
|
||||
}
|
||||
|
||||
/// PDF blend modes (ISO 32000-2 §11.3.5).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum BlendMode {
|
||||
#[default]
|
||||
Normal,
|
||||
Multiply,
|
||||
Screen,
|
||||
Overlay,
|
||||
Darken,
|
||||
Lighten,
|
||||
ColorDodge,
|
||||
ColorBurn,
|
||||
HardLight,
|
||||
SoftLight,
|
||||
Difference,
|
||||
Exclusion,
|
||||
Hue,
|
||||
Saturation,
|
||||
Color,
|
||||
Luminosity,
|
||||
}
|
||||
|
||||
impl BlendMode {
|
||||
/// Hue, Saturation, Color and Luminosity; on spot inks they act as Normal
|
||||
/// (ISO 32000-1 §11.3.5.3).
|
||||
pub fn is_non_separable(self) -> bool {
|
||||
matches!(self, Self::Hue | Self::Saturation | Self::Color | Self::Luminosity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Complex regions rasterized as ink images (plan tasks C3 and C4), and [`flatten_page`], which
|
||||
//! flattens one page of a PDF.
|
||||
//!
|
||||
//! A region's image is its items written alone as a small PDF ([`page_pdf`]) and separated with
|
||||
//! [`plates`], overprint on: one plane per ink, so spot inks stay spot inks and overprint applies
|
||||
//! per plate as on press. The planes map to the list's inks by name and come back as an
|
||||
//! [`ImageSpace::Inks`] image, which the writer clips to the region and writes as DeviceN.
|
||||
//!
|
||||
//! Backdrop: the sub-list is [`ComplexRegion::items`], in paint order, and nothing else. Those
|
||||
//! are the owners of every shape covering the region, and a flattened group holds every item
|
||||
//! whose box touches one of its members ([`geom::overlap_groups`](super::geom::overlap_groups)),
|
||||
//! up to its last transparent item. Kept items are either in other groups, so their boxes are
|
||||
//! clear of the region, or painted after it. So no kept item is under a complex region, and the
|
||||
//! items listed are the region's whole backdrop.
|
||||
//!
|
||||
//! Seams:
|
||||
//! - The image is snapped outward to the device pixel grid at the raster resolution (counted
|
||||
//! from the page box's corner) and reaches one more device pixel on every side. The clip stays
|
||||
//! the exact region path, so the image's edge pixels are never seen: no paper shows where the
|
||||
//! clip edge falls between pixel centers, and anti-aliased edges of the image don't exist.
|
||||
//! - Items render unclipped in the sub-list, so pixels along the region edge hold the full color
|
||||
//! of the art on both sides of the edge, not a mix with paper.
|
||||
//! - Neighboring complex regions are merged before rasterizing (`compose`), so their shared
|
||||
//! edges fall inside one image.
|
||||
//! - Regions larger than a plate render in tiles that abut on the device grid and are joined
|
||||
//! into one image before it is written.
|
||||
//!
|
||||
//! Whole groups the region sweep failed on
|
||||
//! ([`ComplexReason::Degenerate`](super::ComplexReason::Degenerate)) are clipped to their
|
||||
//! members' boxes, which hold pixels nothing paints. Their image is opaque like any other: the
|
||||
//! boxes are clear of every other group's items, so the paper is the only backdrop there, and
|
||||
//! the plates of the items over paper print the same as the items would. A soft mask would leave
|
||||
//! transparency on the page, which PDF/X-1a forbids.
|
||||
//!
|
||||
//! Ported from VectorCraft (MIT OR Apache-2.0) at commit c2b15982: `render_raster` in
|
||||
//! `crates/engine/src/cmd/flatten.rs` (the image reaching a pixel past its area). VectorCraft
|
||||
//! renders RGB over white; here the plates give each ink's tint directly.
|
||||
|
||||
use craft_pdf::OpenError;
|
||||
use craft_pdf::interpret::color::CmykConversion;
|
||||
use craft_pdf::write::backend::SerializeSettings;
|
||||
use kurbo::{Affine, Rect, Shape as _};
|
||||
|
||||
use super::document::{FlattenDocumentError, FlattenDocumentOptions, flatten_document};
|
||||
use super::pdf::{ReadError, ReadOptions, ReadReport};
|
||||
use super::write::{WriteError, page_pdf};
|
||||
use super::{
|
||||
BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, FlattenError, FlattenOptions, FlattenReport, GroupItem, ImageItem, ImageSpace, Item,
|
||||
Mask, MaskKind, Paint,
|
||||
};
|
||||
use crate::ink::{Ink, InkColor, InkId, Overprint, Process};
|
||||
use crate::plates::{Fallback, MAX_SPOT_INKS, Options, plates};
|
||||
|
||||
/// Pixels per complex region's image. A larger region rasterizes at a lower resolution;
|
||||
/// [`RasterReport::lowest_dpi`] says how low.
|
||||
pub const MAX_RASTER_PIXELS: u64 = 64 << 20;
|
||||
|
||||
/// Tile side in device pixels: tiles stay below [`crate::MAX_PLATE_PIXELS`], so `plates` renders
|
||||
/// them at the resolution asked for.
|
||||
const TILE: i64 = 1536;
|
||||
|
||||
/// What [`Rasterizer`] made.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct RasterReport {
|
||||
/// Images returned.
|
||||
pub images: usize,
|
||||
/// Device pixels of all images.
|
||||
pub pixels: u64,
|
||||
/// The lowest resolution used, when a region past [`MAX_RASTER_PIXELS`] needed less than
|
||||
/// the one asked for.
|
||||
pub lowest_dpi: Option<f32>,
|
||||
}
|
||||
|
||||
/// Rasterizes the complex regions of a flattened [`DisplayList`]; [`Self::region`] is the
|
||||
/// callback [`write_flattened`](super::write::write_flattened) takes.
|
||||
pub struct Rasterizer<'a> {
|
||||
list: &'a DisplayList,
|
||||
settings: SerializeSettings,
|
||||
printing: Fallback,
|
||||
cms: &'a dyn CmykConversion,
|
||||
dpi: f32,
|
||||
report: RasterReport,
|
||||
}
|
||||
|
||||
impl<'a> Rasterizer<'a> {
|
||||
/// `settings` and `cms` are the output's, and `printing` is its printing condition: the
|
||||
/// sub-lists are written without the output intent, which `plates` would decode and parse
|
||||
/// for every region, and separated with `printing` as the fallback.
|
||||
pub fn new(list: &'a DisplayList, settings: SerializeSettings, printing: Fallback, cms: &'a dyn CmykConversion, dpi: f32) -> Self {
|
||||
let settings = SerializeSettings { output_intent: None, ..settings };
|
||||
Self { list, settings, printing, cms, dpi, report: RasterReport::default() }
|
||||
}
|
||||
|
||||
pub fn report(&self) -> &RasterReport {
|
||||
&self.report
|
||||
}
|
||||
|
||||
/// The image of `region`, or `None` when it prints no ink (nothing is under a complex
|
||||
/// region to knock out).
|
||||
pub fn region(&mut self, region: &ComplexRegion) -> Result<Option<ImageItem>, WriteError> {
|
||||
let page = self.list.page;
|
||||
let bounds = region.path.bounding_box();
|
||||
if !(bounds.is_finite() && page.is_finite() && self.dpi.is_finite() && self.dpi > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut scale = f64::from(self.dpi) / 72.0;
|
||||
let mut grid = Grid::new(bounds, page, scale);
|
||||
let pixels = grid.pixels();
|
||||
if pixels > MAX_RASTER_PIXELS {
|
||||
scale *= (MAX_RASTER_PIXELS as f64 / pixels as f64).sqrt() * 0.999;
|
||||
grid = Grid::new(bounds, page, scale);
|
||||
let dpi = (scale * 72.0) as f32;
|
||||
self.report.lowest_dpi = Some(self.report.lowest_dpi.map_or(dpi, |d| d.min(dpi)));
|
||||
}
|
||||
if grid.width() <= 0 || grid.height() <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let items: Vec<Item> =
|
||||
region.items.iter().filter_map(|&i| self.list.items.get(i)).map(|i| self.alpha_masks(i, &grid)).collect::<Result<_, _>>()?;
|
||||
let planes = self.separate(&items, &grid, true)?;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
let mut used = Vec::new();
|
||||
for ((id, _), plane) in self.list.inks.iter().zip(planes) {
|
||||
if let Some(plane) = plane.filter(|p| p.iter().any(|v| *v > 0)) {
|
||||
ids.push(id);
|
||||
used.push(plane);
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let n = grid.len();
|
||||
let mut samples = Vec::with_capacity(n * used.len());
|
||||
for px in 0..n {
|
||||
samples.extend(used.iter().map(|p| p[px]));
|
||||
}
|
||||
self.report.images += 1;
|
||||
self.report.pixels += n as u64;
|
||||
let r = grid.rect();
|
||||
Ok(Some(ImageItem {
|
||||
space: ImageSpace::Inks(ids),
|
||||
size: (grid.width() as u32, grid.height() as u32),
|
||||
samples: samples.into(),
|
||||
transform: Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]),
|
||||
smask: None,
|
||||
overprint: Overprint::default(),
|
||||
jpeg: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `item` with its luminosity masks, and those of the groups in it, made alpha masks over
|
||||
/// `grid` (see [`Self::luminosity`]).
|
||||
fn alpha_masks(&self, item: &Item, grid: &Grid) -> Result<Item, WriteError> {
|
||||
let Item::Group(g) = item else { return Ok(item.clone()) };
|
||||
let mask = match g.mask.as_deref() {
|
||||
Some(m) if m.kind == MaskKind::Luminosity => Some(Box::new(self.luminosity(m, grid)?)),
|
||||
other => other.cloned().map(Box::new),
|
||||
};
|
||||
let children = g.children.iter().map(|c| self.alpha_masks(c, grid)).collect::<Result<_, _>>()?;
|
||||
Ok(Item::Group(GroupItem {
|
||||
opacity: g.opacity,
|
||||
blend: g.blend,
|
||||
isolated: g.isolated,
|
||||
knockout: g.knockout,
|
||||
clip: g.clip.clone(),
|
||||
mask,
|
||||
actual_text: g.actual_text.clone(),
|
||||
children,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A luminosity mask as an alpha mask: an image whose alpha is the mask's luminosity over
|
||||
/// `grid`, its transfer function applied.
|
||||
///
|
||||
/// The mask's content arrives in inks, and a CMYK blending space has no exact black for the
|
||||
/// renderer: K 100% (DeviceGray black, read as ink) has a luminosity of about 12% through
|
||||
/// the printing condition. So the content is separated and its luminosity computed from the
|
||||
/// inks with the ISO 32000-2 §10.4.2.2 formula, `1 - min(1, 0.3c + 0.59m + 0.11y + k)`,
|
||||
/// spot inks through their CMYK equivalents.
|
||||
fn luminosity(&self, m: &Mask, grid: &Grid) -> Result<Mask, WriteError> {
|
||||
let r = grid.rect();
|
||||
// Where nothing paints the mask is the backdrop's luminosity, black by default.
|
||||
let backdrop = m.backdrop.clone().unwrap_or_else(|| InkColor::Tints(vec![(InkId::process(Process::Black), 1.0)]));
|
||||
let content = [
|
||||
Item::Fill(FillItem { path: r.to_path(0.1), rule: FillRule::NonZero, paint: Paint::Solid(backdrop), overprint: Overprint::default() }),
|
||||
self.alpha_masks(&Item::Group(m.group.clone()), grid)?,
|
||||
];
|
||||
let planes = self.separate(&content, grid, false)?;
|
||||
let weights: Vec<[f32; 4]> = self
|
||||
.list
|
||||
.inks
|
||||
.iter()
|
||||
.map(|(id, _)| match Process::ALL.get(id.index()) {
|
||||
Some(p) => {
|
||||
let mut w = [0.0; 4];
|
||||
w[p.index()] = 1.0;
|
||||
w
|
||||
}
|
||||
None => self.list.inks.spot_cmyk(id, self.cms).unwrap_or([0.0, 0.0, 0.0, 1.0]),
|
||||
})
|
||||
.collect();
|
||||
let used: Vec<(&[u8], [f32; 4])> = planes.iter().zip(&weights).filter_map(|(p, w)| Some((p.as_deref()?, *w))).collect();
|
||||
let alpha: Vec<u8> = (0..grid.len())
|
||||
.map(|px| {
|
||||
let mut cmyk = [0.0_f32; 4];
|
||||
for (plane, w) in &used {
|
||||
let t = f32::from(plane[px]) / 255.0;
|
||||
for (v, w) in cmyk.iter_mut().zip(w) {
|
||||
*v = 1.0 - (1.0 - *v) * (1.0 - t * w);
|
||||
}
|
||||
}
|
||||
let [c, m_, y, k] = cmyk;
|
||||
let gray = 1.0 - (0.3 * c + 0.59 * m_ + 0.11 * y + k).min(1.0);
|
||||
let v = (gray * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
m.transfer.as_ref().and_then(|t| t.get(usize::from(v)).copied()).unwrap_or(v)
|
||||
})
|
||||
.collect();
|
||||
let n = alpha.len();
|
||||
let image = ImageItem {
|
||||
space: ImageSpace::Gray,
|
||||
size: (grid.width() as u32, grid.height() as u32),
|
||||
samples: vec![0; n].into(),
|
||||
transform: Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]),
|
||||
smask: Some(alpha.into()),
|
||||
overprint: Overprint::default(),
|
||||
jpeg: None,
|
||||
};
|
||||
let group = GroupItem {
|
||||
opacity: 1.0,
|
||||
blend: BlendMode::Normal,
|
||||
isolated: false,
|
||||
knockout: false,
|
||||
clip: None,
|
||||
mask: None,
|
||||
actual_text: None,
|
||||
children: vec![Item::Image(image)],
|
||||
};
|
||||
Ok(Mask { kind: MaskKind::Alpha, group, backdrop: None, transfer: None })
|
||||
}
|
||||
|
||||
/// `items` separated over `grid`, tile by tile: per ink of the list, its plane, `None` for
|
||||
/// inks the items don't print.
|
||||
fn separate(&self, items: &[Item], grid: &Grid, overprint: bool) -> Result<Vec<Option<Vec<u8>>>, WriteError> {
|
||||
let (w, h) = (grid.width() as usize, grid.height() as usize);
|
||||
let mut planes: Vec<Option<Vec<u8>>> = vec![None; self.list.inks.len()];
|
||||
let dpi = (grid.scale * 72.0) as f32;
|
||||
// The renderer makes a page at least 1 pt on a side: thinner tiles are rendered at that
|
||||
// size and cropped.
|
||||
let min = grid.scale.ceil() as i64;
|
||||
let mut ty = 0;
|
||||
while ty < grid.height() {
|
||||
let th = TILE.min(grid.height() - ty);
|
||||
let mut tx = 0;
|
||||
while tx < grid.width() {
|
||||
let tw = TILE.min(grid.width() - tx);
|
||||
let (rw, rh) = (tw.max(min), th.max(min));
|
||||
// A quarter pixel more than the tile: the renderer floors the page size.
|
||||
let (x0, y0) = grid.point(grid.x0 + tx, grid.y0 + ty);
|
||||
let extra = 0.25 / grid.scale;
|
||||
let tile = Rect::new(x0, y0, x0 + rw as f64 / grid.scale + extra, y0 + rh as f64 / grid.scale + extra);
|
||||
let sub = DisplayList { page: tile, inks: self.list.inks.clone(), items: items.to_vec() };
|
||||
let pdf = page_pdf(&sub, self.settings.clone(), self.cms)?;
|
||||
let p = plates(&pdf, 0, dpi, &Options { overprint, fallback: self.printing.clone() })
|
||||
.map_err(|e| WriteError::Region(format!("separating a complex region: {e}")))?;
|
||||
if !p.unseparated.is_empty() {
|
||||
let spots = p.inks.iter().filter(|(_, ink)| matches!(ink, Ink::Spot(_))).count();
|
||||
return Err(WriteError::SpotInks { inks: spots + p.unseparated.len(), max: MAX_SPOT_INKS, unseparated: p.unseparated });
|
||||
}
|
||||
if (p.width as i64, p.height as i64) != (rw, rh) {
|
||||
return Err(WriteError::Region(format!("a tile of {rw} × {rh} px separated at {} × {} px", p.width, p.height)));
|
||||
}
|
||||
for (pid, ink) in p.inks.iter() {
|
||||
let Some(film) = p.plane(pid).filter(|f| f.iter().any(|v| *v > 0)) else { continue };
|
||||
let id = self.list.inks.lookup(ink.name()).ok_or_else(|| WriteError::Region(format!("ink {} is not in the list", ink.name())))?;
|
||||
let Some(plane) = planes.get_mut(id.index()) else { continue };
|
||||
let plane = plane.get_or_insert_with(|| vec![0; w * h]);
|
||||
for (row, src) in film.chunks_exact(rw as usize).take(th as usize).enumerate() {
|
||||
let at = (ty as usize + row) * w + tx as usize;
|
||||
if let (Some(dst), Some(src)) = (plane.get_mut(at..at + tw as usize), src.get(..tw as usize)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
tx += tw;
|
||||
}
|
||||
ty += th;
|
||||
}
|
||||
Ok(planes)
|
||||
}
|
||||
}
|
||||
|
||||
/// A region's pixels on the device grid of the page: `x0..x1` and `y0..y1` count device pixels
|
||||
/// from the page box's top left corner.
|
||||
struct Grid {
|
||||
page: Rect,
|
||||
scale: f64,
|
||||
x0: i64,
|
||||
y0: i64,
|
||||
x1: i64,
|
||||
y1: i64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// `bounds` snapped outward and grown by one pixel, within the page's pixels.
|
||||
fn new(bounds: Rect, page: Rect, scale: f64) -> Self {
|
||||
let px = |v: f64| v.clamp(-1e12, 1e12) as i64;
|
||||
let (pw, ph) = (px((page.width() * scale).ceil()), px((page.height() * scale).ceil()));
|
||||
Self {
|
||||
page,
|
||||
scale,
|
||||
x0: px(((bounds.x0 - page.x0) * scale).floor() - 1.0).max(0),
|
||||
y0: px(((bounds.y0 - page.y0) * scale).floor() - 1.0).max(0),
|
||||
x1: px(((bounds.x1 - page.x0) * scale).ceil() + 1.0).min(pw),
|
||||
y1: px(((bounds.y1 - page.y0) * scale).ceil() + 1.0).min(ph),
|
||||
}
|
||||
}
|
||||
|
||||
fn width(&self) -> i64 {
|
||||
self.x1 - self.x0
|
||||
}
|
||||
|
||||
fn height(&self) -> i64 {
|
||||
self.y1 - self.y0
|
||||
}
|
||||
|
||||
fn pixels(&self) -> u64 {
|
||||
(self.width().max(0) as u64).saturating_mul(self.height().max(0) as u64)
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
usize::try_from(self.pixels()).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Page space of device pixel corner (`x`, `y`).
|
||||
fn point(&self, x: i64, y: i64) -> (f64, f64) {
|
||||
(self.page.x0 + x as f64 / self.scale, self.page.y0 + y as f64 / self.scale)
|
||||
}
|
||||
|
||||
fn rect(&self) -> Rect {
|
||||
let (x0, y0) = self.point(self.x0, self.y0);
|
||||
let (x1, y1) = self.point(self.x1, self.y1);
|
||||
Rect::new(x0, y0, x1, y1)
|
||||
}
|
||||
}
|
||||
|
||||
/// How [`flatten_page`] reads and flattens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FlattenPageOptions {
|
||||
pub read: ReadOptions,
|
||||
pub flatten: FlattenOptions,
|
||||
}
|
||||
|
||||
/// What [`flatten_page`] did.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct FlattenPageReport {
|
||||
pub read: ReadReport,
|
||||
pub flatten: FlattenReport,
|
||||
pub raster: RasterReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum FlattenPageError {
|
||||
#[error(transparent)]
|
||||
Open(#[from] OpenError),
|
||||
#[error(transparent)]
|
||||
Read(#[from] ReadError),
|
||||
#[error(transparent)]
|
||||
Flatten(#[from] FlattenError),
|
||||
#[error(transparent)]
|
||||
Write(#[from] WriteError),
|
||||
}
|
||||
|
||||
/// Page `page` (from 0) of `pdf` with its transparency flattened, as a one-page PDF: the
|
||||
/// one-page case of [`flatten_document`].
|
||||
pub fn flatten_page(pdf: &[u8], page: usize, opts: &FlattenPageOptions) -> Result<(Vec<u8>, FlattenPageReport), FlattenPageError> {
|
||||
let doc =
|
||||
FlattenDocumentOptions { read: opts.read.clone(), flatten: opts.flatten, pages: Some(page..page.saturating_add(1)), ..Default::default() };
|
||||
let (bytes, mut report) = flatten_document(pdf, &doc).map_err(|e| match e {
|
||||
FlattenDocumentError::Open(e) => FlattenPageError::Open(e),
|
||||
FlattenDocumentError::PageRange { .. } => FlattenPageError::Read(ReadError::NoPage(page)),
|
||||
FlattenDocumentError::Read { source, .. } => FlattenPageError::Read(source),
|
||||
FlattenDocumentError::Flatten { source, .. } => FlattenPageError::Flatten(source),
|
||||
FlattenDocumentError::Write { source, .. } => FlattenPageError::Write(source),
|
||||
FlattenDocumentError::Serialize(e) => FlattenPageError::Write(WriteError::Pdf(e)),
|
||||
})?;
|
||||
let (_, report) = report.pages.pop().ok_or(FlattenPageError::Read(ReadError::Interpret))?;
|
||||
Ok((bytes, report))
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
//! Raster work for the PDF-in adapter, all in ink values: gradient stops sampled from a shading
|
||||
//! function, shadings rasterized, tiling patterns past the cell cap drawn, stencil masks traced.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_pdf::interpret::color::{CmykSimulation, ColorSpace};
|
||||
use craft_pdf::interpret::pattern::ShadingPattern;
|
||||
use craft_pdf::interpret::shading::{ShadingFunction, ShadingType, Triangle};
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Vec2};
|
||||
|
||||
use super::{FillRule, GradientGeometry, ImageItem, ImageSpace, Item, Paint};
|
||||
use crate::ink::{InkColor, InkId, InkSet, Overprint, Process};
|
||||
|
||||
/// Most a gradient's piecewise-linear stops may differ from the function, per ink.
|
||||
const STOP_TOLERANCE: f32 = 0.5 / 255.0;
|
||||
|
||||
/// The narrowest interval between gradient stops; a discontinuous function gets a pair of stops
|
||||
/// this close.
|
||||
const MIN_STOP_STEP: f32 = 1.0 / 4096.0;
|
||||
|
||||
/// Sub-rows per pixel row when the canvas computes path coverage.
|
||||
const SUBROWS: usize = 4;
|
||||
|
||||
/// Stops `(offset, values)` approximating `eval` on `0..=1` within [`STOP_TOLERANCE`]. `eval`
|
||||
/// returns the same number of values everywhere; a failed evaluation reads as zeros.
|
||||
pub(super) fn sample_stops(len: usize, eval: impl Fn(f32) -> Option<Vec<f32>>) -> Vec<(f32, Vec<f32>)> {
|
||||
let f = |s: f32| {
|
||||
let mut v = eval(s).unwrap_or_default();
|
||||
v.resize(len, 0.0);
|
||||
v
|
||||
};
|
||||
const START: usize = 8;
|
||||
let mut stops = vec![(0.0, f(0.0))];
|
||||
for k in 0..START {
|
||||
let (a, b) = (k as f32 / START as f32, (k + 1) as f32 / START as f32);
|
||||
let fa = stops.last().map(|s| s.1.clone()).unwrap_or_default();
|
||||
refine(a, &fa, b, f(b), &f, &mut stops);
|
||||
}
|
||||
stops
|
||||
}
|
||||
|
||||
fn refine(a: f32, fa: &[f32], b: f32, fb: Vec<f32>, f: &impl Fn(f32) -> Vec<f32>, out: &mut Vec<(f32, Vec<f32>)>) {
|
||||
let m = (a + b) / 2.0;
|
||||
let fm = f(m);
|
||||
let err = |t: f32, ft: &[f32]| {
|
||||
let u = (t - a) / (b - a);
|
||||
fa.iter().zip(&fb).zip(ft).map(|((x, y), v)| (x + (y - x) * u - v).abs()).fold(0.0_f32, f32::max)
|
||||
};
|
||||
let within = err(m, &fm) <= STOP_TOLERANCE
|
||||
&& [0.25, 0.75].iter().all(|q| {
|
||||
let t = a + (b - a) * q;
|
||||
err(t, &f(t)) <= STOP_TOLERANCE
|
||||
});
|
||||
if within || b - a <= MIN_STOP_STEP {
|
||||
out.push((b, fb));
|
||||
} else {
|
||||
refine(a, fa, m, fm.clone(), f, out);
|
||||
refine(m, &fm, b, fb, f, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `function` at `input`.
|
||||
pub(super) fn eval(function: &ShadingFunction, input: &[f32]) -> Option<Vec<f32>> {
|
||||
function.eval(&input.iter().copied().collect()).map(|v| v.to_vec())
|
||||
}
|
||||
|
||||
/// A shading's colors sampled on a pixel grid.
|
||||
pub(super) struct ShadeRaster {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `ink_space.len()` values per pixel.
|
||||
pub values: Vec<f32>,
|
||||
/// 255 where the shading (or its background) paints.
|
||||
pub alpha: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The function-based or mesh shading `sp` over `area` (page space) at `scale` pixels per point,
|
||||
/// with `/Background` under it when `background`. `None` for other shading types.
|
||||
pub(super) fn rasterize_shading(
|
||||
sp: &ShadingPattern,
|
||||
area: Rect,
|
||||
scale: f64,
|
||||
background: bool,
|
||||
simulation: Option<&CmykSimulation>,
|
||||
) -> Option<ShadeRaster> {
|
||||
let (w, h) = pixel_size(area, scale)?;
|
||||
let cs: &ColorSpace = &sp.shading.color_space;
|
||||
let n = cs.components().max(1);
|
||||
let mut comps = vec![0.0_f32; w * h * n];
|
||||
let mut covered = vec![false; w * h];
|
||||
let to_px = Affine::scale(scale) * Affine::translate(-area.origin().to_vec2());
|
||||
match sp.shading.shading_type.as_ref() {
|
||||
ShadingType::FunctionBased { domain, matrix, function } => {
|
||||
let inv = (to_px * sp.matrix * *matrix).inverse();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let r = inv * Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let (rx, ry) = (r.x as f32, r.y as f32);
|
||||
if rx < domain[0] || rx > domain[1] || ry < domain[2] || ry > domain[3] {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = eval(function, &[rx, ry]) {
|
||||
set(&mut comps, &mut covered, n, y * w + x, &c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ShadingType::TriangleMesh { triangles, function } => {
|
||||
let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() };
|
||||
for t in triangles {
|
||||
raster.draw(t, to_px * sp.matrix);
|
||||
}
|
||||
}
|
||||
ShadingType::CoonsPatchMesh { patches, function } => {
|
||||
let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() };
|
||||
let mut buffer = Vec::new();
|
||||
for patch in patches {
|
||||
buffer.clear();
|
||||
patch.to_triangles(&mut buffer);
|
||||
for t in &buffer {
|
||||
raster.draw(t, to_px * sp.matrix);
|
||||
}
|
||||
}
|
||||
}
|
||||
ShadingType::TensorProductPatchMesh { patches, function } => {
|
||||
let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() };
|
||||
let mut buffer = Vec::new();
|
||||
for patch in patches {
|
||||
buffer.clear();
|
||||
patch.to_triangles(&mut buffer);
|
||||
for t in &buffer {
|
||||
raster.draw(t, to_px * sp.matrix);
|
||||
}
|
||||
}
|
||||
}
|
||||
ShadingType::RadialAxial { .. } | ShadingType::Dummy => return None,
|
||||
}
|
||||
if let Some(bg) = sp.shading.background.as_ref().filter(|_| background) {
|
||||
for i in 0..w * h {
|
||||
if !covered[i] {
|
||||
set(&mut comps, &mut covered, n, i, bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only covered pixels are converted.
|
||||
let mut colors = Vec::new();
|
||||
for (i, c) in covered.iter().enumerate() {
|
||||
if *c {
|
||||
colors.extend_from_slice(&comps[i * n..(i + 1) * n]);
|
||||
}
|
||||
}
|
||||
let mut converted = Vec::new();
|
||||
cs.ink_values(&colors, simulation, &mut converted);
|
||||
let len = cs.ink_space(simulation).len();
|
||||
let mut values = vec![0.0_f32; w * h * len];
|
||||
let mut src = converted.chunks_exact(len.max(1));
|
||||
for (i, c) in covered.iter().enumerate() {
|
||||
if *c
|
||||
&& len > 0
|
||||
&& let Some(v) = src.next()
|
||||
{
|
||||
values[i * len..(i + 1) * len].copy_from_slice(v);
|
||||
}
|
||||
}
|
||||
let alpha = covered.iter().map(|c| if *c { 255 } else { 0 }).collect();
|
||||
Some(ShadeRaster { width: w as u32, height: h as u32, values, alpha })
|
||||
}
|
||||
|
||||
fn set(comps: &mut [f32], covered: &mut [bool], n: usize, i: usize, c: &[f32]) {
|
||||
for (k, v) in comps[i * n..(i + 1) * n].iter_mut().enumerate() {
|
||||
*v = c.get(k).copied().unwrap_or(0.0);
|
||||
}
|
||||
covered[i] = true;
|
||||
}
|
||||
|
||||
/// The pixel grid covering `area` at `scale`: its size, or `None` when empty.
|
||||
pub(super) fn pixel_size(area: Rect, scale: f64) -> Option<(usize, usize)> {
|
||||
let (w, h) = ((area.width() * scale).ceil(), (area.height() * scale).ceil());
|
||||
(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0).then_some((w as usize, h as usize))
|
||||
}
|
||||
|
||||
/// Mesh triangles drawn into component buffers, later triangles over earlier ones.
|
||||
struct Triangles<'a> {
|
||||
w: usize,
|
||||
h: usize,
|
||||
n: usize,
|
||||
comps: &'a mut [f32],
|
||||
covered: &'a mut [bool],
|
||||
function: Option<&'a ShadingFunction>,
|
||||
}
|
||||
|
||||
impl Triangles<'_> {
|
||||
fn draw(&mut self, t: &Triangle, to_px: Affine) {
|
||||
let (a, b, c) = (to_px * t.p0.point, to_px * t.p1.point, to_px * t.p2.point);
|
||||
let area2 = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
|
||||
if !area2.is_finite() || area2.abs() < 1e-12 {
|
||||
return;
|
||||
}
|
||||
let bbox = Rect::from_points(a, b).union_pt(c);
|
||||
let x0 = (bbox.x0 - 0.5).ceil().max(0.0) as usize;
|
||||
let y0 = (bbox.y0 - 0.5).ceil().max(0.0) as usize;
|
||||
let x1 = ((bbox.x1 - 0.5).floor() + 1.0).clamp(0.0, self.w as f64) as usize;
|
||||
let y1 = ((bbox.y1 - 0.5).floor() + 1.0).clamp(0.0, self.h as f64) as usize;
|
||||
let mut color = Vec::with_capacity(self.n);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let la = ((b.x - p.x) * (c.y - p.y) - (c.x - p.x) * (b.y - p.y)) / area2;
|
||||
let lb = ((c.x - p.x) * (a.y - p.y) - (a.x - p.x) * (c.y - p.y)) / area2;
|
||||
let lc = 1.0 - la - lb;
|
||||
if la < -1e-9 || lb < -1e-9 || lc < -1e-9 {
|
||||
continue;
|
||||
}
|
||||
color.clear();
|
||||
let (ca, cb, cc) = (&t.p0.colors, &t.p1.colors, &t.p2.colors);
|
||||
for k in 0..ca.len() {
|
||||
let v =
|
||||
la * f64::from(ca[k]) + lb * f64::from(cb.get(k).copied().unwrap_or(0.0)) + lc * f64::from(cc.get(k).copied().unwrap_or(0.0));
|
||||
color.push(v as f32);
|
||||
}
|
||||
let value = match self.function {
|
||||
Some(f) => match eval(f, &color[..color.len().min(1)]) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
},
|
||||
None => color.clone(),
|
||||
};
|
||||
set(self.comps, self.covered, self.n, y * self.w + x, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `items` with every coordinate moved by `by`.
|
||||
pub(super) fn translate(items: &mut [Item], by: Vec2) {
|
||||
let t = Affine::translate(by);
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Fill(f) => {
|
||||
f.path.apply_affine(t);
|
||||
translate_paint(&mut f.paint, t);
|
||||
}
|
||||
Item::Text(text) => {
|
||||
text.transform = t * text.transform;
|
||||
text.outlines.apply_affine(t);
|
||||
translate_paint(&mut text.paint, t);
|
||||
}
|
||||
Item::Image(im) => im.transform = t * im.transform,
|
||||
Item::Group(g) => {
|
||||
if let Some(clip) = g.clip.as_mut() {
|
||||
clip.apply_affine(t);
|
||||
}
|
||||
if let Some(mask) = g.mask.as_mut() {
|
||||
translate(&mut mask.group.children, by);
|
||||
if let Some(clip) = mask.group.clip.as_mut() {
|
||||
clip.apply_affine(t);
|
||||
}
|
||||
}
|
||||
translate(&mut g.children, by);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_paint(paint: &mut Paint, t: Affine) {
|
||||
if let Paint::Gradient { geometry, .. } = paint {
|
||||
match geometry {
|
||||
GradientGeometry::Axial { transform, .. } | GradientGeometry::Radial { transform, .. } => *transform = t * *transform,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The opaque samples (≥ 128) of a `w` × `h` mask as disjoint rectangles in sample space;
|
||||
/// `None` past `cap` rectangles.
|
||||
pub(super) fn trace_stencil(mask: &[u8], w: usize, h: usize, cap: usize) -> Option<Vec<Rect>> {
|
||||
let mut done = Vec::new();
|
||||
let mut open: HashMap<(usize, usize), usize> = HashMap::new();
|
||||
for y in 0..=h {
|
||||
let mut next = HashMap::new();
|
||||
if y < h {
|
||||
let row = mask.get(y * w..(y + 1) * w)?;
|
||||
let mut x = 0;
|
||||
while x < w {
|
||||
if row[x] >= 128 {
|
||||
let start = x;
|
||||
while x < w && row[x] >= 128 {
|
||||
x += 1;
|
||||
}
|
||||
let top = open.remove(&(start, x)).unwrap_or(y);
|
||||
next.insert((start, x), top);
|
||||
} else {
|
||||
x += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((x0, x1), top) in open.drain() {
|
||||
done.push(Rect::new(x0 as f64, top as f64, x1 as f64, y as f64));
|
||||
if done.len() > cap {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
open = next;
|
||||
}
|
||||
Some(done)
|
||||
}
|
||||
|
||||
/// `src` (`sw` × `sh`) resampled bilinearly to `dw` × `dh`.
|
||||
pub(super) fn resample(src: &[u8], (sw, sh): (usize, usize), (dw, dh): (usize, usize)) -> Vec<u8> {
|
||||
let at = |x: usize, y: usize| f64::from(src.get(y.min(sh - 1) * sw + x.min(sw - 1)).copied().unwrap_or(0));
|
||||
let mut out = Vec::with_capacity(dw * dh);
|
||||
for y in 0..dh {
|
||||
let fy = ((y as f64 + 0.5) * sh as f64 / dh as f64 - 0.5).max(0.0);
|
||||
let (y0, ty) = (fy.floor() as usize, fy.fract());
|
||||
for x in 0..dw {
|
||||
let fx = ((x as f64 + 0.5) * sw as f64 / dw as f64 - 0.5).max(0.0);
|
||||
let (x0, tx) = (fx.floor() as usize, fx.fract());
|
||||
let top = at(x0, y0) * (1.0 - tx) + at(x0 + 1, y0) * tx;
|
||||
let bottom = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx;
|
||||
out.push((top * (1.0 - ty) + bottom * ty).round().clamp(0.0, 255.0) as u8);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A raster of every ink in an [`InkSet`] with alpha (values premultiplied), for content drawn
|
||||
/// as an image.
|
||||
/// Transparency groups draw as plain groups: their clip and opacity apply; blend modes and soft
|
||||
/// masks are left out.
|
||||
pub(super) struct Canvas<'a> {
|
||||
w: usize,
|
||||
h: usize,
|
||||
inks: &'a InkSet,
|
||||
n: usize,
|
||||
/// Page space to pixels.
|
||||
to_px: Affine,
|
||||
values: Vec<f32>,
|
||||
alpha: Vec<f32>,
|
||||
}
|
||||
|
||||
impl<'a> Canvas<'a> {
|
||||
pub fn new(area: Rect, scale: f64, inks: &'a InkSet) -> Option<Self> {
|
||||
let (w, h) = pixel_size(area, scale)?;
|
||||
let n = inks.len();
|
||||
let to_px = Affine::scale(scale) * Affine::translate(-area.origin().to_vec2());
|
||||
let pixels = w.checked_mul(h)?;
|
||||
Some(Self { w, h, inks, n, to_px, values: vec![0.0; pixels.checked_mul(n)?], alpha: vec![0.0; pixels] })
|
||||
}
|
||||
|
||||
pub fn size(&self) -> (usize, usize) {
|
||||
(self.w, self.h)
|
||||
}
|
||||
|
||||
/// The unit square to page space, as [`ImageItem::transform`].
|
||||
pub fn image_transform(&self) -> Affine {
|
||||
self.to_px.inverse() * Affine::scale_non_uniform(self.w as f64, self.h as f64)
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, items: &[Item]) {
|
||||
self.draw_clipped(items, None, 1.0);
|
||||
}
|
||||
|
||||
fn draw_clipped(&mut self, items: &[Item], clip: Option<&[f32]>, opacity: f32) {
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Fill(f) => self.fill(&f.path, f.rule, &f.paint, f.overprint, clip, opacity),
|
||||
Item::Text(t) => self.fill(&t.outlines, FillRule::NonZero, &t.paint, t.overprint, clip, opacity),
|
||||
Item::Image(im) => self.image(im, clip, opacity),
|
||||
Item::Group(g) => {
|
||||
let own = g.clip.as_ref().map(|c| {
|
||||
let mut cov = self.coverage(c, FillRule::NonZero);
|
||||
if let Some(outer) = clip {
|
||||
cov.iter_mut().zip(outer).for_each(|(a, b)| *a *= b);
|
||||
}
|
||||
cov
|
||||
});
|
||||
self.draw_clipped(&g.children, own.as_deref().or(clip), opacity * g.opacity.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dense values and the inks a paint sets, as the compositor reads a flat paint.
|
||||
fn dense(&self, color: &InkColor, overprint: Overprint) -> Option<(Vec<f32>, Vec<bool>)> {
|
||||
let n = self.n;
|
||||
let (mut value, mut named) = (vec![0.0; n], vec![false; n]);
|
||||
match color {
|
||||
InkColor::None => return None,
|
||||
InkColor::All(t) => {
|
||||
value.fill(t.clamp(0.0, 1.0));
|
||||
named.fill(true);
|
||||
}
|
||||
InkColor::Tints(tints) => {
|
||||
for &(id, t) in tints {
|
||||
if let (Some(v), Some(k)) = (value.get_mut(id.index()), named.get_mut(id.index())) {
|
||||
*v = t.clamp(0.0, 1.0);
|
||||
*k = true;
|
||||
}
|
||||
}
|
||||
if overprint.nonzero && color.as_cmyk().is_some() {
|
||||
for (k, v) in named.iter_mut().zip(&value).take(4) {
|
||||
*k = *v > 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !overprint.fill {
|
||||
named.fill(true);
|
||||
}
|
||||
Some((value, named))
|
||||
}
|
||||
|
||||
fn put(&mut self, i: usize, cov: f32, value: &[f32], named: &[bool]) {
|
||||
if cov <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let cov = cov.min(1.0);
|
||||
for k in 0..self.n {
|
||||
if named[k] {
|
||||
let v = &mut self.values[i * self.n + k];
|
||||
*v = *v * (1.0 - cov) + value[k] * cov;
|
||||
}
|
||||
}
|
||||
self.alpha[i] += cov * (1.0 - self.alpha[i]);
|
||||
}
|
||||
|
||||
fn fill(&mut self, path: &BezPath, rule: FillRule, paint: &Paint, overprint: Overprint, clip: Option<&[f32]>, opacity: f32) {
|
||||
let cov = self.coverage(path, rule);
|
||||
let factor = |i: usize| cov[i] * clip.map_or(1.0, |c| c[i]) * opacity;
|
||||
match paint {
|
||||
Paint::Solid(color) => {
|
||||
let Some((value, named)) = self.dense(color, overprint) else { return };
|
||||
for i in 0..self.w * self.h {
|
||||
self.put(i, factor(i), &value, &named);
|
||||
}
|
||||
}
|
||||
Paint::Gradient { stops, geometry } => {
|
||||
let dense: Vec<(f32, Vec<f32>, Vec<bool>)> =
|
||||
stops.iter().filter_map(|(s, c)| self.dense(c, overprint).map(|(v, k)| (*s, v, k))).collect();
|
||||
let Some(first) = dense.first() else { return };
|
||||
let mut named = first.2.clone();
|
||||
for d in &dense {
|
||||
named.iter_mut().zip(&d.2).for_each(|(a, b)| *a |= b);
|
||||
}
|
||||
let from_px = self.to_px.inverse();
|
||||
let mut value = vec![0.0; self.n];
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
let i = y * self.w + x;
|
||||
let f = factor(i);
|
||||
if f <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = from_px * Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let Some(s) = gradient_offset(geometry, p) else { continue };
|
||||
interpolate_stops(&dense, s, &mut value);
|
||||
self.put(i, f, &value, &named);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image(&mut self, im: &ImageItem, clip: Option<&[f32]>, opacity: f32) {
|
||||
let (iw, ih) = (im.size.0 as usize, im.size.1 as usize);
|
||||
let inv = (self.to_px * im.transform).inverse();
|
||||
let ids: Vec<InkId> = match &im.space {
|
||||
ImageSpace::Gray => vec![InkId::process(Process::Black)],
|
||||
ImageSpace::Cmyk => Process::ALL.iter().map(|p| InkId::process(*p)).collect(),
|
||||
ImageSpace::Inks(ids) => ids.clone(),
|
||||
ImageSpace::Rgb => return,
|
||||
};
|
||||
let comps = if im.space == ImageSpace::Gray { 1 } else { ids.len() };
|
||||
let mut named = vec![!im.overprint.fill; self.n];
|
||||
for id in &ids {
|
||||
if let Some(k) = named.get_mut(id.index()) {
|
||||
*k = true;
|
||||
}
|
||||
}
|
||||
if im.space == ImageSpace::Gray && !im.overprint.fill {
|
||||
named.fill(true);
|
||||
} else if im.space == ImageSpace::Gray {
|
||||
named.iter_mut().take(4).for_each(|k| *k = true);
|
||||
}
|
||||
let mut value = vec![0.0; self.n];
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
let u = inv * Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
if !(0.0..1.0).contains(&u.x) || !(0.0..1.0).contains(&u.y) {
|
||||
continue;
|
||||
}
|
||||
let (sx, sy) = ((u.x * iw as f64) as usize, (u.y * ih as f64) as usize);
|
||||
let s = sy * iw + sx;
|
||||
let Some(sample) = im.samples.get(s * comps..(s + 1) * comps) else { continue };
|
||||
value.fill(0.0);
|
||||
if im.space == ImageSpace::Gray {
|
||||
value[Process::Black.index()] = 1.0 - f32::from(sample[0]) / 255.0;
|
||||
} else {
|
||||
for (id, v) in ids.iter().zip(sample) {
|
||||
if let Some(slot) = value.get_mut(id.index()) {
|
||||
*slot = f32::from(*v) / 255.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let a = im.smask.as_ref().and_then(|m| m.get(s)).map_or(1.0, |a| f32::from(*a) / 255.0);
|
||||
let i = y * self.w + x;
|
||||
self.put(i, a * clip.map_or(1.0, |c| c[i]) * opacity, &value, &named);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fraction of each pixel `path` covers.
|
||||
fn coverage(&self, path: &BezPath, rule: FillRule) -> Vec<f32> {
|
||||
let mut edges: Vec<(Point, Point)> = Vec::new();
|
||||
let (mut start, mut last) = (Point::ZERO, Point::ZERO);
|
||||
kurbo::flatten(self.to_px * path.clone(), 0.1, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if last != start {
|
||||
edges.push((last, start));
|
||||
}
|
||||
(start, last) = (p, p);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
edges.push((last, p));
|
||||
last = p;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if last != start {
|
||||
edges.push((last, start));
|
||||
}
|
||||
last = start;
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if last != start {
|
||||
edges.push((last, start));
|
||||
}
|
||||
let mut cov = vec![0.0_f32; self.w * self.h];
|
||||
let mut crossings: Vec<(f64, i32)> = Vec::new();
|
||||
for sy in 0..self.h * SUBROWS {
|
||||
let y = (sy as f64 + 0.5) / SUBROWS as f64;
|
||||
crossings.clear();
|
||||
for (a, b) in &edges {
|
||||
let (lo, hi, dir) = if a.y <= b.y { (a, b, 1) } else { (b, a, -1) };
|
||||
if y >= lo.y && y < hi.y {
|
||||
crossings.push((lo.x + (y - lo.y) / (hi.y - lo.y) * (hi.x - lo.x), dir));
|
||||
}
|
||||
}
|
||||
crossings.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let row = sy / SUBROWS;
|
||||
let mut winding = 0;
|
||||
for k in 0..crossings.len() {
|
||||
winding += crossings[k].1;
|
||||
let inside = match rule {
|
||||
FillRule::NonZero => winding != 0,
|
||||
FillRule::EvenOdd => winding % 2 != 0,
|
||||
};
|
||||
if inside && let Some(next) = crossings.get(k + 1) {
|
||||
self.span(&mut cov, row, crossings[k].0, next.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
fn span(&self, cov: &mut [f32], row: usize, xa: f64, xb: f64) {
|
||||
let (xa, xb) = (xa.clamp(0.0, self.w as f64), xb.clamp(0.0, self.w as f64));
|
||||
if xb <= xa {
|
||||
return;
|
||||
}
|
||||
let weight = 1.0 / SUBROWS as f32;
|
||||
let last = (xb.ceil() as usize).min(self.w);
|
||||
for px in xa.floor() as usize..last {
|
||||
let overlap = xb.min(px as f64 + 1.0) - xa.max(px as f64);
|
||||
if overlap > 0.0 {
|
||||
cov[row * self.w + px] += overlap as f32 * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas as an image in every ink of the set, and its alpha when not opaque throughout.
|
||||
pub fn into_image(self, overprint: Overprint) -> ImageItem {
|
||||
let transform = self.image_transform();
|
||||
let ids: Vec<InkId> = self.inks.iter().map(|(id, _)| id).collect();
|
||||
// Values are premultiplied by alpha; samples are not.
|
||||
let n = self.n.max(1);
|
||||
let samples: Vec<u8> = self
|
||||
.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, v)| {
|
||||
let a = self.alpha[k / n];
|
||||
let v = if a > 0.0 { v / a } else { 0.0 };
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
})
|
||||
.collect();
|
||||
let opaque = self.alpha.iter().all(|a| *a >= 1.0 - 1e-4);
|
||||
let smask = (!opaque).then(|| self.alpha.iter().map(|a| (a.clamp(0.0, 1.0) * 255.0).round() as u8).collect());
|
||||
ImageItem {
|
||||
space: ImageSpace::Inks(ids),
|
||||
size: (self.w as u32, self.h as u32),
|
||||
samples: samples.into(),
|
||||
transform,
|
||||
smask,
|
||||
overprint,
|
||||
jpeg: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas sample nearest page point `p`: premultiplied values and alpha.
|
||||
pub fn sample(&self, p: Point) -> Option<(&[f32], f32)> {
|
||||
let q = self.to_px * p;
|
||||
if q.x < 0.0 || q.y < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (q.x as usize, q.y as usize);
|
||||
if x >= self.w || y >= self.h {
|
||||
return None;
|
||||
}
|
||||
let i = y * self.w + x;
|
||||
Some((&self.values[i * self.n..(i + 1) * self.n], self.alpha[i]))
|
||||
}
|
||||
|
||||
/// Composites the premultiplied values `value` with alpha `a` over pixel (`x`, `y`),
|
||||
/// knocking out every ink.
|
||||
pub fn over(&mut self, x: usize, y: usize, value: &[f32], a: f32) {
|
||||
let i = y * self.w + x;
|
||||
for (v, s) in self.values[i * self.n..(i + 1) * self.n].iter_mut().zip(value) {
|
||||
*v = *v * (1.0 - a) + s;
|
||||
}
|
||||
self.alpha[i] += a * (1.0 - self.alpha[i]);
|
||||
}
|
||||
|
||||
/// The page point at the center of pixel (`x`, `y`).
|
||||
pub fn center(&self, x: usize, y: usize) -> Point {
|
||||
self.to_px.inverse() * Point::new(x as f64 + 0.5, y as f64 + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where page point `p` falls on the gradient, `0..=1`; `None` where it paints nothing.
|
||||
fn gradient_offset(geometry: &GradientGeometry, p: Point) -> Option<f64> {
|
||||
let clamp = |s: f64, extend: [bool; 2]| {
|
||||
if s < 0.0 {
|
||||
extend[0].then_some(0.0)
|
||||
} else if s > 1.0 {
|
||||
extend[1].then_some(1.0)
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
};
|
||||
match geometry {
|
||||
GradientGeometry::Axial { start, end, extend, transform } => {
|
||||
let q = transform.inverse() * p;
|
||||
let d = *end - *start;
|
||||
let len2 = d.hypot2();
|
||||
if len2 <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
clamp((q - *start).dot(d) / len2, *extend)
|
||||
}
|
||||
GradientGeometry::Radial { start, start_radius, end, end_radius, extend, transform } => {
|
||||
let q = transform.inverse() * p;
|
||||
let (cd, pd, dr, r0) = (*end - *start, q - *start, end_radius - start_radius, *start_radius);
|
||||
let a = cd.hypot2() - dr * dr;
|
||||
let b = pd.dot(cd) + r0 * dr;
|
||||
let c = pd.hypot2() - r0 * r0;
|
||||
let candidates = if a.abs() < 1e-9 {
|
||||
if b.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
[c / (2.0 * b), f64::NAN]
|
||||
} else {
|
||||
let disc = b * b - a * c;
|
||||
if disc < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let root = disc.sqrt();
|
||||
let (s1, s2) = ((b + root) / a, (b - root) / a);
|
||||
[s1.max(s2), s1.min(s2)]
|
||||
};
|
||||
candidates.into_iter().filter(|s| s.is_finite() && r0 + s * dr >= 0.0).find_map(|s| clamp(s, *extend))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn interpolate_stops(stops: &[(f32, Vec<f32>, Vec<bool>)], s: f64, out: &mut [f32]) {
|
||||
let s = s as f32;
|
||||
let k = stops.iter().position(|st| st.0 >= s).unwrap_or(stops.len() - 1);
|
||||
let (hi, lo) = (&stops[k], &stops[k.saturating_sub(1)]);
|
||||
let u = if hi.0 > lo.0 { ((s - lo.0) / (hi.0 - lo.0)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
for (o, (a, b)) in out.iter_mut().zip(lo.1.iter().zip(&hi.1)) {
|
||||
*o = a + (b - a) * u;
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit square of a raster image placed over `area` at `scale`, as [`ImageItem::transform`].
|
||||
pub(super) fn area_transform(area: Rect, scale: f64, (w, h): (usize, usize)) -> Affine {
|
||||
Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(w as f64 / scale, h as f64 / scale)
|
||||
}
|
||||
|
||||
/// A gradient stop's color from ink values.
|
||||
pub(super) fn stop_color(ids: &StopInks, values: &[f32]) -> InkColor {
|
||||
match ids {
|
||||
StopInks::Gray => {
|
||||
let k = 1.0 - values.first().copied().unwrap_or(1.0);
|
||||
InkColor::Tints(Process::ALL.iter().map(|p| (InkId::process(*p), if *p == Process::Black { k } else { 0.0 })).collect())
|
||||
}
|
||||
StopInks::Inks(ids) => InkColor::Tints(ids.iter().zip(values).map(|(id, v)| (*id, *v)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What ink values mean in a gradient stop.
|
||||
pub(super) enum StopInks {
|
||||
/// A gray level, painted as black.
|
||||
Gray,
|
||||
Inks(Vec<InkId>),
|
||||
}
|
||||
|
||||
/// Where a gradient with `extend` paints, in page space, as a clip for the same gradient
|
||||
/// extended at both ends; `None` when it extends at both. `bounds` is the page area that can
|
||||
/// show. Exact for axial gradients; for radial ones it is the convex hull of the circles the
|
||||
/// gradient sweeps, which is where it paints.
|
||||
pub(super) fn gradient_extent(geometry: &GradientGeometry, extend: [bool; 2], bounds: Rect) -> Option<BezPath> {
|
||||
if extend == [true, true] {
|
||||
return None;
|
||||
}
|
||||
let (transform, path) = match geometry {
|
||||
GradientGeometry::Axial { start, end, transform, .. } => {
|
||||
let inv = transform.inverse();
|
||||
let r = inv.transform_rect_bbox(bounds);
|
||||
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
|
||||
let d = *end - *start;
|
||||
let len = d.hypot();
|
||||
if len.is_nan() || len <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let normal = Vec2::new(-d.y, d.x) / len;
|
||||
let s: Vec<f64> = corners.iter().map(|c| (*c - *start).dot(d) / (len * len)).collect();
|
||||
let t: Vec<f64> = corners.iter().map(|c| (*c - *start).dot(normal)).collect();
|
||||
let (smin, smax) = s.iter().fold((0.0_f64, 1.0_f64), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
let (tmin, tmax) = t.iter().fold((0.0_f64, 0.0_f64), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
let a = if extend[0] { smin - 1.0 } else { 0.0 };
|
||||
let b = if extend[1] { smax + 1.0 } else { 1.0 };
|
||||
let at = |s: f64, t: f64| *start + d * s + normal * t;
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(at(a, tmin - 1.0));
|
||||
path.line_to(at(b, tmin - 1.0));
|
||||
path.line_to(at(b, tmax + 1.0));
|
||||
path.line_to(at(a, tmax + 1.0));
|
||||
path.close_path();
|
||||
(*transform, path)
|
||||
}
|
||||
GradientGeometry::Radial { start, start_radius, end, end_radius, transform, .. } => {
|
||||
let inv = transform.inverse();
|
||||
let r = inv.transform_rect_bbox(bounds);
|
||||
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
|
||||
let circle = |s: f64| (*start + (*end - *start) * s, start_radius + s * (end_radius - start_radius));
|
||||
let dr = end_radius - start_radius;
|
||||
// How far an extended end reaches: to where the radius is 0, or until the circle
|
||||
// holds the visible area.
|
||||
let reach = |from: f64, dir: f64| {
|
||||
if dr * dir < 0.0 {
|
||||
return -start_radius / dr;
|
||||
}
|
||||
let mut s = from + dir;
|
||||
for _ in 0..64 {
|
||||
let (c, radius) = circle(s);
|
||||
if corners.iter().all(|p| (*p - c).hypot() <= radius) {
|
||||
break;
|
||||
}
|
||||
s = from + (s - from) * 2.0;
|
||||
}
|
||||
s
|
||||
};
|
||||
let a = if extend[0] { reach(0.0, -1.0) } else { 0.0 };
|
||||
let b = if extend[1] { reach(1.0, 1.0) } else { 1.0 };
|
||||
(*transform, hull_of_circles(circle(a), circle(b)))
|
||||
}
|
||||
};
|
||||
Some(transform * path)
|
||||
}
|
||||
|
||||
/// The convex hull of two circles, circumscribed by a polygon.
|
||||
fn hull_of_circles((c0, r0): (Point, f64), (c1, r1): (Point, f64)) -> BezPath {
|
||||
const SIDES: usize = 256;
|
||||
let grow = 1.0 / (std::f64::consts::PI / SIDES as f64).cos();
|
||||
let mut points = Vec::with_capacity(2 * SIDES);
|
||||
for (c, r) in [(c0, r0.max(0.0)), (c1, r1.max(0.0))] {
|
||||
for k in 0..SIDES {
|
||||
let a = k as f64 * std::f64::consts::TAU / SIDES as f64;
|
||||
points.push(c + Vec2::new(a.cos(), a.sin()) * r * grow);
|
||||
}
|
||||
}
|
||||
points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
let cross = |o: Point, a: Point, b: Point| (a - o).cross(b - o);
|
||||
let mut hull: Vec<Point> = Vec::new();
|
||||
for pass in 0..2 {
|
||||
let floor = hull.len();
|
||||
let iter: Box<dyn Iterator<Item = &Point>> = if pass == 0 { Box::new(points.iter()) } else { Box::new(points.iter().rev()) };
|
||||
for p in iter {
|
||||
while hull.len() >= floor + 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], *p) <= 0.0 {
|
||||
hull.pop();
|
||||
}
|
||||
hull.push(*p);
|
||||
}
|
||||
hull.pop();
|
||||
}
|
||||
let mut path = BezPath::new();
|
||||
for (k, p) in hull.iter().enumerate() {
|
||||
if k == 0 {
|
||||
path.move_to(*p);
|
||||
} else {
|
||||
path.line_to(*p);
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// The rectangle's corners in page space through `t`, as a closed path.
|
||||
pub(super) fn rect_path(r: Rect, t: Affine) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(t * Point::new(r.x0, r.y0));
|
||||
path.line_to(t * Point::new(r.x1, r.y0));
|
||||
path.line_to(t * Point::new(r.x1, r.y1));
|
||||
path.line_to(t * Point::new(r.x0, r.y1));
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
//! Writes a display list, or a [`Flattened`] page, as PDF content through krilla.
|
||||
//!
|
||||
//! Colors print as [`InkSet::resolve`] makes them (aliases, spots printed as process color)
|
||||
//! and are written by [`to_pdf_color`], with one exception: an overprinting process-only
|
||||
//! color that names fewer than four process inks is written as a DeviceN color of just the
|
||||
//! inks it names. DeviceCMYK would set the unnamed plates to 0 under `OPM 0`, and `OPM 1`
|
||||
//! would also leave alone the plates of named inks at 0; a DeviceN color paints exactly its
|
||||
//! colorants when it overprints (ISO 32000-2 §8.6.7), whatever `OPM` says.
|
||||
//!
|
||||
//! Overprint is written as `op`/`OP`/`OPM` on each item, never as blend modes or knockout
|
||||
//! groups.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::interpret::color::CmykConversion;
|
||||
use craft_pdf::write::backend::blend::{self as kblend, GroupOptions};
|
||||
use craft_pdf::write::backend::color::device_n::{self, Colorant, DeviceNSpace};
|
||||
use craft_pdf::write::backend::color::{Color as PdfColor, RegularColor, cmyk};
|
||||
use craft_pdf::write::backend::configure::PdfVersion;
|
||||
use craft_pdf::write::backend::geom::{Path, PathBuilder, Point as KPoint, Size, Transform};
|
||||
use craft_pdf::write::backend::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
|
||||
use craft_pdf::write::backend::mask::{Mask as KMask, MaskType, TransferFunction};
|
||||
use craft_pdf::write::backend::num::NormalizedF32;
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::backend::paint::{Fill, FillRule as KFillRule, LinearGradient, Paint as KPaint, RadialGradient, SpreadMethod, Stop};
|
||||
use craft_pdf::write::backend::surface::Surface;
|
||||
use craft_pdf::write::backend::tagging::{ContentTag, SpanTag};
|
||||
use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph};
|
||||
use craft_pdf::write::backend::{Data, SerializeSettings};
|
||||
use craft_pdf::write::{self as pdf_write, Metadata, Writer};
|
||||
use kurbo::{Affine, BezPath, PathEl};
|
||||
|
||||
use super::{
|
||||
BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, Flattened, FontBytes, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, Mask,
|
||||
MaskKind, Paint, TextFont, TextItem,
|
||||
};
|
||||
use crate::ink::{Ink, InkColor, InkId, InkSet, Overprint, Process, to_pdf_color};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum WriteError {
|
||||
#[error(transparent)]
|
||||
Pdf(#[from] pdf_write::WriteError),
|
||||
/// An ink isn't in the set or a spot's Lab alternate can't be converted.
|
||||
#[error("color {0:?} can't be written")]
|
||||
Color(InkColor),
|
||||
/// A DeviceN space would have more colorants than the output's PDF version takes
|
||||
/// ([`max_colorants`]).
|
||||
#[error("a DeviceN space of {colorants} colorants; this output takes at most {max}")]
|
||||
Colorants { colorants: usize, max: usize },
|
||||
/// A complex region paints more spot inks than [`plates`](crate::plates::plates) separates;
|
||||
/// `unseparated` are the ones past the limit.
|
||||
#[error("a complex region paints {inks} spot inks; at most {max} are separated, not {}", some_names(.unseparated))]
|
||||
SpotInks { inks: usize, max: usize, unseparated: Vec<String> },
|
||||
#[error("the page box is empty or not finite")]
|
||||
Page,
|
||||
#[error("image: {0}")]
|
||||
Image(&'static str),
|
||||
/// Display-list features the writer doesn't write yet.
|
||||
#[error("not written yet: {0}")]
|
||||
Unsupported(&'static str),
|
||||
/// Raised by a [`write_flattened`] region callback.
|
||||
#[error("complex region: {0}")]
|
||||
Region(String),
|
||||
}
|
||||
|
||||
/// The most colorants a DeviceN space takes at `version`: 8 up to PDF 1.4 (implementation
|
||||
/// limits, PDF 1.4 reference Appendix C), 32 from PDF 1.5.
|
||||
pub fn max_colorants(version: PdfVersion) -> usize {
|
||||
match version {
|
||||
PdfVersion::Pdf14 => 8,
|
||||
_ => DeviceNSpace::MAX_COLORANTS,
|
||||
}
|
||||
}
|
||||
|
||||
fn some_names(names: &[String]) -> String {
|
||||
let mut listed = names.iter().take(3).map(String::as_str).collect::<Vec<_>>().join(", ");
|
||||
if names.len() > 3 {
|
||||
listed.push_str(", …");
|
||||
}
|
||||
listed
|
||||
}
|
||||
|
||||
/// Draws `items` on `surface` in page space, with colors of `inks`, for an output of PDF
|
||||
/// `version`. `cms` converts spot inks' Lab alternates into the printing condition. On error,
|
||||
/// the items before the failing one are drawn and the surface is left balanced.
|
||||
pub fn draw(surface: &mut Surface<'_>, inks: &InkSet, items: &[Item], cms: &dyn CmykConversion, version: PdfVersion) -> Result<(), WriteError> {
|
||||
let mut painter = Painter::new(inks, cms, version);
|
||||
items.iter().try_for_each(|item| painter.item(surface, item))
|
||||
}
|
||||
|
||||
/// Draws `flat.items` in order and, before item `at` of each complex region, the image
|
||||
/// `raster` returns for it, clipped to the region's path and without overprint. `raster`
|
||||
/// returns an [`ImageItem`] whose space is [`ImageSpace::Inks`] of `list.inks` and which
|
||||
/// covers the region, or `None` to leave the region empty. `version` is the output's PDF
|
||||
/// version.
|
||||
pub fn write_flattened(
|
||||
surface: &mut Surface<'_>,
|
||||
list: &DisplayList,
|
||||
flat: &Flattened,
|
||||
cms: &dyn CmykConversion,
|
||||
version: PdfVersion,
|
||||
raster: &mut dyn FnMut(&ComplexRegion) -> Result<Option<ImageItem>, WriteError>,
|
||||
) -> Result<(), WriteError> {
|
||||
Painter::new(&list.inks, cms, version).flattened(surface, flat, raster)
|
||||
}
|
||||
|
||||
/// [`write_flattened`] for one page of a document whose pages share `fonts`.
|
||||
pub fn write_flattened_with_fonts(
|
||||
surface: &mut Surface<'_>,
|
||||
list: &DisplayList,
|
||||
flat: &Flattened,
|
||||
cms: &dyn CmykConversion,
|
||||
version: PdfVersion,
|
||||
fonts: &mut FontMap,
|
||||
raster: &mut dyn FnMut(&ComplexRegion) -> Result<Option<ImageItem>, WriteError>,
|
||||
) -> Result<(), WriteError> {
|
||||
let mut painter = Painter::new(&list.inks, cms, version);
|
||||
painter.shared = Some(fonts);
|
||||
painter.flattened(surface, flat, raster)
|
||||
}
|
||||
|
||||
/// krilla fonts shared by the pages of one document, so that a font used on several pages is
|
||||
/// embedded once. Runs share a font only when their [`TextFont`] bytes are equal: equal keys on
|
||||
/// two pages can name different bytes (per-page widths of a wrapped bare CFF).
|
||||
#[derive(Default)]
|
||||
pub struct FontMap {
|
||||
/// By [`TextFont::key`] and face index: each font program seen, `None` where krilla can't
|
||||
/// read it.
|
||||
fonts: HashMap<(u128, u32), Vec<Shared>>,
|
||||
}
|
||||
|
||||
type Shared = (FontBytes, Option<Font>);
|
||||
|
||||
impl FontMap {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Distinct font programs seen, including those krilla can't read.
|
||||
pub fn len(&self) -> usize {
|
||||
self.fonts.values().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fonts.is_empty()
|
||||
}
|
||||
|
||||
fn get(&mut self, tf: &TextFont) -> Option<Font> {
|
||||
let same = |d: &FontBytes| std::ptr::addr_eq(Arc::as_ptr(d), Arc::as_ptr(&tf.data)) || (**d).as_ref() == (*tf.data).as_ref();
|
||||
let entries = self.fonts.entry((tf.key, tf.index)).or_default();
|
||||
if let Some((_, font)) = entries.iter().find(|(d, _)| same(d)) {
|
||||
return font.clone();
|
||||
}
|
||||
let font = Font::new(Data::from(tf.data.clone()), tf.index);
|
||||
entries.push((tf.data.clone(), font.clone()));
|
||||
font
|
||||
}
|
||||
}
|
||||
|
||||
impl Painter<'_, '_> {
|
||||
fn flattened(
|
||||
&mut self,
|
||||
surface: &mut Surface<'_>,
|
||||
flat: &Flattened,
|
||||
raster: &mut dyn FnMut(&ComplexRegion) -> Result<Option<ImageItem>, WriteError>,
|
||||
) -> Result<(), WriteError> {
|
||||
let mut regions: Vec<&ComplexRegion> = flat.complex.iter().collect();
|
||||
regions.sort_by_key(|r| r.at);
|
||||
let mut regions = regions.into_iter().peekable();
|
||||
for at in 0..=flat.items.len() {
|
||||
while let Some(region) = regions.next_if(|r| r.at <= at || at == flat.items.len()) {
|
||||
let Some(image) = raster(region)? else { continue };
|
||||
let Some(clip) = to_path(®ion.path) else { continue };
|
||||
surface.push_clip_path(&clip, &KFillRule::NonZero);
|
||||
let drawn = self.image(surface, &ImageItem { overprint: Overprint::default(), ..image });
|
||||
surface.pop();
|
||||
drawn?;
|
||||
}
|
||||
if let Some(item) = flat.items.get(at) {
|
||||
self.item(surface, item)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `list` as a one-page PDF with `settings`.
|
||||
pub fn page_pdf(list: &DisplayList, settings: SerializeSettings, cms: &dyn CmykConversion) -> Result<Vec<u8>, WriteError> {
|
||||
let page = list.page;
|
||||
let size = PageSettings::from_wh(page.width() as f32, page.height() as f32).filter(|_| page.is_finite()).ok_or(WriteError::Page)?;
|
||||
let version = settings.configuration.version();
|
||||
pdf_write::guard(|| {
|
||||
let mut writer = Writer::new(settings, Metadata::default());
|
||||
let mut page_builder = writer.document_mut().start_page_with(size);
|
||||
let mut surface = page_builder.surface();
|
||||
let shifted = page.x0 != 0.0 || page.y0 != 0.0;
|
||||
if shifted {
|
||||
surface.push_transform(&Transform::from_translate(-page.x0 as f32, -page.y0 as f32));
|
||||
}
|
||||
let drawn = draw(&mut surface, &list.inks, &list.items, cms, version);
|
||||
if shifted {
|
||||
surface.pop();
|
||||
}
|
||||
surface.finish();
|
||||
page_builder.finish();
|
||||
drawn?;
|
||||
Ok(writer.finish()?)
|
||||
})
|
||||
}
|
||||
|
||||
struct Painter<'a, 'f> {
|
||||
inks: &'a InkSet,
|
||||
cms: &'a dyn CmykConversion,
|
||||
/// [`max_colorants`] of the output.
|
||||
max_colorants: usize,
|
||||
/// The document's fonts, consulted on a miss in `fonts`.
|
||||
shared: Option<&'f mut FontMap>,
|
||||
/// `None` for fonts krilla can't read; their runs are written as outlines. Keyed by
|
||||
/// [`TextFont::key`] for one page only: a font built for another page may have the same
|
||||
/// key and other bytes (per-page widths of a wrapped bare CFF).
|
||||
fonts: HashMap<(u128, u32), Option<Font>>,
|
||||
/// Inside an `/ActualText` span, which can't nest.
|
||||
tagged: bool,
|
||||
}
|
||||
|
||||
impl<'a> Painter<'a, '_> {
|
||||
fn new(inks: &'a InkSet, cms: &'a dyn CmykConversion, version: PdfVersion) -> Self {
|
||||
Self { inks, cms, max_colorants: max_colorants(version), shared: None, fonts: HashMap::new(), tagged: false }
|
||||
}
|
||||
|
||||
/// An error when a DeviceN space of `colorants` is more than the output takes.
|
||||
fn fits(&self, colorants: usize) -> Result<(), WriteError> {
|
||||
if colorants > self.max_colorants {
|
||||
return Err(WriteError::Colorants { colorants, max: self.max_colorants });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn item(&mut self, s: &mut Surface<'_>, item: &Item) -> Result<(), WriteError> {
|
||||
self.item_with_opacity(s, item, 1.0)
|
||||
}
|
||||
|
||||
/// `opacity` is a group's, applied to a fill or text drawn alone in it.
|
||||
fn item_with_opacity(&mut self, s: &mut Surface<'_>, item: &Item, opacity: f32) -> Result<(), WriteError> {
|
||||
match item {
|
||||
Item::Fill(f) => self.fill(s, f, opacity),
|
||||
Item::Text(t) => self.text(s, t, opacity),
|
||||
Item::Image(im) => self.image(s, im),
|
||||
Item::Group(g) => self.group(s, g),
|
||||
}
|
||||
}
|
||||
|
||||
fn fill(&mut self, s: &mut Surface<'_>, f: &FillItem, opacity: f32) -> Result<(), WriteError> {
|
||||
let Some(path) = to_path(&f.path) else { return Ok(()) };
|
||||
let rule = match f.rule {
|
||||
FillRule::NonZero => KFillRule::NonZero,
|
||||
FillRule::EvenOdd => KFillRule::EvenOdd,
|
||||
};
|
||||
let (paint, overprint) = self.paint(&f.paint, f.overprint, Affine::IDENTITY)?;
|
||||
with_overprint(s, overprint, |s| {
|
||||
s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule }));
|
||||
s.draw_path(&path);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn text(&mut self, s: &mut Surface<'_>, t: &TextItem, opacity: f32) -> Result<(), WriteError> {
|
||||
let text = t.text.as_deref().unwrap_or("");
|
||||
let font = t.font.as_deref().and_then(|tf| self.font(tf));
|
||||
let (Some(font), Some(first)) = (font, t.glyphs.first()) else {
|
||||
let Some(path) = to_path(&t.outlines) else { return Ok(()) };
|
||||
let (paint, overprint) = self.paint(&t.paint, t.overprint, Affine::IDENTITY)?;
|
||||
let tag = !self.tagged && t.text.is_some();
|
||||
with_overprint(s, overprint, |s| {
|
||||
if tag {
|
||||
s.start_tagged(ContentTag::Span(SpanTag { actual_text: Some(text), ..SpanTag::empty() }));
|
||||
}
|
||||
s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule: KFillRule::NonZero }));
|
||||
s.draw_path(&path);
|
||||
if tag {
|
||||
s.end_tagged();
|
||||
}
|
||||
});
|
||||
return Ok(());
|
||||
};
|
||||
let size = f64::from(t.size);
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return Ok(());
|
||||
}
|
||||
let glyphs: Vec<KrillaGlyph> = t
|
||||
.glyphs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
let next_x = t.glyphs.get(i + 1).map_or(g.origin.x, |n| n.origin.x);
|
||||
let range = if text.get(g.text.clone()).is_some() { g.text.clone() } else { 0..0 };
|
||||
KrillaGlyph::new(GlyphId::new(g.id), ((next_x - g.origin.x) / size) as f32, 0.0, (g.origin.y / size) as f32, 0.0, range, None)
|
||||
})
|
||||
.collect();
|
||||
// Text space is y up; krilla places glyphs y down.
|
||||
let ctm = t.transform * Affine::scale_non_uniform(1.0, -1.0);
|
||||
let (paint, overprint) = self.paint(&t.paint, t.overprint, ctm)?;
|
||||
with_overprint(s, overprint, |s| {
|
||||
s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule: KFillRule::NonZero }));
|
||||
s.push_transform(&to_transform(ctm));
|
||||
s.draw_glyphs(KPoint::from_xy(first.origin.x as f32, 0.0), &glyphs, font, text, t.size, false);
|
||||
s.pop();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn font(&mut self, tf: &TextFont) -> Option<Font> {
|
||||
if let Some(font) = self.fonts.get(&(tf.key, tf.index)) {
|
||||
return font.clone();
|
||||
}
|
||||
let font = match self.shared.as_deref_mut() {
|
||||
Some(shared) => shared.get(tf),
|
||||
None => Font::new(Data::from(tf.data.clone()), tf.index),
|
||||
};
|
||||
self.fonts.insert((tf.key, tf.index), font.clone());
|
||||
font
|
||||
}
|
||||
|
||||
fn image(&mut self, s: &mut Surface<'_>, im: &ImageItem) -> Result<(), WriteError> {
|
||||
let (w, h) = im.size;
|
||||
let pixels = usize::try_from(u64::from(w) * u64::from(h)).map_err(|_| WriteError::Image("too large"))?;
|
||||
if pixels == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let draw = |s: &mut Surface<'_>, image: Image| {
|
||||
let Some(unit) = Size::from_wh(1.0, 1.0) else { return };
|
||||
with_overprint(s, im.overprint, |s| {
|
||||
s.push_transform(&to_transform(im.transform));
|
||||
s.draw_image(image, unit);
|
||||
s.pop();
|
||||
});
|
||||
};
|
||||
if let Some(image) = passthrough(im) {
|
||||
draw(s, image);
|
||||
return Ok(());
|
||||
}
|
||||
let (space, components, device_n, samples) = match &im.space {
|
||||
ImageSpace::Gray => (ImageColorspace::Luma, 1, None, im.samples.clone()),
|
||||
ImageSpace::Rgb => (ImageColorspace::Rgb, 3, None, im.samples.clone()),
|
||||
ImageSpace::Cmyk => (ImageColorspace::Cmyk, 4, None, im.samples.clone()),
|
||||
ImageSpace::Inks(ids) => {
|
||||
if im.samples.len() != pixels.saturating_mul(ids.len()) {
|
||||
return Err(WriteError::Image("sample count doesn't match the size and inks"));
|
||||
}
|
||||
let (space, samples) = self.ink_samples(ids, &im.samples)?;
|
||||
(ImageColorspace::DeviceN, space.colorants().len(), Some(space), samples)
|
||||
}
|
||||
};
|
||||
if samples.len() != pixels.saturating_mul(components) {
|
||||
return Err(WriteError::Image("sample count doesn't match the size and space"));
|
||||
}
|
||||
if im.smask.as_ref().is_some_and(|a| a.len() != pixels) {
|
||||
return Err(WriteError::Image("smask size doesn't match the image"));
|
||||
}
|
||||
let image = Image::from_custom(Samples { samples, alpha: im.smask.clone(), size: im.size, space, device_n }, false)
|
||||
.map_err(|_| WriteError::Image("krilla rejected it"))?;
|
||||
draw(s, image);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Samples of `ids` as samples of the inks they print on: each ink's tint moves to the
|
||||
/// inks [`InkSet::resolve`] gives a full tint of it, and tints on one ink combine as
|
||||
/// overlapping halftones, as `resolve` combines them.
|
||||
fn ink_samples(&self, ids: &[InkId], samples: &Arc<[u8]>) -> Result<(DeviceNSpace, Arc<[u8]>), WriteError> {
|
||||
let mut weights: Vec<Vec<(InkId, f32)>> = Vec::with_capacity(ids.len());
|
||||
for &id in ids {
|
||||
let full = InkColor::Tints(vec![(id, 1.0)]);
|
||||
match self.inks.resolve(&full, self.cms) {
|
||||
InkColor::Tints(t) => weights.push(t),
|
||||
_ => return Err(WriteError::Color(full)),
|
||||
}
|
||||
}
|
||||
let out: Vec<InkId> = weights.iter().flatten().map(|(id, _)| *id).collect::<BTreeSet<_>>().into_iter().collect();
|
||||
self.fits(out.len())?;
|
||||
let colorants = out
|
||||
.iter()
|
||||
.map(|&id| self.colorant(id).ok_or_else(|| WriteError::Color(InkColor::Tints(vec![(id, 1.0)]))))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let space = DeviceNSpace::new(colorants).ok_or(WriteError::Image("krilla rejected its DeviceN space"))?;
|
||||
let renames = weights.iter().all(|w| matches!(w[..], [(_, t)] if t == 1.0)) && out.len() == ids.len();
|
||||
if renames && weights.iter().zip(&out).all(|(w, o)| w.first().is_some_and(|(id, _)| id == o)) {
|
||||
return Ok((space, samples.clone()));
|
||||
}
|
||||
// Column of each ink's weights in `out`.
|
||||
let routes: Vec<Vec<(usize, f32)>> =
|
||||
weights.iter().map(|w| w.iter().filter_map(|(id, t)| out.iter().position(|o| o == id).map(|i| (i, *t))).collect()).collect();
|
||||
let n = ids.len();
|
||||
let mut result = Vec::with_capacity(samples.len() / n.max(1) * out.len());
|
||||
let mut keep = vec![1.0_f32; out.len()];
|
||||
for px in samples.chunks_exact(n.max(1)) {
|
||||
keep.fill(1.0);
|
||||
for (v, route) in px.iter().zip(&routes) {
|
||||
let v = f32::from(*v) / 255.0;
|
||||
for &(i, t) in route {
|
||||
if let Some(k) = keep.get_mut(i) {
|
||||
*k *= 1.0 - v * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.extend(keep.iter().map(|k| byte(1.0 - k)));
|
||||
}
|
||||
Ok((space, result.into()))
|
||||
}
|
||||
|
||||
fn group(&mut self, s: &mut Surface<'_>, g: &GroupItem) -> Result<(), WriteError> {
|
||||
if g.children.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut pushed = 0;
|
||||
let clip = match &g.clip {
|
||||
Some(clip) => match to_path(clip) {
|
||||
Some(path) => Some(path),
|
||||
// An empty clip shows nothing.
|
||||
None => return Ok(()),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
if let Some(clip) = clip {
|
||||
s.push_clip_path(&clip, &KFillRule::NonZero);
|
||||
pushed += 1;
|
||||
}
|
||||
let tag = match &g.actual_text {
|
||||
Some(text) if !self.tagged => {
|
||||
s.start_tagged(ContentTag::Span(SpanTag { actual_text: Some(text), ..SpanTag::empty() }));
|
||||
self.tagged = true;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let drawn = self.group_content(s, g, &mut pushed);
|
||||
for _ in 0..pushed {
|
||||
s.pop();
|
||||
}
|
||||
if tag {
|
||||
s.end_tagged();
|
||||
self.tagged = false;
|
||||
}
|
||||
drawn
|
||||
}
|
||||
|
||||
/// Pushes the group's blend mode, mask, opacity and attributes, counted in `pushed`, and
|
||||
/// draws the children.
|
||||
fn group_content(&mut self, s: &mut Surface<'_>, g: &GroupItem, pushed: &mut usize) -> Result<(), WriteError> {
|
||||
let opacity = unit(g.opacity);
|
||||
let blended = g.blend != BlendMode::Normal;
|
||||
if blended {
|
||||
s.push_blend_mode(blend_mode(g.blend));
|
||||
*pushed += 1;
|
||||
}
|
||||
// One fill or text run alone in a plain group: opacity and blend mode on the paint
|
||||
// itself composite the same.
|
||||
if let ([child @ (Item::Fill(_) | Item::Text(_))], false, false, None) = (&g.children[..], g.isolated, g.knockout, &g.mask) {
|
||||
return self.item_with_opacity(s, child, opacity);
|
||||
}
|
||||
let mut isolated = false;
|
||||
if let Some(mask) = &g.mask {
|
||||
let mask = self.mask(s, mask, opacity)?;
|
||||
s.push_mask(mask);
|
||||
*pushed += 1;
|
||||
} else if opacity < 1.0 && g.isolated {
|
||||
s.push_opacity(normalized(opacity));
|
||||
*pushed += 1;
|
||||
isolated = true;
|
||||
} else if opacity < 1.0 || (blended && !g.isolated && !g.knockout) {
|
||||
// krilla writes opacity as an isolated group and a group without attributes as a
|
||||
// plain form; a constant soft mask makes a non-isolated transparency group.
|
||||
let mask = constant_mask(s, opacity);
|
||||
s.push_mask(mask);
|
||||
*pushed += 1;
|
||||
}
|
||||
if g.knockout || (g.isolated && !isolated) {
|
||||
s.push_group(GroupOptions { isolated: g.isolated, knockout: g.knockout });
|
||||
*pushed += 1;
|
||||
}
|
||||
g.children.iter().try_for_each(|child| self.item(s, child))
|
||||
}
|
||||
|
||||
/// `m` as a krilla mask, with `opacity` folded into its transfer function.
|
||||
fn mask(&mut self, s: &mut Surface<'_>, m: &Mask, opacity: f32) -> Result<KMask, WriteError> {
|
||||
let mut builder = s.stream_builder();
|
||||
let mut sub = builder.surface();
|
||||
let tagged = std::mem::replace(&mut self.tagged, true);
|
||||
let drawn = self.group(&mut sub, &m.group);
|
||||
self.tagged = tagged;
|
||||
sub.finish();
|
||||
let stream = builder.finish();
|
||||
drawn?;
|
||||
let kind = match m.kind {
|
||||
MaskKind::Alpha => MaskType::Alpha,
|
||||
MaskKind::Luminosity => MaskType::Luminosity,
|
||||
};
|
||||
let mut mask = KMask::new(stream, kind);
|
||||
if let (MaskKind::Luminosity, Some(backdrop)) = (m.kind, &m.backdrop) {
|
||||
// The backdrop is a color of the CMYK blending space, so spots count as process.
|
||||
let mut process = self.inks.clone();
|
||||
process.set_all_to_process(true);
|
||||
let cmyk = process.resolve(backdrop, self.cms).as_cmyk().ok_or_else(|| WriteError::Color(backdrop.clone()))?;
|
||||
let [c, m, y, k] = cmyk.map(byte);
|
||||
mask = mask.with_backdrop(cmyk::Color::new(c, m, y, k));
|
||||
}
|
||||
let transfer = match &m.transfer {
|
||||
Some(samples) => Some(samples.iter().map(|v| f32::from(*v) / 255.0 * opacity).collect()),
|
||||
None if opacity < 1.0 => Some(vec![0.0, opacity]),
|
||||
None => None,
|
||||
};
|
||||
if let Some(transfer) = transfer {
|
||||
mask = mask.with_transfer(TransferFunction::Sampled(transfer.into_iter().map(normalized).collect()));
|
||||
}
|
||||
Ok(mask)
|
||||
}
|
||||
|
||||
/// `paint` as krilla draws it under `ctm` (page space from the drawing space), and the
|
||||
/// overprint to draw it with.
|
||||
fn paint(&self, paint: &Paint, overprint: Overprint, ctm: Affine) -> Result<(KPaint, Overprint), WriteError> {
|
||||
match paint {
|
||||
Paint::Solid(color) => {
|
||||
let (color, overprint) = self.solid(color, overprint)?;
|
||||
Ok((color.into(), overprint))
|
||||
}
|
||||
Paint::Gradient { stops, geometry } => Ok((self.gradient(stops, geometry, overprint, ctm)?, overprint)),
|
||||
}
|
||||
}
|
||||
|
||||
fn solid(&self, color: &InkColor, overprint: Overprint) -> Result<(PdfColor, Overprint), WriteError> {
|
||||
let resolved = self.inks.resolve(color, self.cms);
|
||||
if let InkColor::Tints(tints) = &resolved
|
||||
&& overprint.fill
|
||||
&& let Some(cmyk) = resolved.as_cmyk()
|
||||
{
|
||||
let named: Vec<usize> =
|
||||
(0..4).filter(|&i| tints.iter().any(|(id, _)| id.index() == i) && (!overprint.nonzero || cmyk[i] > 0.0)).collect();
|
||||
if named.len() < 4 {
|
||||
if named.is_empty() {
|
||||
// Zero DeviceCMYK under OPM 1 leaves every plate alone.
|
||||
return Ok((RegularColor::Cmyk(cmyk::Color::new(0, 0, 0, 0)).into(), Overprint { nonzero: true, ..overprint }));
|
||||
}
|
||||
return Ok((self.process_device_n(&named, cmyk).ok_or_else(|| WriteError::Color(color.clone()))?, overprint));
|
||||
}
|
||||
}
|
||||
if let InkColor::Tints(tints) = &resolved {
|
||||
// More inks than the output's limit include a spot, so `to_pdf_color` makes a
|
||||
// DeviceN space of them.
|
||||
self.fits(tints.iter().map(|(id, _)| *id).collect::<BTreeSet<_>>().len())?;
|
||||
}
|
||||
let pdf = to_pdf_color(&resolved, self.inks, self.cms).ok_or_else(|| WriteError::Color(color.clone()))?;
|
||||
Ok((pdf, overprint))
|
||||
}
|
||||
|
||||
/// A DeviceN color of process inks `named` (indices in plate order) at their `cmyk` tints.
|
||||
fn process_device_n(&self, named: &[usize], cmyk: [f32; 4]) -> Option<PdfColor> {
|
||||
let colorants = named.iter().map(|&i| Process::ALL.get(i).map(|p| process_colorant(*p))).collect::<Option<Vec<_>>>()?;
|
||||
let tints = named.iter().map(|&i| cmyk.get(i).copied().map(byte)).collect::<Option<Vec<_>>>()?;
|
||||
Some(device_n::Color::new(tints, DeviceNSpace::new(colorants)?)?.into())
|
||||
}
|
||||
|
||||
/// Stops in one color space: each lists every ink some stop lists, so krilla writes them
|
||||
/// all in the space a color of those inks gets.
|
||||
fn gradient(&self, stops: &[(f32, InkColor)], geometry: &GradientGeometry, overprint: Overprint, ctm: Affine) -> Result<KPaint, WriteError> {
|
||||
let mut resolved: Vec<(f32, BTreeMap<InkId, f32>)> = Vec::with_capacity(stops.len());
|
||||
for (offset, color) in stops {
|
||||
let InkColor::Tints(tints) = self.inks.resolve(color, self.cms) else {
|
||||
return Err(WriteError::Unsupported("a gradient stop of the All or None colorant"));
|
||||
};
|
||||
let mut merged = BTreeMap::new();
|
||||
for (id, t) in tints {
|
||||
let v = merged.entry(id).or_insert(0.0);
|
||||
*v = 1.0 - (1.0 - *v) * (1.0 - unit(t));
|
||||
}
|
||||
resolved.push((*offset, merged));
|
||||
}
|
||||
let all: BTreeSet<InkId> = resolved.iter().flat_map(|(_, m)| m.keys().copied()).collect();
|
||||
self.fits(all.len())?;
|
||||
let partial_process = overprint.fill && all.len() < 4 && all.iter().all(|id| id.index() < 4);
|
||||
let mut krilla_stops = Vec::with_capacity(resolved.len());
|
||||
for ((offset, merged), (_, original)) in resolved.iter().zip(stops) {
|
||||
let tint = |id: &InkId| merged.get(id).copied().unwrap_or(0.0);
|
||||
let color = if partial_process {
|
||||
let named: Vec<usize> = all.iter().map(|id| id.index()).collect();
|
||||
let mut cmyk = [0.0; 4];
|
||||
for id in &all {
|
||||
if let Some(c) = cmyk.get_mut(id.index()) {
|
||||
*c = tint(id);
|
||||
}
|
||||
}
|
||||
self.process_device_n(&named, cmyk)
|
||||
} else {
|
||||
to_pdf_color(&InkColor::Tints(all.iter().map(|id| (*id, tint(id))).collect()), self.inks, self.cms)
|
||||
};
|
||||
let color = color.ok_or_else(|| WriteError::Color(original.clone()))?;
|
||||
krilla_stops.push(Stop { offset: normalized(*offset), color, opacity: NormalizedF32::ONE });
|
||||
}
|
||||
let to_drawing = |t: &Affine| to_transform(ctm.inverse() * *t);
|
||||
Ok(match geometry {
|
||||
GradientGeometry::Axial { start, end, extend: [true, true], transform } => LinearGradient {
|
||||
x1: start.x as f32,
|
||||
y1: start.y as f32,
|
||||
x2: end.x as f32,
|
||||
y2: end.y as f32,
|
||||
transform: to_drawing(transform),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops: krilla_stops,
|
||||
anti_alias: false,
|
||||
}
|
||||
.into(),
|
||||
GradientGeometry::Radial { start, start_radius, end, end_radius, extend: [true, true], transform } => RadialGradient {
|
||||
fx: start.x as f32,
|
||||
fy: start.y as f32,
|
||||
fr: *start_radius as f32,
|
||||
cx: end.x as f32,
|
||||
cy: end.y as f32,
|
||||
cr: *end_radius as f32,
|
||||
transform: to_drawing(transform),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops: krilla_stops,
|
||||
anti_alias: false,
|
||||
}
|
||||
.into(),
|
||||
_ => return Err(WriteError::Unsupported("a gradient that doesn't extend at both ends")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ink `id` as a DeviceN colorant; a spot carries its CMYK alternate.
|
||||
fn colorant(&self, id: InkId) -> Option<Colorant> {
|
||||
match self.inks.get(id)? {
|
||||
Ink::Process(p) => Some(process_colorant(*p)),
|
||||
Ink::Spot(spot) => {
|
||||
let [c, m, y, k] = self.inks.spot_cmyk(id, self.cms)?.map(byte);
|
||||
(!spot.name.is_empty()).then(|| Colorant::Spot { name: spot.name.clone(), alternate: cmyk::Color::new(c, m, y, k) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A soft mask of `opacity` everywhere: an empty luminosity group whose transfer function
|
||||
/// maps every value to `opacity`.
|
||||
fn constant_mask(s: &mut Surface<'_>, opacity: f32) -> KMask {
|
||||
let builder = s.stream_builder();
|
||||
let stream = builder.finish();
|
||||
let o = normalized(opacity);
|
||||
KMask::new(stream, MaskType::Luminosity).with_transfer(TransferFunction::Sampled(vec![o, o]))
|
||||
}
|
||||
|
||||
fn with_overprint(s: &mut Surface<'_>, overprint: Overprint, draw: impl FnOnce(&mut Surface<'_>)) {
|
||||
if overprint == Overprint::default() {
|
||||
draw(s);
|
||||
return;
|
||||
}
|
||||
s.push_overprint(kblend::Overprint { fill: overprint.fill, stroke: overprint.stroke, nonzero: overprint.nonzero });
|
||||
draw(s);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
fn process_colorant(p: Process) -> Colorant {
|
||||
Colorant::Process(match p {
|
||||
Process::Cyan => device_n::Process::Cyan,
|
||||
Process::Magenta => device_n::Process::Magenta,
|
||||
Process::Yellow => device_n::Process::Yellow,
|
||||
Process::Black => device_n::Process::Black,
|
||||
})
|
||||
}
|
||||
|
||||
fn blend_mode(b: BlendMode) -> kblend::BlendMode {
|
||||
use kblend::BlendMode as K;
|
||||
match b {
|
||||
BlendMode::Normal => K::Normal,
|
||||
BlendMode::Multiply => K::Multiply,
|
||||
BlendMode::Screen => K::Screen,
|
||||
BlendMode::Overlay => K::Overlay,
|
||||
BlendMode::Darken => K::Darken,
|
||||
BlendMode::Lighten => K::Lighten,
|
||||
BlendMode::ColorDodge => K::ColorDodge,
|
||||
BlendMode::ColorBurn => K::ColorBurn,
|
||||
BlendMode::HardLight => K::HardLight,
|
||||
BlendMode::SoftLight => K::SoftLight,
|
||||
BlendMode::Difference => K::Difference,
|
||||
BlendMode::Exclusion => K::Exclusion,
|
||||
BlendMode::Hue => K::Hue,
|
||||
BlendMode::Saturation => K::Saturation,
|
||||
BlendMode::Color => K::Color,
|
||||
BlendMode::Luminosity => K::Luminosity,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_transform(a: Affine) -> Transform {
|
||||
let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v as f32);
|
||||
Transform::from_row(a, b, c, d, e, f)
|
||||
}
|
||||
|
||||
/// `None` for an empty path, which paints nothing.
|
||||
fn to_path(path: &BezPath) -> Option<Path> {
|
||||
let mut b = PathBuilder::new();
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32),
|
||||
PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32),
|
||||
PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32),
|
||||
PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32),
|
||||
PathEl::ClosePath => b.close(),
|
||||
}
|
||||
}
|
||||
b.finish()
|
||||
}
|
||||
|
||||
fn unit(v: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn normalized(v: f32) -> NormalizedF32 {
|
||||
NormalizedF32::new(unit(v)).unwrap_or(NormalizedF32::ONE)
|
||||
}
|
||||
|
||||
fn byte(v: f32) -> u8 {
|
||||
(unit(v) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// Image samples for krilla.
|
||||
#[derive(Clone)]
|
||||
struct Samples {
|
||||
samples: Arc<[u8]>,
|
||||
alpha: Option<Arc<[u8]>>,
|
||||
size: (u32, u32),
|
||||
space: ImageColorspace,
|
||||
device_n: Option<DeviceNSpace>,
|
||||
}
|
||||
|
||||
impl Hash for Samples {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.samples.hash(state);
|
||||
self.alpha.hash(state);
|
||||
self.size.hash(state);
|
||||
self.space.hash(state);
|
||||
self.device_n.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomImage for Samples {
|
||||
fn color_channel(&self) -> &[u8] {
|
||||
&self.samples
|
||||
}
|
||||
fn alpha_channel(&self) -> Option<&[u8]> {
|
||||
self.alpha.as_deref()
|
||||
}
|
||||
fn bits_per_component(&self) -> BitsPerComponent {
|
||||
BitsPerComponent::Eight
|
||||
}
|
||||
fn size(&self) -> (u32, u32) {
|
||||
self.size
|
||||
}
|
||||
fn icc_profile(&self) -> Option<&[u8]> {
|
||||
None
|
||||
}
|
||||
fn color_space(&self) -> ImageColorspace {
|
||||
self.space
|
||||
}
|
||||
fn device_n_space(&self) -> Option<DeviceNSpace> {
|
||||
self.device_n.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// `im`'s JPEG data as a krilla image, written as it is (DCTDecode), when it has one that
|
||||
/// decodes to its size. The embedded ICC profile is left out, as the samples path leaves it.
|
||||
fn passthrough(im: &ImageItem) -> Option<Image> {
|
||||
if im.smask.is_some() || !matches!(im.space, ImageSpace::Gray | ImageSpace::Cmyk) {
|
||||
return None;
|
||||
}
|
||||
let data: Arc<dyn AsRef<[u8]> + Send + Sync> = Arc::new(im.jpeg.clone()?);
|
||||
let image = Image::from_jpeg_with_icc(Data::from(data), None, false).ok()?;
|
||||
(image.size() == im.size).then_some(image)
|
||||
}
|
||||
@@ -99,7 +99,7 @@ impl InkId {
|
||||
}
|
||||
|
||||
/// The id of process ink `p`, the same in every [`InkSet`].
|
||||
pub fn process(p: Process) -> Self {
|
||||
pub const fn process(p: Process) -> Self {
|
||||
Self(p as u16)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
pub mod cms;
|
||||
pub mod composite;
|
||||
pub mod coverage;
|
||||
#[cfg(feature = "flatten")]
|
||||
pub mod flatten;
|
||||
pub mod ink;
|
||||
pub mod plates;
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ fn plate_scale(w: f32, h: f32, scale: f32) -> f32 {
|
||||
if s.is_finite() && s > 0.0 { s } else { f32::MIN_POSITIVE }
|
||||
}
|
||||
|
||||
fn fallback(fallback: &Fallback) -> Result<(Option<CmykSimulation>, Conversion), Error> {
|
||||
pub(crate) fn fallback(fallback: &Fallback) -> Result<(Option<CmykSimulation>, Conversion), Error> {
|
||||
let (conversion, icc): (CmsConversion, &[u8]) = match fallback {
|
||||
Fallback::DeviceFormulas => return Ok((None, Conversion::DeviceFormulas)),
|
||||
Fallback::Fogra39 => (CmsConversion::fogra39(), craft_cms::builtin::COATED_FOGRA39_ICC),
|
||||
@@ -264,7 +264,7 @@ fn text_entry(dict: &Dict<'_>, key: &[u8]) -> Option<String> {
|
||||
|
||||
/// A PDF text string (ISO 32000-2 §7.9.2.2): UTF-16BE or UTF-8 after its byte order mark, else
|
||||
/// PDFDocEncoding, read here as Latin-1 (they differ only in rarely used punctuation).
|
||||
fn pdf_text(bytes: &[u8]) -> String {
|
||||
pub(crate) fn pdf_text(bytes: &[u8]) -> String {
|
||||
if let Some(utf16) = bytes.strip_prefix(&[0xFE, 0xFF]) {
|
||||
let units: Vec<u16> = utf16.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
|
||||
return String::from_utf16_lossy(&units);
|
||||
@@ -278,18 +278,8 @@ fn pdf_text(bytes: &[u8]) -> String {
|
||||
/// The printing condition of page `page` (ISO 32000-2 §14.11.5): of the page's own
|
||||
/// `/OutputIntents` (PDF 2.0), else the catalog's, the first whose `/DestOutputProfile` is a
|
||||
/// CMYK profile craft-cms converts into, a `GTS_PDFX` intent before the others.
|
||||
fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> {
|
||||
fn list<'a>(intents: Option<Array<'a>>) -> Vec<Dict<'a>> {
|
||||
intents.iter().flat_map(|a| a.iter::<Dict<'a>>()).take(MAX_OUTPUT_INTENTS).collect()
|
||||
}
|
||||
let xref = pdf.xref();
|
||||
let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::<Array<'_>>(OUTPUT_INTENTS)));
|
||||
if listed.is_empty() {
|
||||
listed = list(xref.get::<Dict<'_>>(xref.root_id()).and_then(|catalog| catalog.get::<Array<'_>>(OUTPUT_INTENTS)));
|
||||
}
|
||||
// A stable sort: the PDF/X intent first, the others in their order.
|
||||
listed.sort_by_key(|d| d.get::<Name<'_>>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX"));
|
||||
listed.iter().find_map(|intent| {
|
||||
pub(crate) fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> {
|
||||
output_intents(pdf, page).iter().find_map(|intent| {
|
||||
let icc = intent.get::<Stream<'_>>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?;
|
||||
let simulation = CmsConversion::new(&icc).ok()?.simulation(&icc);
|
||||
let condition = text_entry(intent, OUTPUT_CONDITION_IDENTIFIER);
|
||||
@@ -303,6 +293,50 @@ fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)>
|
||||
})
|
||||
}
|
||||
|
||||
/// The output intent [`output_intent`] picks, as written in the PDF.
|
||||
#[cfg(feature = "flatten")]
|
||||
pub(crate) struct IntentProfile {
|
||||
pub icc: Vec<u8>,
|
||||
pub identifier: Option<String>,
|
||||
pub condition: Option<String>,
|
||||
pub registry_name: Option<String>,
|
||||
pub info: Option<String>,
|
||||
}
|
||||
|
||||
/// The output intent [`output_intent`] picks: its profile bytes and entries, to write the same
|
||||
/// intent again.
|
||||
#[cfg(feature = "flatten")]
|
||||
pub(crate) fn output_intent_profile(pdf: &Pdf, page: usize) -> Option<IntentProfile> {
|
||||
output_intents(pdf, page).iter().find_map(|intent| {
|
||||
let icc = intent.get::<Stream<'_>>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?;
|
||||
if *icc != *craft_cms::builtin::COATED_FOGRA39_ICC && CmsConversion::new(&icc).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(IntentProfile {
|
||||
icc: icc.into_owned(),
|
||||
identifier: text_entry(intent, OUTPUT_CONDITION_IDENTIFIER),
|
||||
condition: text_entry(intent, OUTPUT_CONDITION),
|
||||
registry_name: text_entry(intent, b"RegistryName"),
|
||||
info: text_entry(intent, b"Info"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The page's own `/OutputIntents` (PDF 2.0), else the catalog's, a `GTS_PDFX` intent first.
|
||||
fn output_intents(pdf: &Pdf, page: usize) -> Vec<Dict<'_>> {
|
||||
fn list<'a>(intents: Option<Array<'a>>) -> Vec<Dict<'a>> {
|
||||
intents.iter().flat_map(|a| a.iter::<Dict<'a>>()).take(MAX_OUTPUT_INTENTS).collect()
|
||||
}
|
||||
let xref = pdf.xref();
|
||||
let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::<Array<'_>>(OUTPUT_INTENTS)));
|
||||
if listed.is_empty() {
|
||||
listed = list(xref.get::<Dict<'_>>(xref.root_id()).and_then(|catalog| catalog.get::<Array<'_>>(OUTPUT_INTENTS)));
|
||||
}
|
||||
// A stable sort: the PDF/X intent first, the others in their order.
|
||||
listed.sort_by_key(|d| d.get::<Name<'_>>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX"));
|
||||
listed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
After Width: | Height: | Size: 297 B |
|
After Width: | Height: | Size: 162 B |
@@ -0,0 +1,379 @@
|
||||
//! Whole documents flattened: pages, boxes, metadata, output intent and shared fonts carry over,
|
||||
//! and each page's plates match the source.
|
||||
//!
|
||||
//! The text cases need Inter from `CRAFT_PDF_TEST_FONT_DIR` or `~/.cache/craft-pdf-test-fonts`,
|
||||
//! and are skipped when it is missing.
|
||||
#![cfg(feature = "flatten")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use craft_pdf::syntax::object::dict::keys::{BLEED_BOX, DEST_OUTPUT_PROFILE, OUTPUT_CONDITION_IDENTIFIER, OUTPUT_INTENTS, TRIM_BOX};
|
||||
use craft_pdf::syntax::object::{Array, Dict, Rect as PdfRect, Stream, String as PdfString};
|
||||
use craft_pdf::write::backend;
|
||||
use craft_pdf::write::backend::color::cmyk;
|
||||
use craft_pdf::write::backend::color::device_n::{self, Colorant, DeviceNSpace, Process};
|
||||
use craft_pdf::write::backend::configure::PdfVersion;
|
||||
use craft_pdf::write::backend::geom::{PathBuilder, Point, Rect as KRect};
|
||||
use craft_pdf::write::backend::metadata::DateTime;
|
||||
use craft_pdf::write::backend::num::NormalizedF32;
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::backend::paint::Fill;
|
||||
use craft_pdf::write::backend::surface::Surface;
|
||||
use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph};
|
||||
use craft_pdf::write::backend::{GroupColorSpace, SerializeSettings};
|
||||
use craft_pdf::write::print::{self, PrintColor};
|
||||
use craft_pdf::write::{Metadata, Writer};
|
||||
use craft_plate_test::reference;
|
||||
use craft_plate_test::structure;
|
||||
use craft_prepress::flatten::{
|
||||
FlattenDocumentOptions, FlattenOptions, FlattenPageOptions, flatten_document, flatten_document_with_progress, flatten_page,
|
||||
};
|
||||
use craft_prepress::{Options, Plates, plates};
|
||||
|
||||
const DPI: f32 = 36.0;
|
||||
const SIZES: [(f32, f32); 3] = [(200.0, 160.0), (150.0, 200.0), (220.0, 120.0)];
|
||||
|
||||
fn inter() -> Option<Vec<u8>> {
|
||||
let dir = std::env::var_os(reference::FONT_DIR_ENV)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts")))?;
|
||||
let font = std::fs::read(dir.join(reference::FONT_FILE)).ok();
|
||||
if font.is_none() {
|
||||
eprintln!("skipped: no {} in {}", reference::FONT_FILE, dir.display());
|
||||
}
|
||||
font
|
||||
}
|
||||
|
||||
fn fill(c: u8, m: u8, y: u8, k: u8) -> Fill {
|
||||
Fill { paint: cmyk::Color::new(c, m, y, k).into(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn rect(s: &mut Surface<'_>, x0: f32, y0: f32, x1: f32, y1: f32, f: Fill) {
|
||||
let mut b = PathBuilder::new();
|
||||
b.push_rect(tiny_rect(x0, y0, x1, y1));
|
||||
s.set_fill(Some(f));
|
||||
s.draw_path(&b.finish().unwrap());
|
||||
}
|
||||
|
||||
fn tiny_rect(x0: f32, y0: f32, x1: f32, y1: f32) -> craft_pdf::write::backend::geom::Rect {
|
||||
KRect::from_ltrb(x0, y0, x1, y1).unwrap()
|
||||
}
|
||||
|
||||
fn text(s: &mut Surface<'_>, font: &Font, face: &ttf_parser::Face<'_>, at: (f32, f32), size: f32, string: &str) {
|
||||
let upem = f32::from(face.units_per_em());
|
||||
let glyphs: Vec<KrillaGlyph> = string
|
||||
.char_indices()
|
||||
.map(|(i, c)| {
|
||||
let id = face.glyph_index(c).unwrap();
|
||||
let advance = f32::from(face.glyph_hor_advance(id).unwrap()) / upem;
|
||||
KrillaGlyph::new(GlyphId::new(u32::from(id.0)), advance, 0.0, 0.0, 0.0, i..i + c.len_utf8(), None)
|
||||
})
|
||||
.collect();
|
||||
s.set_fill(Some(fill(0, 0, 0, 255)));
|
||||
s.draw_glyphs(Point::from_xy(at.0, at.1), &glyphs, font.clone(), string, size, false);
|
||||
}
|
||||
|
||||
/// Three pages of different sizes: transparency beside text, no transparency, and transparency
|
||||
/// over text in page 1's font. Page 1 has a TrimBox and a BleedBox.
|
||||
fn three_pages(font_bytes: &[u8]) -> Vec<u8> {
|
||||
let face = ttf_parser::Face::parse(font_bytes, 0).unwrap();
|
||||
let font = craft_pdf::write::font(font_bytes, 0, None).unwrap();
|
||||
let metadata = Metadata {
|
||||
title: Some("Flatten test".into()),
|
||||
authors: vec!["A. Writer".into()],
|
||||
created: Some(DateTime::new(2026).month(10).day(20).hour(9).minute(30).second(0).utc_offset_hour(2)),
|
||||
modified: Some(DateTime::new(2026).month(10).day(21).hour(16).minute(5).second(0).utc_offset_hour(2)),
|
||||
..Metadata::default()
|
||||
};
|
||||
// DeviceCMYK blending and no output intent: parsing a profile takes half a second in a debug build.
|
||||
let settings = SerializeSettings { no_device_cs: false, group_color_space: GroupColorSpace::Cmyk, ..SerializeSettings::default() };
|
||||
let mut writer = Writer::new(settings, metadata);
|
||||
let mut page = |settings: PageSettings, draw: &dyn Fn(&mut Surface<'_>)| {
|
||||
let mut page = writer.document_mut().start_page_with(settings);
|
||||
let mut s = page.surface();
|
||||
draw(&mut s);
|
||||
s.finish();
|
||||
page.finish();
|
||||
};
|
||||
let size = |i: usize| PageSettings::from_wh(SIZES[i].0, SIZES[i].1).unwrap();
|
||||
page(size(0).with_trim_box(Some(tiny_rect(10.0, 10.0, 190.0, 150.0))).with_bleed_box(Some(tiny_rect(5.0, 5.0, 195.0, 155.0))), &|s| {
|
||||
rect(s, 20.0, 20.0, 120.0, 100.0, fill(255, 0, 0, 0));
|
||||
s.push_opacity(NormalizedF32::new(0.5).unwrap());
|
||||
rect(s, 70.0, 60.0, 170.0, 130.0, fill(0, 255, 0, 0));
|
||||
s.pop();
|
||||
text(s, &font, &face, (10.0, 150.0), 10.0, "Hello");
|
||||
});
|
||||
page(size(1), &|s| {
|
||||
rect(s, 10.0, 10.0, 140.0, 90.0, fill(0, 0, 255, 0));
|
||||
text(s, &font, &face, (10.0, 150.0), 14.0, "Opaque");
|
||||
});
|
||||
page(size(2), &|s| {
|
||||
text(s, &font, &face, (20.0, 60.0), 30.0, "Hello");
|
||||
s.push_opacity(NormalizedF32::new(0.4).unwrap());
|
||||
rect(s, 40.0, 25.0, 120.0, 80.0, fill(255, 0, 0, 0));
|
||||
s.pop();
|
||||
text(s, &font, &face, (20.0, 110.0), 12.0, "World");
|
||||
});
|
||||
drop(page);
|
||||
writer.finish().unwrap()
|
||||
}
|
||||
|
||||
fn opts() -> FlattenDocumentOptions {
|
||||
FlattenDocumentOptions { flatten: FlattenOptions { raster_dpi: 72.0, ..FlattenOptions::default() }, ..FlattenDocumentOptions::default() }
|
||||
}
|
||||
|
||||
fn separate(pdf: &[u8], page: usize) -> Plates {
|
||||
plates(pdf, page, DPI, &Options::default()).unwrap()
|
||||
}
|
||||
|
||||
fn tint(p: &Plates, name: &str, x: f32, y: f32) -> u8 {
|
||||
let s = p.dpi / 72.0;
|
||||
p.inks.lookup(name).and_then(|id| p.tint(id, (x * s) as u32, (y * s) as u32)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// No transparency groups, soft masks in graphics states, blend modes or constant alpha.
|
||||
fn assert_opaque(pdf: &[u8]) {
|
||||
let s = structure::summarize(pdf).unwrap();
|
||||
assert!(s.groups.iter().all(|g| g.s != "Transparency"), "transparency groups: {:?}", s.groups);
|
||||
for gs in &s.ext_g_states {
|
||||
assert!(gs.smask.as_deref().is_none_or(|m| m == "None" || m.is_empty()), "soft mask: {gs:?}");
|
||||
assert!(gs.bm.as_deref().is_none_or(|b| b == "Normal" || b == "Compatible"), "blend mode: {gs:?}");
|
||||
assert!(gs.alpha_fill.is_none_or(|a| a >= 1.0) && gs.alpha_stroke.is_none_or(|a| a >= 1.0), "alpha: {gs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn has_text(pdf: &craft_pdf::syntax::Pdf, page: usize) -> bool {
|
||||
let pages = pdf.pages();
|
||||
let mut ops = pages.get(page).unwrap().operations();
|
||||
let mut found = false;
|
||||
while let Some(op) = ops.next() {
|
||||
found |= matches!(&**op.operator, b"Tj" | b"TJ");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn count(haystack: &[u8], needle: &[u8]) -> usize {
|
||||
haystack.windows(needle.len()).filter(|w| *w == needle).count()
|
||||
}
|
||||
|
||||
fn page_box(pdf: &craft_pdf::syntax::Pdf, page: usize, key: &[u8]) -> Option<[f64; 4]> {
|
||||
pdf.pages().get(page)?.raw().get::<PdfRect>(key).map(|r| [r.x0, r.y0, r.x1, r.y1])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_flattens_page_by_page_and_keeps_boxes_metadata_and_shared_fonts() {
|
||||
let Some(font) = inter() else { return };
|
||||
let source = three_pages(&font);
|
||||
let start = Instant::now();
|
||||
let (out, report) = flatten_document(&source, &opts()).unwrap();
|
||||
eprintln!("flatten_document: {:?}, totals {:?}", start.elapsed(), report.totals);
|
||||
|
||||
let doc = craft_pdf::open_slice(&out, None).unwrap();
|
||||
let src = craft_pdf::open_slice(&source, None).unwrap();
|
||||
assert_eq!(doc.pages().len(), 3);
|
||||
for (i, (w, h)) in SIZES.iter().enumerate() {
|
||||
assert_eq!(doc.pages()[i].render_dimensions(), (*w, *h), "page {i}");
|
||||
}
|
||||
assert_eq!(report.pages.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [0, 1, 2]);
|
||||
assert!(report.pages[0].1.flatten.flattened > 0 && report.pages[2].1.flatten.flattened > 0);
|
||||
assert_eq!(report.pages[1].1.flatten.flattened, 0);
|
||||
assert!(report.unflattened.is_empty() && report.other_intents.is_empty());
|
||||
|
||||
assert_opaque(&out);
|
||||
for page in 0..3 {
|
||||
assert!(has_text(&doc, page), "page {page} has live text");
|
||||
}
|
||||
|
||||
// One embedded program for the font all three pages use.
|
||||
assert_eq!(count(&source, b"/FontFile2"), 1);
|
||||
assert_eq!(count(&out, b"/FontFile2"), 1, "the shared font is embedded once");
|
||||
|
||||
for key in [TRIM_BOX, BLEED_BOX] {
|
||||
assert_eq!(page_box(&doc, 0, key), page_box(&src, 0, key));
|
||||
assert!(page_box(&doc, 0, key).is_some());
|
||||
assert_eq!(page_box(&doc, 1, key), None);
|
||||
}
|
||||
|
||||
let info = doc.metadata();
|
||||
assert_eq!(info.title.as_deref(), Some(b"Flatten test".as_slice()));
|
||||
assert_eq!(info.author.as_deref(), Some(b"A. Writer".as_slice()));
|
||||
let created = info.creation_date.unwrap();
|
||||
assert_eq!((created.year, created.month, created.day, created.hour, created.minute), (2026, 10, 20, 9, 30));
|
||||
let modified = info.modification_date.unwrap();
|
||||
assert_eq!((modified.year, modified.month, modified.day, modified.hour, modified.minute), (2026, 10, 21, 16, 5));
|
||||
|
||||
let points: [&[(f32, f32)]; 3] = [
|
||||
&[(40.0, 40.0), (100.0, 80.0), (150.0, 120.0), (190.0, 15.0)],
|
||||
&[(50.0, 50.0), (100.0, 180.0)],
|
||||
&[(110.0, 30.0), (115.0, 75.0), (180.0, 50.0)],
|
||||
];
|
||||
for (page, points) in points.iter().enumerate() {
|
||||
let (a, b) = (separate(&source, page), separate(&out, page));
|
||||
for &(x, y) in *points {
|
||||
for ink in ["Cyan", "Magenta", "Yellow", "Black"] {
|
||||
let (want, got) = (tint(&a, ink, x, y), tint(&b, ink, x, y));
|
||||
assert!(want.abs_diff(got) <= 3, "page {page} {ink} at ({x}, {y}): {got}, the source has {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let single: usize = (0..3)
|
||||
.map(|p| {
|
||||
let opts = FlattenPageOptions { flatten: opts().flatten, ..FlattenPageOptions::default() };
|
||||
flatten_page(&source, p, &opts).unwrap().0.len()
|
||||
})
|
||||
.sum();
|
||||
eprintln!("source {} bytes; flattened document {} bytes; three flatten_page outputs {single} bytes", source.len(), out.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_range_flattens_only_those_pages() {
|
||||
let Some(font) = inter() else { return };
|
||||
let t = Instant::now();
|
||||
let source = three_pages(&font);
|
||||
eprintln!("three_pages {:?}", t.elapsed());
|
||||
let mut calls = Vec::new();
|
||||
let opts = FlattenDocumentOptions { pages: Some(1..3), ..opts() };
|
||||
let t = Instant::now();
|
||||
let (out, report) = flatten_document_with_progress(&source, &opts, &mut |done, total| calls.push((done, total))).unwrap();
|
||||
eprintln!("flatten {:?}", t.elapsed());
|
||||
let doc = craft_pdf::open_slice(&out, None).unwrap();
|
||||
assert_eq!(doc.pages().len(), 2);
|
||||
assert_eq!(doc.pages()[0].render_dimensions(), SIZES[1]);
|
||||
assert_eq!(doc.pages()[1].render_dimensions(), SIZES[2]);
|
||||
assert_eq!(report.pages.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(calls, [(1, 2), (2, 2)]);
|
||||
|
||||
let opts = FlattenDocumentOptions { pages: Some(2..4), ..FlattenDocumentOptions::default() };
|
||||
assert!(flatten_document(&source, &opts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_output_intent_is_written_once() {
|
||||
let color = PrintColor {
|
||||
profile: craft_cms::builtin::COATED_CMYK_ICC,
|
||||
condition_identifier: "Test coated",
|
||||
condition: Some("A coated test condition"),
|
||||
registry_name: None,
|
||||
info: Some("A coated test condition"),
|
||||
};
|
||||
let mut writer = Writer::new(print::settings(color).unwrap(), Metadata::default());
|
||||
for _ in 0..2 {
|
||||
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap());
|
||||
let mut s = page.surface();
|
||||
rect(&mut s, 10.0, 10.0, 60.0, 60.0, fill(255, 0, 0, 0));
|
||||
s.push_opacity(NormalizedF32::new(0.5).unwrap());
|
||||
rect(&mut s, 30.0, 30.0, 90.0, 90.0, fill(0, 0, 0, 255));
|
||||
s.pop();
|
||||
s.finish();
|
||||
page.finish();
|
||||
}
|
||||
let source = writer.finish().unwrap();
|
||||
let (out, report) = flatten_document(&source, &opts()).unwrap();
|
||||
assert!(report.other_intents.is_empty());
|
||||
assert_opaque(&out);
|
||||
|
||||
let doc = craft_pdf::open_slice(&out, None).unwrap();
|
||||
let xref = doc.xref();
|
||||
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
|
||||
let intents: Vec<Dict<'_>> = catalog.get::<Array<'_>>(OUTPUT_INTENTS).unwrap().iter::<Dict<'_>>().collect();
|
||||
assert_eq!(intents.len(), 1);
|
||||
let id = intents[0].get::<PdfString<'_>>(OUTPUT_CONDITION_IDENTIFIER).unwrap();
|
||||
assert_eq!(id.as_bytes(), b"Test coated");
|
||||
let icc = intents[0].get::<Stream<'_>>(DEST_OUTPUT_PROFILE).unwrap().decoded().unwrap();
|
||||
assert_eq!(&*icc, craft_cms::builtin::COATED_CMYK_ICC);
|
||||
assert_eq!(count(&out, b"/DestOutputProfile"), 1);
|
||||
}
|
||||
|
||||
/// PDF/X-1a output: the source's DeviceN fill (cyan and a spot, NChannel at 1.7) is written at
|
||||
/// 1.4 without the 1.6 NChannel attributes, and the PDF/X version goes to Info and XMP.
|
||||
#[test]
|
||||
fn pdf_x1a_output_is_pdf_14_without_nchannel() {
|
||||
let gold = cmyk::Color::new(0, 51, 255, 26);
|
||||
let space = DeviceNSpace::new(vec![Colorant::Process(Process::Cyan), Colorant::Spot { name: "Gold".to_owned(), alternate: gold }]).unwrap();
|
||||
let color = device_n::Color::new(vec![255, 128], space).unwrap();
|
||||
let mut writer = Writer::new(print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap(), Metadata::default());
|
||||
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap());
|
||||
let mut s = page.surface();
|
||||
rect(&mut s, 10.0, 10.0, 60.0, 60.0, Fill { paint: backend::color::Color::from(color).into(), ..Default::default() });
|
||||
s.push_opacity(NormalizedF32::new(0.5).unwrap());
|
||||
rect(&mut s, 30.0, 30.0, 90.0, 90.0, fill(0, 0, 0, 255));
|
||||
s.pop();
|
||||
s.finish();
|
||||
page.finish();
|
||||
let source = writer.finish().unwrap();
|
||||
assert!(count(&source, b"/NChannel") > 0);
|
||||
|
||||
let opts = FlattenDocumentOptions {
|
||||
version: PdfVersion::Pdf14,
|
||||
pdfx_version: Some("PDF/X-1:2003".to_owned()),
|
||||
pdfx_conformance: Some("PDF/X-1a:2003".to_owned()),
|
||||
..opts()
|
||||
};
|
||||
let (out, _) = flatten_document(&source, &opts).unwrap();
|
||||
assert_opaque(&out);
|
||||
assert!(out.starts_with(b"%PDF-1.4"));
|
||||
assert_eq!(count(&out, b"NChannel"), 0);
|
||||
assert_eq!(count(&out, b"/GTS_PDFXVersion(PDF/X-1:2003)"), 1);
|
||||
assert_eq!(count(&out, b"/GTS_PDFXConformance(PDF/X-1a:2003)"), 1);
|
||||
assert_eq!(count(&out, b"<pdfxid:GTS_PDFXVersion>PDF/X-1:2003</pdfxid:GTS_PDFXVersion>"), 1);
|
||||
assert_eq!(count(&out, b"<pdf:PDFVersion>1.4</pdf:PDFVersion>"), 1);
|
||||
let gold_plate = separate(&out, 0);
|
||||
assert!(tint(&gold_plate, "Gold", 20.0, 20.0) > 100);
|
||||
}
|
||||
|
||||
/// A 16 × 16 Adobe CMYK JPEG of C 100%, and a 16 × 16 gray JPEG of 25% gray (ImageMagick).
|
||||
const CYAN_JPEG: &[u8] = include_bytes!("fixtures/cyan-cmyk.jpg");
|
||||
const GRAY_JPEG: &[u8] = include_bytes!("fixtures/gray-25.jpg");
|
||||
|
||||
#[test]
|
||||
fn untouched_jpeg_images_are_written_as_they_are() {
|
||||
let image = |cs: &str, decode: &str, data: &[u8]| {
|
||||
let mut obj = format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width 16 /Height 16 /ColorSpace /{cs} {decode} /BitsPerComponent 8 \
|
||||
/Filter /DCTDecode /Length {} >> stream\n",
|
||||
data.len()
|
||||
)
|
||||
.into_bytes();
|
||||
obj.extend_from_slice(data);
|
||||
obj.extend_from_slice(b"\nendstream");
|
||||
obj
|
||||
};
|
||||
// The images, and a half-transparent black square away from them.
|
||||
let content = "q 50 0 0 50 10 10 cm /Im1 Do Q q 50 0 0 50 70 10 cm /Im2 Do Q \
|
||||
q /GS1 gs 0 0 0 1 k 140 10 50 50 re f Q";
|
||||
let objects =
|
||||
[image("DeviceCMYK", "/Decode [1 0 1 0 1 0 1 0]", CYAN_JPEG), image("DeviceGray", "", GRAY_JPEG), b"<< /Type /ExtGState /ca 0.5 >>".to_vec()];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
pdf.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
|
||||
pdf.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
|
||||
pdf.extend_from_slice(
|
||||
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 70] /Contents 4 0 R \
|
||||
/Resources << /XObject << /Im1 5 0 R /Im2 6 0 R >> /ExtGState << /GS1 7 0 R >> >> >> endobj\n",
|
||||
);
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
pdf.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes());
|
||||
pdf.extend_from_slice(body);
|
||||
pdf.extend_from_slice(b" endobj\n");
|
||||
}
|
||||
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
|
||||
|
||||
let (out, report) = flatten_document(&pdf, &opts()).unwrap();
|
||||
assert_eq!(report.totals.images, 2);
|
||||
assert!(report.totals.flattened > 0);
|
||||
assert_opaque(&out);
|
||||
assert_eq!(count(&out, CYAN_JPEG), 1, "the CMYK JPEG is written as it is");
|
||||
assert_eq!(count(&out, GRAY_JPEG), 1, "the gray JPEG is written as it is");
|
||||
assert_eq!(count(&out, b"/DCTDecode"), 2);
|
||||
let (a, b) = (separate(&pdf, 0), separate(&out, 0));
|
||||
for (x, y) in [(35.0, 35.0), (95.0, 35.0), (165.0, 35.0)] {
|
||||
for ink in ["Cyan", "Magenta", "Yellow", "Black"] {
|
||||
let (want, got) = (tint(&a, ink, x, y), tint(&b, ink, x, y));
|
||||
assert!(want.abs_diff(got) <= 3, "{ink} at ({x}, {y}): {got}, the source has {want}");
|
||||
}
|
||||
}
|
||||
assert!(tint(&b, "Cyan", 35.0, 35.0) > 240 && tint(&b, "Black", 95.0, 35.0) > 150);
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
//! The PDF-in adapter on plate-test scenes and hand-written pages: display list structure, ink
|
||||
//! colors, overprint and group attributes, and plates of opaque items written back.
|
||||
#![cfg(feature = "flatten")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use craft_pdf::write::backend::blend::Overprint as KOverprint;
|
||||
use craft_pdf::write::backend::geom::PathBuilder;
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::backend::paint::{Fill, FillRule as KFillRule};
|
||||
use craft_pdf::write::{Metadata, Writer};
|
||||
use craft_plate_test::reference::{self, Context};
|
||||
use craft_plate_test::scene;
|
||||
use craft_prepress::flatten::kurbo::{BezPath, PathEl, Point, Shape};
|
||||
use craft_prepress::flatten::pdf::{ReadOptions, ReadReport, read_page};
|
||||
use craft_prepress::flatten::{
|
||||
BlendMode, DisplayList, FillItem, FillRule, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, MaskKind, Paint,
|
||||
};
|
||||
use craft_prepress::{CmsConversion, InkColor, InkId, Options, Overprint, Process, plates, to_pdf_color};
|
||||
|
||||
fn scene_pdf(id: &str) -> Vec<u8> {
|
||||
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
|
||||
let s = scenes.iter().find(|s| s.id == id).unwrap();
|
||||
reference::write(s, &Context { scenes: &scenes, font: None }).unwrap().pdf
|
||||
}
|
||||
|
||||
fn read(pdf: &[u8]) -> (DisplayList, ReadReport) {
|
||||
read_page(pdf, 0, &ReadOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Every non-group item with the groups it is in, outermost first.
|
||||
fn leaves(items: &[Item]) -> Vec<(Vec<&GroupItem>, &Item)> {
|
||||
fn walk<'a>(items: &'a [Item], path: &mut Vec<&'a GroupItem>, out: &mut Vec<(Vec<&'a GroupItem>, &'a Item)>) {
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Group(g) => {
|
||||
path.push(g);
|
||||
walk(&g.children, path, out);
|
||||
path.pop();
|
||||
}
|
||||
other => out.push((path.clone(), other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(items, &mut Vec::new(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn fill_items(list: &DisplayList) -> Vec<(Vec<&GroupItem>, &FillItem)> {
|
||||
leaves(&list.items)
|
||||
.into_iter()
|
||||
.filter_map(|(groups, item)| match item {
|
||||
Item::Fill(f) => Some((groups, f)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ink(p: Process) -> InkId {
|
||||
InkId::process(p)
|
||||
}
|
||||
|
||||
fn cmyk_color(c: f32, m: f32, y: f32, k: f32) -> InkColor {
|
||||
InkColor::Tints(vec![(ink(Process::Cyan), c), (ink(Process::Magenta), m), (ink(Process::Yellow), y), (ink(Process::Black), k)])
|
||||
}
|
||||
|
||||
fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Paint {
|
||||
Paint::Solid(cmyk_color(c, m, y, k))
|
||||
}
|
||||
|
||||
fn op(fill: bool, nonzero: bool) -> Overprint {
|
||||
Overprint { fill, stroke: fill, nonzero }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_overprints_cyan() {
|
||||
let (list, report) = read(&scene_pdf("S01"));
|
||||
let fills = fill_items(&list);
|
||||
assert_eq!(fills.len(), 2, "two rects (text is skipped without a font): {fills:?}");
|
||||
assert_eq!(fills[0].1.paint, cmyk(1.0, 0.0, 0.0, 0.0));
|
||||
assert_eq!(fills[0].1.overprint, op(false, false));
|
||||
assert_eq!(fills[1].1.paint, cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
assert_eq!(fills[1].1.overprint, op(true, true));
|
||||
assert_eq!(fills[1].1.path.bounding_box().size().width.round(), 100.0);
|
||||
assert_eq!((report.images, report.gradients), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_inks_join_the_set() {
|
||||
let (list, _) = read(&scene_pdf("S04"));
|
||||
let gold = list.inks.lookup("Gold").unwrap();
|
||||
let fills = fill_items(&list);
|
||||
let Paint::Solid(InkColor::Tints(tints)) = &fills[1].1.paint else { panic!() };
|
||||
assert!(matches!(tints[..], [(id, t)] if id == gold && (t - 0.5).abs() < 0.01), "{tints:?}");
|
||||
assert!(fills[1].1.overprint.fill);
|
||||
let alternate = list.inks.spots().find(|(id, _)| *id == gold).unwrap().1.alternate;
|
||||
assert_eq!(alternate, craft_prepress::Alternate::Cmyk([0.0, 0.2, 0.6, 0.2]));
|
||||
|
||||
// An overprinting stroke carries `OP` in `fill`.
|
||||
let (list, _) = read(&scene_pdf("D06"));
|
||||
let gold = list.inks.lookup("Gold").unwrap();
|
||||
let stroke = fill_items(&list).into_iter().find(|(_, f)| f.paint == Paint::Solid(InkColor::Tints(vec![(gold, 1.0)]))).unwrap().1;
|
||||
assert_eq!(stroke.overprint, op(true, false));
|
||||
// The 10 pt stroke outlined around a 110 × 60 rect.
|
||||
assert_eq!(stroke.path.bounding_box().size().width.round(), 120.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_is_all_inks() {
|
||||
let (list, _) = read(&scene_pdf("S13"));
|
||||
let all: Vec<_> = fill_items(&list).into_iter().filter(|(_, f)| f.paint == Paint::Solid(InkColor::All(1.0))).collect();
|
||||
assert_eq!(all.len(), 2, "the registration fill and stroke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knockout_and_isolated_groups() {
|
||||
let (list, _) = read(&scene_pdf("S09"));
|
||||
let fills = fill_items(&list);
|
||||
let ellipses: Vec<_> = fills.iter().filter(|(groups, _)| groups.iter().any(|g| g.knockout)).collect();
|
||||
assert_eq!(ellipses.len(), 2);
|
||||
for (groups, _) in ellipses {
|
||||
// The writer puts each ellipse in an opacity group of its own, inside the knockout group.
|
||||
let at = groups.iter().position(|g| g.knockout).unwrap();
|
||||
assert!(groups[at + 1..].iter().any(|g| g.opacity == 0.5));
|
||||
}
|
||||
|
||||
let (list, _) = read(&scene_pdf("S10"));
|
||||
let fills = fill_items(&list);
|
||||
let magenta = fills.iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap();
|
||||
assert!(magenta.0.iter().any(|g| g.isolated));
|
||||
assert_eq!(magenta.0.last().unwrap().blend, BlendMode::Multiply);
|
||||
let yellow = fills.iter().find(|(_, f)| f.paint == cmyk(0.0, 0.0, 1.0, 0.0)).unwrap();
|
||||
assert!(yellow.0.iter().any(|g| g.isolated));
|
||||
assert!(yellow.0.iter().all(|g| g.blend == BlendMode::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_masks_keep_backdrop_and_transfer() {
|
||||
let (list, _) = read(&scene_pdf("M1"));
|
||||
let (groups, _) = fill_items(&list).into_iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap();
|
||||
let mask = groups.iter().find_map(|g| g.mask.as_deref()).unwrap();
|
||||
assert_eq!(mask.kind, MaskKind::Luminosity);
|
||||
assert!(mask.group.isolated);
|
||||
assert_eq!(leaves(&mask.group.children).len(), 1, "the mask's rect");
|
||||
// The scene's backdrop is white (no black ink).
|
||||
assert_eq!(mask.backdrop, Some(cmyk_color(0.0, 0.0, 0.0, 0.0)));
|
||||
|
||||
let (list, _) = read(&scene_pdf("M2"));
|
||||
let (groups, _) = fill_items(&list).into_iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap();
|
||||
let mask = groups.iter().find_map(|g| g.mask.as_deref()).unwrap();
|
||||
let transfer = mask.transfer.as_deref().unwrap();
|
||||
assert_eq!((transfer.len(), transfer[0], transfer[255]), (256, 255, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_over_spot_counts_images() {
|
||||
let (list, report) = read(&scene_pdf("S11"));
|
||||
assert_eq!(report.images, 3, "the split image and two feather images in masks");
|
||||
let shadows: Vec<_> = fill_items(&list).into_iter().filter(|(_, f)| f.paint == cmyk(0.0, 0.0, 0.0, 1.0)).collect();
|
||||
assert_eq!(shadows.len(), 2);
|
||||
for (groups, _) in shadows {
|
||||
let masked = groups.iter().find(|g| g.mask.is_some()).unwrap();
|
||||
assert_eq!(masked.blend, BlendMode::Multiply);
|
||||
let mask = masked.mask.as_deref().unwrap();
|
||||
assert_eq!(mask.kind, MaskKind::Luminosity);
|
||||
assert_eq!(mask.backdrop, Some(cmyk_color(0.0, 0.0, 0.0, 1.0)));
|
||||
// The mask's content is the feather image, alpha and all.
|
||||
let feathers: Vec<_> =
|
||||
leaves(&mask.group.children).into_iter().filter_map(|(_, i)| if let Item::Image(im) = i { Some(im) } else { None }).collect();
|
||||
assert_eq!(feathers.len(), 1, "{:?}", mask.group.children);
|
||||
assert!(groups.iter().any(|g| g.opacity == 0.75));
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-page PDF from raw content and resources, objects numbered from 5.
|
||||
fn raw_pdf(content: &str, resources: &str, objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
|
||||
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
|
||||
out.extend_from_slice(
|
||||
format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
out.extend_from_slice(format!("{} 0 obj {body} endobj\n", i + 5).as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict: &str, data: &str) -> String {
|
||||
format!("<< {dict} /Length {} >> stream\n{data}\nendstream", data.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_clips_strokes_and_process_color() {
|
||||
let to_unicode = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /T3 def /CMapType 2 def \
|
||||
1 begincodespacerange <00> <FF> endcodespacerange 2 beginbfchar <41> <0041> <42> <0042> endbfchar \
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end";
|
||||
let objects = [
|
||||
// 5: a Type 3 font with a shape glyph (A) and a colored one (B).
|
||||
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \
|
||||
/CharProcs << /sq 6 0 R /cg 7 0 R >> /Encoding << /Type /Encoding /Differences [65 /sq /cg] >> \
|
||||
/FirstChar 65 /LastChar 66 /Widths [1000 1000] /ToUnicode 8 0 R >>"
|
||||
.to_string(),
|
||||
stream("", "1000 0 0 0 1000 1000 d1 0 0 1000 1000 re f"),
|
||||
stream("", "1000 0 d0 0 1 0 0 k 0 0 1000 1000 re f"),
|
||||
stream("", to_unicode),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
];
|
||||
let content = "BT /F1 20 Tf 10 90 Td (AAB) Tj ET \
|
||||
BT /F2 24 Tf 100 90 Td (Hi) Tj ET \
|
||||
q 0 0 100 50 re 25 10 50 30 re W* n 0 0 0 1 k 0 0 100 50 re f Q \
|
||||
0.5 G 4 w [6 3] 0 d 110 10 m 290 10 l S \
|
||||
1 0 0 rg 200 30 10 10 re f";
|
||||
let pdf = raw_pdf(content, "/Font << /F1 5 0 R /F2 9 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
let items = leaves(&list.items);
|
||||
|
||||
// The two shape glyphs form one run; the colored glyph is a group with its text.
|
||||
let Item::Text(t3) = items[0].1 else { panic!("{:?}", items[0]) };
|
||||
assert_eq!((t3.glyphs.len(), t3.text.as_deref(), t3.font.is_none()), (2, Some("AA"), true));
|
||||
assert_eq!(t3.size.round(), 20.0);
|
||||
assert_eq!(t3.glyphs[1].origin.x.round(), 20.0, "origins in text space");
|
||||
assert_eq!(t3.paint, cmyk(0.0, 0.0, 0.0, 1.0), "DeviceGray 0 is solid black only");
|
||||
let colored = items[1].0.last().unwrap();
|
||||
assert_eq!(colored.actual_text.as_deref(), Some("B"));
|
||||
assert!(matches!(items[1].1, Item::Fill(f) if f.paint == cmyk(0.0, 1.0, 0.0, 0.0)));
|
||||
|
||||
let Item::Text(hi) = items[2].1 else { panic!("{:?}", items[2]) };
|
||||
assert_eq!(hi.text.as_deref(), Some("Hi"));
|
||||
assert_eq!(hi.glyphs[1].text, 1..2);
|
||||
assert!(!hi.outlines.is_empty());
|
||||
// Text space is y up: the run's transform flips y into page space.
|
||||
assert!(hi.transform.as_coeffs()[3] < 0.0);
|
||||
|
||||
// The even-odd clip, as a nonzero path with the hole kept.
|
||||
let (groups, _) = &items[3];
|
||||
let clip = groups.last().unwrap().clip.as_ref().unwrap();
|
||||
assert_eq!(clip.winding(Point::new(50.0, 95.0)), 0, "the hole");
|
||||
assert_ne!(clip.winding(Point::new(10.0, 115.0)), 0);
|
||||
|
||||
// The dashed stroke, outlined: 20 dashes of 6 pt in 180 pt, 4 pt wide.
|
||||
let Item::Fill(dashes) = items[4].1 else { panic!() };
|
||||
assert_eq!(dashes.paint, cmyk(0.0, 0.0, 0.0, 0.5));
|
||||
assert_eq!(dashes.path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(), 20);
|
||||
assert_eq!(dashes.path.bounding_box().height().round(), 4.0);
|
||||
|
||||
// RGB converts through FOGRA39: red is mostly magenta and yellow.
|
||||
let Item::Fill(red) = items[5].1 else { panic!() };
|
||||
let Paint::Solid(InkColor::Tints(tints)) = &red.paint else { panic!() };
|
||||
assert_eq!(tints.len(), 4);
|
||||
assert!(tints[1].1 > 0.8 && tints[2].1 > 0.8 && tints[0].1 < 0.2, "{tints:?}");
|
||||
assert!(matches!(report.conversion, plates::Conversion::Fallback { .. }));
|
||||
assert_eq!(items.len(), 6);
|
||||
}
|
||||
|
||||
fn images(list: &DisplayList) -> Vec<(Vec<&GroupItem>, &ImageItem)> {
|
||||
leaves(&list.items)
|
||||
.into_iter()
|
||||
.filter_map(|(groups, item)| match item {
|
||||
Item::Image(im) => Some((groups, im)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The ink tints of `im` at page point `p` (nearest sample).
|
||||
fn tints_at(im: &ImageItem, p: Point) -> Vec<(InkId, f32)> {
|
||||
let u = im.transform.inverse() * p;
|
||||
let (w, h) = (im.size.0 as usize, im.size.1 as usize);
|
||||
let (x, y) = (((u.x * w as f64) as usize).min(w - 1), ((u.y * h as f64) as usize).min(h - 1));
|
||||
let ids: Vec<InkId> = match &im.space {
|
||||
ImageSpace::Gray => vec![ink(Process::Black)],
|
||||
ImageSpace::Cmyk => Process::ALL.iter().map(|p| ink(*p)).collect(),
|
||||
ImageSpace::Inks(ids) => ids.clone(),
|
||||
ImageSpace::Rgb => panic!("RGB image"),
|
||||
};
|
||||
let n = if im.space == ImageSpace::Gray { 1 } else { ids.len() };
|
||||
let s = &im.samples[(y * w + x) * n..(y * w + x + 1) * n];
|
||||
if im.space == ImageSpace::Gray {
|
||||
return vec![(ids[0], 1.0 - f32::from(s[0]) / 255.0)];
|
||||
}
|
||||
ids.into_iter().zip(s).map(|(id, v)| (id, f32::from(*v) / 255.0)).collect()
|
||||
}
|
||||
|
||||
fn tint(tints: &[(InkId, f32)], id: InkId) -> f32 {
|
||||
tints.iter().find(|t| t.0 == id).map_or(0.0, |t| t.1)
|
||||
}
|
||||
|
||||
fn hex_image(dict: &str, hex: &str) -> String {
|
||||
stream(&format!("/Type /XObject /Subtype /Image /Filter /ASCIIHexDecode {dict}"), &format!("{hex}>"))
|
||||
}
|
||||
|
||||
const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.2 0.6 0.2] /N 1 >>]";
|
||||
|
||||
#[test]
|
||||
fn cmyk_image_keeps_its_samples() {
|
||||
let objects = [hex_image("/Width 2 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", "FF000000 00804020")];
|
||||
let pdf = raw_pdf("q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
let ims = images(&list);
|
||||
assert_eq!(ims.len(), 1);
|
||||
let im = ims[0].1;
|
||||
assert_eq!((&im.space, im.size, &im.samples[..]), (&ImageSpace::Cmyk, (2, 1), &[255, 0, 0, 0, 0, 128, 64, 32][..]));
|
||||
assert_eq!(im.transform * Point::ORIGIN, Point::new(10.0, 60.0), "(0, 0) is the top left sample");
|
||||
assert_eq!(im.transform * Point::new(1.0, 1.0), Point::new(110.0, 110.0));
|
||||
assert!(im.smask.is_none());
|
||||
assert_eq!((report.images, report.converted_images), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_image_overprints_without_nonzero_mode() {
|
||||
let objects = [hex_image("/Width 2 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", "FF000000 00804020")];
|
||||
let pdf = raw_pdf("/GS1 gs q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true /OPM 1 >> >>", &objects);
|
||||
let (list, _) = read(&pdf);
|
||||
assert_eq!(images(&list)[0].1.overprint, op(true, false), "nonzero overprint mode is not for images");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separation_image_is_its_ink_with_decode_applied() {
|
||||
let objects = [hex_image(&format!("/Width 2 /Height 1 /ColorSpace {GOLD} /BitsPerComponent 8 /Decode [1 0]"), "FF40")];
|
||||
let pdf = raw_pdf("/GS1 gs q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true /OPM 1 >> >>", &objects);
|
||||
let (list, _) = read(&pdf);
|
||||
let gold = list.inks.lookup("Gold").unwrap();
|
||||
let im = images(&list)[0].1;
|
||||
assert_eq!(im.space, ImageSpace::Inks(vec![gold]));
|
||||
assert_eq!(&im.samples[..], &[0, 0xBF]);
|
||||
assert_eq!(im.overprint, op(true, false), "nonzero overprint mode is for DeviceCMYK only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_image_converts_to_cmyk() {
|
||||
let objects = [hex_image("/Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", "FF0000")];
|
||||
let pdf = raw_pdf("q 10 0 0 10 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
let im = images(&list)[0].1;
|
||||
assert_eq!(im.space, ImageSpace::Cmyk);
|
||||
assert!(im.samples[1] > 200 && im.samples[2] > 200 && im.samples[0] < 50, "{:?}", im.samples);
|
||||
assert_eq!(report.converted_images, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_smask_is_resampled_to_the_image() {
|
||||
let objects = [
|
||||
hex_image("/Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R", "00FF8040"),
|
||||
hex_image("/Width 1 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", "FF00"),
|
||||
];
|
||||
let pdf = raw_pdf("q 20 0 0 20 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
let im = images(&list)[0].1;
|
||||
assert_eq!((&im.space, &im.samples[..]), (&ImageSpace::Gray, &[0, 255, 128, 64][..]));
|
||||
assert_eq!(im.smask.as_deref(), Some(&[255, 255, 0, 0][..]));
|
||||
assert_eq!(report.resampled_masks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_stencil_is_a_traced_fill() {
|
||||
// Sample 0 paints: the left half of each row.
|
||||
let objects = [hex_image("/Width 8 /Height 2 /ImageMask true /BitsPerComponent 1", "0F0F")];
|
||||
let pdf = raw_pdf("/CS1 cs 1 scn q 80 0 0 20 10 10 cm /Im1 Do Q", &format!("/XObject << /Im1 5 0 R >> /ColorSpace << /CS1 {GOLD} >>"), &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
let gold = list.inks.lookup("Gold").unwrap();
|
||||
let fills = fill_items(&list);
|
||||
assert_eq!(fills.len(), 1);
|
||||
assert_eq!(fills[0].1.paint, Paint::Solid(InkColor::Tints(vec![(gold, 1.0)])));
|
||||
let bbox = fills[0].1.path.bounding_box();
|
||||
assert_eq!((bbox.x0, bbox.x1, bbox.y0, bbox.y1), (10.0, 50.0, 90.0, 110.0));
|
||||
assert_eq!((report.stencils, images(&list).len()), (1, 0));
|
||||
}
|
||||
|
||||
/// The gradient's ink values at offset `s`, interpolated between stops.
|
||||
fn stop_tints(stops: &[(f32, InkColor)], s: f32) -> Vec<(InkId, f32)> {
|
||||
let k = stops.iter().position(|st| st.0 >= s).unwrap();
|
||||
let (lo, hi) = (&stops[k.saturating_sub(1)], &stops[k]);
|
||||
let u = if hi.0 > lo.0 { (s - lo.0) / (hi.0 - lo.0) } else { 1.0 };
|
||||
let (InkColor::Tints(a), InkColor::Tints(b)) = (&lo.1, &hi.1) else { panic!() };
|
||||
a.iter().zip(b).map(|(x, y)| (x.0, x.1 + (y.1 - x.1) * u)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axial_spot_to_paper_shading_is_a_gradient() {
|
||||
// Gold from 100% to paper, quadratic: 1 - s².
|
||||
let objects = [format!(
|
||||
"<< /ShadingType 2 /ColorSpace {GOLD} /Coords [0 0 100 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 2 >> >>"
|
||||
)];
|
||||
let pdf = raw_pdf("q 0 0 100 100 re W n /Sh1 sh Q", "/Shading << /Sh1 5 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
let gold = list.inks.lookup("Gold").unwrap();
|
||||
let fills = fill_items(&list);
|
||||
assert_eq!(fills.len(), 1);
|
||||
let Paint::Gradient { stops, geometry } = &fills[0].1.paint else { panic!("{:?}", fills[0].1.paint) };
|
||||
for s in [0.25_f32, 0.5, 0.75] {
|
||||
let t = stop_tints(stops, s);
|
||||
assert!((tint(&t, gold) - (1.0 - s * s)).abs() < 1.0 / 255.0, "at {s}: {t:?}");
|
||||
}
|
||||
assert!(stops.len() < 64, "{} stops", stops.len());
|
||||
let GradientGeometry::Axial { start, end, extend, .. } = geometry else { panic!() };
|
||||
assert_eq!((*start, *end, *extend), (Point::new(0.0, 0.0), Point::new(100.0, 0.0), [true, true]));
|
||||
assert_eq!(report.gradients, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_process_shading_is_clipped_to_its_extent() {
|
||||
let objects = ["<< /ShadingType 3 /ColorSpace /DeviceCMYK /Coords [50 50 0 50 50 40] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0 0] /C1 [0 1 0 0] /N 1 >> >>".to_string()];
|
||||
let pdf = raw_pdf("/Sh1 sh", "/Shading << /Sh1 5 0 R >>", &objects);
|
||||
let (list, _) = read(&pdf);
|
||||
let fills = fill_items(&list);
|
||||
assert_eq!(fills.len(), 1);
|
||||
let (groups, fill) = &fills[0];
|
||||
let Paint::Gradient { stops, geometry } = &fill.paint else { panic!() };
|
||||
assert!(matches!(geometry, GradientGeometry::Radial { extend: [true, true], end_radius, .. } if *end_radius == 40.0));
|
||||
let mid = stop_tints(stops, 0.5);
|
||||
assert!((tint(&mid, ink(Process::Cyan)) - 0.5).abs() < 0.01 && (tint(&mid, ink(Process::Magenta)) - 0.5).abs() < 0.01);
|
||||
assert_eq!(mid.len(), 4);
|
||||
// Without Extend it paints within the outer circle only (page space: y flipped).
|
||||
let extent = groups.iter().rev().find_map(|g| g.clip.as_ref()).unwrap();
|
||||
assert_ne!(extent.winding(Point::new(50.0, 70.0)), 0);
|
||||
assert_ne!(extent.winding(Point::new(89.5, 70.0)), 0);
|
||||
assert_eq!(extent.winding(Point::new(91.0, 70.0)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coons_mesh_shading_rasterizes() {
|
||||
// One square patch over 0..100 with C, M, Y and K corners.
|
||||
let points = "00 00 00 55 00 AA 00 FF 55 FF AA FF FF FF FF AA FF 55 FF 00 AA 00 55 00";
|
||||
let colors = "FF000000 00FF0000 0000FF00 000000FF";
|
||||
let objects = [hex_image_free(
|
||||
"/ShadingType 6 /ColorSpace /DeviceCMYK /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 100 0 100 0 1 0 1 0 1 0 1]",
|
||||
&format!("00 {points} {colors}"),
|
||||
)];
|
||||
let pdf = raw_pdf("q 1 0 0 1 10 10 cm /Sh1 sh Q", "/Shading << /Sh1 5 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
let ims = images(&list);
|
||||
assert_eq!((ims.len(), report.rasterized_shadings), (1, 1));
|
||||
let im = ims[0].1;
|
||||
assert_eq!(im.space, ImageSpace::Cmyk);
|
||||
let corner = tints_at(im, Point::new(11.0, 109.0));
|
||||
assert!(tint(&corner, ink(Process::Cyan)) > 0.95, "{corner:?}");
|
||||
let center = tints_at(im, Point::new(60.0, 60.0));
|
||||
for p in Process::ALL {
|
||||
assert!((tint(¢er, ink(p)) - 0.25).abs() < 0.03, "{center:?}");
|
||||
}
|
||||
assert_eq!(im.smask.as_ref().unwrap()[0], 0, "outside the patch");
|
||||
}
|
||||
|
||||
fn hex_image_free(dict: &str, hex: &str) -> String {
|
||||
stream(&format!("{dict} /Filter /ASCIIHexDecode"), &format!("{hex}>"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiling_pattern_expands_into_cells() {
|
||||
let objects = [
|
||||
stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 20 /YStep 20 /Resources << >>", "1 0 0 0 k 0 0 10 10 re f"),
|
||||
stream(
|
||||
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0.25 0.25] /XStep 0.5 /YStep 0.5 /Resources << >>",
|
||||
"0 1 0 0 k 0 0 0.25 0.25 re f",
|
||||
),
|
||||
];
|
||||
let pdf = raw_pdf("/Pattern cs /P1 scn 0 0 90 30 re f /P2 scn 100 0 90 30 re f", "/Pattern << /P1 5 0 R /P2 6 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
// Cells start every 20 pt from 0: five across 90 pt, two up 30 pt.
|
||||
assert_eq!(report.tiling_cells, 10);
|
||||
let cyan = fill_items(&list).into_iter().filter(|(_, f)| f.paint == cmyk(1.0, 0.0, 0.0, 0.0)).count();
|
||||
assert_eq!(cyan, 10);
|
||||
// 180 × 60 cells are past the cap: an image, a quarter magenta on average.
|
||||
assert_eq!(report.rasterized_tilings, 1);
|
||||
let im = images(&list)[0].1;
|
||||
let a = im.smask.as_ref().unwrap();
|
||||
let mean = a.iter().map(|v| f64::from(*v)).sum::<f64>() / a.len() as f64 / 255.0;
|
||||
assert!((mean - 0.25).abs() < 0.05, "{mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiling_cells_past_the_item_budget_rasterize() {
|
||||
// Each of the 99 × 99 outer cells holds an expanded 15 × 15 inner pattern: 2.2 million
|
||||
// items in all.
|
||||
let objects = [
|
||||
stream(
|
||||
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 1 /YStep 1 /Resources << /Pattern << /P2 6 0 R >> >>",
|
||||
"/Pattern cs /P2 scn 0 0 1 1 re f",
|
||||
),
|
||||
stream(
|
||||
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0.02 0.02] /XStep 0.07 /YStep 0.07 /Resources << >>",
|
||||
"1 0 0 0 k 0 0 0.02 0.02 re f",
|
||||
),
|
||||
];
|
||||
let pdf = raw_pdf("/Pattern cs /P1 scn 0.5 0.5 98 98 re f", "/Pattern << /P1 5 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
assert_eq!(report.tiling_cells, 225, "only the inner pattern expands");
|
||||
assert_eq!(report.rasterized_tilings, 1);
|
||||
assert_eq!(images(&list).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_overlapping_tiling_cells_paint_nothing() {
|
||||
// Every point lies in 6 × 10^14 cells.
|
||||
let objects = [stream(
|
||||
"/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 100000 100000] /XStep 0.004 /YStep 0.004 /Resources << >>",
|
||||
"1 0 0 0 k 0 0 1 1 re f",
|
||||
)];
|
||||
let pdf = raw_pdf("/Pattern cs /P1 scn 0 0 90 30 re f", "/Pattern << /P1 5 0 R >>", &objects);
|
||||
let (list, report) = read(&pdf);
|
||||
assert_eq!((report.tiling_cells, report.rasterized_tilings, report.dropped_tilings), (0, 0, 1));
|
||||
assert!(images(&list).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_past_the_budget_stroke_solid() {
|
||||
// A billion dashes.
|
||||
let pdf = raw_pdf("[0.000001] 0 d 0 0 m 1000 0 l S", "", &[]);
|
||||
let (list, _) = read(&pdf);
|
||||
let fills = fill_items(&list);
|
||||
assert_eq!(fills.len(), 1);
|
||||
assert!(fills[0].1.path.elements().len() < 20, "{:?}", fills[0].1.path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plate_test_gradients_and_meshes_read() {
|
||||
let (list, report) = read(&scene_pdf("S14"));
|
||||
let gold = list.inks.lookup("Gold").unwrap();
|
||||
assert_eq!((report.gradients, report.unreadable_images), (2, 0));
|
||||
let duotone = images(&list)[0].1;
|
||||
let at = tints_at(duotone, Point::new(100.0, 60.0));
|
||||
assert!((tint(&at, ink(Process::Black)) - 0.25).abs() < 0.1 && (tint(&at, gold) - 0.15).abs() < 0.1, "{at:?}");
|
||||
for (_, f) in fill_items(&list) {
|
||||
assert!(matches!(f.paint, Paint::Gradient { .. }), "{:?}", f.paint);
|
||||
}
|
||||
|
||||
let (list, report) = read(&scene_pdf("S17"));
|
||||
let gold = list.inks.lookup("Gold").unwrap();
|
||||
assert_eq!(report.rasterized_shadings, 1);
|
||||
let mesh = images(&list)[0].1;
|
||||
let expect = [
|
||||
((24.0, 24.0), vec![(gold, 0.95)]),
|
||||
((216.0, 24.0), vec![(ink(Process::Cyan), 0.95)]),
|
||||
((120.0, 80.0), vec![(gold, 0.375), (ink(Process::Cyan), 0.25), (ink(Process::Magenta), 0.25)]),
|
||||
];
|
||||
for ((x, y), inks) in expect {
|
||||
let at = tints_at(mesh, Point::new(x, y));
|
||||
for (id, v) in inks {
|
||||
assert!((tint(&at, id) - v).abs() < 0.1, "({x}, {y}): {at:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_fills(list: &DisplayList) -> Vec<u8> {
|
||||
let cms = CmsConversion::fogra39();
|
||||
let mut writer = Writer::new(Default::default(), Metadata::default());
|
||||
let size = list.page.size();
|
||||
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(size.width as f32, size.height as f32).unwrap());
|
||||
let mut surface = page.surface();
|
||||
for (groups, item) in leaves(&list.items) {
|
||||
assert!(groups.iter().all(|g| g.opacity == 1.0 && g.mask.is_none() && g.clip.is_none()), "opaque scenes only");
|
||||
let Item::Fill(f) = item else { panic!("{item:?}") };
|
||||
let Paint::Solid(color) = &f.paint else { panic!() };
|
||||
let o = f.overprint;
|
||||
surface.push_overprint(KOverprint { fill: o.fill, stroke: o.stroke, nonzero: o.nonzero });
|
||||
let rule = if f.rule == FillRule::EvenOdd { KFillRule::EvenOdd } else { KFillRule::NonZero };
|
||||
surface.set_fill(Some(Fill { paint: to_pdf_color(color, &list.inks, &cms).unwrap().into(), rule, ..Default::default() }));
|
||||
surface.draw_path(&krilla_path(&f.path));
|
||||
surface.pop();
|
||||
}
|
||||
surface.finish();
|
||||
page.finish();
|
||||
writer.finish().unwrap()
|
||||
}
|
||||
|
||||
fn krilla_path(path: &BezPath) -> craft_pdf::write::backend::geom::Path {
|
||||
let mut b = PathBuilder::new();
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32),
|
||||
PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32),
|
||||
PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32),
|
||||
PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32),
|
||||
PathEl::ClosePath => b.close(),
|
||||
}
|
||||
}
|
||||
b.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_items_separate_as_the_source() {
|
||||
for id in ["S01", "S04", "D06", "S13"] {
|
||||
let source = scene_pdf(id);
|
||||
let (list, _) = read(&source);
|
||||
let copy = write_fills(&list);
|
||||
let (a, b) = (plates(&source, 0, 36.0, &Options::default()).unwrap(), plates(©, 0, 36.0, &Options::default()).unwrap());
|
||||
assert_eq!(a.planes.len(), b.planes.len(), "{id}: plates");
|
||||
for (k, (pa, pb)) in a.planes.iter().zip(&b.planes).enumerate() {
|
||||
let differ = pa.iter().zip(pb).filter(|(x, y)| x.abs_diff(**y) > 8).count();
|
||||
assert!(differ * 100 < pa.len(), "{id}: plate {k} differs in {differ} of {} pixels", pa.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_flagged_annotations_left_out_are_counted() {
|
||||
let ap = stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", "0 0 0 1 k 0 0 10 10 re f");
|
||||
let annot = |flags: u32| format!("<< /Type /Annot /Subtype /Stamp /Rect [10 10 20 20] /F {flags} /AP << /N 5 0 R >> >>");
|
||||
// Print; Print and Hidden; no flags (screen only); Print without an appearance.
|
||||
let objects = [ap, annot(4), annot(4 | 2), annot(0), "<< /Type /Annot /Subtype /Text /Rect [0 0 5 5] /F 4 >>".to_string()];
|
||||
// `raw_pdf` puts its argument inside the page's /Resources; this closes that dictionary to
|
||||
// add /Annots to the page.
|
||||
let pdf = raw_pdf("", ">> /Annots [6 0 R 7 0 R 8 0 R 9 0 R] /PieceInfo <<", &objects);
|
||||
let (list, report) = read_page(&pdf, 0, &ReadOptions::default()).unwrap();
|
||||
assert!(list.items.is_empty());
|
||||
assert_eq!(report.dropped_annotations, 1);
|
||||
let (list, report) = read_page(&pdf, 0, &ReadOptions { annotations: true, ..ReadOptions::default() }).unwrap();
|
||||
assert!(!list.items.is_empty());
|
||||
assert_eq!(report.dropped_annotations, 0);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Local plate check of the flattener: every plate-test scene written, flattened with
|
||||
//! [`flatten_page`] at 300 dpi, separated with Ghostscript and judged against the scene, with no
|
||||
//! transparency left in the flattened page. Run it with `scripts/plate-check.sh --flatten`; it
|
||||
//! never runs in CI.
|
||||
#![cfg(feature = "flatten")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use craft_plate_test::reference::{self, Context};
|
||||
use craft_plate_test::report::{self, Status};
|
||||
use craft_plate_test::{gs, scene};
|
||||
use craft_prepress::flatten::{FlattenPageOptions, flatten_page};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Ghostscript; run scripts/plate-check.sh --flatten"]
|
||||
fn every_scene_separates_as_expected_flattened() {
|
||||
let Some(version) = gs::version() else {
|
||||
println!("note: Ghostscript ({:?}) not found; set {} or install gs", gs::program(), gs::GS_ENV);
|
||||
return;
|
||||
};
|
||||
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
|
||||
let ctx = Context::from_env(&scenes);
|
||||
if ctx.font.is_none() {
|
||||
println!("note: {} not set; text points are skipped", reference::FONT_DIR_ENV);
|
||||
}
|
||||
let opts = FlattenPageOptions::default();
|
||||
let flatten = |pdf: &[u8]| flatten_page(pdf, 0, &opts).map(|(flat, _)| flat).map_err(|e| e.to_string());
|
||||
let report = report::run_flattened(&ctx, version, &flatten);
|
||||
let path = report::write(&report, &report::output_dir().join("flattened")).unwrap();
|
||||
for r in &report.scenes {
|
||||
let pending = r.pending.as_deref().map(|t| format!(" (pending {t})")).unwrap_or_default();
|
||||
let flat = r.flattened.as_ref().map(|f| format!(", flattened {:?} in {:.0} ms", f.status, f.millis)).unwrap_or_default();
|
||||
println!("{} {:?}{flat}{pending}", r.id, r.status);
|
||||
}
|
||||
println!("report: {}", path.display());
|
||||
let wrong = report.unexpected();
|
||||
assert!(wrong.is_empty(), "see {}:\n{}", path.display(), wrong.join("\n"));
|
||||
assert!(report.scenes.iter().any(|r| r.flattened.as_ref().is_some_and(|f| f.status == Status::Pass)));
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
//! Complex regions rasterized as ink images, and plate-test scenes flattened end to end: the
|
||||
//! plates of a scene and of its flattened page match at the scene's sample points, and the
|
||||
//! flattened page has no transparency left.
|
||||
//!
|
||||
//! D10 needs Inter from `CRAFT_PDF_TEST_FONT_DIR` or `~/.cache/craft-pdf-test-fonts`, and is
|
||||
//! skipped when it is missing.
|
||||
#![cfg(feature = "flatten")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use craft_pdf::write::backend::SerializeSettings;
|
||||
use craft_pdf::write::backend::configure::PdfVersion;
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::print::{self, PrintColor};
|
||||
use craft_pdf::write::{Metadata, Writer, configuration};
|
||||
use craft_plate_test::reference::{self, Context};
|
||||
use craft_plate_test::{scene, structure};
|
||||
use craft_prepress::flatten::kurbo::{Affine, BezPath, Point, Rect, Shape};
|
||||
use craft_prepress::flatten::raster::Rasterizer;
|
||||
use craft_prepress::flatten::write::{WriteError, page_pdf, write_flattened};
|
||||
use craft_prepress::flatten::{
|
||||
BlendMode, ComplexReason, ComplexRegion, DisplayList, FillItem, FillRule, FlattenOptions, FlattenPageOptions, Flattened, GradientGeometry,
|
||||
GroupItem, Item, Paint, flatten, flatten_page,
|
||||
};
|
||||
use craft_prepress::{Alternate, CmsConversion, Fallback, Ink, InkColor, InkId, InkSet, Options, Overprint, Plates, Process, Spot, plates};
|
||||
|
||||
const C: InkId = InkId::process(Process::Cyan);
|
||||
const K: InkId = InkId::process(Process::Black);
|
||||
|
||||
fn settings() -> SerializeSettings {
|
||||
// Reading the profile takes a quarter second in a debug build.
|
||||
static SETTINGS: OnceLock<SerializeSettings> = OnceLock::new();
|
||||
SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap()).clone()
|
||||
}
|
||||
|
||||
fn separate(pdf: &[u8], dpi: f32) -> Plates {
|
||||
plates(pdf, 0, dpi, &Options::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Ink `name`'s tint at (`x`, `y`) in points, 0 when the page doesn't use the ink.
|
||||
fn tint(p: &Plates, name: &str, x: f32, y: f32) -> u8 {
|
||||
let s = p.dpi / 72.0;
|
||||
p.inks.lookup(name).and_then(|id| p.tint(id, (x * s) as u32, (y * s) as u32)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The flattened page has no transparency left ([`structure::transparency`]).
|
||||
fn assert_opaque(pdf: &[u8]) {
|
||||
let left = structure::transparency(&structure::summarize(pdf).unwrap());
|
||||
assert!(left.is_empty(), "transparency left: {left:?}");
|
||||
}
|
||||
|
||||
fn font() -> Option<Arc<Vec<u8>>> {
|
||||
let dir = std::env::var_os(reference::FONT_DIR_ENV)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts")))?;
|
||||
std::fs::read(dir.join(reference::FONT_FILE)).ok().map(Arc::new)
|
||||
}
|
||||
|
||||
/// Scene `id` written by the reference writer and flattened with complex regions at 72 dpi: the
|
||||
/// plates match at every judged sample point within `tol` (0–255), or the flattened page meets the
|
||||
/// scene's expectation there. The renderer's source page can differ from the scene where it
|
||||
/// converts a mask's CMYK luminosity through the printing condition (S11's K100 `/BC` reads as
|
||||
/// about 12% luminosity); the flattener uses the DeviceCMYK formula, as Acrobat does. Returns the worst
|
||||
/// difference from the source.
|
||||
fn scene_round_trip(id: &str, tol: u8) -> u8 {
|
||||
scene_planes(id, tol).0
|
||||
}
|
||||
|
||||
/// [`scene_round_trip`], with the source's and the flattened page's plates.
|
||||
fn scene_planes(id: &str, tol: u8) -> (u8, Plates, Plates) {
|
||||
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
|
||||
let s = scenes.iter().find(|s| s.id == id).unwrap();
|
||||
let pdf = reference::write(s, &Context { scenes: &scenes, font: font() }).unwrap().pdf;
|
||||
let opts = FlattenPageOptions { flatten: FlattenOptions { raster_dpi: 72.0, ..FlattenOptions::default() }, ..FlattenPageOptions::default() };
|
||||
let start = Instant::now();
|
||||
let (flat, report) = flatten_page(&pdf, 0, &opts).unwrap();
|
||||
eprintln!("{id}: flatten_page {:?}, {:?}", start.elapsed(), report.raster);
|
||||
assert_opaque(&flat);
|
||||
let (a, b) = (separate(&pdf, 72.0), separate(&flat, 72.0));
|
||||
let mut worst = 0;
|
||||
for point in s.expect.points.iter().filter(|p| !p.info) {
|
||||
let [x, y] = point.at;
|
||||
for name in s.expect.plates.iter().map(String::as_str) {
|
||||
let (want, got) = (tint(&a, name, x, y), tint(&b, name, x, y));
|
||||
if want.abs_diff(got) <= tol {
|
||||
worst = worst.max(want.abs_diff(got));
|
||||
continue;
|
||||
}
|
||||
let meets = |v: u8| {
|
||||
let pct = f32::from(v) / 2.55;
|
||||
match point.inks.get(name).copied().unwrap_or(scene::Level::Value(0.0)) {
|
||||
scene::Level::Value(e) => (pct - e).abs() <= point.tol,
|
||||
scene::Level::Range([lo, hi]) => pct >= lo - point.tol && pct <= hi + point.tol,
|
||||
}
|
||||
};
|
||||
assert!(meets(got), "{id} {name} at ({x}, {y}): {got}, the source has {want}");
|
||||
eprintln!("{id} {name} at ({x}, {y}): {got} meets the scene, the source renders {want}");
|
||||
}
|
||||
}
|
||||
eprintln!("{id}: worst ink difference {worst}/255");
|
||||
(worst, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s06_half_black_over_cyan() {
|
||||
scene_round_trip("S06", 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s07_spot_multiply() {
|
||||
scene_round_trip("S07", 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s08_non_separable_blend_over_a_spot() {
|
||||
scene_round_trip("S08", 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s10_isolated_multiply() {
|
||||
scene_round_trip("S10", 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s11_shadow_over_spot() {
|
||||
scene_round_trip("S11", 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s14_duotone_gradients() {
|
||||
scene_round_trip("S14", 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m1_luminosity_mask_rasterizes() {
|
||||
scene_round_trip("M1", 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m2_mask_transfer_rasterizes() {
|
||||
scene_round_trip("M2", 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f02_hidden_art_leaves_no_trace() {
|
||||
// vectorcraft#2161: no pixel of the opaque card and box, anti-aliased as viewers draw them,
|
||||
// differs from the source: art hidden under them doesn't cut them into pieces whose edges
|
||||
// show.
|
||||
let (_, a, b) = scene_planes("F02", 3);
|
||||
let s = a.dpi / 72.0;
|
||||
let mut off = Vec::new();
|
||||
for (x0, x1) in [(22.0, 168.0), (182.0, 338.0)] {
|
||||
for y in (22.0 * s) as u32..(158.0 * s) as u32 {
|
||||
for x in (x0 * s) as u32..(x1 * s) as u32 {
|
||||
for name in ["Cyan", "Magenta", "Yellow", "Black"] {
|
||||
let tint = |p: &Plates| p.inks.lookup(name).and_then(|id| p.tint(id, x, y)).unwrap_or(0);
|
||||
if tint(&a) != tint(&b) {
|
||||
off.push(format!("{name} {} vs {} at pixel ({x}, {y})", tint(&b), tint(&a)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(off.is_empty(), "{} pixels differ, e.g. {:?}", off.len(), &off[..off.len().min(8)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d10_text_over_transparency_stays_live() {
|
||||
if font().is_none() {
|
||||
eprintln!("skipped: no test font");
|
||||
return;
|
||||
}
|
||||
scene_round_trip("D10", 3);
|
||||
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
|
||||
let s = scenes.iter().find(|s| s.id == "D10").unwrap();
|
||||
let pdf = reference::write(s, &Context { scenes: &scenes, font: font() }).unwrap().pdf;
|
||||
let (flat, _) = flatten_page(&pdf, 0, &FlattenPageOptions::default()).unwrap();
|
||||
let doc = craft_pdf::open_slice(&flat, None).unwrap();
|
||||
let pages = doc.pages();
|
||||
let mut ops = Vec::new();
|
||||
let mut it = pages.first().unwrap().operations();
|
||||
while let Some(op) = it.next() {
|
||||
ops.push(String::from_utf8_lossy(op.operator).into_owned());
|
||||
}
|
||||
assert!(ops.iter().any(|op| op == "Tj" || op == "TJ"), "text drawn as text: {ops:?}");
|
||||
}
|
||||
|
||||
fn spot_inks() -> (InkSet, InkId) {
|
||||
let mut inks = InkSet::new();
|
||||
let gold = inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None })).unwrap();
|
||||
(inks, gold)
|
||||
}
|
||||
|
||||
fn solid(r: Rect, tints: &[(InkId, f32)]) -> Item {
|
||||
Item::Fill(FillItem {
|
||||
path: r.to_path(0.1),
|
||||
rule: FillRule::NonZero,
|
||||
paint: Paint::Solid(InkColor::Tints(tints.to_vec())),
|
||||
overprint: Overprint::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn group(opacity: f32, blend: BlendMode, children: Vec<Item>) -> Item {
|
||||
Item::Group(GroupItem { opacity, blend, isolated: false, knockout: false, clip: None, mask: None, actual_text: None, children })
|
||||
}
|
||||
|
||||
fn ramp(r: Rect, from: InkColor, to: InkColor) -> Item {
|
||||
Item::Fill(FillItem {
|
||||
path: r.to_path(0.1),
|
||||
rule: FillRule::NonZero,
|
||||
paint: Paint::Gradient {
|
||||
stops: vec![(0.0, from), (1.0, to)],
|
||||
geometry: GradientGeometry::Axial {
|
||||
start: Point::new(r.x0, 0.0),
|
||||
end: Point::new(r.x1, 0.0),
|
||||
extend: [true; 2],
|
||||
transform: Affine::IDENTITY,
|
||||
},
|
||||
},
|
||||
overprint: Overprint::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `flat` written with complex regions rasterized at `dpi`.
|
||||
fn write(list: &DisplayList, flat: &Flattened, dpi: f32) -> Vec<u8> {
|
||||
write_at(list, flat, dpi, PdfVersion::default()).unwrap()
|
||||
}
|
||||
|
||||
/// [`write`] for an output of PDF `version`.
|
||||
fn write_at(list: &DisplayList, flat: &Flattened, dpi: f32, version: PdfVersion) -> Result<Vec<u8>, WriteError> {
|
||||
let cms = CmsConversion::fogra39();
|
||||
let mut raster = Rasterizer::new(list, settings(), Fallback::Fogra39, &cms, dpi);
|
||||
let output = SerializeSettings { configuration: configuration(version, None).unwrap(), ..settings() };
|
||||
let mut writer = Writer::new(output, Metadata::default());
|
||||
let size = PageSettings::from_wh(list.page.width() as f32, list.page.height() as f32).unwrap();
|
||||
let mut page_builder = writer.document_mut().start_page_with(size);
|
||||
let mut surface = page_builder.surface();
|
||||
let drawn = write_flattened(&mut surface, list, flat, &cms, version, &mut |r| raster.region(r));
|
||||
surface.finish();
|
||||
page_builder.finish();
|
||||
drawn?;
|
||||
Ok(writer.finish().unwrap())
|
||||
}
|
||||
|
||||
/// A page of 2 pt stripes, one of `process` tints and one of each of `count` spot inks, under a
|
||||
/// half-transparent ramp: a complex region that paints them all.
|
||||
fn many_inks(count: usize, process: &[(InkId, f32)]) -> (DisplayList, Flattened) {
|
||||
let mut inks = InkSet::new();
|
||||
let stripe = |i: usize| Rect::new(2.0 * i as f64, 0.0, 2.0 * (i + 1) as f64, 10.0);
|
||||
let mut items = vec![solid(stripe(0), process)];
|
||||
for i in 0..count {
|
||||
let spot = Spot { name: format!("Spot {i}"), alternate: Alternate::Cmyk([0.0, 0.0, 0.0, 0.1]), solidity: None };
|
||||
items.push(solid(stripe(i + 1), &[(inks.add(Ink::Spot(spot)).unwrap(), 0.5)]));
|
||||
}
|
||||
let width = 2.0 * (count + 1) as f64;
|
||||
let ramp = ramp(Rect::new(0.0, 0.0, width, 10.0), InkColor::Tints(vec![(K, 0.0)]), InkColor::Tints(vec![(K, 1.0)]));
|
||||
items.push(group(0.5, BlendMode::Multiply, vec![ramp]));
|
||||
let list = DisplayList { page: Rect::new(0.0, 0.0, width, 10.0), inks, items };
|
||||
let flat = flatten(&list, &FlattenOptions::default()).unwrap();
|
||||
assert!(!flat.complex.is_empty());
|
||||
(list, flat)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_of_more_spot_inks_than_plates_separates_is_an_error() {
|
||||
let (list, flat) = many_inks(33, &[(K, 0.5)]);
|
||||
match write_at(&list, &flat, 72.0, PdfVersion::default()) {
|
||||
Err(WriteError::SpotInks { inks: 33, max: 32, unseparated }) => assert_eq!(unseparated.len(), 1),
|
||||
other => panic!("{:?}", other.map(|_| ())),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_region_of_nine_inks_needs_pdf_1_5() {
|
||||
let (list, flat) = many_inks(5, &[(C, 0.5), (InkId::process(Process::Magenta), 0.5), (InkId::process(Process::Yellow), 0.5), (K, 0.5)]);
|
||||
match write_at(&list, &flat, 72.0, PdfVersion::Pdf14) {
|
||||
Err(WriteError::Colorants { colorants: 9, max: 8 }) => {}
|
||||
other => panic!("{:?}", other.map(|_| ())),
|
||||
}
|
||||
let p = separate(&write_at(&list, &flat, 72.0, PdfVersion::Pdf17).unwrap(), 72.0);
|
||||
assert!(tint(&p, "Spot 4", 11.0, 5.0) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_under_multiply_over_a_spot_prints_the_same_rasterized() {
|
||||
// A K ramp multiplied over Gold and cyan, half transparent: complex regions over a spot and
|
||||
// a process backdrop, with a kept K fill above them.
|
||||
let (inks, gold) = spot_inks();
|
||||
let list = DisplayList {
|
||||
page: Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
inks,
|
||||
items: vec![
|
||||
solid(Rect::new(0.0, 0.0, 50.0, 100.0), &[(gold, 1.0)]),
|
||||
solid(Rect::new(50.0, 0.0, 100.0, 100.0), &[(C, 0.6)]),
|
||||
group(
|
||||
0.5,
|
||||
BlendMode::Multiply,
|
||||
vec![ramp(Rect::new(10.0, 20.0, 90.0, 80.0), InkColor::Tints(vec![(K, 0.0)]), InkColor::Tints(vec![(K, 1.0)]))],
|
||||
),
|
||||
solid(Rect::new(40.0, 45.0, 60.0, 55.0), &[(K, 1.0)]),
|
||||
],
|
||||
};
|
||||
let flat = flatten(&list, &FlattenOptions::default()).unwrap();
|
||||
assert_eq!(flat.complex.len(), 1, "touching complex regions merge: {:?}", flat.complex.iter().map(|c| &c.items).collect::<Vec<_>>());
|
||||
let start = Instant::now();
|
||||
let out = write(&list, &flat, 72.0);
|
||||
eprintln!("ramp: written in {:?}", start.elapsed());
|
||||
assert_opaque(&out);
|
||||
let (a, b) = (separate(&page_pdf(&list, settings(), &CmsConversion::fogra39()).unwrap(), 72.0), separate(&out, 72.0));
|
||||
let mut worst = 0;
|
||||
for (x, y) in [(5.0, 50.0), (20.0, 30.0), (30.0, 70.0), (45.0, 40.0), (50.0, 50.0), (55.0, 60.0), (70.0, 30.0), (85.0, 75.0), (95.0, 50.0)] {
|
||||
for name in ["Cyan", "Magenta", "Yellow", "Black", "Gold"] {
|
||||
let (want, got) = (tint(&a, name, x, y), tint(&b, name, x, y));
|
||||
assert!(want.abs_diff(got) <= 3, "{name} at ({x}, {y}): {got}, expected {want}");
|
||||
worst = worst.max(want.abs_diff(got));
|
||||
}
|
||||
}
|
||||
eprintln!("ramp: worst ink difference {worst}/255");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abutting_complex_regions_leave_no_paper_at_their_edge() {
|
||||
// Two regions cut off the grid at x = 50.3, each its own image of the same 60% K fill.
|
||||
let list = DisplayList {
|
||||
page: Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
inks: InkSet::new(),
|
||||
items: vec![solid(Rect::new(10.0, 10.0, 90.0, 90.0), &[(K, 0.6)])],
|
||||
};
|
||||
let region = |r: Rect| ComplexRegion { path: r.to_path(0.1), items: vec![0], at: 0, whole: None };
|
||||
let flat = Flattened {
|
||||
items: Vec::new(),
|
||||
complex: vec![region(Rect::new(10.0, 10.0, 50.3, 90.0)), region(Rect::new(50.3, 10.0, 90.0, 90.0))],
|
||||
report: Default::default(),
|
||||
};
|
||||
let dpi = 36.0;
|
||||
let p = separate(&write(&list, &flat, dpi), 2.0 * dpi);
|
||||
let k = p.inks.lookup("Black").unwrap();
|
||||
let edge = (50.3 * p.dpi / 72.0) as u32;
|
||||
for y in (12.0 * p.dpi / 72.0) as u32..(88.0 * p.dpi / 72.0) as u32 {
|
||||
for x in edge.saturating_sub(1)..=edge + 1 {
|
||||
let v = p.tint(k, x, y).unwrap();
|
||||
assert!(v >= 153 / 2, "K {v} at pixel ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_degenerate_group_is_an_opaque_image_over_paper() {
|
||||
// The region's path is a box larger than the half-black disc in it, with cyan beside it.
|
||||
// Nothing else is under the box, so its image needs no soft mask: paper where the disc
|
||||
// doesn't paint, the disc's own color where it does.
|
||||
let disc = kurbo_circle(Point::new(50.0, 50.0), 20.0);
|
||||
let list = DisplayList {
|
||||
page: Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
inks: InkSet::new(),
|
||||
items: vec![
|
||||
solid(Rect::new(0.0, 0.0, 20.0, 100.0), &[(C, 1.0)]),
|
||||
group(
|
||||
0.5,
|
||||
BlendMode::Normal,
|
||||
vec![Item::Fill(FillItem {
|
||||
path: disc,
|
||||
rule: FillRule::NonZero,
|
||||
paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])),
|
||||
overprint: Overprint::default(),
|
||||
})],
|
||||
),
|
||||
],
|
||||
};
|
||||
let flat = Flattened {
|
||||
items: vec![list.items[0].clone()],
|
||||
complex: vec![ComplexRegion {
|
||||
path: Rect::new(25.0, 25.0, 75.0, 75.0).to_path(0.1),
|
||||
items: vec![1],
|
||||
at: 1,
|
||||
whole: Some(ComplexReason::Degenerate),
|
||||
}],
|
||||
report: Default::default(),
|
||||
};
|
||||
let out = write(&list, &flat, 72.0);
|
||||
assert_opaque(&out);
|
||||
let p = separate(&out, 72.0);
|
||||
// A corner of the box, outside the disc: paper.
|
||||
assert!(tint(&p, "Cyan", 28.0, 28.0) <= 3 && tint(&p, "Black", 28.0, 28.0) <= 3);
|
||||
// The disc: K 50%.
|
||||
assert!(tint(&p, "Black", 50.0, 50.0).abs_diff(128) <= 3 && tint(&p, "Cyan", 50.0, 50.0) <= 3);
|
||||
// The cyan beside the box, untouched.
|
||||
assert!(tint(&p, "Cyan", 10.0, 50.0) >= 250);
|
||||
}
|
||||
|
||||
fn kurbo_circle(c: Point, r: f64) -> BezPath {
|
||||
craft_prepress::flatten::kurbo::Circle::new(c, r).to_path(0.01)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regions_thinner_than_a_point_rasterize() {
|
||||
// At 300 dpi the hairline region is a tile of 3 px, 0.72 pt; the strip's last tile is 3 px
|
||||
// wide.
|
||||
let list = DisplayList {
|
||||
page: Rect::new(0.0, 0.0, 400.0, 40.0),
|
||||
inks: InkSet::new(),
|
||||
items: vec![group(0.5, BlendMode::Normal, vec![solid(Rect::new(10.0, 10.0, 390.0, 30.0), &[(K, 1.0)])])],
|
||||
};
|
||||
let region = |r: Rect| ComplexRegion { path: r.to_path(0.1), items: vec![0], at: 0, whole: None };
|
||||
let flat = Flattened {
|
||||
items: Vec::new(),
|
||||
complex: vec![region(Rect::new(20.0, 20.0, 20.1, 25.0)), region(Rect::new(10.0, 10.0, 378.71, 15.0))],
|
||||
report: Default::default(),
|
||||
};
|
||||
let p = separate(&write(&list, &flat, 300.0), 300.0);
|
||||
assert!(tint(&p, "Black", 20.05, 22.0) > 0, "the hairline prints");
|
||||
assert!(tint(&p, "Black", 378.4, 12.0).abs_diff(128) <= 3, "the last tile prints");
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Times the flattener in stages on every plate-test scene and on a synthetic heavy page, at
|
||||
//! 300 dpi. A report, not a benchmark: run it in release with
|
||||
//! `cargo test --release -p craft-prepress --features flatten --test flatten_timings -- --ignored --nocapture`.
|
||||
#![cfg(feature = "flatten")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use craft_pdf::write::backend::SerializeSettings;
|
||||
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::print::{self, PrintColor};
|
||||
use craft_pdf::write::{Metadata, Writer};
|
||||
use craft_plate_test::reference::{self, Context};
|
||||
use craft_plate_test::scene;
|
||||
use craft_prepress::flatten::kurbo::{Affine, Circle, Point, Rect, Shape};
|
||||
use craft_prepress::flatten::pdf::{ReadOptions, read_page};
|
||||
use craft_prepress::flatten::raster::Rasterizer;
|
||||
use craft_prepress::flatten::write::{page_pdf, write_flattened};
|
||||
use craft_prepress::flatten::{
|
||||
BlendMode, DisplayList, FillItem, FillRule, FlattenOptions, FlattenPageOptions, GradientGeometry, GroupItem, Item, Paint, flatten, flatten_page,
|
||||
};
|
||||
use craft_prepress::{CmsConversion, Fallback, InkColor, InkId, InkSet, Options, Overprint, Process, plates};
|
||||
|
||||
const DPI: f32 = 300.0;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Stages {
|
||||
read: Duration,
|
||||
flatten: Duration,
|
||||
raster: Duration,
|
||||
write: Duration,
|
||||
total: Duration,
|
||||
regions: usize,
|
||||
whole: String,
|
||||
pixels: u64,
|
||||
fills: usize,
|
||||
complex: usize,
|
||||
}
|
||||
|
||||
/// The page read, flattened, and written with its complex regions rasterized, each stage timed;
|
||||
/// `total` is [`flatten_page`] in one call.
|
||||
fn stages(pdf: &[u8]) -> Stages {
|
||||
let mut st = Stages::default();
|
||||
let start = Instant::now();
|
||||
let opts = FlattenPageOptions { flatten: FlattenOptions { raster_dpi: DPI, ..FlattenOptions::default() }, ..FlattenPageOptions::default() };
|
||||
flatten_page(pdf, 0, &opts).unwrap();
|
||||
st.total = start.elapsed();
|
||||
|
||||
let start = Instant::now();
|
||||
let (list, _) = read_page(pdf, 0, &ReadOptions::default()).unwrap();
|
||||
st.read = start.elapsed();
|
||||
written(&list, &mut st);
|
||||
st
|
||||
}
|
||||
|
||||
/// Flattens and writes `list`, timing the stages into `st`.
|
||||
fn written(list: &DisplayList, st: &mut Stages) -> Vec<u8> {
|
||||
let start = Instant::now();
|
||||
let flat = flatten(list, &FlattenOptions { raster_dpi: DPI, ..FlattenOptions::default() }).unwrap();
|
||||
st.flatten = start.elapsed();
|
||||
st.fills = flat.report.fills;
|
||||
st.regions = flat.report.regions;
|
||||
st.whole = flat.report.complex_groups.iter().map(|g| format!("{:?}", g.reason)).collect::<Vec<_>>().join(", ");
|
||||
st.complex = flat.complex.len();
|
||||
|
||||
let settings = settings();
|
||||
let cms = CmsConversion::fogra39();
|
||||
let start = Instant::now();
|
||||
let mut raster = Rasterizer::new(list, settings.clone(), Fallback::Fogra39, &cms, DPI);
|
||||
let mut raster_time = Duration::ZERO;
|
||||
let version = settings.configuration.version();
|
||||
let mut writer = Writer::new(settings, Metadata::default());
|
||||
let size = PageSettings::from_wh(list.page.width() as f32, list.page.height() as f32).unwrap();
|
||||
let mut page_builder = writer.document_mut().start_page_with(size);
|
||||
let mut surface = page_builder.surface();
|
||||
write_flattened(&mut surface, list, &flat, &cms, version, &mut |r| {
|
||||
let t = Instant::now();
|
||||
let image = raster.region(r);
|
||||
raster_time += t.elapsed();
|
||||
image
|
||||
})
|
||||
.unwrap();
|
||||
surface.finish();
|
||||
page_builder.finish();
|
||||
let pdf = writer.finish().unwrap();
|
||||
st.raster = raster_time;
|
||||
st.write = start.elapsed() - raster_time;
|
||||
st.pixels = raster.report().pixels;
|
||||
pdf
|
||||
}
|
||||
|
||||
fn settings() -> SerializeSettings {
|
||||
static SETTINGS: OnceLock<SerializeSettings> = OnceLock::new();
|
||||
SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap()).clone()
|
||||
}
|
||||
|
||||
fn ms(d: Duration) -> String {
|
||||
format!("{:.1}", d.as_secs_f64() * 1000.0)
|
||||
}
|
||||
|
||||
fn row(name: &str, st: &Stages) -> String {
|
||||
format!(
|
||||
"| {name} | {} | {} | {} | {} | {} | {} | {} | {}{} | {:.1} |",
|
||||
ms(st.total),
|
||||
ms(st.read),
|
||||
ms(st.flatten),
|
||||
ms(st.raster),
|
||||
ms(st.write),
|
||||
st.regions,
|
||||
st.fills,
|
||||
st.complex,
|
||||
if st.whole.is_empty() { String::new() } else { format!(" ({})", st.whole) },
|
||||
st.pixels as f64 / 1e6
|
||||
)
|
||||
}
|
||||
|
||||
/// About 1,000 shapes up to `size` pt on an A4 page, 100 of them transparent (opacity or
|
||||
/// Multiply); `gradients` of the transparent ones are filled with a ramp. Positions come from a fixed
|
||||
/// sequence, so every run draws the same page.
|
||||
fn heavy(size: f64, gradients: usize) -> DisplayList {
|
||||
let mut seed = 0x2545_f491_u64;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed % 10_000) as f64 / 10_000.0
|
||||
};
|
||||
let ink = |i: usize| InkId::process(Process::ALL[i % 4]);
|
||||
let mut items = Vec::new();
|
||||
let mut transparent = 0;
|
||||
for i in 0..1000 {
|
||||
let (x, y) = (20.0 + next() * 520.0, 20.0 + next() * 780.0);
|
||||
let (w, h) = (2.0 + next() * (size - 2.0), 2.0 + next() * (size - 2.0));
|
||||
let path = if i % 3 == 0 { Circle::new(Point::new(x, y), w / 2.0).to_path(0.1) } else { Rect::new(x, y, x + w, y + h).to_path(0.1) };
|
||||
let color = InkColor::Tints(vec![(ink(i), 0.2 + next() as f32 * 0.8), (ink(i + 1), next() as f32 * 0.5)]);
|
||||
let paint = if i % 10 == 9 && transparent < gradients {
|
||||
Paint::Gradient {
|
||||
stops: vec![(0.0, color), (1.0, InkColor::Tints(vec![(ink(i + 2), 1.0)]))],
|
||||
geometry: GradientGeometry::Axial {
|
||||
start: Point::new(x, y),
|
||||
end: Point::new(x + w, y),
|
||||
extend: [true; 2],
|
||||
transform: Affine::IDENTITY,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
Paint::Solid(color)
|
||||
};
|
||||
let fill = Item::Fill(FillItem { path, rule: FillRule::NonZero, paint, overprint: Overprint::default() });
|
||||
if i % 10 == 9 {
|
||||
transparent += 1;
|
||||
let blend = if i % 40 == 9 { BlendMode::Multiply } else { BlendMode::Normal };
|
||||
items.push(Item::Group(GroupItem {
|
||||
opacity: 0.3 + next() as f32 * 0.5,
|
||||
blend,
|
||||
isolated: false,
|
||||
knockout: false,
|
||||
clip: None,
|
||||
mask: None,
|
||||
actual_text: None,
|
||||
children: vec![fill],
|
||||
}));
|
||||
} else {
|
||||
items.push(fill);
|
||||
}
|
||||
}
|
||||
DisplayList { page: Rect::new(0.0, 0.0, 595.0, 842.0), inks: InkSet::new(), items }
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing report; run in release"]
|
||||
fn flatten_timings() {
|
||||
let scenes = scene::load_dir(&scene::scenes_dir()).unwrap();
|
||||
let ctx = Context::from_env(&scenes);
|
||||
// Load the profiles once, so the first scene doesn't pay for it.
|
||||
let _ = stages(&reference::write(&scenes[0], &ctx).unwrap().pdf);
|
||||
println!(
|
||||
"| Page | flatten_page ms | read | flatten | rasterize | write | regions | fills | complex | Mpx |\n|---|---|---|---|---|---|---|---|---|---|"
|
||||
);
|
||||
for s in scenes.iter().filter(|s| s.pending.is_none()) {
|
||||
let pdf = reference::write(s, &ctx).unwrap().pdf;
|
||||
println!("{}", row(&s.id, &stages(&pdf)));
|
||||
}
|
||||
let pages = [
|
||||
("heavy, 2–65 pt, flat paints", 65.0, 0),
|
||||
("heavy, 2–65 pt, 10 gradients", 65.0, 10),
|
||||
("heavy, 2–20 pt, flat paints", 20.0, 0),
|
||||
("heavy, 2–20 pt, 10 gradients", 20.0, 10),
|
||||
];
|
||||
for (name, size, gradients) in pages {
|
||||
let list = heavy(size, gradients);
|
||||
let start = Instant::now();
|
||||
let pdf = page_pdf(&list, settings(), &CmsConversion::fogra39()).unwrap();
|
||||
let wrote = start.elapsed();
|
||||
let start = Instant::now();
|
||||
let p = plates(&pdf, 0, DPI, &Options::default()).unwrap();
|
||||
// What rasterizing a region as large as the page costs: writing its items as a PDF and
|
||||
// separating that.
|
||||
println!("<!-- {name}: page_pdf {} ms, plates {} ms for {} x {} px -->", ms(wrote), ms(start.elapsed()), p.width, p.height);
|
||||
println!("{}", row(name, &stages(&pdf)));
|
||||
let mut st = Stages::default();
|
||||
let start = Instant::now();
|
||||
written(&list, &mut st);
|
||||
st.total = start.elapsed();
|
||||
println!("{}", row(&format!("{name} (display list)"), &st));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! The flattener's writer: display lists written with krilla and separated again by `plates`.
|
||||
//! Pages are 100 pt square and separated at 36 dpi; sample points are in points.
|
||||
//!
|
||||
//! Text cases with an embedded font need Inter from `CRAFT_PDF_TEST_FONT_DIR` or
|
||||
//! `~/.cache/craft-pdf-test-fonts`, and are skipped when it is missing.
|
||||
#![cfg(feature = "flatten")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use craft_pdf::interpret::font::Glyph;
|
||||
use craft_pdf::interpret::hayro_cmap::BfString;
|
||||
use craft_pdf::interpret::{
|
||||
BlendMode as HBlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint as HPaint, PathDrawMode,
|
||||
SoftMask, TransformExt, interpret_page,
|
||||
};
|
||||
use craft_pdf::syntax::object::{Dict, Name, Stream};
|
||||
use craft_pdf::write::backend::SerializeSettings;
|
||||
use craft_pdf::write::backend::configure::PdfVersion;
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::print::{self, PrintColor};
|
||||
use craft_pdf::write::{Metadata, Writer};
|
||||
use craft_prepress::flatten::kurbo::{Affine, BezPath, Point, Rect, Shape};
|
||||
use craft_prepress::flatten::write::{WriteError, page_pdf, write_flattened};
|
||||
use craft_prepress::flatten::{
|
||||
BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, FlattenOptions, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, Paint,
|
||||
PlacedGlyph, TextFont, TextItem, flatten,
|
||||
};
|
||||
use craft_prepress::{Alternate, CmsConversion, Ink, InkColor, InkId, InkSet, Options, Overprint, Plates, Process, Spot, plates};
|
||||
|
||||
const C: InkId = InkId::process(Process::Cyan);
|
||||
const M: InkId = InkId::process(Process::Magenta);
|
||||
const Y: InkId = InkId::process(Process::Yellow);
|
||||
const K: InkId = InkId::process(Process::Black);
|
||||
const OVERPRINT: Overprint = Overprint { fill: true, stroke: false, nonzero: false };
|
||||
|
||||
fn fill(r: Rect, tints: &[(InkId, f32)], overprint: Overprint) -> Item {
|
||||
Item::Fill(FillItem { path: r.to_path(0.1), rule: FillRule::NonZero, paint: Paint::Solid(InkColor::Tints(tints.to_vec())), overprint })
|
||||
}
|
||||
|
||||
fn solid(r: Rect, tints: &[(InkId, f32)]) -> Item {
|
||||
fill(r, tints, Overprint::default())
|
||||
}
|
||||
|
||||
fn group(opacity: f32, children: Vec<Item>) -> Item {
|
||||
Item::Group(GroupItem {
|
||||
opacity,
|
||||
blend: BlendMode::Normal,
|
||||
isolated: false,
|
||||
knockout: false,
|
||||
clip: None,
|
||||
mask: None,
|
||||
actual_text: None,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
fn page(inks: InkSet, items: Vec<Item>) -> DisplayList {
|
||||
DisplayList { page: Rect::new(0.0, 0.0, 100.0, 100.0), inks, items }
|
||||
}
|
||||
|
||||
fn pdf(list: &DisplayList) -> Vec<u8> {
|
||||
// Reading the profile takes a quarter second in a debug build.
|
||||
static SETTINGS: OnceLock<SerializeSettings> = OnceLock::new();
|
||||
let settings = SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap());
|
||||
page_pdf(list, settings.clone(), &CmsConversion::fogra39()).unwrap()
|
||||
}
|
||||
|
||||
/// `list` flattened, with nothing left for the raster step, written.
|
||||
fn flat_pdf(list: &DisplayList) -> Vec<u8> {
|
||||
let flat = flatten(list, &FlattenOptions::default()).unwrap();
|
||||
assert!(flat.complex.is_empty());
|
||||
pdf(&DisplayList { items: flat.items, ..list.clone() })
|
||||
}
|
||||
|
||||
fn separate(pdf: &[u8]) -> Plates {
|
||||
plates(pdf, 0, 36.0, &Options::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Ink `name`'s tint at (`x`, `y`) in points, 0 when the page doesn't use the ink.
|
||||
fn tint(p: &Plates, name: &str, x: u32, y: u32) -> u8 {
|
||||
p.inks.lookup(name).and_then(|id| p.tint(id, x / 2, y / 2)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Each ink's tint at (`x`, `y`) is within 3 of `expected`.
|
||||
fn assert_inks(p: &Plates, x: u32, y: u32, expected: &[(&str, u8)]) {
|
||||
for (name, want) in expected {
|
||||
let got = tint(p, name, x, y);
|
||||
assert!(got.abs_diff(*want) <= 3, "{name} at ({x}, {y}): {got}, expected {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_black_over_cyan_in_a_group_composites_the_same_flattened_or_not() {
|
||||
let list = page(
|
||||
InkSet::new(),
|
||||
vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]), group(0.5, vec![solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])])],
|
||||
);
|
||||
for written in [pdf(&list), flat_pdf(&list)] {
|
||||
let p = separate(&written);
|
||||
assert_inks(&p, 50, 50, &[("Cyan", 128), ("Black", 128)]);
|
||||
assert_inks(&p, 20, 50, &[("Cyan", 255), ("Black", 0)]);
|
||||
assert_inks(&p, 80, 50, &[("Cyan", 0), ("Black", 128)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_opacity_applies_to_the_group_not_to_each_child() {
|
||||
// Two overlapping K100 fills in a 50% group: K50 where they overlap, not K75.
|
||||
let list = page(
|
||||
InkSet::new(),
|
||||
vec![group(0.5, vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(K, 1.0)]), solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])])],
|
||||
);
|
||||
for written in [pdf(&list), flat_pdf(&list)] {
|
||||
let p = separate(&written);
|
||||
assert_inks(&p, 50, 50, &[("Black", 128)]);
|
||||
assert_inks(&p, 20, 50, &[("Black", 128)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_overprint_keeps_the_cyan_under_it() {
|
||||
let list = page(
|
||||
InkSet::new(),
|
||||
vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), fill(Rect::new(50.0, 0.0, 100.0, 100.0), &[(K, 1.0)], OVERPRINT)],
|
||||
);
|
||||
for written in [pdf(&list), flat_pdf(&list)] {
|
||||
let p = separate(&written);
|
||||
assert_inks(&p, 75, 50, &[("Cyan", 255), ("Black", 255)]);
|
||||
assert_inks(&p, 25, 50, &[("Cyan", 255), ("Black", 0)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprinting_process_color_leaves_the_plates_of_inks_it_doesnt_list() {
|
||||
let over = |tints: &[(InkId, f32)], nonzero: bool| {
|
||||
let list = page(
|
||||
InkSet::new(),
|
||||
vec![
|
||||
solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]),
|
||||
fill(Rect::new(50.0, 0.0, 100.0, 100.0), tints, Overprint { nonzero, ..OVERPRINT }),
|
||||
],
|
||||
);
|
||||
separate(&pdf(&list))
|
||||
};
|
||||
// Magenta and yellow only: cyan stays, black stays empty.
|
||||
assert_inks(&over(&[(M, 1.0), (Y, 0.5)], false), 75, 50, &[("Cyan", 255), ("Magenta", 255), ("Yellow", 128), ("Black", 0)]);
|
||||
// A listed cyan at 0 knocks cyan out under OPM 0 ...
|
||||
assert_inks(&over(&[(C, 0.0), (M, 1.0)], false), 75, 50, &[("Cyan", 0), ("Magenta", 255)]);
|
||||
// ... and leaves it under OPM 1.
|
||||
assert_inks(&over(&[(C, 0.0), (M, 1.0)], true), 75, 50, &[("Cyan", 255), ("Magenta", 255)]);
|
||||
// Nothing named paints nothing.
|
||||
assert_inks(&over(&[(C, 0.0)], true), 75, 50, &[("Cyan", 255)]);
|
||||
}
|
||||
|
||||
fn gold() -> Spot {
|
||||
Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.2, 0.8, 0.1]), solidity: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_fills_print_on_their_plate_or_where_the_ink_manager_sends_them() {
|
||||
let mut inks = InkSet::new();
|
||||
let gold = inks.add(Ink::Spot(gold())).unwrap();
|
||||
let list = page(inks.clone(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(gold, 0.8)])]);
|
||||
assert_inks(&separate(&pdf(&list)), 50, 50, &[("Gold", 204), ("Cyan", 0), ("Magenta", 0), ("Yellow", 0)]);
|
||||
|
||||
let mut aliased = inks.clone();
|
||||
assert!(aliased.alias(gold, M));
|
||||
let p = separate(&pdf(&DisplayList { inks: aliased, ..list.clone() }));
|
||||
assert!(p.inks.lookup("Gold").is_none());
|
||||
assert_inks(&p, 50, 50, &[("Magenta", 204), ("Yellow", 0)]);
|
||||
|
||||
let mut process = inks;
|
||||
assert!(process.set_to_process(gold, true));
|
||||
let p = separate(&pdf(&DisplayList { inks: process, ..list }));
|
||||
assert!(p.inks.lookup("Gold").is_none());
|
||||
assert_inks(&p, 50, 50, &[("Cyan", 0), ("Magenta", 41), ("Yellow", 163), ("Black", 20)]);
|
||||
}
|
||||
|
||||
// ---- text ----------------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE: &str = "Hamburg";
|
||||
|
||||
fn inter() -> Option<Vec<u8>> {
|
||||
let dir = std::env::var_os("CRAFT_PDF_TEST_FONT_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts")));
|
||||
let data = dir.as_ref().and_then(|d| std::fs::read(d.join("Inter-Regular.ttf")).ok());
|
||||
if data.is_none() {
|
||||
println!("note: Inter-Regular.ttf not found in {dir:?}; case skipped");
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
struct Outline(BezPath);
|
||||
|
||||
impl ttf_parser::OutlineBuilder for Outline {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((f64::from(x), f64::from(y)));
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((f64::from(x), f64::from(y)));
|
||||
}
|
||||
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
|
||||
self.0.quad_to((f64::from(x1), f64::from(y1)), (f64::from(x), f64::from(y)));
|
||||
}
|
||||
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
|
||||
self.0.curve_to((f64::from(x1), f64::from(y1)), (f64::from(x2), f64::from(y2)), (f64::from(x), f64::from(y)));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
/// `SAMPLE` in K100 at 18 pt on a baseline at (5, 60), with its outlines.
|
||||
fn text_run(font: &[u8], embed: bool) -> TextItem {
|
||||
let face = ttf_parser::Face::parse(font, 0).unwrap();
|
||||
let upem = f64::from(face.units_per_em());
|
||||
let size = 18.0;
|
||||
let transform = Affine::new([1.0, 0.0, 0.0, -1.0, 5.0, 60.0]);
|
||||
let (mut x, mut glyphs, mut outlines) = (0.0, Vec::new(), BezPath::new());
|
||||
for (i, c) in SAMPLE.char_indices() {
|
||||
let id = face.glyph_index(c).unwrap();
|
||||
glyphs.push(PlacedGlyph { id: u32::from(id.0), origin: Point::new(x, 0.0), text: i..i + c.len_utf8() });
|
||||
let mut outline = Outline(BezPath::new());
|
||||
face.outline_glyph(id, &mut outline);
|
||||
outlines.extend((transform * Affine::translate((x, 0.0)) * Affine::scale(size / upem) * outline.0).iter());
|
||||
x += f64::from(face.glyph_hor_advance(id).unwrap()) * size / upem;
|
||||
}
|
||||
TextItem {
|
||||
font: embed.then(|| Arc::new(TextFont { data: Arc::new(font.to_vec()), index: 0, key: 1 })),
|
||||
size: size as f32,
|
||||
transform,
|
||||
glyphs,
|
||||
text: Some(SAMPLE.to_owned()),
|
||||
outlines,
|
||||
paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])),
|
||||
overprint: Overprint::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Reader {
|
||||
unicode: String,
|
||||
actual_text: String,
|
||||
}
|
||||
|
||||
impl<'a> Device<'a> for Reader {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: HBlendMode) {}
|
||||
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &HPaint<'a>, _: &PathDrawMode) {}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: HBlendMode) {}
|
||||
fn draw_glyph(&mut self, glyph: &Glyph<'a>, _: Affine, _: Affine, _: &HPaint<'a>, _: &GlyphDrawMode) {
|
||||
if let Glyph::Outline(o) = glyph {
|
||||
match o.as_unicode() {
|
||||
Some(BfString::Char(c)) => self.unicode.push(c),
|
||||
Some(BfString::String(s)) => self.unicode.push_str(&s),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
fn actual_text(&mut self, text: &[u8]) {
|
||||
self.actual_text.push_str(&match text {
|
||||
[0xFE, 0xFF, rest @ ..] => String::from_utf16_lossy(&rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect::<Vec<_>>()),
|
||||
_ => text.iter().map(|&b| char::from(b)).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn read_text(pdf: &[u8]) -> Reader {
|
||||
let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||||
let page = &doc.pages()[0];
|
||||
let cache = InterpreterCache::new();
|
||||
let bbox = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let mut context = Context::new(page.initial_transform(true).to_kurbo(), bbox, &cache, page.xref(), InterpreterSettings::default());
|
||||
let mut reader = Reader::default();
|
||||
interpret_page(page, &mut context, &mut reader);
|
||||
reader
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_a_font_is_live_and_prints_like_its_outlines() {
|
||||
let Some(font) = inter() else { return };
|
||||
let live = pdf(&page(InkSet::new(), vec![Item::Text(text_run(&font, true))]));
|
||||
let outlined = pdf(&page(InkSet::new(), vec![Item::Text(text_run(&font, false))]));
|
||||
assert_eq!(read_text(&live).unicode, SAMPLE);
|
||||
let (a, b) = (separate(&live), separate(&outlined));
|
||||
let (a, b) = (a.plane(K).unwrap(), b.plane(K).unwrap());
|
||||
assert!(a.iter().map(|v| u64::from(*v)).sum::<u64>() > 255 * 50, "the text prints");
|
||||
let differ = a.iter().zip(b).filter(|(x, y)| x.abs_diff(**y) > 64).count();
|
||||
assert!(differ <= a.len() / 200, "{differ} pixels differ between glyphs and outlines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_without_a_font_is_drawn_as_outlines_with_its_text() {
|
||||
let mut run = TextItem {
|
||||
font: None,
|
||||
size: 18.0,
|
||||
transform: Affine::new([1.0, 0.0, 0.0, -1.0, 5.0, 60.0]),
|
||||
glyphs: Vec::new(),
|
||||
text: Some(SAMPLE.to_owned()),
|
||||
outlines: Rect::new(5.0, 45.0, 80.0, 60.0).to_path(0.1),
|
||||
paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])),
|
||||
overprint: Overprint::default(),
|
||||
};
|
||||
if let Some(font) = inter() {
|
||||
run = TextItem { font: None, ..text_run(&font, false) };
|
||||
}
|
||||
let written = pdf(&page(InkSet::new(), vec![Item::Text(run)]));
|
||||
let read = read_text(&written);
|
||||
assert_eq!(read.actual_text, SAMPLE);
|
||||
assert!(read.unicode.is_empty());
|
||||
}
|
||||
|
||||
// ---- structure -----------------------------------------------------------------------------
|
||||
|
||||
/// Transparency left on a page: groups, soft masks, blend modes other than Normal and
|
||||
/// constant opacity below 1, in its resources and those of the forms it uses.
|
||||
fn transparency(pdf: &[u8]) -> Vec<String> {
|
||||
let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||||
let page = &doc.pages()[0];
|
||||
let mut found = Vec::new();
|
||||
if page.raw().get::<Dict<'_>>(b"Group".as_slice()).is_some() {
|
||||
found.push("page group".to_owned());
|
||||
}
|
||||
visit(&page.resources().x_objects, &page.resources().ext_g_states, &mut found, 0);
|
||||
found
|
||||
}
|
||||
|
||||
fn visit(x_objects: &Dict<'_>, states: &Dict<'_>, found: &mut Vec<String>, depth: usize) {
|
||||
assert!(depth < 16);
|
||||
for key in states.keys() {
|
||||
let Some(state) = states.get::<Dict<'_>>(key.as_ref()) else { continue };
|
||||
if state.get::<Dict<'_>>(b"SMask".as_slice()).is_some() {
|
||||
found.push("soft mask".to_owned());
|
||||
}
|
||||
if let Some(bm) = state.get::<Name<'_>>(b"BM".as_slice())
|
||||
&& bm.as_str() != "Normal"
|
||||
{
|
||||
found.push(format!("blend mode {}", bm.as_str()));
|
||||
}
|
||||
for alpha in ["ca", "CA"] {
|
||||
if state.get::<f32>(alpha.as_bytes()).is_some_and(|a| a < 1.0) {
|
||||
found.push(alpha.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in x_objects.keys() {
|
||||
let Some(x) = x_objects.get::<Stream<'_>>(key.as_ref()) else { continue };
|
||||
let dict = x.dict();
|
||||
if dict.get::<Dict<'_>>(b"Group".as_slice()).is_some() {
|
||||
found.push("group".to_owned());
|
||||
}
|
||||
if let Some(res) = dict.get::<Dict<'_>>(b"Resources".as_slice()) {
|
||||
let sub = |key: &[u8]| res.get::<Dict<'_>>(key).unwrap_or_default();
|
||||
visit(&sub(b"XObject"), &sub(b"ExtGState"), found, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flattened_page_has_no_transparency_left() {
|
||||
let multiply = Item::Group(GroupItem {
|
||||
blend: BlendMode::Multiply,
|
||||
isolated: true,
|
||||
..match group(0.7, vec![solid(Rect::new(30.0, 30.0, 90.0, 90.0), &[(M, 1.0)]), solid(Rect::new(50.0, 10.0, 95.0, 60.0), &[(Y, 0.6)])]) {
|
||||
Item::Group(g) => g,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
});
|
||||
let list = page(
|
||||
InkSet::new(),
|
||||
vec![
|
||||
solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]),
|
||||
group(0.5, vec![solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])]),
|
||||
multiply,
|
||||
],
|
||||
);
|
||||
let unflattened = transparency(&pdf(&list));
|
||||
assert!(unflattened.iter().any(|t| t == "ca") && unflattened.iter().any(|t| t.starts_with("blend mode")), "{unflattened:?}");
|
||||
assert_eq!(transparency(&flat_pdf(&list)), Vec::<String>::new());
|
||||
}
|
||||
|
||||
// ---- images, gradients and complex regions ------------------------------------------------
|
||||
|
||||
fn axial(from: InkColor, to: InkColor) -> Paint {
|
||||
Paint::Gradient {
|
||||
stops: vec![(0.0, from), (1.0, to)],
|
||||
geometry: GradientGeometry::Axial {
|
||||
start: Point::new(0.0, 0.0),
|
||||
end: Point::new(100.0, 0.0),
|
||||
extend: [true, true],
|
||||
transform: Affine::IDENTITY,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overprinting_cyan_gradient_keeps_the_yellow_under_it() {
|
||||
let ramp = Item::Fill(FillItem {
|
||||
path: Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1),
|
||||
rule: FillRule::NonZero,
|
||||
paint: axial(InkColor::Tints(vec![(C, 0.0)]), InkColor::Tints(vec![(C, 1.0)])),
|
||||
overprint: OVERPRINT,
|
||||
});
|
||||
let p = separate(&pdf(&page(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(Y, 1.0)]), ramp])));
|
||||
assert_inks(&p, 26, 50, &[("Cyan", 66), ("Yellow", 255)]);
|
||||
assert_inks(&p, 74, 50, &[("Cyan", 189), ("Yellow", 255)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_images_print_through_the_ink_manager() {
|
||||
let mut inks = InkSet::new();
|
||||
let gold = inks.add(Ink::Spot(gold())).unwrap();
|
||||
assert!(inks.alias(gold, C));
|
||||
let image = Item::Image(ImageItem {
|
||||
space: ImageSpace::Inks(vec![C, gold]),
|
||||
size: (2, 1),
|
||||
samples: Arc::from(&[128, 128, 0, 255][..]),
|
||||
transform: Affine::new([100.0, 0.0, 0.0, 100.0, 0.0, 0.0]),
|
||||
smask: None,
|
||||
overprint: Overprint::default(),
|
||||
jpeg: None,
|
||||
});
|
||||
let p = separate(&pdf(&page(inks, vec![image])));
|
||||
// Two half tints on one plate overlap as halftones: 1 − 0.5 × 0.5.
|
||||
assert_inks(&p, 25, 50, &[("Cyan", 191)]);
|
||||
assert_inks(&p, 75, 50, &[("Cyan", 255)]);
|
||||
assert!(p.inks.lookup("Gold").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complex_regions_are_drawn_where_the_flattened_items_put_them() {
|
||||
let ramp = Item::Fill(FillItem {
|
||||
path: Rect::new(40.0, 0.0, 100.0, 100.0).to_path(0.1),
|
||||
rule: FillRule::NonZero,
|
||||
paint: axial(InkColor::Tints(vec![(K, 0.2)]), InkColor::Tints(vec![(K, 1.0)])),
|
||||
overprint: Overprint::default(),
|
||||
});
|
||||
let list = page(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]), group(0.5, vec![ramp])]);
|
||||
let flat = flatten(&list, &FlattenOptions::default()).unwrap();
|
||||
assert!(!flat.complex.is_empty());
|
||||
// A stand-in for the raster step: magenta over each region's bounds.
|
||||
let mut raster = |region: &ComplexRegion| -> Result<Option<ImageItem>, WriteError> {
|
||||
let b = region.path.bounding_box();
|
||||
Ok(Some(ImageItem {
|
||||
space: ImageSpace::Inks(vec![M]),
|
||||
size: (1, 1),
|
||||
samples: Arc::from(&[255][..]),
|
||||
transform: Affine::new([b.width(), 0.0, 0.0, b.height(), b.x0, b.y0]),
|
||||
smask: None,
|
||||
overprint: Overprint::default(),
|
||||
jpeg: None,
|
||||
}))
|
||||
};
|
||||
let settings = print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap();
|
||||
let mut writer = Writer::new(settings, Metadata::default());
|
||||
let mut page_builder = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap());
|
||||
let mut surface = page_builder.surface();
|
||||
write_flattened(&mut surface, &list, &flat, &CmsConversion::fogra39(), PdfVersion::default(), &mut raster).unwrap();
|
||||
surface.finish();
|
||||
page_builder.finish();
|
||||
let p = separate(&writer.finish().unwrap());
|
||||
assert_inks(&p, 20, 50, &[("Cyan", 255), ("Magenta", 0)]);
|
||||
assert_inks(&p, 50, 50, &[("Cyan", 0), ("Magenta", 255)]);
|
||||
assert_inks(&p, 80, 50, &[("Cyan", 0), ("Magenta", 255), ("Black", 0)]);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Text read from a PDF through hayro-interpret and written again with krilla: as live text
|
||||
//! when the font can be embedded again, as outlines with `/ActualText` otherwise. Covers what
|
||||
//! the PDF-in adapter relies on: glyph ids, positions, Unicode and plates survive the trip.
|
||||
//!
|
||||
//! Fonts not in the repository are read from paths and their cases are skipped when missing:
|
||||
//! Inter from `CRAFT_PDF_TEST_FONT_DIR`, a CFF-flavored OpenType font from
|
||||
//! `CRAFT_PREPRESS_CFF_FONT` (default: Debian's `fonts-urw-base35` Nimbus Sans). The Type1C
|
||||
//! case embeds hayro's Foxit Sans, which is bare CFF.
|
||||
#![cfg(feature = "flatten")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::interpret::font::{Glyph, OutlineFontData, StandardFont};
|
||||
use craft_pdf::interpret::hayro_cmap::BfString;
|
||||
use craft_pdf::interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt,
|
||||
interpret_page,
|
||||
};
|
||||
use craft_pdf::write::backend::color::cmyk;
|
||||
use craft_pdf::write::backend::geom::{PathBuilder, Point as KPoint, Transform};
|
||||
use craft_pdf::write::backend::page::PageSettings;
|
||||
use craft_pdf::write::backend::paint::Fill;
|
||||
use craft_pdf::write::backend::surface::Surface;
|
||||
use craft_pdf::write::backend::tagging::{ContentTag, SpanTag};
|
||||
use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph};
|
||||
use craft_pdf::write::backend::{self, Data};
|
||||
use craft_pdf::write::{Metadata, Writer};
|
||||
use craft_prepress::flatten::font::FontCache;
|
||||
use craft_prepress::flatten::kurbo::{Affine, BezPath, PathEl, Rect, Shape};
|
||||
use craft_prepress::flatten::{PlacedGlyph, TextFont, TextItem};
|
||||
use craft_prepress::{InkColor, InkId, Options, Overprint, Process, plates};
|
||||
|
||||
const W: f64 = 300.0;
|
||||
const H: f64 = 120.0;
|
||||
const SAMPLE: &str = "Hamburgefonstiv 0123";
|
||||
|
||||
// ---- fonts ---------------------------------------------------------------------------------
|
||||
|
||||
fn read_font(path: Option<PathBuf>, what: &str) -> Option<Vec<u8>> {
|
||||
let data = path.as_ref().and_then(|p| std::fs::read(p).ok());
|
||||
if data.is_none() {
|
||||
println!("note: {what} not found at {path:?}; case skipped");
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn inter() -> Option<Vec<u8>> {
|
||||
read_font(std::env::var_os("CRAFT_PDF_TEST_FONT_DIR").map(|d| PathBuf::from(d).join("Inter-Regular.ttf")), "Inter (CRAFT_PDF_TEST_FONT_DIR)")
|
||||
}
|
||||
|
||||
fn cff_otf() -> Option<Vec<u8>> {
|
||||
let path = std::env::var_os("CRAFT_PREPRESS_CFF_FONT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/usr/share/fonts/opentype/urw-base35/NimbusSans-Regular.otf"));
|
||||
read_font(Some(path), "a CFF OpenType font (CRAFT_PREPRESS_CFF_FONT)")
|
||||
}
|
||||
|
||||
// ---- writing -------------------------------------------------------------------------------
|
||||
|
||||
fn black() -> Fill {
|
||||
Fill { paint: cmyk::Color::new(0, 0, 0, 255).into(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn write_page(draw: impl FnOnce(&mut Surface<'_>)) -> Vec<u8> {
|
||||
let settings = backend::SerializeSettings { compress_content_streams: false, ..Default::default() };
|
||||
let mut writer = Writer::new(settings, Metadata::default());
|
||||
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(W as f32, H as f32).unwrap());
|
||||
let mut surface = page.surface();
|
||||
draw(&mut surface);
|
||||
surface.finish();
|
||||
page.finish();
|
||||
writer.finish().unwrap()
|
||||
}
|
||||
|
||||
/// The source document: `SAMPLE` mapped through the font's cmap (no shaping), once on a
|
||||
/// baseline and once rotated and scaled.
|
||||
fn source_pdf(font_bytes: &[u8]) -> Vec<u8> {
|
||||
let face = ttf_parser::Face::parse(font_bytes, 0).unwrap();
|
||||
let upem = f32::from(face.units_per_em());
|
||||
let glyphs: Vec<_> = SAMPLE
|
||||
.char_indices()
|
||||
.map(|(i, c)| {
|
||||
let id = face.glyph_index(c).unwrap();
|
||||
let advance = f32::from(face.glyph_hor_advance(id).unwrap()) / upem;
|
||||
KrillaGlyph::new(GlyphId::new(u32::from(id.0)), advance, 0.0, 0.0, 0.0, i..i + c.len_utf8(), None)
|
||||
})
|
||||
.collect();
|
||||
let font = craft_pdf::write::font(font_bytes, 0, None).unwrap();
|
||||
write_page(|s| {
|
||||
s.set_fill(Some(black()));
|
||||
s.draw_glyphs(KPoint::from_xy(10.0, 40.0), &glyphs, font.clone(), SAMPLE, 24.0, false);
|
||||
s.push_transform(&Transform::from_row(0.96, -0.26, 0.26, 0.96, 20.0, 100.0));
|
||||
s.draw_glyphs(KPoint::from_xy(0.0, 0.0), &glyphs, font, SAMPLE, 13.0, false);
|
||||
s.pop();
|
||||
})
|
||||
}
|
||||
|
||||
fn to_krilla(a: Affine) -> Transform {
|
||||
let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v as f32);
|
||||
Transform::from_row(a, b, c, d, e, f)
|
||||
}
|
||||
|
||||
fn to_krilla_path(path: &BezPath) -> Option<backend::geom::Path> {
|
||||
let mut b = PathBuilder::new();
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32),
|
||||
PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32),
|
||||
PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32),
|
||||
PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32),
|
||||
PathEl::ClosePath => b.close(),
|
||||
}
|
||||
}
|
||||
b.finish()
|
||||
}
|
||||
|
||||
/// Writes text runs as the flattener's output would: live text through krilla when the run
|
||||
/// has a font, else its outlines with the Unicode text as `/ActualText`.
|
||||
fn rewrite(items: &[TextItem]) -> Vec<u8> {
|
||||
let mut fonts: HashMap<u128, Font> = HashMap::new();
|
||||
write_page(|s| {
|
||||
s.set_fill(Some(black()));
|
||||
for item in items {
|
||||
let text = item.text.as_deref().unwrap_or("");
|
||||
match &item.font {
|
||||
Some(tf) => {
|
||||
let font = fonts
|
||||
.entry(tf.key)
|
||||
.or_insert_with(|| Font::new(Data::from(tf.data.clone()), tf.index).expect("krilla reads the font"))
|
||||
.clone();
|
||||
let size = item.size as f64;
|
||||
let glyphs: Vec<_> = item
|
||||
.glyphs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
let next_x = item.glyphs.get(i + 1).map_or(g.origin.x, |n| n.origin.x);
|
||||
let x_advance = ((next_x - g.origin.x) / size) as f32;
|
||||
KrillaGlyph::new(GlyphId::new(g.id), x_advance, 0.0, (g.origin.y / size) as f32, 0.0, g.text.clone(), None)
|
||||
})
|
||||
.collect();
|
||||
// Text space is y up; krilla's glyph placement is y down.
|
||||
s.push_transform(&to_krilla(item.transform * Affine::scale_non_uniform(1.0, -1.0)));
|
||||
s.draw_glyphs(KPoint::from_xy(0.0, 0.0), &glyphs, font, text, item.size, false);
|
||||
s.pop();
|
||||
}
|
||||
None => {
|
||||
let span = SpanTag { actual_text: Some(text), ..SpanTag::empty() };
|
||||
s.start_tagged(ContentTag::Span(span));
|
||||
if let Some(path) = to_krilla_path(&item.outlines) {
|
||||
s.draw_path(&path);
|
||||
}
|
||||
s.end_tagged();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A one-page PDF from raw content and resources, objects numbered from 5.
|
||||
fn raw_pdf(content: &str, resources: &str, objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
|
||||
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
|
||||
out.extend_from_slice(
|
||||
format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
out.extend_from_slice(format!("{} 0 obj {body} endobj\n", i + 5).as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict: &str, data: &str) -> String {
|
||||
format!("<< {dict} /Length {} >> stream\n{data}\nendstream", data.len())
|
||||
}
|
||||
|
||||
// ---- reading -------------------------------------------------------------------------------
|
||||
|
||||
/// What the PDF-in adapter needs from one drawn glyph.
|
||||
struct Drawn {
|
||||
/// The font's key in the [`FontCache`]; `None` for Type3 glyphs.
|
||||
key: Option<u128>,
|
||||
/// What hayro hands out for the font, for the checks.
|
||||
data: Option<OutlineFontData>,
|
||||
id: u32,
|
||||
/// Glyph space (1000 units per em, y up) to page space.
|
||||
matrix: Affine,
|
||||
unicode: Option<String>,
|
||||
vertical: bool,
|
||||
type3: bool,
|
||||
outline: BezPath,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Recorder {
|
||||
glyphs: Vec<Drawn>,
|
||||
actual_text: String,
|
||||
fonts: FontCache,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PathSink(BezPath);
|
||||
|
||||
impl Device<'_> for PathSink {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) {
|
||||
self.0.extend((transform * path.clone()).iter());
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
|
||||
fn unicode(s: BfString) -> String {
|
||||
match s {
|
||||
BfString::Char(c) => c.to_string(),
|
||||
BfString::String(s) => s,
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Device<'a> for Recorder {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, mode: &GlyphDrawMode) {
|
||||
if !matches!(mode, GlyphDrawMode::Fill) {
|
||||
return;
|
||||
}
|
||||
let matrix = transform * glyph_transform;
|
||||
let drawn = match glyph {
|
||||
Glyph::Outline(o) => Drawn {
|
||||
key: Some(self.fonts.note(o)),
|
||||
data: o.font_data(),
|
||||
id: o.glyph_id().to_u32(),
|
||||
matrix,
|
||||
unicode: o.as_unicode().map(unicode),
|
||||
vertical: o.is_vertical(),
|
||||
type3: false,
|
||||
outline: matrix * o.outline(),
|
||||
},
|
||||
Glyph::Type3(t) => {
|
||||
let mut sink = PathSink::default();
|
||||
t.interpret(&mut sink, transform, glyph_transform, paint);
|
||||
Drawn { key: None, data: None, id: 0, matrix, unicode: t.as_unicode().map(unicode), vertical: false, type3: true, outline: sink.0 }
|
||||
}
|
||||
};
|
||||
self.glyphs.push(drawn);
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
fn actual_text(&mut self, text: &[u8]) {
|
||||
self.actual_text.push_str(&pdf_text_string(text));
|
||||
}
|
||||
}
|
||||
|
||||
fn pdf_text_string(bytes: &[u8]) -> String {
|
||||
match bytes {
|
||||
[0xFE, 0xFF, rest @ ..] => {
|
||||
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
_ => bytes.iter().map(|&b| char::from(b)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read(pdf: &[u8]) -> Recorder {
|
||||
let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||||
let page = &doc.pages()[0];
|
||||
let cache = InterpreterCache::new();
|
||||
let mut context =
|
||||
Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, W, H), &cache, page.xref(), InterpreterSettings::default());
|
||||
let mut recorder = Recorder::default();
|
||||
interpret_page(page, &mut context, &mut recorder);
|
||||
recorder
|
||||
}
|
||||
|
||||
// ---- runs, as the adapter builds them ------------------------------------------------------
|
||||
|
||||
fn same_linear(a: Affine, b: Affine) -> bool {
|
||||
let (a, b) = (a.as_coeffs(), b.as_coeffs());
|
||||
let scale = a[..4].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
a[..4].iter().zip(&b[..4]).all(|(x, y)| (x - y).abs() <= scale * 1e-6)
|
||||
}
|
||||
|
||||
/// Groups drawn glyphs into runs: one font and one text matrix (up to translation) each.
|
||||
fn text_items(drawn: &[Drawn], fonts: &HashMap<u128, Option<Arc<TextFont>>>) -> Vec<TextItem> {
|
||||
let mut items: Vec<TextItem> = Vec::new();
|
||||
for d in drawn {
|
||||
let font = d.key.and_then(|key| fonts[&key].clone());
|
||||
let linear = d.matrix * Affine::scale(1000.0);
|
||||
let continues = items
|
||||
.last()
|
||||
.is_some_and(|last| last.font == font && last.font.is_some() && same_linear(last.transform * Affine::scale(last.size as f64), linear));
|
||||
if !continues {
|
||||
let size = linear.determinant().abs().sqrt();
|
||||
let origin = d.matrix.translation();
|
||||
let [a, b, c, dd, ..] = linear.as_coeffs();
|
||||
items.push(TextItem {
|
||||
font: font.clone(),
|
||||
size: size as f32,
|
||||
transform: Affine::new([a / size, b / size, c / size, dd / size, origin.x, origin.y]),
|
||||
glyphs: Vec::new(),
|
||||
text: Some(String::new()),
|
||||
outlines: BezPath::new(),
|
||||
paint: craft_prepress::flatten::Paint::Solid(InkColor::Tints(vec![(InkId::process(Process::Black), 1.0)])),
|
||||
overprint: Overprint::default(),
|
||||
});
|
||||
}
|
||||
let item = items.last_mut().unwrap();
|
||||
let text = item.text.as_mut().unwrap();
|
||||
let start = text.len();
|
||||
text.push_str(d.unicode.as_deref().unwrap_or(""));
|
||||
let origin = item.transform.inverse() * d.matrix.translation().to_point();
|
||||
item.glyphs.push(PlacedGlyph { id: d.id, origin, text: start..text.len() });
|
||||
item.outlines.extend(d.outline.iter());
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
// ---- checks --------------------------------------------------------------------------------
|
||||
|
||||
fn black_plate(pdf: &[u8]) -> Vec<u8> {
|
||||
let p = plates(pdf, 0, 72.0, &Options::default()).unwrap();
|
||||
p.plane(InkId::process(Process::Black)).unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// Mean and largest per-pixel difference of the black plates.
|
||||
fn plate_difference(a: &[u8], b: &[u8]) -> (f64, u8) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
let max = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
|
||||
let mean = a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.len() as f64;
|
||||
(mean, max)
|
||||
}
|
||||
|
||||
fn text_of(r: &Recorder) -> String {
|
||||
r.glyphs.iter().filter_map(|g| g.unicode.as_deref()).collect()
|
||||
}
|
||||
|
||||
/// Reads `source`, writes it again from the runs, and checks positions, outlines, text and
|
||||
/// plates. Returns the runs, the source and the rewrite as read.
|
||||
fn round_trip(source: &[u8]) -> (Vec<TextItem>, Recorder, Recorder) {
|
||||
let mut before = read(source);
|
||||
assert!(!before.glyphs.is_empty());
|
||||
let items = text_items(&before.glyphs, &std::mem::take(&mut before.fonts).finish());
|
||||
let pdf = rewrite(&items);
|
||||
let after = read(&pdf);
|
||||
|
||||
let (mean, max) = plate_difference(&black_plate(source), &black_plate(&pdf));
|
||||
println!("black plates: mean difference {mean:.4}, largest {max}");
|
||||
assert!(mean < 0.01 && max <= 4, "plates differ: mean {mean}, max {max}");
|
||||
|
||||
if items.iter().all(|i| i.font.is_some()) {
|
||||
assert_eq!(after.glyphs.len(), before.glyphs.len());
|
||||
for (b, a) in before.glyphs.iter().zip(&after.glyphs) {
|
||||
let (pa, pb) = (a.matrix.translation(), b.matrix.translation());
|
||||
assert!((pa - pb).hypot() < 1e-3, "glyph origin {pa:?} vs {pb:?}");
|
||||
assert!(same_linear(a.matrix, b.matrix));
|
||||
let (ba, bb) = (a.outline.bounding_box(), b.outline.bounding_box());
|
||||
assert!((ba.min_x() - bb.min_x()).abs() < 1e-3 && (ba.max_y() - bb.max_y()).abs() < 1e-3, "outline {ba:?} vs {bb:?}");
|
||||
}
|
||||
assert_eq!(text_of(&after), text_of(&before), "Unicode survives");
|
||||
} else {
|
||||
assert_eq!(after.actual_text, text_of(&before), "Unicode survives as ActualText");
|
||||
}
|
||||
(items, before, after)
|
||||
}
|
||||
|
||||
/// krilla keeps glyph ids when the source is one of its own subsets: those are compact in
|
||||
/// first-use order already.
|
||||
fn assert_same_ids(before: &Recorder, after: &Recorder) {
|
||||
assert!(before.glyphs.iter().zip(&after.glyphs).all(|(b, a)| a.id == b.id), "glyph ids match");
|
||||
}
|
||||
|
||||
/// Whether the rewrite has the text as live text in an embedded CFF font.
|
||||
fn has_live_cff(after: &Recorder) -> bool {
|
||||
after.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| d.embedded && (*d.data).as_ref().starts_with(&[1])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truetype_text_reembeds_as_live_text() {
|
||||
let Some(font) = inter() else { return };
|
||||
let source = source_pdf(&font);
|
||||
let (items, before, after) = round_trip(&source);
|
||||
assert_eq!(items.len(), 2, "two runs: one per text matrix");
|
||||
assert!(items.iter().all(|i| i.font.is_some()));
|
||||
assert_same_ids(&before, &after);
|
||||
assert_eq!(text_of(&after), SAMPLE.repeat(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cff_text_reembeds_after_wrapping_the_bare_cff() {
|
||||
let Some(font) = cff_otf() else { return };
|
||||
let source = source_pdf(&font);
|
||||
assert!(String::from_utf8_lossy(&source).contains("/CIDFontType0C"), "krilla writes CFF as bare CIDFontType0C");
|
||||
let (items, before, after) = round_trip(&source);
|
||||
let data = before.glyphs[0].data.as_ref().unwrap().data.clone();
|
||||
assert_eq!((*data).as_ref()[0], 1, "hayro hands out the bare CFF");
|
||||
assert!(Font::new(Data::from(data), 0).is_none(), "krilla needs an OpenType wrapper");
|
||||
assert!(items.iter().all(|i| i.font.is_some()));
|
||||
assert!(has_live_cff(&after), "the rewrite embeds CFF again, not outlines");
|
||||
assert_same_ids(&before, &after);
|
||||
assert_eq!(text_of(&after), SAMPLE.repeat(2));
|
||||
}
|
||||
|
||||
const TYPE3_CMAP: &str = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /T3 def \
|
||||
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def 1 begincodespacerange <00> <FF> endcodespacerange \
|
||||
2 beginbfchar <41> <0041> <42> <0042> endbfchar endcmap CMapName currentdict /CMapResource defineresource pop end end";
|
||||
|
||||
#[test]
|
||||
fn type3_text_falls_back_to_outlines_with_actual_text() {
|
||||
let font = "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \
|
||||
/CharProcs << /a 6 0 R /b 7 0 R >> /Encoding << /Type /Encoding /Differences [65 /a /b] >> \
|
||||
/FirstChar 65 /LastChar 66 /Widths [1000 1000] /Resources << >> /ToUnicode 8 0 R >>";
|
||||
let objects = [
|
||||
font.to_owned(),
|
||||
stream("", "1000 0 0 0 800 800 d1 0 0 800 800 re f"),
|
||||
stream("", "1000 0 0 0 800 800 d1 0 0 m 800 0 l 400 800 l f"),
|
||||
stream("", TYPE3_CMAP),
|
||||
];
|
||||
let source = raw_pdf("0 0 0 1 k BT /F1 30 Tf 10 30 Td (ABBA) Tj ET", "/Font << /F1 5 0 R >>", &objects);
|
||||
let (items, before, _) = round_trip(&source);
|
||||
assert!(before.glyphs.iter().all(|g| g.type3 && g.key.is_none()));
|
||||
assert!(items.iter().all(|i| i.font.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_14_text_has_no_font_bytes_and_falls_back_to_outlines() {
|
||||
let source = raw_pdf(
|
||||
"0 0 0 1 k BT /F1 24 Tf 10 40 Td (Hamburg) Tj ET",
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
|
||||
&[],
|
||||
);
|
||||
let (items, before, _) = round_trip(&source);
|
||||
assert!(before.glyphs.iter().all(|g| !g.type3 && g.data.is_none()), "hayro returns no bytes for standard fonts");
|
||||
assert!(items.iter().all(|i| i.font.is_none()));
|
||||
assert_eq!(text_of(&before), "Hamburg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_text_reembeds_as_horizontal_glyphs_at_their_positions() {
|
||||
let Some(font) = cff_otf().or_else(inter) else { return };
|
||||
let mut source = source_pdf(&font);
|
||||
let at: Vec<usize> = source.windows(11).enumerate().filter(|(_, w)| *w == b"/Identity-H").map(|(i, _)| i).collect();
|
||||
assert_eq!(at.len(), 1, "one Type0 font");
|
||||
// Same length, so the cross-reference offsets stay valid.
|
||||
source[at[0] + 10] = b'V';
|
||||
let (items, before, after) = round_trip(&source);
|
||||
assert!(before.glyphs.iter().all(|g| g.vertical));
|
||||
assert!(items.iter().all(|i| i.font.is_some()));
|
||||
assert_same_ids(&before, &after);
|
||||
assert!(after.glyphs.iter().all(|g| !g.vertical), "krilla writes horizontal fonts only");
|
||||
}
|
||||
|
||||
const IDENTITY_CMAP: &str = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /U def \
|
||||
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def 1 begincodespacerange <0000> <FFFF> endcodespacerange \
|
||||
2 beginbfchar <0041> <0041> <0042> <0042> endbfchar endcmap CMapName currentdict /CMapResource defineresource pop end end";
|
||||
|
||||
#[test]
|
||||
fn a_non_embedded_cid_font_falls_back_to_outlines() {
|
||||
let objects = [
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /NotEmbedded /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 8 0 R >>".to_owned(),
|
||||
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /NotEmbedded /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
|
||||
/FontDescriptor 7 0 R >>"
|
||||
.to_owned(),
|
||||
"<< /Type /FontDescriptor /FontName /NotEmbedded /Flags 32 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 \
|
||||
/CapHeight 700 /StemV 80 >>"
|
||||
.to_owned(),
|
||||
stream("", IDENTITY_CMAP),
|
||||
];
|
||||
let source = raw_pdf("0 0 0 1 k BT /F1 24 Tf 10 40 Td <00410042> Tj ET", "/Font << /F1 5 0 R >>", &objects);
|
||||
let (items, before, _) = round_trip(&source);
|
||||
// hayro hands out the font resolver's substitute, marked as not embedded.
|
||||
assert!(before.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| !d.embedded)));
|
||||
assert!(items.iter().all(|i| i.font.is_none()));
|
||||
assert_eq!(text_of(&before), "AB");
|
||||
}
|
||||
|
||||
/// A simple font with `FontFile3/Type1C`: hayro's Foxit Sans substitute, which is bare CFF,
|
||||
/// hex-encoded so the PDF stays text. The widths are made up; positions follow them.
|
||||
fn type1c_pdf() -> Vec<u8> {
|
||||
let (data, _) = StandardFont::Helvetica.get_font_data();
|
||||
let hex: String = (*data).as_ref().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let widths: Vec<String> = (32..=126).map(|c| (400 + c % 7 * 50).to_string()).collect();
|
||||
let objects = [
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /ChromSansOTF /FirstChar 32 /LastChar 126 /Widths [{}] \
|
||||
/Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>",
|
||||
widths.join(" ")
|
||||
),
|
||||
"<< /Type /FontDescriptor /FontName /ChromSansOTF /Flags 32 /FontBBox [-166 -225 1000 931] /ItalicAngle 0 /Ascent 718 \
|
||||
/Descent -207 /CapHeight 718 /StemV 88 /FontFile3 7 0 R >>"
|
||||
.to_owned(),
|
||||
stream("/Subtype /Type1C /Filter /ASCIIHexDecode", &format!("{hex}>")),
|
||||
];
|
||||
let content = format!("0 0 0 1 k BT /F1 24 Tf 10 40 Td ({SAMPLE}) Tj ET BT /F1 13 Tf 0.96 0.26 -0.26 0.96 20 80 Tm ({SAMPLE}) Tj ET");
|
||||
raw_pdf(&content, "/Font << /F1 5 0 R >>", &objects)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type1c_text_reembeds_after_wrapping_the_cff() {
|
||||
let source = type1c_pdf();
|
||||
let (items, before, after) = round_trip(&source);
|
||||
assert!(before.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| d.embedded && (*d.data).as_ref()[0] == 1)), "bare CFF from the file");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(items.iter().all(|i| i.font.is_some()));
|
||||
assert!(has_live_cff(&after), "the rewrite embeds CFF again, not outlines");
|
||||
assert_eq!(text_of(&after), SAMPLE.repeat(2));
|
||||
}
|
||||
@@ -12,14 +12,30 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = { workspace = true }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"], optional = true }
|
||||
|
||||
# Fading a native window while it is dragged (`NSWindow.alphaValue`); winit already uses these.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSString"] }
|
||||
|
||||
[features]
|
||||
serde = ["dep:serde"]
|
||||
# Floating windows may move to their own OS windows (desktop egui backends with viewports).
|
||||
native-windows = []
|
||||
# `docking::testing`: a headless driver for application docking tests.
|
||||
testing = []
|
||||
|
||||
[dev-dependencies]
|
||||
egui = { workspace = true, features = ["default_fonts"] }
|
||||
serde_json = "1"
|
||||
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
|
||||
egui_kittest = { version = "0.37", features = ["wgpu"] }
|
||||
eframe = { version = "0.37", default-features = false, features = ["wgpu", "x11", "default_fonts"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[[example]]
|
||||
name = "docking_native"
|
||||
required-features = ["native-windows"]
|
||||
@@ -107,6 +107,13 @@ indices and widths, overflow indices, and the actual reserved overflow width. It
|
||||
tabs before hiding them, keeps input order and retains the selected tab. Tiny strips may reduce
|
||||
its width to zero. Negative/non-finite dimensions become zero and large dimensions are bounded.
|
||||
The work is linear in tab count with at most 32 fitting passes; the caller caps its own tab count.
|
||||
Docking tab strips use this policy, with `DockStyle::min_tab_width` as the minimum.
|
||||
|
||||
`tabs::fit_whole(natural_widths, selected, available, overflow_width)` keeps every shown tab at its
|
||||
natural width and sends the rest to overflow. Tabs are shown in order up to the first that does not
|
||||
fit; the selected tab is always shown in its logical place, taking the place of the last tabs that
|
||||
fit when it lies past them. Only a selected tab wider than the strip minus the overflow button is
|
||||
narrowed.
|
||||
|
||||
`tabs::Tab` registers a tab at a supplied rectangle and invokes the application's painter:
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Docking with floating panels in their own OS windows. Run with
|
||||
//! `cargo run -p craft-ui --example docking_native --features native-windows`.
|
||||
//!
|
||||
//! Prints every action it applies and every native window's place, for checking moves by
|
||||
//! hand or under a headless X server.
|
||||
|
||||
use craft_ui::docking::{DockArea, DockContent, DockStyle, Floating, Layout, Node};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
|
||||
struct Panels;
|
||||
|
||||
impl DockContent<u32> for Panels {
|
||||
fn body(&mut self, ui: &mut egui::Ui, panel: &u32) {
|
||||
ui.label(format!("Body of panel {panel}"));
|
||||
}
|
||||
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
format!("Panel {panel}")
|
||||
}
|
||||
}
|
||||
|
||||
struct App {
|
||||
layout: Layout<u32>,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let style = DockStyle::from_ui(ui);
|
||||
let out = DockArea::new(egui::Id::unique("dock")).show_content(ui, &self.layout, &style, &mut Panels);
|
||||
if !out.actions.is_empty() {
|
||||
println!("actions {:?}", out.actions);
|
||||
if let Err(error) = self.layout.apply_all(out.actions, |_| Default::default()) {
|
||||
println!("rejected {error:?}");
|
||||
}
|
||||
for window in &self.layout.floating {
|
||||
println!("window {} panels {:?} native {:?}", window.id, window.panels().collect::<Vec<_>>(), window.native);
|
||||
}
|
||||
}
|
||||
if !out.forwarded_events.is_empty() {
|
||||
println!("forwarded {:?} {:?}", out.forwarded_events, out.forwarded_modifiers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let tabs = |panels: &[u32]| Node::Tabs { panels: panels.to_vec(), active: 0 };
|
||||
let layout = Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::Ratio(0.5),
|
||||
first: Box::new(tabs(&[1, 2])),
|
||||
second: Box::new(tabs(&[3, 4])),
|
||||
}),
|
||||
floating: vec![Floating::single(vec![5, 6], 0, [300.0, 200.0, 320.0, 260.0]), Floating::single(vec![7], 0, [700.0, 300.0, 260.0, 200.0])],
|
||||
};
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default().with_inner_size([1000.0, 700.0]).with_position([0.0, 0.0]),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native("Docking", options, Box::new(|_| Ok(Box::new(App { layout }))))
|
||||
}
|
||||
@@ -390,7 +390,7 @@ fn dock_layout(panels: u32, with_floating: bool) -> Layout<u32> {
|
||||
}),
|
||||
second: Box::new(leaf(2)),
|
||||
}),
|
||||
floating: vec![Floating { panels: vec![3], active: 0, rect: [80.0, 70.0, 300.0, 220.0] }],
|
||||
floating: vec![Floating::single(vec![3], 0, [80.0, 70.0, 300.0, 220.0]).with_id(1)],
|
||||
}
|
||||
} else {
|
||||
let all: Vec<u32> = (0..panels).collect();
|
||||
@@ -662,7 +662,7 @@ fn run_case(case: Case<'_>, fonts: &egui::FontDefinitions) -> Result<Value, Box<
|
||||
let dock_id = Id::unique(("ui-perf", case.label));
|
||||
let layout = dock_layout(case.panels, case.floating);
|
||||
layout.validate()?;
|
||||
let floating: BTreeSet<u32> = layout.floating.iter().flat_map(|group| group.panels.iter().copied()).collect();
|
||||
let floating: BTreeSet<u32> = layout.floating.iter().flat_map(|group| group.panels().copied()).collect();
|
||||
let mut scene =
|
||||
Scene { dock_id, side_panel: case.side_panel, style: None, error: None, dock_rect: Rect::NOTHING, left_rect: None, central_rect: None };
|
||||
let frames = {
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
//! Floating window chrome: an opaque frame with anchored resize handles, and a title bar with an
|
||||
//! optional title, ▾ menu button and ×. [`super::DockArea`] draws its floating windows with
|
||||
//! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes
|
||||
//! a layout: the caller turns the responses into its own commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{
|
||||
Color32, Context, FontId, Galley, Id, Key, LayerId, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2,
|
||||
};
|
||||
|
||||
use super::{DockStyle, PanelLimits, resize::AnchoredResize};
|
||||
|
||||
/// Height of a floating window's title bar.
|
||||
pub const TITLE_HEIGHT: f32 = 14.0;
|
||||
|
||||
/// Names of the resize handles, appended to the frame id: `id.with(name)`.
|
||||
pub const RESIZE_HANDLES: [&str; 8] =
|
||||
["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left", "resize-top", "resize-top-left", "resize-top-right"];
|
||||
|
||||
/// Height of the top resize band of a resizable frame. The frame's contents start below it, so
|
||||
/// the band takes no moves or clicks from a title bar.
|
||||
pub const RESIZE_BAND: f32 = 4.0;
|
||||
|
||||
/// Colors and font of the frame and title bar.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChromeStyle {
|
||||
/// Painted under the contents, so the window stays opaque when the contents are not.
|
||||
pub background: Color32,
|
||||
pub stroke: Stroke,
|
||||
pub title_background: Color32,
|
||||
pub text: Color32,
|
||||
pub weak_text: Color32,
|
||||
pub accent: Color32,
|
||||
pub font: FontId,
|
||||
/// Mirror the title bar: buttons at the left, the title at the right.
|
||||
pub right_to_left: bool,
|
||||
}
|
||||
|
||||
impl ChromeStyle {
|
||||
pub fn from_ui(ui: &Ui) -> Self {
|
||||
Self::from_dock(&DockStyle::from_ui(ui))
|
||||
}
|
||||
|
||||
pub fn from_dock(style: &DockStyle) -> Self {
|
||||
Self {
|
||||
background: style.floating_background,
|
||||
stroke: style.floating_stroke,
|
||||
title_background: style.tab_background,
|
||||
text: style.text,
|
||||
weak_text: style.inactive_text,
|
||||
accent: style.accent,
|
||||
font: style.font.clone(),
|
||||
right_to_left: style.right_to_left,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize handle hit rects and the edges they move: every side and corner. The top handles
|
||||
/// fill the band above the contents; the others lie over the contents' edges.
|
||||
fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 8] {
|
||||
let band_bottom = (rect.top() + RESIZE_BAND).min(rect.bottom());
|
||||
let left_corner = (rect.left() + 12.0).min(rect.right());
|
||||
let right_corner = (rect.right() - 12.0).max(rect.left());
|
||||
[
|
||||
("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)),
|
||||
(
|
||||
"resize-left",
|
||||
Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)),
|
||||
vec2(-1.0, 0.0),
|
||||
),
|
||||
(
|
||||
"resize-right",
|
||||
Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)),
|
||||
vec2(1.0, 0.0),
|
||||
),
|
||||
(
|
||||
"resize-bottom",
|
||||
Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())),
|
||||
vec2(0.0, 1.0),
|
||||
),
|
||||
(
|
||||
"resize-bottom-left",
|
||||
Rect::from_min_max(pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), pos2(left_corner, rect.bottom())),
|
||||
vec2(-1.0, 1.0),
|
||||
),
|
||||
("resize-top", Rect::from_min_max(pos2(left_corner, rect.top()), pos2(right_corner, band_bottom)), vec2(0.0, -1.0)),
|
||||
("resize-top-left", Rect::from_min_max(rect.min, pos2(left_corner, band_bottom)), vec2(-1.0, -1.0)),
|
||||
("resize-top-right", Rect::from_min_max(pos2(right_corner, rect.top()), pos2(rect.right(), band_bottom)), vec2(1.0, -1.0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// An opaque in-window frame on its own `Order::Middle` layer, `id`, at `rect`.
|
||||
pub struct FloatingFrame<'a> {
|
||||
id: Id,
|
||||
rect: Rect,
|
||||
bounds: Option<Rect>,
|
||||
limits: PanelLimits,
|
||||
resizable: bool,
|
||||
resize_label: &'a str,
|
||||
}
|
||||
|
||||
/// What a [`FloatingFrame`] did this frame.
|
||||
pub struct FrameResponse<R> {
|
||||
pub inner: R,
|
||||
/// Where the frame was drawn, inside its bounds.
|
||||
pub rect: Rect,
|
||||
pub layer: LayerId,
|
||||
/// The rect a resize handle asks for: pointer drags anchored at the opposite edges, or
|
||||
/// arrow keys on a focused handle (8 pt per press). Escape and focus loss end a resize.
|
||||
pub resized: Option<Rect>,
|
||||
/// The handle whose pointer resize started this frame.
|
||||
pub resize_started: Option<Id>,
|
||||
/// Every handle's response, for focus and gesture bookkeeping.
|
||||
pub handles: Vec<Response>,
|
||||
}
|
||||
|
||||
impl<'a> FloatingFrame<'a> {
|
||||
pub fn new(id: Id, rect: Rect) -> Self {
|
||||
Self { id, rect, bounds: None, limits: PanelLimits::default(), resizable: true, resize_label: "Resize window" }
|
||||
}
|
||||
|
||||
/// The frame stays inside `bounds`; the default is the viewport's content rect.
|
||||
pub fn bounds(mut self, bounds: Rect) -> Self {
|
||||
self.bounds = Some(bounds);
|
||||
self
|
||||
}
|
||||
|
||||
/// Size limits of a resize, top resize band included. The frame itself is drawn at the
|
||||
/// given rect.
|
||||
pub fn limits(mut self, limits: PanelLimits) -> Self {
|
||||
self.limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resizable(mut self, resizable: bool) -> Self {
|
||||
self.resizable = resizable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Accessibility label of the resize handles.
|
||||
pub fn resize_label(mut self, label: &'a str) -> Self {
|
||||
self.resize_label = label;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn layer(id: Id) -> LayerId {
|
||||
LayerId::new(Order::Middle, id)
|
||||
}
|
||||
|
||||
/// Draw the frame, then `add_contents` with the rect for its contents (the frame's rect below
|
||||
/// the top resize band when resizable), then the outline and handles.
|
||||
pub fn show<R>(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse<R> {
|
||||
let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect());
|
||||
let rect = clamp(self.rect, bounds);
|
||||
let area = egui::Area::new(self.id)
|
||||
.fade_in(false)
|
||||
.order(Order::Middle)
|
||||
.fixed_pos(rect.min)
|
||||
.default_size(rect.size())
|
||||
.movable(false)
|
||||
.constrain_to(bounds);
|
||||
let shown = area.show(ctx, |ui| {
|
||||
// Drawn at `rect` itself, also in egui's first (sizing) pass of a new area.
|
||||
ui.set_min_size(rect.size());
|
||||
ui.allocate_rect(rect, Sense::hover());
|
||||
ui.painter().rect_filled(rect, 0.0, style.background);
|
||||
let contents =
|
||||
if self.resizable { Rect::from_min_max(pos2(rect.left(), (rect.top() + RESIZE_BAND).min(rect.bottom())), rect.max) } else { rect };
|
||||
let inner = add_contents(ui, contents);
|
||||
ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside);
|
||||
let (resized, resize_started, handles) = if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) };
|
||||
FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles }
|
||||
});
|
||||
shown.inner
|
||||
}
|
||||
|
||||
fn handles(&self, ui: &mut Ui, rect: Rect, bounds: Rect, style: &ChromeStyle) -> (Option<Rect>, Option<Id>, Vec<Response>) {
|
||||
let mut resized = None;
|
||||
let mut started = None;
|
||||
let mut responses = Vec::new();
|
||||
let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits };
|
||||
let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx());
|
||||
for (name, handle, direction) in handles(rect) {
|
||||
if !super::targets::usable(handle) {
|
||||
continue;
|
||||
}
|
||||
let cursor = if direction.x == 0.0 {
|
||||
egui::CursorIcon::ResizeVertical
|
||||
} else if direction.y == 0.0 {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
} else if direction.x == direction.y {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
};
|
||||
let response = ui.interact(handle, self.id.with(name), Sense::drag()).on_hover_cursor(cursor);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), self.resize_label));
|
||||
let anchor_id = response.id.with("pointer-anchor");
|
||||
crate::layout::drag_focus(ui, &response);
|
||||
if response.drag_started()
|
||||
&& let Some(origin) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(anchor) = AnchoredResize::new(rect, origin, direction)
|
||||
{
|
||||
ui.ctx().data_mut(|data| data.insert_temp(anchor_id, anchor));
|
||||
started = Some(response.id);
|
||||
}
|
||||
let mut delta = Vec2::ZERO;
|
||||
if response.has_focus() {
|
||||
ui.memory_mut(|memory| {
|
||||
memory.set_focus_lock_filter(
|
||||
response.id,
|
||||
egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() },
|
||||
)
|
||||
});
|
||||
ui.input_mut(|input| {
|
||||
for (key, step) in [
|
||||
(Key::ArrowLeft, vec2(-8.0, 0.0)),
|
||||
(Key::ArrowRight, vec2(8.0, 0.0)),
|
||||
(Key::ArrowUp, vec2(0.0, -8.0)),
|
||||
(Key::ArrowDown, vec2(0.0, 8.0)),
|
||||
] {
|
||||
if input.consume_key(egui::Modifiers::NONE, key) {
|
||||
delta += step;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let next = if response.dragged() || response.drag_stopped() {
|
||||
ui.ctx()
|
||||
.data(|data| data.get_temp::<AnchoredResize>(anchor_id))
|
||||
.and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, bounds, limits)))
|
||||
} else if delta != Vec2::ZERO {
|
||||
AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, bounds, limits))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if response.enabled()
|
||||
&& !canceled
|
||||
&& let Some(next) = next.filter(|next| *next != rect)
|
||||
{
|
||||
resized = Some(next);
|
||||
}
|
||||
if canceled || !ui.input(|input| input.pointer.primary_down()) {
|
||||
ui.ctx().data_mut(|data| data.remove::<AnchoredResize>(anchor_id));
|
||||
}
|
||||
if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) {
|
||||
ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, style.accent));
|
||||
}
|
||||
responses.push(response);
|
||||
}
|
||||
(resized, started, responses)
|
||||
}
|
||||
}
|
||||
|
||||
/// A drop preview: a 2 pt accent outline inside `rect`, filled with 15% accent when `filled`.
|
||||
/// Docking and the pane host draw every destination and drag ghost with it.
|
||||
pub fn drop_preview(painter: &egui::Painter, rect: Rect, accent: Color32, filled: bool) {
|
||||
let fill = if filled { accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT };
|
||||
painter.rect(rect, 0.0, fill, Stroke::new(2.0, accent), StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// Keep `rect` inside `bounds`, shrinking it when it is larger.
|
||||
pub fn clamp(rect: Rect, bounds: Rect) -> Rect {
|
||||
if !super::targets::usable(bounds) {
|
||||
return rect;
|
||||
}
|
||||
let size = rect.size().max(vec2(1.0, 1.0)).min(bounds.size());
|
||||
let min = rect.min.max(bounds.min).min(bounds.max - size);
|
||||
Rect::from_min_size(min, size)
|
||||
}
|
||||
|
||||
/// `text` on one line no wider than `width`, cut at the end with '…'. egui does not wrap a
|
||||
/// host's pre-laid-out `WidgetText::Galley` again, so that one is laid out again from its job.
|
||||
pub(super) fn truncated(ui: &Ui, text: WidgetText, width: f32, font: FontId) -> Arc<Galley> {
|
||||
let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), width, font);
|
||||
if galley.size().x <= width + 0.5 {
|
||||
return galley;
|
||||
}
|
||||
let mut job = (*galley.job).clone();
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(width);
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// A title bar: an optional title, an optional ▾ menu button and an optional ×, by default 10 pt
|
||||
/// in from the right edge, mirrored when [`ChromeStyle::right_to_left`] is set. The bar senses
|
||||
/// clicks and drags; double-clicks are the caller's.
|
||||
pub struct TitleBar<'a> {
|
||||
id: Id,
|
||||
move_label: &'a str,
|
||||
title: Option<WidgetText>,
|
||||
menu: Option<&'a str>,
|
||||
close: Option<&'a str>,
|
||||
/// The ×'s hit area and glyph, where the caller places them.
|
||||
close_at: Option<(Rect, Rect)>,
|
||||
movable: bool,
|
||||
}
|
||||
|
||||
/// The parts of a [`TitleBar`] that were shown.
|
||||
pub struct TitleResponse {
|
||||
/// Drag to move; double-click is the caller's (docking minimizes every group).
|
||||
pub bar: Response,
|
||||
pub menu: Option<Response>,
|
||||
pub close: Option<Response>,
|
||||
}
|
||||
|
||||
impl<'a> TitleBar<'a> {
|
||||
/// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`.
|
||||
/// `move_label` names the bar for accessibility.
|
||||
pub fn new(id: Id, move_label: &'a str) -> Self {
|
||||
Self { id, move_label, title: None, menu: None, close: None, close_at: None, movable: true }
|
||||
}
|
||||
|
||||
/// Text at the leading edge, cut with '…' to fit.
|
||||
pub fn title(mut self, title: impl Into<WidgetText>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Show a ▾ button with this accessibility label.
|
||||
pub fn menu(mut self, label: &'a str) -> Self {
|
||||
self.menu = Some(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Show a × button with this accessibility label.
|
||||
pub fn close(mut self, label: &'a str) -> Self {
|
||||
self.close = Some(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Place the × at `glyph`, taking clicks in `hit`: rects as drawn, already mirrored in a
|
||||
/// right-to-left layout. By default the × is a square the bar's height, its center 10 pt
|
||||
/// in from the trailing edge.
|
||||
pub fn close_at(mut self, hit: Rect, glyph: Rect) -> Self {
|
||||
self.close_at = Some((hit, glyph));
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the bar shows the grab cursor. It senses drags either way.
|
||||
pub fn movable(mut self, movable: bool) -> Self {
|
||||
self.movable = movable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show(self, ui: &mut Ui, rect: Rect, style: &ChromeStyle) -> TitleResponse {
|
||||
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
painter.rect_filled(rect, 0.0, style.title_background);
|
||||
let side = rect.height();
|
||||
// Laid out left to right, then mirrored in a right-to-left layout.
|
||||
let mirror = |r: Rect| {
|
||||
if style.right_to_left {
|
||||
Rect::from_x_y_ranges((rect.left() + rect.right() - r.right())..=(rect.left() + rect.right() - r.left()), r.y_range())
|
||||
} else {
|
||||
r
|
||||
}
|
||||
};
|
||||
let close_rect = self.close.map(|_| match self.close_at {
|
||||
Some((hit, _)) => mirror(hit),
|
||||
None => Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)),
|
||||
});
|
||||
let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0);
|
||||
let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom())));
|
||||
let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left());
|
||||
let bar_rect = mirror(Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom())));
|
||||
let (close_rect, menu_rect) = (close_rect.map(mirror), menu_rect.map(mirror));
|
||||
let bar = ui.interact(bar_rect, self.id, Sense::click_and_drag());
|
||||
let bar = if self.movable { bar.on_hover_cursor(egui::CursorIcon::Grab) } else { bar };
|
||||
bar.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), self.move_label));
|
||||
if let Some(title) = self.title {
|
||||
let galley = truncated(ui, title, (bar_rect.width() - 12.0).max(0.0), style.font.clone());
|
||||
let x = if style.right_to_left { bar_rect.right() - 6.0 - galley.size().x } else { bar_rect.left() + 6.0 };
|
||||
painter.galley(pos2(x, rect.center().y - galley.size().y * 0.5), galley, style.text);
|
||||
}
|
||||
if bar.has_focus() {
|
||||
painter.rect_stroke(bar_rect.shrink(1.0), 0.0, Stroke::new(1.0, style.accent), StrokeKind::Inside);
|
||||
}
|
||||
let button = |ui: &mut Ui, rect: Rect, name: &str, label: &str| {
|
||||
let response = ui.interact(rect, self.id.with(name), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label));
|
||||
let color = if response.hovered() || response.has_focus() { style.text } else { style.weak_text };
|
||||
(response, Stroke::new(1.2, color))
|
||||
};
|
||||
let menu = menu_rect.zip(self.menu).map(|(menu_rect, label)| {
|
||||
let (response, stroke) = button(ui, menu_rect, "menu", label);
|
||||
let c = menu_rect.center();
|
||||
painter.add(egui::Shape::convex_polygon(vec![c + vec2(-3.5, -1.5), c + vec2(3.5, -1.5), c + vec2(0.0, 2.5)], stroke.color, Stroke::NONE));
|
||||
response
|
||||
});
|
||||
let close = close_rect.zip(self.close).map(|(close_rect, label)| {
|
||||
let (response, stroke) = button(ui, close_rect, "close", label);
|
||||
let r = self.close_at.map_or_else(|| Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))), |(_, glyph)| glyph);
|
||||
painter.line_segment([r.left_top(), r.right_bottom()], stroke);
|
||||
painter.line_segment([r.right_top(), r.left_bottom()], stroke);
|
||||
response
|
||||
});
|
||||
TitleResponse { bar, menu, close }
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of a title-bar move, from [`live_move`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum MoveStep {
|
||||
/// The rect follows the pointer, inside the bounds.
|
||||
Moving(Rect),
|
||||
/// Escape or window focus loss ended the move: the rect it started from.
|
||||
Canceled(Rect),
|
||||
/// Released over `pointer`, with the rect where it was dropped.
|
||||
Dropped { rect: Rect, pointer: Pos2 },
|
||||
}
|
||||
|
||||
/// Move `rect` with a drag on `bar` (usually [`TitleResponse::bar`]). The rect and the press
|
||||
/// position at drag start are kept in egui memory under the bar's id, so the window follows the
|
||||
/// pointer without drift. Escape or window focus loss ends the move and returns the start rect;
|
||||
/// the rest of that drag is ignored. `None` when the bar is not being moved.
|
||||
pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Option<MoveStep> {
|
||||
let key = bar.id.with("live-move");
|
||||
if bar.drag_started()
|
||||
&& let Some(origin) = ctx.input(|input| input.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|data| data.insert_temp(key, (rect, origin)));
|
||||
}
|
||||
let (start, origin) = ctx.data(|data| data.get_temp::<(Rect, Pos2)>(key))?;
|
||||
let forget = || ctx.data_mut(|data| data.remove::<(Rect, Pos2)>(key));
|
||||
if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) {
|
||||
forget();
|
||||
return Some(MoveStep::Canceled(start));
|
||||
}
|
||||
let pointer = ctx.pointer_latest_pos().unwrap_or(origin);
|
||||
let moved = clamp(start.translate(pointer - origin), bounds);
|
||||
if bar.drag_stopped() {
|
||||
forget();
|
||||
return Some(MoveStep::Dropped { rect: moved, pointer });
|
||||
}
|
||||
if bar.dragged() {
|
||||
return Some(MoveStep::Moving(moved));
|
||||
}
|
||||
// The drag ended without a release on this bar (the bar was not shown).
|
||||
forget();
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! The shared docking command vocabulary (feature `serde`).
|
||||
//!
|
||||
//! Applications deserialize a control command's JSON params, with its id under `"command"`,
|
||||
//! into [`DockCommand`] and apply it. craft-ui does not depend on `serde_json`.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let mut params = params.clone();
|
||||
//! params["command"] = json!(id);
|
||||
//! let command: DockCommand<PanelId> = serde_json::from_value(params)?;
|
||||
//! let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect()));
|
||||
//! command.apply(&mut app.workspace, style.right_to_left, &space, |p| app.permissions(p))?;
|
||||
//! ```
|
||||
|
||||
use std::hash::Hash;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use super::{
|
||||
Action, DockError, FloatSpace, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place, surface::DEFAULT_FLOAT,
|
||||
};
|
||||
use crate::layout::SplitSize;
|
||||
|
||||
/// A docking command, tagged by its control id under `"command"`.
|
||||
///
|
||||
/// Ids and params (`?` marks optional ones):
|
||||
///
|
||||
/// | Id | Params |
|
||||
/// |---|---|
|
||||
/// | `window.panel.layout` | `{action}`: a serialized [`Action`] |
|
||||
/// | `window.panel.move` | `{panel, anchor, zone?, before?, group?, placement?}` |
|
||||
/// | `window.panel.float` | `{panel, x?, y?, width?, height?, group?, column?}` |
|
||||
/// | `window.panel.dock` | `{panel, anchor?, zone?, before?, group?, column?, side?}` |
|
||||
/// | `window.panel.activate`, `.close`, `.closeGroup`, `.closeOthers` | `{panel}` |
|
||||
/// | `window.panel.minimize` | `{panel, minimized?}`; without `minimized` it toggles |
|
||||
/// | `window.panel.iconic` | `{panel, iconic?}`; without `iconic` it toggles |
|
||||
/// | `window.panel.window` | `{panel, native?}` (desktop) |
|
||||
/// | `window.workspace`, `.new`, `.delete` | `{name}` |
|
||||
/// | `window.workspace.reset`, `ui.dock.reset` | `{}` |
|
||||
/// | `window.workspace.lock` | `{locked?}`; without `locked` it toggles |
|
||||
/// | `ui.dock` | `{action}`, or `{operation, …}`, see [`DockOperation`] |
|
||||
///
|
||||
/// `zone` is `tab` (the default for `move` and `dock`), `center`, `left`, `right`, `top`,
|
||||
/// `bottom`, `above` or `below` (a new group in a dock column, or a split in a floating window). The side
|
||||
/// zones map onto the anchor's container with [`Placement::for_zone`]. `before`
|
||||
/// names the tab to insert before; `null` appends. `group` moves the panel's whole group,
|
||||
/// `column` its dock column. `side` is `Start` or `End`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "command", bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))]
|
||||
#[non_exhaustive]
|
||||
pub enum DockCommand<P> {
|
||||
#[serde(rename = "window.panel.layout")]
|
||||
Layout { action: Action<P> },
|
||||
#[serde(rename = "window.panel.move")]
|
||||
Move {
|
||||
panel: P,
|
||||
anchor: P,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
zone: Option<ZoneName>,
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
before: Option<Option<P>>,
|
||||
#[serde(default)]
|
||||
group: bool,
|
||||
/// A serialized [`Placement`]; it takes precedence over `zone` and `before`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
placement: Option<Placement<P>>,
|
||||
},
|
||||
#[serde(rename = "window.panel.float")]
|
||||
Float {
|
||||
panel: P,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
x: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
y: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
width: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
height: Option<f32>,
|
||||
#[serde(default)]
|
||||
group: bool,
|
||||
#[serde(default)]
|
||||
column: bool,
|
||||
},
|
||||
/// To `anchor`; else to a new column of the dock on `side` (a new dock when it has none);
|
||||
/// else with a `left`, `right`, `top` or `bottom` zone to a new frame on that outer edge
|
||||
/// ([`Placement::Edge`]); else a floating panel joins the first dock group (or frame) as a
|
||||
/// tab, and a docked one stays. A hidden panel without `anchor` and `side` is shown at its
|
||||
/// last location, then moved to the edge a zone names.
|
||||
#[serde(rename = "window.panel.dock")]
|
||||
Dock {
|
||||
panel: P,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
anchor: Option<P>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
zone: Option<ZoneName>,
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
before: Option<Option<P>>,
|
||||
#[serde(default)]
|
||||
group: bool,
|
||||
#[serde(default)]
|
||||
column: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
side: Option<Side>,
|
||||
},
|
||||
/// Select a panel; a hidden one is shown at its last location first.
|
||||
#[serde(rename = "window.panel.activate")]
|
||||
Activate { panel: P },
|
||||
#[serde(rename = "window.panel.close")]
|
||||
Close { panel: P },
|
||||
#[serde(rename = "window.panel.closeGroup")]
|
||||
CloseGroup { panel: P },
|
||||
#[serde(rename = "window.panel.closeOthers")]
|
||||
CloseOthers { panel: P },
|
||||
#[serde(rename = "window.panel.minimize")]
|
||||
Minimize {
|
||||
panel: P,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
minimized: Option<bool>,
|
||||
},
|
||||
#[serde(rename = "window.panel.iconic")]
|
||||
Iconic {
|
||||
panel: P,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
iconic: Option<bool>,
|
||||
},
|
||||
/// Move the panel's floating window to its own OS window (`native`, the default) or back.
|
||||
/// A docked panel's group floats first.
|
||||
#[serde(rename = "window.panel.window")]
|
||||
Window {
|
||||
panel: P,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
native: Option<bool>,
|
||||
},
|
||||
/// Switch to a named workspace. The application owns named workspaces.
|
||||
#[serde(rename = "window.workspace")]
|
||||
Workspace { name: String },
|
||||
#[serde(rename = "window.workspace.new")]
|
||||
NewWorkspace { name: String },
|
||||
#[serde(rename = "window.workspace.delete")]
|
||||
DeleteWorkspace { name: String },
|
||||
/// Reset the current workspace to its saved or built-in arrangement (application-owned).
|
||||
#[serde(rename = "window.workspace.reset", alias = "ui.dock.reset")]
|
||||
ResetWorkspace {},
|
||||
#[serde(rename = "window.workspace.lock")]
|
||||
Lock {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
locked: Option<bool>,
|
||||
},
|
||||
/// The `ui.dock` shape of LightCraft, PDFCraft, CADCraft and SoundCraft.
|
||||
#[serde(rename = "ui.dock")]
|
||||
UiDock(UiDock<P>),
|
||||
}
|
||||
|
||||
/// `ui.dock` params: a raw action, or an operation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged, bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))]
|
||||
pub enum UiDock<P> {
|
||||
Action { action: Action<P> },
|
||||
Operation(DockOperation<P>),
|
||||
}
|
||||
|
||||
/// A `ui.dock` operation, tagged by `operation`. `move` takes `target` (the anchor), `zone`
|
||||
/// (default `center`) and `before`, which only applies to a center move.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", rename_all = "camelCase", bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))]
|
||||
#[non_exhaustive]
|
||||
pub enum DockOperation<P> {
|
||||
/// Show a panel at its last location and select it.
|
||||
Open {
|
||||
panel: P,
|
||||
},
|
||||
Close {
|
||||
panel: P,
|
||||
},
|
||||
Activate {
|
||||
panel: P,
|
||||
},
|
||||
Float {
|
||||
panel: P,
|
||||
rect: [f32; 4],
|
||||
},
|
||||
MoveFloating {
|
||||
panel: P,
|
||||
rect: [f32; 4],
|
||||
},
|
||||
Move {
|
||||
panel: P,
|
||||
target: P,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
zone: Option<ZoneName>,
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
before: Option<Option<P>>,
|
||||
},
|
||||
SetStackOpen {
|
||||
panel: P,
|
||||
open: bool,
|
||||
},
|
||||
/// `height` is required; `null` clears it.
|
||||
ResizeStack {
|
||||
panel: P,
|
||||
#[serde(deserialize_with = "required")]
|
||||
height: Option<f32>,
|
||||
},
|
||||
ResizeSplit {
|
||||
path: Vec<bool>,
|
||||
size: SplitSize,
|
||||
},
|
||||
}
|
||||
|
||||
/// A drop zone by name, as control protocols write it. PascalCase [`Zone`] names also parse.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ZoneName {
|
||||
Tab,
|
||||
#[serde(alias = "Center")]
|
||||
Center,
|
||||
#[serde(alias = "Left")]
|
||||
Left,
|
||||
#[serde(alias = "Right")]
|
||||
Right,
|
||||
#[serde(alias = "Top")]
|
||||
Top,
|
||||
#[serde(alias = "Bottom")]
|
||||
Bottom,
|
||||
Above,
|
||||
Below,
|
||||
}
|
||||
|
||||
/// A present field, `null` included: `Some(None)` for `null`, `None` when absent.
|
||||
fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result<Option<T>, D::Error> {
|
||||
T::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
/// A required field that may be `null`.
|
||||
fn required<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result<T, D::Error> {
|
||||
T::deserialize(deserializer)
|
||||
}
|
||||
|
||||
/// Where a command puts what it moves relative to `anchor`: `zone` and `before`, with side
|
||||
/// zones mapped by [`Placement::for_zone`]. `default` is the zone when none is given.
|
||||
struct Zoned<'a, P> {
|
||||
layout: &'a Layout<P>,
|
||||
right_to_left: bool,
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash> Zoned<'_, P> {
|
||||
fn placement(&self, anchor: &P, zone: Option<ZoneName>, before: &Option<Option<P>>, default: ZoneName) -> Result<Placement<P>, DockError> {
|
||||
let side = |zone| Placement::for_zone(self.layout, anchor, zone, self.right_to_left);
|
||||
Ok(match (zone.unwrap_or(default), before.clone()) {
|
||||
(ZoneName::Tab | ZoneName::Center, Some(before)) => Placement::Tab { before },
|
||||
(ZoneName::Tab, None) => Placement::Tab { before: None },
|
||||
(ZoneName::Center, None) => Placement::Split(Zone::Center),
|
||||
(_, Some(_)) => return Err(DockError::InvalidPlacement),
|
||||
(ZoneName::Left, None) => side(Zone::Left)?,
|
||||
(ZoneName::Right, None) => side(Zone::Right)?,
|
||||
(ZoneName::Top, None) => side(Zone::Top)?,
|
||||
(ZoneName::Bottom, None) => side(Zone::Bottom)?,
|
||||
(ZoneName::Above, None) => Placement::Group { below: false, height: None },
|
||||
(ZoneName::Below, None) => Placement::Group { below: true, height: None },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the layout a command moves.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Unit {
|
||||
Panel,
|
||||
Group,
|
||||
Column,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
fn of(group: bool, column: bool) -> Self {
|
||||
if column {
|
||||
Self::Column
|
||||
} else if group {
|
||||
Self::Group
|
||||
} else {
|
||||
Self::Panel
|
||||
}
|
||||
}
|
||||
|
||||
/// The action that moves this unit of `panel` to `to`. A floating panel's column is its
|
||||
/// whole window.
|
||||
fn to<P: Clone + Eq + Hash>(self, layout: &Layout<P>, panel: &P, to: Target<P>) -> Result<Action<P>, DockError> {
|
||||
let floating = matches!(layout.place(panel), Some(Place::Floating { .. }));
|
||||
let panel = panel.clone();
|
||||
Ok(match (self, to) {
|
||||
(Self::Panel, Target::At { anchor, placement }) => Action::Move { panel, anchor, placement },
|
||||
(Self::Panel, to) => Action::MovePanel { panel, to },
|
||||
(Self::Group, to) => Action::MoveGroup { panel, to },
|
||||
(Self::Column, to) if floating => Action::MoveWindow { panel, to },
|
||||
(Self::Column, to) => Action::MoveColumn { panel, to },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<P> DockCommand<P> {
|
||||
/// Every command id, for an application's command registry.
|
||||
pub const IDS: &'static [&'static str] = &[
|
||||
"window.panel.layout",
|
||||
"window.panel.move",
|
||||
"window.panel.float",
|
||||
"window.panel.dock",
|
||||
"window.panel.activate",
|
||||
"window.panel.close",
|
||||
"window.panel.closeGroup",
|
||||
"window.panel.closeOthers",
|
||||
"window.panel.minimize",
|
||||
"window.panel.iconic",
|
||||
"window.panel.window",
|
||||
"window.workspace",
|
||||
"window.workspace.new",
|
||||
"window.workspace.delete",
|
||||
"window.workspace.reset",
|
||||
"window.workspace.lock",
|
||||
"ui.dock",
|
||||
"ui.dock.reset",
|
||||
];
|
||||
|
||||
/// Named workspaces and Reset belong to the application, which handles these commands
|
||||
/// before [`Self::apply`].
|
||||
pub fn is_app_owned(&self) -> bool {
|
||||
matches!(self, Self::Workspace { .. } | Self::NewWorkspace { .. } | Self::DeleteWorkspace { .. } | Self::ResetWorkspace {})
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash> DockCommand<P> {
|
||||
/// The layout actions this command makes on `workspace`, to apply all or none.
|
||||
/// Showing a hidden panel and Lock change more than the layout, and named workspaces
|
||||
/// belong to the application: those return [`DockError::NotAllowed`]; use [`Self::apply`].
|
||||
/// A panel or anchor that is not shown returns [`DockError::MissingPanel`] or
|
||||
/// [`DockError::MissingTarget`].
|
||||
///
|
||||
/// `left`, `right`, `top` and `bottom` zones follow [`Placement::for_zone`]: beside a docked
|
||||
/// anchor they make a new column or group, and `right_to_left` is the
|
||||
/// [`super::DockStyle::right_to_left`] the layout is drawn with.
|
||||
///
|
||||
/// `space` is where `window.panel.float` floats what it moves when it gives no rect: at the
|
||||
/// size a drag would float it at, inside the dock area and clear of the app's menu bar. Pass
|
||||
/// [`super::float_space`] of the area that draws `workspace`. Given `x`, `y`, `width` and
|
||||
/// `height` replace the matching values, and a group or column that is already a whole
|
||||
/// floating window keeps its rect.
|
||||
pub fn to_actions(&self, workspace: &Workspace<P>, right_to_left: bool, space: &FloatSpace<P>) -> Result<Vec<Action<P>>, DockError> {
|
||||
let layout = &workspace.layout;
|
||||
let zoned = Zoned { layout, right_to_left };
|
||||
let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) };
|
||||
let one = |action: Action<P>| Ok(vec![action]);
|
||||
match self {
|
||||
Self::Layout { action } | Self::UiDock(UiDock::Action { action }) => one(action.clone()),
|
||||
Self::Move { panel, anchor, zone, before, group, placement: given } => {
|
||||
shown(panel)?;
|
||||
let placement = match given {
|
||||
Some(placement) => placement.clone(),
|
||||
None => zoned.placement(anchor, *zone, before, ZoneName::Tab)?,
|
||||
};
|
||||
one(Unit::of(*group, false).to(layout, panel, Target::At { anchor: anchor.clone(), placement })?)
|
||||
}
|
||||
Self::Float { panel, x, y, width, height, group, column } => {
|
||||
shown(panel)?;
|
||||
let given = [*x, *y, *width, *height];
|
||||
let window = layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.rect);
|
||||
let rect = |base: [f32; 4]| {
|
||||
let mut rect = base;
|
||||
for (value, given) in rect.iter_mut().zip(given) {
|
||||
*value = given.unwrap_or(*value);
|
||||
}
|
||||
rect
|
||||
};
|
||||
let any = given.iter().any(Option::is_some);
|
||||
let unit = Unit::of(*group, *column);
|
||||
let lone = layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1);
|
||||
let whole =
|
||||
layout.floating.iter().any(|w| w.contains(panel) && matches!(w.root, super::Node::Tabs { .. } | super::Node::Stack { .. }));
|
||||
// The rect a drag would float the unit at, inside the area.
|
||||
let placed = || {
|
||||
let docked = layout.location(panel).ok().and_then(|l| l.group).and_then(|g| g.column).map(|c| c.width);
|
||||
let fallback = egui::vec2(docked.filter(|_| !matches!(unit, Unit::Panel)).unwrap_or(DEFAULT_FLOAT.x), DEFAULT_FLOAT.y);
|
||||
let size = match unit {
|
||||
Unit::Panel => space.panel(panel),
|
||||
Unit::Group => space.group(panel),
|
||||
Unit::Column => space.column(panel),
|
||||
};
|
||||
rect(space.place(size.unwrap_or(fallback)))
|
||||
};
|
||||
match (unit, window) {
|
||||
(Unit::Panel, Some(base)) if lone => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }),
|
||||
(Unit::Column, Some(base)) => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }),
|
||||
// Without given values the window keeps its rect and native state.
|
||||
(Unit::Group, Some(base)) if whole => one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(base)) })?),
|
||||
(Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: placed() }),
|
||||
(unit, _) => one(unit.to(layout, panel, Target::Float { rect: Some(placed()) })?),
|
||||
}
|
||||
}
|
||||
Self::Dock { panel, anchor, zone, before, group, column, side } => {
|
||||
shown(panel)?;
|
||||
let unit = Unit::of(*group, *column);
|
||||
if let Some(anchor) = anchor {
|
||||
let placement = zoned.placement(anchor, *zone, before, ZoneName::Tab)?;
|
||||
return one(unit.to(layout, panel, Target::At { anchor: anchor.clone(), placement })?);
|
||||
}
|
||||
if let Some(side) = side {
|
||||
let to = match last_column(layout, *side) {
|
||||
Some(column) => match column.into_iter().find(|p| !members(layout, panel, unit).contains(p)) {
|
||||
Some(anchor) => Target::At { anchor, placement: Placement::Column { after: true } },
|
||||
// What moves already is that dock's last column.
|
||||
None => return Ok(Vec::new()),
|
||||
},
|
||||
None => Target::NewDock { side: *side },
|
||||
};
|
||||
let unit = match (unit, &to) {
|
||||
(Unit::Panel, Target::NewDock { .. }) if group_len(layout, panel) == Some(1) => Unit::Group,
|
||||
(unit, _) => unit,
|
||||
};
|
||||
return one(unit.to(layout, panel, to)?);
|
||||
}
|
||||
let members = members(layout, panel, unit);
|
||||
if let Some(zone) = self.edge() {
|
||||
if before.is_some() {
|
||||
return Err(DockError::InvalidPlacement);
|
||||
}
|
||||
let anchor = layout
|
||||
.panels()
|
||||
.into_iter()
|
||||
.find(|p| !members.contains(p) && matches!(layout.place(p), Some(Place::Dock { .. } | Place::Node(_))))
|
||||
.cloned()
|
||||
.ok_or(DockError::MissingTarget)?;
|
||||
return one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Edge(zone) })?);
|
||||
}
|
||||
if !matches!(layout.place(panel), Some(Place::Floating { .. })) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let panels = layout.panels();
|
||||
let free = || panels.iter().copied().filter(|p| !members.contains(p));
|
||||
let docked = free().find(|p| matches!(layout.place(p), Some(Place::Dock { .. })));
|
||||
let framed = || free().find(|p| matches!(layout.place(p), Some(Place::Node(_))));
|
||||
let anchor = docked.or_else(framed).cloned().ok_or(DockError::MissingTarget)?;
|
||||
one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Tab { before: None } })?)
|
||||
}
|
||||
Self::Activate { panel } => {
|
||||
shown(panel)?;
|
||||
one(Action::Activate { panel: panel.clone() })
|
||||
}
|
||||
Self::Close { panel } => one(Action::Close { panel: panel.clone() }),
|
||||
Self::CloseGroup { panel } => one(Action::CloseGroup { panel: panel.clone() }),
|
||||
Self::CloseOthers { panel } => one(Action::CloseOthers { panel: panel.clone() }),
|
||||
Self::Minimize { panel, minimized } => {
|
||||
let place = layout.place(panel).ok_or(DockError::MissingPanel)?;
|
||||
// An accordion entry is minimized while it is closed.
|
||||
let current = match &place {
|
||||
Place::Node(path) => match layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, path)) {
|
||||
Some(super::Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && !e.open),
|
||||
_ => false,
|
||||
},
|
||||
Place::Floating { window, .. } => layout.floating.get(*window).is_some_and(|w| w.minimized),
|
||||
place => layout.group(place).is_some_and(|g| g.minimized),
|
||||
};
|
||||
one(Action::SetMinimized { panel: panel.clone(), minimized: minimized.unwrap_or(!current) })
|
||||
}
|
||||
Self::Iconic { panel, iconic } => {
|
||||
let location = layout.location(panel)?;
|
||||
let current = location.group.and_then(|g| g.column).is_some_and(|c| c.iconic);
|
||||
one(Action::SetIconic { panel: panel.clone(), iconic: iconic.unwrap_or(!current) })
|
||||
}
|
||||
Self::Window { panel, native } => {
|
||||
shown(panel)?;
|
||||
if !native.unwrap_or(true) {
|
||||
return one(Action::SetNative { panel: panel.clone(), rect: None });
|
||||
}
|
||||
let mut actions = Vec::new();
|
||||
let mut scratch = layout.clone();
|
||||
if !scratch.floating.iter().any(|w| w.contains(panel)) {
|
||||
let float = Action::MoveGroup { panel: panel.clone(), to: Target::Float { rect: None } };
|
||||
scratch.apply(float.clone())?;
|
||||
actions.push(float);
|
||||
}
|
||||
let rect = scratch.floating.iter().find(|w| w.contains(panel)).map(|w| w.rect).ok_or(DockError::MissingPanel)?;
|
||||
actions.push(Action::SetNative { panel: panel.clone(), rect: Some(rect) });
|
||||
Ok(actions)
|
||||
}
|
||||
Self::UiDock(UiDock::Operation(operation)) => match operation {
|
||||
DockOperation::Open { panel } | DockOperation::Activate { panel } => {
|
||||
shown(panel)?;
|
||||
one(Action::Activate { panel: panel.clone() })
|
||||
}
|
||||
DockOperation::Close { panel } => one(Action::Close { panel: panel.clone() }),
|
||||
DockOperation::Float { panel, rect } => one(Action::Float { panel: panel.clone(), rect: *rect }),
|
||||
DockOperation::MoveFloating { panel, rect } => one(Action::MoveFloating { panel: panel.clone(), rect: *rect }),
|
||||
DockOperation::Move { panel, target, zone, before } => {
|
||||
let placement = zoned.placement(target, *zone, before, ZoneName::Center)?;
|
||||
one(Action::Move { panel: panel.clone(), anchor: target.clone(), placement })
|
||||
}
|
||||
DockOperation::SetStackOpen { panel, open } => one(Action::SetStackOpen { panel: panel.clone(), open: *open }),
|
||||
DockOperation::ResizeStack { panel, height } => one(Action::ResizeStack { panel: panel.clone(), height: *height }),
|
||||
DockOperation::ResizeSplit { path, size } => one(Action::ResizeSplit { path: path.clone(), size: *size }),
|
||||
},
|
||||
Self::Workspace { .. } | Self::NewWorkspace { .. } | Self::DeleteWorkspace { .. } | Self::ResetWorkspace {} | Self::Lock { .. } => {
|
||||
Err(DockError::NotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the command to `workspace`, all or nothing, and return the panels it closed so
|
||||
/// the application can release their state. Showing a hidden panel restores its last
|
||||
/// location, and a panel never laid out opens as [`Action::Open`] without an anchor does
|
||||
/// ([`Workspace::set_visible`]); Lock sets [`Workspace::locked`]. Application-owned commands
|
||||
/// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing.
|
||||
/// `space` is where `window.panel.float` floats, as for [`Self::to_actions`].
|
||||
pub fn apply(
|
||||
&self,
|
||||
workspace: &mut Workspace<P>,
|
||||
right_to_left: bool,
|
||||
space: &FloatSpace<P>,
|
||||
permissions: impl Fn(&P) -> Permissions,
|
||||
) -> Result<Vec<P>, DockError> {
|
||||
let show = match self {
|
||||
Self::Activate { panel }
|
||||
| Self::Dock { panel, anchor: None, side: None, .. }
|
||||
| Self::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) => Some(panel),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(panel) = show.filter(|p| !workspace.layout.contains(p)) {
|
||||
if self.edge().is_none() {
|
||||
workspace.set_visible(panel.clone(), true, None, &permissions)?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Shown, then moved to the edge, all or nothing.
|
||||
let mut shown = workspace.clone();
|
||||
shown.set_visible(panel.clone(), true, None, &permissions)?;
|
||||
let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space)?, permissions)?;
|
||||
*workspace = shown;
|
||||
return Ok(closed);
|
||||
}
|
||||
if let Self::Lock { locked } = self {
|
||||
workspace.locked = locked.unwrap_or(!workspace.locked);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let actions = self.to_actions(workspace, right_to_left, space)?;
|
||||
workspace.apply_all(actions, permissions)
|
||||
}
|
||||
}
|
||||
|
||||
impl<P> DockCommand<P> {
|
||||
/// The outer edge `window.panel.dock` goes to: a side zone without an anchor or side.
|
||||
fn edge(&self) -> Option<Zone> {
|
||||
match self {
|
||||
Self::Dock { anchor: None, side: None, zone: Some(zone), .. } => match zone {
|
||||
ZoneName::Left => Some(Zone::Left),
|
||||
ZoneName::Right => Some(Zone::Right),
|
||||
ZoneName::Top => Some(Zone::Top),
|
||||
ZoneName::Bottom => Some(Zone::Bottom),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The panels of the last column of the dock on `side`, or `None` without such a dock.
|
||||
fn last_column<P: Clone + Eq + Hash>(layout: &Layout<P>, side: Side) -> Option<Vec<P>> {
|
||||
let path = layout.dock_path(side)?;
|
||||
let node = layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, &path))?;
|
||||
match node {
|
||||
super::Node::Dock { columns, .. } => Some(columns.last()?.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn group_len<P: Clone + Eq + Hash>(layout: &Layout<P>, panel: &P) -> Option<usize> {
|
||||
layout.members(panel, super::dock_model::Unit::Group).ok().map(|members| members.len())
|
||||
}
|
||||
|
||||
/// The panels that move with `panel`.
|
||||
fn members<P: Clone + Eq + Hash>(layout: &Layout<P>, panel: &P, unit: Unit) -> Vec<P> {
|
||||
match unit {
|
||||
Unit::Panel => vec![panel.clone()],
|
||||
Unit::Group => layout.members(panel, super::dock_model::Unit::Group).unwrap_or_default(),
|
||||
Unit::Column => match layout.place(panel) {
|
||||
Some(Place::Dock { path, column, .. }) => match layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, &path)) {
|
||||
Some(super::Node::Dock { columns, .. }) => {
|
||||
columns.get(column).map(|c| c.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect()).unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some(Place::Floating { .. }) => {
|
||||
layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.panels().cloned().collect()).unwrap_or_default()
|
||||
}
|
||||
_ => vec![panel.clone()],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! `DockCommand`: every documented shape parses and maps to atomic layout actions.
|
||||
#![cfg(feature = "serde")]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
|
||||
type P = String;
|
||||
|
||||
fn s(panel: &str) -> P {
|
||||
panel.to_owned()
|
||||
}
|
||||
|
||||
fn permissions(panel: &P) -> Permissions {
|
||||
if panel == "canvas" { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
|
||||
/// A start dock with one column of `[layers, channels]` and `[history]` beside the canvas, a
|
||||
/// floating `color`, and `swatches` hidden from history's group.
|
||||
fn workspace() -> Workspace<P> {
|
||||
let groups = vec![Group::new(vec![s("layers"), s("channels")], 0), Group::new(vec![s("history"), s("swatches")], 0)];
|
||||
let dock = Node::Dock { side: Side::Start, columns: vec![Column::new(groups, 240.0)] };
|
||||
let canvas = Node::Tabs { panels: vec![s("canvas")], active: 0 };
|
||||
let layout = Layout {
|
||||
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }),
|
||||
floating: vec![Floating::single(vec![s("color")], 0, [300.0, 200.0, 200.0, 150.0])],
|
||||
};
|
||||
let mut workspace = Workspace::new(layout);
|
||||
workspace.apply(Action::Close { panel: s("swatches") }, permissions).unwrap();
|
||||
workspace
|
||||
}
|
||||
|
||||
/// An 800x600 dock area that has drawn nothing.
|
||||
fn space() -> FloatSpace<P> {
|
||||
FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)))
|
||||
}
|
||||
|
||||
fn parse(params: Value) -> Result<DockCommand<P>, serde_json::Error> {
|
||||
serde_json::from_value(params)
|
||||
}
|
||||
|
||||
/// The actions `params` makes on a fresh workspace.
|
||||
fn actions(params: Value) -> Vec<Action<P>> {
|
||||
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
|
||||
command.to_actions(&workspace(), false, &space()).unwrap_or_else(|e| panic!("{params}: {e}"))
|
||||
}
|
||||
|
||||
/// Apply `params` to a fresh workspace; return it and the panels closed.
|
||||
fn applied(params: Value) -> (Workspace<P>, Vec<P>) {
|
||||
let mut workspace = workspace();
|
||||
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
|
||||
let closed = command.apply(&mut workspace, false, &space(), permissions).unwrap_or_else(|e| panic!("{params}: {e}"));
|
||||
(workspace, closed)
|
||||
}
|
||||
|
||||
/// `params` parses but applying it fails with `error` and changes nothing.
|
||||
fn rejected(params: Value, error: DockError) {
|
||||
let mut workspace = workspace();
|
||||
let before = workspace.clone();
|
||||
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
|
||||
assert_eq!(command.apply(&mut workspace, false, &space(), permissions), Err(error), "{params}");
|
||||
assert_eq!(workspace, before, "{params}");
|
||||
}
|
||||
|
||||
fn tab(before: Option<&str>) -> Placement<P> {
|
||||
Placement::Tab { before: before.map(s) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut id = |params: Value| {
|
||||
seen.insert(params["command"].as_str().unwrap().to_owned());
|
||||
params
|
||||
};
|
||||
|
||||
// The raw action form, in both protocols.
|
||||
let raw = json!({"MoveFloating": {"panel": "color", "rect": [44, 66, 360, 450]}});
|
||||
let moved = vec![Action::MoveFloating { panel: s("color"), rect: [44.0, 66.0, 360.0, 450.0] }];
|
||||
assert_eq!(actions(id(json!({"command": "window.panel.layout", "action": raw}))), moved);
|
||||
assert_eq!(actions(id(json!({"command": "ui.dock", "action": raw}))), moved);
|
||||
|
||||
// move: tab by default, before a tab or appended, zones, a serialized placement, a group.
|
||||
let mv = |placement| vec![Action::Move { panel: s("history"), anchor: s("layers"), placement }];
|
||||
let base = json!({"command": "window.panel.move", "panel": "history", "anchor": "layers"});
|
||||
let with = |extra: Value| {
|
||||
let mut params = base.clone();
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
params
|
||||
};
|
||||
assert_eq!(actions(id(base.clone())), mv(tab(None)));
|
||||
assert_eq!(actions(with(json!({"before": "channels"}))), mv(tab(Some("channels"))));
|
||||
assert_eq!(actions(with(json!({"zone": "center", "before": null}))), mv(tab(None)));
|
||||
assert_eq!(actions(with(json!({"zone": "center"}))), mv(Placement::Split(Zone::Center)));
|
||||
assert_eq!(actions(with(json!({"zone": "Left"}))), mv(Placement::Column { after: false }));
|
||||
assert_eq!(actions(with(json!({"zone": "below"}))), mv(Placement::Group { below: true, height: None }));
|
||||
assert_eq!(actions(with(json!({"placement": {"Column": {"after": true}}}))), mv(Placement::Column { after: true }));
|
||||
assert_eq!(
|
||||
actions(with(json!({"group": true, "zone": "above"}))),
|
||||
vec![Action::MoveGroup {
|
||||
panel: s("history"),
|
||||
to: Target::At { anchor: s("layers"), placement: Placement::Group { below: false, height: None } }
|
||||
}]
|
||||
);
|
||||
|
||||
// float: a docked panel, a lone floating panel, a group and a column.
|
||||
assert_eq!(
|
||||
actions(id(json!({"command": "window.panel.float", "panel": "layers", "x": 10, "y": 20}))),
|
||||
vec![Action::Float { panel: s("layers"), rect: [10.0, 20.0, 260.0, 320.0] }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.float", "panel": "color", "width": 320})),
|
||||
vec![Action::MoveFloating { panel: s("color"), rect: [300.0, 200.0, 320.0, 150.0] }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.float", "panel": "layers", "group": true})),
|
||||
vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([24.0, 24.0, 240.0, 320.0]) } }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.float", "panel": "history", "column": true})),
|
||||
vec![Action::MoveColumn { panel: s("history"), to: Target::Float { rect: Some([24.0, 24.0, 240.0, 320.0]) } }]
|
||||
);
|
||||
|
||||
// dock: to an anchor, to a side, back into the dock, and a hidden panel to its last place.
|
||||
assert_eq!(
|
||||
actions(id(json!({"command": "window.panel.dock", "panel": "color", "anchor": "history", "zone": "tab"}))),
|
||||
vec![Action::Move { panel: s("color"), anchor: s("history"), placement: tab(None) }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.dock", "panel": "color", "side": "End"})),
|
||||
vec![Action::MoveGroup { panel: s("color"), to: Target::NewDock { side: Side::End } }]
|
||||
);
|
||||
let new_column = Target::At { anchor: s("layers"), placement: Placement::Column { after: true } };
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.dock", "panel": "color", "column": true, "side": "Start"})),
|
||||
vec![Action::MoveWindow { panel: s("color"), to: new_column.clone() }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.dock", "panel": "history", "group": true, "side": "Start"})),
|
||||
vec![Action::MoveGroup { panel: s("history"), to: new_column }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.dock", "panel": "color"})),
|
||||
vec![Action::Move { panel: s("color"), anchor: s("layers"), placement: tab(None) }]
|
||||
);
|
||||
assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers"})), vec![]);
|
||||
let (shown, _) = applied(json!({"command": "window.panel.dock", "panel": "swatches"}));
|
||||
assert!(shown.layout.contains(&s("swatches")) && shown.hidden.is_empty());
|
||||
|
||||
// activate shows a hidden panel; close actions report what they closed.
|
||||
let (shown, _) = applied(id(json!({"command": "window.panel.activate", "panel": "swatches"})));
|
||||
assert!(shown.layout.contains(&s("swatches")));
|
||||
assert_eq!(actions(json!({"command": "window.panel.activate", "panel": "channels"})), vec![Action::Activate { panel: s("channels") }]);
|
||||
assert_eq!(applied(id(json!({"command": "window.panel.close", "panel": "channels"}))).1, vec![s("channels")]);
|
||||
assert_eq!(applied(id(json!({"command": "window.panel.closeGroup", "panel": "channels"}))).1, vec![s("layers"), s("channels")]);
|
||||
assert_eq!(applied(id(json!({"command": "window.panel.closeOthers", "panel": "channels"}))).1, vec![s("layers")]);
|
||||
|
||||
// minimize and iconic toggle without a value.
|
||||
assert_eq!(
|
||||
actions(id(json!({"command": "window.panel.minimize", "panel": "layers"}))),
|
||||
vec![Action::SetMinimized { panel: s("layers"), minimized: true }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.minimize", "panel": "layers", "minimized": false})),
|
||||
vec![Action::SetMinimized { panel: s("layers"), minimized: false }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(id(json!({"command": "window.panel.iconic", "panel": "history"}))),
|
||||
vec![Action::SetIconic { panel: s("history"), iconic: true }]
|
||||
);
|
||||
|
||||
// window: a floating window goes native at its rect; a docked group floats first.
|
||||
assert_eq!(
|
||||
actions(id(json!({"command": "window.panel.window", "panel": "color"}))),
|
||||
vec![Action::SetNative { panel: s("color"), rect: Some([300.0, 200.0, 200.0, 150.0]) }]
|
||||
);
|
||||
let docked = actions(json!({"command": "window.panel.window", "panel": "layers"}));
|
||||
assert!(
|
||||
matches!(docked.as_slice(), [Action::MoveGroup { to: Target::Float { rect: None }, .. }, Action::SetNative { rect: Some(_), .. }]),
|
||||
"{docked:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
actions(json!({"command": "window.panel.window", "panel": "color", "native": false})),
|
||||
vec![Action::SetNative { panel: s("color"), rect: None }]
|
||||
);
|
||||
|
||||
// Named workspaces and Reset belong to the application.
|
||||
for params in [
|
||||
json!({"command": "window.workspace", "name": "Painting"}),
|
||||
json!({"command": "window.workspace.new", "name": "Mine"}),
|
||||
json!({"command": "window.workspace.delete", "name": "Mine"}),
|
||||
json!({"command": "window.workspace.reset"}),
|
||||
json!({"command": "ui.dock.reset"}),
|
||||
] {
|
||||
let command = parse(id(params.clone())).unwrap();
|
||||
assert!(command.is_app_owned(), "{params}");
|
||||
rejected(params, DockError::NotAllowed);
|
||||
}
|
||||
assert_eq!(parse(json!({"command": "ui.dock.reset"})).unwrap(), DockCommand::ResetWorkspace {});
|
||||
|
||||
// Lock toggles or sets.
|
||||
assert!(applied(id(json!({"command": "window.workspace.lock"}))).0.locked);
|
||||
assert!(!applied(json!({"command": "window.workspace.lock", "locked": false})).0.locked);
|
||||
|
||||
// The small apps' ui.dock operations.
|
||||
let op = |params: Value| {
|
||||
let mut params = params;
|
||||
params["command"] = json!("ui.dock");
|
||||
params
|
||||
};
|
||||
let (shown, _) = applied(op(json!({"operation": "open", "panel": "swatches"})));
|
||||
assert!(shown.layout.contains(&s("swatches")));
|
||||
assert_eq!(actions(op(json!({"operation": "close", "panel": "layers"}))), vec![Action::Close { panel: s("layers") }]);
|
||||
assert_eq!(actions(op(json!({"operation": "activate", "panel": "channels"}))), vec![Action::Activate { panel: s("channels") }]);
|
||||
assert_eq!(
|
||||
actions(op(json!({"operation": "float", "panel": "layers", "rect": [1, 2, 300, 200]}))),
|
||||
vec![Action::Float { panel: s("layers"), rect: [1.0, 2.0, 300.0, 200.0] }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(op(json!({"operation": "moveFloating", "panel": "color", "rect": [1, 2, 300, 200]}))),
|
||||
vec![Action::MoveFloating { panel: s("color"), rect: [1.0, 2.0, 300.0, 200.0] }]
|
||||
);
|
||||
let to_layers = |placement| vec![Action::Move { panel: s("color"), anchor: s("layers"), placement }];
|
||||
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers"}))), to_layers(Placement::Split(Zone::Center)));
|
||||
assert_eq!(
|
||||
actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))),
|
||||
to_layers(Placement::Column { after: false })
|
||||
);
|
||||
assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": null}))), to_layers(tab(None)));
|
||||
assert_eq!(
|
||||
actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": "channels"}))),
|
||||
to_layers(tab(Some("channels")))
|
||||
);
|
||||
assert_eq!(
|
||||
actions(op(json!({"operation": "setStackOpen", "panel": "layers", "open": false}))),
|
||||
vec![Action::SetStackOpen { panel: s("layers"), open: false }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(op(json!({"operation": "resizeStack", "panel": "layers", "height": null}))),
|
||||
vec![Action::ResizeStack { panel: s("layers"), height: None }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(op(json!({"operation": "resizeStack", "panel": "layers", "height": 120}))),
|
||||
vec![Action::ResizeStack { panel: s("layers"), height: Some(120.0) }]
|
||||
);
|
||||
assert_eq!(
|
||||
actions(op(json!({"operation": "resizeSplit", "path": [], "size": {"Ratio": 0.35}}))),
|
||||
vec![Action::ResizeSplit { path: vec![], size: SplitSize::Ratio(0.35) }]
|
||||
);
|
||||
|
||||
let ids: BTreeSet<String> = DockCommand::<P>::IDS.iter().map(|id| (*id).to_owned()).collect();
|
||||
assert_eq!(seen, ids, "one case per id");
|
||||
|
||||
// Bad params: parse errors, or a DockError with nothing changed.
|
||||
for params in [
|
||||
json!({"command": "window.panel.unknown", "panel": "layers"}),
|
||||
json!({"command": "window.panel.move", "panel": "layers"}),
|
||||
json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "middle"}),
|
||||
json!({"command": "window.panel.minimize", "panel": "layers", "minimized": "yes"}),
|
||||
op(json!({"operation": "spin", "panel": "layers"})),
|
||||
op(json!({"operation": "float", "panel": "layers", "rect": [1, 2, 3]})),
|
||||
op(json!({"operation": "resizeStack", "panel": "layers"})),
|
||||
op(json!({"operation": "setStackOpen", "panel": "layers"})),
|
||||
op(json!({"operation": "move", "panel": "layers"})),
|
||||
] {
|
||||
assert!(parse(params.clone()).is_err(), "{params}");
|
||||
}
|
||||
rejected(json!({"command": "window.panel.move", "panel": "layers", "anchor": "nowhere"}), DockError::MissingTarget);
|
||||
rejected(json!({"command": "window.panel.move", "panel": "nowhere", "anchor": "layers"}), DockError::MissingPanel);
|
||||
rejected(
|
||||
json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "left", "before": null}),
|
||||
DockError::InvalidPlacement,
|
||||
);
|
||||
rejected(json!({"command": "window.panel.float", "panel": "layers", "width": 1e30}), DockError::InvalidGeometry);
|
||||
rejected(json!({"command": "window.panel.move", "panel": "canvas", "anchor": "layers"}), DockError::NotAllowed);
|
||||
rejected(op(json!({"operation": "moveFloating", "panel": "color", "rect": [0, 0, -5, 10]})), DockError::InvalidGeometry);
|
||||
rejected(
|
||||
op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "top", "before": "channels"})),
|
||||
DockError::InvalidPlacement,
|
||||
);
|
||||
rejected(op(json!({"operation": "resizeSplit", "path": [true, true, true], "size": {"Ratio": 0.5}})), DockError::MissingTarget);
|
||||
// A docked group cannot float into a native window when locked.
|
||||
let mut locked = workspace();
|
||||
locked.locked = true;
|
||||
let before = locked.clone();
|
||||
assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, false, &space(), permissions).is_err());
|
||||
assert_eq!(locked, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() {
|
||||
let command =
|
||||
|zone: &str, anchor: &str| parse(json!({"command": "window.panel.move", "panel": "history", "anchor": anchor, "zone": zone})).unwrap();
|
||||
let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl, &space()) {
|
||||
Ok(actions) => match actions.as_slice() {
|
||||
[Action::Move { placement, .. }] => Ok(placement.clone()),
|
||||
other => panic!("{other:?}"),
|
||||
},
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
// A start dock is on the left edge left to right, on the right edge right to left.
|
||||
assert_eq!(placed("right", "layers", false), Ok(Placement::Column { after: true }));
|
||||
assert_eq!(placed("right", "layers", true), Ok(Placement::Column { after: false }));
|
||||
assert_eq!(placed("left", "layers", true), Ok(Placement::Column { after: true }));
|
||||
assert_eq!(placed("top", "layers", false), Ok(Placement::Group { below: false, height: None }));
|
||||
// A floating window is a tree like the docked layout's.
|
||||
assert_eq!(placed("bottom", "color", false), Ok(Placement::Split(Zone::Bottom)));
|
||||
assert_eq!(placed("left", "color", false), Ok(Placement::Split(Zone::Left)));
|
||||
assert_eq!(placed("left", "canvas", true), Ok(Placement::Split(Zone::Left)));
|
||||
|
||||
let (workspace, _) = applied(json!({"command": "window.panel.move", "panel": "history", "anchor": "layers", "zone": "right"}));
|
||||
let Some(Node::Split { first, .. }) = &workspace.layout.root else { panic!() };
|
||||
let Node::Dock { columns, .. } = &**first else { panic!() };
|
||||
assert_eq!(columns.len(), 2);
|
||||
assert_eq!(columns[1].groups[0].panels, vec![s("history")]);
|
||||
}
|
||||
|
||||
/// `layers` prefers 200x150 when it floats.
|
||||
struct Sized;
|
||||
|
||||
impl DockContent<P> for Sized {
|
||||
fn body(&mut self, _: &mut egui::Ui, _: &P) {}
|
||||
fn permissions(&self, panel: &P) -> Permissions {
|
||||
permissions(panel)
|
||||
}
|
||||
fn floating_size(&self, panel: &P) -> Option<egui::Vec2> {
|
||||
(panel == "layers").then_some(egui::vec2(200.0, 150.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() {
|
||||
// The area starts under a 30 pt menu bar.
|
||||
let area = egui::Rect::from_min_max(egui::pos2(0.0, 30.0), egui::pos2(800.0, 600.0));
|
||||
let d = testing::DockDriver::new(workspace().layout, Sized).with_area(area);
|
||||
let space = float_space::<P>(&d.ctx, d.id).expect("the area records its float space");
|
||||
assert_eq!(space.area, area);
|
||||
let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space).unwrap();
|
||||
|
||||
let panel = float(json!({"command": "window.panel.float", "panel": "layers"}));
|
||||
// The floating_size width, and an empty body's least fitted height under its 150 points.
|
||||
assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }], "floating_size, inset in the area");
|
||||
let [Action::MoveGroup { to: Target::Float { rect: Some([x, y, width, height]) }, .. }] =
|
||||
float(json!({"command": "window.panel.float", "panel": "history", "group": true}))[..]
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!([x, y], [24.0, 54.0]);
|
||||
assert!(width > 100.0 && width <= 240.0 && height <= 320.0, "the docked group's size, capped: {width}x{height}");
|
||||
|
||||
// Given values stay exactly; a lone floating window keeps its rect.
|
||||
let given = float(json!({"command": "window.panel.float", "panel": "layers", "x": 5, "y": 6, "width": 100, "height": 90}));
|
||||
assert_eq!(given, vec![Action::Float { panel: s("layers"), rect: [5.0, 6.0, 100.0, 90.0] }]);
|
||||
let color = float(json!({"command": "window.panel.float", "panel": "color", "group": true}));
|
||||
assert_eq!(color, vec![Action::MoveGroup { panel: s("color"), to: Target::Float { rect: None } }]);
|
||||
|
||||
// Without recorded sizes a unit floats at the layout's size, shrunk into a small area.
|
||||
let small = FloatSpace::new(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(200.0, 200.0)));
|
||||
let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions(&workspace(), false, &small);
|
||||
assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }]));
|
||||
|
||||
// A host seeds the size of a panel the area never drew; a recorded size still wins.
|
||||
let seeded = FloatSpace::new(area).with_panel_size(s("layers"), egui::vec2(180.0, 240.0));
|
||||
let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded);
|
||||
assert_eq!(applied, Ok(vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 180.0, 240.0] }]));
|
||||
let recorded = space.clone().with_panel_size(s("layers"), egui::vec2(180.0, 240.0));
|
||||
assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }]);
|
||||
}
|
||||
|
||||
fn float_with(space: &FloatSpace<P>, panel: &str) -> Vec<Action<P>> {
|
||||
parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_to_a_side_without_a_dock_takes_one_panel_out_of_a_shared_group() {
|
||||
let params = json!({"command": "window.panel.dock", "panel": "layers", "side": "End"});
|
||||
assert_eq!(actions(params.clone()), vec![Action::MovePanel { panel: s("layers"), to: Target::NewDock { side: Side::End } }]);
|
||||
let (workspace, _) = applied(params);
|
||||
let side = |panel: &str| workspace.layout.location(&s(panel)).unwrap().group.and_then(|g| g.column).map(|c| c.side);
|
||||
assert_eq!(side("layers"), Some(Side::End));
|
||||
assert_eq!(side("channels"), Some(Side::Start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimize_without_a_value_toggles_an_accordion_entry_both_ways() {
|
||||
let entry = |panel: &str| StackEntry { panel: s(panel), open: true, height: None };
|
||||
let mut workspace = Workspace::new(Layout { root: Some(Node::Stack { entries: vec![entry("layers"), entry("history")] }), floating: Vec::new() });
|
||||
let toggle = parse(json!({"command": "window.panel.minimize", "panel": "layers"})).unwrap();
|
||||
toggle.apply(&mut workspace, false, &space(), permissions).unwrap();
|
||||
assert_eq!(workspace.layout.panel_state(&s("layers")), PanelState::Open);
|
||||
toggle.apply(&mut workspace, false, &space(), permissions).unwrap();
|
||||
assert_eq!(workspace.layout.panel_state(&s("layers")), PanelState::Front);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_with_a_side_zone_and_no_anchor_makes_an_outer_edge_frame() {
|
||||
let edge = |panel: &str, zone| Action::Move { panel: s(panel), anchor: s("layers"), placement: Placement::Edge(zone) };
|
||||
assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "color", "zone": "left"})), vec![edge("color", Zone::Left)]);
|
||||
assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "history", "zone": "bottom"})), vec![edge("history", Zone::Bottom)]);
|
||||
let (moved, _) = applied(json!({"command": "window.panel.dock", "panel": "color", "zone": "right"}));
|
||||
assert!(matches!(moved.layout.place(&s("color")), Some(crate::docking::dock_model::Place::Node(_))), "{:?}", moved.layout);
|
||||
let (shown, _) = applied(json!({"command": "window.panel.dock", "panel": "swatches", "zone": "top"}));
|
||||
assert!(
|
||||
matches!(shown.layout.place(&s("swatches")), Some(crate::docking::dock_model::Place::Node(_))) && shown.hidden.is_empty(),
|
||||
"{:?}",
|
||||
shown.layout
|
||||
);
|
||||
rejected(json!({"command": "window.panel.dock", "panel": "color", "zone": "left", "before": null}), DockError::InvalidPlacement);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_to_the_side_a_panel_already_leads_the_last_column_of() {
|
||||
// Layers leads the dock's only column: it moves out to a new column after it.
|
||||
let (workspace, _) = applied(json!({"command": "window.panel.dock", "panel": "layers", "side": "Start"}));
|
||||
assert!(matches!(workspace.layout.place(&s("layers")), Some(dock_model::Place::Dock { column: 1, .. })));
|
||||
assert!(matches!(workspace.layout.place(&s("channels")), Some(dock_model::Place::Dock { column: 0, .. })));
|
||||
// The whole column already is the last one: nothing to do.
|
||||
assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers", "column": true, "side": "Start"})), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showing_a_panel_never_laid_out_opens_it_in_the_first_dock_group_or_at_an_edge() {
|
||||
// "brushes" is in neither the layout nor `hidden`.
|
||||
for params in [
|
||||
json!({"command": "window.panel.activate", "panel": "brushes"}),
|
||||
json!({"command": "window.panel.dock", "panel": "brushes"}),
|
||||
json!({"command": "ui.dock", "operation": "open", "panel": "brushes"}),
|
||||
] {
|
||||
let (shown, _) = applied(params.clone());
|
||||
assert!(
|
||||
matches!(shown.layout.place(&s("brushes")), Some(dock_model::Place::Dock { column: 0, group: 0, .. })),
|
||||
"{params}: {:?}",
|
||||
shown.layout
|
||||
);
|
||||
assert_eq!(shown.layout.panel_state(&s("brushes")), PanelState::Front, "{params}");
|
||||
}
|
||||
// No dock, and the only frame is a protected canvas: a new frame at the edge, not a tab.
|
||||
let mut workspace = Workspace::new(Layout { root: Some(Node::Tabs { panels: vec![s("canvas")], active: 0 }), floating: Vec::new() });
|
||||
let command = parse(json!({"command": "window.panel.activate", "panel": "brushes"})).unwrap();
|
||||
command.apply(&mut workspace, false, &space(), permissions).unwrap();
|
||||
assert!(matches!(workspace.layout.place(&s("brushes")), Some(dock_model::Place::Node(_))), "{:?}", workspace.layout);
|
||||
assert_ne!(workspace.layout.place(&s("brushes")), workspace.layout.place(&s("canvas")));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Which of the application's windows lies under a point on the desktop (feature
|
||||
//! `native-windows`). Points and rects are in desktop logical points.
|
||||
|
||||
use egui::{Pos2, Rect};
|
||||
|
||||
/// Where a desktop point falls.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Over {
|
||||
/// The floating native window with this [`super::Floating::id`], at this point in its own
|
||||
/// coordinates.
|
||||
Window(u64, Pos2),
|
||||
/// The main window, at this point in its coordinates.
|
||||
Main(Pos2),
|
||||
/// None of the application's windows.
|
||||
Desktop,
|
||||
}
|
||||
|
||||
/// The window under `point`: the first of `windows` (floating native windows by id and desktop
|
||||
/// rect, front first) that holds it, where the first `front` of them lie in front of `main`
|
||||
/// (the main window's content) and the rest behind it. The window `dragged` follows the pointer
|
||||
/// by its bar and is never its own target.
|
||||
pub(super) fn window_at(point: Pos2, windows: &[(u64, Rect)], front: usize, main: Option<Rect>, dragged: Option<u64>) -> Over {
|
||||
if !point.is_finite() {
|
||||
return Over::Desktop;
|
||||
}
|
||||
let (before, behind) = windows.split_at(front.min(windows.len()));
|
||||
let hit = |windows: &[(u64, Rect)]| {
|
||||
windows.iter().find(|(id, rect)| Some(*id) != dragged && rect.contains(point)).map(|(id, rect)| Over::Window(*id, point - rect.min.to_vec2()))
|
||||
};
|
||||
hit(before)
|
||||
.or_else(|| main.filter(|main| main.contains(point)).map(|main| Over::Main(point - main.min.to_vec2())))
|
||||
.or_else(|| hit(behind))
|
||||
.unwrap_or(Over::Desktop)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{pos2, vec2};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
|
||||
Rect::from_min_size(pos2(x, y), vec2(w, h))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_front_window_wins_then_the_main_window_then_the_desktop() {
|
||||
let main = Some(rect(100.0, 50.0, 800.0, 600.0));
|
||||
// Window 2 lies over window 1, and both over the main window.
|
||||
let windows = [(2, rect(300.0, 100.0, 200.0, 200.0)), (1, rect(250.0, 80.0, 200.0, 200.0))];
|
||||
assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, None), Over::Window(2, pos2(50.0, 50.0)));
|
||||
assert_eq!(window_at(pos2(260.0, 90.0), &windows, 2, main, None), Over::Window(1, pos2(10.0, 10.0)));
|
||||
assert_eq!(window_at(pos2(120.0, 60.0), &windows, 2, main, None), Over::Main(pos2(20.0, 10.0)));
|
||||
assert_eq!(window_at(pos2(20.0, 20.0), &windows, 2, main, None), Over::Desktop);
|
||||
// A floating window outside the main window.
|
||||
let windows = [(3, rect(1000.0, 0.0, 100.0, 100.0))];
|
||||
assert_eq!(window_at(pos2(1050.0, 40.0), &windows, 2, main, None), Over::Window(3, pos2(50.0, 40.0)));
|
||||
// Unknown main window, a point that is not one.
|
||||
assert_eq!(window_at(pos2(120.0, 60.0), &[], 0, None, None), Over::Desktop);
|
||||
assert_eq!(window_at(pos2(f32::NAN, 60.0), &windows, 2, main, None), Over::Desktop);
|
||||
// A normal window behind the main window is reached only past the main window's edge.
|
||||
let windows = [(4, rect(50.0, 40.0, 200.0, 200.0))];
|
||||
assert_eq!(window_at(pos2(120.0, 60.0), &windows, 0, main, None), Over::Main(pos2(20.0, 10.0)));
|
||||
assert_eq!(window_at(pos2(60.0, 45.0), &windows, 0, main, None), Over::Window(4, pos2(10.0, 5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dragged_window_is_looked_through() {
|
||||
let main = Some(rect(0.0, 0.0, 800.0, 600.0));
|
||||
let windows = [(2, rect(300.0, 100.0, 200.0, 200.0)), (1, rect(250.0, 80.0, 200.0, 200.0))];
|
||||
assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, Some(2)), Over::Window(1, pos2(100.0, 70.0)));
|
||||
assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, Some(1)), Over::Window(2, pos2(50.0, 50.0)));
|
||||
assert_eq!(window_at(pos2(480.0, 280.0), &windows, 2, main, Some(2)), Over::Main(pos2(480.0, 280.0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
//! Docks, columns, groups and floating windows: their types and the structural edits behind
|
||||
//! group, column and window moves.
|
||||
//!
|
||||
//! A group, column or floating window is named by a panel it contains. Edits run on a proposed
|
||||
//! copy of the layout, so a helper that fails part-way leaves nothing behind.
|
||||
|
||||
use std::{collections::HashMap, hash::Hash};
|
||||
|
||||
use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, MAX_PANELS, Node, Placement, Zone, valid_height, valid_rect, valid_width};
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
|
||||
/// Width of a new column when nothing better is known.
|
||||
pub(super) const DEFAULT_COLUMN_WIDTH: f32 = 240.0;
|
||||
/// Rect of a new floating window when the caller gives none and the source has no size.
|
||||
const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0];
|
||||
|
||||
/// The window edge a [`Node::Dock`] sits on, in reading order so right-to-left interfaces
|
||||
/// mirror. `Start` is the edge of a horizontal split's first child (left in a left-to-right
|
||||
/// layout); the renderer maps it to a physical edge from the layout direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[non_exhaustive]
|
||||
pub enum Side {
|
||||
Start,
|
||||
End,
|
||||
}
|
||||
|
||||
/// Tabs that move, minimize and size together in a dock column.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))]
|
||||
pub struct Group<P> {
|
||||
pub panels: Vec<P>,
|
||||
pub active: usize,
|
||||
/// Only the tab strip shows. `height` is kept for when the group expands again.
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub minimized: bool,
|
||||
/// Preferred height in points, set when the user resizes the group; `None` shares the free
|
||||
/// height of the column or stack, or in a dock column takes
|
||||
/// [`DockContent::dock_heights`](crate::docking::DockContent::dock_heights).
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub height: Option<f32>,
|
||||
}
|
||||
|
||||
impl<P> Group<P> {
|
||||
pub fn new(panels: Vec<P>, active: usize) -> Self {
|
||||
Self { panels, active, minimized: false, height: None }
|
||||
}
|
||||
|
||||
pub fn active_panel(&self) -> Option<&P> {
|
||||
self.panels.get(self.active)
|
||||
}
|
||||
}
|
||||
|
||||
/// One column of a dock: groups from top to bottom.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))]
|
||||
pub struct Column<P> {
|
||||
pub groups: Vec<Group<P>>,
|
||||
/// Expanded width in points.
|
||||
pub width: f32,
|
||||
/// Collapsed to one icon per panel.
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub iconic: bool,
|
||||
/// Width while iconic; labels show beside the icons when it is wide enough.
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub iconic_width: Option<f32>,
|
||||
}
|
||||
|
||||
impl<P> Column<P> {
|
||||
pub fn new(groups: Vec<Group<P>>, width: f32) -> Self {
|
||||
Self { groups, width, iconic: false, iconic_width: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a group, column or floating window goes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[non_exhaustive]
|
||||
pub enum Target<P> {
|
||||
/// Relative to the panel `anchor`, which must not be part of what moves.
|
||||
At { anchor: P, placement: Placement<P> },
|
||||
/// A new dock on an edge that has none (or an empty one). The root becomes the other side
|
||||
/// of a horizontal split.
|
||||
NewDock { side: Side },
|
||||
/// A new floating window. `None` keeps the source's size at a default position.
|
||||
Float { rect: Option<[f32; 4]> },
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash> Placement<P> {
|
||||
/// The placement for a drop zone on `anchor`'s group, in the vocabulary its container
|
||||
/// accepts. `zone` is physical: `Left` is the left half on screen in either direction.
|
||||
///
|
||||
/// | Anchor in | `Center` | `Top`, `Bottom` | `Left`, `Right` |
|
||||
/// |---|---|---|---|
|
||||
/// | the split tree or a floating window | [`Placement::Split`] | `Split` | `Split` |
|
||||
/// | a dock column | `Split(Center)` (join as tabs) | [`Placement::Group`] above or below | [`Placement::Column`] on that side |
|
||||
///
|
||||
/// A dock's `Column { after }` counts from its window edge, which depends on the dock's
|
||||
/// side and `right_to_left` (see [`Side`]); this picks `after` so the new column lands on
|
||||
/// the zone's side of the anchor's column.
|
||||
pub fn for_zone(layout: &Layout<P>, anchor: &P, zone: Zone, right_to_left: bool) -> Result<Self, DockError> {
|
||||
let place = layout.place(anchor).ok_or(DockError::MissingTarget)?;
|
||||
Ok(match (place, zone) {
|
||||
(Place::Node(_) | Place::Floating { .. }, zone) | (_, zone @ Zone::Center) => Placement::Split(zone),
|
||||
(Place::Dock { .. }, Zone::Top | Zone::Bottom) => Placement::Group { below: zone == Zone::Bottom, height: None },
|
||||
(Place::Dock { path, .. }, _) => {
|
||||
let side = match layout.root.as_ref().and_then(|root| node_at(root, &path)) {
|
||||
Some(Node::Dock { side, .. }) => *side,
|
||||
_ => return Err(DockError::MissingTarget),
|
||||
};
|
||||
// The physical edge of the dock: Start is left in a left-to-right layout.
|
||||
let left_edge = (side == Side::Start) != right_to_left;
|
||||
// `after` is away from the window edge: rightwards for a left dock.
|
||||
Placement::Column { after: (zone == Zone::Right) == left_edge }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Window menu shows for a panel. The model does not track the z-order of floating
|
||||
/// windows, so `Front` means "showing": the active tab of an expanded group in an expanded column.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PanelState {
|
||||
Hidden,
|
||||
Open,
|
||||
Front,
|
||||
}
|
||||
|
||||
/// The group a closed panel was in, when that group was in a dock.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct GroupLocation {
|
||||
pub minimized: bool,
|
||||
pub height: Option<f32>,
|
||||
/// The column, when the group was docked.
|
||||
pub column: Option<ColumnLocation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct ColumnLocation {
|
||||
pub side: Side,
|
||||
pub width: f32,
|
||||
pub iconic: bool,
|
||||
pub iconic_width: Option<f32>,
|
||||
}
|
||||
|
||||
/// Where a panel is. Paths lead from the root: false is a split's first child, true its second.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Place {
|
||||
/// A `Tabs` or `Stack` node.
|
||||
Node(Vec<bool>),
|
||||
Dock {
|
||||
path: Vec<bool>,
|
||||
column: usize,
|
||||
group: usize,
|
||||
},
|
||||
/// A `Tabs` or `Stack` node of a floating window's tree.
|
||||
Floating {
|
||||
window: usize,
|
||||
path: Vec<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Place {
|
||||
/// The tree (`None` for the docked layout, else a floating window) and the path of a
|
||||
/// `Tabs` or `Stack` node.
|
||||
pub(super) fn tree_path(&self) -> Option<(Option<usize>, &[bool])> {
|
||||
match self {
|
||||
Place::Node(path) => Some((None, path)),
|
||||
Place::Floating { window, path } => Some((Some(*window), path)),
|
||||
Place::Dock { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which container a whole-container action names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Unit {
|
||||
Group,
|
||||
Column,
|
||||
Window,
|
||||
}
|
||||
|
||||
/// Groups in transit, with the size and state of the container they came from.
|
||||
pub(super) struct Moving<P> {
|
||||
pub groups: Vec<Group<P>>,
|
||||
/// A floating window's whole tree, whose leaves are `groups`. A split or a new window
|
||||
/// takes the tree; docks take the groups.
|
||||
pub node: Option<Node<P>>,
|
||||
pub minimized: bool,
|
||||
pub width: Option<f32>,
|
||||
pub iconic: bool,
|
||||
pub iconic_width: Option<f32>,
|
||||
pub rect: Option<[f32; 4]>,
|
||||
pub native: Option<[f32; 4]>,
|
||||
/// The id of a whole floating window in transit, which a new window keeps.
|
||||
pub id: Option<u64>,
|
||||
}
|
||||
|
||||
impl<P> Moving<P> {
|
||||
pub fn group(group: Group<P>) -> Self {
|
||||
Self { groups: vec![group], node: None, minimized: false, width: None, iconic: false, iconic_width: None, rect: None, native: None, id: None }
|
||||
}
|
||||
|
||||
fn into_column(self, width: f32) -> Column<P> {
|
||||
Column { groups: self.groups, width: self.width.unwrap_or(width), iconic: self.iconic, iconic_width: self.iconic_width }
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn node_at<'a, P>(mut node: &'a Node<P>, path: &[bool]) -> Option<&'a Node<P>> {
|
||||
for second_child in path {
|
||||
let Node::Split { first, second, .. } = node else { return None };
|
||||
node = if *second_child { second } else { first };
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
pub(super) fn node_at_mut<'a, P>(mut node: &'a mut Node<P>, path: &[bool]) -> Option<&'a mut Node<P>> {
|
||||
for second_child in path {
|
||||
let Node::Split { first, second, .. } = node else { return None };
|
||||
node = if *second_child { second } else { first };
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Every `Tabs`, `Stack` and `Dock` node of a tree with its path, first to last. Stops at
|
||||
/// [`MAX_NODES`] for an invalid tree.
|
||||
pub(super) fn leaves<P>(root: &Node<P>) -> Vec<(&Node<P>, Vec<bool>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut visit = vec![(root, Vec::new())];
|
||||
let mut visited = 0usize;
|
||||
while let Some((node, path)) = visit.pop() {
|
||||
visited = visited.saturating_add(1);
|
||||
if visited > MAX_NODES {
|
||||
break;
|
||||
}
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
let mut second_path = path.clone();
|
||||
second_path.push(true);
|
||||
let mut first_path = path;
|
||||
first_path.push(false);
|
||||
visit.push((second, second_path));
|
||||
visit.push((first, first_path));
|
||||
}
|
||||
leaf => out.push((leaf, path)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The panels of a tree in order, without recursion; at most [`MAX_PANELS`].
|
||||
pub(super) fn tree_panels<P>(root: &Node<P>) -> Vec<&P> {
|
||||
let mut panels = Vec::new();
|
||||
for (leaf, _) in leaves(root) {
|
||||
let room = MAX_PANELS.saturating_sub(panels.len());
|
||||
match leaf {
|
||||
Node::Tabs { panels: group, .. } => panels.extend(group.iter().take(room)),
|
||||
Node::Stack { entries } => panels.extend(entries.iter().map(|e| &e.panel).take(room)),
|
||||
Node::Dock { columns, .. } => panels.extend(columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).take(room)),
|
||||
Node::Split { .. } => {}
|
||||
}
|
||||
}
|
||||
panels
|
||||
}
|
||||
|
||||
/// The shown panel of a tree's first group.
|
||||
#[cfg(feature = "native-windows")]
|
||||
pub(super) fn first_active<P>(root: &Node<P>) -> Option<&P> {
|
||||
match leaves(root).into_iter().next()?.0 {
|
||||
Node::Tabs { panels, active } => panels.get(*active),
|
||||
Node::Stack { entries } => entries.first().map(|e| &e.panel),
|
||||
Node::Split { .. } | Node::Dock { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The groups of a tree, first to last: what a dock column takes from a floating window.
|
||||
pub(super) fn tree_groups<P: Clone>(root: &Node<P>) -> Vec<Group<P>> {
|
||||
let mut groups = Vec::new();
|
||||
for (leaf, _) in leaves(root) {
|
||||
match leaf {
|
||||
Node::Tabs { panels, active } => groups.push(Group::new(panels.clone(), *active)),
|
||||
Node::Stack { entries } => groups.push(Group::new(entries.iter().map(|e| e.panel.clone()).collect(), 0)),
|
||||
Node::Dock { columns, .. } => groups.extend(columns.iter().flat_map(|c| c.groups.iter().cloned())),
|
||||
Node::Split { .. } => {}
|
||||
}
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Groups stacked top to bottom as a tree: each group over a vertical split of the ones below
|
||||
/// it, sized by their heights, with an equal share where a group has none.
|
||||
pub(super) fn stack_tree<P>(groups: Vec<Group<P>>) -> Option<Node<P>> {
|
||||
let height = |g: &Group<P>| g.height.filter(|h| h.is_finite() && *h > 0.0);
|
||||
let known: Vec<f32> = groups.iter().filter_map(height).collect();
|
||||
let share = if known.is_empty() { 1.0 } else { known.iter().sum::<f32>() / known.len() as f32 };
|
||||
let weights: Vec<f32> = groups.iter().map(|g| height(g).unwrap_or(share)).collect();
|
||||
let mut node = None;
|
||||
let mut below = 0.0;
|
||||
for (group, weight) in groups.into_iter().zip(weights).rev() {
|
||||
let tabs = Node::Tabs { panels: group.panels, active: group.active };
|
||||
node = Some(match node {
|
||||
None => tabs,
|
||||
Some(rest) => {
|
||||
let ratio = (weight / (weight + below)).clamp(0.0, 1.0);
|
||||
Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(ratio), first: Box::new(tabs), second: Box::new(rest) }
|
||||
}
|
||||
});
|
||||
below += weight;
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Put `new` on the `zone` side of the frame at `path`: the frame splits into two halves and
|
||||
/// every other frame keeps its size.
|
||||
pub(super) fn split_frame<P>(root: &mut Node<P>, path: &[bool], new: Node<P>, zone: Zone) -> bool {
|
||||
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
|
||||
let new_first = matches!(zone, Zone::Top | Zone::Left);
|
||||
let Some(node) = node_at_mut(root, path) else { return false };
|
||||
let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 });
|
||||
let (first, second) = if new_first { (new, old) } else { (old, new) };
|
||||
*node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) };
|
||||
true
|
||||
}
|
||||
|
||||
/// Insert `new` at `index` and select the member at `index + selected`.
|
||||
fn splice<P>(panels: &mut Vec<P>, active: &mut usize, index: usize, new: Vec<P>, selected: usize) {
|
||||
let index = index.min(panels.len());
|
||||
let count = new.len();
|
||||
panels.splice(index..index, new);
|
||||
*active = index + selected.min(count.saturating_sub(1));
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash> Layout<P> {
|
||||
pub(super) fn place(&self, panel: &P) -> Option<Place> {
|
||||
for (w, window) in self.floating.iter().enumerate() {
|
||||
for (leaf, path) in leaves(&window.root) {
|
||||
if leaf_holds(leaf, panel) {
|
||||
return Some(Place::Floating { window: w, path });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (leaf, path) in self.root.as_ref().map(leaves).unwrap_or_default() {
|
||||
match leaf {
|
||||
Node::Dock { columns, .. } => {
|
||||
for (c, column) in columns.iter().enumerate() {
|
||||
if let Some(g) = column.groups.iter().position(|group| group.panels.contains(panel)) {
|
||||
return Some(Place::Dock { path, column: c, group: g });
|
||||
}
|
||||
}
|
||||
}
|
||||
leaf if leaf_holds(leaf, panel) => return Some(Place::Node(path)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// [`Self::place`] of every panel, from one traversal: the renderer looks up many panels a
|
||||
/// frame.
|
||||
pub(super) fn places(&self) -> HashMap<P, Place> {
|
||||
fn note<P: Clone + Eq + Hash>(places: &mut HashMap<P, Place>, leaf: &Node<P>, place: &dyn Fn() -> Place) {
|
||||
match leaf {
|
||||
Node::Tabs { panels, .. } => {
|
||||
for panel in panels {
|
||||
places.entry(panel.clone()).or_insert_with(place);
|
||||
}
|
||||
}
|
||||
Node::Stack { entries } => {
|
||||
for entry in entries {
|
||||
places.entry(entry.panel.clone()).or_insert_with(place);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut places = HashMap::new();
|
||||
for (w, window) in self.floating.iter().enumerate() {
|
||||
for (leaf, path) in leaves(&window.root) {
|
||||
note(&mut places, leaf, &|| Place::Floating { window: w, path: path.clone() });
|
||||
}
|
||||
}
|
||||
for (leaf, path) in self.root.as_ref().map(leaves).unwrap_or_default() {
|
||||
if let Node::Dock { columns, .. } = leaf {
|
||||
for (c, column) in columns.iter().enumerate() {
|
||||
for (g, group) in column.groups.iter().enumerate() {
|
||||
for panel in &group.panels {
|
||||
places.entry(panel.clone()).or_insert_with(|| Place::Dock { path: path.clone(), column: c, group: g });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
note(&mut places, leaf, &|| Place::Node(path.clone()));
|
||||
}
|
||||
}
|
||||
places
|
||||
}
|
||||
|
||||
/// The docked layout (`None`) or a floating window's tree.
|
||||
pub(super) fn tree(&self, window: Option<usize>) -> Option<&Node<P>> {
|
||||
match window {
|
||||
None => self.root.as_ref(),
|
||||
Some(window) => self.floating.get(window).map(|w| &w.root),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tree_mut(&mut self, window: Option<usize>) -> Option<&mut Node<P>> {
|
||||
match window {
|
||||
None => self.root.as_mut(),
|
||||
Some(window) => self.floating.get_mut(window).map(|w| &mut w.root),
|
||||
}
|
||||
}
|
||||
|
||||
/// The docked layout's tree, then each floating window's.
|
||||
pub(super) fn trees_mut(&mut self) -> impl Iterator<Item = &mut Node<P>> {
|
||||
self.root.iter_mut().chain(self.floating.iter_mut().map(|w| &mut w.root))
|
||||
}
|
||||
|
||||
/// The `Tabs` or `Stack` node at a tree place.
|
||||
pub(super) fn node_of(&self, place: &Place) -> Option<&Node<P>> {
|
||||
let (window, path) = place.tree_path()?;
|
||||
node_at(self.tree(window)?, path)
|
||||
}
|
||||
|
||||
fn node_of_mut(&mut self, place: &Place) -> Option<&mut Node<P>> {
|
||||
let (window, path) = place.tree_path()?;
|
||||
node_at_mut(self.tree_mut(window)?, path)
|
||||
}
|
||||
|
||||
pub(super) fn dock_path(&self, side: Side) -> Option<Vec<bool>> {
|
||||
let mut visit: Vec<(&Node<P>, Vec<bool>)> = self.root.iter().map(|node| (node, Vec::new())).collect();
|
||||
let mut visited = 0usize;
|
||||
while let Some((node, path)) = visit.pop() {
|
||||
visited = visited.saturating_add(1);
|
||||
if visited > MAX_NODES {
|
||||
return None;
|
||||
}
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
let mut second_path = path.clone();
|
||||
second_path.push(true);
|
||||
let mut first_path = path;
|
||||
first_path.push(false);
|
||||
visit.push((second, second_path));
|
||||
visit.push((first, first_path));
|
||||
}
|
||||
Node::Dock { side: s, .. } if *s == side => return Some(path),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The node an edge split wraps: the root, or the part between the docks.
|
||||
pub(in crate::docking) fn edge_path(&self) -> Vec<bool> {
|
||||
let mut path = Vec::new();
|
||||
let mut node = self.root.as_ref();
|
||||
while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node {
|
||||
if path.len() > MAX_DEPTH {
|
||||
break;
|
||||
}
|
||||
let second_side = match (&**first, &**second) {
|
||||
(Node::Dock { .. }, _) => true,
|
||||
(_, Node::Dock { .. }) => false,
|
||||
_ => break,
|
||||
};
|
||||
path.push(second_side);
|
||||
node = Some(if second_side { second } else { first });
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec<Column<P>>> {
|
||||
match self.root.as_mut().and_then(|root| node_at_mut(root, path)) {
|
||||
Some(Node::Dock { columns, .. }) => Some(columns),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn column(&self, place: &Place) -> Option<&Column<P>> {
|
||||
let Place::Dock { path, column, .. } = place else { return None };
|
||||
match self.root.as_ref().and_then(|root| node_at(root, path)) {
|
||||
Some(Node::Dock { columns, .. }) => columns.get(*column),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn column_mut(&mut self, place: &Place) -> Option<&mut Column<P>> {
|
||||
let Place::Dock { path, column, .. } = place else { return None };
|
||||
self.columns_mut(path)?.get_mut(*column)
|
||||
}
|
||||
|
||||
/// The dock group at `place`; `None` for a `Tabs` or `Stack` node.
|
||||
pub(super) fn group(&self, place: &Place) -> Option<&Group<P>> {
|
||||
match place {
|
||||
Place::Dock { group, .. } => self.column(place)?.groups.get(*group),
|
||||
Place::Node(_) | Place::Floating { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn group_mut(&mut self, place: &Place) -> Option<&mut Group<P>> {
|
||||
match place {
|
||||
Place::Dock { group, .. } => self.column_mut(place)?.groups.get_mut(*group),
|
||||
Place::Node(_) | Place::Floating { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the node at `path` of the docked layout; its parent split collapses into the
|
||||
/// sibling.
|
||||
pub(super) fn take_node(&mut self, path: &[bool]) -> Option<Node<P>> {
|
||||
self.take_node_in(None, path)
|
||||
}
|
||||
|
||||
/// [`Self::take_node`] in the docked layout (`None`) or a floating window, which closes
|
||||
/// when its root goes.
|
||||
fn take_node_in(&mut self, window: Option<usize>, path: &[bool]) -> Option<Node<P>> {
|
||||
let Some((second_child, parent_path)) = path.split_last() else {
|
||||
return match window {
|
||||
None => self.root.take(),
|
||||
Some(window) => (window < self.floating.len()).then(|| self.floating.remove(window).root),
|
||||
};
|
||||
};
|
||||
let parent = self.tree_mut(window).and_then(|root| node_at_mut(root, parent_path))?;
|
||||
if !matches!(parent, Node::Split { .. }) {
|
||||
return None;
|
||||
}
|
||||
match std::mem::replace(parent, Node::Tabs { panels: Vec::new(), active: 0 }) {
|
||||
Node::Split { first, second, .. } => {
|
||||
let (taken, kept) = if *second_child { (second, first) } else { (first, second) };
|
||||
*parent = *kept;
|
||||
Some(*taken)
|
||||
}
|
||||
other => {
|
||||
*parent = other;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The members of the group, column or floating window containing `panel`, in order.
|
||||
/// An accordion entry is a group of one.
|
||||
pub(super) fn members(&self, panel: &P, unit: Unit) -> Result<Vec<P>, DockError> {
|
||||
let place = self.place(panel).ok_or(DockError::MissingPanel)?;
|
||||
match (unit, &place) {
|
||||
(Unit::Group, Place::Node(_) | Place::Floating { .. }) => match self.node_of(&place) {
|
||||
Some(Node::Tabs { panels, .. }) => Ok(panels.clone()),
|
||||
Some(Node::Stack { .. }) => Ok(vec![panel.clone()]),
|
||||
_ => Err(DockError::MissingPanel),
|
||||
},
|
||||
(Unit::Group, _) => self.group(&place).map(|g| g.panels.clone()).ok_or(DockError::MissingPanel),
|
||||
(Unit::Column, Place::Dock { .. }) => {
|
||||
let column = self.column(&place).ok_or(DockError::MissingPanel)?;
|
||||
Ok(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect())
|
||||
}
|
||||
(Unit::Window, Place::Floating { window, .. }) => {
|
||||
let window = self.floating.get(*window).ok_or(DockError::MissingPanel)?;
|
||||
Ok(window.panels().cloned().collect())
|
||||
}
|
||||
(Unit::Column | Unit::Window, _) => Err(DockError::InvalidPlacement),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn take_unit(&mut self, panel: &P, unit: Unit) -> Result<Moving<P>, DockError> {
|
||||
match unit {
|
||||
Unit::Group => self.take_group(panel),
|
||||
Unit::Column => self.take_column(panel),
|
||||
Unit::Window => self.take_window(panel),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_group(&mut self, panel: &P) -> Result<Moving<P>, DockError> {
|
||||
let place = self.place(panel).ok_or(DockError::MissingPanel)?;
|
||||
match place {
|
||||
Place::Node(_) | Place::Floating { .. } => {
|
||||
let (window, path) = place.tree_path().map(|(w, p)| (w, p.to_vec())).ok_or(DockError::MissingPanel)?;
|
||||
// A group leaving a floating window keeps its width; the window's only group
|
||||
// takes the window's rect along.
|
||||
let source = window.and_then(|w| self.floating.get(w)).map(|w| (w.rect, w.native, w.minimized, w.id));
|
||||
let whole = |moving: Moving<P>| match source {
|
||||
Some((rect, native, minimized, id)) if path.is_empty() => {
|
||||
Moving { width: rect.get(2).copied(), rect: Some(rect), native, minimized, id: Some(id), ..moving }
|
||||
}
|
||||
Some((rect, ..)) => Moving { width: rect.get(2).copied(), ..moving },
|
||||
None => moving,
|
||||
};
|
||||
if let Some(Node::Stack { entries }) = self.node_of_mut(&place)
|
||||
&& entries.len() > 1
|
||||
&& let Some(index) = entries.iter().position(|e| &e.panel == panel)
|
||||
{
|
||||
let entry = entries.remove(index);
|
||||
let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height };
|
||||
let moving = whole(Moving::group(group));
|
||||
return Ok(Moving { rect: None, native: None, id: None, ..moving });
|
||||
}
|
||||
match self.take_node_in(window, &path) {
|
||||
Some(Node::Tabs { panels, active }) => Ok(whole(Moving::group(Group::new(panels, active)))),
|
||||
Some(Node::Stack { mut entries }) if entries.len() == 1 => {
|
||||
let entry = entries.remove(0);
|
||||
Ok(whole(Moving::group(Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height })))
|
||||
}
|
||||
_ => Err(DockError::MissingPanel),
|
||||
}
|
||||
}
|
||||
Place::Dock { path, column, group } => {
|
||||
let columns = self.columns_mut(&path).ok_or(DockError::MissingPanel)?;
|
||||
let source = columns.get_mut(column).ok_or(DockError::MissingPanel)?;
|
||||
if group >= source.groups.len() {
|
||||
return Err(DockError::MissingPanel);
|
||||
}
|
||||
let taken = source.groups.remove(group);
|
||||
let width = source.width;
|
||||
if source.groups.is_empty() {
|
||||
columns.remove(column);
|
||||
}
|
||||
if columns.is_empty() {
|
||||
self.take_node(&path);
|
||||
}
|
||||
Ok(Moving { width: Some(width), ..Moving::group(taken) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_column(&mut self, panel: &P) -> Result<Moving<P>, DockError> {
|
||||
let Some(Place::Dock { path, column, .. }) = self.place(panel) else { return Err(DockError::InvalidPlacement) };
|
||||
let columns = self.columns_mut(&path).ok_or(DockError::MissingPanel)?;
|
||||
if column >= columns.len() {
|
||||
return Err(DockError::MissingPanel);
|
||||
}
|
||||
let taken = columns.remove(column);
|
||||
if columns.is_empty() {
|
||||
self.take_node(&path);
|
||||
}
|
||||
Ok(Moving {
|
||||
groups: taken.groups,
|
||||
node: None,
|
||||
minimized: false,
|
||||
width: Some(taken.width),
|
||||
iconic: taken.iconic,
|
||||
iconic_width: taken.iconic_width,
|
||||
rect: None,
|
||||
native: None,
|
||||
id: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn take_window(&mut self, panel: &P) -> Result<Moving<P>, DockError> {
|
||||
let Some(Place::Floating { window, .. }) = self.place(panel) else { return Err(DockError::InvalidPlacement) };
|
||||
if window >= self.floating.len() {
|
||||
return Err(DockError::MissingPanel);
|
||||
}
|
||||
let taken = self.floating.remove(window);
|
||||
Ok(Moving {
|
||||
groups: tree_groups(&taken.root),
|
||||
node: Some(taken.root),
|
||||
minimized: taken.minimized,
|
||||
width: taken.rect.get(2).copied(),
|
||||
iconic: false,
|
||||
iconic_width: None,
|
||||
rect: Some(taken.rect),
|
||||
native: taken.native,
|
||||
id: Some(taken.id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert groups taken by [`Self::take_unit`] (or a new single-panel group).
|
||||
pub(super) fn insert_moving(&mut self, moving: Moving<P>, to: Target<P>) -> Result<(), DockError> {
|
||||
if moving.groups.is_empty() || moving.groups.iter().any(|g| g.panels.is_empty()) {
|
||||
return Err(DockError::MissingPanel);
|
||||
}
|
||||
match to {
|
||||
Target::Float { rect } => {
|
||||
let native = if rect.is_none() { moving.native } else { None };
|
||||
let rect = rect.or(moving.rect).unwrap_or_else(|| {
|
||||
let [x, y, width, height] = DEFAULT_FLOAT;
|
||||
[x, y, moving.width.unwrap_or(width), height]
|
||||
});
|
||||
if !valid_rect(rect) {
|
||||
return Err(DockError::InvalidGeometry);
|
||||
}
|
||||
let root = match moving.node {
|
||||
Some(node) => node,
|
||||
None => stack_tree(moving.groups).ok_or(DockError::MissingPanel)?,
|
||||
};
|
||||
let window = Floating { native, minimized: moving.minimized, ..Floating::new(root, rect) };
|
||||
self.floating.push(Floating { id: moving.id.unwrap_or(window.id), ..window });
|
||||
Ok(())
|
||||
}
|
||||
Target::NewDock { side } => {
|
||||
if let Some(path) = self.dock_path(side) {
|
||||
let columns = self.columns_mut(&path).ok_or(DockError::MissingTarget)?;
|
||||
if !columns.is_empty() {
|
||||
return Err(DockError::InvalidPlacement);
|
||||
}
|
||||
columns.push(moving.into_column(DEFAULT_COLUMN_WIDTH));
|
||||
return Ok(());
|
||||
}
|
||||
let column = moving.into_column(DEFAULT_COLUMN_WIDTH);
|
||||
self.add_dock(side, column);
|
||||
Ok(())
|
||||
}
|
||||
Target::At { anchor, placement } => {
|
||||
let place = self.place(&anchor).ok_or(DockError::MissingTarget)?;
|
||||
match placement {
|
||||
Placement::Tab { before } => {
|
||||
let index = match &before {
|
||||
Some(before) => {
|
||||
if self.place(before).as_ref() != Some(&place) {
|
||||
return Err(DockError::MissingTarget);
|
||||
}
|
||||
self.index_in(&place, before)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
self.merge(&place, index, moving)
|
||||
}
|
||||
Placement::Split(Zone::Center) => {
|
||||
let index = self.index_in(&place, &anchor).map(|i| i + 1);
|
||||
self.merge(&place, index, moving)
|
||||
}
|
||||
Placement::Split(zone) => {
|
||||
let (window, path) = place.tree_path().ok_or(DockError::InvalidPlacement)?;
|
||||
let new = match moving.node {
|
||||
Some(node) => node,
|
||||
None => {
|
||||
let (panels, active) = flatten(moving.groups);
|
||||
Node::Tabs { panels, active }
|
||||
}
|
||||
};
|
||||
let root = self.tree_mut(window).ok_or(DockError::MissingTarget)?;
|
||||
if split_frame(root, path, new, zone) { Ok(()) } else { Err(DockError::MissingTarget) }
|
||||
}
|
||||
// A floating window is a tree: a group above or below splits the anchor's group.
|
||||
Placement::Group { below, .. } if matches!(place, Place::Floating { .. }) => {
|
||||
let zone = if below { Zone::Bottom } else { Zone::Top };
|
||||
self.insert_moving(moving, Target::At { anchor, placement: Placement::Split(zone) })
|
||||
}
|
||||
Placement::Group { below, height } => {
|
||||
if !valid_height(height) {
|
||||
return Err(DockError::InvalidGeometry);
|
||||
}
|
||||
let mut moving = moving;
|
||||
if height.is_some() {
|
||||
moving.groups.iter_mut().for_each(|group| group.height = height);
|
||||
}
|
||||
let (groups, index) = match &place {
|
||||
Place::Dock { group, .. } => (&mut self.column_mut(&place).ok_or(DockError::MissingTarget)?.groups, *group),
|
||||
Place::Node(_) | Place::Floating { .. } => return Err(DockError::InvalidPlacement),
|
||||
};
|
||||
let index = (index + usize::from(below)).min(groups.len());
|
||||
groups.splice(index..index, moving.groups);
|
||||
Ok(())
|
||||
}
|
||||
Placement::Edge(zone) => {
|
||||
if zone == Zone::Center || matches!(place, Place::Floating { .. }) {
|
||||
return Err(DockError::InvalidPlacement);
|
||||
}
|
||||
let path = self.edge_path();
|
||||
let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?;
|
||||
if matches!(node, Node::Dock { .. }) {
|
||||
return Err(DockError::InvalidPlacement);
|
||||
}
|
||||
let (panels, active) = flatten(moving.groups);
|
||||
let new = Node::Tabs { panels, active };
|
||||
let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 });
|
||||
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
|
||||
let (first, second, size) = if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) };
|
||||
*node = Node::Split { axis, size: SplitSize::Ratio(size), first: Box::new(first), second: Box::new(second) };
|
||||
Ok(())
|
||||
}
|
||||
Placement::Column { after } => {
|
||||
let Place::Dock { path, column, .. } = &place else { return Err(DockError::InvalidPlacement) };
|
||||
let columns = self.columns_mut(path).ok_or(DockError::MissingTarget)?;
|
||||
let width = columns.get(*column).map_or(DEFAULT_COLUMN_WIDTH, |c| c.width);
|
||||
let index = (column + usize::from(after)).min(columns.len());
|
||||
columns.insert(index, moving.into_column(width));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap the root in a horizontal split with a new dock on `side`. The split's fixed size is
|
||||
/// the column width.
|
||||
pub(super) fn add_dock(&mut self, side: Side, column: Column<P>) {
|
||||
let width = column.width;
|
||||
let dock = Node::Dock { side, columns: vec![column] };
|
||||
self.root = Some(match self.root.take() {
|
||||
None => dock,
|
||||
Some(root) => {
|
||||
let (first, second, size) = match side {
|
||||
Side::Start => (dock, root, SplitSize::FixedFirst(width)),
|
||||
Side::End => (root, dock, SplitSize::FixedSecond(width)),
|
||||
};
|
||||
Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn index_in(&self, place: &Place, panel: &P) -> Option<usize> {
|
||||
match place {
|
||||
Place::Node(_) | Place::Floating { .. } => match self.node_of(place)? {
|
||||
Node::Tabs { panels, .. } => panels.iter().position(|p| p == panel),
|
||||
Node::Stack { entries } => entries.iter().position(|e| &e.panel == panel),
|
||||
_ => None,
|
||||
},
|
||||
_ => self.group(place)?.panels.iter().position(|p| p == panel),
|
||||
}
|
||||
}
|
||||
|
||||
/// Join every moving panel to the group at `place`, before `index` or at the end. The first
|
||||
/// moving group's active panel becomes active.
|
||||
fn merge(&mut self, place: &Place, index: Option<usize>, moving: Moving<P>) -> Result<(), DockError> {
|
||||
let (panels, selected) = flatten(moving.groups);
|
||||
match place {
|
||||
Place::Node(_) | Place::Floating { .. } => match self.node_of_mut(place) {
|
||||
Some(Node::Tabs { panels: target, active }) => {
|
||||
splice(target, active, index.unwrap_or(target.len()), panels, selected);
|
||||
Ok(())
|
||||
}
|
||||
Some(Node::Stack { entries }) => {
|
||||
let index = index.unwrap_or(entries.len()).min(entries.len());
|
||||
entries.splice(index..index, panels.into_iter().map(|panel| super::StackEntry { panel, open: true, height: None }));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DockError::MissingTarget),
|
||||
},
|
||||
_ => {
|
||||
let group = self.group_mut(place).ok_or(DockError::MissingTarget)?;
|
||||
let end = group.panels.len();
|
||||
splice(&mut group.panels, &mut group.active, index.unwrap_or(end), panels, selected);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_holds<P: PartialEq>(leaf: &Node<P>, panel: &P) -> bool {
|
||||
match leaf {
|
||||
Node::Tabs { panels, .. } => panels.contains(panel),
|
||||
Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel),
|
||||
Node::Split { .. } | Node::Dock { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// All panels in order, and the index of the first group's active panel among them.
|
||||
fn flatten<P>(groups: Vec<Group<P>>) -> (Vec<P>, usize) {
|
||||
let selected = groups.first().map_or(0, |g| g.active.min(g.panels.len().saturating_sub(1)));
|
||||
(groups.into_iter().flat_map(|g| g.panels).collect(), selected)
|
||||
}
|
||||
|
||||
pub(super) fn valid_column<P>(column: &Column<P>) -> bool {
|
||||
valid_width(column.width) && column.iconic_width.is_none_or(valid_width)
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
//! [D] Docks: columns, stacked groups, seams, column bars and their drops.
|
||||
|
||||
use super::*;
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
use egui::{Context, Event, Id, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, pos2, vec2};
|
||||
|
||||
const AREA: &str = "dock-area";
|
||||
|
||||
fn group(panels: &[u32]) -> Group<u32> {
|
||||
Group::new(panels.to_vec(), 0)
|
||||
}
|
||||
|
||||
/// A dock on `side` with `columns` (each a list of groups, 240 wide) beside canvas 0.
|
||||
fn docked(side: Side, columns: &[&[&[u32]]]) -> Layout<u32> {
|
||||
let dock = Node::Dock { side, columns: columns.iter().map(|groups| Column::new(groups.iter().map(|g| group(g)).collect(), 240.0)).collect() };
|
||||
let canvas = Node::Tabs { panels: vec![0], active: 0 };
|
||||
let (first, second, size) = match side {
|
||||
Side::Start => (dock, canvas, SplitSize::FixedFirst(240.0)),
|
||||
Side::End => (canvas, dock, SplitSize::FixedSecond(240.0)),
|
||||
};
|
||||
Layout { root: Some(Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }), floating: Vec::new() }
|
||||
}
|
||||
|
||||
fn with_floating(mut layout: Layout<u32>, windows: &[(u32, [f32; 4])]) -> Layout<u32> {
|
||||
layout.floating.extend(windows.iter().map(|(panel, rect)| Floating::single(vec![*panel], 0, *rect)));
|
||||
layout
|
||||
}
|
||||
|
||||
fn columns(layout: &Layout<u32>, side: Side) -> Vec<Vec<Vec<u32>>> {
|
||||
fn find(node: &Node<u32>, side: Side) -> Option<Vec<Vec<Vec<u32>>>> {
|
||||
match node {
|
||||
Node::Dock { side: s, columns } if *s == side => {
|
||||
Some(columns.iter().map(|c| c.groups.iter().map(|g| g.panels.clone()).collect()).collect())
|
||||
}
|
||||
Node::Split { first, second, .. } => find(first, side).or_else(|| find(second, side)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
layout.root.as_ref().and_then(|root| find(root, side)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn column(layout: &Layout<u32>, index: usize) -> Column<u32> {
|
||||
fn find(node: &Node<u32>) -> Option<&Vec<Column<u32>>> {
|
||||
match node {
|
||||
Node::Dock { columns, .. } => Some(columns),
|
||||
Node::Split { first, second, .. } => find(first).or_else(|| find(second)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
layout.root.as_ref().and_then(find).and_then(|c| c.get(index)).cloned().unwrap()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestContent {
|
||||
min_height: f32,
|
||||
}
|
||||
|
||||
impl DockContent<u32> for TestContent {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
ui.label("Body");
|
||||
}
|
||||
fn permissions(&self, panel: &u32) -> Permissions {
|
||||
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
fn limits(&self, _: &u32) -> PanelLimits {
|
||||
PanelLimits { min: vec2(0.0, self.min_height), ..PanelLimits::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a layout in a 600x600 area of an 800x600 window and applies what it emits.
|
||||
struct Driver {
|
||||
ctx: Context,
|
||||
layout: Layout<u32>,
|
||||
content: TestContent,
|
||||
focused: bool,
|
||||
right_to_left: bool,
|
||||
shapes: Vec<egui::epaint::ClippedShape>,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new(layout: Layout<u32>) -> Self {
|
||||
Self::with(layout, TestContent::default(), false)
|
||||
}
|
||||
|
||||
fn with(layout: Layout<u32>, content: TestContent, right_to_left: bool) -> Self {
|
||||
Self::start(Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 })
|
||||
}
|
||||
|
||||
/// A window egui never heard has focus (macOS can make it key without a `Focused` event).
|
||||
fn never_focused(layout: Layout<u32>) -> Self {
|
||||
Self::start(Self {
|
||||
ctx: Context::default(),
|
||||
layout,
|
||||
content: TestContent::default(),
|
||||
focused: false,
|
||||
right_to_left: false,
|
||||
shapes: Vec::new(),
|
||||
time: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
fn start(mut driver: Self) -> Self {
|
||||
// Floating areas settle after their sizing pass.
|
||||
for _ in 0..3 {
|
||||
driver.step(vec![]);
|
||||
}
|
||||
driver
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) -> DockOutput<u32> {
|
||||
let mut output = DockOutput::default();
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
|
||||
events,
|
||||
focused: self.focused,
|
||||
time: Some(self.time),
|
||||
..Default::default()
|
||||
};
|
||||
let (layout, content, rtl) = (&self.layout, &mut self.content, self.right_to_left);
|
||||
let mut full = self.ctx.run_ui(input, |ui| {
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
style.right_to_left = rtl;
|
||||
ui.scope_builder(UiBuilder::new().max_rect(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 600.0))), |ui| {
|
||||
output = DockArea::new(Id::unique(AREA)).show_content(ui, layout, &style, content);
|
||||
});
|
||||
});
|
||||
full.textures_delta.clear();
|
||||
self.shapes = full.shapes;
|
||||
output
|
||||
}
|
||||
|
||||
fn step(&mut self, events: Vec<Event>) -> DockOutput<u32> {
|
||||
let output = self.frame(events);
|
||||
assert!(output.error.is_none(), "{output:?}");
|
||||
let permissions = |p: &u32| self.content.permissions(p);
|
||||
for action in &output.actions {
|
||||
self.layout.apply_with_permissions(action.clone(), permissions).unwrap();
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn rect(&self, name: &str, panel: u32) -> Rect {
|
||||
self.ctx.read_response(Id::unique(AREA).with((name, Some(&panel)))).unwrap().rect
|
||||
}
|
||||
|
||||
fn tab(&self, panel: u32) -> Rect {
|
||||
self.ctx.read_response(Id::unique(AREA).with(("tab", &panel))).unwrap().rect
|
||||
}
|
||||
|
||||
fn hold(&mut self, from: Pos2, to: Pos2) -> DockOutput<u32> {
|
||||
self.step(vec![Event::PointerMoved(from)]);
|
||||
self.step(vec![button(from, true)]);
|
||||
self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]);
|
||||
self.step(vec![Event::PointerMoved(to)])
|
||||
}
|
||||
|
||||
fn drag(&mut self, from: Pos2, to: Pos2) -> DockOutput<u32> {
|
||||
self.hold(from, to);
|
||||
self.step(vec![button(to, false)])
|
||||
}
|
||||
|
||||
fn click(&mut self, at: Pos2) -> DockOutput<u32> {
|
||||
self.step(vec![Event::PointerMoved(at), button(at, true)]);
|
||||
self.step(vec![button(at, false)])
|
||||
}
|
||||
|
||||
/// Two frames: egui reports a moved widget's rect a frame late.
|
||||
fn settle(&mut self) {
|
||||
self.step(vec![]);
|
||||
self.step(vec![]);
|
||||
}
|
||||
|
||||
fn wait(&mut self) {
|
||||
for _ in 0..45 {
|
||||
self.step(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last frame painted the filled insertion line `line`.
|
||||
fn painted(&self, line: Rect) -> bool {
|
||||
self.shapes.iter().any(|clipped| match &clipped.shape {
|
||||
egui::Shape::Rect(shape) => shape.rect == line && shape.fill != egui::Color32::TRANSPARENT,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn key(key: egui::Key) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn moved(actions: &[Action<u32>]) -> Option<(u32, &Target<u32>)> {
|
||||
actions.iter().find_map(|a| match a {
|
||||
Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } => Some((*panel, to)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn hline(y: f32, x: std::ops::RangeInclusive<f32>) -> Rect {
|
||||
Rect::from_x_y_ranges(x, (y - 2.0)..=(y + 2.0))
|
||||
}
|
||||
|
||||
fn vline(x: f32, y: std::ops::RangeInclusive<f32>) -> Rect {
|
||||
Rect::from_x_y_ranges((x - 2.0)..=(x + 2.0), y)
|
||||
}
|
||||
|
||||
/// A floating group's drag bar.
|
||||
fn bar(d: &Driver, panel: u32) -> Pos2 {
|
||||
d.rect("drag-bar", panel).center()
|
||||
}
|
||||
|
||||
// Geometry of `docked(Start, ..)`: the dock is 0..240 wide, its column bar 14 high, and two open
|
||||
// groups share 14..600, meeting at y = 307.
|
||||
|
||||
#[test]
|
||||
fn group_dropped_on_a_seam_inserts_between_and_shows_a_horizontal_line() {
|
||||
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]);
|
||||
let mut d = Driver::new(layout);
|
||||
assert_eq!(d.tab(3).top(), 307.0);
|
||||
let seam = pos2(120.0, 309.0);
|
||||
d.hold(bar(&d, 5), seam);
|
||||
assert!(d.painted(hline(307.0, 0.0..=240.0)), "a horizontal line across the column at the seam");
|
||||
let out = d.step(vec![button(seam, false)]);
|
||||
assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } })));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![3]]]);
|
||||
assert!(d.layout.floating.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_dropped_on_a_seam_or_column_edge_moves_only_that_panel() {
|
||||
let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]]));
|
||||
let out = d.drag(d.tab(3).center(), pos2(120.0, 309.0));
|
||||
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 2, placement: Placement::Group { below: true, height: None } }]);
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3], vec![4]]]);
|
||||
d.settle();
|
||||
d.drag(d.tab(4).center(), pos2(246.0, 200.0));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]], vec![vec![4]]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_dropped_on_a_top_or_bottom_band_stacks_above_or_below() {
|
||||
for (at, expected) in [
|
||||
// The top quarter of group 2's body, and the bottom quarter of group 3's.
|
||||
(pos2(120.0, 14.0 + 26.0 + 20.0), vec![vec![5], vec![2], vec![3]]),
|
||||
(pos2(120.0, 590.0), vec![vec![2], vec![3], vec![5]]),
|
||||
// The middle of a body joins as tabs.
|
||||
(pos2(120.0, 450.0), vec![vec![2], vec![3, 5]]),
|
||||
] {
|
||||
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]);
|
||||
let mut d = Driver::new(layout);
|
||||
d.drag(bar(&d, 5), at);
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![expected], "{at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_dropped_on_a_column_edge_makes_a_new_column() {
|
||||
// The dock's inner edge: a column after the existing one, previewed full height.
|
||||
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]);
|
||||
let mut d = Driver::new(layout.clone());
|
||||
let edge = pos2(246.0, 200.0);
|
||||
d.hold(bar(&d, 5), edge);
|
||||
assert!(d.painted(vline(240.0, 0.0..=600.0)), "a vertical line the full height of the dock");
|
||||
let out = d.step(vec![button(edge, false)]);
|
||||
assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Column { after: true } })));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]], vec![vec![5]]]);
|
||||
assert_eq!(column(&d.layout, 1).width, 150.0, "the new column keeps the window's width");
|
||||
|
||||
// The window edge of the outermost column: a column before it.
|
||||
let mut d = Driver::new(layout);
|
||||
d.drag(bar(&d, 5), pos2(3.0, 200.0));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]], vec![vec![2], vec![3]]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_dropped_on_the_window_edge_without_a_dock_makes_a_new_dock() {
|
||||
for (side, at, new) in [(Side::Start, pos2(595.0, 200.0), Side::End), (Side::End, pos2(4.0, 200.0), Side::Start)] {
|
||||
let layout = with_floating(docked(side, &[&[&[2]]]), &[(5, [300.0, 300.0, 150.0, 150.0])]);
|
||||
let mut d = Driver::new(layout);
|
||||
d.hold(bar(&d, 5), at);
|
||||
let x = if new == Side::End { 598.0 } else { 2.0 };
|
||||
assert!(d.painted(vline(x, 0.0..=600.0)), "{new:?}: a vertical line at the window edge");
|
||||
let out = d.step(vec![button(at, false)]);
|
||||
assert_eq!(moved(&out.actions), Some((5, &Target::NewDock { side: new })));
|
||||
assert_eq!(columns(&d.layout, new), vec![vec![vec![5]]]);
|
||||
assert_eq!(columns(&d.layout, side), vec![vec![vec![2]]]);
|
||||
// The new dock is drawn on its edge, its »/« button at the window edge.
|
||||
d.settle();
|
||||
let button = d.rect("column-collapse", 5);
|
||||
assert!(if new == Side::End { button.right() == 600.0 } else { button.left() == 0.0 }, "{button:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_dragged_to_the_window_edge_without_a_dock_makes_a_dock_of_that_panel() {
|
||||
let mut d = testing::DockDriver::new(docked(Side::Start, &[&[&[2, 3]]]), TestContent::default());
|
||||
let tab = d.hit(&Hit::Tab(3)).unwrap_or_else(|| panic!("{:?}", d.hits()));
|
||||
let out = d.drag(tab.center(), pos2(795.0, 300.0));
|
||||
assert!(out.actions.contains(&Action::MovePanel { panel: 3, to: Target::NewDock { side: Side::End } }), "{:?}", out.actions);
|
||||
assert_eq!(columns(&d.layout, Side::End), vec![vec![vec![3]]]);
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2]]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_takes_groups_anywhere_in_it_and_as_tabs() {
|
||||
let windows = [(5, [400.0, 40.0, 150.0, 120.0]), (6, [400.0, 220.0, 150.0, 120.0]), (7, [400.0, 400.0, 150.0, 120.0])];
|
||||
let mut d = Driver::new(with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &windows));
|
||||
// Onto group 3's tab strip: joins as a tab.
|
||||
d.drag(bar(&d, 5), pos2(d.tab(3).right() + 20.0, d.tab(3).center().y));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3, 5]]]);
|
||||
// Onto the middle of group 2's body: joins as the last tab.
|
||||
d.drag(bar(&d, 6), pos2(120.0, 160.0));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2, 6], vec![3, 5]]]);
|
||||
// Into the room under the groups, every group minimized: below the last group.
|
||||
d.step(vec![Event::PointerMoved(pos2(700.0, 10.0))]);
|
||||
let mut minimized = d.layout.clone();
|
||||
for panel in [2, 3] {
|
||||
minimized.apply(Action::SetMinimized { panel, minimized: true }).unwrap();
|
||||
}
|
||||
d.layout = minimized;
|
||||
d.step(vec![]);
|
||||
d.drag(bar(&d, 7), pos2(120.0, 500.0));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2, 6], vec![3, 5], vec![7]]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_too_tall_for_the_window_scrolls() {
|
||||
let groups: [&[u32]; 5] = [&[2], &[3], &[4], &[5], &[6]];
|
||||
let mut d = Driver::with(docked(Side::Start, &[&groups]), TestContent { min_height: 160.0 }, false);
|
||||
let last = d.tab(6);
|
||||
assert!(last.top() > 600.0, "the last group starts below the window: {last:?}");
|
||||
let over = d.tab(2).center();
|
||||
for _ in 0..6 {
|
||||
d.step(vec![
|
||||
Event::PointerMoved(over),
|
||||
Event::MouseWheel {
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, -200.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Modifiers::NONE,
|
||||
},
|
||||
]);
|
||||
}
|
||||
d.wait();
|
||||
let moved = d.tab(6);
|
||||
assert!(moved.top() < last.top() - 100.0, "scrolled up: {moved:?} from {last:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_bar_drag_moves_the_column_and_away_from_a_dock_floats_its_groups() {
|
||||
let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3]], &[&[5]]]));
|
||||
// The second column to the window edge: it becomes the first.
|
||||
let out = d.drag(d.rect("column-bar", 5).center(), pos2(3.0, 200.0));
|
||||
assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Column { after: false } })));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]], vec![vec![2], vec![3]]]);
|
||||
d.settle();
|
||||
// Away from the dock: a floating window holding the column's groups.
|
||||
let source = Rect::from_min_max(pos2(240.0, 0.0), pos2(480.0, 600.0));
|
||||
assert_eq!(d.rect("column-bar", 2).left(), 240.0 + 18.0);
|
||||
let grab = d.rect("column-bar", 2).center();
|
||||
d.hold(grab, pos2(560.0, 300.0));
|
||||
// The column's width; a docked column floats at most 320 pt tall.
|
||||
let size = vec2(source.width(), 320.0);
|
||||
let ghost = egui::Rect::from_min_size(pos2(560.0, 300.0) - (grab - source.min), size);
|
||||
let ghost = egui::Rect::from_min_size(ghost.min.min(pos2(800.0, 600.0) - size).max(Pos2::ZERO), size);
|
||||
assert!(
|
||||
d.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == ghost && r.fill != egui::Color32::TRANSPARENT)),
|
||||
"a ghost of the column follows the pointer"
|
||||
);
|
||||
let out = d.step(vec![button(pos2(560.0, 300.0), false)]);
|
||||
assert!(matches!(moved(&out.actions), Some((2, Target::Float { .. }))), "{:?}", out.actions);
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]]]);
|
||||
assert_eq!(d.layout.floating[0].groups(), vec![group(&[2]), group(&[3])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() {
|
||||
let layout = docked(Side::Start, &[&[&[2], &[3]], &[&[5]]]);
|
||||
for case in ["escape", "focus", "exit", "cancel"] {
|
||||
let mut d = Driver::new(layout.clone());
|
||||
let target = pos2(3.0, 200.0);
|
||||
let held = d.hold(d.rect("column-bar", 5).center(), target);
|
||||
assert_eq!(held.group_drag, Some(5), "{case}");
|
||||
let out = match case {
|
||||
"escape" => d.step(vec![key(egui::Key::Escape)]),
|
||||
"focus" => {
|
||||
d.focused = false;
|
||||
let out = d.step(vec![]);
|
||||
d.focused = true;
|
||||
out
|
||||
}
|
||||
"exit" => d.step(vec![Event::PointerGone]),
|
||||
_ => {
|
||||
cancel_drag::<u32>(&d.ctx, Id::unique(AREA));
|
||||
d.step(vec![Event::PointerMoved(target + vec2(1.0, 0.0))])
|
||||
}
|
||||
};
|
||||
assert!(out.actions.is_empty(), "{case}: {out:?}");
|
||||
let out = d.step(vec![Event::PointerMoved(target), button(target, false)]);
|
||||
assert!(out.actions.is_empty() && out.group_drag.is_none(), "{case}: {out:?}");
|
||||
assert_eq!(d.layout, layout, "{case}");
|
||||
assert!(!is_dragging::<u32>(&d.ctx, Id::unique(AREA)), "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_and_group_dividers_resize_and_minimized_groups_keep_their_height() {
|
||||
let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[4]]]));
|
||||
// The dock's inner edge widens its column; the canvas gives way.
|
||||
d.drag(pos2(240.0, 300.0), pos2(280.0, 300.0));
|
||||
assert_eq!(column(&d.layout, 0).width, 280.0);
|
||||
d.settle();
|
||||
assert_eq!(d.rect("column-bar", 2).right(), 280.0);
|
||||
// The divider between the groups saves both heights.
|
||||
d.wait();
|
||||
d.drag(pos2(120.0, 307.0), pos2(120.0, 357.0));
|
||||
let groups = column(&d.layout, 0).groups;
|
||||
assert_eq!((groups[0].height, groups[1].height), (Some(343.0), Some(243.0)));
|
||||
d.settle();
|
||||
assert_eq!(d.tab(4).top(), 357.0);
|
||||
// Minimized, the lower group keeps its saved height and the upper one takes the room.
|
||||
d.layout.apply(Action::SetMinimized { panel: 4, minimized: true }).unwrap();
|
||||
d.settle();
|
||||
assert_eq!(column(&d.layout, 0).groups[1].height, Some(243.0));
|
||||
assert_eq!(d.tab(4).top(), 600.0 - 26.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_divider_dragged_past_the_lower_groups_minimum_shrinks_the_next_group() {
|
||||
let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3], &[4]]]));
|
||||
let divider = d.rect("group-resize", 2).center();
|
||||
d.drag(divider, divider + vec2(0.0, 500.0));
|
||||
// Groups 3 and 4 are at their minimum: a tab strip and a 40 pt body.
|
||||
let heights = column(&d.layout, 0).groups.iter().map(|g| g.height).collect::<Vec<_>>();
|
||||
assert_eq!(heights[1..], [Some(66.0), Some(66.0)], "{heights:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_a_docked_tab_minimizes_and_clicking_another_tab_expands() {
|
||||
let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]]));
|
||||
let minimized = |d: &Driver| column(&d.layout, 0).groups[0].minimized;
|
||||
let tab = d.tab(2).center();
|
||||
d.click(tab);
|
||||
d.click(tab);
|
||||
assert!(minimized(&d));
|
||||
d.settle();
|
||||
assert_eq!(d.tab(4).top(), 14.0 + 26.0, "the minimized group takes its strip");
|
||||
d.wait();
|
||||
d.click(d.tab(3).center());
|
||||
assert!(!minimized(&d));
|
||||
assert_eq!(column(&d.layout, 0).groups[0].active_panel(), Some(&3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_button_and_column_bar_double_click_emit_set_iconic() {
|
||||
let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3]]]));
|
||||
let iconic = |d: &Driver| column(&d.layout, 0).iconic;
|
||||
let button = d.rect("column-collapse", 2).center();
|
||||
let out = d.click(button);
|
||||
assert_eq!(out.actions, vec![Action::SetIconic { panel: 2, iconic: true }]);
|
||||
assert!(iconic(&d));
|
||||
d.wait();
|
||||
d.click(d.rect("column-collapse", 2).center());
|
||||
assert!(!iconic(&d));
|
||||
d.wait();
|
||||
let bar = d.rect("column-bar", 2).center();
|
||||
d.click(bar);
|
||||
d.click(bar);
|
||||
assert!(iconic(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_targets_respect_occlusion_by_floating_windows() {
|
||||
// Window 5 covers the column's seam at x 150..240; window 6 is dragged.
|
||||
let windows = [(5, [150.0, 250.0, 200.0, 150.0]), (6, [400.0, 450.0, 150.0, 120.0])];
|
||||
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &windows);
|
||||
let mut d = Driver::new(layout.clone());
|
||||
let out = d.drag(bar(&d, 6), pos2(200.0, 309.0));
|
||||
assert!(matches!(moved(&out.actions), Some((6, Target::At { anchor: 5, .. }))), "joins the window in front: {:?}", out.actions);
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]]]);
|
||||
// Left of the window, the seam takes it.
|
||||
let mut d = Driver::new(layout);
|
||||
d.drag(bar(&d, 6), pos2(100.0, 309.0));
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![6], vec![3]]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_mirrors_docks_their_edges_and_new_docks() {
|
||||
let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [100.0, 300.0, 150.0, 150.0])]);
|
||||
let mut d = Driver::with(layout.clone(), TestContent::default(), true);
|
||||
// The start dock is on the right, its »/« button at the window edge.
|
||||
let column_bar = d.rect("column-bar", 2);
|
||||
assert_eq!((column_bar.left(), column_bar.right()), (360.0, 600.0 - 18.0));
|
||||
// Its inner edge is on the left: dragging it left widens the column.
|
||||
d.drag(pos2(360.0, 300.0), pos2(320.0, 300.0));
|
||||
assert_eq!(column(&d.layout, 0).width, 280.0);
|
||||
// The left window edge has no dock: a new end dock.
|
||||
d.wait();
|
||||
let out = d.drag(bar(&d, 5), pos2(4.0, 200.0));
|
||||
assert_eq!(moved(&out.actions), Some((5, &Target::NewDock { side: Side::End })));
|
||||
d.settle();
|
||||
assert_eq!(d.rect("column-collapse", 5).left(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_s_first_tab_dropped_on_its_own_column_edge_anchors_on_another_panel() {
|
||||
// Two columns, 0..240 and 240..480; the dragged tab is the first panel of the last one.
|
||||
let wide = |columns: &[&[&[u32]]]| {
|
||||
let mut layout = docked(Side::Start, columns);
|
||||
if let Some(Node::Split { size, .. }) = &mut layout.root {
|
||||
*size = SplitSize::FixedFirst(480.0);
|
||||
}
|
||||
layout
|
||||
};
|
||||
let mut d = Driver::new(wide(&[&[&[2]], &[&[3, 4]]]));
|
||||
let out = d.drag(d.tab(3).center(), pos2(484.0, 300.0));
|
||||
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 4, placement: Placement::Column { after: true } }]);
|
||||
assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2]], vec![vec![4]], vec![vec![3]]]);
|
||||
|
||||
// Alone in its column, the panel stays where it is.
|
||||
let layout = wide(&[&[&[2]], &[&[3]]]);
|
||||
let mut d = Driver::new(layout.clone());
|
||||
let out = d.drag(d.tab(3).center(), pos2(484.0, 300.0));
|
||||
assert_eq!(out.actions, vec![]);
|
||||
assert_eq!(d.layout, layout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_that_never_reported_focus_drags_tabs_and_losing_focus_cancels_a_drag() {
|
||||
let mut layout = docked(Side::Start, &[&[&[2]], &[&[3, 4]]]);
|
||||
if let Some(Node::Split { size, .. }) = &mut layout.root {
|
||||
*size = SplitSize::FixedFirst(480.0);
|
||||
}
|
||||
let to = pos2(484.0, 300.0);
|
||||
let mut d = Driver::never_focused(layout.clone());
|
||||
let out = d.drag(d.tab(3).center(), to);
|
||||
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 4, placement: Placement::Column { after: true } }]);
|
||||
|
||||
// Focus that arrives during the drag and goes again ends it.
|
||||
let mut d = Driver::never_focused(layout.clone());
|
||||
d.hold(d.tab(3).center(), to);
|
||||
d.focused = true;
|
||||
d.step(vec![Event::WindowFocused(true)]);
|
||||
assert!(is_dragging::<u32>(&d.ctx, Id::unique(AREA)));
|
||||
d.focused = false;
|
||||
d.step(vec![Event::WindowFocused(false)]);
|
||||
let out = d.step(vec![button(to, false)]);
|
||||
assert!(out.actions.is_empty(), "{out:?}");
|
||||
assert_eq!(d.layout, layout);
|
||||
assert!(!is_dragging::<u32>(&d.ctx, Id::unique(AREA)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dock_wider_than_the_window_shrinks_to_fit_and_resizes() {
|
||||
let widths = render::fit_widths(&[(600.0, Some(40.0)), (600.0, Some(40.0)), (48.0, None)], 800.0);
|
||||
assert!((widths.iter().sum::<f32>() - 800.0).abs() < 0.01 && widths[2] == 48.0, "{widths:?}");
|
||||
assert_eq!(render::fit_widths(&[(600.0, Some(300.0)), (600.0, Some(300.0))], 400.0), vec![300.0, 300.0]);
|
||||
|
||||
// Three 600-wide columns saved on a larger screen, in an 800-wide window.
|
||||
let mut layout = docked(Side::Start, &[&[&[1]], &[&[2]], &[&[3]]]);
|
||||
if let Some(Node::Split { first, .. }) = &mut layout.root
|
||||
&& let Node::Dock { columns, .. } = &mut **first
|
||||
{
|
||||
for column in columns {
|
||||
column.width = 600.0;
|
||||
}
|
||||
}
|
||||
let mut d = testing::DockDriver::new(layout, TestContent::default());
|
||||
for panel in [1, 2, 3] {
|
||||
let divider = d.hit(&Hit::Divider(Divider::Column(panel))).unwrap();
|
||||
assert!(d.hit(&Hit::Tab(panel)).is_some_and(|tab| tab.width() > 0.0), "{panel}");
|
||||
assert!(divider.right() < 800.0, "{panel}: {divider:?}");
|
||||
}
|
||||
let divider = d.hit(&Hit::Divider(Divider::Column(1))).unwrap().center();
|
||||
d.press(divider);
|
||||
d.move_to(divider + vec2(-8.0, 0.0));
|
||||
let out = d.move_to(divider + vec2(-30.0, 0.0));
|
||||
assert!(out.error.is_none() && !out.actions.is_empty(), "{out:?}");
|
||||
for action in &out.actions {
|
||||
assert!(matches!(action, Action::ResizeColumn { width, .. } if *width > 0.0), "{action:?}");
|
||||
}
|
||||
d.release(divider + vec2(-30.0, 0.0));
|
||||
let total: f32 = (0..3).map(|i| column(&d.layout, i).width).sum();
|
||||
assert!(total < 800.0, "{total}");
|
||||
assert!((d.hit(&Hit::Divider(Divider::Column(1))).unwrap().center().x - (divider.x - 30.0)).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_reader_values_follow_the_column_width_on_either_edge() {
|
||||
use egui::accesskit::{Action as A, ActionData, ActionRequest, TreeId};
|
||||
for (side, right_to_left) in [(Side::End, false), (Side::Start, true), (Side::Start, false)] {
|
||||
let mut d = testing::DockDriver::new(docked(side, &[&[&[2]]]), TestContent::default()).with_style(move |s| s.right_to_left = right_to_left);
|
||||
d.ctx.enable_accesskit();
|
||||
d.settle();
|
||||
let id = DockArea::<u32>::hit_id(d.id, &Hit::Divider(Divider::Column(2)));
|
||||
let request =
|
||||
|action, data| Event::AccessKitActionRequest(ActionRequest { action, target_tree: TreeId::ROOT, target_node: id.accesskit_id(), data });
|
||||
let case = format!("{side:?}, right to left {right_to_left}");
|
||||
let out = d.frame(vec![request(A::SetValue, Some(ActionData::NumericValue(300.0)))]);
|
||||
assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width: 300.0 }], "{case}");
|
||||
let out = d.frame(vec![request(A::Increment, None)]);
|
||||
assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width: 248.0 }], "{case}");
|
||||
// Arrow keys stay physical: right moves the divider right.
|
||||
d.ctx.memory_mut(|m| m.request_focus(id));
|
||||
d.frame(vec![]);
|
||||
let out = d.frame(vec![key(egui::Key::ArrowRight)]);
|
||||
let wider = d.hit(&Hit::Divider(Divider::Column(2))).unwrap().center().x < 400.0;
|
||||
let width = if wider { 248.0 } else { 232.0 };
|
||||
assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width }], "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unanchored_open_prefers_the_end_dock_and_an_edge_frame_takes_a_quarter() {
|
||||
let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group(&[1])], 240.0)] };
|
||||
let end = docked(Side::End, &[&[&[2]]]).root.unwrap();
|
||||
let mut layout = Layout {
|
||||
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(start), second: Box::new(end) }),
|
||||
floating: Vec::new(),
|
||||
};
|
||||
layout.apply(Action::Open { panel: 9, anchor: None }).unwrap();
|
||||
assert_eq!(columns(&layout, Side::End), vec![vec![vec![2, 9]]]);
|
||||
assert_eq!(columns(&layout, Side::Start), vec![vec![vec![1]]]);
|
||||
// A minimized group and an iconic column are passed over: the panel would not show there.
|
||||
let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group(&[1])], 240.0)] };
|
||||
let mut end = docked(Side::End, &[&[&[2]]]).root.unwrap();
|
||||
if let Node::Split { second, .. } = &mut end
|
||||
&& let Node::Dock { columns, .. } = second.as_mut()
|
||||
{
|
||||
columns[0].groups[0].minimized = true;
|
||||
}
|
||||
let mut layout = Layout {
|
||||
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(start), second: Box::new(end) }),
|
||||
floating: Vec::new(),
|
||||
};
|
||||
let mut iconic = layout.clone();
|
||||
layout.apply(Action::Open { panel: 9, anchor: None }).unwrap();
|
||||
assert_eq!(columns(&layout, Side::Start), vec![vec![vec![1, 9]]]);
|
||||
if let Some(Node::Split { first, .. }) = &mut iconic.root
|
||||
&& let Node::Dock { columns, .. } = first.as_mut()
|
||||
{
|
||||
columns[0].iconic = true;
|
||||
}
|
||||
iconic.apply(Action::Open { panel: 9, anchor: None }).unwrap();
|
||||
assert!(matches!(iconic.place(&9), Some(dock_model::Place::Node(_))), "{iconic:?}");
|
||||
|
||||
// No dock: a frame at the right edge, a quarter of the width.
|
||||
let mut layout = Layout { root: Some(Node::Tabs { panels: vec![0], active: 0 }), floating: Vec::new() };
|
||||
layout.apply(Action::Open { panel: 9, anchor: None }).unwrap();
|
||||
let Some(Node::Split { axis: SplitAxis::Horizontal, size, second, .. }) = &layout.root else { panic!("{layout:?}") };
|
||||
assert_eq!(*size, SplitSize::Ratio(0.75));
|
||||
assert_eq!(**second, Node::Tabs { panels: vec![9], active: 0 });
|
||||
}
|
||||
|
||||
/// Panels 1-3 as a creative app's Color, Properties and Layers: Layers fills the column and asks
|
||||
/// for room for about ten rows.
|
||||
struct Creative;
|
||||
|
||||
impl DockContent<u32> for Creative {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
ui.label("Body");
|
||||
}
|
||||
fn permissions(&self, panel: &u32) -> Permissions {
|
||||
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
fn limits(&self, panel: &u32) -> PanelLimits {
|
||||
PanelLimits { min: vec2(0.0, if *panel == 3 { 120.0 } else { 60.0 }), ..PanelLimits::default() }
|
||||
}
|
||||
fn dock_heights(&self, panel: &u32) -> Option<DockHeights> {
|
||||
match panel {
|
||||
1 => Some(DockHeights::new(160.0).compact(100.0)),
|
||||
2 => Some(DockHeights::new(220.0).compact(130.0)),
|
||||
3 => Some(DockHeights::new(300.0).fill(480.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Color, Properties and Layers docked in a window `height` tall, with saved `heights`.
|
||||
fn sized_column(height: f32, heights: [Option<f32>; 3]) -> testing::DockDriver<u32, Creative> {
|
||||
let mut layout = docked(Side::Start, &[&[&[1], &[2], &[3]]]);
|
||||
if let Some(Node::Split { first, .. }) = &mut layout.root
|
||||
&& let Node::Dock { columns, .. } = first.as_mut()
|
||||
{
|
||||
columns[0].groups.iter_mut().zip(heights).for_each(|(g, h)| g.height = h);
|
||||
}
|
||||
let mut d = testing::DockDriver::new(layout, Creative);
|
||||
d.screen = Rect::from_min_size(Pos2::ZERO, vec2(1440.0, height));
|
||||
d.area = d.screen;
|
||||
d.settle();
|
||||
d
|
||||
}
|
||||
|
||||
/// Heights of the column's groups as drawn, from their tabs' tops.
|
||||
fn drawn_heights(d: &testing::DockDriver<u32, Creative>) -> [f32; 3] {
|
||||
let top = |p| d.hit(&Hit::Tab(p)).unwrap().top();
|
||||
[top(2) - top(1), top(3) - top(2), d.area.bottom() - top(3)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_default_column_gives_the_filler_its_fill_height() {
|
||||
let strip = DockStyle::from_visuals(&egui::Visuals::dark()).tab_height;
|
||||
// A 900 pt window: Properties, nearest Layers, gives way towards its compact height so
|
||||
// Layers gets the height it asks for; Color keeps its default.
|
||||
let d = sized_column(900.0, [None; 3]);
|
||||
let [color, properties, layers] = drawn_heights(&d);
|
||||
assert!((layers - (480.0 + strip)).abs() < 0.5, "{:?}", drawn_heights(&d));
|
||||
assert_eq!(color, 160.0 + strip, "{:?}", drawn_heights(&d));
|
||||
assert!(properties < 220.0 + strip && properties >= 130.0 + strip, "{:?}", drawn_heights(&d));
|
||||
// A tall window: both at their defaults, Layers takes the rest.
|
||||
let d = sized_column(1200.0, [None; 3]);
|
||||
let [color, properties, layers] = drawn_heights(&d);
|
||||
assert_eq!((color, properties), (160.0 + strip, 220.0 + strip));
|
||||
assert!(layers > 480.0 + strip);
|
||||
// Nothing is saved until the user resizes a group.
|
||||
assert!(column(&d.layout, 0).groups.iter().all(|g| g.height.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_groups_with_different_minimums_fit_an_exact_room() {
|
||||
let open = |min: f32, content: Option<DockHeights>| render::GroupSize { minimized: false, saved: None, min, content };
|
||||
let column = [open(50.0, Some(DockHeights::new(186.0))), open(86.0, None), open(146.0, None)];
|
||||
assert_eq!(render::group_heights(&column, 26.0, 418.0), vec![186.0, 86.0, 146.0]);
|
||||
assert_eq!(render::group_heights(&column, 26.0, 518.0), vec![186.0, 166.0, 166.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_asking_for_a_fill_fills_the_column_beside_groups_without_dock_heights() {
|
||||
let open = |min: f32, content: Option<DockHeights>| render::GroupSize { minimized: false, saved: None, min, content };
|
||||
let column = [open(86.0, Some(DockHeights::new(186.0))), open(86.0, None), open(146.0, Some(DockHeights::new(326.0).fill(506.0)))];
|
||||
assert_eq!(render::group_heights(&column, 26.0, 1000.0), vec![186.0, 86.0, 728.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_give_way_to_the_filler_but_user_heights_stay() {
|
||||
let open = |saved: Option<f32>, min: f32, content: Option<DockHeights>| render::GroupSize { minimized: false, saved, min, content };
|
||||
let color = Some(DockHeights::new(186.0).compact(126.0));
|
||||
let properties = Some(DockHeights::new(246.0).compact(156.0));
|
||||
let layers = Some(DockHeights::new(326.0).fill(506.0));
|
||||
let column = [open(None, 86.0, color), open(None, 86.0, properties), open(None, 146.0, layers)];
|
||||
// Tall: defaults, the filler takes the rest.
|
||||
assert_eq!(render::group_heights(&column, 26.0, 1200.0), vec![186.0, 246.0, 768.0]);
|
||||
// Short: the default nearest the filler gives way first; Color keeps its default.
|
||||
assert_eq!(render::group_heights(&column, 26.0, 863.0), vec![186.0, 171.0, 506.0]);
|
||||
// Shorter: Properties is at its compact height before Color gives way.
|
||||
assert_eq!(render::group_heights(&column, 26.0, 818.0), vec![156.0, 156.0, 506.0]);
|
||||
// Shorter than the compact heights and the fill: every group gives way towards its minimum.
|
||||
let hs = render::group_heights(&column, 26.0, 400.0);
|
||||
assert!(hs.iter().zip(&column).all(|(h, g)| *h >= g.min - 0.01), "{hs:?}");
|
||||
assert!((hs.iter().sum::<f32>() - 400.0).abs() < 0.01 && hs[0] < 126.0 && hs[1] < 156.0, "{hs:?}");
|
||||
// Heights the user set stay; only the defaults give way.
|
||||
let mine = [open(Some(200.0), 86.0, color), open(None, 86.0, properties), open(None, 146.0, layers)];
|
||||
assert_eq!(render::group_heights(&mine, 26.0, 838.0), vec![200.0, 156.0, 482.0]);
|
||||
// In a drawn column too: Layers gets less than it asks for rather than the user's heights.
|
||||
let d = sized_column(900.0, [Some(200.0), Some(340.0), None]);
|
||||
assert_eq!(drawn_heights(&d), [200.0, 340.0, 900.0 - 14.0 - 540.0]);
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
//! Floating windows (title bar, stacks, close), the split-tree settings (outer edge, Maximize,
|
||||
//! Ctrl/Cmd undock, frame menu items), hidden panels, native windows and the public chrome.
|
||||
|
||||
use super::*;
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, Vec2, pos2, vec2};
|
||||
|
||||
const AREA: &str = "floating-area";
|
||||
|
||||
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
|
||||
Node::Tabs { panels: panels.to_vec(), active }
|
||||
}
|
||||
|
||||
/// `[1] | [2, 3]` with panel 3 selected.
|
||||
fn frames() -> Layout<u32> {
|
||||
Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(300.0),
|
||||
first: Box::new(tabs(&[1], 0)),
|
||||
second: Box::new(tabs(&[2, 3], 1)),
|
||||
}),
|
||||
floating: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A start dock (`[2, 3]` above `[4]`) beside protected canvas 0; window W1 stacks `[5]` above
|
||||
/// `[6, 7]`; window W2 holds `[8]`.
|
||||
fn workspace() -> Layout<u32> {
|
||||
Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(240.0),
|
||||
first: Box::new(Node::Dock {
|
||||
side: Side::Start,
|
||||
columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)],
|
||||
}),
|
||||
second: Box::new(tabs(&[0], 0)),
|
||||
}),
|
||||
floating: vec![
|
||||
Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [300.0, 80.0, 240.0, 300.0]).unwrap(),
|
||||
Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0]),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Content {
|
||||
native: bool,
|
||||
keep: Vec<u32>,
|
||||
/// Canvas 0 accepts side splits, as an app that opens panels beside it does.
|
||||
beside: bool,
|
||||
drawn: Vec<u32>,
|
||||
}
|
||||
|
||||
impl DockContent<u32> for Content {
|
||||
fn body(&mut self, ui: &mut egui::Ui, panel: &u32) {
|
||||
self.drawn.push(*panel);
|
||||
ui.label("Body");
|
||||
}
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
format!("Panel {panel}")
|
||||
}
|
||||
fn permissions(&self, panel: &u32) -> Permissions {
|
||||
match *panel {
|
||||
0 if self.beside => Permissions { accepts_splits: true, ..Permissions::PROTECTED },
|
||||
0 => Permissions::PROTECTED,
|
||||
_ => Permissions::default(),
|
||||
}
|
||||
}
|
||||
fn allow_native(&self, _: &u32) -> bool {
|
||||
self.native
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Hide {
|
||||
None,
|
||||
All,
|
||||
Keep,
|
||||
}
|
||||
|
||||
/// The style of an editor whose layout is one split tree of tab groups, as FilmCraft sets it.
|
||||
pub(super) fn frame_style(style: &mut DockStyle) {
|
||||
style.side_edge_zones = true;
|
||||
style.new_dock_zones = false;
|
||||
style.close_others_item = true;
|
||||
style.maximize_item = true;
|
||||
style.command_press_floats = true;
|
||||
style.float_on_empty_drop = false;
|
||||
style.title_bar_docks = false;
|
||||
style.float_hint = true;
|
||||
style.wheel_selects_tabs = true;
|
||||
style.middle_click_closes_tabs = true;
|
||||
style.float_label = "Undock Panel".into();
|
||||
style.float_group_label = "Undock Panel Group".into();
|
||||
style.close_group_label = "Close Panel Group".into();
|
||||
}
|
||||
|
||||
struct Driver {
|
||||
ctx: Context,
|
||||
layout: Layout<u32>,
|
||||
content: Content,
|
||||
split_tree: bool,
|
||||
maximized: Option<u32>,
|
||||
hide: Hide,
|
||||
modifiers: Modifiers,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new(layout: Layout<u32>, split_tree: bool) -> Self {
|
||||
let mut driver = Self {
|
||||
ctx: Context::default(),
|
||||
layout,
|
||||
content: Content::default(),
|
||||
split_tree,
|
||||
maximized: None,
|
||||
hide: Hide::None,
|
||||
modifiers: Modifiers::NONE,
|
||||
time: 0.0,
|
||||
};
|
||||
driver.step(vec![]);
|
||||
driver.step(vec![]);
|
||||
driver
|
||||
}
|
||||
|
||||
fn frame(&mut self, mut events: Vec<Event>) -> DockOutput<u32> {
|
||||
let mut output = DockOutput::default();
|
||||
events.insert(0, Event::ModifiersChanged(self.modifiers));
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
|
||||
events,
|
||||
focused: true,
|
||||
time: Some(self.time),
|
||||
..Default::default()
|
||||
};
|
||||
let (layout, content, split_tree, maximized, hide) = (&self.layout, &mut self.content, self.split_tree, self.maximized, self.hide);
|
||||
let keep: Vec<u32> = content.keep.clone();
|
||||
let keep = move |p: &u32| keep.contains(p);
|
||||
let full = self.ctx.run_ui(input, |ui| {
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
if split_tree {
|
||||
frame_style(&mut style);
|
||||
}
|
||||
let hide = match hide {
|
||||
Hide::None => HidePanels::None,
|
||||
Hide::All => HidePanels::All,
|
||||
Hide::Keep => HidePanels::Except(&keep),
|
||||
};
|
||||
output =
|
||||
DockArea::new(Id::unique(AREA)).native_windows(true).maximized(maximized).hide_panels(hide).show_content(ui, layout, &style, content);
|
||||
});
|
||||
let mut full = full;
|
||||
full.textures_delta.clear();
|
||||
output
|
||||
}
|
||||
|
||||
fn step(&mut self, events: Vec<Event>) -> DockOutput<u32> {
|
||||
let output = self.frame(events);
|
||||
assert!(output.error.is_none(), "{output:?}");
|
||||
for action in &output.actions {
|
||||
self.layout.apply_with_permissions(action.clone(), |p| self.content.permissions(p)).unwrap();
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn response(&self, id: Id) -> Option<Rect> {
|
||||
self.ctx.read_response(id).map(|r| r.rect)
|
||||
}
|
||||
|
||||
fn title(&self, first: u32) -> Rect {
|
||||
self.response(Id::unique(AREA).with(("move-group", Some(&first)))).unwrap()
|
||||
}
|
||||
|
||||
fn tab(&self, panel: u32) -> Option<Rect> {
|
||||
self.response(Id::unique(AREA).with(("tab", &panel)))
|
||||
}
|
||||
|
||||
fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
|
||||
self.step(vec![Event::PointerMoved(from), button(from, true)]);
|
||||
self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]);
|
||||
self.step(vec![Event::PointerMoved(to)])
|
||||
}
|
||||
|
||||
fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
|
||||
self.hold(from, to);
|
||||
self.step(vec![button(to, false)])
|
||||
}
|
||||
|
||||
fn click(&mut self, at: egui::Pos2) -> DockOutput<u32> {
|
||||
self.step(vec![Event::PointerMoved(at), button(at, true)]);
|
||||
self.step(vec![button(at, false)])
|
||||
}
|
||||
|
||||
fn wait(&mut self) {
|
||||
for _ in 0..45 {
|
||||
self.step(vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn done(mut full: egui::FullOutput) {
|
||||
full.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn button(pos: egui::Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn window_of(layout: &Layout<u32>, panel: u32) -> Option<&Floating<u32>> {
|
||||
layout.floating.iter().find(|w| w.contains(&panel))
|
||||
}
|
||||
|
||||
fn groups_of(window: &Floating<u32>) -> Vec<Vec<u32>> {
|
||||
window.groups().iter().map(|g| g.panels.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_groups() {
|
||||
// The window follows the pointer with every group; Escape puts it back.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let title = d.title(5).center();
|
||||
d.hold(title, title + vec2(40.0, 30.0));
|
||||
assert_eq!(d.layout.floating[0].rect, [340.0, 110.0, 240.0, 300.0]);
|
||||
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![5], vec![6, 7]]);
|
||||
d.step(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
d.step(vec![button(title + vec2(40.0, 30.0), false)]);
|
||||
assert_eq!(d.layout, workspace());
|
||||
|
||||
// Released over another window's lower band: its tree goes below that window's group,
|
||||
// unless that leaves a group without room; then its panels join that group.
|
||||
let out = d.drag(title, pos2(660.0, 480.0));
|
||||
assert!(
|
||||
out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Split(Zone::Center) } }),
|
||||
"{:?}",
|
||||
out.actions
|
||||
);
|
||||
assert_eq!(d.layout.floating.len(), 1);
|
||||
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8, 5, 6, 7]]);
|
||||
|
||||
// Released over a docked group's strip: every panel joins it as tabs.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let title = d.title(5).center();
|
||||
let strip = d.tab(4).unwrap().center();
|
||||
let out = d.drag(title, strip);
|
||||
assert!(out.actions.iter().any(|a| matches!(a, Action::MoveWindow { panel: 5, to: Target::At { anchor: 4, .. } })), "{:?}", out.actions);
|
||||
assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 7).is_none());
|
||||
assert_eq!(d.layout.place(&4), d.layout.place(&6));
|
||||
|
||||
// Released over the canvas: the window stays where it was moved and hosts get the release.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let title = d.title(5).center();
|
||||
let out = d.drag(title, pos2(500.0, 300.0));
|
||||
assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveWindow { .. })), "{:?}", out.actions);
|
||||
assert_eq!(out.group_drop, Some((5, pos2(500.0, 300.0))));
|
||||
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![6, 7]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_drag_onto_dock_targets_docks_the_window() {
|
||||
// Released on the seam between two docked groups: the window's group stacks between them.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let title = d.title(8).center();
|
||||
let seam = pos2(120.0, d.tab(4).unwrap().top());
|
||||
let out = d.drag(title, seam);
|
||||
assert!(
|
||||
out.actions
|
||||
.contains(&Action::MoveWindow { panel: 8, to: Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } } }),
|
||||
"{:?}",
|
||||
out.actions
|
||||
);
|
||||
assert!(window_of(&d.layout, 8).is_none());
|
||||
let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) };
|
||||
let Node::Dock { columns, .. } = first.as_ref() else { panic!("{first:?}") };
|
||||
let stacked: Vec<_> = columns[0].groups.iter().map(|g| g.panels.clone()).collect();
|
||||
assert_eq!(stacked, vec![vec![2, 3], vec![8], vec![4]]);
|
||||
|
||||
// Released on the new-dock band along the right edge: the window becomes an end dock.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let title = d.title(5).center();
|
||||
let out = d.drag(title, pos2(796.0, 300.0));
|
||||
assert!(out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::NewDock { side: Side::End } }), "{:?}", out.actions);
|
||||
assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 6).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floating_window_is_a_tree_whose_groups_split_and_resize_like_frames() {
|
||||
// A group dragged by its bar to another window's top band splits that window's group.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let bar = d.response(Id::unique(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center();
|
||||
let out = d.drag(bar, pos2(660.0, 360.0));
|
||||
assert!(
|
||||
out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Split(Zone::Top) } }),
|
||||
"{:?}",
|
||||
out.actions
|
||||
);
|
||||
assert_eq!(groups_of(window_of(&d.layout, 8).unwrap()), vec![vec![6, 7], vec![8]]);
|
||||
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]);
|
||||
|
||||
// The gap between two groups of a window is no target.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let tab = d.tab(8).unwrap().center();
|
||||
let gap = pos2(420.0, d.tab(6).unwrap().top() - 2.0);
|
||||
d.drag(tab, gap);
|
||||
assert_eq!(d.layout, workspace());
|
||||
|
||||
// The divider between them resizes both.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let divider = d.response(DockArea::hit_id(Id::unique(AREA), &Hit::Divider(Divider::WindowSplit(5u32, Vec::new())))).unwrap().center();
|
||||
d.drag(divider, divider + vec2(0.0, 40.0));
|
||||
let Node::Split { size: SplitSize::Ratio(ratio), .. } = window_of(&d.layout, 5).unwrap().root else { panic!("{:?}", d.layout) };
|
||||
assert!(ratio > 0.6, "{ratio}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() {
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let title = d.title(5).center();
|
||||
d.click(title);
|
||||
d.click(title);
|
||||
assert!(window_of(&d.layout, 5).unwrap().minimized);
|
||||
d.wait();
|
||||
d.click(title);
|
||||
d.click(title);
|
||||
assert!(!window_of(&d.layout, 5).unwrap().minimized);
|
||||
|
||||
let close = d.response(Id::unique(AREA).with(("move-group", Some(&5u32))).with("close")).unwrap();
|
||||
assert_eq!(close.center().x, 530.0, "× sits 10 pt in from the right edge");
|
||||
let before = d.layout.clone();
|
||||
d.wait();
|
||||
let out = d.click(close.center());
|
||||
assert_eq!(out.actions, vec![Action::CloseWindow { panel: 5 }]);
|
||||
assert_eq!(before.closed_by(&Action::CloseWindow { panel: 5 }), Ok(vec![5, 6, 7]));
|
||||
assert!(window_of(&d.layout, 6).is_none() && d.layout.contains(&8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_floating_window_reopens_each_panel_at_its_last_rect() {
|
||||
let mut layout = workspace();
|
||||
let locations: Vec<_> = [5, 6, 7].map(|p| (p, layout.location(&p).unwrap())).into();
|
||||
layout.apply(Action::CloseWindow { panel: 6 }).unwrap();
|
||||
for (panel, location) in locations {
|
||||
layout.restore(panel, &location).unwrap();
|
||||
assert_eq!(window_of(&layout, panel).unwrap().rect, [300.0, 80.0, 240.0, 300.0], "{panel}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() {
|
||||
let mut d = Driver::new(frames(), true);
|
||||
let tab = d.tab(3).unwrap().center();
|
||||
let other = d.tab(1).unwrap().center();
|
||||
d.modifiers = Modifiers::COMMAND;
|
||||
d.hold(tab, other);
|
||||
d.modifiers = Modifiers::NONE;
|
||||
let out = d.step(vec![button(other, false)]);
|
||||
assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions);
|
||||
assert!(window_of(&d.layout, 3).is_some());
|
||||
|
||||
// Without the modifier the same drag joins the other frame.
|
||||
let mut d = Driver::new(frames(), true);
|
||||
let out = d.drag(tab, other);
|
||||
assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 3, anchor: 1, .. }]), "{:?}", out.actions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_edge_band_splits_at_the_root() {
|
||||
// With side edge zones: all four edges.
|
||||
let mut d = Driver::new(frames(), true);
|
||||
let tab = d.tab(3).unwrap().center();
|
||||
let out = d.drag(tab, pos2(3.0, 300.0));
|
||||
assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions);
|
||||
let Some(Node::Split { axis: SplitAxis::Horizontal, first, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
|
||||
assert_eq!(**first, tabs(&[3], 0));
|
||||
|
||||
// The defaults: the bottom edge of the part between the docks, for a Timeline.
|
||||
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, false);
|
||||
let tab = d.tab(2).unwrap().center();
|
||||
let out = d.drag(tab, pos2(500.0, 597.0));
|
||||
assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 2, placement: Placement::Edge(Zone::Bottom), .. })), "{:?}", out.actions);
|
||||
let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() };
|
||||
assert!(matches!(**first, Node::Dock { .. }));
|
||||
assert!(matches!(&**second, Node::Split { axis: SplitAxis::Vertical, second, .. } if **second == tabs(&[2], 0)));
|
||||
|
||||
// Its left and right edges are the docks' (new columns and docks), not root splits.
|
||||
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, false);
|
||||
let tab = d.tab(4).unwrap().center();
|
||||
let out = d.drag(tab, pos2(246.0, 300.0));
|
||||
assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() {
|
||||
let mut layout = frames();
|
||||
layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0]));
|
||||
let mut d = Driver::new(layout.clone(), true);
|
||||
let window_title = d.title(5).center();
|
||||
d.maximized = Some(2);
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.tab(2).unwrap().left(), 0.0, "the maximized frame starts at the area's edge");
|
||||
assert!(d.tab(1).is_none());
|
||||
assert!(d.response(Id::unique(AREA).with(("move-group", Some(&5u32)))).is_none());
|
||||
let out = d.drag(window_title, window_title + vec2(30.0, 30.0));
|
||||
assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveFloating { .. } | Action::MoveWindow { .. })), "{:?}", out.actions);
|
||||
|
||||
// A floating panel does not maximize.
|
||||
let mut d = Driver::new(layout, true);
|
||||
d.maximized = Some(5);
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert!(d.tab(1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_other_panels_keeps_only_the_chosen_tab() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
type State = (Content, Vec<Action<u32>>, Vec<DockEvent<u32>>);
|
||||
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|
||||
|ui, (content, actions, events): &mut State| {
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
frame_style(&mut style);
|
||||
let out = DockArea::new(Id::unique(AREA)).native_windows(true).show_content(ui, &frames(), &style, content);
|
||||
actions.extend(out.actions);
|
||||
events.extend(out.events);
|
||||
},
|
||||
(Content::default(), Vec::new(), Vec::new()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
let at = h.ctx.read_response(Id::unique(AREA).with(("tab", &3u32))).unwrap().rect.center();
|
||||
let open_menu = |h: &mut Harness<'_, State>| {
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
};
|
||||
open_menu(&mut h);
|
||||
for label in ["Undock Panel", "Close Panel Group", "Undock Panel Group", "Close Other Panels in Group", "Maximize Frame"] {
|
||||
assert_eq!(h.query_all_by_label(label).count(), 1, "{label}");
|
||||
}
|
||||
assert_eq!(h.query_all_by_label("Minimize").count(), 0);
|
||||
h.get_by_label("Close Other Panels in Group").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().1, vec![Action::CloseOthers { panel: 3 }]);
|
||||
let mut layout = frames();
|
||||
layout.apply(Action::CloseOthers { panel: 3 }).unwrap();
|
||||
assert!(layout.contains(&3) && !layout.contains(&2));
|
||||
|
||||
open_menu(&mut h);
|
||||
h.get_by_label("Maximize Frame").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2, vec![DockEvent::ToggleMaximize { panel: 3 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_panels_keeps_protected_panels_and_the_except_set() {
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
d.hide = Hide::All;
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
for panel in [2, 3, 4, 5, 6, 8] {
|
||||
assert!(d.tab(panel).is_none(), "{panel}");
|
||||
}
|
||||
assert!(d.layout == workspace(), "hiding leaves the layout unchanged");
|
||||
|
||||
d.hide = Hide::Keep;
|
||||
d.content.keep = vec![4, 6];
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.tab(4).map(|r| r.left()), Some(0.0));
|
||||
assert!(d.tab(6).is_some());
|
||||
for panel in [2, 3, 5, 7, 8] {
|
||||
assert!(d.tab(panel).is_none(), "{panel}");
|
||||
}
|
||||
// A window keeps its rect with its hidden groups left out.
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.title(6).min, pos2(300.0, 84.0), "below the resize band");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_canvas_that_accepts_splits_is_still_protected() {
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
d.content.beside = true;
|
||||
d.hide = Hide::All;
|
||||
d.step(vec![]);
|
||||
d.content.drawn.clear();
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.content.drawn, vec![0], "hiding every panel keeps the canvas");
|
||||
assert!(d.tab(0).is_none());
|
||||
assert!(d.response(DockArea::<u32>::hit_id(Id::unique(AREA), &Hit::DragBar(0))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_window_falls_back_in_window_when_viewports_are_embedded() {
|
||||
let mut layout = workspace();
|
||||
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
|
||||
let mut d = Driver::new(layout.clone(), false);
|
||||
d.content.native = true;
|
||||
assert!(d.ctx.embed_viewports());
|
||||
let out = d.step(vec![]);
|
||||
assert!(out.actions.is_empty(), "{out:?}");
|
||||
assert_eq!(d.title(5).min, pos2(300.0, 84.0));
|
||||
assert_eq!(d.layout, layout);
|
||||
}
|
||||
|
||||
#[cfg(feature = "native-windows")]
|
||||
#[test]
|
||||
fn native_window_close_closes_its_panels() {
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
let close = Rc::new(Cell::new(false));
|
||||
let requested = close.clone();
|
||||
// A backend with real viewports: the child reports its place and, when asked, a close.
|
||||
Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
|
||||
let mut info = egui::ViewportInfo {
|
||||
outer_rect: Some(Rect::from_min_size(pos2(1210.0, 905.0), vec2(250.0, 330.0))),
|
||||
inner_rect: Some(Rect::from_min_size(pos2(1210.0, 935.0), vec2(250.0, 300.0))),
|
||||
..Default::default()
|
||||
};
|
||||
if requested.get() {
|
||||
info.events.push(egui::ViewportEvent::Close);
|
||||
}
|
||||
let input = RawInput {
|
||||
viewport_id: viewport.ids.this,
|
||||
viewports: std::iter::once((viewport.ids.this, info)).collect(),
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(250.0, 300.0))),
|
||||
..Default::default()
|
||||
};
|
||||
done(ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)));
|
||||
});
|
||||
let mut layout = workspace();
|
||||
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
|
||||
let mut d = Driver::new(Layout::default(), false);
|
||||
d.ctx.set_embed_viewports(false);
|
||||
d.content.native = true;
|
||||
d.layout = layout;
|
||||
d.step(vec![]);
|
||||
assert!(d.response(Id::unique(AREA).with(("move-group", Some(&5u32)))).is_none(), "drawn in its own OS window");
|
||||
// Where the window system put its content, once a report that late is no longer the
|
||||
// opening move landing.
|
||||
let reported = (0..60).any(|_| d.step(vec![]).actions.contains(&Action::SetNative { panel: 5, rect: Some([1210.0, 935.0, 250.0, 300.0]) }));
|
||||
assert!(reported, "{:?}", d.layout.floating[0].native);
|
||||
|
||||
close.set(true);
|
||||
let out = d.step(vec![]);
|
||||
assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions);
|
||||
assert!(window_of(&d.layout, 5).is_none() && !d.layout.contains(&6) && !d.layout.contains(&7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrome_frame_and_title_bar_work_without_a_dock_area() {
|
||||
use chrome::{ChromeStyle, FloatingFrame, TitleBar};
|
||||
let ctx = Context::default();
|
||||
let id = Id::unique("task-pane");
|
||||
let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0));
|
||||
let mut time = 0.0;
|
||||
let mut frame = |events: Vec<Event>| {
|
||||
time += 1.0 / 60.0;
|
||||
let input =
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() };
|
||||
let mut result = None;
|
||||
done(ctx.run_ui(input, |ui| {
|
||||
let style = ChromeStyle::from_ui(ui);
|
||||
let shown = FloatingFrame::new(id, rect).show(ui.ctx(), &style, |ui, inner| {
|
||||
let title = Rect::from_min_size(inner.min, vec2(inner.width(), 22.0));
|
||||
let bar = TitleBar::new(id.with("title"), "Move pane").title("Styles").menu("Pane menu").close("Close pane").show(ui, title, &style);
|
||||
(bar.bar.dragged(), bar.bar.drag_delta(), bar.menu.is_some_and(|r| r.clicked()), bar.close.is_some_and(|r| r.clicked()))
|
||||
});
|
||||
result = Some((shown.inner, shown.resized));
|
||||
}));
|
||||
result.unwrap()
|
||||
};
|
||||
frame(vec![]);
|
||||
frame(vec![]);
|
||||
type Shown = ((bool, Vec2, bool, bool), Option<Rect>);
|
||||
let click = |frame: &mut dyn FnMut(Vec<Event>) -> Shown, at| {
|
||||
frame(vec![Event::PointerMoved(at), button(at, true)]);
|
||||
frame(vec![button(at, false)])
|
||||
};
|
||||
let ((_, _, _, closed), _) = click(&mut frame, pos2(390.0, 111.0));
|
||||
assert!(closed, "× 10 pt in from the right");
|
||||
let ((_, _, menu, _), _) = click(&mut frame, pos2(374.0, 111.0));
|
||||
assert!(menu, "▾ left of ×");
|
||||
|
||||
frame(vec![Event::PointerMoved(pos2(200.0, 110.0)), button(pos2(200.0, 110.0), true)]);
|
||||
frame(vec![Event::PointerMoved(pos2(210.0, 115.0))]);
|
||||
let ((dragged, delta, ..), _) = frame(vec![Event::PointerMoved(pos2(230.0, 125.0))]);
|
||||
assert!(dragged && delta == vec2(20.0, 10.0), "{delta:?}");
|
||||
frame(vec![button(pos2(230.0, 125.0), false)]);
|
||||
|
||||
let edge = pos2(398.0, 200.0);
|
||||
frame(vec![Event::PointerMoved(edge), button(edge, true)]);
|
||||
frame(vec![Event::PointerMoved(edge + vec2(10.0, 0.0))]);
|
||||
let (_, resized) = frame(vec![Event::PointerMoved(edge + vec2(50.0, 0.0))]);
|
||||
assert_eq!(resized, Some(Rect::from_min_size(rect.min, vec2(350.0, 200.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() {
|
||||
let layout = Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(200.0),
|
||||
first: Box::new(tabs(&[9], 0)),
|
||||
second: Box::new(Node::Split {
|
||||
axis: SplitAxis::Vertical,
|
||||
size: SplitSize::Ratio(0.5),
|
||||
first: Box::new(tabs(&[1], 0)),
|
||||
second: Box::new(tabs(&[2], 0)),
|
||||
}),
|
||||
}),
|
||||
floating: Vec::new(),
|
||||
};
|
||||
let mut d = Driver::new(layout, true);
|
||||
d.hide = Hide::Keep;
|
||||
d.content.keep = vec![1, 2];
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.tab(1).map(|r| r.left()), Some(0.0), "the hidden frame's space goes to the others");
|
||||
// Hits and widget ids name the split by its layout path, as `ResizeSplit` does.
|
||||
let hit = Hit::Divider(Divider::Split(vec![true]));
|
||||
assert!(DockArea::<u32>::hit_rects(&d.ctx, Id::unique(AREA)).iter().any(|(h, _)| *h == hit), "the hits name the layout path");
|
||||
let divider = d.response(DockArea::<u32>::hit_id(Id::unique(AREA), &hit)).unwrap().center();
|
||||
let out = d.hold(divider, divider + vec2(0.0, 60.0));
|
||||
assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeSplit { path, .. } if path == &vec![true])), "{:?}", out.actions);
|
||||
let Some(Node::Split { size: SplitSize::FixedFirst(200.0), second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
|
||||
assert!(matches!(**second, Node::Split { size, .. } if size != SplitSize::Ratio(0.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_floating_window_does_not_cover_dock_targets() {
|
||||
// Window 5 covers the column's seam at x 150..240 until it is hidden.
|
||||
let mut layout = workspace();
|
||||
layout.floating = vec![Floating::single(vec![5], 0, [150.0, 250.0, 200.0, 150.0]), Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0])];
|
||||
let mut d = Driver::new(layout, false);
|
||||
d.hide = Hide::Keep;
|
||||
d.content.keep = vec![2, 3, 4, 8];
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
d.drag(d.title(8).center(), pos2(200.0, 309.0));
|
||||
let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) };
|
||||
let Node::Dock { columns, .. } = &**first else { panic!("{first:?}") };
|
||||
let groups: Vec<Vec<u32>> = columns[0].groups.iter().map(|g| g.panels.clone()).collect();
|
||||
assert_eq!(groups, vec![vec![2, 3], vec![8], vec![4]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_edges_resize_from_the_band_above_the_title_bar() {
|
||||
// W2 holds [8] at [560, 300, 200, 200]; each top handle keeps the opposite edges.
|
||||
let area = Id::unique(AREA);
|
||||
for (edge, by, expected) in [
|
||||
(ResizeEdge::Top, vec2(0.0, -40.0), [560.0, 260.0, 200.0, 240.0]),
|
||||
(ResizeEdge::TopLeft, vec2(-30.0, 20.0), [530.0, 320.0, 230.0, 180.0]),
|
||||
(ResizeEdge::TopRight, vec2(20.0, -10.0), [560.0, 290.0, 220.0, 210.0]),
|
||||
] {
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let handle = d.response(DockArea::hit_id(area, &Hit::Resize(8u32, edge))).unwrap();
|
||||
assert_eq!(handle.bottom(), d.title(8).top(), "{edge:?} lies above the title bar");
|
||||
d.drag(handle.center(), handle.center() + by);
|
||||
assert_eq!(window_of(&d.layout, 8).unwrap().rect, expected, "{edge:?}");
|
||||
}
|
||||
// The title bar still moves the window, and arrow keys move the focused top edge.
|
||||
let mut d = Driver::new(workspace(), false);
|
||||
let title = d.title(8);
|
||||
d.drag(pos2(title.center().x, title.top() + 1.0), pos2(title.center().x - 50.0, title.top() + 1.0));
|
||||
assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 300.0, 200.0, 200.0]);
|
||||
d.ctx.memory_mut(|m| m.request_focus(DockArea::hit_id(area, &Hit::Resize(8u32, ResizeEdge::Top))));
|
||||
d.step(vec![Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 292.0, 200.0, 208.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undock_panel_group_floats_one_group_of_a_floating_window() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let stacked = || Layout {
|
||||
root: Some(tabs(&[1], 0)),
|
||||
floating: vec![Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 0)], [300.0, 80.0, 300.0, 400.0]).unwrap()],
|
||||
};
|
||||
type State = (Content, Layout<u32>, Vec<Action<u32>>);
|
||||
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|
||||
|ui, (content, layout, actions): &mut State| {
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
frame_style(&mut style);
|
||||
let out = DockArea::new(Id::unique(AREA)).native_windows(true).show_content(ui, layout, &style, content);
|
||||
actions.extend(out.actions);
|
||||
},
|
||||
(Content::default(), stacked(), Vec::new()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
let menu = |h: &mut Harness<'_, State>, panel: u32| {
|
||||
let at = h.ctx.read_response(Id::unique(AREA).with(("tab", &panel))).unwrap().rect.center();
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
};
|
||||
menu(&mut h, 6);
|
||||
h.get_by_label("Undock Panel Group").click();
|
||||
h.run_steps(2);
|
||||
let actions = std::mem::take(&mut h.state_mut().2);
|
||||
assert!(matches!(&actions[..], [Action::MoveGroup { panel: 6, to: Target::Float { rect: Some(_) } }]), "{actions:?}");
|
||||
let mut layout = stacked();
|
||||
layout.apply_all(actions, |_| Permissions::default()).unwrap();
|
||||
assert_eq!(layout.floating.iter().map(groups_of).collect::<Vec<_>>(), vec![vec![vec![5]], vec![vec![6, 7]]]);
|
||||
|
||||
// A window's only group has nothing to undock from.
|
||||
h.state_mut().1 = layout;
|
||||
h.run_steps(2);
|
||||
menu(&mut h, 5);
|
||||
assert_eq!(h.query_all_by_label("Undock Panel Group").count(), 0);
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
//! Whole-group gestures: drag bar, top border, drop resolution, minimize and the floating frame.
|
||||
|
||||
use super::*;
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
use egui::{Color32, Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, UiBuilder, Vec2, pos2, vec2};
|
||||
|
||||
const AREA: &str = "group-area";
|
||||
|
||||
#[path = "trial_tests.rs"]
|
||||
mod trial;
|
||||
|
||||
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
|
||||
Node::Tabs { panels: panels.to_vec(), active }
|
||||
}
|
||||
|
||||
/// `[1] | [2, 3]` with panel 3 selected.
|
||||
fn frames() -> Layout<u32> {
|
||||
Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(300.0),
|
||||
first: Box::new(tabs(&[1], 0)),
|
||||
second: Box::new(tabs(&[2, 3], 1)),
|
||||
}),
|
||||
floating: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A start dock with one column holding `[2, 3]` (3 selected) above `[4]`, beside canvas 0.
|
||||
fn docked() -> Layout<u32> {
|
||||
Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(240.0),
|
||||
first: Box::new(Node::Dock {
|
||||
side: Side::Start,
|
||||
columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)],
|
||||
}),
|
||||
second: Box::new(tabs(&[0], 0)),
|
||||
}),
|
||||
floating: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestContent {
|
||||
protected: Vec<u32>,
|
||||
header: f32,
|
||||
header_clicks: usize,
|
||||
/// The strip width the last header was sized for.
|
||||
strip_width: std::cell::Cell<f32>,
|
||||
floating_size: Option<Vec2>,
|
||||
host_owned: bool,
|
||||
whole_group: bool,
|
||||
/// Height every body takes; `None` draws a one-line label.
|
||||
body_height: Option<f32>,
|
||||
/// Every body takes its whole rect.
|
||||
fill: bool,
|
||||
}
|
||||
|
||||
impl DockContent<u32> for TestContent {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
if self.fill {
|
||||
ui.allocate_rect(ui.max_rect(), egui::Sense::hover());
|
||||
return;
|
||||
}
|
||||
match self.body_height {
|
||||
Some(height) => _ = ui.allocate_exact_size(vec2(10.0, height), egui::Sense::hover()),
|
||||
None => _ = ui.label("Body"),
|
||||
}
|
||||
}
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
format!("Panel {panel}")
|
||||
}
|
||||
fn permissions(&self, panel: &u32) -> Permissions {
|
||||
if self.protected.contains(panel) { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
fn floating_size(&self, _: &u32) -> Option<Vec2> {
|
||||
self.floating_size
|
||||
}
|
||||
fn resolve_drag(&mut self, _: &Context, _: &DockStyle, request: &DropRequest<'_, u32>) -> Option<DropFeedback> {
|
||||
self.whole_group |= request.whole_group;
|
||||
self.host_owned.then(DropFeedback::default)
|
||||
}
|
||||
fn group_header_width(&self, _: &[u32], _: &u32, _: bool, strip_width: f32) -> f32 {
|
||||
self.strip_width.set(strip_width);
|
||||
self.header
|
||||
}
|
||||
fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) {
|
||||
if ui.button("≡").clicked() {
|
||||
self.header_clicks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a layout through `show_content` in `area` of an 800x600 window and applies what
|
||||
/// it emits, the way an application's command handler would.
|
||||
struct Driver {
|
||||
ctx: Context,
|
||||
layout: Layout<u32>,
|
||||
content: TestContent,
|
||||
area: Rect,
|
||||
modifiers: Modifiers,
|
||||
focused: bool,
|
||||
transparent: bool,
|
||||
/// Applied to the style every frame.
|
||||
tweak: fn(&mut DockStyle),
|
||||
shapes: Vec<egui::epaint::ClippedShape>,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new(layout: Layout<u32>) -> Self {
|
||||
Self::with(layout, TestContent::default(), Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)))
|
||||
}
|
||||
|
||||
fn with(layout: Layout<u32>, content: TestContent, area: Rect) -> Self {
|
||||
let mut driver = Self {
|
||||
ctx: Context::default(),
|
||||
layout,
|
||||
content,
|
||||
area,
|
||||
modifiers: Modifiers::NONE,
|
||||
focused: true,
|
||||
transparent: false,
|
||||
tweak: |_| {},
|
||||
shapes: Vec::new(),
|
||||
time: 0.0,
|
||||
};
|
||||
driver.step(vec![]);
|
||||
driver.step(vec![]);
|
||||
driver
|
||||
}
|
||||
|
||||
fn frame(&mut self, mut events: Vec<Event>) -> DockOutput<u32> {
|
||||
let mut output = DockOutput::default();
|
||||
events.insert(0, Event::ModifiersChanged(self.modifiers));
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
|
||||
events,
|
||||
focused: self.focused,
|
||||
time: Some(self.time),
|
||||
..Default::default()
|
||||
};
|
||||
let (layout, content, area, transparent, tweak) = (&self.layout, &mut self.content, self.area, self.transparent, self.tweak);
|
||||
let mut full = self.ctx.run_ui(input, |ui| {
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
if transparent {
|
||||
style.background = Color32::TRANSPARENT;
|
||||
style.tab_background = Color32::TRANSPARENT;
|
||||
}
|
||||
tweak(&mut style);
|
||||
ui.scope_builder(UiBuilder::new().max_rect(area), |ui| {
|
||||
output = DockArea::new(Id::unique(AREA)).show_content(ui, layout, &style, content);
|
||||
});
|
||||
});
|
||||
full.textures_delta.clear();
|
||||
self.shapes = full.shapes;
|
||||
output
|
||||
}
|
||||
|
||||
fn step(&mut self, events: Vec<Event>) -> DockOutput<u32> {
|
||||
let output = self.frame(events);
|
||||
assert!(output.error.is_none(), "{output:?}");
|
||||
let permissions = |p: &u32| self.content.permissions(p);
|
||||
for action in &output.actions {
|
||||
self.layout.apply_with_permissions(action.clone(), permissions).unwrap();
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn rect(&self, name: &str, panel: u32) -> Rect {
|
||||
self.ctx.read_response(Id::unique(AREA).with((name, Some(&panel)))).unwrap().rect
|
||||
}
|
||||
|
||||
fn tab(&self, panel: u32) -> Rect {
|
||||
self.ctx.read_response(Id::unique(AREA).with(("tab", &panel))).unwrap().rect
|
||||
}
|
||||
|
||||
/// Press at `from`, pass the drag threshold, move to `to`; returns the last move's output.
|
||||
fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
|
||||
self.step(vec![Event::PointerMoved(from), button(from, true)]);
|
||||
self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]);
|
||||
self.step(vec![Event::PointerMoved(to)])
|
||||
}
|
||||
|
||||
/// A full drag; returns the release frame's output.
|
||||
fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput<u32> {
|
||||
self.hold(from, to);
|
||||
self.step(vec![button(to, false)])
|
||||
}
|
||||
|
||||
/// Lets egui's double- and triple-click windows pass.
|
||||
fn wait(&mut self) {
|
||||
for _ in 0..45 {
|
||||
self.step(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn click(&mut self, at: egui::Pos2) -> DockOutput<u32> {
|
||||
self.step(vec![Event::PointerMoved(at), button(at, true)]);
|
||||
self.step(vec![button(at, false)])
|
||||
}
|
||||
}
|
||||
|
||||
fn button(pos: egui::Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn key(key: egui::Key) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn moved_group(actions: &[Action<u32>]) -> Option<&Target<u32>> {
|
||||
actions.iter().find_map(|a| match a {
|
||||
Action::MoveGroup { to, .. } => Some(to),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_two_tab_group_drags_by_its_bar_and_keeps_order_and_active_tab() {
|
||||
// Onto another group's strip: one MoveGroup, both panels in order, 3 still selected.
|
||||
let mut d = Driver::new(frames());
|
||||
let bar = d.rect("drag-bar", 2);
|
||||
assert!(bar.width() >= 40.0);
|
||||
let out = d.drag(bar.center(), d.tab(1).center());
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Tab { .. } })), "{:?}", out.actions);
|
||||
assert_eq!(out.actions.len(), 1);
|
||||
assert!(out.group_drop.is_none(), "a resolved target leaves group_drop empty");
|
||||
assert_eq!(d.layout.root, Some(tabs(&[1, 2, 3], 2)));
|
||||
|
||||
// Onto empty space: a floating window at the group's docked size.
|
||||
let mut d = Driver::new(frames());
|
||||
let border = d.rect("drag-border", 2);
|
||||
let bar = d.rect("drag-bar", 2);
|
||||
let out = d.drag(bar.center(), pos2(700.0, 200.0));
|
||||
let Some(Target::Float { rect: Some([_, _, width, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) };
|
||||
assert_eq!((*width, *height), (border.width(), 600.0));
|
||||
assert_eq!(out.group_drop, Some((2, pos2(700.0, 200.0))));
|
||||
assert_eq!(d.layout.root, Some(tabs(&[1], 0)));
|
||||
assert_eq!(d.layout.floating[0].groups(), vec![Group::new(vec![2, 3], 1)]);
|
||||
|
||||
// The same group in a dock column joins the group below it.
|
||||
let mut d = Driver::new(docked());
|
||||
let bar = d.rect("drag-bar", 2);
|
||||
let out = d.drag(bar.center(), d.tab(4).center());
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 4, .. })), "{:?}", out.actions);
|
||||
let Some(Node::Split { first, .. }) = &d.layout.root else { panic!() };
|
||||
let Node::Dock { columns, .. } = first.as_ref() else { panic!() };
|
||||
assert_eq!(columns[0].groups.len(), 1);
|
||||
assert_eq!(columns[0].groups[0].panels, vec![4, 2, 3]);
|
||||
assert_eq!(columns[0].groups[0].active_panel(), Some(&3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_bar_survives_a_strip_full_of_tabs() {
|
||||
let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6, 7, 8], 0)), floating: Vec::new() };
|
||||
let content = TestContent { header: 24.0, ..Default::default() };
|
||||
let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 400.0)));
|
||||
let bar = d.rect("drag-bar", 1);
|
||||
assert!(bar.width() >= 40.0, "{bar:?}");
|
||||
assert_eq!(bar.right(), 300.0 - 24.0, "the bar ends at the header");
|
||||
assert_eq!(d.content.strip_width.get(), 300.0, "the header is sized for the whole strip");
|
||||
assert!(d.ctx.read_response(Id::unique(AREA).with(("overflow", &1u32))).is_some(), "tabs shrink, then overflow, before the bar narrows");
|
||||
let out = d.drag(bar.center(), pos2(500.0, 300.0));
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions);
|
||||
assert_eq!(d.layout.floating[0].groups()[0].panels.len(), 8);
|
||||
}
|
||||
|
||||
/// Panels with ordinary names.
|
||||
struct Named;
|
||||
|
||||
impl DockContent<u32> for Named {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
ui.label("Body");
|
||||
}
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
if *panel == 1 { "Properties" } else { "Layers" }.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_frames_shrink_their_tabs_before_the_rest_overflow() {
|
||||
let texts = |d: &testing::DockDriver<u32, Named>| {
|
||||
let mut out = Vec::new();
|
||||
fn walk(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) {
|
||||
match shape {
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)),
|
||||
egui::Shape::Text(text) => out.push(text.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out));
|
||||
out
|
||||
};
|
||||
let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() };
|
||||
let area = |width: f32| Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 300.0));
|
||||
|
||||
// Both tabs show, cut to fit, with their labels inside them.
|
||||
let d = testing::DockDriver::new(layout.clone(), Named).with_area(area(168.0));
|
||||
assert!(d.hit(&Hit::Overflow(1)).is_none(), "no overflow while the tabs fit at their minimum width");
|
||||
let min = DockStyle::default().min_tab_width;
|
||||
for (panel, name) in [(1, "Properties"), (2, "Layers")] {
|
||||
let tab = d.hit(&Hit::Tab(panel)).unwrap_or_else(|| panic!("{name} is hidden"));
|
||||
assert!(tab.width() >= min - 0.5, "{name}: {tab:?}");
|
||||
let label = texts(&d).into_iter().find(|t| t.galley.text() == name && tab.contains(t.pos)).unwrap_or_else(|| panic!("no {name} label"));
|
||||
assert!(tab.contains_rect(label.visual_bounding_rect()), "{name}: {:?} outside {tab:?}", label.visual_bounding_rect());
|
||||
}
|
||||
let properties = texts(&d).into_iter().find(|t| t.galley.text() == "Properties").unwrap();
|
||||
assert!(properties.galley.elided, "the wider tab shrinks to make room");
|
||||
|
||||
// Too narrow for both at their minimum width: the active tab stays, the other is listed.
|
||||
let mut d = testing::DockDriver::new(layout, Named).with_area(area(120.0));
|
||||
assert!(d.hit(&Hit::Tab(1)).is_some() && d.hit(&Hit::Tab(2)).is_none());
|
||||
let overflow = d.hit(&Hit::Overflow(1)).expect("an overflow button");
|
||||
d.click(overflow.center());
|
||||
d.settle();
|
||||
let listed = texts(&d).into_iter().find(|t| t.galley.text() == "Layers").expect("the menu lists Layers");
|
||||
assert!(listed.pos.y >= overflow.bottom(), "{listed:?}");
|
||||
let out = d.click(listed.visual_bounding_rect().center());
|
||||
assert!(out.actions.contains(&Action::Activate { panel: 2 }), "{:?}", out.actions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_border_drags_the_group_and_the_menu_button_only_clicks() {
|
||||
let content = TestContent { header: 30.0, ..Default::default() };
|
||||
let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
|
||||
let border = d.rect("drag-border", 2);
|
||||
assert!(border.height() <= 4.0);
|
||||
// The band lies over the tabs and wins there.
|
||||
let over_tab = pos2(d.tab(2).center().x, border.center().y);
|
||||
let menu = pos2(576.0, d.tab(2).center().y);
|
||||
|
||||
// Pressing ≡ and dragging moves nothing; clicking it reaches the host.
|
||||
let held = d.hold(menu, menu + vec2(0.0, 200.0));
|
||||
assert!(held.group_drag.is_none());
|
||||
let out = d.step(vec![button(menu + vec2(0.0, 200.0), false)]);
|
||||
assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveGroup { .. } | Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions);
|
||||
// A press in the header focuses the group's shown panel, like a press on its tab.
|
||||
let pressed = d.step(vec![Event::PointerMoved(menu), button(menu, true)]);
|
||||
assert_eq!(pressed.focused, Some(3));
|
||||
d.step(vec![button(menu, false)]);
|
||||
assert_eq!(d.content.header_clicks, 1);
|
||||
|
||||
let out = d.drag(over_tab, d.tab(1).center());
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, .. })), "{:?}", out.actions);
|
||||
assert_eq!(d.layout.root, Some(tabs(&[1, 2, 3], 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_floats() {
|
||||
let canvas = || TestContent { protected: vec![1], ..Default::default() };
|
||||
let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0));
|
||||
let mut d = Driver::with(frames(), TestContent::default(), area);
|
||||
let out = d.drag(d.rect("drag-bar", 2).center(), pos2(20.0, 300.0));
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Split(Zone::Left) })), "{:?}", out.actions);
|
||||
let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() };
|
||||
assert_eq!(**first, tabs(&[2, 3], 1));
|
||||
assert_eq!(**second, tabs(&[1], 0));
|
||||
|
||||
// A protected canvas takes nothing, at its center or its edge: no join, no split, no
|
||||
// preview; the group floats there.
|
||||
for at in [pos2(150.0, 300.0), pos2(20.0, 300.0)] {
|
||||
let mut d = Driver::with(frames(), canvas(), area);
|
||||
let out = d.drag(d.rect("drag-bar", 2).center(), at);
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions);
|
||||
assert_eq!(d.layout.root, Some(tabs(&[1], 0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_or_cmd_held_at_release_always_floats() {
|
||||
for modifiers in [Modifiers::CTRL, Modifiers::MAC_CMD, Modifiers::COMMAND] {
|
||||
let mut d = Driver::new(frames());
|
||||
let target = d.tab(1).center();
|
||||
d.hold(d.rect("drag-bar", 2).center(), target);
|
||||
d.modifiers = modifiers;
|
||||
let out = d.step(vec![button(target, false)]);
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{modifiers:?}: {:?}", out.actions);
|
||||
assert_eq!(d.layout.root, Some(tabs(&[1], 0)));
|
||||
|
||||
// A tab drag floats the same way.
|
||||
let mut d = Driver::new(frames());
|
||||
let target = d.tab(1).center();
|
||||
d.hold(d.tab(3).center(), target);
|
||||
d.modifiers = modifiers;
|
||||
let out = d.step(vec![button(target, false)]);
|
||||
assert!(out.actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{modifiers:?}: {:?}", out.actions);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() {
|
||||
for case in ["escape", "focus", "exit", "cancel"] {
|
||||
let mut d = Driver::new(frames());
|
||||
let target = d.tab(1).center();
|
||||
let held = d.hold(d.rect("drag-bar", 2).center(), target);
|
||||
assert_eq!(held.group_drag, Some(2));
|
||||
let out = match case {
|
||||
"escape" => d.step(vec![key(egui::Key::Escape)]),
|
||||
"focus" => {
|
||||
d.focused = false;
|
||||
let out = d.step(vec![]);
|
||||
d.focused = true;
|
||||
out
|
||||
}
|
||||
"exit" => d.step(vec![Event::PointerGone]),
|
||||
_ => {
|
||||
cancel_drag::<u32>(&d.ctx, Id::unique(AREA));
|
||||
d.step(vec![Event::PointerMoved(target + vec2(1.0, 0.0))])
|
||||
}
|
||||
};
|
||||
assert!(out.actions.is_empty() && out.group_drop.is_none(), "{case}: {out:?}");
|
||||
let out = d.step(vec![Event::PointerMoved(target), button(target, false)]);
|
||||
assert!(out.actions.is_empty() && out.group_drop.is_none() && out.group_drag.is_none(), "{case}: {out:?}");
|
||||
assert_eq!(d.layout, frames(), "{case}");
|
||||
assert!(!is_dragging::<u32>(&d.ctx, Id::unique(AREA)), "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floated_panel_fits_its_content_up_to_floating_size_not_the_column_height() {
|
||||
for size in [Some(vec2(200.0, 150.0)), None] {
|
||||
let mut layout = docked();
|
||||
let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() };
|
||||
**first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] };
|
||||
let content = TestContent { protected: vec![0], floating_size: size, body_height: Some(200.0), ..Default::default() };
|
||||
let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
|
||||
let out = d.drag(d.tab(3).center(), pos2(700.0, 100.0));
|
||||
let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a {
|
||||
Action::Float { panel: 3, rect } => Some(*rect),
|
||||
_ => None,
|
||||
}) else {
|
||||
panic!("{:?}", out.actions)
|
||||
};
|
||||
let fitted = crate::docking::chrome::TITLE_HEIGHT + d.rect("drag-bar", 2).height() + 200.0;
|
||||
match size {
|
||||
Some(size) => assert_eq!((width, height), (size.x, size.y)),
|
||||
None => assert_eq!(height, fitted),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_window_is_opaque_with_a_transparent_host_background() {
|
||||
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
|
||||
let mut d = Driver::new(layout);
|
||||
d.transparent = true;
|
||||
d.step(vec![]);
|
||||
let window = Rect::from_min_size(pos2(80.0, 70.0), vec2(300.0, 220.0));
|
||||
let fill = d.ctx.global_style().visuals.window_fill;
|
||||
assert_ne!(fill, Color32::TRANSPARENT);
|
||||
fn filled(shape: &egui::Shape, rect: Rect, fill: Color32) -> bool {
|
||||
match shape {
|
||||
egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == fill,
|
||||
egui::Shape::Vec(shapes) => shapes.iter().any(|shape| filled(shape, rect, fill)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
assert!(d.shapes.iter().any(|clipped| filled(&clipped.shape, window, fill)), "no opaque fill under the floating window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_a_floating_tab_or_bar_minimizes_and_another_tab_expands() {
|
||||
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
|
||||
let mut d = Driver::new(layout);
|
||||
let minimized = |d: &Driver| d.layout.floating[0].minimized;
|
||||
let tab = d.tab(6).center();
|
||||
d.click(tab);
|
||||
let out = d.click(tab);
|
||||
assert!(out.actions.contains(&Action::SetMinimized { panel: 6, minimized: true }), "{:?}", out.actions);
|
||||
assert!(minimized(&d));
|
||||
d.wait();
|
||||
|
||||
// Another tab of the minimized group expands it and selects that tab.
|
||||
let other = d.tab(5).center();
|
||||
d.click(other);
|
||||
assert!(!minimized(&d));
|
||||
assert_eq!(d.layout.floating[0].groups()[0].active_panel(), Some(&5));
|
||||
|
||||
d.wait();
|
||||
let bar = d.rect("drag-bar", 5).center();
|
||||
d.click(bar);
|
||||
d.click(bar);
|
||||
assert!(minimized(&d));
|
||||
d.wait();
|
||||
d.click(bar);
|
||||
d.click(bar);
|
||||
assert!(!minimized(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_over_own_position_is_a_noop() {
|
||||
let mut d = Driver::new(frames());
|
||||
let bar = d.rect("drag-bar", 2);
|
||||
let out = d.drag(bar.center(), bar.center() + vec2(0.0, 200.0));
|
||||
assert!(out.actions.is_empty() && out.group_drop.is_none(), "{out:?}");
|
||||
assert_eq!(d.layout, frames());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_border_and_drag_bar_move_and_cancel_as_a_group() {
|
||||
for name in ["drag-border", "drag-bar"] {
|
||||
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
|
||||
let mut d = Driver::new(layout.clone());
|
||||
let from = d.rect(name, 5).center();
|
||||
d.hold(from, from + vec2(50.0, 40.0));
|
||||
assert_eq!(d.layout, layout, "{name}: a ghost moves, not the window");
|
||||
d.step(vec![key(egui::Key::Escape)]);
|
||||
d.step(vec![button(from + vec2(50.0, 40.0), false)]);
|
||||
assert_eq!(d.layout, layout, "{name}");
|
||||
|
||||
let out = d.drag(from, from + vec2(50.0, 40.0));
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{name}: {:?}", out.actions);
|
||||
let [x, y, w, h] = d.layout.floating[0].rect;
|
||||
assert_eq!([x, y, w, h], [130.0, 110.0, 300.0, 220.0], "{name}");
|
||||
assert_eq!(d.layout.floating[0].groups()[0], Group::new(vec![5, 6], 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_border_movement_does_not_accumulate_fractional_step_rounding() {
|
||||
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] };
|
||||
let mut d = Driver::new(layout);
|
||||
let from = d.rect("drag-border", 5).center();
|
||||
d.step(vec![Event::PointerMoved(from), button(from, true)]);
|
||||
for index in 1..=8 {
|
||||
d.step(vec![Event::PointerMoved(from + vec2(80.3, 60.7) * (index as f32 / 8.0))]);
|
||||
}
|
||||
d.step(vec![button(from + vec2(80.3, 60.7), false)]);
|
||||
let [x, y, ..] = d.layout.floating[0].rect;
|
||||
assert!((x - 160.3).abs() <= 1.0 && (y - 130.7).abs() <= 1.0, "{x} {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_header_drag_reaches_the_host_hook_and_preserves_the_group() {
|
||||
for name in ["drag-border", "drag-bar"] {
|
||||
let content = TestContent { host_owned: true, ..Default::default() };
|
||||
let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
|
||||
let out = d.drag(d.rect(name, 2).center(), pos2(70.0, 300.0));
|
||||
assert!(d.content.whole_group, "{name}");
|
||||
assert!(out.actions.is_empty() && out.group_drop.is_none(), "{name}: the host owns the release");
|
||||
assert_eq!(d.layout, frames());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_protected_panel_draws_no_strip_and_no_bar() {
|
||||
struct Bare;
|
||||
impl DockContent<u32> for Bare {
|
||||
fn body(&mut self, _: &mut egui::Ui, _: &u32) {}
|
||||
fn permissions(&self, panel: &u32) -> Permissions {
|
||||
if *panel == 1 { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
}
|
||||
let ctx = Context::default();
|
||||
for _ in 0..2 {
|
||||
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| {
|
||||
DockArea::new(Id::unique(AREA)).show_content(ui, &frames(), &DockStyle::from_ui(ui), &mut Bare);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
assert!(ctx.read_response(Id::unique(AREA).with(("drag-bar", Some(&1u32)))).is_none());
|
||||
assert!(ctx.read_response(Id::unique(AREA).with(("tab", &1u32))).is_none());
|
||||
assert!(ctx.read_response(Id::unique(AREA).with(("drag-bar", Some(&2u32)))).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|
||||
|ui, (content, actions): &mut (TestContent, Vec<Action<u32>>)| {
|
||||
let out = DockArea::new(Id::unique(AREA)).show_content(ui, &docked(), &DockStyle::from_ui(ui), content);
|
||||
actions.extend(out.actions);
|
||||
},
|
||||
(TestContent { protected: vec![0], ..Default::default() }, Vec::new()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
let bar_id = Id::unique(AREA).with(("drag-bar", Some(&2u32)));
|
||||
let at = h.ctx.read_response(bar_id).unwrap().rect.center();
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Move Group To"] {
|
||||
assert_eq!(h.query_all_by_label(label).count(), 1, "{label}");
|
||||
}
|
||||
h.get_by_label("Close Tab Group").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().1.contains(&Action::CloseGroup { panel: 3 }), "{:?}", h.state().1);
|
||||
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(bar_id));
|
||||
h.run_steps(1);
|
||||
assert!(DockArea::<u32>::new(Id::unique(AREA)).chrome_has_focus(&h.ctx, &docked()));
|
||||
h.input_mut().events.push(key(egui::Key::Enter));
|
||||
h.run_steps(2);
|
||||
assert!(egui::Popup::is_any_open(&h.ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() {
|
||||
let stack = Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [80.0, 70.0, 300.0, 400.0]).unwrap();
|
||||
let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![stack] };
|
||||
let mut d = Driver::new(layout);
|
||||
let from = d.rect("drag-bar", 6).center();
|
||||
let out = d.drag(from, pos2(700.0, 300.0));
|
||||
assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 189.0]) })), "{:?}", out.actions);
|
||||
assert_eq!(d.layout.floating.len(), 2);
|
||||
assert_eq!(d.layout.floating[0].groups(), vec![Group::new(vec![5], 0)]);
|
||||
assert_eq!(d.layout.floating[1].groups(), vec![Group::new(vec![6, 7], 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_docked_panel_floats_at_its_content_height_up_to_its_floating_size() {
|
||||
let title = crate::docking::chrome::TITLE_HEIGHT;
|
||||
let float = |body: f32, floating_size: Option<Vec2>| {
|
||||
let content = TestContent { body_height: Some(body), floating_size, ..Default::default() };
|
||||
let mut d = Driver::with(docked(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
|
||||
let strip = d.rect("drag-bar", 4).height();
|
||||
let recorded = crate::docking::float_space::<u32>(&d.ctx, Id::unique(AREA)).unwrap().panel(&4).unwrap();
|
||||
let target = pos2(400.0, 300.0);
|
||||
d.hold(d.tab(4).center(), target);
|
||||
d.modifiers = Modifiers::COMMAND;
|
||||
let out = d.step(vec![button(target, false)]);
|
||||
let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a {
|
||||
Action::Float { panel: 4, rect } => Some(*rect),
|
||||
_ => None,
|
||||
}) else {
|
||||
panic!("{:?}", out.actions)
|
||||
};
|
||||
assert_eq!(recorded, vec2(width, height), "a command floats at the size a drag does");
|
||||
(strip, vec2(width, height))
|
||||
};
|
||||
let (strip, size) = float(150.0, None);
|
||||
assert_eq!(size.y, title + strip + 150.0);
|
||||
assert_eq!(float(0.0, None).1.y, 120.0, "an empty panel floats at the least fitted height");
|
||||
assert_eq!(float(600.0, None).1.y, 320.0, "a tall docked panel keeps the docked cap");
|
||||
// The host's floating size gives the width and the most height.
|
||||
assert_eq!(float(150.0, Some(vec2(300.0, 360.0))).1, vec2(300.0, title + strip + 150.0));
|
||||
assert_eq!(float(150.0, Some(vec2(300.0, 160.0))).1, vec2(300.0, 160.0));
|
||||
|
||||
// A group takes its active panel's content height.
|
||||
let content = TestContent { body_height: Some(150.0), ..Default::default() };
|
||||
let mut d = Driver::with(docked(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
|
||||
let target = pos2(400.0, 300.0);
|
||||
d.hold(d.rect("drag-bar", 2).center(), target);
|
||||
d.modifiers = Modifiers::COMMAND;
|
||||
let out = d.step(vec![button(target, false)]);
|
||||
let Some(Target::Float { rect: Some([_, _, _, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) };
|
||||
assert_eq!(*height, title + strip + 150.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_that_fills_its_frame_floats_at_its_floating_size_or_320() {
|
||||
let float = |floating_size: Option<Vec2>| {
|
||||
let content = TestContent { fill: true, floating_size, ..Default::default() };
|
||||
let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)));
|
||||
let target = pos2(400.0, 300.0);
|
||||
d.hold(d.tab(3).center(), target);
|
||||
d.modifiers = Modifiers::COMMAND;
|
||||
let out = d.step(vec![button(target, false)]);
|
||||
out.actions
|
||||
.iter()
|
||||
.find_map(|a| match a {
|
||||
Action::Float { panel: 3, rect } => Some(rect[3]),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{:?}", out.actions))
|
||||
};
|
||||
assert_eq!(float(None), 320.0);
|
||||
assert_eq!(float(Some(vec2(300.0, 400.0))), 400.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_focus_follows_only_the_users_input() {
|
||||
let mut d = Driver::new(docked());
|
||||
let tab = Id::unique(AREA).with(("tab", &4u32));
|
||||
// Keyboard focus as Tab gives it.
|
||||
d.ctx.memory_mut(|m| m.request_focus(tab));
|
||||
d.step(vec![]);
|
||||
assert!(d.ctx.memory(|m| m.has_focus(tab)));
|
||||
|
||||
// A reset that moves the panel takes its focus away and reports no new focus.
|
||||
let mut reset = docked();
|
||||
let Some(Node::Split { first, .. }) = &mut reset.root else { panic!() };
|
||||
let Node::Dock { columns, .. } = first.as_mut() else { panic!() };
|
||||
columns[0].groups.reverse();
|
||||
d.layout = reset;
|
||||
let out = d.step(vec![]);
|
||||
assert_eq!(out.focused, None);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.ctx.memory(|m| m.focused()), None, "the moved tab gives up keyboard focus");
|
||||
|
||||
// A user drag keeps the dragged tab's focus where the user put it.
|
||||
let mut d = Driver::new(docked());
|
||||
d.ctx.memory_mut(|m| m.request_focus(tab));
|
||||
d.drag(d.tab(4).center(), d.tab(2).center());
|
||||
d.step(vec![]);
|
||||
assert!(d.layout.place(&4) != docked().place(&4));
|
||||
assert!(d.ctx.memory(|m| m.has_focus(tab)), "a tab moved by the user keeps focus");
|
||||
|
||||
// A closed panel's tab gives focus up too.
|
||||
let out = d.step(vec![]);
|
||||
assert!(out.actions.is_empty());
|
||||
d.layout.apply(Action::Close { panel: 4 }).unwrap();
|
||||
d.step(vec![]);
|
||||
d.step(vec![]);
|
||||
assert_eq!(d.ctx.memory(|m| m.focused()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() {
|
||||
let second = |d: &Driver| match &d.layout.root {
|
||||
Some(Node::Split { second, .. }) => (**second).clone(),
|
||||
_ => panic!("{:?}", d.layout.root),
|
||||
};
|
||||
let wheel = |unit, y| Event::MouseWheel {
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
unit,
|
||||
delta: vec2(0.0, y),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Modifiers::NONE,
|
||||
};
|
||||
let middle = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Middle, pressed, modifiers: Modifiers::NONE };
|
||||
|
||||
// Off by default.
|
||||
let mut d = Driver::new(frames());
|
||||
let over = d.tab(2).center();
|
||||
d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]);
|
||||
d.step(vec![middle(over, true)]);
|
||||
d.step(vec![middle(over, false)]);
|
||||
assert_eq!(second(&d), tabs(&[2, 3], 1));
|
||||
|
||||
let mut d = Driver::new(frames());
|
||||
d.tweak = super::floating_tests::frame_style;
|
||||
d.step(vec![]);
|
||||
let over = d.tab(2).center();
|
||||
d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]);
|
||||
assert_eq!(second(&d), tabs(&[2, 3], 0), "wheel up selects the previous tab");
|
||||
d.step(vec![wheel(egui::MouseWheelUnit::Line, 1.0)]);
|
||||
assert_eq!(second(&d), tabs(&[2, 3], 0), "and stops at the first");
|
||||
d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]);
|
||||
assert_eq!(second(&d), tabs(&[2, 3], 0), "trackpad scrolling adds up to a notch");
|
||||
let out = d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]);
|
||||
assert_eq!(second(&d), tabs(&[2, 3], 1));
|
||||
assert_eq!(out.focused, Some(3));
|
||||
d.step(vec![wheel(egui::MouseWheelUnit::Line, -1e30)]);
|
||||
assert_eq!(second(&d), tabs(&[2, 3], 1), "a huge delta stops at the last tab");
|
||||
d.step(vec![wheel(egui::MouseWheelUnit::Line, 1e30)]);
|
||||
assert_eq!(second(&d), tabs(&[2, 3], 0), "and at the first");
|
||||
|
||||
d.step(vec![middle(over, true)]);
|
||||
let out = d.step(vec![middle(over, false)]);
|
||||
assert_eq!(out.actions, vec![Action::Close { panel: 2 }]);
|
||||
assert_eq!(second(&d), tabs(&[3], 0));
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Where each docking gesture starts, for control protocols and tests.
|
||||
|
||||
use std::{collections::HashMap, fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Id, LayerId, Order, Rect};
|
||||
|
||||
use super::{DockArea, Floating, Layout, Node, render::RESIZE_HANDLES};
|
||||
|
||||
/// A rendered docking control that starts a gesture. Groups, columns and windows are named by
|
||||
/// their first panel.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[non_exhaustive]
|
||||
pub enum Hit<P> {
|
||||
/// A tab: click activates, drag moves the panel (S1).
|
||||
Tab(P),
|
||||
/// A group's drag bar: drag moves the group; a right click, Enter or Space opens the group
|
||||
/// menu (S2).
|
||||
DragBar(P),
|
||||
/// The top border of a group's strip: drag moves the group (S3).
|
||||
Border(P),
|
||||
/// The "…" button of a group whose tabs overflow; named by the active panel.
|
||||
Overflow(P),
|
||||
/// [D] A column bar: drag moves the column (S6).
|
||||
ColumnBar(P),
|
||||
/// [D] The »/« button of a column bar (M2).
|
||||
Collapse(P),
|
||||
/// An accordion entry's −/+ button.
|
||||
StackToggle(P),
|
||||
/// A floating window's title bar (S5).
|
||||
Title(P),
|
||||
/// A floating window's × button.
|
||||
Close(P),
|
||||
/// [D] A panel's icon in an iconic column (S7, M5).
|
||||
Icon(P),
|
||||
/// [D] The grip above an icon group (S8).
|
||||
IconGrip(P),
|
||||
/// A floating window's resize handle.
|
||||
Resize(P, ResizeEdge),
|
||||
Divider(Divider<P>),
|
||||
}
|
||||
|
||||
/// The edge or corner a floating window's resize handle moves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[non_exhaustive]
|
||||
pub enum ResizeEdge {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
Top,
|
||||
TopLeft,
|
||||
TopRight,
|
||||
}
|
||||
|
||||
/// A divider that resizes what is on either side of it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[non_exhaustive]
|
||||
pub enum Divider<P> {
|
||||
/// A split, by its path from the root (see [`super::Action::ResizeSplit`]).
|
||||
Split(Vec<bool>),
|
||||
/// Below the accordion entry of this panel.
|
||||
Stack(P),
|
||||
/// [D] The inner edge of the column holding this panel first.
|
||||
Column(P),
|
||||
/// Below the dock group holding this panel first.
|
||||
Group(P),
|
||||
/// Where the divider of a split inside another split ends on the outer split's divider,
|
||||
/// by the outer and the inner split's paths. Dragging it moves both dividers.
|
||||
Intersection(Vec<bool>, Vec<bool>),
|
||||
/// A divider in the floating window holding the panel, by its path from the window's root.
|
||||
WindowSplit(P, Vec<bool>),
|
||||
WindowIntersection(P, Vec<bool>, Vec<bool>),
|
||||
}
|
||||
|
||||
fn key(area: Id) -> Id {
|
||||
area.with("hit-rects")
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'_, P> {
|
||||
/// Every gesture source the area drew in its last frame, with its rect in points. Hosts
|
||||
/// return these from a `ui.docking` control method beside the layout and `focused`; tests
|
||||
/// drag from their centers.
|
||||
pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit<P>, Rect)> {
|
||||
let Some((layout, rects)) = ctx.data(|d| d.get_temp::<Recorded<P>>(key(id))) else { return Vec::new() };
|
||||
let mut names = named(id, &layout);
|
||||
rects.into_iter().filter_map(|(id, rect)| Some((names.remove(&id)?, rect))).collect()
|
||||
}
|
||||
|
||||
/// The egui widget id of the control `hit` names in the area `id`, for tests and
|
||||
/// automation that focus a control or read its response.
|
||||
pub fn hit_id(id: Id, hit: &Hit<P>) -> Id {
|
||||
match hit {
|
||||
Hit::Tab(p) => id.with(("tab", p)),
|
||||
Hit::Icon(p) => id.with(("icon", p)),
|
||||
Hit::Overflow(p) => id.with(("overflow", p)),
|
||||
Hit::StackToggle(p) => id.with(("collapse", p)),
|
||||
Hit::Divider(Divider::Stack(p)) => id.with(("stack-resize", p)),
|
||||
Hit::DragBar(p) => id.with(("drag-bar", Some(p))),
|
||||
Hit::Border(p) => id.with(("drag-border", Some(p))),
|
||||
Hit::IconGrip(p) => id.with(("icon-grip", Some(p))),
|
||||
Hit::ColumnBar(p) => id.with(("column-bar", Some(p))),
|
||||
Hit::Collapse(p) => id.with(("column-collapse", Some(p))),
|
||||
Hit::Divider(Divider::Column(p)) => id.with(("column-resize", Some(p))),
|
||||
Hit::Divider(Divider::Group(p)) => id.with(("group-resize", Some(p))),
|
||||
Hit::Divider(Divider::Split(path)) => id.with(("split", path)),
|
||||
Hit::Divider(Divider::Intersection(outer, inner)) => id.with(("intersection", outer, inner)),
|
||||
Hit::Divider(Divider::WindowSplit(p, path)) => id.with(("window-split", p, path)),
|
||||
Hit::Divider(Divider::WindowIntersection(p, outer, inner)) => id.with(("window-intersection", p, outer, inner)),
|
||||
Hit::Title(p) => id.with(("move-group", Some(p))),
|
||||
Hit::Close(p) => id.with(("move-group", Some(p))).with("close"),
|
||||
Hit::Resize(p, edge) => {
|
||||
let name = match edge {
|
||||
ResizeEdge::BottomRight => RESIZE_HANDLES[0],
|
||||
ResizeEdge::Left => RESIZE_HANDLES[1],
|
||||
ResizeEdge::Right => RESIZE_HANDLES[2],
|
||||
ResizeEdge::Bottom => RESIZE_HANDLES[3],
|
||||
ResizeEdge::BottomLeft => RESIZE_HANDLES[4],
|
||||
ResizeEdge::Top => RESIZE_HANDLES[5],
|
||||
ResizeEdge::TopLeft => RESIZE_HANDLES[6],
|
||||
ResizeEdge::TopRight => RESIZE_HANDLES[7],
|
||||
};
|
||||
id.with(("floating", p)).with(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The egui layer of a floating window in the area `id`; `None` for an empty window. Of two
|
||||
/// layers, the later in `ctx.memory(|m| m.layer_ids())` is drawn on top, and
|
||||
/// `ctx.memory(|m| m.area_rect(layer.id))` is the window's rect. A window the area does not
|
||||
/// draw (all its panels hidden, or a frame maximized) keeps its last rect there: check
|
||||
/// `ctx.memory(|m| m.areas().is_visible(&layer))` before treating it as covering anything.
|
||||
pub fn floating_layer(id: Id, window: &Floating<P>) -> Option<LayerId> {
|
||||
window.panels().next().map(|first| LayerId::new(Order::Middle, id.with(("floating", first))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every control the area `area` can draw for `layout`, by widget id.
|
||||
pub(super) fn named<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(area: Id, layout: &Layout<P>) -> HashMap<Id, Hit<P>> {
|
||||
let mut names: HashMap<Id, Hit<P>> = HashMap::new();
|
||||
let mut name = |hit: Hit<P>| {
|
||||
names.insert(DockArea::<P>::hit_id(area, &hit), hit);
|
||||
};
|
||||
for panel in layout.panels() {
|
||||
let p = || panel.clone();
|
||||
for hit in [
|
||||
Hit::Tab(p()),
|
||||
Hit::Icon(p()),
|
||||
Hit::Overflow(p()),
|
||||
Hit::StackToggle(p()),
|
||||
Hit::Divider(Divider::Stack(p())),
|
||||
Hit::DragBar(p()),
|
||||
Hit::Border(p()),
|
||||
Hit::IconGrip(p()),
|
||||
Hit::ColumnBar(p()),
|
||||
Hit::Collapse(p()),
|
||||
Hit::Divider(Divider::Column(p())),
|
||||
Hit::Divider(Divider::Group(p())),
|
||||
Hit::Title(p()),
|
||||
Hit::Close(p()),
|
||||
] {
|
||||
name(hit);
|
||||
}
|
||||
for edge in [
|
||||
ResizeEdge::BottomRight,
|
||||
ResizeEdge::Left,
|
||||
ResizeEdge::Right,
|
||||
ResizeEdge::Bottom,
|
||||
ResizeEdge::BottomLeft,
|
||||
ResizeEdge::Top,
|
||||
ResizeEdge::TopLeft,
|
||||
ResizeEdge::TopRight,
|
||||
] {
|
||||
name(Hit::Resize(p(), edge));
|
||||
}
|
||||
}
|
||||
if let Some(root) = &layout.root {
|
||||
name_splits(root, &mut |path, outer| match outer {
|
||||
None => name(Hit::Divider(Divider::Split(path))),
|
||||
Some(outer) => name(Hit::Divider(Divider::Intersection(outer, path))),
|
||||
});
|
||||
}
|
||||
for window in &layout.floating {
|
||||
let Some(key) = window.panels().next() else { continue };
|
||||
name_splits(&window.root, &mut |path, outer| match outer {
|
||||
None => name(Hit::Divider(Divider::WindowSplit(key.clone(), path))),
|
||||
Some(outer) => name(Hit::Divider(Divider::WindowIntersection(key.clone(), outer, path))),
|
||||
});
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Every split of a tree by its path (`None`), and every pair of a split and a split inside it
|
||||
/// (`Some(outer)`), whose dividers may meet.
|
||||
fn name_splits<P>(root: &Node<P>, name: &mut dyn FnMut(Vec<bool>, Option<Vec<bool>>)) {
|
||||
let mut nodes = vec![(root, Vec::<bool>::new())];
|
||||
let mut splits: Vec<Vec<bool>> = Vec::new();
|
||||
while let Some((node, path)) = nodes.pop() {
|
||||
if splits.len() > super::model::MAX_NODES {
|
||||
break;
|
||||
}
|
||||
if let Node::Split { first, second, .. } = node {
|
||||
name(path.clone(), None);
|
||||
for outer in splits.iter().filter(|outer| path.starts_with(outer)) {
|
||||
name(path.clone(), Some(outer.clone()));
|
||||
}
|
||||
splits.push(path.clone());
|
||||
let mut second_path = path.clone();
|
||||
second_path.push(true);
|
||||
let mut first_path = path;
|
||||
first_path.push(false);
|
||||
nodes.push((second, second_path));
|
||||
nodes.push((first, first_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout of the last frame and the rects of the controls it registered, by widget id.
|
||||
type Recorded<P> = (Layout<P>, Vec<(Id, Rect)>);
|
||||
|
||||
/// Store the rects of the controls the renderer registered this frame. They are named only
|
||||
/// when [`DockArea::hit_rects`] asks, which tests and automation do, not every frame.
|
||||
pub(super) fn record<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(ctx: &Context, area: Id, layout: &Layout<P>, owned: &[Id]) {
|
||||
let rects: Vec<(Id, Rect)> =
|
||||
owned.iter().filter_map(|id| Some((*id, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect();
|
||||
ctx.data_mut(|d| d.insert_temp::<Recorded<P>>(key(area), (layout.clone(), rects)));
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//! [D] Iconic columns, flyouts and hit rects, driven through `testing::DockDriver`.
|
||||
|
||||
use super::testing::DockDriver;
|
||||
use super::*;
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
use egui::{Key, Pos2, Rect, pos2, vec2};
|
||||
|
||||
struct Content;
|
||||
|
||||
impl DockContent<u32> for Content {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
ui.label("Body");
|
||||
}
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
format!("Panel {panel}")
|
||||
}
|
||||
fn permissions(&self, panel: &u32) -> Permissions {
|
||||
if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
}
|
||||
|
||||
type Driver = DockDriver<u32, Content>;
|
||||
|
||||
/// A name and a test for one kind of [`Hit`].
|
||||
type Check<'a> = (&'a str, &'a dyn Fn(&Hit<u32>) -> bool);
|
||||
|
||||
/// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0.
|
||||
fn docked(groups: &[&[u32]], iconic: bool) -> Layout<u32> {
|
||||
let mut column = Column::new(groups.iter().map(|g| Group::new(g.to_vec(), 0)).collect(), 240.0);
|
||||
column.iconic = iconic;
|
||||
let dock = Node::Dock { side: Side::Start, columns: vec![column] };
|
||||
let canvas = Node::Tabs { panels: vec![0], active: 0 };
|
||||
Layout {
|
||||
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }),
|
||||
floating: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_column(layout: &Layout<u32>) -> Column<u32> {
|
||||
match &layout.root {
|
||||
Some(Node::Split { first, .. }) => match first.as_ref() {
|
||||
Node::Dock { columns, .. } => columns.first().cloned().unwrap(),
|
||||
other => panic!("no dock: {other:?}"),
|
||||
},
|
||||
other => panic!("no split: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn groups(layout: &Layout<u32>) -> Vec<Vec<u32>> {
|
||||
first_column(layout).groups.iter().map(|g| g.panels.clone()).collect()
|
||||
}
|
||||
|
||||
fn hit(d: &Driver, hit: Hit<u32>) -> Rect {
|
||||
d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits()))
|
||||
}
|
||||
|
||||
fn open(d: &Driver) -> Option<u32> {
|
||||
flyout::<u32>(&d.ctx, d.id)
|
||||
}
|
||||
|
||||
fn texts(d: &Driver) -> Vec<String> {
|
||||
d.shapes()
|
||||
.iter()
|
||||
.filter_map(|clipped| match &clipped.shape {
|
||||
egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const CANVAS: Pos2 = pos2(600.0, 400.0);
|
||||
|
||||
#[test]
|
||||
fn collapsed_column_shows_one_icon_per_panel_with_group_separators() {
|
||||
let d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
let (one, two, three) = (hit(&d, Hit::Icon(1)), hit(&d, Hit::Icon(2)), hit(&d, Hit::Icon(3)));
|
||||
let (grip1, grip3) = (hit(&d, Hit::IconGrip(1)), hit(&d, Hit::IconGrip(3)));
|
||||
assert_eq!(one.width(), 36.0, "the column takes the style's iconic width");
|
||||
assert_eq!(one.bottom(), two.top(), "a group's icons follow each other");
|
||||
assert_eq!(three.top() - two.bottom(), 7.0, "groups are apart by the separator, with no room for a grip");
|
||||
assert_eq!((grip1.top(), grip1.bottom()), (one.top(), one.top() + 4.0), "the first group's band is its top edge");
|
||||
assert_eq!((grip3.top(), grip3.bottom()), (two.bottom(), three.top() + 4.0), "a later group's band covers the separator");
|
||||
assert!(d.hit(&Hit::Tab(1)).is_none(), "an iconic column draws no tabs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widening_an_iconic_column_shows_labels() {
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
assert!(!texts(&d).iter().any(|t| t == "Panel 1"));
|
||||
let edge = hit(&d, Hit::Divider(Divider::Column(1)));
|
||||
let out = d.drag(edge.center(), pos2(150.0, edge.center().y));
|
||||
assert!(out.error.is_none());
|
||||
let width = first_column(&d.layout).iconic_width.unwrap();
|
||||
assert!((100.0..=160.0).contains(&width), "{width}");
|
||||
d.settle();
|
||||
let labels = texts(&d);
|
||||
assert!(["Panel 1", "Panel 2", "Panel 3"].iter().all(|l| labels.iter().any(|t| t == l)), "{labels:?}");
|
||||
let edge = hit(&d, Hit::Divider(Divider::Column(1)));
|
||||
d.drag(edge.center(), pos2(20.0, edge.center().y));
|
||||
d.settle();
|
||||
assert_eq!(first_column(&d.layout).iconic_width, Some(36.0));
|
||||
assert!(!texts(&d).iter().any(|t| t == "Panel 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_click_opens_a_flyout_on_the_canvas_side_and_auto_collapse_closes_it() {
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
let icon = hit(&d, Hit::Icon(2)).center();
|
||||
let out = d.click(icon);
|
||||
assert!(out.actions.contains(&Action::Activate { panel: 2 }));
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(2));
|
||||
let column = hit(&d, Hit::Icon(1));
|
||||
let tab = hit(&d, Hit::Tab(2));
|
||||
assert!(tab.left() >= column.right(), "the flyout opens on the canvas side: {tab:?}");
|
||||
assert!(tab.top() <= hit(&d, Hit::IconGrip(1)).bottom(), "beside its group");
|
||||
assert_eq!(first_column(&d.layout).groups.first().map(|g| g.active), Some(1));
|
||||
|
||||
// Another icon switches flyouts, the same icon closes.
|
||||
d.click(hit(&d, Hit::Icon(3)).center());
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(3));
|
||||
assert!(d.hit(&Hit::Tab(2)).is_none());
|
||||
d.click(hit(&d, Hit::Icon(3)).center());
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None);
|
||||
|
||||
// Escape and a press outside close it.
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
d.key(Key::Escape);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None);
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
let inside = hit(&d, Hit::Tab(2)).center();
|
||||
d.click(inside);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(2), "a press inside keeps it");
|
||||
d.click(CANVAS);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None);
|
||||
|
||||
// Auto-Collapse off: it stays until an icon closes it.
|
||||
d.auto_collapse = false;
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
d.key(Key::Escape);
|
||||
d.click(CANVAS);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(2));
|
||||
d.click(icon);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None);
|
||||
|
||||
// U3: raising an iconic panel, as the Window menu does, opens its flyout.
|
||||
d.raise = Some(3);
|
||||
d.settle();
|
||||
assert_eq!(open(&d), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_collapse_toggle_is_a_column_menu_event() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let layout = docked(&[&[1, 2]], true);
|
||||
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|
||||
|ui, events: &mut Vec<DockEvent<u32>>| {
|
||||
let out = DockArea::new(egui::Id::unique("menu")).show_content(ui, &layout, &DockStyle::from_ui(ui), &mut Content);
|
||||
events.extend(out.events);
|
||||
},
|
||||
Vec::new(),
|
||||
);
|
||||
h.run_steps(2);
|
||||
let bar = h.ctx.read_response(egui::Id::unique("menu").with(("column-bar", Some(&1u32)))).unwrap().rect.center();
|
||||
h.input_mut().events.extend([
|
||||
egui::Event::PointerMoved(bar),
|
||||
egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(egui::Event::PointerButton {
|
||||
pos: bar,
|
||||
button: egui::PointerButton::Secondary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Auto-Collapse Iconic Panels").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state(), &vec![DockEvent::ToggleAutoCollapse]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_flyout_strip_floats_its_group() {
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
d.click(hit(&d, Hit::Icon(1)).center());
|
||||
d.settle();
|
||||
// A one-line body: the flyout and the floated group take the least fitted height.
|
||||
let flyout = d.ctx.memory(|m| m.area_rect(d.id.with("flyout"))).unwrap();
|
||||
assert_eq!(flyout.height(), 120.0);
|
||||
let bar = hit(&d, Hit::DragBar(1));
|
||||
let out = d.drag(bar.center(), CANVAS);
|
||||
let [Action::MoveGroup { panel: 1, to: Target::Float { rect: Some(rect) } }] = out.actions.as_slice() else { panic!("{:?}", out.actions) };
|
||||
assert_eq!(rect[3], 120.0);
|
||||
assert_eq!(groups(&d.layout), vec![vec![3]]);
|
||||
assert_eq!(d.layout.floating.len(), 1);
|
||||
assert_eq!(d.layout.floating.first().map(|w| w.panels().copied().collect::<Vec<_>>()), Some(vec![1, 2]));
|
||||
d.settle();
|
||||
assert_eq!(open(&d), None, "the flyout closes with its group gone");
|
||||
|
||||
// A tab dragged out of a flyout floats that panel alone.
|
||||
d.click(hit(&d, Hit::Icon(3)).center());
|
||||
d.settle();
|
||||
let tab = hit(&d, Hit::Tab(3));
|
||||
let out = d.drag(tab.center(), pos2(450.0, 200.0));
|
||||
assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_and_icon_group_grip_drags_move_a_panel_and_a_group() {
|
||||
let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content);
|
||||
let before = d.layout.clone();
|
||||
// Escape, focus loss and cancel_drag end an icon drag with no change.
|
||||
let icon = hit(&d, Hit::Icon(2)).center();
|
||||
d.hold(icon, CANVAS);
|
||||
d.key(Key::Escape);
|
||||
d.release(CANVAS);
|
||||
d.hold(icon, CANVAS);
|
||||
d.focus(false);
|
||||
d.focus(true);
|
||||
d.release(CANVAS);
|
||||
d.hold(icon, CANVAS);
|
||||
cancel_drag::<u32>(&d.ctx, d.id);
|
||||
d.release(CANVAS);
|
||||
assert_eq!(d.layout, before);
|
||||
|
||||
let out = d.drag(icon, CANVAS);
|
||||
let Some(Action::Float { panel: 2, rect }) = out.actions.first() else { panic!("{:?}", out.actions) };
|
||||
assert_eq!((rect[2], rect[3]), (240.0, 320.0), "a panel floated from its icon takes the column's expanded width");
|
||||
assert_eq!(groups(&d.layout), vec![vec![1], vec![3]]);
|
||||
|
||||
let grip = hit(&d, Hit::IconGrip(3)).center();
|
||||
let out = d.drag(grip, pos2(450.0, 250.0));
|
||||
assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 3, to: Target::Float { .. } }]), "{:?}", out.actions);
|
||||
assert_eq!(groups(&d.layout), vec![vec![1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() {
|
||||
let mut layout = docked(&[&[1], &[2]], true);
|
||||
layout.floating.push(Floating::single(vec![5, 6], 0, [300.0, 200.0, 240.0, 200.0]));
|
||||
let mut d = DockDriver::new(layout, Content);
|
||||
let bar = hit(&d, Hit::DragBar(5)).center();
|
||||
let one = hit(&d, Hit::Icon(1));
|
||||
let target = pos2(one.center().x, one.bottom() - 2.0);
|
||||
d.hold(bar, target);
|
||||
let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0));
|
||||
assert!(d.outlined(column.shrink(1.0)), "the column is outlined");
|
||||
let line = Rect::from_x_y_ranges(column.x_range(), (one.bottom() - 2.0)..=(one.bottom() + 2.0));
|
||||
assert!(d.filled(line), "with the insertion line under group 1");
|
||||
let out = d.release(target);
|
||||
assert!(
|
||||
out.actions
|
||||
.contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true, height: None } } }),
|
||||
"{:?}",
|
||||
out.actions
|
||||
);
|
||||
assert_eq!(groups(&d.layout), vec![vec![1], vec![5, 6], vec![2]]);
|
||||
assert!(first_column(&d.layout).iconic);
|
||||
}
|
||||
|
||||
/// Every kind of gesture source has a rect, and dragging from it does something.
|
||||
#[test]
|
||||
fn hit_rects_cover_every_gesture_source() {
|
||||
let mut expanded = Column::new(vec![Group::new(vec![1, 2, 3, 4, 5, 6, 7], 0), Group::new(vec![8], 0)], 150.0);
|
||||
expanded.width = 150.0;
|
||||
let mut icons = Column::new(vec![Group::new(vec![9], 0)], 240.0);
|
||||
icons.iconic = true;
|
||||
let dock = Node::Dock { side: Side::Start, columns: vec![icons, expanded] };
|
||||
let stack = Node::Stack { entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }] };
|
||||
let frames = Node::Split {
|
||||
axis: SplitAxis::Vertical,
|
||||
size: SplitSize::Ratio(0.5),
|
||||
first: Box::new(Node::Tabs { panels: vec![0], active: 0 }),
|
||||
second: Box::new(stack),
|
||||
};
|
||||
let window = Floating::stack(vec![Group::new(vec![12], 0), Group::new(vec![13], 0)], [500.0, 60.0, 220.0, 300.0]).unwrap();
|
||||
let layout = Layout {
|
||||
root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(200.0), first: Box::new(dock), second: Box::new(frames) }),
|
||||
floating: vec![window],
|
||||
};
|
||||
let d = DockDriver::new(layout.clone(), Content);
|
||||
let hits = d.hits();
|
||||
let kinds = |f: &dyn Fn(&Hit<u32>) -> bool| hits.iter().filter(|(h, _)| f(h)).count();
|
||||
let expected: [Check<'_>; 15] = [
|
||||
("tab", &|h| matches!(h, Hit::Tab(_))),
|
||||
("drag bar", &|h| matches!(h, Hit::DragBar(_))),
|
||||
("border", &|h| matches!(h, Hit::Border(_))),
|
||||
("overflow", &|h| matches!(h, Hit::Overflow(_))),
|
||||
("column bar", &|h| matches!(h, Hit::ColumnBar(_))),
|
||||
("collapse", &|h| matches!(h, Hit::Collapse(_))),
|
||||
("stack toggle", &|h| matches!(h, Hit::StackToggle(_))),
|
||||
("title", &|h| matches!(h, Hit::Title(12))),
|
||||
("close", &|h| matches!(h, Hit::Close(12))),
|
||||
("icon", &|h| matches!(h, Hit::Icon(9))),
|
||||
("icon grip", &|h| matches!(h, Hit::IconGrip(9))),
|
||||
("resize", &|h| matches!(h, Hit::Resize(12, _))),
|
||||
("split", &|h| matches!(h, Hit::Divider(Divider::Split(_)))),
|
||||
("column divider", &|h| matches!(h, Hit::Divider(Divider::Column(_)))),
|
||||
("group divider", &|h| matches!(h, Hit::Divider(Divider::Group(_)))),
|
||||
];
|
||||
for (name, kind) in expected {
|
||||
assert!(kinds(kind) > 0, "{name}: {hits:?}");
|
||||
}
|
||||
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Divider(Divider::Stack(10)))));
|
||||
assert_eq!(hits.iter().filter(|(h, _)| matches!(h, Hit::Resize(..))).count(), 8);
|
||||
for (hit, rect) in hits.iter().filter(|(h, _)| !matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) {
|
||||
let mut d = DockDriver::new(layout.clone(), Content);
|
||||
let from = rect.center();
|
||||
let moved = d.hold(from, from + vec2(-30.0, 40.0));
|
||||
let dragging = is_dragging::<u32>(&d.ctx, d.id);
|
||||
let released = d.release(from + vec2(-30.0, 40.0));
|
||||
assert!(dragging || !moved.actions.is_empty() || !released.actions.is_empty(), "{hit:?} at {rect:?} does nothing");
|
||||
}
|
||||
for (hit, rect) in hits.iter().filter(|(h, _)| matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) {
|
||||
let mut d = DockDriver::new(layout.clone(), Content);
|
||||
let out = d.click(rect.center());
|
||||
let opened = egui::Popup::is_any_open(&d.ctx);
|
||||
assert!(opened || !out.actions.is_empty(), "{hit:?} at {rect:?} does nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints panel 1's icon as a dot in code; panel 2 has neither a painted icon nor an image.
|
||||
struct Painted;
|
||||
|
||||
impl DockContent<u32> for Painted {
|
||||
fn body(&mut self, _: &mut egui::Ui, _: &u32) {}
|
||||
fn logical_label(&self, panel: &u32) -> String {
|
||||
format!("Panel {panel}")
|
||||
}
|
||||
fn paint_icon(&mut self, painter: &egui::Painter, rect: Rect, panel: &u32, color: egui::Color32) -> bool {
|
||||
if *panel != 1 {
|
||||
return false;
|
||||
}
|
||||
painter.circle_filled(rect.center(), 3.0, color);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icon_painted_in_code_replaces_the_initial_and_others_fall_back() {
|
||||
let d = DockDriver::new(docked(&[&[1, 2]], true), Painted);
|
||||
let (one, two) = (d.hit(&Hit::Icon(1)).unwrap(), d.hit(&Hit::Icon(2)).unwrap());
|
||||
let dots: Vec<Pos2> = d
|
||||
.shapes()
|
||||
.iter()
|
||||
.filter_map(|clipped| match &clipped.shape {
|
||||
egui::Shape::Circle(circle) if circle.radius == 3.0 => Some(circle.center),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(dots.len(), 1, "{dots:?}");
|
||||
assert!(one.contains(dots[0]) && !two.contains(dots[0]), "the dot sits in panel 1's cell: {dots:?}");
|
||||
let initials: Vec<Pos2> = d
|
||||
.shapes()
|
||||
.iter()
|
||||
.filter_map(|clipped| match &clipped.shape {
|
||||
egui::Shape::Text(text) if text.galley.text() == "P" => Some(text.pos),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(initials.len(), 1, "only panel 2 draws its initial: {initials:?}");
|
||||
assert!(two.contains(initials[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrome_has_focus_covers_every_drawn_control() {
|
||||
let mut layout = docked(&[&[1, 2], &[3]], true);
|
||||
layout.floating.push(Floating::single(vec![5, 6], 0, [400.0, 100.0, 240.0, 200.0]));
|
||||
let mut d = Driver::new(layout, Content);
|
||||
d.settle();
|
||||
let hits = d.hits();
|
||||
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Icon(_))), "{hits:?}");
|
||||
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::IconGrip(_))), "{hits:?}");
|
||||
assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Resize(_, ResizeEdge::Left))), "{hits:?}");
|
||||
for (hit, _) in hits {
|
||||
d.ctx.memory_mut(|m| m.request_focus(DockArea::<u32>::hit_id(d.id, &hit)));
|
||||
assert!(DockArea::<u32>::new(d.id).chrome_has_focus(&d.ctx, &d.layout), "{hit:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_windows_stay_inside_the_float_bounds() {
|
||||
// The app's title bar strip (top 30 pt) is outside the bounds: a window saved there is
|
||||
// drawn below it, and a title bar drag towards it stops at its edge. Command keeps the
|
||||
// drag from docking.
|
||||
let mut layout = docked(&[&[1]], false);
|
||||
layout.floating.push(Floating::single(vec![5], 0, [400.0, 0.0, 240.0, 200.0]));
|
||||
let mut d = DockDriver::new(layout, Content);
|
||||
d.float_bounds = Some(Rect::from_min_max(pos2(0.0, 30.0), pos2(800.0, 600.0)));
|
||||
d.settle();
|
||||
let title = hit(&d, Hit::Title(5));
|
||||
assert!(title.top() >= 30.0, "{title:?}");
|
||||
d.modifiers = egui::Modifiers::COMMAND;
|
||||
d.drag(title.center(), pos2(title.center().x, 2.0));
|
||||
d.modifiers = egui::Modifiers::NONE;
|
||||
d.settle();
|
||||
let title = hit(&d, Hit::Title(5));
|
||||
assert!(title.top() >= 30.0, "{title:?}");
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! One keyed hook for every label in [`DockStyle`].
|
||||
|
||||
use super::DockStyle;
|
||||
|
||||
impl DockStyle {
|
||||
/// Replace every `*_label` field with `translate(key)`, keeping the English default where it
|
||||
/// returns `None`. The defaults of menu items are in title case; those of controls, which
|
||||
/// only screen readers announce, in sentence case. An application translates the docking
|
||||
/// chrome in one place:
|
||||
///
|
||||
/// ```
|
||||
/// fn dock_style(ui: &egui::Ui) -> craft_ui::docking::DockStyle {
|
||||
/// craft_ui::docking::DockStyle::from_ui(ui).localized(|key| match key {
|
||||
/// "dock.close" => Some("Fermer le panneau".to_owned()),
|
||||
/// _ => None,
|
||||
/// })
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// | Key | Field | English default |
|
||||
/// |---|---|---|
|
||||
/// | `dock.float` | `float_label` | Float Panel |
|
||||
/// | `dock.close` | `close_label` | Close Panel |
|
||||
/// | `dock.movePanelTo` | `move_panel_to_label` | Move Panel to Group |
|
||||
/// | `dock.panels` | `panels_label` | Panels |
|
||||
/// | `dock.resize` | `resize_label` | Resize panels |
|
||||
/// | `dock.resizeWindow` | `resize_window_label` | Resize panel window |
|
||||
/// | `dock.moveGroup` | `move_group_label` | Move tab group |
|
||||
/// | `dock.closeGroup` | `close_group_label` | Close Tab Group |
|
||||
/// | `dock.minimize` | `minimize_label` | Minimize |
|
||||
/// | `dock.expand` | `expand_label` | Expand |
|
||||
/// | `dock.floatGroup` | `float_group_label` | Float Tab Group |
|
||||
/// | `dock.moveGroupTo` | `move_group_to_label` | Move Group To |
|
||||
/// | `dock.moveColumn` | `move_column_label` | Move panel column |
|
||||
/// | `dock.collapseToIcons` | `collapse_to_icons_label` | Collapse to Icons |
|
||||
/// | `dock.expandPanels` | `expand_panels_label` | Expand Panels |
|
||||
/// | `dock.collapseColumn` | `collapse_column_label` | Collapse column to icons |
|
||||
/// | `dock.expandColumn` | `expand_column_label` | Expand column |
|
||||
/// | `dock.moveWindow` | `move_window_label` | Move panel window |
|
||||
/// | `dock.closeWindow` | `close_window_label` | Close panel window |
|
||||
/// | `dock.closeOthers` | `close_others_label` | Close Other Panels in Group |
|
||||
/// | `dock.maximize` | `maximize_label` | Maximize Frame |
|
||||
/// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size |
|
||||
/// | `dock.autoCollapse` | `auto_collapse_label` | Auto-Collapse Iconic Panels |
|
||||
/// | `dock.floatHint` | `float_hint_label` | Hold {key} to undock |
|
||||
#[must_use]
|
||||
pub fn localized(mut self, translate: impl Fn(&str) -> Option<String>) -> Self {
|
||||
for (key, label) in self.labels_mut() {
|
||||
if let Some(text) = translate(key) {
|
||||
*label = text;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn labels_mut(&mut self) -> [(&'static str, &mut String); 24] {
|
||||
[
|
||||
("dock.float", &mut self.float_label),
|
||||
("dock.close", &mut self.close_label),
|
||||
("dock.movePanelTo", &mut self.move_panel_to_label),
|
||||
("dock.panels", &mut self.panels_label),
|
||||
("dock.resize", &mut self.resize_label),
|
||||
("dock.resizeWindow", &mut self.resize_window_label),
|
||||
("dock.moveGroup", &mut self.move_group_label),
|
||||
("dock.closeGroup", &mut self.close_group_label),
|
||||
("dock.minimize", &mut self.minimize_label),
|
||||
("dock.expand", &mut self.expand_label),
|
||||
("dock.floatGroup", &mut self.float_group_label),
|
||||
("dock.moveGroupTo", &mut self.move_group_to_label),
|
||||
("dock.moveColumn", &mut self.move_column_label),
|
||||
("dock.collapseToIcons", &mut self.collapse_to_icons_label),
|
||||
("dock.expandPanels", &mut self.expand_panels_label),
|
||||
("dock.collapseColumn", &mut self.collapse_column_label),
|
||||
("dock.expandColumn", &mut self.expand_column_label),
|
||||
("dock.moveWindow", &mut self.move_window_label),
|
||||
("dock.closeWindow", &mut self.close_window_label),
|
||||
("dock.closeOthers", &mut self.close_others_label),
|
||||
("dock.maximize", &mut self.maximize_label),
|
||||
("dock.restoreFrame", &mut self.restore_frame_label),
|
||||
("dock.autoCollapse", &mut self.auto_collapse_label),
|
||||
("dock.floatHint", &mut self.float_hint_label),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,55 @@
|
||||
//! Validate deserialized layouts before rendering them. Every transaction checks its complete
|
||||
//! input and commits only a valid result; a rejected move cannot lose the source panel.
|
||||
|
||||
pub mod chrome;
|
||||
#[cfg(feature = "serde")]
|
||||
mod command;
|
||||
#[cfg(feature = "native-windows")]
|
||||
mod desktop;
|
||||
mod dock_model;
|
||||
mod hits;
|
||||
mod labels;
|
||||
mod model;
|
||||
#[cfg(feature = "native-windows")]
|
||||
mod placement;
|
||||
mod render;
|
||||
mod resize;
|
||||
mod surface;
|
||||
mod targets;
|
||||
#[cfg(any(test, feature = "testing"))]
|
||||
pub mod testing;
|
||||
mod workspace;
|
||||
|
||||
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
|
||||
pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag};
|
||||
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect};
|
||||
#[cfg(feature = "serde")]
|
||||
pub use command::{DockCommand, DockOperation, UiDock, ZoneName};
|
||||
pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target};
|
||||
pub use hits::{Divider, Hit, ResizeEdge};
|
||||
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Monitor, Node, Permissions, Placement, StackEntry, Zone};
|
||||
pub use render::{close_flyout, flyout, open_flyout};
|
||||
pub use surface::{
|
||||
DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits,
|
||||
WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging,
|
||||
};
|
||||
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape};
|
||||
pub use workspace::Workspace;
|
||||
|
||||
use surface::{Drag, GroupDrag, GroupGesture};
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_tests;
|
||||
#[cfg(test)]
|
||||
mod dock_tests;
|
||||
#[cfg(test)]
|
||||
mod floating_tests;
|
||||
#[cfg(test)]
|
||||
mod group_tests;
|
||||
#[cfg(test)]
|
||||
mod iconic_tests;
|
||||
#[cfg(test)]
|
||||
mod model_tests;
|
||||
#[cfg(all(test, feature = "native-windows"))]
|
||||
mod native_tests;
|
||||
#[cfg(test)]
|
||||
mod split_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,756 @@
|
||||
use super::dock_model::{Place, node_at};
|
||||
use super::*;
|
||||
use crate::layout::{SplitAxis, SplitSize};
|
||||
|
||||
pub(super) fn g(panels: &[u32], active: usize) -> Group<u32> {
|
||||
Group::new(panels.to_vec(), active)
|
||||
}
|
||||
|
||||
fn tabs(panels: &[u32], active: usize) -> Node<u32> {
|
||||
Node::Tabs { panels: panels.to_vec(), active }
|
||||
}
|
||||
|
||||
fn hsplit(size: SplitSize, first: Node<u32>, second: Node<u32>) -> Node<u32> {
|
||||
Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
/// Canvas 1 above frame tabs [11, 12]; an end dock with column A ([2, 3], [4]) and column B
|
||||
/// ([5]); a floating window with groups [6, 7] and [8].
|
||||
pub(super) fn docked() -> Layout<u32> {
|
||||
let middle =
|
||||
Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(0.7), first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[11, 12], 1)) };
|
||||
let dock =
|
||||
Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[2, 3], 1), g(&[4], 0)], 180.0), Column::new(vec![g(&[5], 0)], 120.0)] };
|
||||
Layout {
|
||||
root: Some(hsplit(SplitSize::FixedSecond(300.0), middle, dock)),
|
||||
floating: vec![Floating::stack(vec![g(&[6, 7], 1), g(&[8], 0)], [100.0, 100.0, 300.0, 400.0]).unwrap()],
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const ALL: [u32; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 11, 12];
|
||||
|
||||
fn sorted(layout: &Layout<u32>) -> Vec<u32> {
|
||||
let mut panels: Vec<u32> = layout.panels().into_iter().copied().collect();
|
||||
panels.sort_unstable();
|
||||
panels
|
||||
}
|
||||
|
||||
/// Members, active panel and minimized state of the group holding `panel`.
|
||||
fn group_state(layout: &Layout<u32>, panel: u32) -> (Vec<u32>, u32, bool) {
|
||||
match layout.place(&panel).unwrap() {
|
||||
place @ (Place::Node(_) | Place::Floating { .. }) => match layout.node_of(&place).unwrap() {
|
||||
Node::Tabs { panels, active } => (panels.clone(), panels[*active], false),
|
||||
other => panic!("{other:?}"),
|
||||
},
|
||||
place => {
|
||||
let group = layout.group(&place).unwrap();
|
||||
(group.panels.clone(), group.panels[group.active], group.minimized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dock(layout: &Layout<u32>, side: Side) -> &Vec<Column<u32>> {
|
||||
let path = layout.dock_path(side).unwrap();
|
||||
let Some(Node::Dock { columns, .. }) = node_at(layout.root.as_ref().unwrap(), &path) else { panic!() };
|
||||
columns
|
||||
}
|
||||
|
||||
fn panels_of(groups: &[Group<u32>]) -> Vec<Vec<u32>> {
|
||||
groups.iter().map(|g| g.panels.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_moves_keep_order_active_tab_and_minimized_state() {
|
||||
let targets = [
|
||||
Target::At { anchor: 5, placement: Placement::Group { below: true, height: None } },
|
||||
Target::At { anchor: 5, placement: Placement::Column { after: true } },
|
||||
Target::At { anchor: 8, placement: Placement::Group { below: false, height: None } },
|
||||
Target::NewDock { side: Side::Start },
|
||||
Target::Float { rect: Some([20.0, 20.0, 200.0, 300.0]) },
|
||||
Target::Float { rect: None },
|
||||
];
|
||||
// A frame Tabs node, a column group and a floating group.
|
||||
for source in [11, 2, 6] {
|
||||
for to in &targets {
|
||||
let mut layout = docked();
|
||||
if source != 11 {
|
||||
layout.apply(Action::SetMinimized { panel: source, minimized: true }).unwrap();
|
||||
}
|
||||
let before = group_state(&layout, source);
|
||||
layout.apply(Action::MoveGroup { panel: source, to: to.clone() }).unwrap_or_else(|e| panic!("{source} {to:?}: {e}"));
|
||||
let after = group_state(&layout, source);
|
||||
assert_eq!((&after.0, after.1), (&before.0, before.1), "{source} {to:?}");
|
||||
// A group keeps its minimized state from dock to dock; a floating window's tree
|
||||
// has no minimized groups.
|
||||
if matches!((source, layout.place(&source)), (2, Some(Place::Dock { .. }))) {
|
||||
assert_eq!(after.2, before.2, "{source} {to:?}");
|
||||
}
|
||||
assert_eq!(sorted(&layout), ALL);
|
||||
}
|
||||
}
|
||||
// Into a frame split the group becomes a Tabs node with the same order and selection.
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::MoveGroup { panel: 6, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }).unwrap();
|
||||
assert_eq!(group_state(&layout, 6), (vec![6, 7], 7, false));
|
||||
assert_eq!(layout.floating[0].groups().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_merge_into_tabs_inserts_every_member_before_the_anchor_tab() {
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::MoveGroup { panel: 6, to: Target::At { anchor: 2, placement: Placement::Tab { before: Some(3) } } }).unwrap();
|
||||
assert_eq!(group_state(&layout, 2), (vec![2, 6, 7, 3], 7, false));
|
||||
assert_eq!(panels_of(&layout.floating[0].groups()), [vec![8]]);
|
||||
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 11, placement: Placement::Tab { before: Some(12) } } }).unwrap();
|
||||
assert_eq!(group_state(&layout, 11), (vec![11, 2, 3, 12], 3, false));
|
||||
assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![4]]);
|
||||
|
||||
// A target inside the moving group is a no-op; a protected anchor refuses tabs.
|
||||
let before = docked();
|
||||
let mut layout = before.clone();
|
||||
layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 3, placement: Placement::Tab { before: None } } }).unwrap();
|
||||
assert_eq!(layout, before);
|
||||
let protect = |p: &u32| if *p == 1 { Permissions::PROTECTED } else { Permissions::default() };
|
||||
let to = Target::At { anchor: 1, placement: Placement::Tab { before: None } };
|
||||
assert_eq!(layout.apply_with_permissions(Action::MoveGroup { panel: 2, to }, protect), Err(DockError::NotAllowed));
|
||||
assert_eq!(layout, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_moves_and_floats_keep_every_group() {
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::SetIconic { panel: 4, iconic: true }).unwrap();
|
||||
layout.apply(Action::MoveColumn { panel: 2, to: Target::NewDock { side: Side::Start } }).unwrap();
|
||||
let Some(Node::Split { size, first, .. }) = &layout.root else { panic!() };
|
||||
assert_eq!(*size, SplitSize::FixedFirst(180.0));
|
||||
assert!(matches!(**first, Node::Dock { side: Side::Start, .. }));
|
||||
let start = dock(&layout, Side::Start);
|
||||
assert_eq!(start.len(), 1);
|
||||
assert_eq!(panels_of(&start[0].groups), [vec![2, 3], vec![4]]);
|
||||
assert_eq!((start[0].width, start[0].iconic, start[0].groups[0].active), (180.0, true, 1));
|
||||
assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![5]]);
|
||||
|
||||
// The last column leaves the end dock, which collapses.
|
||||
layout.apply(Action::MoveColumn { panel: 5, to: Target::At { anchor: 2, placement: Placement::Column { after: false } } }).unwrap();
|
||||
assert!(layout.dock_path(Side::End).is_none());
|
||||
let start = dock(&layout, Side::Start);
|
||||
assert_eq!(start.iter().map(|c| c.width).collect::<Vec<_>>(), [120.0, 180.0]);
|
||||
|
||||
layout.apply(Action::MoveColumn { panel: 3, to: Target::Float { rect: None } }).unwrap();
|
||||
let window = layout.floating.last().unwrap();
|
||||
assert_eq!(panels_of(&window.groups()), [vec![2, 3], vec![4]]);
|
||||
assert_eq!(window.rect[2], 180.0);
|
||||
assert_eq!(sorted(&layout), ALL);
|
||||
assert_eq!(layout.apply(Action::MoveColumn { panel: 11, to: Target::Float { rect: None } }), Err(DockError::InvalidPlacement));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_docks_as_stacked_groups_or_merges_as_tabs() {
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 4, placement: Placement::Group { below: true, height: None } } }).unwrap();
|
||||
assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![2, 3], vec![4], vec![6, 7], vec![8]]);
|
||||
assert!(layout.floating.is_empty());
|
||||
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 11, placement: Placement::Tab { before: None } } }).unwrap();
|
||||
assert_eq!(group_state(&layout, 11), (vec![11, 12, 6, 7, 8], 7, false));
|
||||
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::MoveWindow { panel: 6, to: Target::NewDock { side: Side::Start } }).unwrap();
|
||||
let start = dock(&layout, Side::Start);
|
||||
assert_eq!((panels_of(&start[0].groups), start[0].width), (vec![vec![6, 7], vec![8]], 300.0));
|
||||
|
||||
let before = docked();
|
||||
let mut layout = before.clone();
|
||||
layout.apply(Action::MoveWindow { panel: 7, to: Target::At { anchor: 8, placement: Placement::Tab { before: None } } }).unwrap();
|
||||
assert_eq!(layout, before);
|
||||
assert_eq!(layout.apply(Action::MoveWindow { panel: 2, to: Target::Float { rect: None } }), Err(DockError::InvalidPlacement));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_group_and_close_window_record_a_restorable_location_per_member() {
|
||||
let mut workspace = Workspace::new(docked());
|
||||
let allow = |_: &u32| Permissions::default();
|
||||
assert_eq!(workspace.apply(Action::CloseGroup { panel: 3 }, allow).unwrap(), [2, 3]);
|
||||
assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [2, 3]);
|
||||
assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![4]]);
|
||||
workspace.set_visible(2, true, None, |_| Permissions::default()).unwrap();
|
||||
workspace.set_visible(3, true, None, |_| Permissions::default()).unwrap();
|
||||
assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![2, 3], vec![4]]);
|
||||
|
||||
assert_eq!(workspace.layout.closed_by(&Action::CloseWindow { panel: 7 }).unwrap(), [6, 7, 8]);
|
||||
assert_eq!(workspace.apply(Action::CloseWindow { panel: 7 }, allow).unwrap(), [6, 7, 8]);
|
||||
assert!(workspace.layout.floating.is_empty());
|
||||
assert_eq!(workspace.hidden.len(), 3);
|
||||
for panel in [6, 7, 8] {
|
||||
workspace.set_visible(panel, true, None, |_| Permissions::default()).unwrap();
|
||||
}
|
||||
assert_eq!(workspace.layout.floating.len(), 1);
|
||||
assert_eq!(panels_of(&workspace.layout.floating[0].groups()), [vec![6, 7], vec![8]]);
|
||||
assert_eq!(workspace.layout.floating[0].rect, [100.0, 100.0, 300.0, 400.0]);
|
||||
|
||||
workspace.apply(Action::CloseOthers { panel: 12 }, allow).unwrap();
|
||||
assert_eq!(group_state(&workspace.layout, 12).0, [12]);
|
||||
workspace.set_visible(11, true, None, |_| Permissions::default()).unwrap();
|
||||
assert_eq!(group_state(&workspace.layout, 12).0, [11, 12]);
|
||||
assert!(workspace.hidden.is_empty());
|
||||
assert_eq!(sorted(&workspace.layout), ALL);
|
||||
|
||||
// A member that may not close keeps the whole group open.
|
||||
let keep = |p: &u32| if *p == 4 { Permissions { closable: false, ..Permissions::default() } } else { Permissions::default() };
|
||||
let before = workspace.clone();
|
||||
assert_eq!(workspace.apply(Action::CloseGroup { panel: 4 }, keep), Err(DockError::NotAllowed));
|
||||
assert_eq!(workspace, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_closed_one_by_one_reopen_into_one_group() {
|
||||
let allow = |_: &u32| Permissions::default();
|
||||
let rect = [100.0, 100.0, 300.0, 400.0];
|
||||
let cycle = |workspace: &mut Workspace<u32>, close: &[u32], reopen: &[u32]| {
|
||||
for &panel in close {
|
||||
workspace.set_visible(panel, false, None, allow).unwrap();
|
||||
}
|
||||
for &panel in reopen {
|
||||
workspace.set_visible(panel, true, None, allow).unwrap_or_else(|e| panic!("{close:?} {reopen:?} {panel}: {e}"));
|
||||
}
|
||||
assert!(workspace.hidden.is_empty());
|
||||
};
|
||||
let orders2 = [[2, 3], [3, 2]];
|
||||
for close in orders2 {
|
||||
for reopen in orders2 {
|
||||
let mut workspace = Workspace::new(docked());
|
||||
cycle(&mut workspace, &close, &reopen);
|
||||
let columns = dock(&workspace.layout, Side::End);
|
||||
assert_eq!(
|
||||
columns.iter().map(|c| panels_of(&c.groups)).collect::<Vec<_>>(),
|
||||
[vec![vec![2, 3], vec![4]], vec![vec![5]]],
|
||||
"{close:?} {reopen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let orders2 = [[10, 20], [20, 10]];
|
||||
for close in orders2 {
|
||||
for reopen in orders2 {
|
||||
let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![10, 20], 0, rect)] });
|
||||
cycle(&mut workspace, &close, &reopen);
|
||||
let [window] = &workspace.layout.floating[..] else { panic!("{close:?} {reopen:?}: {:?}", workspace.layout.floating) };
|
||||
assert_eq!((window.panels().copied().collect::<Vec<_>>(), window.rect), (vec![10, 20], rect), "{close:?} {reopen:?}");
|
||||
}
|
||||
}
|
||||
// Three tabs: the locations do not always record the order, only the window.
|
||||
let orders3 = [[10, 20, 30], [10, 30, 20], [20, 10, 30], [20, 30, 10], [30, 10, 20], [30, 20, 10]];
|
||||
for close in orders3 {
|
||||
for reopen in orders3 {
|
||||
let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![10, 20, 30], 0, rect)] });
|
||||
cycle(&mut workspace, &close, &reopen);
|
||||
let [window] = &workspace.layout.floating[..] else { panic!("{close:?} {reopen:?}: {:?}", workspace.layout.floating) };
|
||||
let mut panels: Vec<u32> = window.panels().copied().collect();
|
||||
panels.sort_unstable();
|
||||
assert_eq!(panels, [10, 20, 30], "{close:?} {reopen:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_reopened_through_a_hidden_neighbour_keeps_its_own_group_state() {
|
||||
let allow = |_: &u32| Permissions::default();
|
||||
let dock_of = |columns: Vec<Column<u32>>| Layout {
|
||||
root: Some(hsplit(SplitSize::FixedSecond(480.0), tabs(&[1], 0), Node::Dock { side: Side::End, columns })),
|
||||
floating: Vec::new(),
|
||||
};
|
||||
// Groups [2] and [3] stacked with their own heights: 2 comes back at its height, not 3's.
|
||||
let stacked = dock_of(vec![
|
||||
Column::new(vec![Group { height: Some(120.0), ..g(&[2], 0) }, Group { height: Some(300.0), ..g(&[3], 0) }], 240.0),
|
||||
Column::new(vec![g(&[5], 0)], 240.0),
|
||||
]);
|
||||
let mut workspace = Workspace::new(stacked);
|
||||
workspace.set_visible(2, false, None, allow).unwrap();
|
||||
workspace.set_visible(3, false, None, allow).unwrap();
|
||||
workspace.set_visible(2, true, None, allow).unwrap();
|
||||
let group = |w: &Workspace<u32>, panel: u32| {
|
||||
dock(&w.layout, Side::End).iter().flat_map(|c| c.groups.iter()).find(|g| g.panels.contains(&panel)).cloned().unwrap()
|
||||
};
|
||||
assert_eq!(group(&workspace, 2).height, Some(120.0), "{:?}", workspace.layout);
|
||||
workspace.set_visible(3, true, None, allow).unwrap();
|
||||
assert_eq!(group(&workspace, 3).height, Some(300.0), "{:?}", workspace.layout);
|
||||
|
||||
// [2] beside an icon-only column holding [3]: 2 comes back in a column of its own width,
|
||||
// not as an icon.
|
||||
let iconic = Column { iconic: true, ..Column::new(vec![g(&[3], 0)], 200.0) };
|
||||
let mut workspace = Workspace::new(dock_of(vec![Column::new(vec![g(&[2], 0)], 260.0), iconic]));
|
||||
workspace.set_visible(2, false, None, allow).unwrap();
|
||||
workspace.set_visible(3, false, None, allow).unwrap();
|
||||
workspace.set_visible(2, true, None, allow).unwrap();
|
||||
let column = dock(&workspace.layout, Side::End).iter().find(|c| c.groups.iter().any(|g| g.panels.contains(&2))).cloned().unwrap();
|
||||
assert!(!column.iconic, "{:?}", workspace.layout);
|
||||
assert_eq!(column.width, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_closed_one_by_one_reopen_on_their_own_sides() {
|
||||
let allow = |_: &u32| Permissions::default();
|
||||
let orders = [[2, 3], [3, 2]];
|
||||
let frames = Layout {
|
||||
root: Some(hsplit(SplitSize::Ratio(0.3), tabs(&[1], 0), hsplit(SplitSize::Ratio(0.5), tabs(&[2], 0), tabs(&[3], 0)))),
|
||||
floating: Vec::new(),
|
||||
};
|
||||
let columns = Layout {
|
||||
root: Some(hsplit(
|
||||
SplitSize::FixedSecond(480.0),
|
||||
tabs(&[1], 0),
|
||||
Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[2], 0)], 240.0), Column::new(vec![g(&[3], 0)], 240.0)] },
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
};
|
||||
for close in orders {
|
||||
for reopen in orders {
|
||||
let mut workspace = Workspace::new(frames.clone());
|
||||
for (panel, visible) in close.iter().map(|p| (*p, false)).chain(reopen.iter().map(|p| (*p, true))) {
|
||||
workspace.set_visible(panel, visible, None, allow).unwrap();
|
||||
}
|
||||
let order: Vec<u32> = workspace.layout.panels().into_iter().copied().collect();
|
||||
assert_eq!(order, [1, 2, 3], "{close:?} {reopen:?}: {:?}", workspace.layout);
|
||||
assert!(order.iter().all(|p| matches!(workspace.layout.place(p), Some(Place::Node(_)))));
|
||||
assert_ne!(workspace.layout.place(&2), workspace.layout.place(&3), "{close:?} {reopen:?}");
|
||||
|
||||
let mut workspace = Workspace::new(columns.clone());
|
||||
for (panel, visible) in close.iter().map(|p| (*p, false)).chain(reopen.iter().map(|p| (*p, true))) {
|
||||
workspace.set_visible(panel, visible, None, allow).unwrap();
|
||||
}
|
||||
let groups: Vec<_> = dock(&workspace.layout, Side::End).iter().map(|c| panels_of(&c.groups)).collect();
|
||||
assert_eq!(groups, [vec![vec![2]], vec![vec![3]]], "{close:?} {reopen:?}");
|
||||
}
|
||||
}
|
||||
// A panel that sat at the edge beside a hidden one keeps that one's own location: 3 opens
|
||||
// right of 1, not as a tab beside 2.
|
||||
let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() });
|
||||
let at = |anchor: u32, placement: Placement<u32>| Location {
|
||||
floating: None,
|
||||
anchor: Some(anchor),
|
||||
placement,
|
||||
size: None,
|
||||
stack: None,
|
||||
sibling: Vec::new(),
|
||||
group: None,
|
||||
};
|
||||
workspace.hidden = vec![(2, at(3, Placement::Edge(Zone::Right))), (3, at(1, Placement::Split(Zone::Right)))];
|
||||
workspace.set_visible(2, true, None, allow).unwrap();
|
||||
workspace.set_visible(3, true, None, allow).unwrap();
|
||||
assert!(matches!(workspace.layout.place(&3), Some(Place::Node(_))));
|
||||
assert_ne!(workspace.layout.place(&2), workspace.layout.place(&3), "{:?}", workspace.layout);
|
||||
// Past the edge frame the chain keeps its forward anchors: 4 does not anchor on 3 while 3
|
||||
// anchors on 4, and both still reopen.
|
||||
let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() });
|
||||
workspace.hidden =
|
||||
vec![(2, at(3, Placement::Edge(Zone::Right))), (3, at(4, Placement::Split(Zone::Right))), (4, at(1, Placement::Split(Zone::Right)))];
|
||||
workspace.set_visible(2, true, None, allow).unwrap();
|
||||
assert_eq!(workspace.hidden.iter().find(|(p, _)| *p == 4).and_then(|(_, l)| l.anchor), Some(1), "{:?}", workspace.hidden);
|
||||
workspace.set_visible(3, true, None, allow).unwrap();
|
||||
workspace.set_visible(4, true, None, allow).unwrap();
|
||||
assert!(workspace.hidden.is_empty() && [1, 2, 3, 4].iter().all(|p| workspace.layout.contains(p)), "{:?}", workspace.layout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_into_a_column_group_seam_and_new_column_round_trips() {
|
||||
let column_a = Column::new(vec![g(&[2, 3], 1), Group { minimized: true, height: Some(150.0), ..g(&[4], 0) }, g(&[9], 0)], 180.0);
|
||||
let column_b = Column { iconic: true, iconic_width: Some(48.0), ..Column::new(vec![g(&[5], 0)], 120.0) };
|
||||
let end = Node::Dock { side: Side::End, columns: vec![column_a, column_b] };
|
||||
let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![g(&[10], 0)], 200.0)] };
|
||||
let original = Layout {
|
||||
root: Some(hsplit(SplitSize::FixedFirst(200.0), start, hsplit(SplitSize::FixedSecond(300.0), tabs(&[1], 0), end))),
|
||||
floating: vec![Floating::stack(vec![g(&[6], 0), Group { height: Some(90.0), ..g(&[7], 0) }], [10.0, 10.0, 200.0, 300.0]).unwrap()],
|
||||
};
|
||||
original.validate().unwrap();
|
||||
// 4: the seam between [2, 3] and [9]; 9: below [4]; 5: a new column after A; 10: alone in
|
||||
// its dock; 3: a tab; 7: below a floating group.
|
||||
for panel in [4, 9, 5, 10, 3, 7] {
|
||||
let mut layout = original.clone();
|
||||
let location = layout.location(&panel).unwrap();
|
||||
#[cfg(feature = "serde")]
|
||||
let location: Location<u32> = serde_json::from_str(&serde_json::to_string(&location).unwrap()).unwrap();
|
||||
layout.apply(Action::Close { panel }).unwrap();
|
||||
layout.restore(panel, &location).unwrap_or_else(|e| panic!("{panel}: {e} {location:?}"));
|
||||
assert_eq!(layout, original, "{panel}: {location:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_validation_rejects_two_docks_per_side_nested_docks_empty_columns_and_bad_widths() {
|
||||
docked().validate().unwrap();
|
||||
let empty_dock = |side| Node::Dock { side, columns: Vec::new() };
|
||||
Layout { root: Some(hsplit(SplitSize::FixedFirst(10.0), empty_dock(Side::Start), tabs(&[1], 0))), floating: Vec::new() }.validate().unwrap();
|
||||
|
||||
let two = hsplit(SplitSize::Ratio(0.5), empty_dock(Side::Start), empty_dock(Side::Start));
|
||||
let nested = Node::Split {
|
||||
axis: SplitAxis::Vertical,
|
||||
size: SplitSize::Ratio(0.5),
|
||||
first: Box::new(empty_dock(Side::Start)),
|
||||
second: Box::new(tabs(&[1], 0)),
|
||||
};
|
||||
let wrong_side = hsplit(SplitSize::Ratio(0.5), empty_dock(Side::End), tabs(&[1], 0));
|
||||
for root in [two, nested, wrong_side] {
|
||||
assert_eq!(Layout { root: Some(root), floating: Vec::new() }.validate(), Err(DockError::InvalidPlacement));
|
||||
}
|
||||
|
||||
let with_column = |column: Column<u32>| Layout { root: Some(Node::Dock { side: Side::End, columns: vec![column] }), floating: Vec::new() };
|
||||
let bad = [
|
||||
Column::new(Vec::new(), 100.0),
|
||||
Column::new(vec![g(&[], 0)], 100.0),
|
||||
Column::new(vec![g(&[2], 1)], 100.0),
|
||||
Column::new(vec![Group { height: Some(f32::NAN), ..g(&[2], 0) }], 100.0),
|
||||
Column::new(vec![g(&[2], 0)], 0.0),
|
||||
Column::new(vec![g(&[2], 0)], -5.0),
|
||||
Column::new(vec![g(&[2], 0)], f32::INFINITY),
|
||||
Column::new(vec![g(&[2], 0)], 2.0e6),
|
||||
Column { iconic_width: Some(f32::NAN), ..Column::new(vec![g(&[2], 0)], 100.0) },
|
||||
];
|
||||
for column in bad {
|
||||
assert!(with_column(column.clone()).validate().is_err(), "{column:?}");
|
||||
}
|
||||
|
||||
let mut duplicate = docked();
|
||||
duplicate.floating[0].root = Node::Tabs { panels: vec![6, 7, 2], active: 0 };
|
||||
assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel));
|
||||
let mut empty_window = docked();
|
||||
empty_window.floating[0].root = Node::Tabs { panels: Vec::new(), active: 0 };
|
||||
assert_eq!(empty_window.validate(), Err(DockError::InvalidGeometry));
|
||||
let mut docked_window = docked();
|
||||
docked_window.floating[0].root = Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[6, 7], 0)], 100.0)] };
|
||||
assert_eq!(docked_window.validate(), Err(DockError::InvalidPlacement));
|
||||
let mut native = docked();
|
||||
native.floating[0].native = Some([0.0, 0.0, -1.0, 10.0]);
|
||||
assert_eq!(native.validate(), Err(DockError::InvalidGeometry));
|
||||
let mut monitor = docked();
|
||||
monitor.floating[0].monitor = Some(super::Monitor { name: None, rect: [0.0, 0.0, 1920.0, 1080.0], scale: f32::NAN });
|
||||
assert_eq!(monitor.validate(), Err(DockError::InvalidGeometry));
|
||||
let mut monitor_size = docked();
|
||||
monitor_size.floating[0].monitor_size = Some([f32::NAN, 1080.0]);
|
||||
assert_eq!(monitor_size.validate(), Err(DockError::InvalidGeometry));
|
||||
let mut layout = docked();
|
||||
assert_eq!(layout.apply(Action::ResizeColumn { panel: 2, width: f32::NAN }), Err(DockError::InvalidGeometry));
|
||||
assert_eq!(layout.apply(Action::ResizeColumn { panel: 11, width: 50.0 }), Err(DockError::InvalidPlacement));
|
||||
assert_eq!(layout, docked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_workspace_records_its_native_windows() {
|
||||
let mut workspace = Workspace::new(docked());
|
||||
workspace.locked = true;
|
||||
for rect in [[0.0, 0.0, 300.0, 400.0], [40.0, 30.0, 300.0, 400.0]] {
|
||||
workspace.apply(Action::SetNative { panel: 6, rect: Some(rect) }, |_| Permissions::default()).unwrap();
|
||||
assert_eq!(workspace.layout.floating[0].native, Some(rect));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resize() {
|
||||
let mut workspace = Workspace::new(docked());
|
||||
let allow = |_: &u32| Permissions::default();
|
||||
workspace.apply(Action::Close { panel: 12 }, allow).unwrap();
|
||||
workspace.locked = true;
|
||||
let before = workspace.clone();
|
||||
let refused = [
|
||||
Action::Move { panel: 2, anchor: 5, placement: Placement::Tab { before: None } },
|
||||
Action::MoveGroup { panel: 2, to: Target::Float { rect: None } },
|
||||
Action::MoveColumn { panel: 5, to: Target::NewDock { side: Side::Start } },
|
||||
Action::MoveWindow { panel: 6, to: Target::At { anchor: 4, placement: Placement::Group { below: true, height: None } } },
|
||||
Action::Float { panel: 4, rect: [0.0, 0.0, 100.0, 100.0] },
|
||||
Action::MoveFloating { panel: 6, rect: [0.0, 0.0, 100.0, 100.0] },
|
||||
Action::Close { panel: 4 },
|
||||
Action::CloseGroup { panel: 4 },
|
||||
Action::CloseWindow { panel: 6 },
|
||||
];
|
||||
for action in refused {
|
||||
assert_eq!(workspace.apply(action.clone(), allow), Err(DockError::NotAllowed), "{action:?}");
|
||||
assert_eq!(workspace, before);
|
||||
}
|
||||
assert_eq!(workspace.set_visible(4, false, None, |_| Permissions::default()), Err(DockError::NotAllowed));
|
||||
let allowed = [
|
||||
Action::Activate { panel: 2 },
|
||||
Action::Open { panel: 12, anchor: Some(11) },
|
||||
Action::SetMinimized { panel: 4, minimized: true },
|
||||
Action::SetIconic { panel: 5, iconic: true },
|
||||
Action::ResizeColumn { panel: 5, width: 160.0 },
|
||||
Action::ResizeGroup { panel: 4, height: Some(120.0) },
|
||||
Action::ResizeIconic { panel: 5, width: Some(60.0) },
|
||||
Action::ResizeSplit { path: Vec::new(), size: SplitSize::FixedSecond(320.0) },
|
||||
];
|
||||
workspace.apply_all(allowed, allow).unwrap();
|
||||
assert!(workspace.hidden.is_empty());
|
||||
assert_eq!(workspace.layout.panel_state(&12), PanelState::Front);
|
||||
assert_eq!(workspace.layout.panel_state(&4), PanelState::Open);
|
||||
assert_eq!(workspace.layout.panel_state(&5), PanelState::Open);
|
||||
assert_eq!(workspace.layout.panel_state(&2), PanelState::Front);
|
||||
assert_eq!(workspace.layout.panel_state(&3), PanelState::Open);
|
||||
// Opening at a chosen place works too; moving a shown panel there does not.
|
||||
workspace.apply(Action::OpenAt { panel: 13, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow).unwrap();
|
||||
assert!(workspace.layout.contains(&13));
|
||||
assert_eq!(
|
||||
workspace.apply(Action::OpenAt { panel: 2, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow),
|
||||
Err(DockError::NotAllowed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_a_panel_that_may_not_close_is_refused() {
|
||||
let mut workspace = Workspace::new(docked());
|
||||
let canvas = |p: &u32| if *p == 1 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() };
|
||||
assert_eq!(workspace.set_visible(1, false, None, canvas), Err(DockError::NotAllowed));
|
||||
assert!(workspace.layout.contains(&1) && workspace.hidden.is_empty());
|
||||
workspace.set_visible(2, false, None, canvas).unwrap();
|
||||
assert!(!workspace.layout.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_drops_unknown_panels_and_places_missing_registered_ones() {
|
||||
let default = Workspace::new(docked());
|
||||
let mut workspace = default.clone();
|
||||
workspace.layout.apply(Action::Close { panel: 5 }).unwrap();
|
||||
workspace.layout.apply(Action::Close { panel: 12 }).unwrap();
|
||||
workspace.layout.apply(Action::OpenAt { panel: 99, anchor: 2, placement: Placement::Tab { before: None } }).unwrap();
|
||||
let stray = workspace.layout.location(&99).unwrap();
|
||||
workspace.hidden.push((98, stray));
|
||||
let registered = |p: &u32| *p < 90;
|
||||
workspace.reconcile(registered, |_| Permissions::default(), &default);
|
||||
workspace.validate().unwrap();
|
||||
// Unregistered panels wait in `hidden` and come back once registered.
|
||||
assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [98, 99]);
|
||||
assert_eq!(sorted(&workspace.layout), ALL);
|
||||
workspace.set_visible(99, true, None, |_| Permissions::default()).unwrap();
|
||||
assert_eq!(group_state(&workspace.layout, 2).0, [2, 3, 99]);
|
||||
assert_eq!(dock(&workspace.layout, Side::End).len(), 2);
|
||||
assert_eq!(group_state(&workspace.layout, 11).0, [11, 12]);
|
||||
|
||||
let mut broken = default.clone();
|
||||
broken.layout.floating[0].rect[2] = -1.0;
|
||||
broken.reconcile(registered, |_| Permissions::default(), &default);
|
||||
assert_eq!(broken.layout, default.layout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_saved_or_default() {
|
||||
let default = docked();
|
||||
let mut workspace = Workspace::new(default.clone());
|
||||
workspace.apply(Action::MoveGroup { panel: 6, to: Target::NewDock { side: Side::Start } }, |_| Permissions::default()).unwrap();
|
||||
workspace.reset(&default);
|
||||
assert_eq!(workspace.layout, default);
|
||||
|
||||
let mut saved = default.clone();
|
||||
saved.apply(Action::Close { panel: 8 }).unwrap();
|
||||
saved.apply(Action::MoveGroup { panel: 11, to: Target::Float { rect: None } }).unwrap();
|
||||
workspace.saved = Some(saved.clone());
|
||||
workspace.reset(&default);
|
||||
assert_eq!(workspace.layout, saved);
|
||||
assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [8]);
|
||||
workspace.validate().unwrap();
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
mod serde_tests {
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn a_saved_stack_of_groups_loads_as_a_vertical_split_by_their_heights() {
|
||||
let saved = json!({
|
||||
"groups": [{"panels": [4], "active": 0, "height": 100.0}, {"panels": [5, 6], "active": 1, "height": 300.0}],
|
||||
"rect": [1.0, 2.0, 300.0, 400.0],
|
||||
});
|
||||
let window: Floating<u32> = serde_json::from_value(saved).unwrap();
|
||||
let expected = Node::Split {
|
||||
axis: SplitAxis::Vertical,
|
||||
size: SplitSize::Ratio(0.25),
|
||||
first: Box::new(Node::Tabs { panels: vec![4], active: 0 }),
|
||||
second: Box::new(Node::Tabs { panels: vec![5, 6], active: 1 }),
|
||||
};
|
||||
assert_eq!((window.root, window.minimized), (expected, false));
|
||||
let all_minimized = json!({"groups": [{"panels": [4], "active": 0, "minimized": true}], "rect": [1.0, 2.0, 300.0, 400.0]});
|
||||
let window: Floating<u32> = serde_json::from_value(all_minimized).unwrap();
|
||||
assert_eq!((window.root, window.minimized), (Node::Tabs { panels: vec![4], active: 0 }, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_plain_floating_window_writes_the_v1_shape() {
|
||||
let rect = [1.0, 2.0, 300.0, 200.0];
|
||||
let plain = Floating::single(vec![4u32, 5], 1, rect);
|
||||
assert_eq!(serde_json::to_value(&plain).unwrap(), json!({"panels": [4, 5], "active": 1, "rect": rect}));
|
||||
let minimized = Floating { minimized: true, ..Floating::single(vec![4u32], 0, rect) };
|
||||
let stacked = Floating::stack(vec![g(&[4], 0), g(&[5], 0)], rect).unwrap();
|
||||
let native = Floating { native: Some(rect), ..Floating::single(vec![4u32], 0, rect) };
|
||||
for window in [minimized, stacked, native] {
|
||||
let value = serde_json::to_value(&window).unwrap();
|
||||
assert!(value.get("root").is_some() && value.get("panels").is_none(), "{value}");
|
||||
assert_eq!(serde_json::from_value::<Floating<u32>>(value).unwrap(), window);
|
||||
}
|
||||
assert!(serde_json::from_value::<Floating<u32>>(json!({"rect": rect})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_round_trips() {
|
||||
let mut workspace = Workspace::new(docked());
|
||||
workspace.apply(Action::CloseGroup { panel: 4 }, |_| Permissions::default()).unwrap();
|
||||
workspace.apply(Action::SetNative { panel: 8, rect: Some([5.0, 5.0, 400.0, 300.0]) }, |_| Permissions::default()).unwrap();
|
||||
workspace.saved = Some(docked());
|
||||
workspace.locked = true;
|
||||
let text = serde_json::to_string(&workspace).unwrap();
|
||||
let parsed: Workspace<u32> = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(parsed, workspace);
|
||||
assert_eq!(serde_json::from_str::<Value>(&text).unwrap()["version"], json!(2));
|
||||
|
||||
let mut old = parsed.clone();
|
||||
old.version = 1;
|
||||
let mut value = serde_json::to_value(&old).unwrap();
|
||||
value.as_object_mut().unwrap().remove("version");
|
||||
assert_eq!(serde_json::from_value::<Workspace<u32>>(value).unwrap().version, 1);
|
||||
old.version = 3;
|
||||
assert_eq!(old.validate(), Err(DockError::UnsupportedVersion));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Kind {
|
||||
Layout,
|
||||
Workspace,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
/// App, file contents, and the JSON pointers to its docking values.
|
||||
type Fixture = (&'static str, &'static str, &'static [(&'static str, Kind)]);
|
||||
|
||||
#[test]
|
||||
fn v1_layout_json_parses_unchanged() {
|
||||
use Kind::*;
|
||||
let fixtures: [Fixture; 7] = [
|
||||
(
|
||||
"designcraft",
|
||||
include_str!("../../tests/fixtures/v1/designcraft.json"),
|
||||
&[
|
||||
("/docking", Layout),
|
||||
("/dockingHidden", Hidden),
|
||||
("/customWorkspaces/0/docking", Layout),
|
||||
("/customWorkspaces/0/dockingHidden", Hidden),
|
||||
],
|
||||
),
|
||||
("vectorcraft", include_str!("../../tests/fixtures/v1/vectorcraft.json"), &[("/docking", Layout), ("/docking_hidden", Hidden)]),
|
||||
("lightcraft", include_str!("../../tests/fixtures/v1/lightcraft.json"), &[("/docking", Workspace)]),
|
||||
("pdfcraft", include_str!("../../tests/fixtures/v1/pdfcraft.json"), &[("/docking", Workspace)]),
|
||||
("cadcraft", include_str!("../../tests/fixtures/v1/cadcraft.json"), &[("/workspace/docking", Workspace)]),
|
||||
("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), &[("/docking", Workspace), ("/mix_docking", Workspace)]),
|
||||
(
|
||||
"photocraft",
|
||||
include_str!("../../tests/fixtures/v1/photocraft.json"),
|
||||
&[("/docking", Layout), ("/docking_hidden", Hidden), ("/panel_layout/docking", Layout), ("/panel_layout/dockingHidden", Hidden)],
|
||||
),
|
||||
];
|
||||
for (app, text, fields) in fixtures {
|
||||
let file: Value = serde_json::from_str(text).unwrap();
|
||||
for (pointer, kind) in fields {
|
||||
let value = file.pointer(pointer).unwrap_or_else(|| panic!("{app}{pointer}")).clone();
|
||||
let layout_value = match kind {
|
||||
Layout => value.clone(),
|
||||
Workspace => value["layout"].clone(),
|
||||
Hidden => {
|
||||
let hidden: std::collections::BTreeMap<String, Location<String>> = serde_json::from_value(value).unwrap();
|
||||
assert!(!hidden.is_empty(), "{app}{pointer}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Workspace = kind {
|
||||
let workspace: super::Workspace<String> = serde_json::from_value(value).unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
|
||||
workspace.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
|
||||
assert_eq!(workspace.version, 1);
|
||||
for (panel, location) in &workspace.hidden {
|
||||
let mut layout = workspace.layout.clone();
|
||||
layout.restore(panel.clone(), location).unwrap_or_else(|e| panic!("{app}{pointer} {panel}: {e}"));
|
||||
}
|
||||
}
|
||||
let layout: super::Layout<String> = serde_json::from_value(layout_value.clone()).unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
|
||||
layout.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
|
||||
// Nothing new is in use, so the layout writes back exactly as read.
|
||||
assert_eq!(serde_json::to_value(&layout).unwrap(), layout_value, "{app}{pointer}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_app_fixture_reconciles_with_its_canvas_shown() {
|
||||
let s = |p: &str| p.to_owned();
|
||||
let permissions = |p: &String| if p == "canvas" { Permissions::PROTECTED } else { Permissions::default() };
|
||||
// The canvas between a start dock and the properties, which no fixture matches exactly.
|
||||
let beside = super::Layout {
|
||||
root: Some(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::FixedFirst(240.0),
|
||||
first: Box::new(Node::Dock {
|
||||
side: Side::Start,
|
||||
columns: vec![Column::new(vec![Group::new(vec![s("layers"), s("pages")], 0)], 240.0)],
|
||||
}),
|
||||
second: Box::new(Node::Split {
|
||||
axis: SplitAxis::Horizontal,
|
||||
size: SplitSize::Ratio(0.75),
|
||||
first: Box::new(Node::Tabs { panels: vec![s("canvas")], active: 0 }),
|
||||
second: Box::new(Node::Tabs { panels: vec![s("properties"), s("stroke")], active: 0 }),
|
||||
}),
|
||||
}),
|
||||
floating: Vec::new(),
|
||||
};
|
||||
// The canvas with no anchor to go back beside.
|
||||
let alone = super::Layout { root: Some(Node::Tabs { panels: vec![s("canvas")], active: 0 }), floating: Vec::new() };
|
||||
let fixtures = [
|
||||
("designcraft", include_str!("../../tests/fixtures/v1/designcraft.json"), "/docking"),
|
||||
("designcraft", include_str!("../../tests/fixtures/v1/designcraft.json"), "/customWorkspaces/0/docking"),
|
||||
("vectorcraft", include_str!("../../tests/fixtures/v1/vectorcraft.json"), "/docking"),
|
||||
("lightcraft", include_str!("../../tests/fixtures/v1/lightcraft.json"), "/docking/layout"),
|
||||
("pdfcraft", include_str!("../../tests/fixtures/v1/pdfcraft.json"), "/docking/layout"),
|
||||
("cadcraft", include_str!("../../tests/fixtures/v1/cadcraft.json"), "/workspace/docking/layout"),
|
||||
("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), "/docking/layout"),
|
||||
("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), "/mix_docking/layout"),
|
||||
("photocraft", include_str!("../../tests/fixtures/v1/photocraft.json"), "/docking"),
|
||||
("photocraft", include_str!("../../tests/fixtures/v1/photocraft.json"), "/panel_layout/docking"),
|
||||
];
|
||||
for (app, text, pointer) in fixtures {
|
||||
let file: Value = serde_json::from_str(text).unwrap();
|
||||
let mut saved: super::Layout<String> = serde_json::from_value(file.pointer(pointer).unwrap().clone()).unwrap();
|
||||
let own = saved.contains(&s("canvas")).then(|| saved.clone());
|
||||
saved.take(&s("canvas"));
|
||||
for default in own.iter().chain([&beside, &alone]) {
|
||||
let mut workspace = super::Workspace::new(saved.clone());
|
||||
// A file written while the canvas could still be lost.
|
||||
if let Ok(location) = default.location(&s("canvas")) {
|
||||
workspace.hidden.push((s("canvas"), location));
|
||||
}
|
||||
for mut workspace in [super::Workspace::new(saved.clone()), workspace] {
|
||||
workspace.reconcile(|_| true, permissions, &super::Workspace::new(default.clone()));
|
||||
workspace.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}"));
|
||||
assert!(workspace.layout.contains(&s("canvas")), "{app}{pointer}: {:?}", workspace.layout);
|
||||
assert!(workspace.hidden.iter().all(|(p, _)| p != "canvas"), "{app}{pointer}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_placement_splits_the_part_between_the_docks_or_the_root() {
|
||||
// Dock layout: a floating window lands under the frames, beside the end dock.
|
||||
let mut layout = docked();
|
||||
layout.apply(Action::MoveWindow { panel: 6, to: Target::At { anchor: 1, placement: Placement::Edge(Zone::Bottom) } }).unwrap();
|
||||
let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("{layout:?}") };
|
||||
assert!(matches!(&**second, Node::Dock { .. }));
|
||||
let Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(size), second: bottom, .. } = &**first else { panic!("{first:?}") };
|
||||
assert_eq!(*size, 0.75);
|
||||
assert_eq!(**bottom, tabs(&[6, 7, 8], 1));
|
||||
assert!(layout.floating.is_empty());
|
||||
|
||||
// Frame layout: one panel takes the whole left edge.
|
||||
let mut frames = Layout { root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), tabs(&[2, 3], 0))), floating: Vec::new() };
|
||||
frames.apply(Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }).unwrap();
|
||||
let Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(size), first, .. }) = &frames.root else { panic!() };
|
||||
assert_eq!((*size, &**first), (0.25, &tabs(&[3], 0)));
|
||||
|
||||
let before = frames.clone();
|
||||
assert_eq!(frames.apply(Action::Move { panel: 2, anchor: 1, placement: Placement::Edge(Zone::Center) }), Err(DockError::InvalidPlacement));
|
||||
assert_eq!(frames, before);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Where native floating windows go on the desktop (feature `native-windows`). Rects are
|
||||
//! `[x, y, width, height]` in desktop logical points.
|
||||
|
||||
use egui::{Pos2, Rect, Vec2, pos2};
|
||||
|
||||
use super::Monitor;
|
||||
|
||||
/// Largest native window, and the farthest it may sit from the desktop origin.
|
||||
const MAX_SIZE: f32 = 16_384.0;
|
||||
const MAX_POSITION: f32 = 100_000.0;
|
||||
const MIN_SIZE: [f32; 2] = [160.0, 100.0];
|
||||
/// Height of the bar at the top of a native window.
|
||||
pub(super) const BAR_HEIGHT: f32 = 18.0;
|
||||
/// How much of a window's bar must lie on one monitor for the window to be reachable: wide
|
||||
/// enough to grab, and half its height.
|
||||
const REACHABLE: [f32; 2] = [40.0, BAR_HEIGHT / 2.0];
|
||||
|
||||
/// A rect safe to hand to the windowing system: finite, its size and position bounded.
|
||||
/// Hostile workspace files and bad monitor reports included.
|
||||
pub(super) fn sanitized(rect: [f32; 4]) -> [f32; 4] {
|
||||
let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
|
||||
let [x, y, w, h] = rect;
|
||||
[
|
||||
finite(x, 0.0).clamp(-MAX_POSITION, MAX_POSITION),
|
||||
finite(y, 0.0).clamp(-MAX_POSITION, MAX_POSITION),
|
||||
finite(w, 420.0).clamp(MIN_SIZE[0], MAX_SIZE),
|
||||
finite(h, 320.0).clamp(MIN_SIZE[1], MAX_SIZE),
|
||||
]
|
||||
}
|
||||
|
||||
fn usable(monitor: &Monitor) -> bool {
|
||||
let [x, y, w, h] = monitor.rect;
|
||||
[x, y, w, h].iter().all(|v| v.is_finite() && v.abs() <= MAX_POSITION) && w > 0.0 && h > 0.0
|
||||
}
|
||||
|
||||
fn to_rect([x, y, w, h]: [f32; 4]) -> Rect {
|
||||
Rect::from_min_size(pos2(x, y), Vec2::new(w, h))
|
||||
}
|
||||
|
||||
/// The monitor holding the center of `rect`, else the one nearest to it. None for an empty
|
||||
/// list.
|
||||
pub(super) fn monitor_of(monitors: &[Monitor], rect: [f32; 4]) -> Option<&Monitor> {
|
||||
let center = to_rect(rect).center();
|
||||
let monitors = || monitors.iter().filter(|m| usable(m));
|
||||
monitors()
|
||||
.find(|m| to_rect(m.rect).contains(center))
|
||||
.or_else(|| monitors().min_by(|a, b| to_rect(a.rect).distance_sq_to_pos(center).total_cmp(&to_rect(b.rect).distance_sq_to_pos(center))))
|
||||
}
|
||||
|
||||
/// Whether enough of the bar along the top of `rect` lies on one of `monitors` to grab it.
|
||||
fn reachable(rect: [f32; 4], monitors: &[Monitor]) -> bool {
|
||||
let [x, y, w, _] = rect;
|
||||
let bar = to_rect([x, y, w, BAR_HEIGHT]);
|
||||
monitors.iter().filter(|m| usable(m)).any(|m| {
|
||||
let on = bar.intersect(to_rect(m.rect));
|
||||
on.width() >= REACHABLE[0].min(w) && on.height() >= REACHABLE[1]
|
||||
})
|
||||
}
|
||||
|
||||
/// `rect` made safe, and moved wholly onto the nearest of `monitors` when too little of its
|
||||
/// bar lies on any to grab it. Without a monitor list, only made safe.
|
||||
pub(super) fn kept_reachable(rect: [f32; 4], monitors: &[Monitor]) -> [f32; 4] {
|
||||
let rect = sanitized(rect);
|
||||
if !monitors.iter().any(usable) || reachable(rect, monitors) {
|
||||
return rect;
|
||||
}
|
||||
monitor_of(monitors, rect).map_or(rect, |m| clamp_onto(rect, m.rect))
|
||||
}
|
||||
|
||||
/// `rect` moved, and shrunk if it must be, to lie wholly on `area`.
|
||||
fn clamp_onto(rect: [f32; 4], area: [f32; 4]) -> [f32; 4] {
|
||||
let [x, y, w, h] = rect;
|
||||
let [ax, ay, aw, ah] = area;
|
||||
let (w, h) = (w.min(aw), h.min(ah));
|
||||
// `ax + aw - w` can round below `ax` when `w == aw`, so no `clamp`, which panics then.
|
||||
sanitized([x.min(ax + aw - w).max(ax), y.min(ay + ah - h).max(ay), w, h])
|
||||
}
|
||||
|
||||
/// Where a native window from a saved layout opens.
|
||||
///
|
||||
/// - Its saved monitor (`monitor`), found in `monitors` by name or else by rect, is connected:
|
||||
/// the saved rect, moved in proportion if that monitor's rect changed, and onto it if too
|
||||
/// little of its bar lies on a monitor to grab it.
|
||||
/// - It is not: the main window's monitor, at the same offset from its origin.
|
||||
/// - No monitor list: the main window's monitor as egui reports it, by its size alone. Its
|
||||
/// origin is taken as the multiple of that size nearest below the main window's center,
|
||||
/// which holds for a row or grid of monitors of one size.
|
||||
///
|
||||
/// A window that moves to another monitor ends up wholly on it.
|
||||
pub(super) fn restore(saved: [f32; 4], monitor: Option<&Monitor>, monitors: &[Monitor], main: Option<Rect>, monitor_size: Option<Vec2>) -> [f32; 4] {
|
||||
let saved = sanitized(saved);
|
||||
let main = main.filter(|r| r.is_finite()).map(|r| [r.left(), r.top(), r.width().max(1.0), r.height().max(1.0)]);
|
||||
if !monitors.iter().any(usable) {
|
||||
let (Some(main), Some(size)) = (main, monitor_size.filter(|s| s.is_finite() && s.x >= 1.0 && s.y >= 1.0)) else { return saved };
|
||||
let center = to_rect(main).center();
|
||||
let origin = Pos2::new((center.x / size.x).floor() * size.x, (center.y / size.y).floor() * size.y);
|
||||
return clamp_onto(saved, [origin.x, origin.y, size.x, size.y]);
|
||||
}
|
||||
let main_monitor = main.and_then(|main| monitor_of(monitors, main)).or_else(|| monitors.iter().find(|m| usable(m)));
|
||||
let connected = monitor.and_then(|old| {
|
||||
let by_name = old.name.as_ref().and_then(|name| monitors.iter().filter(|m| usable(m)).find(|m| m.name.as_ref() == Some(name)));
|
||||
by_name.or_else(|| monitors.iter().filter(|m| usable(m)).find(|m| m.rect == old.rect)).map(|now| (old, now))
|
||||
});
|
||||
let [x, y, w, h] = saved;
|
||||
match (connected, monitor) {
|
||||
(Some((old, now)), _) if old.rect == now.rect => {
|
||||
if reachable(saved, monitors) {
|
||||
saved
|
||||
} else {
|
||||
clamp_onto(saved, now.rect)
|
||||
}
|
||||
}
|
||||
(Some((old, now)), _) => {
|
||||
let [ox, oy, ow, oh] = old.rect;
|
||||
let [nx, ny, nw, nh] = now.rect;
|
||||
let (sx, sy) = if usable(old) { (nw / ow, nh / oh) } else { (1.0, 1.0) };
|
||||
clamp_onto([nx + (x - ox) * sx, ny + (y - oy) * sy, w, h], now.rect)
|
||||
}
|
||||
(None, Some(old)) => {
|
||||
let Some(target) = main_monitor else { return saved };
|
||||
let [ox, oy, ..] = old.rect;
|
||||
let [tx, ty, ..] = target.rect;
|
||||
let (dx, dy) = if usable(old) { (x - ox, y - oy) } else { (0.0, 0.0) };
|
||||
clamp_onto([tx + dx, ty + dy, w, h], target.rect)
|
||||
}
|
||||
// No saved monitor: the window stays where its bar can be grabbed.
|
||||
(None, None) => {
|
||||
if reachable(saved, monitors) {
|
||||
return saved;
|
||||
}
|
||||
main_monitor.map_or(saved, |target| clamp_onto(saved, target.rect))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::vec2;
|
||||
|
||||
fn monitor(name: &str, rect: [f32; 4], scale: f32) -> Monitor {
|
||||
Monitor { name: Some(name.into()), rect, scale }
|
||||
}
|
||||
|
||||
fn main_at(x: f32, y: f32) -> Option<Rect> {
|
||||
Some(Rect::from_min_size(pos2(x, y), vec2(1200.0, 800.0)))
|
||||
}
|
||||
|
||||
const LEFT: [f32; 4] = [0.0, 0.0, 1920.0, 1080.0];
|
||||
const RIGHT: [f32; 4] = [1920.0, 0.0, 2560.0, 1440.0];
|
||||
|
||||
#[test]
|
||||
fn a_connected_monitor_keeps_the_saved_rect() {
|
||||
let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)];
|
||||
let saved = [2400.0, 300.0, 400.0, 500.0];
|
||||
assert_eq!(restore(saved, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), saved);
|
||||
// Renamed, but the same rect.
|
||||
let renamed = Monitor { name: Some("DP-2".into()), ..monitors[1].clone() };
|
||||
assert_eq!(restore(saved, Some(&renamed), &monitors, main_at(100.0, 100.0), None), saved);
|
||||
// Partly off its monitor, as the user left it.
|
||||
let edge = [4300.0, 300.0, 400.0, 500.0];
|
||||
assert_eq!(restore(edge, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), edge);
|
||||
// Wholly off it, or with only its body on it: back onto it.
|
||||
let off = [5000.0, 300.0, 400.0, 500.0];
|
||||
assert_eq!(restore(off, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), [4080.0, 300.0, 400.0, 500.0]);
|
||||
let above = [2400.0, -10.0, 400.0, 500.0];
|
||||
assert_eq!(restore(above, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), [2400.0, 0.0, 400.0, 500.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_is_kept_where_its_bar_can_be_grabbed() {
|
||||
let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)];
|
||||
// Across the seam, or mostly off screen with enough of its bar on one.
|
||||
for rect in [[1800.0, 100.0, 400.0, 300.0], [-360.0, 1071.0, 400.0, 300.0]] {
|
||||
assert_eq!(kept_reachable(rect, &monitors), rect);
|
||||
}
|
||||
assert_eq!(kept_reachable([-370.0, 100.0, 400.0, 300.0], &monitors), [0.0, 100.0, 400.0, 300.0]);
|
||||
assert_eq!(kept_reachable([500.0, 1075.0, 400.0, 300.0], &monitors), [500.0, 780.0, 400.0, 300.0]);
|
||||
assert_eq!(kept_reachable([9000.0, 100.0, 400.0, 300.0], &monitors), [4080.0, 100.0, 400.0, 300.0]);
|
||||
// No monitor list: only made safe.
|
||||
assert_eq!(kept_reachable([9000.0, f32::NAN, 400.0, 300.0], &[]), [9000.0, 0.0, 400.0, 300.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_monitor_moves_the_window_to_the_main_window_monitor() {
|
||||
let saved_on = monitor("right", RIGHT, 2.0);
|
||||
let monitors = [monitor("left", LEFT, 1.0)];
|
||||
// Same offset from the monitor's origin.
|
||||
assert_eq!(restore([2020.0, 50.0, 400.0, 500.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [100.0, 50.0, 400.0, 500.0]);
|
||||
// Clamped wholly on screen, shrunk if larger than the monitor.
|
||||
assert_eq!(restore([4000.0, 1200.0, 400.0, 500.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [1520.0, 580.0, 400.0, 500.0]);
|
||||
assert_eq!(restore([1920.0, 0.0, 3000.0, 1300.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [0.0, 0.0, 1920.0, 1080.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_monitor_moves_the_window_in_proportion() {
|
||||
let old = monitor("left", LEFT, 1.0);
|
||||
let monitors = [monitor("left", [0.0, 0.0, 960.0, 540.0], 2.0)];
|
||||
assert_eq!(restore([960.0, 540.0, 200.0, 200.0], Some(&old), &monitors, main_at(0.0, 0.0), None), [480.0, 270.0, 200.0, 200.0]);
|
||||
assert_eq!(restore([1800.0, 1000.0, 200.0, 200.0], Some(&old), &monitors, main_at(0.0, 0.0), None), [760.0, 340.0, 200.0, 200.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_coordinates_and_mixed_scales() {
|
||||
let monitors = [monitor("left", [-2560.0, -200.0, 2560.0, 1440.0], 2.0), monitor("main", LEFT, 1.0)];
|
||||
assert_eq!(monitor_of(&monitors, [-500.0, 0.0, 300.0, 300.0]).and_then(|m| m.name.as_deref()), Some("left"));
|
||||
assert_eq!(monitor_of(&monitors, [100.0, 0.0, 300.0, 300.0]).and_then(|m| m.name.as_deref()), Some("main"));
|
||||
// Off every monitor: the nearest.
|
||||
assert_eq!(monitor_of(&monitors, [-3000.0, 2000.0, 100.0, 100.0]).and_then(|m| m.name.as_deref()), Some("left"));
|
||||
let saved = [-1000.0, -100.0, 300.0, 300.0];
|
||||
assert_eq!(restore(saved, Some(&monitors[0]), &monitors, main_at(100.0, 100.0), None), saved);
|
||||
// Unplugged: the same offset on the main window's monitor, at its own scale.
|
||||
let rest = &monitors[1..];
|
||||
assert_eq!(restore(saved, Some(&monitors[0]), rest, main_at(100.0, 100.0), None), [1560.0, 100.0, 300.0, 300.0]);
|
||||
// A main window on the left monitor takes the window there.
|
||||
let main = monitor("main", LEFT, 1.0);
|
||||
assert_eq!(restore([100.0, 100.0, 400.0, 300.0], Some(&main), &monitors[..1], main_at(-2000.0, 0.0), None), [-2460.0, -100.0, 400.0, 300.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_monitor_list_the_window_goes_on_the_main_window_monitor_by_size() {
|
||||
let size = Some(vec2(1920.0, 1080.0));
|
||||
let saved = [2500.0, 300.0, 400.0, 500.0];
|
||||
assert_eq!(restore(saved, None, &[], main_at(100.0, 100.0), size), [1520.0, 300.0, 400.0, 500.0]);
|
||||
assert_eq!(restore(saved, None, &[], main_at(2000.0, 100.0), size), saved);
|
||||
assert_eq!(restore(saved, None, &[], main_at(-1500.0, 100.0), size), [-400.0, 300.0, 400.0, 500.0]);
|
||||
// Nothing known: the saved rect, made safe.
|
||||
assert_eq!(restore([f32::NAN, 1e9, 10.0, 400.0], None, &[], None, None), [0.0, 100_000.0, 160.0, 400.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamping_onto_a_fractional_origin_does_not_panic() {
|
||||
let area = [910.6667, 0.333_33, 1280.0, 720.0];
|
||||
let [x, y, w, h] = clamp_onto([0.0, 2000.0, 1280.0, 900.0], area);
|
||||
assert_eq!([w, h], [1280.0, 720.0]);
|
||||
assert!((x - area[0]).abs() < 0.01 && (y - area[1]).abs() < 0.01, "{x} {y}");
|
||||
let [x, ..] = clamp_onto([5000.0, 0.0, 2000.0, 100.0], area);
|
||||
assert!((x - area[0]).abs() < 0.01, "{x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_without_a_saved_monitor_stays_on_screen() {
|
||||
let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)];
|
||||
let on = [2000.0, 100.0, 400.0, 300.0];
|
||||
assert_eq!(restore(on, None, &monitors, main_at(100.0, 100.0), None), on);
|
||||
assert_eq!(restore([5000.0, 100.0, 400.0, 300.0], None, &monitors, main_at(100.0, 100.0), None), [1520.0, 100.0, 400.0, 300.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Drags across OS windows (feature `native-windows`, where viewports are native).
|
||||
//!
|
||||
//! The window where a drag started owns it: the window system sends it the pointer's moves
|
||||
//! and the release while the button is held, inside the window or not. Each frame the owner
|
||||
//! writes the pointer, with its own desktop origin, under the area's `"drag-pointer"`. The
|
||||
//! context's data is shared by every viewport. The window under the desktop point (floating
|
||||
//! windows first, front first, then the main window) shows the drop preview in its own
|
||||
//! frame, with the targets it drew. The release is resolved at the end of the main window's
|
||||
//! frame, after every native window has drawn.
|
||||
|
||||
use std::{fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Key, Pos2, Rect, Ui, Vec2, vec2};
|
||||
|
||||
use super::floating::WindowHit;
|
||||
use super::native::{BAR_HEIGHT, WindowDrag};
|
||||
use super::preview::DragInput;
|
||||
use super::{Renderer, clamp_float, dock::group_action};
|
||||
use crate::docking::desktop::{Over, window_at};
|
||||
use crate::docking::targets::DockDrop;
|
||||
use crate::docking::{Action, Drag, DropTarget, Floating, GroupDrag, GroupGesture, Target, cancel_drag};
|
||||
|
||||
/// Key of [`DragPointer`] in the context's data, under the area's id.
|
||||
pub(super) const POINTER: &str = "drag-pointer";
|
||||
/// Key of an Escape that reached one of the application's windows other than the drag's
|
||||
/// owner. Keys go to the window with keyboard focus, which need not be the one dragging.
|
||||
const ESCAPE: &str = "drag-escape";
|
||||
|
||||
/// The pointer of a drag, as the window that owns the drag last saw it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(in crate::docking) struct DragPointer {
|
||||
/// [`Floating::id`] of the owning native window; `None` for the main window.
|
||||
pub owner: Option<u64>,
|
||||
/// Desktop position of the owner's (0, 0). `None` where the window system reports no
|
||||
/// positions (Wayland).
|
||||
pub origin: Option<Pos2>,
|
||||
/// In the owner's coordinates. Kept while egui reports no position.
|
||||
pub local: Option<Pos2>,
|
||||
pub down: bool,
|
||||
pub released: bool,
|
||||
pub escape: bool,
|
||||
/// Ctrl or Cmd held: the drop floats.
|
||||
pub command: bool,
|
||||
/// The window a bar drag moves. It follows the pointer and is never its own target.
|
||||
pub window: Option<u64>,
|
||||
/// That drag began on the window's only tab or group: it docks whatever
|
||||
/// [`crate::docking::DockStyle::title_bar_docks`] says.
|
||||
pub docks: bool,
|
||||
}
|
||||
|
||||
impl DragPointer {
|
||||
fn desktop(&self) -> Option<Pos2> {
|
||||
Some(self.origin? + self.local?.to_vec2())
|
||||
}
|
||||
}
|
||||
|
||||
/// A native window's drop targets from its frame, in its own coordinates.
|
||||
pub(in crate::docking) struct Surface<P> {
|
||||
pub window: u64,
|
||||
/// Desktop position of the window's (0, 0), where known.
|
||||
pub origin: Option<Pos2>,
|
||||
/// The window's content, in its coordinates.
|
||||
pub bounds: Rect,
|
||||
pub targets: Vec<DropTarget<P>>,
|
||||
pub dock_targets: Vec<DockDrop<P>>,
|
||||
pub windows: Vec<WindowHit<P>>,
|
||||
}
|
||||
|
||||
/// Where a drag's pointer is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Dest {
|
||||
Main(Pos2),
|
||||
Window(u64, Pos2),
|
||||
/// Over no window of the application: the desktop point, where known.
|
||||
Desktop(Option<Pos2>),
|
||||
}
|
||||
|
||||
/// What one frame of a dock area knows of its OS windows.
|
||||
pub(in crate::docking) struct Cross<P> {
|
||||
/// Viewports are native: drags cross windows.
|
||||
pub on: bool,
|
||||
/// The application's windows lost focus this pass: one had it on the last pass, none now.
|
||||
pub focus_lost: bool,
|
||||
/// The main window's content on the desktop, where known.
|
||||
pub main: Option<Rect>,
|
||||
/// Shown native windows' desktop rects, front first.
|
||||
pub windows: Vec<(u64, Rect)>,
|
||||
/// How many of `windows` lie in front of the main window.
|
||||
pub front: usize,
|
||||
/// Targets of the native windows drawn this frame.
|
||||
pub surfaces: Vec<Surface<P>>,
|
||||
/// The drag's pointer, as its owner wrote it this frame.
|
||||
pub pointer: Option<DragPointer>,
|
||||
/// A bar drag released this frame.
|
||||
pub release: Option<(WindowDrag, DragPointer)>,
|
||||
/// Resolving a native window's bar drag over the main window: frame edge zones count.
|
||||
pub bar: bool,
|
||||
/// Where the native window drawing now was told to move its content this frame.
|
||||
pub moved: Option<Pos2>,
|
||||
/// Resolving a window move that docks whatever `title_bar_docks` says.
|
||||
pub docks: bool,
|
||||
/// A native window's dragged widget is one of the area's controls: egui keeps a drag per
|
||||
/// viewport.
|
||||
pub held: bool,
|
||||
/// The panel whose body is under the pointer in the native window drawing now.
|
||||
pub pointed: Option<P>,
|
||||
}
|
||||
|
||||
impl<P> Default for Cross<P> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
on: false,
|
||||
focus_lost: false,
|
||||
main: None,
|
||||
windows: Vec::new(),
|
||||
front: 0,
|
||||
surfaces: Vec::new(),
|
||||
pointer: None,
|
||||
release: None,
|
||||
bar: false,
|
||||
moved: None,
|
||||
docks: false,
|
||||
held: false,
|
||||
pointed: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
/// Write the pointer of the drag this window owns. `origin` is the window's desktop
|
||||
/// origin for this frame's input; `window` the bar drag it is.
|
||||
pub(super) fn record_pointer(&mut self, ctx: &Context, owner: Option<u64>, origin: Option<Pos2>, window: Option<&WindowDrag>) -> DragPointer {
|
||||
let key = self.id.with(POINTER);
|
||||
let kept = ctx.data(|d| d.get_temp::<DragPointer>(key)).filter(|p| p.owner == owner).and_then(|p| p.local);
|
||||
let elsewhere = ctx.data_mut(|d| d.remove_temp::<bool>(self.id.with(ESCAPE))).unwrap_or(false);
|
||||
let pointer = ctx.input(|i| DragPointer {
|
||||
owner,
|
||||
origin,
|
||||
local: i.pointer.latest_pos().or(i.pointer.interact_pos()).filter(|p| p.is_finite()).or(kept),
|
||||
down: i.pointer.primary_down(),
|
||||
released: i.pointer.primary_released(),
|
||||
escape: elsewhere || i.key_pressed(Key::Escape),
|
||||
command: i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd,
|
||||
window: window.map(|w| w.window),
|
||||
docks: window.is_some_and(|w| w.docks),
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(key, pointer));
|
||||
self.cross.pointer = Some(pointer);
|
||||
pointer
|
||||
}
|
||||
|
||||
/// In a window that does not own the drag (`window`, `None` for the main window): an Escape
|
||||
/// pressed here cancels the drag, and is no shortcut.
|
||||
pub(super) fn escape_elsewhere(&self, ctx: &Context, window: Option<u64>) {
|
||||
let owner = ctx.data(|d| d.get_temp::<DragPointer>(self.id.with(POINTER))).filter(|p| p.down).map(|p| p.owner);
|
||||
if owner.is_some_and(|owner| owner != window) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) {
|
||||
ctx.data_mut(|d| d.insert_temp(self.id.with(ESCAPE), true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the drag's pointer is. Without desktop positions only the owner's own window is
|
||||
/// known, and a point outside it is on the desktop. `current` is a window drawing now.
|
||||
fn destination(&self, pointer: &DragPointer, current: Option<&Surface<P>>) -> Option<Dest> {
|
||||
if let (Some(point), Some(main)) = (pointer.desktop(), self.cross.main) {
|
||||
return Some(match window_at(point, &self.cross.windows, self.cross.front, Some(main), pointer.window) {
|
||||
Over::Main(local) => Dest::Main(local),
|
||||
Over::Window(id, local) => Dest::Window(id, local),
|
||||
Over::Desktop => Dest::Desktop(Some(point)),
|
||||
});
|
||||
}
|
||||
let local = pointer.local?;
|
||||
let Some(owner) = pointer.owner else { return Some(Dest::Main(local)) };
|
||||
let bounds = current.into_iter().chain(&self.cross.surfaces).find(|s| s.window == owner).map(|s| s.bounds);
|
||||
Some(if bounds.is_some_and(|b| b.contains(local)) { Dest::Window(owner, local) } else { Dest::Desktop(None) })
|
||||
}
|
||||
|
||||
/// Run `f` with a native window's targets and bounds in place of the main window's.
|
||||
fn within<R>(&mut self, surface: &mut Surface<P>, f: impl FnOnce(&mut Self) -> R) -> R {
|
||||
std::mem::swap(&mut self.targets, &mut surface.targets);
|
||||
std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets);
|
||||
std::mem::swap(&mut self.frame.windows, &mut surface.windows);
|
||||
let bounds = std::mem::replace(&mut self.float_bounds, surface.bounds);
|
||||
let blockers = std::mem::take(&mut self.blockers);
|
||||
let result = f(self);
|
||||
std::mem::swap(&mut self.targets, &mut surface.targets);
|
||||
std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets);
|
||||
std::mem::swap(&mut self.frame.windows, &mut surface.windows);
|
||||
(self.float_bounds, self.blockers) = (bounds, blockers);
|
||||
result
|
||||
}
|
||||
|
||||
/// [`Self::within`] the native window `window` drawn this frame.
|
||||
fn within_window<R>(&mut self, window: u64, f: impl FnOnce(&mut Self) -> R) -> Option<R> {
|
||||
let index = self.cross.surfaces.iter().position(|s| s.window == window)?;
|
||||
let mut surface = self.cross.surfaces.swap_remove(index);
|
||||
let result = self.within(&mut surface, f);
|
||||
self.cross.surfaces.push(surface);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// The offset from the owner's coordinates to those of a window at `to`.
|
||||
fn shift(&self, pointer: &DragPointer, to: Option<Pos2>) -> Vec2 {
|
||||
match (pointer.origin, to) {
|
||||
(Some(from), Some(to)) => from - to,
|
||||
_ => Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_of(&self, window: u64) -> Option<Pos2> {
|
||||
self.cross.surfaces.iter().find(|s| s.window == window).and_then(|s| s.origin)
|
||||
}
|
||||
|
||||
/// A tab drag at the end of the main window's frame. Over the main window it returns the
|
||||
/// drag in the main window's coordinates and the input to finish it with; elsewhere it
|
||||
/// docks in another window or floats a new one on release.
|
||||
pub(super) fn cross_tab(&mut self, ctx: &Context, drag: &Drag<P>, input: DragInput) -> Option<(Drag<P>, DragInput)> {
|
||||
let Some(pointer) = self.cross.pointer.filter(|p| self.cross.on && p.window.is_none() && p.local.is_some()) else {
|
||||
return Some((drag.clone(), input));
|
||||
};
|
||||
let input = DragInput { escape: input.escape || pointer.escape, ..self.input_of(&pointer, pointer.local) };
|
||||
if input.escape || !input.focused || (!input.down && !input.released) {
|
||||
return Some((drag.clone(), input));
|
||||
}
|
||||
let float_only = pointer.command || drag.undock;
|
||||
let floats_out = float_only && pointer.released && pointer.desktop().is_some();
|
||||
match self.destination(&pointer, None)? {
|
||||
Dest::Main(local) if !floats_out => {
|
||||
let shift = self.shift(&pointer, self.cross.main.map(|m| m.min));
|
||||
let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() };
|
||||
return Some((drag, DragInput { pointer: Some(local), ..input }));
|
||||
}
|
||||
_ if !pointer.released => {}
|
||||
Dest::Window(window, local) if !float_only => {
|
||||
let shift = self.shift(&pointer, self.origin_of(window));
|
||||
let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() };
|
||||
let action = self.within_window(window, |r| {
|
||||
let proposal = r.tab_proposal(&drag.panel, local).filter(|p| !p.noop && local.distance(drag.origin) >= 2.0)?;
|
||||
Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement })
|
||||
});
|
||||
self.push_checked(action.flatten(), &drag.panel);
|
||||
}
|
||||
_ if !self.permissions(&drag.panel).floatable => {}
|
||||
_ => {
|
||||
let size = self.float_size(&drag.panel, drag.source.size());
|
||||
let grab = drag.grab.min(vec2(size.x, self.style.tab_height)) + vec2(0.0, BAR_HEIGHT);
|
||||
let rect = self.desktop_float(&pointer, size, grab);
|
||||
let action = Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] };
|
||||
self.push_checked(Some(action), &drag.panel);
|
||||
}
|
||||
}
|
||||
if pointer.released {
|
||||
cancel_drag::<P>(ctx, self.id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A group or column drag at the end of the main window's frame, like [`Self::cross_tab`].
|
||||
pub(super) fn cross_group(&mut self, ui: &Ui, drag: &GroupDrag<P>, input: DragInput) -> Option<(GroupDrag<P>, DragInput)> {
|
||||
let Some(pointer) = self.cross.pointer.filter(|p| self.cross.on && p.window.is_none() && p.local.is_some()) else {
|
||||
return Some((drag.clone(), input));
|
||||
};
|
||||
if drag.gesture == GroupGesture::Window {
|
||||
return Some((drag.clone(), input));
|
||||
}
|
||||
let input = self.input_of(&pointer, pointer.local);
|
||||
if input.escape || !input.focused || (!input.down && !input.released) {
|
||||
return Some((drag.clone(), input));
|
||||
}
|
||||
let floats_out = pointer.command && pointer.released && pointer.desktop().is_some();
|
||||
let ctx = ui.ctx();
|
||||
match self.destination(&pointer, None)? {
|
||||
Dest::Main(local) if !floats_out => {
|
||||
let shift = self.shift(&pointer, self.cross.main.map(|m| m.min));
|
||||
let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() };
|
||||
return Some((drag, DragInput { pointer: Some(local), ..input }));
|
||||
}
|
||||
_ if !pointer.released => {}
|
||||
Dest::Window(window, local) if !pointer.command => {
|
||||
let shift = self.shift(&pointer, self.origin_of(window));
|
||||
let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() };
|
||||
let action = self.within_window(window, |r| {
|
||||
let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop && local.distance(drag.origin) >= 2.0)?;
|
||||
Some(group_action(&drag, Target::At { anchor: proposal.anchor, placement: proposal.placement }))
|
||||
});
|
||||
self.push_checked(action.flatten(), &drag.panel);
|
||||
}
|
||||
_ => {
|
||||
let size = self.group_float_size(&drag.members, drag.source.size());
|
||||
let grab = (drag.origin - drag.source.min).clamp(Vec2::ZERO, size) + vec2(0.0, BAR_HEIGHT);
|
||||
let rect = self.desktop_float(&pointer, size, grab);
|
||||
let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) });
|
||||
self.push_checked(Some(action), &drag.panel);
|
||||
}
|
||||
}
|
||||
if pointer.released {
|
||||
cancel_drag::<P>(ctx, self.id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A bar drag of a native window, at the end of the main window's frame: the preview
|
||||
/// over the main window, and the release.
|
||||
pub(in crate::docking) fn finish_window_drag(&mut self, ui: &Ui) {
|
||||
if let Some((drag, pointer)) = self.cross.release.take() {
|
||||
self.drop_native_window(drag.window, &pointer);
|
||||
return;
|
||||
}
|
||||
let Some(pointer) = self.cross.pointer.filter(|p| p.window.is_some() && !p.released) else { return };
|
||||
let Some(drag) = pointer.window.and_then(|w| self.window_group_drag(w)) else { return };
|
||||
if let Some(Dest::Main(local)) = self.destination(&pointer, None) {
|
||||
(self.cross.bar, self.cross.docks) = (true, pointer.docks);
|
||||
self.paint_group_drag(ui, &drag, local, pointer.command);
|
||||
(self.cross.bar, self.cross.docks) = (false, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Release of a bar drag: over a target in another window the whole window docks or
|
||||
/// joins there. Over the desktop, or with Ctrl/Cmd held, it stays where it was moved.
|
||||
fn drop_native_window(&mut self, window: u64, pointer: &DragPointer) {
|
||||
let Some(drag) = self.window_group_drag(window).filter(|_| !pointer.command && (pointer.docks || self.style.title_bar_docks)) else { return };
|
||||
match self.destination(pointer, None) {
|
||||
Some(Dest::Main(local)) => {
|
||||
(self.cross.bar, self.cross.docks) = (true, pointer.docks);
|
||||
self.drop_window(&drag, local, false);
|
||||
(self.cross.bar, self.cross.docks) = (false, false);
|
||||
}
|
||||
Some(Dest::Window(target, local)) => {
|
||||
let action = self.within_window(target, |r| {
|
||||
let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop)?;
|
||||
Some(group_action(&drag, Target::At { anchor: proposal.anchor, placement: proposal.placement }))
|
||||
});
|
||||
if let Some(action) = action.flatten() {
|
||||
self.output.actions.push(action);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A native window's bar drag as a whole-window group drag.
|
||||
fn window_group_drag(&self, window: u64) -> Option<GroupDrag<P>> {
|
||||
let window: &Floating<P> = self.layout.floating.iter().find(|w| w.id == window)?;
|
||||
let members: Vec<P> = window.panels().cloned().collect();
|
||||
let panel = members.first()?.clone();
|
||||
Some(GroupDrag { panel, original: window.rect, gesture: GroupGesture::Window, members, origin: Pos2::ZERO, source: Rect::NOTHING })
|
||||
}
|
||||
|
||||
/// At the end of a native window's frame: the drop preview, and its undock hint, when the
|
||||
/// drag's pointer is over this window.
|
||||
pub(super) fn paint_cross(&mut self, ui: &Ui, surface: &mut Surface<P>) {
|
||||
let ctx = ui.ctx();
|
||||
let Some(pointer) = ctx.data(|d| d.get_temp::<DragPointer>(self.id.with(POINTER))).filter(|p| !p.released && p.down) else { return };
|
||||
if let Some(Dest::Window(window, local)) = self.destination(&pointer, Some(&*surface))
|
||||
&& window == surface.window
|
||||
{
|
||||
let shift = self.shift(&pointer, surface.origin);
|
||||
self.within(surface, |r| r.paint_drop(ui, &pointer, local, shift));
|
||||
}
|
||||
}
|
||||
|
||||
/// The preview of the drag at `local`, in the window drawing now. `shift` takes the
|
||||
/// owner's coordinates to this window's.
|
||||
fn paint_drop(&mut self, ui: &Ui, pointer: &DragPointer, local: Pos2, shift: Vec2) {
|
||||
let ctx = ui.ctx();
|
||||
if let Some(window) = pointer.window {
|
||||
if let Some(drag) = self.window_group_drag(window) {
|
||||
self.cross.docks = pointer.docks;
|
||||
self.paint_group_drag(ui, &drag, local, pointer.command);
|
||||
self.cross.docks = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(drag) = ctx.data(|d| d.get_temp::<Drag<P>>(self.id.with("drag"))) {
|
||||
if pointer.command || drag.undock {
|
||||
return;
|
||||
}
|
||||
let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag };
|
||||
let proposal = self.tab_proposal(&drag.panel, local);
|
||||
let painter = self.preview_painter(ctx);
|
||||
self.paint_tab_proposal(ctx, &painter, &drag, proposal.as_ref(), local);
|
||||
} else if let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))).filter(|d| d.gesture != GroupGesture::Window)
|
||||
{
|
||||
let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag };
|
||||
self.paint_group_drag(ui, &drag, local, pointer.command);
|
||||
}
|
||||
}
|
||||
|
||||
/// End of the main window's frame: forget the pointer of a finished drag.
|
||||
pub(in crate::docking) fn end_cross(&mut self, ctx: &Context) {
|
||||
let id = self.id;
|
||||
let active = ctx.data(|d| {
|
||||
d.get_temp::<Drag<P>>(id.with("drag")).is_some()
|
||||
|| d.get_temp::<GroupDrag<P>>(id.with("group-drag")).is_some()
|
||||
|| d.get_temp::<WindowDrag>(id.with("window-drag")).is_some()
|
||||
});
|
||||
if !active {
|
||||
ctx.data_mut(|d| d.remove::<DragPointer>(id.with(POINTER)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The drag's input as its owner saw it, at `pointer` in the coordinates of the window
|
||||
/// finishing it. Leaving a window does not end the drag.
|
||||
fn input_of(&self, pointer: &DragPointer, at: Option<Pos2>) -> DragInput {
|
||||
DragInput {
|
||||
pointer: at,
|
||||
down: pointer.down,
|
||||
released: pointer.released,
|
||||
focused: !self.cross.focus_lost,
|
||||
gone: false,
|
||||
escape: pointer.escape,
|
||||
command: pointer.command,
|
||||
touch_ended: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-window rect of a window floated out at the desktop pointer with its top-left
|
||||
/// `grab` from it. The new window opens at the main window's origin plus this rect. Where
|
||||
/// positions are unknown the window system places it.
|
||||
fn desktop_float(&self, pointer: &DragPointer, size: Vec2, grab: Vec2) -> Rect {
|
||||
match (pointer.desktop(), self.cross.main) {
|
||||
(Some(point), Some(main)) => Rect::from_min_size(point - grab - main.min.to_vec2(), size),
|
||||
_ => clamp_float(Rect::from_min_size(self.float_bounds.min, size), self.float_bounds),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push `action` when the model accepts it, focusing `panel`.
|
||||
fn push_checked(&mut self, action: Option<Action<P>>, panel: &P) {
|
||||
let Some(action) = action else { return };
|
||||
if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() {
|
||||
self.output.actions.push(action);
|
||||
self.output.focused = Some(panel.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
//! [D] Docks: side docks of columns, each a column bar above stacked groups.
|
||||
//!
|
||||
//! Columns run from the window edge inwards. Every column's inner edge resizes it; the last
|
||||
//! column's inner edge is the dock's inner edge. Behavior follows VectorCraft `floating.rs`
|
||||
//! (column layout, drop geometry) and PhotoCraft `dock.rs` (group heights).
|
||||
|
||||
use std::{fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
|
||||
|
||||
use super::{GroupKind, Renderer, RowItem, row_drag, row_range};
|
||||
use crate::docking::{
|
||||
Action, Column, DockEvent, DockHeights, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target,
|
||||
dock_model::Unit,
|
||||
targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line},
|
||||
};
|
||||
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
|
||||
|
||||
/// Width of the »/« button at the outer end of a column bar.
|
||||
const COLLAPSE_WIDTH: f32 = 18.0;
|
||||
|
||||
/// The physical window edge a dock sits on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(in crate::docking) enum Edge {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// `Start` is the left edge in a left-to-right layout and the right edge in a right-to-left one.
|
||||
pub(in crate::docking) fn edge_of(side: Side, right_to_left: bool) -> Edge {
|
||||
match (side, right_to_left) {
|
||||
(Side::Start, false) | (Side::End, true) => Edge::Left,
|
||||
(Side::Start, true) | (Side::End, false) => Edge::Right,
|
||||
}
|
||||
}
|
||||
|
||||
fn side_at(edge: Edge, right_to_left: bool) -> Side {
|
||||
match (edge, right_to_left) {
|
||||
(Edge::Left, false) | (Edge::Right, true) => Side::Start,
|
||||
(Edge::Left, true) | (Edge::Right, false) => Side::End,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rects of a dock beside `other` in `rect`, and the widest the dock may get.
|
||||
pub(in crate::docking) fn dock_rects<P>(
|
||||
side: Side,
|
||||
columns: &[Column<P>],
|
||||
other: &Node<P>,
|
||||
rect: Rect,
|
||||
style: &DockStyle,
|
||||
content: &dyn crate::docking::DockContent<P>,
|
||||
) -> (Rect, Rect, f32) {
|
||||
let gap = style.gap;
|
||||
let room = (rect.width() - gap - super::node_limits(other, style, content).min.x).clamp(0.0, rect.width());
|
||||
let width = columns.iter().map(|c| column_width(c, style)).sum::<f32>().clamp(0.0, room);
|
||||
let (dock, rest) = match edge_of(side, style.right_to_left) {
|
||||
Edge::Left => (
|
||||
Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())),
|
||||
Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max),
|
||||
),
|
||||
Edge::Right => (
|
||||
Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max),
|
||||
Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())),
|
||||
),
|
||||
};
|
||||
(dock, rest, room)
|
||||
}
|
||||
|
||||
pub(in crate::docking) fn column_width<P>(column: &Column<P>, style: &DockStyle) -> f32 {
|
||||
if column.iconic { column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) } else { column.width }
|
||||
}
|
||||
|
||||
/// A group of a column, as [`group_heights`] sizes it. Heights include the tab strip.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(in crate::docking) struct GroupSize {
|
||||
pub(in crate::docking) minimized: bool,
|
||||
/// `Group::height`: set by the user; `None` or not finite when never resized.
|
||||
pub(in crate::docking) saved: Option<f32>,
|
||||
pub(in crate::docking) min: f32,
|
||||
/// The content's [`DockHeights`](crate::docking::DockHeights), the strip added (to a
|
||||
/// `fill` above 0 only, so 0 still means the group does not ask to fill).
|
||||
pub(in crate::docking) content: Option<DockHeights>,
|
||||
}
|
||||
|
||||
/// Heights of stacked groups in a column body `room` high. Minimized groups take `strip`,
|
||||
/// groups with a saved height keep it, and groups the user never resized whose content asks
|
||||
/// for a fill height share what is left. Without those, the groups without content heights
|
||||
/// share it, and when every open group has a height, the last one takes the rest. Other
|
||||
/// groups take their content's default height, or their minimum. Default heights shrink towards their compact heights until the
|
||||
/// filling groups get their content's fill height, then every height shrinks towards its
|
||||
/// minimum before the column overflows and scrolls. Default heights give way nearest the
|
||||
/// filling groups first; saved and default heights then shrink in proportion to their room.
|
||||
pub(in crate::docking) fn group_heights(groups: &[GroupSize], strip: f32, room: f32) -> Vec<f32> {
|
||||
let saved = |g: &GroupSize| g.saved.filter(|h| h.is_finite());
|
||||
let open: Vec<usize> = (0..groups.len()).filter(|i| groups.get(*i).is_some_and(|g| !g.minimized)).collect();
|
||||
let unsaved = |i: &usize, test: fn(Option<DockHeights>) -> bool| groups.get(*i).is_some_and(|g| saved(g).is_none() && test(g.content));
|
||||
let mut shared: Vec<usize> = open.iter().copied().filter(|i| unsaved(i, |c| c.is_some_and(|c| c.fill > 0.0))).collect();
|
||||
if shared.is_empty() {
|
||||
shared = open.iter().copied().filter(|i| unsaved(i, |c| c.is_none())).collect();
|
||||
}
|
||||
if shared.is_empty() {
|
||||
shared.extend(open.last());
|
||||
}
|
||||
let sized: Vec<usize> = open.iter().copied().filter(|i| !shared.contains(i)).collect();
|
||||
let mut defaulted: Vec<usize> = sized.iter().copied().filter(|i| groups.get(*i).is_some_and(|g| saved(g).is_none())).collect();
|
||||
let mut heights: Vec<f32> = groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
if g.minimized {
|
||||
strip
|
||||
} else if shared.contains(&i) {
|
||||
0.0
|
||||
} else {
|
||||
saved(g).or(g.content.map(|c| c.default)).unwrap_or(g.min).max(g.min)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mins: Vec<f32> = groups.iter().map(|g| if g.minimized { strip } else { g.min }).collect();
|
||||
let compacts: Vec<f32> = groups.iter().zip(&mins).map(|(g, min)| g.content.map_or(*min, |c| c.compact).max(*min)).collect();
|
||||
let fill: f32 = shared.iter().filter_map(|i| groups.get(*i)).map(|g| g.content.map_or(g.min, |c| c.fill).max(g.min)).sum();
|
||||
// Nearest the filling groups first: one group gives way to its compact height before the next.
|
||||
defaulted.sort_by_key(|i| shared.iter().map(|s| s.abs_diff(*i)).min());
|
||||
let mut deficit = heights.iter().sum::<f32>() + fill - room;
|
||||
for i in defaulted {
|
||||
if deficit <= 0.0 {
|
||||
break;
|
||||
}
|
||||
if let (Some(height), Some(floor)) = (heights.get_mut(i), compacts.get(i)) {
|
||||
let give = (*height - floor).clamp(0.0, deficit);
|
||||
*height -= give;
|
||||
deficit -= give;
|
||||
}
|
||||
}
|
||||
let shared_min: f32 = shared.iter().filter_map(|i| mins.get(*i)).sum();
|
||||
give_way(&mut heights, &sized, &mins, shared_min - room);
|
||||
let free = (room - heights.iter().sum::<f32>()).max(0.0);
|
||||
// The shared groups split what is left evenly, each at least its minimum: groups whose
|
||||
// minimum is above the even share take their minimum and the rest split the remainder.
|
||||
let mut floors: Vec<f32> = shared.iter().filter_map(|i| mins.get(*i).copied()).collect();
|
||||
floors.sort_by(|a, b| b.total_cmp(a));
|
||||
let (mut rest, mut count) = (free, floors.len());
|
||||
for floor in floors {
|
||||
if count > 1 && floor > rest / count as f32 {
|
||||
rest -= floor;
|
||||
count -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let share = rest / count.max(1) as f32;
|
||||
for i in &shared {
|
||||
if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) {
|
||||
*height = share.max(*min);
|
||||
}
|
||||
}
|
||||
heights
|
||||
}
|
||||
|
||||
/// Shrink the `which` heights in proportion towards their `floors` until the column's heights
|
||||
/// plus `extra` fit, or every one is at its floor.
|
||||
fn give_way(heights: &mut [f32], which: &[usize], floors: &[f32], extra: f32) {
|
||||
let deficit = heights.iter().sum::<f32>() + extra;
|
||||
let slack: f32 = which.iter().filter_map(|i| Some((heights.get(*i)? - floors.get(*i)?).max(0.0))).sum();
|
||||
if deficit > 0.0 && slack > 0.0 {
|
||||
let take = deficit.min(slack) / slack;
|
||||
for i in which {
|
||||
if let (Some(height), Some(floor)) = (heights.get_mut(*i), floors.get(*i)) {
|
||||
*height -= (*height - floor).max(0.0) * take;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Column widths that fit `room`. Each entry is (width, floor); a column with a floor shrinks
|
||||
/// in proportion, never below its floor, and one without keeps its width. A dock saved on a
|
||||
/// larger screen still fits, as far as the floors allow.
|
||||
pub(in crate::docking) fn fit_widths(columns: &[(f32, Option<f32>)], room: f32) -> Vec<f32> {
|
||||
let mut widths: Vec<f32> = columns.iter().map(|(w, _)| *w).collect();
|
||||
for _ in 0..columns.len() {
|
||||
let total: f32 = widths.iter().sum();
|
||||
if total <= room {
|
||||
break;
|
||||
}
|
||||
let flex: f32 = widths.iter().zip(columns).filter(|(w, (_, floor))| floor.is_some_and(|f| **w > f)).map(|(w, _)| *w).sum();
|
||||
if flex <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let scale = ((room - (total - flex)) / flex).max(0.0);
|
||||
for (width, (_, floor)) in widths.iter_mut().zip(columns) {
|
||||
if let Some(floor) = *floor
|
||||
&& *width > floor
|
||||
{
|
||||
*width = (*width * scale).max(floor);
|
||||
}
|
||||
}
|
||||
}
|
||||
widths
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
/// A horizontal split with a dock child: the dock takes its columns' width on its physical
|
||||
/// edge, the sibling the rest. False when neither child is a dock.
|
||||
pub(super) fn dock_split(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
first: &Node<P>,
|
||||
second: &Node<P>,
|
||||
rect: Rect,
|
||||
path: &mut Vec<bool>,
|
||||
root: (&Node<P>, Rect),
|
||||
) -> bool {
|
||||
let (dock_first, side, columns, other) = match (first, second) {
|
||||
(Node::Dock { side, columns }, other) => (true, *side, columns, other),
|
||||
(other, Node::Dock { side, columns }) => (false, *side, columns, other),
|
||||
_ => return false,
|
||||
};
|
||||
let (dock, rest, room) = dock_rects(side, columns, other, rect, self.style, &*self.content);
|
||||
self.dock(ui, side, columns, dock, room);
|
||||
path.push(dock_first);
|
||||
self.node(ui, other, rest, path, root);
|
||||
path.pop();
|
||||
true
|
||||
}
|
||||
|
||||
/// One dock in `rect`, columns from its outer edge. `room` is the widest the dock may get.
|
||||
pub(super) fn dock(&mut self, ui: &mut Ui, side: Side, columns: &[Column<P>], rect: Rect, room: f32) {
|
||||
let edge = edge_of(side, self.style.right_to_left);
|
||||
let (outer, inward) = match edge {
|
||||
Edge::Left => (rect.left(), 1.0),
|
||||
Edge::Right => (rect.right(), -1.0),
|
||||
};
|
||||
if columns.is_empty() {
|
||||
let (band, line) = column_edge(outer, inward, rect.y_range());
|
||||
let band = band.union(Rect::from_x_y_ranges(
|
||||
outer.min(outer + inward * self.style.new_dock_band)..=outer.max(outer + inward * self.style.new_dock_band),
|
||||
rect.y_range(),
|
||||
));
|
||||
self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line });
|
||||
return;
|
||||
}
|
||||
if usable(rect) {
|
||||
ui.painter().rect_filled(rect, 0.0, self.style.background);
|
||||
}
|
||||
let bounds: Vec<(f32, f32)> =
|
||||
columns.iter().map(|c| if c.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(c) }).collect();
|
||||
let natural: Vec<(f32, Option<f32>)> =
|
||||
columns.iter().zip(&bounds).map(|(c, (lo, _))| (column_width(c, self.style), (!c.iconic).then_some(lo.max(1.0)))).collect();
|
||||
let widths = fit_widths(&natural, rect.width());
|
||||
let scaled = widths.iter().zip(&natural).any(|(w, (n, _))| w != n);
|
||||
let total: f32 = widths.iter().sum();
|
||||
let mut x = outer;
|
||||
let mut drawn = Vec::new();
|
||||
for (index, column) in columns.iter().enumerate() {
|
||||
let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue };
|
||||
let fitted = widths.get(index).copied().unwrap_or_default();
|
||||
let width = fitted.min((rect.width() - (x - outer).abs()).max(0.0));
|
||||
let inner = x + inward * width;
|
||||
let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range());
|
||||
self.column(ui, column, first, column_rect, edge);
|
||||
if index == 0 {
|
||||
let (band, line) = column_edge(x, inward, rect.y_range());
|
||||
self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: false }, band, line });
|
||||
}
|
||||
let (band, line) = column_edge(inner, -inward, rect.y_range());
|
||||
self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: true }, band, line });
|
||||
drawn.push((index, column, first, width, inner));
|
||||
x = inner;
|
||||
}
|
||||
// Registered after the columns, so the narrow hit areas win at the edges.
|
||||
for (index, column, first, width, inner) in drawn {
|
||||
if width < 1.0 {
|
||||
continue;
|
||||
}
|
||||
let others = total - widths.get(index).copied().unwrap_or_default();
|
||||
let (lo, hi) = bounds.get(index).copied().unwrap_or_default();
|
||||
let hi = hi.min(room - others).max(lo.min(room - others).max(0.0));
|
||||
let lo = lo.min(hi);
|
||||
let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range());
|
||||
let response = ResizeHandle::new(
|
||||
self.id.with(("column-resize", Some(first))),
|
||||
SplitAxis::Horizontal,
|
||||
divider,
|
||||
&self.style.resize_label,
|
||||
self.handle_style(),
|
||||
)
|
||||
.value(width, lo..=hi)
|
||||
.reversed(inward < 0.0)
|
||||
.show(ui);
|
||||
self.register_interaction(ui.ctx(), response.response.id);
|
||||
let next = (width + inward * response.delta).clamp(lo, hi);
|
||||
if response.delta != 0.0 && next != width && next > 0.0 {
|
||||
self.output.actions.push(if column.iconic {
|
||||
Action::ResizeIconic { panel: first.clone(), width: Some(next) }
|
||||
} else {
|
||||
Action::ResizeColumn { panel: first.clone(), width: next }
|
||||
});
|
||||
// A dock shrunk to fit keeps the widths it was drawn at, so the resized column
|
||||
// follows the pointer instead of being scaled again.
|
||||
if scaled {
|
||||
for (other, (column, width)) in columns.iter().zip(&widths).enumerate() {
|
||||
let first = column.groups.first().and_then(|g| g.panels.first());
|
||||
if let Some(first) = first.filter(|_| other != index && !column.iconic && *width > 0.0) {
|
||||
self.output.actions.push(Action::ResizeColumn { panel: first.clone(), width: *width });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_style(&self) -> ResizeHandleStyle {
|
||||
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }
|
||||
}
|
||||
|
||||
/// Width bounds of an expanded column from its panels' limits.
|
||||
fn column_bounds(&self, column: &Column<P>) -> (f32, f32) {
|
||||
let mut lo = self.style.min_pane;
|
||||
let mut hi = f32::INFINITY;
|
||||
for panel in column.groups.iter().flat_map(|g| g.panels.iter()) {
|
||||
let limits = self.limits(panel);
|
||||
lo = lo.max(limits.min.x);
|
||||
hi = hi.min(limits.max.x);
|
||||
}
|
||||
(lo, hi.max(lo))
|
||||
}
|
||||
|
||||
/// Least height of an open group: its strip and the tallest minimum of its panels.
|
||||
fn group_min(&self, group: &Group<P>) -> f32 {
|
||||
let body = group.panels.iter().map(|p| self.limits(p).min.y).fold(self.style.min_pane, f32::max);
|
||||
self.style.tab_height + body
|
||||
}
|
||||
|
||||
/// The largest of each of the group's panels' [`DockHeights`], the tab strip added.
|
||||
fn dock_heights(&self, group: &Group<P>) -> Option<DockHeights> {
|
||||
let strip = self.style.tab_height;
|
||||
group
|
||||
.panels
|
||||
.iter()
|
||||
.filter_map(|p| self.content.dock_heights(p).filter(|h| h.valid()))
|
||||
.reduce(|a, b| DockHeights { default: a.default.max(b.default), compact: a.compact.max(b.compact), fill: a.fill.max(b.fill) })
|
||||
.map(|h| DockHeights {
|
||||
default: h.default + strip,
|
||||
compact: h.compact.min(h.default) + strip,
|
||||
fill: if h.fill > 0.0 { h.fill + strip } else { 0.0 },
|
||||
})
|
||||
}
|
||||
|
||||
fn column(&mut self, ui: &mut Ui, column: &Column<P>, first: &P, rect: Rect, edge: Edge) {
|
||||
if !usable(rect) {
|
||||
return;
|
||||
}
|
||||
self.record_column_float(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), rect);
|
||||
let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom())));
|
||||
self.column_bar(ui, column, first, bar, rect, edge);
|
||||
let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max);
|
||||
if column.iconic && usable(body) {
|
||||
self.iconic_column(ui, column, body, edge);
|
||||
}
|
||||
if column.iconic || !usable(body) {
|
||||
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
|
||||
return;
|
||||
}
|
||||
let sizes: Vec<_> = column
|
||||
.groups
|
||||
.iter()
|
||||
.map(|g| GroupSize { minimized: g.minimized, saved: g.height, min: self.group_min(g), content: self.dock_heights(g) })
|
||||
.collect();
|
||||
let heights = group_heights(&sizes, self.style.tab_height, body.height());
|
||||
let total: f32 = heights.iter().sum();
|
||||
let start = self.targets.len();
|
||||
if total > body.height() + 0.5 {
|
||||
let mut child =
|
||||
ui.new_child(UiBuilder::new().id_salt(("dock-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
child.set_clip_rect(body.intersect(ui.clip_rect()));
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(self.id.with(("column-scroll", first)))
|
||||
.auto_shrink([false, false])
|
||||
.scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL)
|
||||
.show(&mut child, |ui| {
|
||||
let (content, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover());
|
||||
self.column_groups(ui, column, &heights, content, body);
|
||||
});
|
||||
} else {
|
||||
self.column_groups(ui, column, &heights, body, body);
|
||||
}
|
||||
// A group scrolled out of view takes nothing.
|
||||
for target in self.targets.iter_mut().skip(start) {
|
||||
target.rect = target.rect.intersect(body);
|
||||
target.tab_strip = target.tab_strip.map(|strip| strip.intersect(body));
|
||||
for (_, tab) in &mut target.tabs {
|
||||
*tab = tab.intersect(body);
|
||||
}
|
||||
}
|
||||
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// Groups top to bottom in `area` (the scrolled content when the column scrolls), their
|
||||
/// seams and bands clipped to the visible `view`, and the height dividers.
|
||||
fn column_groups(&mut self, ui: &mut Ui, column: &Column<P>, heights: &[f32], area: Rect, view: Rect) {
|
||||
let mut top = area.top();
|
||||
let mut rects = Vec::new();
|
||||
for (group, height) in column.groups.iter().zip(heights) {
|
||||
let rect = Rect::from_min_max(pos2(area.left(), top), pos2(area.right(), top + height));
|
||||
self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Dock);
|
||||
rects.push(rect);
|
||||
top += height;
|
||||
}
|
||||
let xs = view.x_range();
|
||||
let half = self.style.seam_band * 0.5;
|
||||
let push = |targets: &mut Vec<DockDrop<P>>, target: DockTarget<P>, band: Rect, line: Rect| {
|
||||
let band = band.intersect(view);
|
||||
if usable(band) {
|
||||
targets.push(DockDrop { target, band, line: line.intersect(view.expand(2.0)) });
|
||||
}
|
||||
};
|
||||
let mut previous: Option<&P> = None;
|
||||
for (group, rect) in column.groups.iter().zip(&rects) {
|
||||
let Some(anchor) = group.panels.first() else { continue };
|
||||
if let Some(above) = previous {
|
||||
let band = Rect::from_x_y_ranges(xs, (rect.top() - half)..=(rect.top() + half));
|
||||
push(&mut self.dock_targets, DockTarget::Seam { anchor: above.clone() }, band, horizontal_line(rect.top(), xs));
|
||||
}
|
||||
if !group.minimized {
|
||||
let body = Rect::from_min_max(pos2(rect.left(), (rect.top() + self.style.tab_height).min(rect.bottom())), rect.max);
|
||||
let quarter = body.height() * 0.25;
|
||||
let upper = Rect::from_x_y_ranges(xs, body.top()..=(body.top() + quarter));
|
||||
let lower = Rect::from_x_y_ranges(xs, (body.bottom() - quarter)..=body.bottom());
|
||||
push(&mut self.dock_targets, DockTarget::GroupBand { anchor: anchor.clone(), below: false }, upper, horizontal_line(rect.top(), xs));
|
||||
push(
|
||||
&mut self.dock_targets,
|
||||
DockTarget::GroupBand { anchor: anchor.clone(), below: true },
|
||||
lower,
|
||||
horizontal_line(rect.bottom(), xs),
|
||||
);
|
||||
}
|
||||
previous = Some(anchor);
|
||||
}
|
||||
// The room under the last group, when every group is minimized, stacks below it.
|
||||
if let Some(last) = previous
|
||||
&& top < view.bottom()
|
||||
{
|
||||
let band = Rect::from_x_y_ranges(xs, top..=view.bottom());
|
||||
push(&mut self.dock_targets, DockTarget::GroupBand { anchor: last.clone(), below: true }, band, horizontal_line(top, xs));
|
||||
}
|
||||
// Height dividers between an open group and the next open one, after the groups so
|
||||
// their hit areas win at the boundary. The heights of the groups a drag changes are
|
||||
// saved: the upper group, and the lower groups that give way in turn, or the reverse.
|
||||
let open: Vec<usize> = (0..column.groups.len()).filter(|i| column.groups.get(*i).is_some_and(|g| !g.minimized)).collect();
|
||||
let row: Vec<RowItem> = open
|
||||
.iter()
|
||||
.filter_map(|i| Some(RowItem { size: *heights.get(*i)?, min: self.group_min(column.groups.get(*i)?), max: f32::INFINITY }))
|
||||
.collect();
|
||||
for (index, pair) in open.windows(2).enumerate() {
|
||||
let &[i, _] = pair else { continue };
|
||||
let (Some(upper), Some(rect), Some(a)) = (column.groups.get(i), rects.get(i), heights.get(i)) else { continue };
|
||||
let Some(first) = upper.panels.first() else { continue };
|
||||
let (lo, hi) = row_range(&row, index);
|
||||
let divider = Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - 1.0)..=(rect.bottom() + 1.0));
|
||||
let response = ResizeHandle::new(
|
||||
self.id.with(("group-resize", Some(first))),
|
||||
SplitAxis::Vertical,
|
||||
divider,
|
||||
&self.style.resize_label,
|
||||
self.handle_style(),
|
||||
)
|
||||
.value(*a, (a + lo)..=(a + hi))
|
||||
.show(ui);
|
||||
self.register_interaction(ui.ctx(), response.response.id);
|
||||
if response.delta == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for ((group, item), next) in open.iter().filter_map(|g| column.groups.get(*g)).zip(&row).zip(row_drag(&row, index, response.delta)) {
|
||||
if let Some(panel) = group.panels.first().filter(|_| next != item.size) {
|
||||
self.output.actions.push(Action::ResizeGroup { panel: panel.clone(), height: Some(next) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The column bar: the »/« button at its outer end, the rest drags the column (S6),
|
||||
/// toggles iconic on double-click (M2) and opens the column menu (U1).
|
||||
fn column_bar(&mut self, ui: &mut Ui, column: &Column<P>, first: &P, bar: Rect, rect: Rect, edge: Edge) {
|
||||
ui.painter().rect_filled(bar, 0.0, self.style.tab_background);
|
||||
let width = COLLAPSE_WIDTH.min(bar.width());
|
||||
let (button, grip) = match edge {
|
||||
Edge::Left => (
|
||||
Rect::from_min_max(bar.min, pos2(bar.left() + width, bar.bottom())),
|
||||
Rect::from_min_max(pos2(bar.left() + width, bar.top()), bar.max),
|
||||
),
|
||||
Edge::Right => (
|
||||
Rect::from_min_max(pos2(bar.right() - width, bar.top()), bar.max),
|
||||
Rect::from_min_max(bar.min, pos2(bar.right() - width, bar.bottom())),
|
||||
),
|
||||
};
|
||||
let label = if column.iconic { self.style.expand_panels_label.clone() } else { self.style.collapse_to_icons_label.clone() };
|
||||
if usable(button) {
|
||||
let response = ui.interact(button, self.id.with(("column-collapse", Some(first))), Sense::click());
|
||||
self.register_interaction(ui.ctx(), response.id);
|
||||
let button_label = if column.iconic { &self.style.expand_column_label } else { &self.style.collapse_column_label };
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), button_label));
|
||||
// The arrows point where the column goes: towards the window edge when it collapses.
|
||||
let glyph = if (edge == Edge::Left) != column.iconic { "«" } else { "»" };
|
||||
let color = if response.hovered() || response.has_focus() { self.style.text } else { self.style.inactive_text };
|
||||
ui.painter().with_clip_rect(button).text(button.center(), egui::Align2::CENTER_CENTER, glyph, self.style.font.clone(), color);
|
||||
if response.clicked() {
|
||||
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
|
||||
}
|
||||
}
|
||||
if !usable(grip) {
|
||||
return;
|
||||
}
|
||||
let movable = column.groups.iter().flat_map(|g| g.panels.iter()).all(|p| self.permissions(p).movable);
|
||||
let response = ui.interact(grip, self.id.with(("column-bar", Some(first))), Sense::click_and_drag());
|
||||
let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response };
|
||||
self.register_interaction(ui.ctx(), response.id);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_column_label));
|
||||
if movable && response.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center());
|
||||
let drag = GroupDrag {
|
||||
panel: first.clone(),
|
||||
original: [rect.left(), rect.top(), rect.width(), rect.height()],
|
||||
gesture: GroupGesture::Column,
|
||||
members: column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(),
|
||||
origin,
|
||||
source: rect,
|
||||
};
|
||||
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
|
||||
}
|
||||
if response.double_clicked() {
|
||||
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
|
||||
}
|
||||
if response.has_focus() || response.hovered() {
|
||||
ui.painter().with_clip_rect(grip).rect_stroke(
|
||||
grip.shrink(1.0),
|
||||
0.0,
|
||||
Stroke::new(1.0, self.style.accent.gamma_multiply(0.5)),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
let keyboard = response.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) || i.key_pressed(Key::Space));
|
||||
let command = if response.secondary_clicked() || keyboard {
|
||||
Some(egui::SetOpenCommand::Bool(true))
|
||||
} else if response.clicked() {
|
||||
Some(egui::SetOpenCommand::Bool(false))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
egui::Popup::menu(&response).open_memory(command).show(|ui| {
|
||||
if self.menu_action(ui, &label) {
|
||||
self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic });
|
||||
}
|
||||
if self.auto_collapse_item(ui) {
|
||||
self.output.events.push(DockEvent::ToggleAutoCollapse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a floating window drawn this frame (other than the one following the pointer)
|
||||
/// or a host overlay covers `pointer`. Covered dock targets take nothing.
|
||||
fn dock_covered(&self, pointer: Pos2, moving: Option<&P>) -> bool {
|
||||
self.blockers.iter().any(|r| r.contains(pointer))
|
||||
|| self.iconic.covers(pointer)
|
||||
|| self.frame.windows.iter().any(|window| window.window.contains(pointer) && !moving.is_some_and(|p| window.members.contains(p)))
|
||||
}
|
||||
|
||||
/// Dock targets, with the new-dock bands along the docked area's left and right edges that
|
||||
/// have no dock yet.
|
||||
fn dock_candidates(&self) -> Vec<DockDrop<P>> {
|
||||
let mut candidates = self.dock_targets.clone();
|
||||
if !self.style.new_dock_zones || !usable(self.workspace) {
|
||||
return candidates;
|
||||
}
|
||||
let rtl = self.style.right_to_left;
|
||||
let ys = self.workspace.y_range();
|
||||
for edge in [Edge::Left, Edge::Right] {
|
||||
let side = side_at(edge, rtl);
|
||||
if self.layout.dock_path(side).is_some() {
|
||||
continue;
|
||||
}
|
||||
let (x, inward) = match edge {
|
||||
Edge::Left => (self.workspace.left(), self.style.new_dock_band),
|
||||
Edge::Right => (self.workspace.right(), -self.style.new_dock_band),
|
||||
};
|
||||
let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys);
|
||||
let line = vertical_line(x + inward.signum() * 2.0, ys);
|
||||
candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line });
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
/// The dock target a whole group or column drag would take at `pointer`, with the action
|
||||
/// and its line. A target that leaves the layout unchanged is a no-op: `None` action.
|
||||
pub(super) fn dock_group_proposal(&self, drag: &GroupDrag<P>, pointer: Pos2) -> Option<(Option<Action<P>>, Rect)> {
|
||||
let moving = (drag.gesture == GroupGesture::Window).then_some(&drag.panel);
|
||||
if self.dock_covered(pointer, moving) {
|
||||
return None;
|
||||
}
|
||||
let candidates = self.dock_candidates();
|
||||
let drop = resolve_dock(pointer, &candidates)?;
|
||||
let action = group_action(drag, drop.target.to());
|
||||
self.checked(action).map(|action| (action, drop.line))
|
||||
}
|
||||
|
||||
/// The dock target a single tab drag would take: a new group, column or dock of that panel.
|
||||
/// A panel alone in its group takes the group's state to a new dock; one that shares its
|
||||
/// group leaves the other tabs behind (P6).
|
||||
pub(super) fn dock_tab_proposal(&self, panel: &P, pointer: Pos2) -> Option<(Option<Action<P>>, Rect)> {
|
||||
if self.dock_covered(pointer, None) {
|
||||
return None;
|
||||
}
|
||||
let candidates = self.dock_candidates();
|
||||
let drop = resolve_dock(pointer, &candidates)?;
|
||||
let action = match drop.target.to() {
|
||||
// A target named by the dragged panel is its own group or column: another panel
|
||||
// there names the same one. With none, the drop leaves the panel where it is.
|
||||
Target::At { anchor, placement } if &anchor == panel => {
|
||||
let unit = if matches!(placement, Placement::Column { .. }) { Unit::Column } else { Unit::Group };
|
||||
match self.layout.members(panel, unit).ok()?.into_iter().find(|p| p != panel) {
|
||||
Some(anchor) => Action::Move { panel: panel.clone(), anchor, placement },
|
||||
None => return Some((None, drop.line)),
|
||||
}
|
||||
}
|
||||
Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement },
|
||||
to @ Target::NewDock { .. } if self.place(panel).and_then(|place| self.layout.group(place)).is_some_and(|g| g.panels.len() == 1) => {
|
||||
Action::MoveGroup { panel: panel.clone(), to }
|
||||
}
|
||||
to @ Target::NewDock { .. } => Action::MovePanel { panel: panel.clone(), to },
|
||||
Target::Float { .. } => return None,
|
||||
};
|
||||
self.checked(action).map(|action| (action, drop.line))
|
||||
}
|
||||
|
||||
/// `Some(None)` when the action is valid but changes nothing, `None` when it is invalid.
|
||||
fn checked(&self, action: Action<P>) -> Option<Option<Action<P>>> {
|
||||
let mut candidate = self.layout.clone();
|
||||
candidate.apply_with_permissions(action.clone(), |p| self.permissions(p)).ok()?;
|
||||
Some((candidate != *self.layout).then_some(action))
|
||||
}
|
||||
|
||||
/// The insertion line of a dock target.
|
||||
pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) {
|
||||
if usable(line) {
|
||||
let painter = self.preview_painter(ctx);
|
||||
self.iconic.outline(&painter, line, self.style);
|
||||
painter.rect_filled(line, 1.0, self.style.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The move a group gesture makes: the column for a column bar, every group for a window's
|
||||
/// title bar, else the group.
|
||||
pub(super) fn group_action<P: Clone>(drag: &GroupDrag<P>, to: Target<P>) -> Action<P> {
|
||||
match drag.gesture {
|
||||
GroupGesture::Column => Action::MoveColumn { panel: drag.panel.clone(), to },
|
||||
GroupGesture::Window => Action::MoveWindow { panel: drag.panel.clone(), to },
|
||||
GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
use std::{fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Id, Key, Pos2, Rect, Ui, pos2, vec2};
|
||||
|
||||
use super::{GroupKind, Renderer, clamp_float, node_limits};
|
||||
use crate::docking::chrome::{ChromeStyle, FloatingFrame, RESIZE_BAND, TitleBar};
|
||||
use crate::docking::{Action, DropProposal, Floating, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target};
|
||||
|
||||
pub(in crate::docking) use crate::docking::chrome::RESIZE_HANDLES;
|
||||
|
||||
/// A floating window as drawn: what covers the layout behind it.
|
||||
pub(in crate::docking) struct WindowHit<P> {
|
||||
pub(super) window: Rect,
|
||||
pub(super) members: Vec<P>,
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating<P>]) {
|
||||
let screen = self.float_bounds;
|
||||
if !crate::docking::targets::usable(screen) {
|
||||
return;
|
||||
}
|
||||
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
|
||||
let mut windows: Vec<_> = windows.iter().collect();
|
||||
windows.sort_by_key(|window| {
|
||||
let id = window.panels().next().map(|panel| self.id.with(("floating", panel)));
|
||||
layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX)
|
||||
});
|
||||
#[cfg(feature = "native-windows")]
|
||||
let windows = self.native_windows(ctx, windows);
|
||||
for window in windows {
|
||||
self.floating_window(ctx, window, screen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Size limits of a floating window: its title bar and, unless it is minimized, its tree.
|
||||
pub(super) fn window_limits(&self, window: &Floating<P>) -> PanelLimits {
|
||||
let chrome = self.window_chrome(window);
|
||||
let tree = node_limits(&window.root, self.style, &*self.content);
|
||||
let body = match (&window.root, window.minimized) {
|
||||
(Node::Tabs { .. }, true) => (self.style.tab_height, self.style.tab_height),
|
||||
(_, true) => (0.0, 0.0),
|
||||
(_, false) => (tree.min.y, tree.max.y),
|
||||
};
|
||||
let min = vec2(tree.min.x, chrome + body.0).min(vec2(1_000_000.0, 1_000_000.0));
|
||||
PanelLimits { min, max: vec2(tree.max.x, chrome + body.1).max(min).min(vec2(1_000_000.0, 1_000_000.0)) }
|
||||
}
|
||||
|
||||
/// The title bar and, for a movable (resizable) window, the resize band above it.
|
||||
fn window_chrome(&self, window: &Floating<P>) -> f32 {
|
||||
let band = if window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 };
|
||||
band + self.style.title_height
|
||||
}
|
||||
|
||||
/// Where a floating window draws its tree, as [`Self::floating_window`] places it.
|
||||
pub(super) fn window_body(&self, window: &Floating<P>) -> Rect {
|
||||
let [x, y, width, height] = window.rect;
|
||||
let limits = self.window_limits(window);
|
||||
let size = vec2(width, height).max(limits.min).min(limits.max);
|
||||
let rect = crate::docking::chrome::clamp(Rect::from_min_size(pos2(x, y), size), self.float_bounds);
|
||||
Rect::from_min_max(pos2(rect.left(), (rect.top() + self.window_chrome(window)).min(rect.bottom())), rect.max)
|
||||
}
|
||||
|
||||
fn floating_window(&mut self, ctx: &Context, window: &Floating<P>, screen: Rect) {
|
||||
let Some(first) = window.panels().next() else { return };
|
||||
let [x, y, width, height] = window.rect;
|
||||
let limits = self.window_limits(window);
|
||||
let size = vec2(width, height).max(limits.min).min(limits.max);
|
||||
let rect = crate::docking::chrome::clamp(Rect::from_min_size(pos2(x, y), size), screen);
|
||||
let id = self.id.with(("floating", first));
|
||||
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
|
||||
ctx.move_to_top(FloatingFrame::layer(id));
|
||||
}
|
||||
let movable = window.panels().all(|panel| self.permissions(panel).movable);
|
||||
let style = ChromeStyle::from_dock(self.style);
|
||||
let resize_label = self.style.resize_window_label.clone();
|
||||
let response = FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).show(
|
||||
ctx,
|
||||
&style,
|
||||
|ui, contents| self.window_contents(ui, window, contents, rect, &style),
|
||||
);
|
||||
for handle in &response.handles {
|
||||
self.register_interaction(ctx, handle.id);
|
||||
}
|
||||
let down = ctx.input(|i| i.pointer.primary_down());
|
||||
ctx.data_mut(|data| {
|
||||
let key = self.id.with("pointer-resize");
|
||||
if let Some(started) = response.resize_started {
|
||||
data.insert_temp(key, started);
|
||||
} else if !down && data.get_temp::<Id>(key).is_some_and(|id| response.handles.iter().any(|h| h.id == id)) {
|
||||
data.remove::<Id>(key);
|
||||
}
|
||||
});
|
||||
if let Some(resized) = response.resized {
|
||||
self.output
|
||||
.actions
|
||||
.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] });
|
||||
}
|
||||
}
|
||||
|
||||
/// The title bar, then the window's tree below it, in `contents`. Moves carry the whole
|
||||
/// `frame`, resize band included.
|
||||
fn window_contents(&mut self, ui: &mut Ui, window: &Floating<P>, contents: Rect, frame: Rect, style: &ChromeStyle) {
|
||||
let title = Rect::from_min_max(contents.min, pos2(contents.right(), (contents.top() + self.style.title_height).min(contents.bottom())));
|
||||
self.title_bar(ui, window, title, frame, style);
|
||||
let body = Rect::from_min_max(pos2(contents.left(), title.bottom()), contents.max);
|
||||
self.floating_body(ui, window, body, frame);
|
||||
}
|
||||
|
||||
/// A floating window's tree in `body`, like the docked layout's: splits, groups, dividers
|
||||
/// and intersections. A minimized window shows the tab strip of its only group, or nothing.
|
||||
pub(super) fn floating_body(&mut self, ui: &mut Ui, window: &Floating<P>, body: Rect, frame: Rect) {
|
||||
let Some(key) = window.panels().next().cloned() else { return };
|
||||
match (&window.root, window.minimized) {
|
||||
(Node::Tabs { panels, active }, true) => self.group(ui, panels, *active, true, body, GroupKind::Floating { window: frame }),
|
||||
(_, true) => {}
|
||||
(root, false) => {
|
||||
let outer = self.tree.replace((key, frame));
|
||||
self.node(ui, root, body, &mut Vec::new(), (root, body));
|
||||
self.tree = outer;
|
||||
}
|
||||
}
|
||||
self.frame.windows.push(WindowHit { window: frame, members: window.panels().cloned().collect() });
|
||||
}
|
||||
|
||||
/// Drag moves the whole window live, double-click minimizes or expands it, × closes every
|
||||
/// panel in it.
|
||||
fn title_bar(&mut self, ui: &mut Ui, window: &Floating<P>, title: Rect, rect: Rect, style: &ChromeStyle) {
|
||||
let Some(first) = window.panels().next() else { return };
|
||||
let movable = window.panels().all(|p| self.permissions(p).movable);
|
||||
let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable);
|
||||
let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone());
|
||||
let mut bar = TitleBar::new(self.id.with(("move-group", Some(first))), &move_label).movable(movable);
|
||||
if closable {
|
||||
bar = bar.close(&close_label);
|
||||
}
|
||||
let response = bar.show(ui, title, style);
|
||||
self.register_interaction(ui.ctx(), response.bar.id);
|
||||
if movable && response.bar.drag_started() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(title.center());
|
||||
let drag = GroupDrag {
|
||||
panel: first.clone(),
|
||||
original: [rect.left(), rect.top(), rect.width(), rect.height()],
|
||||
gesture: GroupGesture::Window,
|
||||
members: window.panels().cloned().collect(),
|
||||
origin,
|
||||
source: rect,
|
||||
};
|
||||
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag));
|
||||
}
|
||||
if movable
|
||||
&& response.bar.dragged()
|
||||
&& let Some(drag) = ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag")))
|
||||
&& drag.gesture == GroupGesture::Window
|
||||
&& let Some(pointer) = ui.ctx().pointer_interact_pos()
|
||||
&& !ui.input(|i| i.key_pressed(Key::Escape))
|
||||
&& !crate::focus::lost(ui.ctx())
|
||||
{
|
||||
// From the press origin, so fractional pointer steps do not accumulate rounding.
|
||||
let moved = clamp_float(drag.source.translate(pointer - drag.origin), self.float_bounds);
|
||||
self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] });
|
||||
}
|
||||
if response.bar.double_clicked() {
|
||||
self.output.actions.push(Action::SetMinimized { panel: first.clone(), minimized: !window.minimized });
|
||||
}
|
||||
if let Some(close) = response.close {
|
||||
self.register_interaction(ui.ctx(), close.id);
|
||||
if close.clicked() {
|
||||
self.output.actions.push(Action::CloseWindow { panel: first.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The title bar of a floating window occludes what lies behind it: a no-op proposal. Then
|
||||
/// the layout's outer edge (P8). None leaves the pointer to the group targets. `skip_own`
|
||||
/// looks through the window holding `members` (a window moving live).
|
||||
pub(super) fn extra_proposal(&self, members: &[P], pointer: Pos2, skip_own: bool) -> Option<DropProposal<P>> {
|
||||
let own = |panels: &[P]| panels.iter().any(|p| members.contains(p));
|
||||
let window = self.frame.windows.iter().rev().find(|w| w.window.contains(pointer) && !(skip_own && own(&w.members)));
|
||||
if let Some(window) = window {
|
||||
let in_group = self.targets.iter().any(|t| t.rect.contains(pointer) && t.tabs.iter().any(|(p, _)| window.members.contains(p)));
|
||||
if in_group {
|
||||
return None;
|
||||
}
|
||||
let anchor = members.first()?.clone();
|
||||
return Some(DropProposal { anchor, placement: Placement::Tab { before: None }, preview: Rect::NOTHING, insertion: None, noop: true });
|
||||
}
|
||||
let (anchor, zone, preview) = self.edge_proposal(members, pointer)?;
|
||||
let lead = members.first()?.clone();
|
||||
let action = match members {
|
||||
[_] => Action::Move { panel: lead, anchor: anchor.clone(), placement: Placement::Edge(zone) },
|
||||
_ => Action::MoveGroup { panel: lead, to: Target::At { anchor: anchor.clone(), placement: Placement::Edge(zone) } },
|
||||
};
|
||||
if !self.leaves_room(action) {
|
||||
return None;
|
||||
}
|
||||
Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false })
|
||||
}
|
||||
|
||||
/// What a group drag carries: the whole window rect for the only group of a floating
|
||||
/// window, so floating it again keeps the window's size; else the group's rect.
|
||||
pub(super) fn drag_source(&self, owner: &super::group::TabGroup<'_, P>) -> Rect {
|
||||
match owner.kind {
|
||||
GroupKind::Floating { window } if owner.panels.first().is_some_and(|p| self.whole_window(p)) => window,
|
||||
_ => owner.rect,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a double-click or the group menu minimizes `owner`: a dock group, or the only
|
||||
/// group of a floating window, which minimizes the window to that group's strip.
|
||||
pub(super) fn minimizes(&self, owner: &super::group::TabGroup<'_, P>) -> bool {
|
||||
match owner.kind {
|
||||
GroupKind::Dock => true,
|
||||
GroupKind::Floating { .. } => self.whole_window(owner.selected),
|
||||
GroupKind::Frame => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `panel`'s group is the whole tree of a floating window.
|
||||
pub(super) fn whole_window(&self, panel: &P) -> bool {
|
||||
matches!(self.place(panel), Some(crate::docking::dock_model::Place::Floating { path, .. }) if path.is_empty())
|
||||
}
|
||||
|
||||
/// Whether moving a whole window docks it: [`crate::docking::DockStyle::title_bar_docks`],
|
||||
/// or a native window moved by its only tab or group.
|
||||
pub(super) fn title_bar_docks(&self) -> bool {
|
||||
#[cfg(feature = "native-windows")]
|
||||
if self.cross.docks {
|
||||
return true;
|
||||
}
|
||||
self.style.title_bar_docks
|
||||
}
|
||||
|
||||
/// Release of a title bar drag. The window already sits where it was dragged; a dock or
|
||||
/// group target docks or merges every group in it, else hosts get the release in
|
||||
/// `group_drop`.
|
||||
pub(super) fn drop_window(&mut self, drag: &GroupDrag<P>, pointer: Pos2, float_only: bool) {
|
||||
let float_only = float_only || !self.title_bar_docks();
|
||||
if !float_only && let Some((action, _)) = self.dock_group_proposal(drag, pointer) {
|
||||
self.output.actions.extend(action);
|
||||
return;
|
||||
}
|
||||
let proposal = if float_only { None } else { self.group_proposal(drag, pointer) };
|
||||
match proposal.filter(|p| !p.noop) {
|
||||
Some(proposal) => {
|
||||
let to = Target::At { anchor: proposal.anchor, placement: proposal.placement };
|
||||
self.output.actions.push(super::dock::group_action(drag, to));
|
||||
}
|
||||
None => self.output.group_drop = Some((drag.panel.clone(), pointer)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! What the split tree adds around the groups: Maximize Frame, the outer edge band, hidden
|
||||
//! panels, the float hint and the group menu tail.
|
||||
|
||||
use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash};
|
||||
|
||||
use egui::{Context, Pos2, Rect, pos2, vec2};
|
||||
|
||||
use super::{GroupKind, Renderer, floating::WindowHit, group::TabGroup};
|
||||
use crate::docking::{
|
||||
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Zone,
|
||||
dock_model::{Place, node_at},
|
||||
};
|
||||
|
||||
/// How far below the pointer the float hint sits, clear of the cursor.
|
||||
const HINT_OFFSET: f32 = 28.0;
|
||||
|
||||
/// What the renderer draws when it is not the layout itself, and the targets that depend on it.
|
||||
pub(in crate::docking) struct FrameState<P> {
|
||||
/// View path to layout path of each split, when the view is not the layout.
|
||||
paths: Option<HashMap<Vec<bool>, Vec<bool>>>,
|
||||
/// The same for each floating window's splits, by the window's first shown panel.
|
||||
window_paths: HashMap<P, HashMap<Vec<bool>, Vec<bool>>>,
|
||||
/// The view node whose rect is the outer edge area; none while a frame is maximized.
|
||||
edge_path: Option<Vec<bool>>,
|
||||
pub(super) edge: Option<Rect>,
|
||||
pub(super) maximized: Option<P>,
|
||||
/// Floating windows as drawn, back to front.
|
||||
pub(super) windows: Vec<WindowHit<P>>,
|
||||
}
|
||||
|
||||
/// The layout as shown: a maximized frame alone, and hidden panels left out.
|
||||
pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
|
||||
layout: &'a Layout<P>,
|
||||
maximized: Option<&P>,
|
||||
hide: HidePanels<'_, P>,
|
||||
content: &dyn DockContent<P>,
|
||||
) -> (Cow<'a, Layout<P>>, FrameState<P>) {
|
||||
let protected = |p: &P| content.permissions(p).is_protected();
|
||||
let hidden = |p: &P| match hide {
|
||||
HidePanels::None => false,
|
||||
HidePanels::All => !protected(p),
|
||||
HidePanels::Except(keep) => !protected(p) && !keep(p),
|
||||
};
|
||||
// Only a docked panel maximizes its frame.
|
||||
let maximized = maximized.and_then(|panel| match layout.place(panel)? {
|
||||
Place::Node(path) => Some((panel.clone(), path.clone(), layout.root.as_ref().and_then(|root| node_at(root, &path))?.clone())),
|
||||
place @ Place::Dock { .. } => {
|
||||
let group = layout.group(&place)?;
|
||||
Some((panel.clone(), path_of(&place), Node::Tabs { panels: group.panels.clone(), active: group.active }))
|
||||
}
|
||||
Place::Floating { .. } => None,
|
||||
});
|
||||
let mut state = FrameState { paths: None, window_paths: HashMap::new(), edge_path: None, edge: None, maximized: None, windows: Vec::new() };
|
||||
if maximized.is_none() && matches!(hide, HidePanels::None) {
|
||||
state.edge_path = Some(layout.edge_path());
|
||||
return (Cow::Borrowed(layout), state);
|
||||
}
|
||||
let mut paths = HashMap::new();
|
||||
let view = match maximized {
|
||||
Some((panel, path, node)) => {
|
||||
state.maximized = Some(panel);
|
||||
Layout { root: prune(&node, path, Vec::new(), &hidden, &mut paths), floating: Vec::new() }
|
||||
}
|
||||
None => {
|
||||
let root = layout.root.as_ref().and_then(|root| prune(root, Vec::new(), Vec::new(), &hidden, &mut paths));
|
||||
let mut floating = Vec::new();
|
||||
for window in &layout.floating {
|
||||
let mut paths = HashMap::new();
|
||||
let Some(root) = prune(&window.root, Vec::new(), Vec::new(), &hidden, &mut paths) else { continue };
|
||||
let shown = Floating { root, ..window.clone() };
|
||||
if let Some(key) = shown.panels().next() {
|
||||
state.window_paths.insert(key.clone(), paths);
|
||||
}
|
||||
floating.push(shown);
|
||||
}
|
||||
Layout { root, floating }
|
||||
}
|
||||
};
|
||||
if state.maximized.is_none() {
|
||||
state.edge_path = Some(view.edge_path());
|
||||
}
|
||||
state.paths = Some(paths);
|
||||
(Cow::Owned(view), state)
|
||||
}
|
||||
|
||||
fn path_of(place: &Place) -> Vec<bool> {
|
||||
match place {
|
||||
Place::Node(path) | Place::Dock { path, .. } => path.clone(),
|
||||
Place::Floating { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn visible<P>(node: &Node<P>, hidden: &dyn Fn(&P) -> bool) -> bool {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => visible(first, hidden) || visible(second, hidden),
|
||||
Node::Tabs { panels, .. } => panels.iter().any(|p| !hidden(p)),
|
||||
Node::Stack { entries } => entries.iter().any(|e| !hidden(&e.panel)),
|
||||
Node::Dock { columns, .. } => columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).any(|p| !hidden(p)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `node` without hidden panels; a split with one visible side becomes that side. Records the
|
||||
/// layout path of every split kept.
|
||||
fn prune<P: Clone + PartialEq>(
|
||||
node: &Node<P>,
|
||||
path: Vec<bool>,
|
||||
at: Vec<bool>,
|
||||
hidden: &dyn Fn(&P) -> bool,
|
||||
paths: &mut HashMap<Vec<bool>, Vec<bool>>,
|
||||
) -> Option<Node<P>> {
|
||||
let child = |path: &[bool], side: bool| [path, &[side]].concat();
|
||||
match node {
|
||||
Node::Split { axis, size, first, second } => match (visible(first, hidden), visible(second, hidden)) {
|
||||
(true, true) => {
|
||||
let first = prune(first, child(&path, false), child(&at, false), hidden, paths)?;
|
||||
let second = prune(second, child(&path, true), child(&at, true), hidden, paths)?;
|
||||
paths.insert(at, path);
|
||||
Some(Node::Split { axis: *axis, size: *size, first: Box::new(first), second: Box::new(second) })
|
||||
}
|
||||
(true, false) => prune(first, child(&path, false), at, hidden, paths),
|
||||
(false, true) => prune(second, child(&path, true), at, hidden, paths),
|
||||
(false, false) => None,
|
||||
},
|
||||
Node::Tabs { panels, active } => {
|
||||
let group = prune_group(&Group::new(panels.clone(), *active), hidden)?;
|
||||
Some(Node::Tabs { panels: group.panels, active: group.active })
|
||||
}
|
||||
Node::Stack { entries } => {
|
||||
let entries: Vec<_> = entries.iter().filter(|e| !hidden(&e.panel)).cloned().collect();
|
||||
(!entries.is_empty()).then_some(Node::Stack { entries })
|
||||
}
|
||||
Node::Dock { side, columns } => {
|
||||
let columns: Vec<_> = columns
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let groups: Vec<_> = c.groups.iter().filter_map(|g| prune_group(g, hidden)).collect();
|
||||
(!groups.is_empty()).then(|| Column { groups, ..c.clone() })
|
||||
})
|
||||
.collect();
|
||||
(!columns.is_empty()).then_some(Node::Dock { side: *side, columns })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group without hidden panels; the selected panel stays selected when it is shown.
|
||||
fn prune_group<P: Clone + PartialEq>(group: &Group<P>, hidden: &dyn Fn(&P) -> bool) -> Option<Group<P>> {
|
||||
let selected = group.active_panel();
|
||||
let panels: Vec<P> = group.panels.iter().filter(|p| !hidden(p)).cloned().collect();
|
||||
let active = selected.and_then(|s| panels.iter().position(|p| p == s)).unwrap_or(0);
|
||||
(!panels.is_empty()).then(|| Group { panels, active, ..group.clone() })
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) {
|
||||
if self.tree.is_none() && self.frame.edge_path.as_deref() == Some(path) {
|
||||
self.frame.edge = Some(rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout path of a split drawn at view path `path`, in the tree being drawn.
|
||||
pub(super) fn original_path(&self, path: &[bool]) -> Vec<bool> {
|
||||
let paths = match &self.tree {
|
||||
None => self.frame.paths.as_ref(),
|
||||
Some((key, _)) => self.frame.window_paths.get(key),
|
||||
};
|
||||
paths.and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec())
|
||||
}
|
||||
|
||||
/// The outer edge band under the pointer (P8): top and bottom, and left and right with
|
||||
/// [`crate::docking::DockStyle::side_edge_zones`]. Returns a docked anchor that is not
|
||||
/// moving, the zone and the preview of the new frame.
|
||||
pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> {
|
||||
let area = self.frame.edge.filter(|area| area.contains(pointer))?;
|
||||
let band = self.style.edge_band.min(area.width() / 4.0).min(area.height() / 4.0);
|
||||
let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)];
|
||||
if self.style.side_edge_zones {
|
||||
edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]);
|
||||
}
|
||||
let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?;
|
||||
if distance > band {
|
||||
return None;
|
||||
}
|
||||
let anchor =
|
||||
self.layout.panels().into_iter().find(|p| !members.contains(p) && !matches!(self.place(p), Some(Place::Floating { .. }) | None))?.clone();
|
||||
let size = area.size() * 0.25;
|
||||
let preview = match zone {
|
||||
Zone::Left => Rect::from_min_size(area.min, vec2(size.x, area.height())),
|
||||
Zone::Right => Rect::from_min_max(pos2(area.right() - size.x, area.top()), area.max),
|
||||
Zone::Top => Rect::from_min_size(area.min, vec2(area.width(), size.y)),
|
||||
Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(area.left(), area.bottom() - size.y), area.max),
|
||||
};
|
||||
Some((anchor, zone, preview))
|
||||
}
|
||||
|
||||
/// Under the pointer while a drag offers a docking target: which modifier floats the drop
|
||||
/// instead. With native windows, the window under the pointer draws it, beside its preview.
|
||||
pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) {
|
||||
if !self.style.float_hint || self.style.float_hint_label.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = if ctx.os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" };
|
||||
let painter = self.preview_painter(ctx);
|
||||
let galley = painter.layout_no_wrap(self.style.float_hint_label.replace("{key}", key), self.style.font.clone(), self.style.text);
|
||||
let rect = Rect::from_center_size(pointer + vec2(0.0, HINT_OFFSET), galley.size() + vec2(12.0, 6.0));
|
||||
let rect = crate::docking::chrome::clamp(rect, self.float_bounds);
|
||||
painter.rect(rect, 4.0, self.style.floating_background, egui::Stroke::new(1.0, self.style.accent), egui::StrokeKind::Inside);
|
||||
painter.galley(rect.center() - galley.size() * 0.5, galley, self.style.text);
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd held when a split-tree tab drag starts: the drop floats (S9).
|
||||
pub(super) fn latch_undock(&self, ctx: &Context, kind: Option<GroupKind>) {
|
||||
let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd);
|
||||
if self.style.command_press_floats && matches!(kind, Some(GroupKind::Frame | GroupKind::Floating { .. })) && command {
|
||||
ctx.data_mut(|d| {
|
||||
if let Some(drag) = d.get_temp::<crate::docking::Drag<P>>(self.id.with("drag")) {
|
||||
d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu tail: Close Other Panels in Group and Maximize Frame when the style shows
|
||||
/// them.
|
||||
pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) {
|
||||
let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable);
|
||||
if self.style.close_others_item
|
||||
&& owner.panels.len() > 1
|
||||
&& others
|
||||
&& self.style.close_menu_items
|
||||
&& self.menu_action(ui, &self.style.close_others_label.clone())
|
||||
{
|
||||
self.output.actions.push(Action::CloseOthers { panel: panel.clone() });
|
||||
}
|
||||
if self.style.maximize_item && owner.kind == GroupKind::Frame {
|
||||
let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m));
|
||||
let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() };
|
||||
if self.menu_action(ui, &label) {
|
||||
let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() };
|
||||
self.output.events.push(DockEvent::ToggleMaximize { panel });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||