diff --git a/.github/workflows/jpeg.yml b/.github/workflows/jpeg.yml new file mode 100644 index 00000000..d10c2598 --- /dev/null +++ b/.github/workflows/jpeg.yml @@ -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 diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index 355f52d2..45b2b363 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 120bfaf1..192a05c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 953e5b68..d8987248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 11a13d3f..9ec49a2a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " { /* 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) -> Result`. + `adobe_transform` is the transform byte of the APP14 `Adobe` segment, when the app found one. +- `frame(bytes) -> Option` 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. diff --git a/crates/jpeg/fuzz/.gitignore b/crates/jpeg/fuzz/.gitignore new file mode 100644 index 00000000..d02230c0 --- /dev/null +++ b/crates/jpeg/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +artifacts +coverage +Cargo.lock diff --git a/crates/jpeg/fuzz/Cargo.toml b/crates/jpeg/fuzz/Cargo.toml new file mode 100644 index 00000000..c4881e66 --- /dev/null +++ b/crates/jpeg/fuzz/Cargo.toml @@ -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 diff --git a/crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg b/crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg new file mode 100644 index 00000000..7c0e6860 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/sof10-progressive.jpg b/crates/jpeg/fuzz/corpus/decode/sof10-progressive.jpg new file mode 100644 index 00000000..f29a0a3c Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/sof10-progressive.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg b/crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg new file mode 100644 index 00000000..dd053182 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg b/crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg new file mode 100644 index 00000000..c165ab46 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testimgari-huffman.jpg b/crates/jpeg/fuzz/corpus/decode/testimgari-huffman.jpg new file mode 100644 index 00000000..2501c615 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testimgari-huffman.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testimgari.jpg b/crates/jpeg/fuzz/corpus/decode/testimgari.jpg new file mode 100644 index 00000000..89664874 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testimgari.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive-restart.jpg b/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive-restart.jpg new file mode 100644 index 00000000..10c5081f Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive-restart.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive.jpg b/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive.jpg new file mode 100644 index 00000000..e814c929 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testorig.jpg b/crates/jpeg/fuzz/corpus/decode/testorig.jpg new file mode 100644 index 00000000..9816a0c6 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testorig.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testorig12-ari-progressive.jpg b/crates/jpeg/fuzz/corpus/decode/testorig12-ari-progressive.jpg new file mode 100644 index 00000000..3a716ea7 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testorig12-ari-progressive.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testorig12-ari.jpg b/crates/jpeg/fuzz/corpus/decode/testorig12-ari.jpg new file mode 100644 index 00000000..66478ad5 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testorig12-ari.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testorig12.jpg b/crates/jpeg/fuzz/corpus/decode/testorig12.jpg new file mode 100644 index 00000000..861aff98 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testorig12.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/whitetail-ari.jpg b/crates/jpeg/fuzz/corpus/decode/whitetail-ari.jpg new file mode 100644 index 00000000..e7011fb6 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/whitetail-ari.jpg differ diff --git a/crates/jpeg/fuzz/fuzz_targets/decode.rs b/crates/jpeg/fuzz/fuzz_targets/decode.rs new file mode 100644 index 00000000..ed96fafe --- /dev/null +++ b/crates/jpeg/fuzz/fuzz_targets/decode.rs @@ -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)); + } +}); diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs new file mode 100644 index 00000000..d9ef3bd3 --- /dev/null +++ b/crates/jpeg/src/lib.rs @@ -0,0 +1,1348 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Clean-room decoder for the JPEG processes most Rust JPEG decoders don't handle, written from +//! ITU-T T.81 (ISO/IEC 10918-1). +//! +//! [`decode`] supports these frames: +//! +//! - sequential DCT, Huffman coded, 8 or 12-bit samples (SOF0 and SOF1; SOF0 files that declare +//! 12 bits are read as SOF1); +//! - extended sequential DCT, arithmetic coded, 8 or 12-bit (SOF9); +//! - progressive DCT, arithmetic coded, 8 or 12-bit (SOF10). +//! +//! Every other frame returns [`Error::Unsupported`]: progressive Huffman (SOF2), lossless (SOF3, +//! SOF11) and hierarchical (SOF5–SOF7, SOF13–SOF15) files, and DCT precisions other than 8 and +//! 12 bits. +//! +//! An app that also uses a baseline decoder routes each file with [`frame`], which reports the +//! first frame marker and its precision without decoding. For example, to send this crate the +//! arithmetic-coded files and the Huffman-coded ones that aren't 8-bit: +//! +//! ``` +//! 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, +//! } +//! } +//! # assert!(!use_craft_jpeg(&[0xFF, 0xD8])); +//! ``` +//! +//! Every scan is decoded into a whole-image buffer of quantized coefficients (Annex F for the +//! sequential scans, Annex G for the progressive ones; the arithmetic decoder of Annex D with the +//! conditioning of `DAC`), then dequantized, inverse transformed in floating point (A.3.3), +//! upsampled and color converted. 12-bit images come out as 16-bit samples (scaled, not +//! truncated). Restart intervals, 1, 3 and 4 components and any sampling factors are supported. +//! Metadata (ICC profiles, EXIF, XMP, Photoshop resources, resolution) is left to the caller, +//! which also reads the APP14 `Adobe` segment and passes its transform byte to [`decode`]. +//! +//! The decoder never panics on input: every read from the stream is bounds-checked, the work +//! is bounded by 64 passes over the frame's blocks (`SCAN_PASSES`), and +//! allocations are checked against the [`Limits`] first. +//! Damaged entropy-coded data stops that scan; what was decoded is kept and the caller is told +//! through [`Decoded::damaged`]. + +/// Why a stream could not be decoded. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The stream breaks T.81 in a way the decoder cannot recover from. + #[error("malformed JPEG: {0}")] + Malformed(String), + /// A valid JPEG process or feature this decoder does not implement. + #[error("{0}")] + Unsupported(String), + /// Decoding would exceed the caller's [`Limits`]. + #[error("limit exceeded: {0}")] + LimitExceeded(String), +} + +fn err(m: impl std::fmt::Display) -> Error { + Error::Malformed(m.to_string()) +} + +fn unsupported(m: impl Into) -> Error { + Error::Unsupported(m.into()) +} + +/// Decompression-bomb guards, checked against the frame header before any image buffer is +/// allocated. +/// +/// ``` +/// let mut limits = craft_jpeg::Limits::default(); +/// limits.max_alloc = 512 << 20; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Maximum `width * height`. + pub max_pixels: u64, + /// Maximum bytes for the decoded output and the decoder's own coefficient and sample + /// buffers together. Totals above `isize::MAX` are rejected whatever this is set to. + pub max_alloc: u64, +} + +impl Default for Limits { + fn default() -> Self { + // 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB. On 32-bit targets (wasm32 has + // a 4 GiB address space shared with the rest of the app) 1 GiB is the most one buffer can + // safely ask for. + let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 }; + Limits { max_pixels: 1 << 30, max_alloc } + } +} + +impl Limits { + /// Check an output buffer of `width × height` pixels of `bytes_per_pixel` bytes. + fn check(&self, width: usize, height: usize, bytes_per_pixel: u64) -> Result<(), Error> { + if width == 0 || height == 0 { + return Err(err(format!("zero-sized image {width}x{height}"))); + } + let pixels = (width as u64).saturating_mul(height as u64); + if pixels > self.max_pixels { + return Err(Error::LimitExceeded(format!("{pixels} pixels exceed max {}", self.max_pixels))); + } + let bytes = pixels.saturating_mul(bytes_per_pixel); + if bytes > self.max_alloc { + return Err(Error::LimitExceeded(format!("{bytes} bytes exceed max_alloc {}", self.max_alloc))); + } + if usize::try_from(bytes).is_err() { + return Err(Error::LimitExceeded(format!("{bytes} bytes do not fit in memory"))); + } + Ok(()) + } +} + +/// Zigzag scan position `k` → natural (row-major) coefficient index (Figure A.6). +const ZIGZAG: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, + 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +/// The first frame header of a stream, as reported by [`frame`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameKind { + /// The second byte of the SOFn marker: `0xC0` (SOF0) to `0xCF` (SOF15), never `0xC4`, + /// `0xC8` or `0xCC`. + pub marker: u8, + /// The sample precision in bits (P in B.2.2), as declared; not validated. + pub precision: u8, +} + +/// The first frame marker (SOFn) and its sample precision, found by walking the marker +/// segments that precede it. `None` when the stream doesn't start with SOI or reaches a scan, +/// EOI or the end of the data first. Reads only the headers, so it is cheap enough to route +/// every JPEG an app opens. +pub fn frame(b: &[u8]) -> Option { + if b.get(..2) != Some(&[0xFF, 0xD8]) { + return None; + } + let mut i = 2; + loop { + if *b.get(i)? != 0xFF { + return None; + } + let m = *b.get(i + 1)?; + match m { + 0xFF => i += 1, + 0x01 | 0xD0..=0xD8 => i += 2, + 0xD9 | 0xDA => return None, + _ => { + let len = usize::from(u16::from_be_bytes([*b.get(i + 2)?, *b.get(i + 3)?])); + if matches!(m, 0xC0..=0xCF) && !matches!(m, 0xC4 | 0xC8 | 0xCC) { + return Some(FrameKind { marker: m, precision: *b.get(i + 4)? }); + } + i = i.checked_add(2 + len)?; + } + } + } +} + +/// Decoded samples, interleaved by pixel (`components` values per pixel), rows top to bottom. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Samples { + /// 8-bit frames: `0..=255`. + U8(Vec), + /// 12-bit frames, scaled to the full 16-bit range: `round(v × 65535 / 4095)`. + U16(Vec), +} + +/// A decoded image. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decoded { + pub width: u32, + pub height: u32, + /// Samples per pixel: 1 for gray, 3 for RGB, 4 for CMYK. + /// + /// Three-component frames are converted from YCbCr to RGB (JFIF, T.871) unless the Adobe + /// transform is 0 or the component IDs are `R`, `G`, `B`. Four-component samples are ink + /// amounts (0 = no ink): the stored values are taken to follow Adobe's convention, inverted + /// (maximum = no ink), and are inverted back. With Adobe transform 2 (YCCK) the first three + /// components are converted from YCbCr, which yields the ink amounts of C, M and Y directly, + /// and K is inverted. + pub components: u8, + pub samples: Samples, + /// Entropy-coded data was damaged or cut short: the rest of that scan, and any scans past + /// the decoder's cap, were left out, and those blocks keep what earlier scans decoded. + pub damaged: bool, +} + +// --------------------------------------------------------------------------------------------- +// Tables and frame + +/// A Huffman table in the decoding form of F.2.2.3 (`MAXCODE`, `VALPTR`, `MINCODE`). +#[derive(Clone)] +struct Huffman { + maxcode: [i32; 17], + valptr: [i32; 17], + mincode: [i32; 17], + values: Vec, +} + +impl Huffman { + fn new(counts: &[u8], values: &[u8]) -> Result { + let mut h = Huffman { maxcode: [-1; 17], valptr: [0; 17], mincode: [0; 17], values: values.to_vec() }; + let mut code: i32 = 0; + let mut k: i32 = 0; + for (l, &n) in (1..=16usize).zip(counts) { + let n = i32::from(n); + if n > 0 { + h.valptr[l] = k; + h.mincode[l] = code; + code += n; + k += n; + h.maxcode[l] = code - 1; + if code > 1 << l { + return Err(err("Huffman table has too many codes")); + } + } + code <<= 1; + } + Ok(h) + } + + fn decode(&self, bits: &mut Bits) -> Result { + let mut code: i32 = 0; + for l in 1..=16 { + code = (code << 1) | bits.bit() as i32; + if code <= self.maxcode[l] { + let idx = self.valptr[l] + code - self.mincode[l]; + return usize::try_from(idx).ok().and_then(|i| self.values.get(i)).copied().ok_or_else(|| err("bad Huffman code")); + } + } + Err(err("bad Huffman code")) + } +} + +struct Tables { + /// Quantization tables in zigzag order. + quant: [Option<[u16; 64]>; 4], + dc: [Option; 4], + ac: [Option; 4], + /// Arithmetic DC conditioning (L, U) and AC conditioning (Kx) per table (F.1.4.4; defaults + /// of B.2.4.3: L = 0, U = 1, Kx = 5). + dc_l: [u8; 4], + dc_u: [u8; 4], + ac_kx: [u8; 4], + restart: usize, +} + +impl Default for Tables { + fn default() -> Self { + Tables { quant: [None; 4], dc: Default::default(), ac: Default::default(), dc_l: [0; 4], dc_u: [1; 4], ac_kx: [5; 4], restart: 0 } + } +} + +struct Component { + id: u8, + h: usize, + v: usize, + tq: usize, + /// Samples in this component (B.2.2: ⌈X·H/Hmax⌉ × ⌈Y·V/Vmax⌉). + cw: usize, + ch: usize, + /// Blocks per line and block lines, padded to whole MCUs. + bw: usize, + bh: usize, + /// Quantized coefficients, one block per entry, in zigzag order. + coefs: Vec<[i32; 64]>, + /// Table `tq` as it was at the component's first scan. B.2.2 lets a DQT redefine the slot + /// once every scan of the component is done; libjpeg latches it the same way + /// (`latch_quant_tables`). + quant: Option<[u16; 64]>, +} + +struct Frame { + arithmetic: bool, + progressive: bool, + precision: u8, + width: usize, + height: usize, + hmax: usize, + vmax: usize, + mcux: usize, + mcuy: usize, + comps: Vec, +} + +fn be16(s: &[u8], at: usize) -> Result { + match (s.get(at), s.get(at + 1)) { + (Some(&a), Some(&b)) => Ok(u16::from_be_bytes([a, b])), + _ => Err(err("segment too short")), + } +} + +fn byte(s: &[u8], at: usize) -> Result { + s.get(at).copied().ok_or_else(|| err("segment too short")) +} + +fn parse_dqt(seg: &[u8], t: &mut Tables) -> Result<(), Error> { + let mut i = 0; + while i < seg.len() { + let pq_tq = byte(seg, i)?; + let (pq, tq) = (pq_tq >> 4, usize::from(pq_tq & 15)); + i += 1; + let mut q = [0u16; 64]; + for v in &mut q { + if pq == 0 { + *v = u16::from(byte(seg, i)?); + i += 1; + } else { + *v = be16(seg, i)?; + i += 2; + } + } + let slot = t.quant.get_mut(tq).ok_or_else(|| err("bad quantization table id"))?; + *slot = Some(q); + } + Ok(()) +} + +fn parse_dht(seg: &[u8], t: &mut Tables) -> Result<(), Error> { + let mut i = 0; + while i < seg.len() { + let tc_th = byte(seg, i)?; + let counts = seg.get(i + 1..i + 17).ok_or_else(|| err("Huffman table too short"))?; + let total: usize = counts.iter().map(|&c| usize::from(c)).sum(); + let values = seg.get(i + 17..i + 17 + total).ok_or_else(|| err("Huffman table too short"))?; + let table = Huffman::new(counts, values)?; + let slot = match tc_th >> 4 { + 0 => t.dc.get_mut(usize::from(tc_th & 15)), + 1 => t.ac.get_mut(usize::from(tc_th & 15)), + _ => None, + }; + *slot.ok_or_else(|| err("bad Huffman table id"))? = Some(table); + i += 17 + total; + } + Ok(()) +} + +fn parse_dac(seg: &[u8], t: &mut Tables) -> Result<(), Error> { + for pair in seg.chunks(2) { + let [tc_tb, cs] = *pair else { return Err(err("DAC segment too short")) }; + let tb = usize::from(tc_tb & 15); + match tc_tb >> 4 { + 0 => { + let (l, u) = (cs & 15, cs >> 4); + if l > u { + return Err(err("DAC conditioning has L > U")); + } + *t.dc_l.get_mut(tb).ok_or_else(|| err("bad DAC table id"))? = l; + *t.dc_u.get_mut(tb).ok_or_else(|| err("bad DAC table id"))? = u; + } + 1 => { + if !(1..=63).contains(&cs) { + return Err(err("DAC Kx out of range")); + } + *t.ac_kx.get_mut(tb).ok_or_else(|| err("bad DAC table id"))? = cs; + } + _ => return Err(err("bad DAC table class")), + } + } + Ok(()) +} + +fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result { + let (arithmetic, progressive) = match marker { + 0xC0 | 0xC1 => (false, false), + 0xC9 => (true, false), + 0xCA => (true, true), + 0xC2 => return Err(unsupported("progressive Huffman-coded JPEG is not supported")), + 0xC3 | 0xCB => return Err(unsupported("lossless JPEG is not supported")), + _ => return Err(unsupported("hierarchical JPEG is not supported")), + }; + let precision = byte(seg, 0)?; + if precision != 8 && precision != 12 { + return Err(unsupported(format!("{precision}-bit DCT JPEG is not supported"))); + } + let height = usize::from(be16(seg, 1)?); + let width = usize::from(be16(seg, 3)?); + if height == 0 { + return Err(unsupported("JPEG with the height in a DNL marker is not supported")); + } + let nf = usize::from(byte(seg, 5)?); + if !matches!(nf, 1 | 3 | 4) { + return Err(unsupported(format!("JPEG with {nf} components is not supported"))); + } + let mut raw = Vec::with_capacity(nf); + for c in 0..nf { + let at = 6 + 3 * c; + let (id, hv, tq) = (byte(seg, at)?, byte(seg, at + 1)?, byte(seg, at + 2)?); + let (h, v) = (usize::from(hv >> 4), usize::from(hv & 15)); + if !(1..=4).contains(&h) || !(1..=4).contains(&v) || tq > 3 { + return Err(err("bad component sampling factors or table")); + } + raw.push((id, h, v, usize::from(tq))); + } + let hmax = raw.iter().map(|c| c.1).max().unwrap_or(1); + let vmax = raw.iter().map(|c| c.2).max().unwrap_or(1); + let bytes_per_pixel = nf as u64 * if precision > 8 { 2 } else { 1 }; + limits.check(width, height, bytes_per_pixel)?; + let mcux = width.div_ceil(8 * hmax); + let mcuy = height.div_ceil(8 * vmax); + // `render` holds the coefficients (4 bytes) and the reconstructed plane (4 bytes) of every + // padded sample while it writes the output, so all three count against `max_alloc` together. + let mut total = (width as u64 * height as u64).saturating_mul(bytes_per_pixel); + for &(_, h, v, _) in &raw { + let samples = (mcux * h * 8) as u64 * (mcuy * v * 8) as u64; + total = total.saturating_add(samples.saturating_mul(8)); + } + // No allocation may pass `isize::MAX` bytes, which matters on 32-bit targets when a caller + // raises `max_alloc`. + let cap = limits.max_alloc.min(isize::MAX as u64); + if total > cap { + return Err(Error::LimitExceeded(format!("{total} bytes of decoder buffers and output exceed the limit of {cap}"))); + } + let mut comps = Vec::with_capacity(nf); + for (id, h, v, tq) in raw { + let (bw, bh) = (mcux * h, mcuy * v); + let blocks = bw.checked_mul(bh).ok_or_else(|| Error::LimitExceeded("too many blocks".into()))?; + comps.push(Component { + id, + h, + v, + tq, + cw: (width * h).div_ceil(hmax), + ch: (height * v).div_ceil(vmax), + bw, + bh, + coefs: vec![[0; 64]; blocks], + quant: None, + }); + } + Ok(Frame { arithmetic, progressive, precision, width, height, hmax, vmax, mcux, mcuy, comps }) +} + +// --------------------------------------------------------------------------------------------- +// Entropy-coded segments + +/// Where the entropy-coded data starting at `start` ends: the first marker that isn't a +/// stuffed `FF 00`, a fill byte or a restart marker. +fn scan_end(b: &[u8], start: usize) -> usize { + let mut i = start; + while let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) { + i += p; + match b.get(i + 1) { + Some(0x00 | 0xD0..=0xD7) => i += 2, + Some(0xFF) => i += 1, + Some(_) => return i, + None => return b.len(), + } + } + b.len() +} + +/// Past the next restart marker at or after `pos`, and `true`; or at the next other marker +/// (or the end) and `false`. +fn skip_restart(d: &[u8], mut pos: usize) -> (usize, bool) { + while let Some(&v) = d.get(pos) { + if v != 0xFF { + pos += 1; + continue; + } + match d.get(pos + 1) { + Some(0xD0..=0xD7) => return (pos + 2, true), + Some(0x00) => pos += 2, + Some(0xFF) => pos += 1, + _ => return (pos, false), + } + } + (pos, false) +} + +/// Next data byte for the decoders: `FF 00` is a data `FF`; at a marker (or the end) zeros +/// are supplied and the marker is not consumed (D.2.6, F.2.2.5). +fn next_byte(d: &[u8], pos: &mut usize) -> Option { + let &v = d.get(*pos)?; + if v != 0xFF { + *pos += 1; + return Some(v); + } + let mut p = *pos + 1; + while d.get(p) == Some(&0xFF) { + p += 1; + } + if d.get(p) == Some(&0x00) { + *pos = p + 1; + Some(0xFF) + } else { + *pos = p - 1; + None + } +} + +/// Huffman bit reader (F.2.2.5). +struct Bits<'a> { + d: &'a [u8], + pos: usize, + acc: u32, + n: u32, + /// How many of the `n` buffered bits (always the last ones) are zero padding supplied at + /// a marker or the end of the data, not real entropy-coded bits. + pad: u32, + /// Set once a read consumed padding: the scan's data ended before the scan did. + exhausted: bool, +} + +impl<'a> Bits<'a> { + fn new(d: &'a [u8]) -> Self { + Bits { d, pos: 0, acc: 0, n: 0, pad: 0, exhausted: false } + } + + fn fill(&mut self) { + while self.n <= 24 { + let v = match next_byte(self.d, &mut self.pos) { + Some(v) => v, + None => { + self.pad += 8; + 0 + } + }; + self.acc |= u32::from(v) << (24 - self.n); + self.n += 8; + } + } + + fn bit(&mut self) -> u32 { + self.get(1) + } + + /// The next `n` (≤ 16) bits. + fn get(&mut self, n: u32) -> u32 { + if n == 0 { + return 0; + } + if self.n < n { + self.fill(); + } + if n > self.n.saturating_sub(self.pad) { + self.exhausted = true; + } + let v = self.acc >> (32 - n); + self.acc <<= n; + self.n -= n; + self.pad = self.pad.min(self.n); + v + } + + fn restart(&mut self) { + self.pos = skip_restart(self.d, self.pos).0; + self.acc = 0; + self.n = 0; + self.pad = 0; + } +} + +/// `EXTEND` of F.2.2.1: the signed value of the `t`-bit magnitude `v`. +fn extend(v: u32, t: u32) -> i32 { + let v = v as i32; + if t == 0 { + 0 + } else if v < 1 << (t - 1) { + v - (1 << t) + 1 + } else { + v + } +} + +/// Qe, next index after LPS, next index after MPS, MPS switch (Table D.2). +#[rustfmt::skip] +const QE: [(u32, u8, u8, bool); 113] = [ + (0x5A1D, 1, 1, true), (0x2586, 14, 2, false), (0x1114, 16, 3, false), (0x080B, 18, 4, false), + (0x03D8, 20, 5, false), (0x01DA, 23, 6, false), (0x00E5, 25, 7, false), (0x006F, 28, 8, false), + (0x0036, 30, 9, false), (0x001A, 33, 10, false), (0x000D, 35, 11, false), (0x0006, 9, 12, false), + (0x0003, 10, 13, false), (0x0001, 12, 13, false), (0x5A7F, 15, 15, true), (0x3F25, 36, 16, false), + (0x2CF2, 38, 17, false), (0x207C, 39, 18, false), (0x17B9, 40, 19, false), (0x1182, 42, 20, false), + (0x0CEF, 43, 21, false), (0x09A1, 45, 22, false), (0x072F, 46, 23, false), (0x055C, 48, 24, false), + (0x0406, 49, 25, false), (0x0303, 51, 26, false), (0x0240, 52, 27, false), (0x01B1, 54, 28, false), + (0x0144, 56, 29, false), (0x00F5, 57, 30, false), (0x00B7, 59, 31, false), (0x008A, 60, 32, false), + (0x0068, 62, 33, false), (0x004E, 63, 34, false), (0x003B, 32, 35, false), (0x002C, 33, 9, false), + (0x5AE1, 37, 37, true), (0x484C, 64, 38, false), (0x3A0D, 65, 39, false), (0x2EF1, 67, 40, false), + (0x261F, 68, 41, false), (0x1F33, 69, 42, false), (0x19A8, 70, 43, false), (0x1518, 72, 44, false), + (0x1177, 73, 45, false), (0x0E74, 74, 46, false), (0x0BFB, 75, 47, false), (0x09F8, 77, 48, false), + (0x0861, 78, 49, false), (0x0706, 79, 50, false), (0x05CD, 48, 51, false), (0x04DE, 50, 52, false), + (0x040F, 50, 53, false), (0x0363, 51, 54, false), (0x02D4, 52, 55, false), (0x025C, 53, 56, false), + (0x01F8, 54, 57, false), (0x01A4, 55, 58, false), (0x0160, 56, 59, false), (0x0125, 57, 60, false), + (0x00F6, 58, 61, false), (0x00CB, 59, 62, false), (0x00AB, 61, 63, false), (0x008F, 61, 32, false), + (0x5B12, 65, 65, true), (0x4D04, 80, 66, false), (0x412C, 81, 67, false), (0x37D8, 82, 68, false), + (0x2FE8, 83, 69, false), (0x293C, 84, 70, false), (0x2379, 86, 71, false), (0x1EDF, 87, 72, false), + (0x1AA9, 87, 73, false), (0x174E, 72, 74, false), (0x1424, 72, 75, false), (0x119C, 74, 76, false), + (0x0F6B, 74, 77, false), (0x0D51, 75, 78, false), (0x0BB6, 77, 79, false), (0x0A40, 77, 48, false), + (0x5832, 80, 81, true), (0x4D1C, 88, 82, false), (0x438E, 89, 83, false), (0x3BDD, 90, 84, false), + (0x34EE, 91, 85, false), (0x2EAE, 92, 86, false), (0x299A, 93, 87, false), (0x2516, 86, 71, false), + (0x5570, 88, 89, true), (0x4CA9, 95, 90, false), (0x44D9, 96, 91, false), (0x3E22, 97, 92, false), + (0x3824, 99, 93, false), (0x32B4, 99, 94, false), (0x2E17, 93, 86, false), (0x56A8, 95, 96, true), + (0x4F46, 101, 97, false), (0x47E5, 102, 98, false), (0x41CF, 103, 99, false), (0x3C3D, 104, 100, false), + (0x375E, 99, 93, false), (0x5231, 105, 102, false), (0x4C0F, 106, 103, false), (0x4639, 107, 104, false), + (0x415E, 103, 99, false), (0x5627, 105, 106, true), (0x50E7, 108, 107, false), (0x4B85, 109, 103, false), + (0x5597, 110, 109, false), (0x504F, 111, 107, false), (0x5A10, 110, 111, true), (0x5522, 112, 109, false), + (0x59EB, 112, 111, true), +]; + +/// The arithmetic decoder of Annex D (D.2): `C` holds `Cx` in its high 16 bits, `A` the +/// interval, `CT` the bits left before the next byte. A statistics bin is one byte: the +/// Qe index in bits 0–6 and the MPS in bit 7. +struct Arith<'a> { + d: &'a [u8], + /// `d` ends at a marker rather than at the end of the file. + at_marker: bool, + pos: usize, + c: u32, + a: u32, + ct: u32, + /// The decoder needed bytes past the end of the file. + eof: bool, + /// A restart was due but its marker was missing. + lost: bool, +} + +impl<'a> Arith<'a> { + fn new(d: &'a [u8], at_marker: bool) -> Self { + let mut ar = Arith { d, at_marker, pos: 0, c: 0, a: 0, ct: 0, eof: false, lost: false }; + ar.init(); + ar + } + + /// At a marker the decoder is fed zeros for as long as it asks (D.2.6): encoders drop + /// trailing zero bytes (libjpeg's `finish_pass` does, as D.1.8 allows), and a flat tail of + /// a few thousand blocks can decode entirely from those zeros. Running out at the end of + /// the file is damage. + fn byte_in(&mut self) { + let v = next_byte(self.d, &mut self.pos).unwrap_or_else(|| { + let at_restart = matches!(self.d.get(self.pos + 1), Some(0xD0..=0xD7)); + self.eof |= !at_restart && !self.at_marker; + 0 + }); + self.c = self.c.wrapping_add(u32::from(v) << 8); + } + + /// The data ended without a marker, or a restart marker is missing. + fn exhausted(&self) -> bool { + self.lost || self.eof + } + + /// INITDEC (D.2.7). + fn init(&mut self) { + self.a = 0x10000; + self.c = 0; + self.byte_in(); + self.c <<= 8; + self.byte_in(); + self.c <<= 8; + self.ct = 0; + } + + /// DECODE (D.2.2–D.2.5) of one binary decision with the adaptive bin `st`. + fn decode(&mut self, st: &mut u8) -> u8 { + let mps = *st >> 7; + let (qe, nlps, nmps, switch) = QE.get(usize::from(*st & 0x7F)).copied().unwrap_or(QE[0]); + self.a = self.a.wrapping_sub(qe); + let d; + if (self.c >> 16) < self.a { + if self.a >= 0x8000 { + return mps; + } + // Conditional MPS exchange. + if self.a < qe { + d = 1 - mps; + *st = nlps | ((mps ^ u8::from(switch)) << 7); + } else { + d = mps; + *st = nmps | (mps << 7); + } + } else { + // Conditional LPS exchange. + self.c = self.c.wrapping_sub(self.a << 16); + if self.a < qe { + d = mps; + *st = nmps | (mps << 7); + } else { + d = 1 - mps; + *st = nlps | ((mps ^ u8::from(switch)) << 7); + } + self.a = qe; + } + // RENORM_D: A ≥ 1 (every Qe is), so this runs at most 16 times. + while self.a < 0x8000 { + if self.ct == 0 { + self.byte_in(); + self.ct = 8; + } + self.a <<= 1; + self.c <<= 1; + self.ct -= 1; + } + d + } + + /// A decision coded with the fixed estimate Qe = 0x5A1D (signs, DC refinement bits). + fn decode_fixed(&mut self) -> u8 { + let mut st = 0; + self.decode(&mut st) + } + + fn restart(&mut self) { + let (pos, found) = skip_restart(self.d, self.pos); + self.pos = pos; + self.lost |= !found; + self.init(); + } +} + +fn bin(st: &mut [u8], i: usize) -> Result<&mut u8, Error> { + st.get_mut(i).ok_or_else(|| err("arithmetic statistics index out of range")) +} + +/// One scan's decoding of a block, after a restart marker, or at its start. +trait ScanDecoder { + fn restart(&mut self); + /// Decode one block of scan component `sc` into `blk` (zigzag order). + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), Error>; +} + +/// The parameters of a scan header (B.2.3). +struct Scan { + /// Frame component index of each scan component, with its DC and AC table ids. + comps: Vec<(usize, usize, usize)>, + ss: usize, + se: usize, + ah: u8, + al: u8, +} + +/// Huffman sequential decoding (F.2.2). +struct HuffmanScan<'a> { + bits: Bits<'a>, + tables: Vec<(&'a Huffman, &'a Huffman)>, + pred: [i32; 4], +} + +impl ScanDecoder for HuffmanScan<'_> { + fn restart(&mut self) { + self.bits.restart(); + self.pred = [0; 4]; + } + + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), Error> { + let &(dc, ac) = self.tables.get(sc).ok_or_else(|| err("bad scan component"))?; + let pred = self.pred.get_mut(sc).ok_or_else(|| err("bad scan component"))?; + let t = u32::from(dc.decode(&mut self.bits)?); + if t > 15 { + return Err(err("bad DC difference category")); + } + let diff = extend(self.bits.get(t), t); + *pred = pred.wrapping_add(diff); + blk[0] = *pred; + let mut k = 1; + while k < 64 { + let rs = ac.decode(&mut self.bits)?; + let (r, s) = (usize::from(rs >> 4), u32::from(rs & 15)); + if s == 0 { + if r != 15 { + break; + } + k += 16; + continue; + } + k += r; + let slot = blk.get_mut(k).ok_or_else(|| err("AC coefficient past the end of the block"))?; + *slot = extend(self.bits.get(s), s); + k += 1; + } + if self.bits.exhausted { + return Err(err("entropy-coded data ends before the scan does")); + } + Ok(()) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ArithMode { + Sequential, + DcFirst, + DcRefine, + AcFirst, + AcRefine, +} + +/// Arithmetic decoding: sequential (F.2.4) and progressive (G.1.3). +struct ArithScan<'a> { + ar: Arith<'a>, + mode: ArithMode, + ss: usize, + se: usize, + al: u8, + /// DC and AC table ids of each scan component. + ids: Vec<(usize, usize)>, + dc_stats: [[u8; 64]; 4], + ac_stats: [[u8; 256]; 4], + dc_l: [u8; 4], + dc_u: [u8; 4], + ac_kx: [u8; 4], + /// DC prediction and conditioning context (offset of S0) per scan component. + pred: [i32; 4], + ctx: [usize; 4], +} + +impl ArithScan<'_> { + /// Decode_DC_DIFF (F.2.4.1, Figures F.19 and F.22). + fn dc_diff(&mut self, sc: usize, tbl: usize) -> Result { + let st = self.dc_stats.get_mut(tbl).ok_or_else(|| err("bad DC table"))?; + let ctx = self.ctx.get_mut(sc).ok_or_else(|| err("bad scan component"))?; + let s0 = *ctx; + if self.ar.decode(bin(st, s0)?) == 0 { + *ctx = 0; + return Ok(0); + } + let sign = self.ar.decode(bin(st, s0 + 1)?); + let mut s = s0 + 2 + usize::from(sign); + let mut m: i32 = i32::from(self.ar.decode(bin(st, s)?)); + if m != 0 { + // Magnitude category: X1 … X15 at bins 20 … 34. + s = 20; + while self.ar.decode(bin(st, s)?) != 0 { + m <<= 1; + s += 1; + if s > 34 { + return Err(err("DC difference out of range")); + } + } + } + // Magnitude bits: M2 … M15 at bins 35 … 48. + let mut v = m; + let mut bit = m >> 1; + while bit != 0 { + if self.ar.decode(bin(st, s + 14)?) != 0 { + v |= bit; + } + bit >>= 1; + } + let magnitude = v + 1; + // Conditioning category of the next difference (F.1.4.4.1.2). + let l = self.dc_l.get(tbl).copied().unwrap_or(0); + let u = self.dc_u.get(tbl).copied().unwrap_or(1); + *ctx = if magnitude <= (1 << l) >> 1 { + 0 + } else if magnitude <= 1 << u { + 4 + 4 * usize::from(sign) + } else { + 12 + 4 * usize::from(sign) + }; + Ok(if sign != 0 { -magnitude } else { magnitude }) + } + + /// Decode_AC_coefficients (F.2.4.2, Figures F.20 and F.24) over `ss..=se`, scaled by `al`. + fn ac_first(&mut self, tbl: usize, blk: &mut [i32; 64]) -> Result<(), Error> { + let kx = usize::from(self.ac_kx.get(tbl).copied().unwrap_or(5)); + let st = self.ac_stats.get_mut(tbl).ok_or_else(|| err("bad AC table"))?; + let mut k = self.ss.max(1); + while k <= self.se { + if self.ar.decode(bin(st, 3 * (k - 1))?) != 0 { + break; // end of block + } + while self.ar.decode(bin(st, 3 * (k - 1) + 1)?) == 0 { + k += 1; + if k > self.se { + return Err(err("AC coefficient past the end of the band")); + } + } + let sign = self.ar.decode_fixed(); + let sp = 3 * (k - 1) + 2; + let mut m: i32 = i32::from(self.ar.decode(bin(st, sp)?)); + let mut s = sp; + if m != 0 && self.ar.decode(bin(st, sp)?) != 0 { + m = 2; + s = if k <= kx { 189 } else { 217 }; + let last = s + 13; + while self.ar.decode(bin(st, s)?) != 0 { + m <<= 1; + s += 1; + if s > last { + return Err(err("AC coefficient out of range")); + } + } + } + let mut v = m; + let mut bit = m >> 1; + while bit != 0 { + if self.ar.decode(bin(st, s + 14)?) != 0 { + v |= bit; + } + bit >>= 1; + } + let value = if sign != 0 { -(v + 1) } else { v + 1 }; + *blk.get_mut(k).ok_or_else(|| err("bad AC index"))? = value << self.al; + k += 1; + } + Ok(()) + } + + /// Successive approximation of AC coefficients (G.1.3.3, Figure G.10). + fn ac_refine(&mut self, tbl: usize, blk: &mut [i32; 64]) -> Result<(), Error> { + let st = self.ac_stats.get_mut(tbl).ok_or_else(|| err("bad AC table"))?; + let p1: i32 = 1 << self.al; + let m1: i32 = -1 << self.al; + let (ss, se) = (self.ss.max(1), self.se); + // Past the last coefficient already non-zero, an end-of-block decision is coded. + let eob = (ss..=se).rev().find(|&k| blk.get(k).is_some_and(|&c| c != 0)).map_or(ss, |k| k + 1); + let mut k = ss; + while k <= se { + if k >= eob && self.ar.decode(bin(st, 3 * (k - 1))?) != 0 { + break; + } + loop { + let c = *blk.get(k).ok_or_else(|| err("bad AC index"))?; + if c != 0 { + if self.ar.decode(bin(st, 3 * (k - 1) + 2)?) != 0 { + *blk.get_mut(k).ok_or_else(|| err("bad AC index"))? = if c < 0 { c.wrapping_add(m1) } else { c.wrapping_add(p1) }; + } + break; + } + if self.ar.decode(bin(st, 3 * (k - 1) + 1)?) != 0 { + *blk.get_mut(k).ok_or_else(|| err("bad AC index"))? = if self.ar.decode_fixed() != 0 { m1 } else { p1 }; + break; + } + k += 1; + if k > se { + return Err(err("AC coefficient past the end of the band")); + } + } + k += 1; + } + Ok(()) + } +} + +impl ScanDecoder for ArithScan<'_> { + fn restart(&mut self) { + self.ar.restart(); + self.reset(); + } + + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), Error> { + let &(td, ta) = self.ids.get(sc).ok_or_else(|| err("bad scan component"))?; + match self.mode { + ArithMode::Sequential | ArithMode::DcFirst => { + let diff = self.dc_diff(sc, td)?; + let pred = self.pred.get_mut(sc).ok_or_else(|| err("bad scan component"))?; + *pred = pred.wrapping_add(diff); + blk[0] = pred.wrapping_shl(u32::from(self.al)); + if self.mode == ArithMode::Sequential { + self.ac_first(ta, blk)?; + } + } + ArithMode::DcRefine => { + if self.ar.decode_fixed() != 0 { + blk[0] |= 1 << self.al; + } + } + ArithMode::AcFirst => self.ac_first(ta, blk)?, + ArithMode::AcRefine => self.ac_refine(ta, blk)?, + } + if self.ar.exhausted() { + return Err(err("entropy-coded data ends before the scan does")); + } + Ok(()) + } +} + +impl ArithScan<'_> { + fn reset(&mut self) { + self.dc_stats = [[0; 64]; 4]; + self.ac_stats = [[0; 256]; 4]; + self.pred = [0; 4]; + self.ctx = [0; 4]; + } +} + +/// The scan's MCU grid (A.2): MCUs per line, MCU lines, and the blocks the scan decodes. +fn scan_grid(frame: &Frame, scan: &Scan) -> Result<(usize, usize, usize), Error> { + if let [(ci, _, _)] = scan.comps[..] { + let c = frame.comps.get(ci).ok_or_else(|| err("bad scan component"))?; + let (mcux, mcuy) = (c.cw.div_ceil(8), c.ch.div_ceil(8)); + return Ok((mcux, mcuy, mcux.saturating_mul(mcuy))); + } + let mut per_mcu = 0usize; + for &(ci, _, _) in &scan.comps { + let c = frame.comps.get(ci).ok_or_else(|| err("bad scan component"))?; + per_mcu += c.h * c.v; + } + Ok((frame.mcux, frame.mcuy, frame.mcux.saturating_mul(frame.mcuy).saturating_mul(per_mcu))) +} + +/// Walk the scan's MCUs in order (A.2), handing each block to `dec`; counts decoded MCUs. +fn walk_scan(frame: &mut Frame, scan: &Scan, restart: usize, dec: &mut dyn ScanDecoder, done: &mut usize) -> Result<(), Error> { + let single = scan.comps.len() == 1; + let (mcux, mcuy, _) = scan_grid(frame, scan)?; + let total = mcux.saturating_mul(mcuy); + for m in 0..total { + if restart > 0 && m > 0 && m % restart == 0 { + dec.restart(); + } + let (mx, my) = (m % mcux, m / mcux); + for (sc, &(ci, _, _)) in scan.comps.iter().enumerate() { + let comp = frame.comps.get_mut(ci).ok_or_else(|| err("bad scan component"))?; + let (h, v) = if single { (1, 1) } else { (comp.h, comp.v) }; + for by in my * v..(my + 1) * v { + for bx in mx * h..(mx + 1) * h { + let idx = by * comp.bw + bx; + let blk = comp.coefs.get_mut(idx).ok_or_else(|| err("block out of range"))?; + dec.block(sc, blk)?; + } + } + } + *done += 1; + } + Ok(()) +} + +fn parse_scan(seg: &[u8], frame: &Frame) -> Result { + let ns = usize::from(byte(seg, 0)?); + if !(1..=4).contains(&ns) { + return Err(err("bad number of scan components")); + } + let mut comps = Vec::with_capacity(ns); + for s in 0..ns { + let (cs, t) = (byte(seg, 1 + 2 * s)?, byte(seg, 2 + 2 * s)?); + let ci = frame.comps.iter().position(|c| c.id == cs).ok_or_else(|| err("scan names an unknown component"))?; + let (td, ta) = (usize::from(t >> 4), usize::from(t & 15)); + if td > 3 || ta > 3 { + return Err(err("bad entropy table id")); + } + comps.push((ci, td, ta)); + } + let at = 1 + 2 * ns; + let (ss, se, a) = (usize::from(byte(seg, at)?), usize::from(byte(seg, at + 1)?), byte(seg, at + 2)?); + let (ah, al) = (a >> 4, a & 15); + if frame.progressive { + let ok = ss <= se && se <= 63 && (ss != 0 || se == 0) && (ss == 0 || ns == 1) && al <= 13; + if !ok { + return Err(err("bad progressive scan parameters")); + } + Ok(Scan { comps, ss, se, ah, al }) + } else { + Ok(Scan { comps, ss: 0, se: 63, ah: 0, al: 0 }) + } +} + +/// Decode one scan's entropy-coded data `d`, counting the decoded MCUs in `done`. +/// `at_marker` says whether a marker follows `d` (otherwise it runs to the end of the file). +fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], at_marker: bool, done: &mut usize) -> Result<(), Error> { + for &(ci, _, _) in &scan.comps { + let comp = frame.comps.get_mut(ci).ok_or_else(|| err("bad scan component"))?; + if comp.quant.is_none() { + let q = tables.quant.get(comp.tq).copied().flatten().ok_or_else(|| err("scan uses an undefined quantization table"))?; + comp.quant = Some(q); + } + } + if frame.arithmetic { + let mode = match (frame.progressive, scan.ss, scan.ah) { + (false, _, _) => ArithMode::Sequential, + (true, 0, 0) => ArithMode::DcFirst, + (true, 0, _) => ArithMode::DcRefine, + (true, _, 0) => ArithMode::AcFirst, + (true, _, _) => ArithMode::AcRefine, + }; + let mut dec = ArithScan { + ar: Arith::new(d, at_marker), + mode, + ss: scan.ss, + se: scan.se, + al: scan.al, + ids: scan.comps.iter().map(|&(_, td, ta)| (td, ta)).collect(), + dc_stats: [[0; 64]; 4], + ac_stats: [[0; 256]; 4], + dc_l: tables.dc_l, + dc_u: tables.dc_u, + ac_kx: tables.ac_kx, + pred: [0; 4], + ctx: [0; 4], + }; + walk_scan(frame, scan, tables.restart, &mut dec, done) + } else { + let mut sel = Vec::with_capacity(scan.comps.len()); + for &(_, td, ta) in &scan.comps { + let dc = tables.dc.get(td).and_then(Option::as_ref).ok_or_else(|| err("scan uses an undefined Huffman table"))?; + let ac = tables.ac.get(ta).and_then(Option::as_ref).ok_or_else(|| err("scan uses an undefined Huffman table"))?; + sel.push((dc, ac)); + } + let mut dec = HuffmanScan { bits: Bits::new(d), tables: sel, pred: [0; 4] }; + walk_scan(frame, scan, tables.restart, &mut dec, done) + } +} + +/// The blocks decoded from one stream are capped at this many times the frame's blocks; the +/// scan that would pass the cap and every later one are dropped, and the image is reported +/// damaged. Every scan walks its whole MCU grid, so without a cap a small file repeating scan +/// headers multiplies the work by the frame size. 64 passes allow a progressive file one scan +/// per coefficient for every component; real files make about 10. +const SCAN_PASSES: usize = 64; + +/// Parse the stream and decode every scan into coefficients. +fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool), Error> { + if b.get(..2) != Some(&[0xFF, 0xD8]) { + return Err(err("not a JPEG stream")); + } + let mut tables = Tables::default(); + let mut frame: Option = None; + let mut damaged = false; + let mut mcus = 0usize; + let mut scans = 0usize; + let mut blocks = 0usize; + let mut i = 2; + while let Some(&v) = b.get(i) { + if v != 0xFF { + i += 1; + continue; + } + let Some(&m) = b.get(i + 1) else { break }; + match m { + 0xFF => { + i += 1; + continue; + } + 0x00 | 0x01 | 0xD0..=0xD8 => { + i += 2; + continue; + } + 0xD9 => break, + _ => {} + } + let Ok(len) = be16(b, i + 2) else { break }; + let len = usize::from(len); + if len < 2 { + return Err(err("bad marker segment length")); + } + let Some(seg) = b.get(i + 4..i + 2 + len) else { + if frame.is_some() && scans > 0 { + damaged = true; + break; + } + return Err(err("marker segment runs past the end of the file")); + }; + i += 2 + len; + match m { + 0xDB => parse_dqt(seg, &mut tables)?, + 0xC4 => parse_dht(seg, &mut tables)?, + 0xCC => parse_dac(seg, &mut tables)?, + 0xDD => tables.restart = usize::from(be16(seg, 0)?), + 0xC0..=0xCF => { + if frame.is_some() { + return Err(unsupported("JPEG with more than one frame is not supported")); + } + frame = Some(parse_frame(m, seg, limits)?); + } + 0xDA => { + let f = frame.as_mut().ok_or_else(|| err("scan before the frame header"))?; + let scan = parse_scan(seg, f)?; + let budget = f.comps.iter().map(|c| c.coefs.len()).sum::().saturating_mul(SCAN_PASSES); + blocks = blocks.saturating_add(scan_grid(f, &scan)?.2); + if blocks > budget { + damaged = true; + break; + } + let end = scan_end(b, i); + let data = b.get(i..end).unwrap_or_default(); + scans += 1; + if let Err(e) = decode_scan(f, &tables, &scan, data, end < b.len(), &mut mcus) { + if mcus == 0 { + return Err(e); + } + damaged = true; + } + i = end; + } + _ => {} + } + } + let frame = frame.ok_or_else(|| err("no frame header"))?; + if scans == 0 { + return Err(err("the file ends before any image data")); + } + Ok((frame, tables, damaged)) +} + +// --------------------------------------------------------------------------------------------- +// Reconstruction + +/// `T[x][u] = C(u)/2 · cos((2x+1)uπ/16)`: one dimension of the inverse DCT (A.3.3). +fn idct_basis() -> [[f32; 8]; 8] { + let mut t = [[0f32; 8]; 8]; + for (x, row) in t.iter_mut().enumerate() { + for (u, v) in row.iter_mut().enumerate() { + let c = if u == 0 { std::f32::consts::FRAC_1_SQRT_2 } else { 1.0 }; + *v = c / 2.0 * (((2 * x + 1) * u) as f32 * std::f32::consts::PI / 16.0).cos(); + } + } + t +} + +/// The component's samples (padded to whole blocks, row stride `bw * 8`), level-shifted and +/// clamped to `0..=max`. +fn reconstruct(comp: &Component, q: &[u16; 64], basis: &[[f32; 8]; 8], max: f32) -> Vec { + let stride = comp.bw * 8; + let mut plane = vec![0f32; stride * comp.bh * 8]; + let shift = (max + 1.0) / 2.0; + for (n, blk) in comp.coefs.iter().enumerate() { + let (bx, by) = (n % comp.bw, n / comp.bw); + if blk[1..].iter().all(|&c| c == 0) { + // Only DC: every sample is T[y][0] · T[x][0] · F[0][0], the same products the full + // transform below sums (its other terms are zero), so the result is identical. + let t = basis[0][0]; + let v = (t * (t * (blk[0] as f32 * f32::from(q[0]))) + shift).clamp(0.0, max); + for y in 0..8 { + let row = (by * 8 + y) * stride + bx * 8; + if let Some(out) = plane.get_mut(row..row + 8) { + out.fill(v); + } + } + continue; + } + let mut f = [0f32; 64]; + for (k, &c) in blk.iter().enumerate() { + f[ZIGZAG[k]] = c as f32 * f32::from(q[k]); + } + // Rows: r[v][x] = Σu T[x][u] F[v][u]; then columns: s[y][x] = Σv T[y][v] r[v][x]. + let mut r = [0f32; 64]; + for v in 0..8 { + for x in 0..8 { + r[v * 8 + x] = (0..8).map(|u| basis[x][u] * f[v * 8 + u]).sum(); + } + } + for (y, ty) in basis.iter().enumerate() { + let row = (by * 8 + y) * stride + bx * 8; + if let Some(out) = plane.get_mut(row..row + 8) { + for (x, o) in out.iter_mut().enumerate() { + let s: f32 = ty.iter().enumerate().map(|(v, t)| t * r[v * 8 + x]).sum(); + *o = (s + shift).clamp(0.0, max); + } + } + } + } + plane +} + +/// For each output coordinate: the two neighboring component samples and the weight of the +/// second (centered linear interpolation of a subsampled component). +fn upsample_map(out: usize, samples: usize, factor: usize, fmax: usize) -> Vec<(usize, usize, f32)> { + let last = samples.saturating_sub(1); + (0..out) + .map(|x| { + let s = ((x as f32 + 0.5) * factor as f32 / fmax as f32 - 0.5).max(0.0); + let x0 = (s.floor() as usize).min(last); + ((x0), (x0 + 1).min(last), (s - x0 as f32).clamp(0.0, 1.0)) + }) + .collect() +} + +/// Turn the coefficients into interleaved output samples. +fn render(frame: Frame, tables: &Tables, adobe_transform: Option) -> Result<(u8, Samples), Error> { + let max = if frame.precision > 8 { 4095.0 } else { 255.0 }; + let basis = idct_basis(); + let (w, h) = (frame.width, frame.height); + let mut planes = Vec::with_capacity(frame.comps.len()); + for comp in &frame.comps { + // A component no scan reached has only zero coefficients; any defined table will do. + let q = comp.quant.or_else(|| tables.quant.get(comp.tq).copied().flatten()); + let q = q.ok_or_else(|| err("component uses an undefined quantization table"))?; + let plane = reconstruct(comp, &q, &basis, max); + let xs = upsample_map(w, comp.cw, comp.h, frame.hmax); + let ys = upsample_map(h, comp.ch, comp.v, frame.vmax); + planes.push((plane, comp.bw * 8, xs, ys)); + } + let nc = planes.len(); + let sixteen = frame.precision > 8; + let rgb_ids = frame.comps.iter().map(|c| c.id).eq(*b"RGB"); + let ycc = match nc { + 3 => adobe_transform != Some(0) && !rgb_ids, + 4 => adobe_transform == Some(2), + _ => false, + }; + let (mut wide, mut narrow) = (Vec::new(), Vec::new()); + if sixteen { + wide.reserve_exact(w * h * nc); + } else { + narrow.reserve_exact(w * h * nc); + } + let mut px = [0f32; 4]; + let center = (max + 1.0) / 2.0; + for y in 0..h { + for x in 0..w { + for (c, (plane, stride, xs, ys)) in planes.iter().enumerate() { + let (Some(&(x0, x1, fx)), Some(&(y0, y1, fy))) = (xs.get(x), ys.get(y)) else { + return Err(err("sample map out of range")); + }; + let at = |xx: usize, yy: usize| plane.get(yy * stride + xx).copied().unwrap_or(0.0); + let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx; + let bottom = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx; + if let Some(p) = px.get_mut(c) { + *p = top + (bottom - top) * fy; + } + } + if ycc { + // YCbCr → RGB (JFIF / T.871); for YCCK the result is the inverted CMY of Adobe's + // convention, i.e. the ink amounts. + let (yy, cb, cr) = (px[0], px[1] - center, px[2] - center); + px[0] = yy + 1.402 * cr; + px[1] = yy - 0.344_136 * cb - 0.714_136 * cr; + px[2] = yy + 1.772 * cb; + if nc == 4 { + px[3] = max - px[3]; + } + } else if nc == 4 { + // Adobe-style CMYK is stored inverted (max = no ink). + for p in &mut px { + *p = max - *p; + } + } + for &p in px.iter().take(nc) { + let p = p.clamp(0.0, max); + if sixteen { + wide.push((p * 65535.0 / max).round() as u16); + } else { + narrow.push(p.round() as u8); + } + } + } + } + let samples = if sixteen { Samples::U16(wide) } else { Samples::U8(narrow) }; + Ok((nc as u8, samples)) +} + +/// Decode a JPEG stream of one of the frame types listed in the [crate docs](crate). +/// +/// `adobe_transform` is the transform byte of the stream's APP14 `Adobe` segment, if the caller +/// found one: it selects the color conversion of 3 and 4-component images (see +/// [`Decoded::components`]). The decoder ignores every APPn segment itself. +/// +/// Damaged entropy-coded data after at least one decoded MCU returns `Ok` with +/// [`Decoded::damaged`] set; a stream that yields no image data returns an error. +pub fn decode(bytes: &[u8], limits: &Limits, adobe_transform: Option) -> Result { + let (frame, tables, damaged) = decode_coefficients(bytes, limits)?; + let (width, height) = (frame.width as u32, frame.height as u32); + let (components, samples) = render(frame, &tables, adobe_transform)?; + Ok(Decoded { width, height, components, samples, damaged }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs new file mode 100644 index 00000000..3398b0e7 --- /dev/null +++ b/crates/jpeg/src/tests.rs @@ -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, (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 { + 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, + c: u32, + a: u32, + ct: u32, + stacked: u32, + b: Option, +} + +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 { + 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, 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, + band: (usize, usize, u8, u8), + before: Vec, +} + +/// 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, + cond: [Cond; 4], + ri: usize, + scans: Vec, +} + +impl TestFrame { + fn encode(&self) -> Vec { + 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::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 { + 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>, 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 { + 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 = (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 = (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::() / 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); +} diff --git a/crates/jpeg/tests/conformance.rs b/crates/jpeg/tests/conformance.rs new file mode 100644 index 00000000..86b9dfb0 --- /dev/null +++ b/crates/jpeg/tests/conformance.rs @@ -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 { + 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, b: impl Iterator) -> (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) { + 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::().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 = 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; diff --git a/crates/jpeg/tests/data/README.ijg b/crates/jpeg/tests/data/README.ijg new file mode 100644 index 00000000..8f376826 --- /dev/null +++ b/crates/jpeg/tests/data/README.ijg @@ -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. diff --git a/crates/jpeg/tests/data/SHA256SUMS b/crates/jpeg/tests/data/SHA256SUMS new file mode 100644 index 00000000..19c71c54 --- /dev/null +++ b/crates/jpeg/tests/data/SHA256SUMS @@ -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 diff --git a/crates/jpeg/tests/data/SOURCE.md b/crates/jpeg/tests/data/SOURCE.md new file mode 100644 index 00000000..bc15d764 --- /dev/null +++ b/crates/jpeg/tests/data/SOURCE.md @@ -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." diff --git a/crates/jpeg/tests/data/testimgari-huffman.jpg b/crates/jpeg/tests/data/testimgari-huffman.jpg new file mode 100644 index 00000000..2501c615 Binary files /dev/null and b/crates/jpeg/tests/data/testimgari-huffman.jpg differ diff --git a/crates/jpeg/tests/data/testimgari.jpg b/crates/jpeg/tests/data/testimgari.jpg new file mode 100644 index 00000000..89664874 Binary files /dev/null and b/crates/jpeg/tests/data/testimgari.jpg differ diff --git a/crates/jpeg/tests/data/testorig-ari-progressive-restart.jpg b/crates/jpeg/tests/data/testorig-ari-progressive-restart.jpg new file mode 100644 index 00000000..10c5081f Binary files /dev/null and b/crates/jpeg/tests/data/testorig-ari-progressive-restart.jpg differ diff --git a/crates/jpeg/tests/data/testorig-ari-progressive.jpg b/crates/jpeg/tests/data/testorig-ari-progressive.jpg new file mode 100644 index 00000000..e814c929 Binary files /dev/null and b/crates/jpeg/tests/data/testorig-ari-progressive.jpg differ diff --git a/crates/jpeg/tests/data/testorig.jpg b/crates/jpeg/tests/data/testorig.jpg new file mode 100644 index 00000000..9816a0c6 Binary files /dev/null and b/crates/jpeg/tests/data/testorig.jpg differ diff --git a/crates/jpeg/tests/data/testorig12-ari-progressive.jpg b/crates/jpeg/tests/data/testorig12-ari-progressive.jpg new file mode 100644 index 00000000..3a716ea7 Binary files /dev/null and b/crates/jpeg/tests/data/testorig12-ari-progressive.jpg differ diff --git a/crates/jpeg/tests/data/testorig12-ari.jpg b/crates/jpeg/tests/data/testorig12-ari.jpg new file mode 100644 index 00000000..66478ad5 Binary files /dev/null and b/crates/jpeg/tests/data/testorig12-ari.jpg differ diff --git a/crates/jpeg/tests/data/testorig12.jpg b/crates/jpeg/tests/data/testorig12.jpg new file mode 100644 index 00000000..861aff98 Binary files /dev/null and b/crates/jpeg/tests/data/testorig12.jpg differ diff --git a/crates/jpeg/tests/data/testorig12.ppm b/crates/jpeg/tests/data/testorig12.ppm new file mode 100644 index 00000000..df60fe91 Binary files /dev/null and b/crates/jpeg/tests/data/testorig12.ppm differ diff --git a/crates/jpeg/tests/data/whitetail-ari.jpg b/crates/jpeg/tests/data/whitetail-ari.jpg new file mode 100644 index 00000000..e7011fb6 Binary files /dev/null and b/crates/jpeg/tests/data/whitetail-ari.jpg differ diff --git a/crates/jpeg/tests/data/whitetail-huffman.jpg b/crates/jpeg/tests/data/whitetail-huffman.jpg new file mode 100644 index 00000000..888cc1b6 Binary files /dev/null and b/crates/jpeg/tests/data/whitetail-huffman.jpg differ diff --git a/crates/jpeg/tests/mutation.rs b/crates/jpeg/tests/mutation.rs new file mode 100644 index 00000000..41d403d2 --- /dev/null +++ b/crates/jpeg/tests/mutation.rs @@ -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 { + (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 { + 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"); +} diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 8e654138..4a73fdc6 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -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)). diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 057e1559..8114327d 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -84,6 +84,8 @@ pub struct Metadata { pub description: Option, pub keywords: Vec, pub created: Option, + /// Written as the modification date; `created` is written there when this is `None`. + pub modified: Option, } 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 } } diff --git a/crates/pdf/tests/print_color.rs b/crates/pdf/tests/print_color.rs index 2a9dd63f..c1f2ccc9 100644 --- a/crates/pdf/tests/print_color.rs +++ b/crates/pdf/tests/print_color.rs @@ -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::>(b"Colorants".as_slice()).unwrap().get::>(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("True"), "XMP pdf:Trapped"); - assert!(marked.contains("PDF/X-4"), "XMP GTS_PDFXVersion"); + assert!(marked.contains("PDF/X-1:2003"), "XMP GTS_PDFXVersion"); let unknown = SerializeSettings { trapped: Some(Trapped::Unknown), ..Default::default() }; let unknown = String::from_utf8_lossy(&sample(unknown, None).unwrap()).into_owned(); diff --git a/crates/pdf/tests/writing.rs b/crates/pdf/tests/writing.rs index 9d4e0fe9..de77d490 100644 --- a/crates/pdf/tests/writing.rs +++ b/crates/pdf/tests/writing.rs @@ -16,6 +16,7 @@ fn document(compress: bool) -> Vec { 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| 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("2026-10-09") && text.contains("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| 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() { diff --git a/crates/plate-test/README.md b/crates/plate-test/README.md index 6a376f19..8b1f7056 100644 --- a/crates/plate-test/README.md +++ b/crates/plate-test/README.md @@ -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. diff --git a/crates/plate-test/scenes/F01-image-alpha-over-fill.json b/crates/plate-test/scenes/F01-image-alpha-over-fill.json new file mode 100644 index 00000000..5aac7245 --- /dev/null +++ b/crates/plate-test/scenes/F01-image-alpha-over-fill.json @@ -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": [] + } +} diff --git a/crates/plate-test/scenes/F02-hidden-art.json b/crates/plate-test/scenes/F02-hidden-art.json new file mode 100644 index 00000000..bd856de3 --- /dev/null +++ b/crates/plate-test/scenes/F02-hidden-art.json @@ -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": [] + } +} diff --git a/crates/plate-test/src/reference.rs b/crates/plate-test/src/reference.rs index a9623d64..2bae10ff 100644 --- a/crates/plate-test/src/reference.rs +++ b/crates/plate-test/src/reference.rs @@ -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 { + fn image(&self, source: &ImageSource, [x0, y0, w, h]: [f32; 4]) -> Result { 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 { #[derive(Clone, Hash)] struct Synthetic { data: Vec, + alpha: Option>, size: (u32, u32), space: ImageColorspace, device_n: Option, @@ -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 diff --git a/crates/plate-test/src/report.rs b/crates/plate-test/src/report.rs index 405d754e..246400c4 100644 --- a/crates/plate-test/src/report.rs +++ b/crates/plate-test/src/report.rs @@ -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, pub judgement: Option, + /// The flattened page's result, from [`run_flattened`]. + pub flattened: Option, } +/// 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, + /// Time spent in the flattener. + pub millis: f64, + pub error: Option, + pub judgement: Option, +} + +/// Flattens a scene's PDF: the flattened PDF, or why it failed. +pub type Flatten<'a> = &'a dyn Fn(&[u8]) -> Result, String>; + #[derive(Debug, Clone, Serialize)] pub struct Report { pub ghostscript: String, @@ -66,13 +89,40 @@ pub struct Report { pub scenes: Vec, } +impl Report { + /// Scenes whose source or flattened result is not what their `pending` field says. + pub fn unexpected(&self) -> Vec { + 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>) -> 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 { + 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 { /// 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::>().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::>().join(" ")), + } +} + fn plate_problems(judgement: &Judgement) -> Option { let mut parts = Vec::new(); if !judgement.missing_plates.is_empty() { diff --git a/crates/plate-test/src/scene.rs b/crates/plate-test/src/scene.rs index ad065764..422db7dd 100644 --- a/crates/plate-test/src/scene.rs +++ b/crates/plate-test/src/scene.rs @@ -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, + /// An alpha channel, written as the image's `/SMask` (a PNG with transparency). + #[serde(default)] + pub alpha: Option, +} + +/// 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")); diff --git a/crates/plate-test/src/structure.rs b/crates/plate-test/src/structure.rs index d551cb67..c51ebf0b 100644 --- a/crates/plate-test/src/structure.rs +++ b/crates/plate-test/src/structure.rs @@ -52,6 +52,12 @@ pub struct ExtGState { pub opm: Option, #[serde(rename = "BM", skip_serializing_if = "Option::is_none")] pub bm: Option, + /// `/ca` (fill and text alpha). + #[serde(rename = "ca", skip_serializing_if = "Option::is_none")] + pub alpha_fill: Option, + /// `/CA` (stroke alpha). + #[serde(rename = "CA", skip_serializing_if = "Option::is_none")] + pub alpha_stroke: Option, /// The soft mask's `/S`, when the state sets a mask. #[serde(rename = "SMask", skip_serializing_if = "Option::is_none")] pub smask: Option, @@ -208,6 +214,8 @@ impl Walk { op_stroke: gs.get::(b"OP".as_slice()), op_fill: gs.get::(b"op".as_slice()), opm: gs.get::(b"OPM".as_slice()), + alpha_fill: gs.get::(b"ca".as_slice()), + alpha_stroke: gs.get::(b"CA".as_slice()), bm: gs.get::>(b"BM".as_slice()).and_then(|bm| match bm { Object::Name(name) => Some(name.as_str().to_owned()), Object::Array(names) => names.iter::>().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 { + let mut out: Vec = 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)] diff --git a/crates/plate-test/tests/ghostscript.rs b/crates/plate-test/tests/ghostscript.rs index f0ca9b71..ae2cd38a 100644 --- a/crates/plate-test/tests/ghostscript.rs +++ b/crates/plate-test/tests/ghostscript.rs @@ -26,12 +26,7 @@ fn every_scene_separates_as_expected() { println!("{} {:?}{pending}", result.id, result.status); } println!("report: {}", path.display()); - let wrong: Vec = 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)); } diff --git a/crates/prepress/Cargo.toml b/crates/prepress/Cargo.toml index d27432ff..e78fbbd8 100644 --- a/crates/prepress/Cargo.toml +++ b/crates/prepress/Cargo.toml @@ -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"] diff --git a/crates/prepress/README.md b/crates/prepress/README.md index 6ebc7644..49beb635 100644 --- a/crates/prepress/README.md +++ b/crates/prepress/README.md @@ -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. diff --git a/crates/prepress/src/flatten/compose.rs b/crates/prepress/src/flatten/compose.rs new file mode 100644 index 00000000..ba0cceef --- /dev/null +++ b/crates/prepress/src/flatten/compose.rs @@ -0,0 +1,1161 @@ +//! The transparency flattener: transparent art becomes opaque fills that print the same. +//! +//! Items split into transitive groups of overlapping items ([`geom::transparent_groups`]). Groups +//! without transparency come back unchanged, so their text stays live. The items of the other +//! groups are cut into planar regions ([`geom::regions`]); the same paints cover all of a region, +//! so one composited color is exact for it. Colors composite as ink vectors, one subtractive value +//! per ink of the list's [`InkSet`], process and spot inks alike (ISO 32000-2 §11.3). Overprint +//! applies per ink while compositing (§11.7.4, `CompatibleOverprint`), never through blend modes or +//! knockout groups. Regions a gradient, image or soft mask reaches have no single color: they are +//! listed in [`Flattened::complex`] for the raster step. +//! +//! Overprint after flattening: a region fill has nothing under it, and its color already holds +//! every plate's result, so it does not overprint. The exception keeps a paint as it was: where a +//! region's color equals its top paint painted alone, the fill takes that paint's color and +//! overprint attributes, which print the same over paper. Kept items keep their attributes, since +//! what they overprint is still under them. +//! +//! Ported from VectorCraft (MIT OR Apache-2.0) at commit c2b15982: `Composer`, `region_fill` and +//! the raster/vector balance (`rasterize_all`) in `crates/engine/src/cmd/flatten.rs`, and the +//! blend formulas in `crates/color/src/blend.rs`. VectorCraft composites RGB or CMY+K planes; +//! here each ink is a plane, and groups follow the §11.4 formulas. + +use std::collections::HashMap; +use std::ops::Range; + +use kurbo::{BezPath, Rect, Shape as _}; + +use super::geom::{self, ClipPath, GeomError, Shape}; +use super::{BlendMode, DisplayList, FillItem, FillRule, GroupItem, Item, Paint}; +use crate::ink::{InkColor, InkId, InkSet, Overprint, Process}; + +/// How [`flatten`] cuts transparent art. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FlattenOptions { + /// Regions narrower than this (in points) are dropped ([`geom::regions_with`]). + pub sliver: f64, + /// A group that cuts into more regions than this goes to [`Flattened::complex`] whole, + /// as VectorCraft's raster/vector balance rasterizes it. + pub max_regions: usize, + /// The resolution complex regions are rasterized at ([`raster`](super::raster)). + pub raster_dpi: f32, +} + +impl Default for FlattenOptions { + fn default() -> Self { + // VectorCraft's medium preset (balance 75) allows 10^(75/20), about 5600 regions. + Self { sliver: geom::DEFAULT_SLIVER, max_regions: 5000, raster_dpi: 300.0 } + } +} + +/// A page with no transparency left. +#[derive(Clone, Debug, PartialEq)] +pub struct Flattened { + /// Kept items and region fills, in painting order. No group in it is transparent. + pub items: Vec, + /// Areas left for rasterizing, each disjoint from every item but those it paints before. + pub complex: Vec, + pub report: FlattenReport, +} + +/// An area without one flat color. +#[derive(Clone, Debug, PartialEq)] +pub struct ComplexRegion { + /// The area, nonzero rule. + pub path: BezPath, + /// Indices into [`DisplayList::items`] of the top-level items painting in the area, in + /// painting order. Rendered alone and clipped to `path`, they give its look. + pub items: Vec, + /// Paints before [`Flattened::items`]`[at]` (after all of them when `at` is their count). + pub at: usize, + /// Set when the region is a whole group ([`FlattenReport::complex_groups`]). + pub whole: Option, +} + +/// What [`flatten`] did. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct FlattenReport { + /// Top-level items returned unchanged. + pub kept: usize, + /// Top-level items cut into regions. + pub flattened: usize, + /// Regions computed. + pub regions: usize, + /// Region fills written. + pub fills: usize, + /// Entries of [`Flattened::complex`]. + pub complex_regions: usize, + /// Groups that went to [`Flattened::complex`] whole. + pub complex_groups: Vec, +} + +/// A group of items rasterized whole. +#[derive(Clone, Debug, PartialEq)] +pub struct ComplexGroup { + /// Indices into [`DisplayList::items`]. + pub items: Vec, + pub reason: ComplexReason, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ComplexReason { + /// The region sweep failed ([`GeomError::Degenerate`]). + Degenerate, + /// More regions than [`FlattenOptions::max_regions`]. + TooManyRegions(usize), +} + +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum FlattenError { + #[error("a color names ink {0:?}, which is not in the list's ink set")] + UnknownInk(InkId), + #[error(transparent)] + Geometry(#[from] GeomError), +} + +/// `list` with its transparency flattened. Items that neither involve transparency nor overlap +/// an item that does come back unchanged, and so do opaque items with no transparency painted +/// above them where they paint. +pub fn flatten(list: &DisplayList, opts: &FlattenOptions) -> Result { + let nan = Rect::new(f64::NAN, f64::NAN, f64::NAN, f64::NAN); + let bounds: Vec = list.items.iter().map(|i| bounds(i).unwrap_or(nan)).collect(); + let transparent: Vec = list.items.iter().map(is_transparent).collect(); + + #[derive(Clone, Copy)] + enum Plan { + Keep, + Drop, + Emit(usize), + } + let groups = geom::transparent_groups(&bounds, &transparent); + let mut plan = vec![Plan::Keep; list.items.len()]; + let mut members: Vec> = Vec::with_capacity(groups.len()); + for g in &groups { + // Opaque items above the group's last transparent item paint over flat regions as they + // would over the composited art, overprint included: they stay as they are. + let Some(&last) = g.iter().rev().find(|&&i| transparent[i]) else { continue }; + let m: Vec = g.iter().copied().filter(|&i| i <= last).collect(); + for &i in &m { + plan[i] = Plan::Drop; + } + plan[last] = Plan::Emit(members.len()); + members.push(m); + } + + let mut out = Flattened { items: Vec::new(), complex: Vec::new(), report: FlattenReport::default() }; + for (i, item) in list.items.iter().enumerate() { + match plan[i] { + Plan::Keep => { + out.items.push(without_groups(item)); + out.report.kept += 1; + } + Plan::Drop => {} + Plan::Emit(g) => flatten_group(list, &members[g], &bounds, &transparent, opts, &mut out)?, + } + } + out.report.complex_regions = out.complex.len(); + Ok(out) +} + +fn flatten_group( + list: &DisplayList, + members: &[usize], + bounds: &[Rect], + transparent: &[bool], + opts: &FlattenOptions, + out: &mut Flattened, +) -> Result<(), FlattenError> { + let mut b = Builder::new(&list.inks); + let mut nodes = Vec::new(); + for &i in members { + b.top = i; + if let Some(n) = b.node(&list.items[i], false)? { + nodes.push(n); + } + } + let whole = |out: &mut Flattened, path: BezPath, reason| { + out.complex.push(ComplexRegion { path, items: members.to_vec(), at: out.items.len(), whole: Some(reason) }); + out.report.complex_groups.push(ComplexGroup { items: members.to_vec(), reason }); + }; + let regions = match geom::regions_with(&b.shapes, &b.clips, opts.sliver) { + Ok(r) => r, + Err(GeomError::Degenerate) => { + // Each member's box is clear of every other group's items. + let mut path = BezPath::new(); + for &i in members { + if !bounds[i].is_nan() { + path.extend(bounds[i].abs().path_elements(0.1)); + } + } + whole(out, path, ComplexReason::Degenerate); + out.report.flattened += members.len(); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + out.report.regions += regions.len(); + if regions.len() > opts.max_regions { + let mut path = BezPath::new(); + for r in ®ions { + path.extend(r.path.iter()); + } + whole(out, path, ComplexReason::TooManyRegions(regions.len())); + out.report.flattened += members.len(); + return Ok(()); + } + + // Members drawn as they are, over the region fills of the others: opaque, with only kept + // members above them wherever they paint. Painting them in order over the composite of what + // is under them gives the same page, and text among them stays live text. + let kept = kept_members(members, transparent, &b.owners, ®ions); + let is_kept = |s: usize| kept.binary_search(&b.owners[s]).is_ok(); + + // A region's look depends only on its visible sources: the last one that covers what is + // under it ([`Builder::covers`]) and those above it. Regions with the same visible sources + // are one area of one look, so they are written as one: art hidden under an opaque fill + // must not cut it into pieces, whose edges anti-aliasing viewers show (vectorcraft#2161). + let mut by_visible: HashMap, BezPath> = HashMap::new(); + let mut order = Vec::new(); + for r in regions { + // Kept members come last in a region's sources. The fill is the look of what is under + // them, also where one covers it: the region then joins its neighbors of that look + // instead of leaving a hole whose edge anti-aliasing viewers show. + let below = r.sources.iter().position(|&s| is_kept(s)).unwrap_or(r.sources.len()); + if below == 0 { + continue; + } + let sources = &r.sources[..below]; + let from = sources.iter().rposition(|&s| b.covers[s]).unwrap_or(0); + let visible = sources[from..].to_vec(); + let path = oriented(r.path); + match by_visible.get_mut(&visible) { + Some(p) => p.extend(path), + None => { + order.push(visible.clone()); + by_visible.insert(visible, path); + } + } + } + + let mut complex = Vec::new(); + let mut fills: Vec<(Option, FillItem)> = Vec::new(); + for sources in order { + let Some(path) = by_visible.remove(&sources) else { continue }; + let r = geom::Region { path, area: 0.0, sources }; + if r.sources.iter().any(|&s| b.paints[s].is_none()) { + let mut items: Vec = r.sources.iter().map(|&s| b.owners[s]).collect(); + items.dedup(); + complex.push(ComplexRegion { path: r.path, items, at: 0, whole: None }); + continue; + } + let c = Composer { sources: &r.sources, paints: &b.paints }; + if nodes.iter().fold(0.0, |a, n| union(a, c.alpha(n))) < 0.5 / 255.0 { + continue; + } + // The page group composites over paper: no ink, opaque. + let paper = Px { c: vec![0.0; list.inks.len()], a: 1.0 }; + let px = nodes.iter().fold(paper, |acc, n| c.draw(n, acc)); + let top = r.sources.last().and_then(|&s| b.paints[s].as_ref()); + let (color, overprint) = match top { + Some(t) if px.c.iter().zip(&t.value).all(|(a, b)| (a - b).abs() < 1e-4) => (t.color.clone(), t.overprint), + _ => (ink_color(&px.c, &list.inks), Overprint::default()), + }; + let tag = r.sources.iter().rev().find_map(|&s| b.tags[s]); + fills.push((tag, FillItem { path: r.path, rule: FillRule::NonZero, paint: Paint::Solid(color), overprint })); + } + // Region fills are disjoint and nothing else of the page is under them, so they can be + // reordered: those showing tagged text go into one plain group per text. + out.report.fills += fills.len(); + let mut tagged: Vec> = vec![Vec::new(); b.texts.len()]; + for (tag, fill) in fills { + match tag.and_then(|t| tagged.get_mut(t)) { + Some(children) => children.push(Item::Fill(fill)), + None => out.items.push(Item::Fill(fill)), + } + } + for (text, children) in b.texts.into_iter().zip(tagged).filter(|(_, c)| !c.is_empty()) { + out.items.push(Item::Group(GroupItem { + opacity: 1.0, + blend: BlendMode::Normal, + isolated: false, + knockout: false, + clip: None, + mask: None, + actual_text: Some(text), + children, + })); + } + let at = out.items.len(); + out.complex.extend(merge_touching(complex).into_iter().map(|c| ComplexRegion { at, ..c })); + for &i in &kept { + out.items.push(without_groups(&list.items[i])); + } + out.report.kept += kept.len(); + out.report.flattened += members.len() - kept.len(); + Ok(()) +} + +/// The members of a group that can be drawn as they are ([`flatten_group`]), ascending: not +/// transparent, and in every region a member's shapes are in, only kept members are above it. +fn kept_members(members: &[usize], transparent: &[bool], owners: &[usize], regions: &[geom::Region]) -> Vec { + let mut regions_of: HashMap> = HashMap::new(); + for (r, region) in regions.iter().enumerate() { + let mut last = None; + for &s in ®ion.sources { + let o = owners[s]; + if last != Some(o) { + regions_of.entry(o).or_default().push(r); + last = Some(o); + } + } + } + // From the top down, so that a member's decision sees every member above it decided. + // `clear[r]`: every member decided so far that is in region `r` is kept. + let mut clear = vec![true; regions.len()]; + let mut kept = Vec::new(); + for &m in members.iter().rev() { + let rs = regions_of.get(&m).map_or(&[][..], Vec::as_slice); + let keep = !transparent[m] && rs.iter().all(|&r| clear[r]); + if keep { + kept.push(m); + } else { + for &r in rs { + clear[r] = false; + } + } + } + kept.reverse(); + kept +} + +/// `path` with its subpaths reversed when its signed area is negative. Faces of one look are +/// written as one path; with one orientation, their shared edges cancel in an anti-aliasing +/// rasterizer's coverage instead of leaving a seam. +fn oriented(path: BezPath) -> BezPath { + if path.area() < 0.0 { path.reverse_subpaths() } else { path } +} + +/// Complex regions whose boxes touch, merged, so that one image spans their shared edges. A +/// merged region lists the items of all its parts: an item paints nothing in a part that +/// doesn't list it, so rendering it there changes nothing. +fn merge_touching(regions: Vec) -> Vec { + if regions.len() < 2 { + return regions; + } + let boxes: Vec = regions.iter().map(|r| r.path.bounding_box().inflate(1e-6, 1e-6)).collect(); + geom::overlap_groups(&boxes) + .into_iter() + .map(|g| { + let (mut path, mut items) = (BezPath::new(), Vec::new()); + for i in g { + path.extend(regions[i].path.iter()); + items.extend_from_slice(®ions[i].items); + } + items.sort_unstable(); + items.dedup(); + ComplexRegion { path, items, at: 0, whole: None } + }) + .collect() +} + +/// Composited ink values as a color: the inks with ink on them, or no ink on the process plates. +fn ink_color(c: &[f32], inks: &InkSet) -> InkColor { + let mut tints: Vec<(InkId, f32)> = inks.iter().zip(c).map(|((id, _), v)| (id, unit(*v))).filter(|(_, v)| *v > 1e-6).collect(); + if tints.is_empty() { + tints = Process::ALL.iter().map(|p| (InkId::process(*p), 0.0)).collect(); + } + InkColor::Tints(tints) +} + +fn bounds(item: &Item) -> Option { + let path = |p: &BezPath| (!p.elements().is_empty()).then(|| p.bounding_box()); + match item { + Item::Fill(f) => path(&f.path), + Item::Text(t) => path(&t.outlines), + Item::Image(im) => Some(im.transform.transform_rect_bbox(Rect::new(0.0, 0.0, 1.0, 1.0))), + Item::Group(g) => { + let inner = g.children.iter().filter_map(bounds).reduce(|a, b| a.union(b))?; + match g.clip.as_ref().and_then(path) { + Some(clip) => Some(inner.intersect(clip)), + None if g.clip.is_some() => None, + None => Some(inner), + } + } + } +} + +/// Whether `item` paints differently composited than painted opaquely in order. +fn is_transparent(item: &Item) -> bool { + match item { + Item::Fill(_) | Item::Text(_) => false, + Item::Image(im) => im.smask.is_some(), + Item::Group(g) => { + g.opacity < 1.0 + || g.blend != BlendMode::Normal + || g.mask.is_some() + || ((g.isolated || g.knockout) && g.children.iter().any(overprints)) + || g.children.iter().any(is_transparent) + } + } +} + +/// A kept item with its isolated and knockout groups made plain groups: without transparency +/// inside ([`is_transparent`]), they paint the same, and a flattened page has no transparency +/// groups. +fn without_groups(item: &Item) -> Item { + match item { + Item::Group(g) => { + Item::Group(GroupItem { isolated: false, knockout: false, children: g.children.iter().map(without_groups).collect(), ..g.clone() }) + } + other => other.clone(), + } +} + +fn overprints(item: &Item) -> bool { + match item { + Item::Fill(f) => f.overprint.fill, + Item::Text(t) => t.overprint.fill, + Item::Image(im) => im.overprint.fill, + Item::Group(g) => g.children.iter().any(overprints), + } +} + +/// A flat paint as the compositor uses it. +#[derive(Debug)] +struct Source { + /// One value per ink, `0.0..=1.0`; unnamed inks 0. + value: Vec, + /// With overprint: which inks the paint names. `None` when it names all of them. + named: Option>, + color: InkColor, + overprint: Overprint, +} + +impl Source { + /// Fills, outlined strokes included, overprint by `overprint.fill`. An overprinting paint + /// names the inks it lists; with nonzero overprint mode a process-only color (DeviceCMYK) + /// leaves out its zero components too. `None` for the `None` colorant, which paints nothing. + fn new(color: &InkColor, overprint: Overprint, inks: &InkSet) -> Result, FlattenError> { + let n = inks.len(); + let (value, named) = match color { + InkColor::None => return Ok(None), + InkColor::All(t) => (vec![unit(*t); n], vec![true; n]), + InkColor::Tints(tints) => { + let (mut value, mut named) = (vec![0.0; n], vec![false; n]); + for &(id, t) in tints { + let (v, k) = value.get_mut(id.index()).zip(named.get_mut(id.index())).ok_or(FlattenError::UnknownInk(id))?; + *v = 1.0 - (1.0 - *v) * (1.0 - unit(t)); + *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; + } + } + (value, named) + } + }; + let named = overprint.fill.then_some(named); + Ok(Some(Self { value, named, color: color.clone(), overprint })) + } +} + +enum Node { + /// A shape and its paint (`Builder::paints`). + Leaf(usize), + Group(GroupNode), +} + +struct GroupNode { + opacity: f32, + blend: BlendMode, + isolated: bool, + knockout: bool, + /// [`is_plain`]. + plain: bool, + /// The shapes of every leaf inside, which come in this range by construction. + shapes: Range, + children: Vec, +} + +impl GroupNode { + /// The children, with those of plain groups ([`is_plain`]) in their place, recursively. + fn elements(self) -> Vec { + let mut out = Vec::with_capacity(self.children.len()); + for c in self.children { + match c { + Node::Group(g) if g.plain => out.extend(g.elements()), + n => out.push(n), + } + } + out + } +} + +/// A group that only clips: it composites nothing of its own. +fn is_plain(item: &Item) -> bool { + matches!(item, Item::Group(g) if g.opacity >= 1.0 && g.blend == BlendMode::Normal && !g.isolated && !g.knockout && g.mask.is_none()) +} + +/// Turns items into the region sweep's shapes and the compositor's tree. +struct Builder<'a> { + inks: &'a InkSet, + shapes: Vec, + clips: Vec, + clip_stack: Vec, + /// Per shape: its flat paint, `None` for art without one (gradients, images, masked art). + paints: Vec>, + /// Per shape: the top-level item it is part of. + owners: Vec, + top: usize, + /// Per shape: the text it shows, an index into `texts`. The outermost source wins: a group's + /// `actual_text`, else a text run's own text. + tags: Vec>, + texts: Vec, + tag: Option, + /// Per shape: it paints its color over whatever is under it, so nothing under it shows. + covers: Vec, + /// Every open group composites at full opacity with Normal blending. + plain_path: bool, + /// Some open group is a knockout group. + knockout_path: bool, +} + +impl<'a> Builder<'a> { + fn new(inks: &'a InkSet) -> Self { + Self { + inks, + shapes: Vec::new(), + clips: Vec::new(), + clip_stack: Vec::new(), + paints: Vec::new(), + owners: Vec::new(), + top: 0, + tags: Vec::new(), + texts: Vec::new(), + tag: None, + covers: Vec::new(), + plain_path: true, + knockout_path: false, + } + } + + fn leaf(&mut self, path: BezPath, rule: FillRule, paint: Option) -> Option { + if path.elements().is_empty() { + return None; + } + self.shapes.push(Shape::new(path, rule).clipped(self.clip_stack.iter().copied())); + // An opaque paint without overprint in groups that composite it unchanged (an isolated + // group too: the paint replaces its backdrop either way). Not in a knockout group: a later + // element there composites over the group's backdrop, so what is under the paint shows. + self.covers.push(self.plain_path && !self.knockout_path && paint.as_ref().is_some_and(|p| p.named.is_none())); + self.paints.push(paint); + self.owners.push(self.top); + self.tags.push(self.tag); + Some(Node::Leaf(self.shapes.len() - 1)) + } + + fn painted(&mut self, path: &BezPath, rule: FillRule, paint: &Paint, overprint: Overprint, masked: bool) -> Result, FlattenError> { + let source = match paint { + Paint::Solid(color) => match Source::new(color, overprint, self.inks)? { + Some(s) => (!masked).then_some(s), + None => return Ok(None), + }, + Paint::Gradient { .. } => None, + }; + Ok(self.leaf(path.clone(), rule, source)) + } + + /// Makes `text` the current tag unless one is set; returns the tag to restore. + fn push_tag(&mut self, text: Option<&str>) -> Option { + let outer = self.tag; + if let (None, Some(text)) = (outer, text) { + self.texts.push(text.to_string()); + self.tag = Some(self.texts.len() - 1); + } + outer + } + + fn node(&mut self, item: &Item, masked: bool) -> Result, FlattenError> { + match item { + Item::Fill(f) => self.painted(&f.path, f.rule, &f.paint, f.overprint, masked), + Item::Text(t) => { + let outer = self.push_tag(t.text.as_deref()); + let node = self.painted(&t.outlines, FillRule::NonZero, &t.paint, t.overprint, masked); + self.tag = outer; + node + } + Item::Image(im) => Ok(self.leaf(im.transform * Rect::new(0.0, 0.0, 1.0, 1.0).to_path(0.1), FillRule::NonZero, None)), + Item::Group(g) => { + let start = self.shapes.len(); + let outer = self.push_tag(g.actual_text.as_deref()); + if let Some(clip) = &g.clip { + self.clip_stack.push(self.clips.len()); + self.clips.push(ClipPath { path: clip.clone(), rule: FillRule::NonZero }); + } + let masked = masked || g.mask.is_some(); + let (plain_path, knockout_path) = (self.plain_path, self.knockout_path); + self.plain_path &= g.opacity >= 1.0 && g.blend == BlendMode::Normal; + self.knockout_path |= g.knockout; + let mut children = Vec::with_capacity(g.children.len()); + for c in &g.children { + match self.node(c, masked)? { + // A clip (or a plain form) is not an element of a knockout group: the + // elements inside it are, and knock each other out. + Some(Node::Group(inner)) if g.knockout && is_plain(c) => children.extend(inner.elements()), + Some(n) => children.push(n), + None => {} + } + } + if g.clip.is_some() { + self.clip_stack.pop(); + } + (self.plain_path, self.knockout_path) = (plain_path, knockout_path); + self.tag = outer; + Ok((!children.is_empty()).then(|| { + Node::Group(GroupNode { + opacity: unit(g.opacity), + blend: g.blend, + isolated: g.isolated, + knockout: g.knockout, + plain: is_plain(item), + shapes: start..self.shapes.len(), + children, + }) + })) + } + } + } +} + +/// A color and its alpha at one point: one subtractive value per ink (0 = no ink). +#[derive(Clone, Debug)] +struct Px { + c: Vec, + a: f32, +} + +/// Composites the items over one region (ISO 32000-2 §11.3–11.4). Shapes are 0 or 1 across a +/// region, so a knockout group shows its last element that covers it. +struct Composer<'a> { + /// The shapes covering the region, ascending. + sources: &'a [usize], + paints: &'a [Option], +} + +impl Composer<'_> { + fn covers(&self, n: &Node) -> bool { + match n { + Node::Leaf(s) => self.sources.binary_search(s).is_ok(), + Node::Group(g) => { + let i = self.sources.partition_point(|&s| s < g.shapes.start); + self.sources.get(i).is_some_and(|&s| s < g.shapes.end) + } + } + } + + /// `n`'s alpha as an element of its parent group. + fn alpha(&self, n: &Node) -> f32 { + match n { + _ if !self.covers(n) => 0.0, + Node::Leaf(_) => 1.0, + Node::Group(g) => g.opacity * self.group_alpha(g), + } + } + + /// The group's own alpha, without its backdrop (§11.4.8, α_gn). + fn group_alpha(&self, g: &GroupNode) -> f32 { + if g.knockout { + g.children.iter().rev().find(|c| self.covers(c)).map_or(0.0, |c| self.alpha(c)) + } else { + g.children.iter().fold(0.0, |a, c| union(a, self.alpha(c))) + } + } + + /// `n` composited over `acc`. + fn draw(&self, n: &Node, acc: Px) -> Px { + if !self.covers(n) { + return acc; + } + let g = match n { + Node::Leaf(s) => match &self.paints[*s] { + Some(p) => return composite(&acc, &p.value, p.named.as_deref(), 1.0, BlendMode::Normal), + None => return acc, + }, + Node::Group(g) => g, + }; + if g.opacity >= 1.0 && g.blend == BlendMode::Normal && !g.isolated && !g.knockout { + return g.children.iter().fold(acc, |acc, c| self.draw(c, acc)); + } + let init = if g.isolated { Px { c: vec![0.0; acc.c.len()], a: 0.0 } } else { acc.clone() }; + let cn = if g.knockout { + match g.children.iter().rev().find(|c| self.covers(c)) { + Some(c) => self.draw(c, init.clone()), + None => init.clone(), + } + } else { + g.children.iter().fold(init.clone(), |acc, c| self.draw(c, acc)) + }; + let ag = self.group_alpha(g); + if ag <= 0.0 { + return acc; + } + let c = if g.isolated { + cn.c + } else { + // Take the backdrop's share out of a non-isolated group's result (§11.4.8). + let k = init.a / ag - init.a; + cn.c.iter().zip(&init.c).map(|(n, b)| unit(n + (n - b) * k)).collect() + }; + composite(&acc, &c, None, ag * g.opacity, g.blend) + } +} + +/// `s` with alpha `a_s` composited over `b` with blend mode `mode` (§11.3.3). Inks that `named` +/// leaves out keep the backdrop's value (`CompatibleOverprint`, §11.7.4.3). +fn composite(b: &Px, s: &[f32], named: Option<&[bool]>, a_s: f32, mode: BlendMode) -> Px { + let a_r = union(b.a, a_s); + if a_r <= 0.0 { + return b.clone(); + } + let mixed = blend(mode, &b.c, s); + let t = a_s / a_r; + let c = (0..b.c.len()) + .map(|i| { + let (cb, cs) = (b.c[i], s[i]); + if named.is_some_and(|n| !n[i]) { cb } else { unit((1.0 - t) * cb + t * ((1.0 - b.a) * cs + b.a * mixed[i])) } + }) + .collect(); + Px { c, a: a_r } +} + +/// B(c_b, c_s) per ink, on subtractive values: the additive formulas on complemented values +/// (§11.3.5). Non-separable modes blend C, M and Y as RGB, keep the backdrop's K (the source's +/// for Luminosity), and act as Normal on spot inks (§11.3.5.3). +fn blend(mode: BlendMode, b: &[f32], s: &[f32]) -> Vec { + if mode == BlendMode::Normal { + return s.to_vec(); + } + if mode.is_non_separable() { + let mut out = s.to_vec(); + if let ([b0, b1, b2, bk, ..], [s0, s1, s2, sk, ..]) = (b, s) { + let m = non_separable(mode, [1.0 - b0, 1.0 - b1, 1.0 - b2], [1.0 - s0, 1.0 - s1, 1.0 - s2]); + for (o, v) in out.iter_mut().zip(m) { + *o = 1.0 - v; + } + out[3] = if mode == BlendMode::Luminosity { *sk } else { *bk }; + } + return out; + } + let f = separable(mode); + b.iter().zip(s).map(|(b, s)| 1.0 - f(1.0 - unit(*b), 1.0 - unit(*s))).collect() +} + +fn separable(mode: BlendMode) -> fn(f32, f32) -> f32 { + use BlendMode as B; + match mode { + B::Multiply => |b, s| b * s, + B::Screen => screen, + B::Overlay => |b, s| hard_light(s, b), + B::Darken => f32::min, + B::Lighten => f32::max, + B::ColorDodge => |b, s| { + if b <= 0.0 { + 0.0 + } else if s >= 1.0 { + 1.0 + } else { + (b / (1.0 - s)).min(1.0) + } + }, + B::ColorBurn => |b, s| { + if b >= 1.0 { + 1.0 + } else if s <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - b) / s).min(1.0) + } + }, + B::HardLight => hard_light, + B::SoftLight => |b, s| { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + b + (2.0 * s - 1.0) * (d - b) + } + }, + B::Difference => |b, s| (b - s).abs(), + B::Exclusion => |b, s| b + s - 2.0 * b * s, + B::Normal | B::Hue | B::Saturation | B::Color | B::Luminosity => |_, s| s, + } +} + +fn non_separable(mode: BlendMode, b: [f32; 3], s: [f32; 3]) -> [f32; 3] { + match mode { + BlendMode::Hue => set_lum(set_sat(s, sat(b)), lum(b)), + BlendMode::Saturation => set_lum(set_sat(b, sat(s)), lum(b)), + BlendMode::Color => set_lum(s, lum(b)), + BlendMode::Luminosity => set_lum(b, lum(s)), + _ => s, + } +} + +fn screen(b: f32, s: f32) -> f32 { + b + s - b * s +} + +fn hard_light(b: f32, s: f32) -> f32 { + if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) } +} + +fn lum(c: [f32; 3]) -> f32 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} + +fn sat(c: [f32; 3]) -> f32 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +/// `c` moved back into gamut keeping its luminance. A gray is clamped instead of scaled by 0/0. +fn clip_color(c: [f32; 3]) -> [f32; 3] { + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + let mut c = c; + if n < 0.0 { + c = if l - n > f32::EPSILON { c.map(|v| l + (v - l) * l / (l - n)) } else { c.map(|v| v.max(0.0)) }; + } + if x > 1.0 { + c = if x - l > f32::EPSILON { c.map(|v| l + (v - l) * (1.0 - l) / (x - l)) } else { c.map(|v| v.min(1.0)) }; + } + c +} + +fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} + +fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + if x <= n { + return [0.0; 3]; + } + c.map(|v| (v - n) * s / (x - n)) +} + +fn union(a: f32, b: f32) -> f32 { + a + b - a * b +} + +fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use kurbo::{Affine, Point}; + + use super::*; + use crate::flatten::{GradientGeometry, GroupItem, PlacedGlyph, TextFont, TextItem}; + use crate::ink::{Alternate, Ink, Spot}; + + 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); + + 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, blend: BlendMode, children: Vec) -> GroupItem { + GroupItem { opacity, blend, isolated: false, knockout: false, clip: None, mask: None, actual_text: None, children } + } + + fn list(inks: InkSet, items: Vec) -> DisplayList { + DisplayList { page: Rect::new(0.0, 0.0, 600.0, 800.0), inks, items } + } + + fn flat(list: &DisplayList) -> Flattened { + flatten(list, &FlattenOptions::default()).unwrap() + } + + /// The fill painting `(x, y)` and its tint of each of `inks`. + fn at<'a>(f: &'a Flattened, x: f64, y: f64, inks: &[InkId]) -> (&'a FillItem, Vec) { + let p = Point::new(x, y); + let hit = f.items.iter().rev().find_map(|i| match i { + Item::Fill(fi) if fi.path.winding(p) != 0 => Some(fi), + _ => None, + }); + let fi = hit.unwrap_or_else(|| panic!("nothing paints {p:?}")); + let Paint::Solid(InkColor::Tints(t)) = &fi.paint else { panic!("{:?}", fi.paint) }; + let tint = |id: InkId| t.iter().find(|(i, _)| *i == id).map_or(0.0, |(_, v)| (v * 1000.0).round() / 1000.0); + (fi, inks.iter().map(|&id| tint(id)).collect()) + } + + #[test] + fn half_black_over_cyan_composites_per_ink() { + let l = list( + InkSet::new(), + vec![ + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), + Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(K, 1.0)])])), + ], + ); + let f = flat(&l); + assert_eq!(at(&f, 75.0, 50.0, &[C, M, Y, K]).1, [0.5, 0.0, 0.0, 0.5]); + assert_eq!(at(&f, 25.0, 50.0, &[C, K]).1, [1.0, 0.0]); + assert_eq!(at(&f, 125.0, 50.0, &[C, K]).1, [0.0, 0.5]); + assert_eq!((f.report.kept, f.report.flattened, f.report.regions, f.report.fills), (0, 2, 3, 3)); + assert!(f.complex.is_empty()); + } + + #[test] + fn multiply_adds_spot_inks_and_non_separable_modes_are_normal_on_spots() { + let mut inks = InkSet::new(); + let spot = |name: &str| Ink::Spot(Spot { name: name.into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None }); + let gold = inks.add(spot("Gold")).unwrap(); + let silver = inks.add(spot("Silver")).unwrap(); + let scene = |blend| { + list( + inks.clone(), + vec![ + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(gold, 0.5)]), + Item::Group(group(1.0, blend, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(gold, 0.4), (silver, 0.6)])])), + ], + ) + }; + let f = flat(&scene(BlendMode::Multiply)); + assert_eq!(at(&f, 75.0, 50.0, &[gold, silver, K]).1, [0.7, 0.6, 0.0]); + let f = flat(&scene(BlendMode::Hue)); + assert_eq!(at(&f, 75.0, 50.0, &[gold, silver, K]).1, [0.4, 0.6, 0.0]); + } + + #[test] + fn overprint_keeps_the_inks_a_paint_leaves_out() { + let op = Overprint { fill: true, ..Overprint::default() }; + let opm = Overprint { nonzero: true, ..op }; + let cyan = || solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]); + // A 50% yellow strip over both, so that neither is kept as it is. + let veil = || Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(0.0, 90.0, 150.0, 100.0), &[(Y, 1.0)])])); + let top = |tints: &[(InkId, f32)], o| list(InkSet::new(), vec![cyan(), fill(Rect::new(50.0, 0.0, 150.0, 100.0), tints, o), veil()]); + + let f = flat(&top(&[(K, 1.0)], op)); + let (fi, v) = at(&f, 75.0, 50.0, &[C, M, Y, K]); + assert_eq!(v, [1.0, 0.0, 0.0, 1.0], "K100 overprints cyan"); + assert_eq!(fi.overprint, Overprint::default(), "the composite needs no overprint"); + let (fi, _) = at(&f, 125.0, 50.0, &[K]); + assert_eq!((fi.paint.clone(), fi.overprint), (Paint::Solid(InkColor::Tints(vec![(K, 1.0)])), op), "a paint shown as it is stays"); + assert_eq!(at(&flat(&top(&[(K, 1.0)], Overprint::default())), 75.0, 50.0, &[C, K]).1, [0.0, 1.0], "knocks out"); + + let magenta = [(C, 0.0), (M, 1.0), (Y, 0.0), (K, 0.0)]; + assert_eq!(at(&flat(&top(&magenta, opm)), 75.0, 50.0, &[C, M]).1, [1.0, 1.0], "OPM 1: a zero component keeps the backdrop"); + assert_eq!(at(&flat(&top(&magenta, op)), 75.0, 50.0, &[C, M]).1, [0.0, 1.0], "OPM 0: zero is a value"); + } + + #[test] + fn knockout_group_replaces_what_is_under_the_top_element() { + let scene = |knockout| { + let half_magenta = Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(M, 1.0)])])); + let g = + GroupItem { knockout, ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), half_magenta]) }; + list(InkSet::new(), vec![Item::Group(g)]) + }; + assert_eq!(at(&flat(&scene(true)), 75.0, 50.0, &[C, M]).1, [0.0, 0.5]); + assert_eq!(at(&flat(&scene(false)), 75.0, 50.0, &[C, M]).1, [0.5, 0.5]); + } + + #[test] + fn knockout_group_shows_its_backdrop_under_a_later_element() { + let half_yellow = Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(Y, 1.0)])])); + let g = + GroupItem { knockout: true, ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(M, 1.0)]), half_yellow]) }; + let l = list(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), Item::Group(g)]); + assert_eq!(at(&flat(&l), 50.0, 50.0, &[C, M, Y]).1, [0.5, 0.0, 0.5]); + } + + #[test] + fn knockout_reaches_elements_inside_a_clip() { + // A form's bounding box arrives as a clip around the knockout group's elements. + let half_magenta = Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(M, 1.0)])])); + let clip = GroupItem { + clip: Some(Rect::new(0.0, 0.0, 150.0, 100.0).to_path(0.1)), + ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), half_magenta]) + }; + let g = GroupItem { knockout: true, ..group(1.0, BlendMode::Normal, vec![Item::Group(clip)]) }; + assert_eq!(at(&flat(&list(InkSet::new(), vec![Item::Group(g)])), 75.0, 50.0, &[C, M]).1, [0.0, 0.5]); + } + + #[test] + fn isolated_group_blends_without_the_backdrop() { + let scene = |isolated| { + let multiply = Item::Group(group(1.0, BlendMode::Multiply, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(M, 1.0)])])); + let outer = GroupItem { isolated, ..group(1.0, BlendMode::Normal, vec![multiply]) }; + list(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), Item::Group(outer)]) + }; + assert_eq!(at(&flat(&scene(false)), 75.0, 50.0, &[C, M]).1, [1.0, 1.0]); + assert_eq!(at(&flat(&scene(true)), 75.0, 50.0, &[C, M]).1, [0.0, 1.0]); + } + + #[test] + fn opaque_items_away_from_transparency_and_above_it_stay_as_they_are() { + let font = Arc::new(TextFont { data: Arc::new(vec![0_u8; 8]), index: 0, key: 7 }); + let text = |x: f64| { + Item::Text(TextItem { + font: Some(font.clone()), + size: 12.0, + transform: Affine::translate((x, 50.0)), + glyphs: vec![PlacedGlyph { id: 3, origin: Point::ZERO, text: 0..1 }], + text: Some("A".into()), + outlines: Rect::new(x, 40.0, x + 8.0, 50.0).to_path(0.1), + paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])), + overprint: Overprint { fill: true, ..Overprint::default() }, + }) + }; + let far = solid(Rect::new(300.0, 300.0, 400.0, 400.0), &[(M, 1.0)]); + let l = list( + InkSet::new(), + vec![ + far.clone(), + text(500.0), + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), + Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(K, 1.0)])])), + text(60.0), + ], + ); + let f = flat(&l); + assert_eq!(f.items[0], far); + assert_eq!(f.items[1], l.items[1]); + assert_eq!(f.items.last(), Some(&l.items[4]), "text above the transparency, painted last"); + let Some(Item::Text(t)) = f.items.last() else { panic!() }; + assert!(Arc::ptr_eq(t.font.as_ref().unwrap(), &font)); + assert_eq!((f.report.kept, f.report.flattened, f.report.fills), (3, 2, 3)); + } + + #[test] + fn opaque_members_with_no_transparency_above_them_stay_as_they_are() { + // A background frame, body text on it, and a transparent object over the frame only: the + // boxes put all three in one group, but only the frame is under transparency. + let text = solid(Rect::new(10.0, 10.0, 50.0, 20.0), &[(K, 1.0)]); + let veiled = |items: Vec| { + let mut items = items; + items.push(Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(100.0, 100.0, 150.0, 150.0), &[(M, 1.0)])]))); + list(InkSet::new(), items) + }; + let f = flat(&veiled(vec![solid(Rect::new(0.0, 0.0, 200.0, 200.0), &[(C, 0.2)]), text.clone()])); + assert_eq!((f.report.kept, f.report.flattened), (1, 2)); + assert_eq!(f.items.last(), Some(&text), "the text is drawn as it is, over the fills"); + assert_eq!(at(&f, 125.0, 125.0, &[C, M]).1, [0.1, 0.5]); + assert_eq!(at(&f, 5.0, 5.0, &[C, K]).1, [0.2, 0.0]); + // Under the text, the frame's fill goes on. + let fills = Flattened { items: f.items[..f.items.len() - 1].to_vec(), ..f.clone() }; + assert_eq!(at(&fills, 30.0, 15.0, &[C, K]).1, [0.2, 0.0]); + + // Under an opaque rectangle that is itself under the transparency, the text is flattened. + let over = solid(Rect::new(40.0, 0.0, 120.0, 120.0), &[(Y, 1.0)]); + let f = flat(&veiled(vec![text.clone(), over])); + assert_eq!((f.report.kept, f.report.flattened), (0, 3)); + assert_eq!(at(&f, 20.0, 15.0, &[Y, K]).1, [0.0, 1.0]); + assert_eq!(at(&f, 45.0, 15.0, &[Y, K]).1, [1.0, 0.0]); + } + + #[test] + fn art_hidden_under_an_opaque_fill_does_not_cut_it() { + // K bars under a cyan fill, one where a half magenta overlaps it: three looks, three fills. + let half_magenta = Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(M, 1.0)])])); + let l = list( + InkSet::new(), + vec![ + solid(Rect::new(10.0, 10.0, 20.0, 90.0), &[(K, 1.0)]), + solid(Rect::new(60.0, 10.0, 70.0, 90.0), &[(K, 1.0)]), + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), + half_magenta, + ], + ); + let f = flat(&l); + assert_eq!(f.report.fills, 3, "{:?}", f.items); + assert_eq!(at(&f, 15.0, 50.0, &[C, K]).1, [1.0, 0.0]); + assert_eq!(at(&f, 65.0, 50.0, &[C, M, K]).1, [0.5, 0.5, 0.0]); + } + + #[test] + fn kept_isolated_and_knockout_groups_come_back_plain() { + // A placed page: an isolated group of opaque art, away from any transparency. + let inner = GroupItem { knockout: true, ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 10.0, 10.0), &[(C, 1.0)])]) }; + let placed = GroupItem { isolated: true, ..group(1.0, BlendMode::Normal, vec![Item::Group(inner)]) }; + let f = flat(&list(InkSet::new(), vec![Item::Group(placed)])); + let Some(Item::Group(g)) = f.items.first() else { panic!("{:?}", f.items) }; + let Some(Item::Group(inner)) = g.children.first() else { panic!("{g:?}") }; + assert!(!g.isolated && !inner.knockout); + } + + #[test] + fn gradients_and_crowded_groups_go_to_complex() { + let gradient = Item::Fill(FillItem { + path: Rect::new(50.0, 0.0, 150.0, 100.0).to_path(0.1), + rule: FillRule::NonZero, + paint: Paint::Gradient { + stops: vec![(0.0, InkColor::Tints(vec![(C, 0.0)])), (1.0, InkColor::Tints(vec![(C, 1.0)]))], + geometry: GradientGeometry::Axial { + start: Point::new(50.0, 0.0), + end: Point::new(150.0, 0.0), + extend: [true; 2], + transform: Affine::IDENTITY, + }, + }, + overprint: Overprint::default(), + }); + let l = list( + InkSet::new(), + vec![ + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(M, 1.0)]), + Item::Group(group(0.5, BlendMode::Normal, vec![gradient])), + solid(Rect::new(140.0, 0.0, 200.0, 100.0), &[(Y, 1.0)]), + ], + ); + let f = flat(&l); + assert_eq!(f.report.complex_regions, 1, "touching regions merge: {:?}", f.complex); + let both = f.complex.iter().find(|c| c.path.winding(Point::new(75.0, 50.0)) != 0).unwrap(); + assert_eq!(both.items, [0, 1]); + assert_eq!(both.at, f.items.len() - 1, "before the yellow kept on top"); + assert_eq!(at(&f, 25.0, 50.0, &[M]).1, [1.0]); + + let f = flatten(&l, &FlattenOptions { max_regions: 2, ..FlattenOptions::default() }).unwrap(); + assert_eq!(f.report.complex_groups, [ComplexGroup { items: vec![0, 1], reason: ComplexReason::TooManyRegions(3) }]); + assert_eq!((f.complex.len(), f.items.len()), (1, 1)); + } + + #[test] + fn flattened_fills_keep_the_actual_text_they_show() { + let tagged = GroupItem { + actual_text: Some("fi".into()), + ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)])]) + }; + let veil = group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(K, 1.0)])]); + let f = flat(&list(InkSet::new(), vec![Item::Group(tagged), Item::Group(veil)])); + let groups: Vec<&GroupItem> = f + .items + .iter() + .filter_map(|i| match i { + Item::Group(g) => Some(g), + _ => None, + }) + .collect(); + assert_eq!(groups.len(), 1, "{:?}", f.items); + assert_eq!(groups[0].actual_text.as_deref(), Some("fi")); + assert_eq!(groups[0].children.len(), 2, "the cyan alone and under the veil"); + assert_eq!((f.items.len(), f.report.fills), (2, 3)); + assert!(!is_transparent(&f.items[1])); + } +} diff --git a/crates/prepress/src/flatten/document.rs b/crates/prepress/src/flatten/document.rs new file mode 100644 index 00000000..0c0b31f4 --- /dev/null +++ b/crates/prepress/src/flatten/document.rs @@ -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>, + /// 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, + /// 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, +} + +/// 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, + 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, + /// 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, 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, 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 { + 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::(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>| 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, Rc>, +} + +impl Colors<'_> { + fn get(&mut self, page: usize) -> Result, FlattenDocumentError> { + let own = + self.doc.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS)).is_some_and(|a| a.iter::>().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> = 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() +} diff --git a/crates/prepress/src/flatten/font.rs b/crates/prepress/src/flatten/font.rs new file mode 100644 index 00000000..1b9d5a6b --- /dev/null +++ b/crates/prepress/src/flatten/font.rs @@ -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, +} + +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, +} + +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>> { + self.fonts.into_iter().map(|(key, noted)| (key, noted.prepare(key).map(Arc::new))).collect() + } +} + +impl Noted { + fn prepare(self, key: u128) -> Option { + 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) -> bool { + let glyphs: Vec = 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) -> Option> { + 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, x: u16| v.extend_from_slice(&x.to_be_bytes()); + let be32 = |v: &mut Vec, 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"); + } +} diff --git a/crates/prepress/src/flatten/geom.rs b/crates/prepress/src/flatten/geom.rs new file mode 100644 index 00000000..f84bf9ca --- /dev/null +++ b/crates/prepress/src/flatten/geom.rs @@ -0,0 +1,1016 @@ +//! Planar regions for the transparency flattener: a page's filled shapes cut into the faces of +//! their arrangement, each tagged with the shapes that cover it. The same shapes cover every +//! point of a region, so one composited color is exact for all of it. +//! +//! Ported from VectorCraft (MIT OR Apache-2.0) at commit c2b15982: +//! `crates/pathops/src/pathfinder.rs` (`regions`, `Region`, `Shape`), +//! `crates/pathops/src/boolean.rs` (the arrangement, per-input winding numbers, horizontal +//! snapping, contour repair and cleanup) and `overlapping` in +//! `crates/engine/src/cmd/flatten.rs`. The sweep is `linesweeper`'s, on `kurbo` curves. +//! +//! Clips go into the same sweep as the shapes instead of being applied first: one sweep replaces +//! a boolean per clipped shape, and a clip edge is then the same edge in every region it bounds. + +use std::collections::HashMap; + +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Rect, Shape as _, Vec2}; +use linesweeper::topology::{ContourIdx, Contours, Topology, WindingNumber}; + +/// Contours narrower than this on average (2 × area / perimeter, in input units) are dropped as +/// numerical debris, such as the lens between two nearly coincident curves. +pub const DEFAULT_SLIVER: f64 = 0.01; + +/// How a path's winding number decides what is inside it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum FillRule { + #[default] + NonZero, + EvenOdd, +} + +impl FillRule { + /// Whether a point of winding number `winding` is inside. + pub fn inside(self, winding: i32) -> bool { + match self { + FillRule::NonZero => winding != 0, + FillRule::EvenOdd => winding % 2 != 0, + } + } +} + +/// A filled shape. Open subpaths are closed, as a fill closes them. +#[derive(Clone, Debug, PartialEq)] +pub struct Shape { + pub path: BezPath, + pub rule: FillRule, + /// Indices into the clip list: the shape covers only what all of them cover. + pub clips: Vec, +} + +impl Shape { + pub fn new(path: BezPath, rule: FillRule) -> Self { + Self { path, rule, clips: Vec::new() } + } + + pub fn clipped(mut self, clips: impl IntoIterator) -> Self { + self.clips.extend(clips); + self + } +} + +/// A clipping path. +#[derive(Clone, Debug, PartialEq)] +pub struct ClipPath { + pub path: BezPath, + pub rule: FillRule, +} + +/// One face of the arrangement: an area where the covering shapes don't change. +#[derive(Clone, Debug, PartialEq)] +pub struct Region { + /// Outer contours and holes, oriented so that the nonzero rule fills the region. + pub path: BezPath, + /// Unsigned area. + pub area: f64, + /// Indices of the shapes covering the region, in input (paint) order. + pub sources: Vec, +} + +impl Region { + /// The front-most shape covering the region. + pub fn top(&self) -> Option { + self.sources.last().copied() + } + + pub fn contains(&self, p: Point) -> bool { + self.path.winding(p) != 0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum GeomError { + #[error("input contains NaN or infinite coordinates")] + NonFinite, + #[error("shape {shape} names clip {clip}, which does not exist")] + UnknownClip { shape: usize, clip: usize }, + /// The sweep failed on near-degenerate input; the caller rasterizes instead. + #[error("the shapes are too degenerate to sweep")] + Degenerate, +} + +/// The regions of `shapes` (back to front), each clipped by the `clips` it names. They tile the +/// union of the clipped shapes without overlaps; regions narrower than [`DEFAULT_SLIVER`] are +/// left out. +pub fn regions(shapes: &[Shape], clips: &[ClipPath]) -> Result, GeomError> { + regions_with(shapes, clips, DEFAULT_SLIVER) +} + +/// [`regions`] with contours narrower than `sliver` dropped. +pub fn regions_with(shapes: &[Shape], clips: &[ClipPath], sliver: f64) -> Result, GeomError> { + if shapes.is_empty() { + return Ok(Vec::new()); + } + Ok(Arrangement::new(shapes, clips, sliver)?.regions()) +} + +/// Indices of `bounds` grouped by overlap, transitively, each group in index order. Touching boxes +/// overlap; NaN boxes overlap nothing. +pub fn overlap_groups(bounds: &[Rect]) -> Vec> { + fn find(p: &mut [usize], mut i: usize) -> usize { + while p[i] != i { + p[i] = p[p[i]]; + i = p[i]; + } + i + } + let mut parent: Vec = (0..bounds.len()).collect(); + let mut order: Vec<(usize, Rect)> = bounds.iter().enumerate().filter(|(_, b)| !b.is_nan()).map(|(i, b)| (i, b.abs())).collect(); + order.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); + // Sweep left to right, comparing each box with those still open at its left edge. + let mut open: Vec<(usize, Rect)> = Vec::new(); + for (i, b) in order { + open.retain(|(_, o)| o.x1 >= b.x0); + for (j, o) in &open { + if o.y0 <= b.y1 && b.y0 <= o.y1 { + let (a, c) = (find(&mut parent, i), find(&mut parent, *j)); + parent[a.max(c)] = a.min(c); + } + } + open.push((i, b)); + } + let mut groups: Vec> = Vec::new(); + let mut slot: Vec> = vec![None; bounds.len()]; + for i in 0..bounds.len() { + let r = find(&mut parent, i); + match slot[r] { + Some(g) => groups[g].push(i), + None => { + slot[r] = Some(groups.len()); + groups.push(vec![i]); + } + } + } + groups +} + +/// The [`overlap_groups`] that hold a transparent item. Only their items need [`regions`]; every +/// other item paints as it is. Groups are transitive so that an opaque item keeps its place in +/// paint order above or below everything it overlaps. +pub fn transparent_groups(bounds: &[Rect], transparent: &[bool]) -> Vec> { + let mut groups = overlap_groups(bounds); + groups.retain(|g| g.iter().any(|&i| transparent.get(i).copied().unwrap_or(false))); + groups +} + +/// Winding numbers for any number of inputs: the non-zero ones as (tag, winding), sorted by tag. +/// A face is covered by a few inputs, so this stays short where a vector of every input's winding +/// would cost one step per input. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +struct Multi(Vec<(usize, i32)>); + +impl Multi { + fn get(&self, tag: usize) -> i32 { + self.0.binary_search_by_key(&tag, |e| e.0).ok().and_then(|k| self.0.get(k)).map_or(0, |e| e.1) + } +} + +impl std::ops::Add for Multi { + type Output = Multi; + fn add(mut self, rhs: Self) -> Multi { + self += rhs; + self + } +} + +impl std::ops::AddAssign for Multi { + fn add_assign(&mut self, rhs: Self) { + // The sweep mostly adds one input's ±1 to a short list: add in place, insert in tag order, + // drop a winding that reaches zero. + for (t, k) in rhs.0 { + match self.0.binary_search_by_key(&t, |e| e.0) { + Ok(i) => { + if let Some(e) = self.0.get_mut(i) { + e.1 += k; + if e.1 == 0 { + self.0.remove(i); + } + } + } + Err(i) if k != 0 => self.0.insert(i, (t, k)), + Err(_) => {} + } + } + } +} + +impl WindingNumber for Multi { + type Tag = usize; + fn single(tag: usize, positive: bool) -> Self { + Multi(vec![(tag, if positive { 1 } else { -1 })]) + } + fn of_tag(&self, tag: usize) -> Self { + let w = self.get(tag); + Multi(if w == 0 { vec![] } else { vec![(tag, w)] }) + } +} + +/// The sweep of shapes and clips. Tags `0..n` are shapes, `n + c` is clip `c`. +struct Arrangement { + top: Topology, + rules: Vec, + n: usize, + /// Each shape's clip tags. + clips: Vec>, + tidy: Tidy, +} + +impl Arrangement { + fn new(shapes: &[Shape], clips: &[ClipPath], sliver: f64) -> Result { + let n = shapes.len(); + let mut used = vec![false; clips.len()]; + let mut shape_clips = Vec::with_capacity(n); + for (shape, s) in shapes.iter().enumerate() { + let mut tags = Vec::with_capacity(s.clips.len()); + for &clip in &s.clips { + *used.get_mut(clip).ok_or(GeomError::UnknownClip { shape, clip })? = true; + tags.push(n + clip); + } + tags.sort_unstable(); + tags.dedup(); + shape_clips.push(tags); + } + let mut paths: Vec<(BezPath, usize)> = shapes + .iter() + .enumerate() + .map(|(i, s)| (closed(&s.path), i)) + .chain(clips.iter().enumerate().filter(|(c, _)| used[*c]).map(|(c, p)| (closed(&p.path), n + c))) + .collect(); + let eps = eps_for(paths.iter().map(|p| &p.0))?; + snap_horizontals(&mut paths.iter_mut().map(|p| &mut p.0).collect::>(), eps); + // The sweep can panic on rare near-degenerate input; the flattener then rasterizes. + let sweep = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(paths.iter().map(|(p, t)| (p, *t)), eps))); + // Every subpath is closed, so a path error is a degenerate input too. + let top = sweep.map_err(|_| GeomError::Degenerate)?.map_err(|_| GeomError::Degenerate)?; + let rules = shapes.iter().map(|s| s.rule).chain(clips.iter().map(|c| c.rule)).collect(); + let tidy = Tidy::keeping(sliver, paths.iter().map(|p| &p.0)); + Ok(Self { top, rules, n, clips: shape_clips, tidy }) + } + + /// The shapes covering a face of winding `w`, inside all their clips. + fn covering(&self, w: &Multi) -> Vec { + let inside: Vec = w.0.iter().filter(|&&(t, k)| self.rules.get(t).is_some_and(|r| r.inside(k))).map(|&(t, _)| t).collect(); + inside.iter().copied().take_while(|&t| t < self.n).filter(|&s| self.clips[s].iter().all(|c| inside.binary_search(c).is_ok())).collect() + } + + /// Every face, with the faces of one coverage set merged where they touch. + /// + /// A contour pass walks the whole arrangement, so one pass per coverage set would cost + /// sets × edges. Instead the sets go into batches whose faces share no vertex (so no edge + /// either): one pass per batch walks each face's contours as its own set's pass would, and the + /// set a contour bounds is the one of its batch at its first point. + fn regions(&self) -> Vec { + let top = &self.top; + // The distinct non-empty coverage sets, numbered in the order met, and the set of each + // side of each segment. Faces have many sides, so a set is worked out once per winding. + let mut ids: HashMap, usize> = HashMap::new(); + let mut sets: Vec> = Vec::new(); + let mut by_winding: HashMap<&Multi, Option> = HashMap::new(); + // The contour passes hand their predicate references into the topology's windings, so + // the winding's address finds its set without hashing the winding. + let mut by_addr: HashMap> = HashMap::new(); + let mut sides = Vec::new(); + for s in top.segment_indices() { + let h = s.first_half(); + let mut pair = [None; 2]; + for (side, w) in pair.iter_mut().zip([top.winding_clockwise(h), top.winding_counter_clockwise(h)]) { + let m = *by_winding.entry(w).or_insert_with(|| { + let set = self.covering(w); + let next = sets.len(); + (!set.is_empty()).then(|| { + *ids.entry(set.clone()).or_insert_with(|| { + sets.push(set); + next + }) + }) + }); + by_addr.insert(std::ptr::from_ref(w).addr(), m); + *side = m; + } + sides.push((s, pair)); + } + let id = |w: &Multi| match by_addr.get(&std::ptr::from_ref(w).addr()) { + Some(m) => *m, + None => ids.get(&self.covering(w)).copied(), + }; + // The sets of the faces around each vertex. + let mut around: HashMap<(u64, u64), Vec> = HashMap::new(); + for (s, pair) in sides { + for end in [s.first_half(), s.second_half()] { + let at = around.entry(vertex_key(top.point(end).to_kurbo())).or_default(); + for m in pair.into_iter().flatten() { + if !at.contains(&m) { + at.push(m); + } + } + } + } + let mut near = vec![Vec::new(); sets.len()]; + for at in around.values() { + for &a in at { + near[a].extend(at.iter().copied().filter(|&b| b != a)); + } + } + // Greedy coloring: each set takes the first batch none of its neighbors is in. + let mut batch = vec![usize::MAX; sets.len()]; + let mut batches = 0; + for m in 0..sets.len() { + let mut taken = vec![false; batches]; + for &n in &near[m] { + if let Some(t) = taken.get_mut(batch[n]) { + *t = true; + } + } + batch[m] = taken.iter().position(|t| !t).unwrap_or(batches); + batches = batches.max(batch[m] + 1); + } + let mut faces: Vec> = vec![Vec::new(); sets.len()]; + for b in 0..batches { + let c = top.contours(|w| id(w).is_some_and(|m| batch[m] == b)); + let owner: Option> = c + .contours() + .map(|ct| { + let Some(PathEl::MoveTo(p)) = ct.path.elements().first() else { return None }; + around.get(&vertex_key(*p))?.iter().copied().find(|&m| batch[m] == b) + }) + .collect(); + let Some(owner) = owner else { + // Not expected: every contour starts at a vertex of the arrangement. Fall back to + // a pass per set for this batch. + for m in (0..sets.len()).filter(|&m| batch[m] == b) { + let c = top.contours(|w| id(w) == Some(m)); + faces[m].extend(c.grouped().into_iter().map(|g| self.outline(&c, g))); + } + continue; + }; + // A contour belongs under the nearest enclosing contour of the same set, as in that + // set's own pass. + let mut children = vec![Vec::new(); owner.len()]; + let mut roots = Vec::new(); + for (i, &m) in owner.iter().enumerate() { + let mut up = c[ContourIdx(i)].parent; + while let Some(p) = up.filter(|p| owner.get(p.0) != Some(&m)) { + up = c[p].parent; + } + match up { + Some(p) => children[p.0].push(i), + None => roots.push(i), + } + } + for r in roots { + let mut group = Vec::new(); + let mut stack = vec![r]; + while let Some(i) = stack.pop() { + group.push(ContourIdx(i)); + stack.extend(children[i].iter().rev()); + } + faces[owner[r]].push(self.outline(&c, group)); + } + } + let mut out = Vec::new(); + for (sources, paths) in sets.into_iter().zip(faces) { + for path in paths.into_iter().filter(|p| !p.elements().is_empty()) { + let area = path.area().abs(); + out.push(Region { path, area, sources: sources.clone() }); + } + } + out + } + + /// The contours `idx` of `c` as one path, repaired, without slivers, and with the pieces the + /// sweep split from one input curve or line joined again. + fn outline(&self, c: &Contours, idx: impl IntoIterator) -> BezPath { + let mut out = BezPath::new(); + for i in idx { + let path = self.tidy.repair(&c[i].path); + if is_sliver(&path, self.tidy.sliver) { + continue; + } + let segs = tidy_segments(path.segments().map(to_seg).collect(), &self.tidy); + let Some(first) = segs.first() else { continue }; + out.move_to(first.c.p0); + for s in &segs { + if s.line { + out.line_to(s.c.p3); + } else { + out.curve_to(s.c.p1, s.c.p2, s.c.p3); + } + } + out.close_path(); + } + out + } +} + +/// A point as a map key (the arrangement's vertices are exact). +fn vertex_key(p: Point) -> (u64, u64) { + (p.x.to_bits(), p.y.to_bits()) +} + +/// `bp` with every subpath closed. +fn closed(bp: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let (mut open, mut start) = (false, Point::ZERO); + for el in bp.iter() { + match el { + PathEl::MoveTo(p) => { + if open { + out.close_path(); + } + out.move_to(p); + (open, start) = (true, p); + } + PathEl::ClosePath => { + if open { + out.close_path(); + } + open = false; + } + _ => { + // Drawing on after a close starts again at the subpath's start. + if !open { + out.move_to(start); + open = true; + } + out.push(el); + } + } + } + if open { + out.close_path(); + } + out +} + +/// The sweep tolerance for a set of paths (linesweeper's own `binary_op` scaling). +fn eps_for<'a>(paths: impl IntoIterator) -> Result { + let mut m: f64 = 0.0; + for p in paths { + for el in p.elements() { + for pt in el_points(el) { + if !pt.x.is_finite() || !pt.y.is_finite() { + return Err(GeomError::NonFinite); + } + m = m.max(pt.x.abs()).max(pt.y.abs()); + } + } + } + Ok((m * f64::EPSILON * 64.0).max(1e-6)) +} + +fn el_points(el: &PathEl) -> impl Iterator { + let v: [Option; 3] = match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => [Some(p), None, None], + PathEl::QuadTo(a, b) => [Some(a), Some(b), None], + PathEl::CurveTo(a, b, c) => [Some(a), Some(b), Some(c)], + PathEl::ClosePath => [None, None, None], + }; + v.into_iter().flatten() +} + +/// Make edges that rise by less than the sweep tolerance `eps` exactly horizontal. +/// +/// The sweep orders a curve against another segment over a y-span shorter than `eps` by bounding +/// boxes alone, so an almost-horizontal edge can be mis-ordered against a curve it passes and a +/// corner is lost; exact horizontals are handled exactly. So on-curve y coordinates within `eps` +/// of each other snap to one value (the lowest of each cluster, keeping distinct values at least +/// `eps` apart), handles move with their anchors, and a handle within `eps` of its anchor's +/// height is leveled with it. Anchors move by less than `eps` and handles by less than `2·eps`. +fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) { + let mut ys: Vec = paths.iter().flat_map(|p| p.elements().iter().filter_map(PathEl::end_point)).map(|p| p.y).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup(); + let mut start = f64::NEG_INFINITY; + let levels: Vec = ys + .iter() + .map(|&y| { + if y - start >= eps { + start = y; + } + start + }) + .collect(); + let snap = |p: &mut Point| { + let y = levels.get(ys.partition_point(|&v| v < p.y)).copied().unwrap_or(p.y); + let moved = y - p.y; + p.y = y; + moved + }; + // Shift a handle with its anchor, then level it with the anchor when within `eps`. + let follow = |h: &mut Point, anchor: Point, moved: f64| { + h.y += moved; + if (h.y - anchor.y).abs() < eps { + h.y = anchor.y; + } + }; + for p in paths.iter_mut() { + // The current point (snapped), how far snapping moved it, and the subpath's start. + let (mut cur, mut moved, mut first) = (Point::ZERO, 0.0, Point::ZERO); + for el in p.elements_mut() { + match el { + PathEl::MoveTo(q) => { + moved = snap(q); + (cur, first) = (*q, *q); + } + PathEl::LineTo(q) => { + moved = snap(q); + cur = *q; + } + PathEl::QuadTo(c, q) => { + let m = snap(q); + follow(c, cur, moved); + follow(c, *q, 0.0); + (cur, moved) = (*q, m); + } + PathEl::CurveTo(c1, c2, q) => { + let m = snap(q); + follow(c1, cur, moved); + follow(c2, *q, m); + (cur, moved) = (*q, m); + } + PathEl::ClosePath => { + (cur, moved) = (first, 0.0); + } + } + } + } +} + +/// Contours whose mean width (2 × area / perimeter) is below `sliver` are debris. +fn is_sliver(bp: &BezPath, sliver: f64) -> bool { + let a = bp.area().abs(); + let per = bp.perimeter(1e-6); + per <= 0.0 || 2.0 * a / per < sliver +} + +/// One segment of a contour: a cubic, and whether it is a straight line. +#[derive(Clone, Copy, Debug)] +struct Seg { + c: CubicBez, + line: bool, +} + +impl Seg { + fn line(a: Point, b: Point) -> Self { + Self { c: CubicBez::new(a, a, b, b), line: true } + } +} + +fn to_seg(s: PathSeg) -> Seg { + match s { + PathSeg::Line(l) => Seg::line(l.p0, l.p1), + PathSeg::Quad(q) => { + let c = q.raise(); + Seg { c, line: is_straight(&c, 1e-9) } + } + PathSeg::Cubic(c) => Seg { c, line: is_straight(&c, 1e-9 * (1.0 + c.p0.distance(c.p3))) }, + } +} + +/// Whether the cubic is (within `tol`) a straight line with its handles inside the chord. +fn is_straight(c: &CubicBez, tol: f64) -> bool { + let chord = c.p3 - c.p0; + let len = chord.hypot(); + if len < 1e-12 { + return c.p1.distance(c.p0) <= tol && c.p2.distance(c.p0) <= tol; + } + let dir = chord / len; + [c.p1, c.p2].iter().all(|&h| { + let v = h - c.p0; + let along = v.dot(dir); + v.cross(dir).abs() <= tol && along >= -tol && along <= len + tol + }) +} + +fn unit(v: Vec2) -> Option { + let l = v.hypot(); + (l > 1e-12 && l.is_finite()).then(|| v / l) +} + +/// The direction the curve leaves `p0`. +fn start_tangent(c: &CubicBez) -> Vec2 { + unit(c.p1 - c.p0).or_else(|| unit(c.p2 - c.p0)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0)) +} + +/// The direction the curve arrives at `p3`. +fn end_tangent(c: &CubicBez) -> Vec2 { + unit(c.p3 - c.p2).or_else(|| unit(c.p3 - c.p1)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0)) +} + +/// The distance two points must be within to count as one, at `p`'s magnitude. +fn tol_at(p: Point) -> f64 { + 1e-6 * (1.0 + p.x.abs().max(p.y.abs())) +} + +/// Output cleanup: the inputs' on-curve points (joints there stay), and their curves, to rebuild +/// split pieces exactly. +struct Tidy { + sliver: f64, + /// Sorted by x. + keep: Vec, + /// Input curves with slightly inflated bounds. + curves: Vec<(Rect, CubicBez)>, + /// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose + /// bounds can hold a point, without testing them all. + by_x: Vec, + max_width: f64, +} + +impl Tidy { + fn keeping<'a>(sliver: f64, paths: impl IntoIterator) -> Self { + let mut keep = Vec::new(); + let mut curves = Vec::new(); + for p in paths { + for s in p.segments() { + let seg = to_seg(s); + keep.push(seg.c.p0); + if !seg.line { + curves.push((seg.c.bounding_box().inflate(1e-6, 1e-6), seg.c)); + } + } + } + keep.sort_by(|a, b| a.x.total_cmp(&b.x)); + let mut by_x: Vec = (0..curves.len()).collect(); + by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0)); + let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max); + Self { sliver, keep, curves, by_x, max_width } + } + + fn is_kept(&self, p: Point) -> bool { + let tol = tol_at(p); + let lo = self.keep.partition_point(|q| q.x < p.x - tol); + self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol) + } + + /// The input curves whose bounds hold both `a` and `b`. + fn curves_around(&self, a: Point, b: Point) -> impl Iterator { + let x = a.x.min(b.x); + let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x); + let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width); + self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c) + } + + /// `bp` with each curve piece that strays from the one input curve through both its ends + /// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't + /// follow its curve; out and back between two points of one curve, such pieces make a lens + /// instead of nothing (VectorCraft issue #1028). A piece on a curve, or whose ends two + /// different input curves pass through (a real lens between them), is left as it is. + fn repair(&self, bp: &BezPath) -> BezPath { + if self.curves.is_empty() { + return bp.clone(); + } + let mut out = BezPath::new(); + let (mut cur, mut start) = (Point::ZERO, Point::ZERO); + for el in bp.iter() { + let piece = match el { + PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()), + PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)), + _ => None, + }; + match (el, piece) { + (_, Some(raw)) => { + let (a, b) = (raw.p0, raw.p3); + let tol = tol_at(Point::new(a.x.abs().max(b.x.abs()), a.y.abs().max(b.y.abs()))); + let mut subs: Vec = Vec::new(); + let mut follows = false; + for c in self.curves_around(a, b) { + let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12)); + if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 { + continue; + } + if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) { + follows = true; + break; + } + subs.push(sub_curve(c, na.t, nb.t, a, b)); + } + let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0; + match subs.split_first() { + Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b), + _ => out.push(el), + } + cur = b; + } + (PathEl::MoveTo(p), None) => { + out.push(el); + (cur, start) = (p, p); + } + (PathEl::LineTo(p), None) => { + out.push(el); + cur = p; + } + (PathEl::ClosePath, None) => { + out.push(el); + cur = start; + } + (_, None) => out.push(el), + } + } + out + } +} + +/// The part of `c` from parameter `ta` to `tb` (either order), with its ends at `a` and `b`. +fn sub_curve(c: &CubicBez, ta: f64, tb: f64, a: Point, b: Point) -> CubicBez { + let sub = if ta < tb { + c.subsegment(ta..tb) + } else { + let r = c.subsegment(tb..ta); + CubicBez::new(r.p3, r.p2, r.p1, r.p0) + }; + CubicBez::new(a, sub.p1, sub.p2, b) +} + +/// The closed contour `segs` with the pieces the sweep split from one input line or curve joined +/// again, and degenerate pieces dropped. Joints at input points stay. Only exact joins are made, +/// so neighboring regions keep the same edges. +fn tidy_segments(segs: Vec, tidy: &Tidy) -> Vec { + let scale = segs.iter().map(|s| s.c.p0.distance(s.c.p3)).fold(0.0, f64::max); + let tiny = (scale * 1e-9).max(1e-9); + let mut segs: Vec = segs + .into_iter() + .filter(|s| { + let r = Rect::from_points(s.c.p0, s.c.p3).union_pt(s.c.p1).union_pt(s.c.p2); + r.width() + r.height() > tiny + }) + .collect(); + let n = segs.len(); + if n < 2 { + return segs; + } + // Joint i sits at the start of segment i. + let mergeable = |a: &Seg, b: &Seg| -> bool { + if a.line != b.line || tidy.is_kept(b.c.p0) { + return false; + } + let ta = end_tangent(&a.c); + let tb = start_tangent(&b.c); + ta.dot(tb) > 0.9998 && ta.cross(tb).abs() < 0.02 + }; + // Start at a real corner, if there is one. + if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) { + segs.rotate_left(k); + } + let mut out: Vec = Vec::with_capacity(n); + let mut i = 0; + while i < n { + let mut cur = segs[i]; + let mut j = i + 1; + while j < n && mergeable(&segs[j - 1], &segs[j]) { + match join(&segs[i..=j], tidy) { + Some(m) => { + cur = m; + j += 1; + } + None => break, + } + } + out.push(cur); + i = j; + } + out +} + +/// `run` as one segment, when its pieces lie on one line or one input curve. +fn join(run: &[Seg], tidy: &Tidy) -> Option { + let (first, last) = (run.first()?.c, run.last()?.c); + let (a, b) = (first.p0, last.p3); + let tol = tol_at(Point::new(a.x.abs().max(b.x.abs()), a.y.abs().max(b.y.abs()))); + if run[0].line { + let chord = b - a; + let len = chord.hypot(); + if len < 1e-12 { + return None; + } + let ok = run.iter().all(|s| ((s.c.p3 - a).cross(chord) / len).abs() <= tol); + return ok.then(|| Seg::line(a, b)); + } + for c in tidy.curves_around(a, b) { + let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12)); + if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 { + continue; + } + if run.iter().all(|s| c.nearest(s.c.eval(0.5), 1e-12).distance_sq.sqrt() <= tol * 10.0) { + return Some(Seg { c: sub_curve(c, na.t, nb.t, a, b), line: false }); + } + } + None +} + +#[cfg(test)] +mod tests { + use kurbo::Circle; + + use super::*; + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath { + Rect::new(x0, y0, x1, y1).to_path(0.1) + } + + fn fill(path: BezPath) -> Shape { + Shape::new(path, FillRule::NonZero) + } + + /// The regions with their sources and areas rounded to 1e-6, sorted. + fn summary(regions: &[Region]) -> Vec<(Vec, f64)> { + let mut v: Vec<(Vec, f64)> = regions.iter().map(|r| (r.sources.clone(), (r.area * 1e6).round() / 1e6)).collect(); + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + v + } + + /// Each grid point inside the union is in exactly one region, whose sources are the shapes + /// holding the point. + fn assert_tiles(shapes: &[Shape], regions: &[Region], area: Rect, step: f64) { + let mut y = area.y0 + step * 0.37; + while y < area.y1 { + let mut x = area.x0 + step * 0.41; + while x < area.x1 { + let p = Point::new(x, y); + let covering: Vec = shapes.iter().enumerate().filter(|(_, s)| s.rule.inside(s.path.winding(p))).map(|(i, _)| i).collect(); + let hits: Vec<&Region> = regions.iter().filter(|r| r.contains(p)).collect(); + if covering.is_empty() { + assert!(hits.is_empty(), "{p:?} is in no shape but in {} regions", hits.len()); + } else { + assert_eq!(hits.len(), 1, "{p:?} is in {} regions", hits.len()); + assert_eq!(hits[0].sources, covering, "at {p:?}"); + } + x += step; + } + y += step; + } + } + + #[test] + fn two_overlapping_rectangles_make_three_regions() { + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)), fill(rect(5.0, 5.0, 15.0, 15.0))]; + let r = regions(&shapes, &[]).unwrap(); + assert_eq!(summary(&r), vec![(vec![0], 75.0), (vec![0, 1], 25.0), (vec![1], 75.0)]); + assert_tiles(&shapes, &r, Rect::new(-1.0, -1.0, 16.0, 16.0), 0.5); + // The pieces the sweep split rectangle edges into are joined again. + let overlap = r.iter().find(|r| r.sources == [0, 1]).unwrap(); + assert_eq!(overlap.path.segments().count(), 4); + assert_eq!(overlap.top(), Some(1)); + } + + #[test] + fn coincident_edges_make_no_slivers() { + // Side by side, sharing an edge. + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)), fill(rect(10.0, 0.0, 20.0, 10.0))]; + assert_eq!(summary(®ions(&shapes, &[]).unwrap()), vec![(vec![0], 100.0), (vec![1], 100.0)]); + // Sharing three edges. + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)), fill(rect(0.0, 0.0, 10.0, 5.0))]; + assert_eq!(summary(®ions(&shapes, &[]).unwrap()), vec![(vec![0], 50.0), (vec![0, 1], 50.0)]); + // The same circle twice, one reversed. + let circle = Circle::new((5.0, 5.0), 5.0).to_path(0.01); + let shapes = [fill(circle.clone()), fill(circle.reverse_subpaths())]; + let r = regions(&shapes, &[]).unwrap(); + assert_eq!(r.len(), 1); + assert_eq!(r[0].sources, [0, 1]); + assert!((r[0].area - circle.area().abs()).abs() < 1e-9); + } + + #[test] + fn fill_rules_differ_on_self_overlap() { + // Two squares in one path, wound the same way: the overlap has winding 2. + let mut path = rect(0.0, 0.0, 10.0, 10.0); + path.extend(rect(5.0, 5.0, 15.0, 15.0)); + let center = Point::new(7.5, 7.5); + let nonzero = regions(&[Shape::new(path.clone(), FillRule::NonZero)], &[]).unwrap(); + assert_eq!(summary(&nonzero), vec![(vec![0], 175.0)]); + assert!(nonzero[0].contains(center)); + let shapes = [Shape::new(path, FillRule::EvenOdd)]; + let evenodd = regions(&shapes, &[]).unwrap(); + assert!((evenodd.iter().map(|r| r.area).sum::() - 150.0).abs() < 1e-9); + assert!(evenodd.iter().all(|r| r.sources == [0] && !r.contains(center))); + assert_tiles(&shapes, &evenodd, Rect::new(-1.0, -1.0, 16.0, 16.0), 0.5); + } + + #[test] + fn a_hole_is_part_of_its_region_and_a_shape_in_it_is_its_own() { + // Nonzero hole by reversal, even-odd hole by nesting. + let mut nonzero = rect(0.0, 0.0, 10.0, 10.0); + nonzero.extend(rect(3.0, 3.0, 7.0, 7.0).reverse_subpaths()); + let mut evenodd = rect(0.0, 0.0, 10.0, 10.0); + evenodd.extend(rect(3.0, 3.0, 7.0, 7.0)); + for donut in [Shape::new(nonzero, FillRule::NonZero), Shape::new(evenodd, FillRule::EvenOdd)] { + let shapes = [donut, fill(rect(4.0, 4.0, 6.0, 6.0))]; + let r = regions(&shapes, &[]).unwrap(); + assert_eq!(summary(&r), vec![(vec![0], 84.0), (vec![1], 4.0)]); + let ring = r.iter().find(|r| r.sources == [0]).unwrap(); + assert_eq!(ring.path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(), 2); + assert!(!ring.contains(Point::new(3.5, 3.5))); + assert_tiles(&shapes, &r, Rect::new(-1.0, -1.0, 11.0, 11.0), 0.25); + } + } + + #[test] + fn slivers_below_the_threshold_are_dropped() { + // Overlapping by 0.001. + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)), fill(rect(9.999, 0.0, 20.0, 10.0))]; + let r = regions(&shapes, &[]).unwrap(); + assert!(r.iter().all(|r| r.sources != [0, 1])); + assert_eq!(r.len(), 2); + let kept = regions_with(&shapes, &[], 0.0).unwrap(); + let sliver = kept.iter().find(|r| r.sources == [0, 1]).unwrap(); + assert!((sliver.area - 0.01).abs() < 1e-9, "{}", sliver.area); + } + + #[test] + fn region_areas_sum_to_the_union_area() { + let shapes = [ + fill(rect(0.0, 0.0, 40.0, 30.0)), + fill(Circle::new((35.0, 25.0), 12.0).to_path(0.01)), + fill(rect(10.0, 10.0, 60.0, 20.0)), + Shape::new(Circle::new((20.0, 35.0), 8.0).to_path(0.01), FillRule::EvenOdd), + fill(rect(70.0, 0.0, 80.0, 10.0)), + ]; + let r = regions(&shapes, &[]).unwrap(); + let sum: f64 = r.iter().map(|r| r.area).sum(); + let union = union_area(&shapes); + assert!((sum - union).abs() < 1e-6 * union, "{sum} vs {union}"); + assert_tiles(&shapes, &r, Rect::new(-1.0, -1.0, 81.0, 44.0), 0.7); + } + + /// The area of the union of `shapes` (all positively wound), from a sweep of them as one path. + fn union_area(shapes: &[Shape]) -> f64 { + let mut all = BezPath::new(); + for s in shapes { + all.extend(s.path.iter()); + } + let top = Topology::::from_path(&all, 1e-6).unwrap(); + top.contours(|w| *w != 0).contours().map(|c| c.path.area()).sum::().abs() + } + + #[test] + fn clips_limit_their_shapes_only() { + let clips = [ClipPath { path: rect(5.0, 0.0, 25.0, 10.0), rule: FillRule::NonZero }]; + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)).clipped([0]), fill(rect(20.0, 0.0, 30.0, 10.0))]; + let r = regions(&shapes, &clips).unwrap(); + // The clip's edge at x = 25 crosses only the unclipped shape, which stays one region. + assert_eq!(summary(&r), vec![(vec![0], 50.0), (vec![1], 100.0)]); + assert!(r.iter().any(|r| r.sources == [0] && r.contains(Point::new(7.0, 5.0)))); + assert_eq!(regions(&[fill(rect(0.0, 0.0, 1.0, 1.0)).clipped([3])], &clips), Err(GeomError::UnknownClip { shape: 0, clip: 3 })); + } + + #[test] + fn open_and_non_finite_input() { + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((10.0, 0.0)); + open.line_to((10.0, 10.0)); + assert_eq!(summary(®ions(&[fill(open)], &[]).unwrap()), vec![(vec![0], 50.0)]); + assert_eq!(regions(&[fill(rect(0.0, 0.0, f64::NAN, 1.0))], &[]), Err(GeomError::NonFinite)); + assert_eq!(regions(&[], &[]), Ok(vec![])); + } + + #[test] + fn only_groups_with_transparency_need_regions() { + let bounds = [ + Rect::new(0.0, 0.0, 10.0, 10.0), + Rect::new(100.0, 100.0, 110.0, 110.0), + Rect::new(10.0, 5.0, 20.0, 15.0), // touches 0 + Rect::new(18.0, 14.0, 30.0, 30.0), + Rect::new(200.0, 0.0, 210.0, 10.0), + ]; + assert_eq!(overlap_groups(&bounds), vec![vec![0, 2, 3], vec![1], vec![4]]); + assert_eq!(transparent_groups(&bounds, &[false, false, false, true, false]), vec![vec![0, 2, 3]]); + assert!(transparent_groups(&bounds, &[false; 5]).is_empty()); + } + + /// About 1,000 rectangles on a letter page. Run with `--ignored --nocapture`. + #[test] + #[ignore] + fn timing_on_a_thousand_rectangles() { + let mut seed: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + let shapes: Vec = (0..1000) + .map(|_| { + let (x, y) = (next() * 560.0, next() * 740.0); + let (w, h) = (2.0 + next() * 50.0, 2.0 + next() * 50.0); + fill(rect(x, y, x + w, y + h)) + }) + .collect(); + let t = std::time::Instant::now(); + let r = regions(&shapes, &[]).unwrap(); + let elapsed = t.elapsed(); + eprintln!("{} shapes -> {} regions in {:?}", shapes.len(), r.len(), elapsed); + let union = union_area(&shapes); + let sum: f64 = r.iter().map(|r| r.area).sum(); + // Less the slivers left out. + assert!(sum <= union * (1.0 + 1e-9) && sum > union * (1.0 - 1e-4), "{sum} vs {union}"); + } +} diff --git a/crates/prepress/src/flatten/mod.rs b/crates/prepress/src/flatten/mod.rs new file mode 100644 index 00000000..860d9ed7 --- /dev/null +++ b/crates/prepress/src/flatten/mod.rs @@ -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, +} + +/// 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>, + /// 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, + /// The Unicode text of the run, when the source had it; [`PlacedGlyph::text`] indexes it. + pub text: Option, + /// 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, +} + +/// Font bytes shared without copying, as hayro-interpret and krilla hold them. +pub type FontBytes = Arc + 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>, + 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>, +} + +/// 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), +} + +/// 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, + pub mask: Option>, + /// 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, + pub children: Vec, +} + +/// 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, + /// The transfer function (`/TR`) sampled at 256 evenly spaced mask values from 0 to 1, as + /// bytes; `None` for the identity. + pub transfer: Option>, +} + +/// 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) + } +} diff --git a/crates/prepress/src/flatten/pdf.rs b/crates/prepress/src/flatten/pdf.rs new file mode 100644 index 00000000..f6d67a9e --- /dev/null +++ b/crates/prepress/src/flatten/pdf.rs @@ -0,0 +1,1335 @@ +//! The PDF-in adapter: a page read through hayro-interpret into a [`DisplayList`]. +//! +//! - Coordinates are page space (`Page::initial_transform(true)`): rotation and the crop box +//! are applied. +//! - Colors stay inks, as plate mode separates them (`InterpreterSettings::plate`): +//! `Separation` and `DeviceN` colorants are their inks, DeviceCMYK its four process tints, +//! DeviceGray black only (C, M and Y at 0), and RGB, Lab and ICC colors convert to CMYK through +//! the page's output intent, else [`ReadOptions::fallback`]. Spot inks join +//! [`DisplayList::inks`] with their alternate: CMYK when the space's alternate is DeviceCMYK, +//! else Lab measured from the sRGB appearance. +//! - Overprint: an item's [`Overprint::fill`] and [`Overprint::stroke`] both hold the flag that +//! governed its paint (`op` for fills, glyphs and images, `OP` for strokes). +//! [`Overprint::nonzero`] is `OPM 1` for colors set in DeviceCMYK only, where it applies. +//! - Constant opacity, blend modes and soft masks wrap each paint in a group of its own; a text +//! run shares one wrapper. +//! - Images keep their own space where it is inks: DeviceGray, DeviceCMYK, and the colorants of +//! `Separation` and `DeviceN` (Indexed images take their base's). Other spaces convert to CMYK +//! as colors do. `/Decode` and `/Matte` are applied; transfer functions are not, as on a plate. +//! - Stencil masks become fills traced from the mask, or images when the mask is too detailed. +//! - Axial and radial shadings become [`Paint::Gradient`]s with stops sampled in ink space. +//! Function-based and mesh shadings, and tiling patterns past [`MAX_TILING_CELLS`] cells or +//! [`MAX_TILING_ITEMS`] items, become images at [`ReadOptions::raster_dpi`]; other tiling patterns expand into their cells. + +use std::collections::HashMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; + +use craft_pdf::interpret::color::{CmykSimulation, ColorSpace, Ink as PdfInk, InkPaint, InkSpace, SpotAlternate}; +use craft_pdf::interpret::font::{Glyph, OutlineGlyph}; +use craft_pdf::interpret::hayro_cmap::BfString; +use craft_pdf::interpret::pattern::{Pattern, ShadingPattern, TilingPattern}; +use craft_pdf::interpret::shading::ShadingType; +use craft_pdf::interpret::{ + BlendMode as PdfBlend, CacheKey, ClipPath, Context, Device, FillRule as PdfFillRule, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, + MaskType, Paint as PdfPaint, PathDrawMode, RasterImage, SoftMask, StencilImage, StrokeProps, TransformExt, interpret_page, +}; +use craft_pdf::syntax::Filter; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::dict::keys::{ANNOTS, AP, BITS_PER_COMPONENT, COLORSPACE, DECODE, DECODE_PARMS, F, HEIGHT, N, WIDTH}; +use craft_pdf::syntax::object::{Array, Dict, Name, Stream}; +use craft_pdf::syntax::page::Page; +use craft_pdf::{OpenError, open_slice}; +use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Stroke, StrokeOpts}; + +use super::font::FontCache; +use super::shade::{self, Canvas, StopInks}; +use super::{ + BlendMode, DisplayList, FillItem, FillRule, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, Mask, MaskKind, Paint, PlacedGlyph, + TextFont, TextItem, +}; +use crate::cms::{ProfileError, srgb8_to_lab}; +use crate::ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot}; +use crate::plates::{self, Conversion, Fallback}; + +/// Curve flattening tolerance of outlined strokes, in points. +const STROKE_TOLERANCE: f64 = 0.01; + +/// The width of a `0 w` stroke, the thinnest line a device can draw, in points. +const HAIRLINE: f64 = 0.1; + +/// Dashes one stroke may be cut into; past this it is stroked solid, as hayro draws it. +const MAX_DASHES_PER_STROKE: f64 = 250_000.0; + +/// Tiling cells one pattern fill expands into; past this the fill is read as an image. +pub const MAX_TILING_CELLS: usize = 10_000; + +/// Items one pattern fill expands into, counting those inside groups and nested pattern fills; +/// past this the fill is read as an image. A hundred per cell at [`MAX_TILING_CELLS`]. +pub const MAX_TILING_ITEMS: usize = 1_000_000; + +/// Rectangles a stencil mask may trace into; past this it is read as an image. +const MAX_STENCIL_RECTS: usize = 4_096; + +/// Pixels one image read from a shading or tiling pattern may hold; a larger area is sampled +/// at a lower resolution. +const MAX_RASTER_PIXELS: f64 = 16_777_216.0; + +/// How much finer than the image a tiling cell is drawn, so that small cells keep their shapes. +const CELL_OVERSAMPLE: f64 = 4.0; + +/// Pixels the drawing of one tiling cell may hold. +const MAX_CELL_PIXELS: f64 = 4_194_304.0; + +/// Cell samples one image read from a tiling pattern may take; a pattern whose cells overlap is +/// sampled at a lower resolution. Four per pixel at [`MAX_RASTER_PIXELS`], because a cell no +/// larger than its step meets three others at its corners. +const MAX_TILING_SAMPLES: f64 = 4.0 * MAX_RASTER_PIXELS; + +/// Inks a tiling pattern's canvases hold at their full pixel limits. Each pixel holds a value +/// per ink of the page, so a page with more inks is sampled at a lower resolution, keeping the +/// canvases (and their sampling) at the memory and work of CMYK ones. +const CANVAS_INKS: f64 = 4.0; + +/// How [`read_page`] reads. +#[derive(Clone, Debug)] +pub struct ReadOptions { + /// The printing condition RGB, Lab and ICC colors convert into when the page has no output + /// intent with a usable CMYK profile. + pub fallback: Fallback, + /// Read the page's annotation appearances too. When `false`, the print-flagged ones with an + /// appearance are left out and counted ([`ReadReport::dropped_annotations`]). + pub annotations: bool, + /// The resolution, in pixels per inch, of images read from function-based and mesh + /// shadings and from tiling patterns past [`MAX_TILING_CELLS`] or [`MAX_TILING_ITEMS`]. + pub raster_dpi: f32, +} + +impl Default for ReadOptions { + fn default() -> Self { + Self { fallback: Fallback::default(), annotations: false, raster_dpi: 300.0 } + } +} + +/// What [`read_page`] read, converted or left out. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct ReadReport { + /// How process color that isn't CMYK became CMYK. + pub conversion: Conversion, + /// Raster images read. + pub images: usize, + /// Of [`Self::images`], those whose samples were converted into CMYK (RGB, Lab, ICC and + /// CIE-based spaces). + pub converted_images: usize, + /// Images whose `/SMask` or `/Mask` had another size than the image, resampled to it. + pub resampled_masks: usize, + /// Images and stencil masks that didn't decode, left out. + pub unreadable_images: usize, + /// Stencil masks (`/ImageMask`) read. + pub stencils: usize, + /// Axial and radial shadings read as gradients. + pub gradients: usize, + /// Function-based and mesh shadings read as images. + pub rasterized_shadings: usize, + /// Tiling pattern cells expanded. + pub tiling_cells: usize, + /// Tiling pattern fills past [`MAX_TILING_CELLS`] cells or [`MAX_TILING_ITEMS`] items, read + /// as images. + pub rasterized_tilings: usize, + /// Tiling pattern fills left out: cells stacked too deep to sample, or canvases too large. + pub dropped_tilings: usize, + /// Glyphs in text rendering mode 3, dropped. + pub invisible_glyphs: usize, + /// Spot inks past the ink set's capacity, whose tints were dropped. + pub dropped_inks: usize, + /// Annotations that would print (Print flag set, not Hidden, with a normal appearance), left + /// out because [`ReadOptions::annotations`] is `false`. + pub dropped_annotations: usize, +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ReadError { + #[error(transparent)] + Open(#[from] OpenError), + #[error("page {0} does not exist")] + NoPage(usize), + #[error("the fallback profile can't be a printing condition: {0}")] + Profile(#[from] ProfileError), + #[error("the PDF interpreter failed on the page")] + Interpret, +} + +/// Reads page `page` (from 0) of `pdf` into a display list. Never panics. +pub fn read_page(pdf: &[u8], page: usize, opts: &ReadOptions) -> Result<(DisplayList, ReadReport), ReadError> { + let doc = open_slice(pdf, None)?; + let color = read_color(&doc, page, &opts.fallback)?; + read_page_in(&doc, page, opts, color) +} + +/// How a page's process color converts: its output intent's simulation, else `fallback`'s. +pub(crate) type ReadColor = (Option, Conversion); + +/// The [`ReadColor`] of page `page`; parsing the profile is the slow part, so a document +/// reads it once per output intent. +pub(crate) fn read_color(doc: &Pdf, page: usize, fallback: &Fallback) -> Result { + match catch_unwind(AssertUnwindSafe(|| plates::output_intent(doc, page))) { + Ok(Some(found)) => Ok((Some(found.0), found.1)), + Ok(None) | Err(_) => plates::fallback(fallback).map_err(|e| match e { + plates::Error::Profile(p) => ReadError::Profile(p), + _ => ReadError::Interpret, + }), + } +} + +/// [`read_page`] of an open document, with the page's color already read. +pub(crate) fn read_page_in(doc: &Pdf, page: usize, opts: &ReadOptions, color: ReadColor) -> Result<(DisplayList, ReadReport), ReadError> { + let (simulation, conversion) = color; + let pages = doc.pages(); + let p = pages.get(page).ok_or(ReadError::NoPage(page))?; + let (w, h) = p.render_dimensions(); + let bbox = Rect::new(0.0, 0.0, f64::from(w), f64::from(h)); + let report = ReadReport { + conversion, + images: 0, + converted_images: 0, + resampled_masks: 0, + unreadable_images: 0, + stencils: 0, + gradients: 0, + rasterized_shadings: 0, + tiling_cells: 0, + rasterized_tilings: 0, + dropped_tilings: 0, + invisible_glyphs: 0, + dropped_inks: 0, + dropped_annotations: if opts.annotations { 0 } else { catch_unwind(AssertUnwindSafe(|| printing_annotations(p))).unwrap_or(0) }, + }; + let dpi = if opts.raster_dpi.is_finite() && opts.raster_dpi > 0.0 { opts.raster_dpi } else { 300.0 }; + let mut shared = Shared { + simulation, + inks: InkSet::new(), + spots: HashMap::new(), + report, + masks: HashMap::new(), + fonts: FontCache::new(), + scale: f64::from(dpi) / 72.0, + }; + + let items = catch_unwind(AssertUnwindSafe(|| { + let cache = InterpreterCache::new(); + let settings = InterpreterSettings { render_annotations: opts.annotations, ..Default::default() }; + let mut context = Context::new(p.initial_transform(true).to_kurbo(), bbox, &cache, p.xref(), settings); + let mut reader = Reader::new(&mut shared, false, bbox); + interpret_page(p, &mut context, &mut reader); + reader.finish() + })) + .map_err(|_| ReadError::Interpret)?; + + let Shared { inks, report, fonts, .. } = shared; + let fonts = fonts.finish(); + let mut items = items; + set_fonts(&mut items, &fonts); + Ok((DisplayList { page: bbox, inks, items }, report)) +} + +/// The annotations of `page` a printer would paint: Print flag (bit 3) set, Hidden (bit 2) +/// clear, with a normal appearance (`/AP /N`). +fn printing_annotations(page: &Page<'_>) -> usize { + let Some(annots) = page.raw().get::>(ANNOTS) else { return 0 }; + annots + .iter::>() + .filter(|a| a.get::(F).unwrap_or(0) & (4 | 2) == 4 && a.get::>(AP).is_some_and(|ap| ap.contains_key(N))) + .count() +} + +/// State shared by the page's reader and the readers of its soft masks and Type 3 glyphs. +struct Shared { + simulation: Option, + inks: InkSet, + spots: HashMap>, + report: ReadReport, + /// Soft masks read so far, by hayro's cache key. + masks: HashMap>, + fonts: FontCache, + /// Raster pixels per point. + scale: f64, +} + +/// How a paint composites: what wraps it in a group of its own. +#[derive(Clone)] +struct Look<'a> { + opacity: f32, + blend: BlendMode, + mask: Option>, +} + +impl Look<'_> { + fn same(&self, other: &Self) -> bool { + self.opacity == other.opacity + && self.blend == other.blend + && self.mask.as_ref().map(CacheKey::cache_key) == other.mask.as_ref().map(CacheKey::cache_key) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FrameKind { + Root, + Clip, + Group, +} + +struct Frame { + kind: FrameKind, + group: GroupItem, +} + +/// A text run being collected. +struct Run<'a> { + /// The source font's key; `None` for Type 3 glyphs. + font: Option, + /// The linear part of glyph space (one unit per em) to page space. + linear: Affine, + look: Look<'a>, + item: TextItem, +} + +/// What a paint paints with. +enum Painted<'a> { + /// A paint an item carries. + Flat(Paint, Look<'a>, Overprint), + /// A pattern read over the painted area ([`Reader::area_fill`]). + Area, +} + +/// The hayro-interpret device. `glyph` readers read a Type 3 glyph procedure: they collect +/// paints without their own transparency, which the glyph's run carries. +struct Reader<'s, 'a> { + shared: &'s mut Shared, + glyph: bool, + /// Where the content can show, before clips: the page, or unbounded for a tiling cell, + /// whose copies land elsewhere. + bounds: Rect, + frames: Vec, + soft_mask: Option>, + blend: BlendMode, + /// `op`, `OP` and `OPM`. + overprint: (bool, bool, u8), + run: Option>, +} + +fn plain_group(clip: Option) -> GroupItem { + GroupItem { opacity: 1.0, blend: BlendMode::Normal, isolated: false, knockout: false, clip, mask: None, actual_text: None, children: Vec::new() } +} + +impl<'s, 'a> Reader<'s, 'a> { + fn new(shared: &'s mut Shared, glyph: bool, bounds: Rect) -> Self { + Self { + shared, + glyph, + bounds, + frames: vec![Frame { kind: FrameKind::Root, group: plain_group(None) }], + soft_mask: None, + blend: BlendMode::Normal, + overprint: (false, false, 0), + run: None, + } + } + + /// The items read, with every open clip and group closed. + fn finish(mut self) -> Vec { + self.flush(); + while self.frames.len() > 1 { + self.close_top(); + } + self.frames.pop().map(|f| f.group.children).unwrap_or_default() + } + + fn add(&mut self, item: Item) { + if let Some(frame) = self.frames.last_mut() { + frame.group.children.push(item); + } + } + + fn close_top(&mut self) { + let Some(frame) = self.frames.pop_if(|f| f.kind != FrameKind::Root) else { return }; + if frame.group.children.is_empty() { + return; + } + // A group that changes nothing (hayro wraps every image in one) adds its children. + let g = &frame.group; + let plain = g.opacity == 1.0 + && g.blend == BlendMode::Normal + && !g.isolated + && !g.knockout + && g.clip.is_none() + && g.mask.is_none() + && g.actual_text.is_none(); + if plain { + for item in frame.group.children { + self.add(item); + } + } else { + self.add(Item::Group(frame.group)); + } + } + + /// The page area content can show in, through every open clip. + fn clip_bounds(&self) -> Rect { + let mut r = self.bounds; + for frame in &self.frames { + if let Some(clip) = &frame.group.clip { + r = r.intersect(clip.bounding_box()); + } + } + r + } + + /// Raster pixels per point for an image over `area`, within [`MAX_RASTER_PIXELS`]. + fn raster_scale(&self, area: Rect) -> f64 { + limit_scale(self.shared.scale, area, MAX_RASTER_PIXELS) + } + + /// Closes the innermost frame of `kind`, and the frames opened inside it. + fn close(&mut self, kind: FrameKind) { + self.flush(); + let Some(at) = self.frames.iter().rposition(|f| f.kind == kind) else { return }; + while self.frames.len() > at { + self.close_top(); + } + } + + fn look(&self, opacity: f32) -> Look<'a> { + if self.glyph { + return Look { opacity: 1.0, blend: BlendMode::Normal, mask: None }; + } + Look { opacity, blend: self.blend, mask: self.soft_mask.clone() } + } + + /// Adds `item`, in a group of its own when `look` is transparent. + fn push(&mut self, item: Item, look: &Look<'a>) { + let mask = look.mask.as_ref().and_then(|m| self.mask(m)); + let item = if look.opacity < 1.0 || look.blend != BlendMode::Normal || mask.is_some() { + Item::Group(GroupItem { opacity: look.opacity, blend: look.blend, mask: mask.map(Box::new), children: vec![item], ..plain_group(None) }) + } else { + item + }; + self.add(item); + } + + fn flush(&mut self) { + let Some(mut run) = self.run.take() else { return }; + if run.item.text.as_deref() == Some("") { + run.item.text = None; + } + self.push(Item::Text(run.item), &run.look); + } + + fn mask(&mut self, mask: &SoftMask<'a>) -> Option { + let key = mask.cache_key(); + if let Some(read) = self.shared.masks.get(&key) { + return read.clone(); + } + // A mask that refers to itself reads as no mask. + self.shared.masks.insert(key, None); + let mut reader = Reader::new(&mut *self.shared, false, self.bounds); + mask.interpret(&mut reader); + let children = reader.finish(); + let kind = match mask.mask_type() { + MaskType::Luminosity => MaskKind::Luminosity, + MaskType::Alpha => MaskKind::Alpha, + }; + let backdrop = match kind { + MaskKind::Luminosity => self.ink_color(&mask.background_color()), + MaskKind::Alpha => None, + }; + let transfer = mask.transfer_function().map(|tf| (0..=255_u8).map(|v| (tf.apply(f32::from(v) / 255.0) * 255.0).round() as u8).collect()); + let group = GroupItem { isolated: true, children, ..plain_group(None) }; + let read = Mask { kind, group, backdrop, transfer }; + self.shared.masks.insert(key, Some(read.clone())); + Some(read) + } + + fn ink(&mut self, ink: &PdfInk, alternate: impl FnOnce() -> Option) -> Option { + let process = match ink { + PdfInk::Cyan => Process::Cyan, + PdfInk::Magenta => Process::Magenta, + PdfInk::Yellow => Process::Yellow, + PdfInk::Black => Process::Black, + PdfInk::Spot(name) => { + if let Some(id) = self.shared.spots.get(ink) { + return *id; + } + let alternate = match alternate() { + Some(SpotAlternate::Cmyk(cmyk)) => Alternate::Cmyk(cmyk), + Some(SpotAlternate::Srgb(rgb)) => Alternate::Lab(srgb8_to_lab(rgb).unwrap_or([0.0; 3])), + None => Alternate::Lab([0.0; 3]), + }; + let id = self.shared.inks.add(Ink::Spot(Spot { name: name.to_string(), alternate, solidity: None })); + self.shared.spots.insert(ink.clone(), id); + return id; + } + }; + Some(InkId::process(process)) + } + + /// The color as inks; `None` when it paints nothing (the `None` colorant). + fn ink_color(&mut self, color: &craft_pdf::interpret::color::Color) -> Option { + match color.ink_paint(self.shared.simulation.as_ref())? { + InkPaint::Tints(tints) => { + let mut out = Vec::with_capacity(tints.len()); + for (ink, tint) in tints { + match self.ink(&ink, || color.spot_alternate(&ink)) { + Some(id) => out.push((id, tint)), + None => self.shared.report.dropped_inks += 1, + } + } + Some(InkColor::Tints(out)) + } + InkPaint::All(tint) => Some(InkColor::All(tint)), + InkPaint::None => None, + } + } + + /// How values of `space` (colors of `cs`) become samples or stops: the inks, and which + /// values to keep. `None` when the space paints nothing. + fn space_inks(&mut self, space: &InkSpace, cs: &ColorSpace) -> Option<(Inks, Channels)> { + let process = || Process::ALL.iter().map(|p| InkId::process(*p)).collect(); + Some(match space { + InkSpace::None => return None, + InkSpace::Gray => (Inks::Gray, Channels::All), + InkSpace::Cmyk { .. } => (Inks::Cmyk(process()), Channels::All), + InkSpace::All => (Inks::List(self.shared.inks.iter().map(|(id, _)| id).collect()), Channels::Spread(self.shared.inks.len())), + InkSpace::Inks(list) => { + let mut ids = Vec::with_capacity(list.len()); + let mut keep = Vec::with_capacity(list.len()); + for ink in list { + let id = self.ink(ink, || cs.spot_alternate(ink)); + if id.is_none() { + self.shared.report.dropped_inks += 1; + } + keep.push(id.is_some()); + ids.extend(id); + } + if ids.is_empty() { + return None; + } + let channels = if keep.iter().all(|k| *k) { Channels::All } else { Channels::Keep(keep) }; + (Inks::List(ids), channels) + } + }) + } + + /// The paint and how it paints; `None` when it paints nothing. + fn paint(&mut self, paint: &PdfPaint<'a>, stroke: bool) -> Option> { + let (op, big_op, mode) = self.overprint; + let flag = if stroke { big_op } else { op }; + let mut overprint = Overprint { fill: flag, stroke: flag, nonzero: false }; + match paint { + PdfPaint::Color(color) => { + overprint.nonzero = mode == 1 && color.is_device_cmyk(); + let ink = self.ink_color(color)?; + Some(Painted::Flat(Paint::Solid(ink), self.look(color.opacity()), overprint)) + } + PdfPaint::Pattern(pattern) => match pattern.as_ref() { + // A gradient that needs a clip (a bounding box, or an end that doesn't extend) or a + // background is read over its area. + Pattern::Shading(s) + if s.shading.clip_path.is_none() + && s.shading.background.is_none() + && matches!(s.shading.shading_type.as_ref(), ShadingType::RadialAxial { extend: [true, true], .. }) => + { + overprint.nonzero = mode == 1 && s.shading.color_space.is_device_cmyk(); + let (gradient, _) = self.gradient(s)?; + Some(Painted::Flat(gradient, self.look(s.opacity), overprint)) + } + Pattern::Shading(_) | Pattern::Tiling(_) => Some(Painted::Area), + }, + } + } + + /// An axial or radial shading as a gradient extended at both ends, with the clip to its own + /// extent when the shading doesn't extend (see [`shade::gradient_extent`]); `None` for other + /// types, or when it paints nothing. + fn gradient(&mut self, sp: &ShadingPattern) -> Option<(Paint, Option)> { + let ShadingType::RadialAxial { coords, domain, function, extend, axial } = sp.shading.shading_type.as_ref() else { return None }; + let cs = &sp.shading.color_space; + let simulation = self.shared.simulation.clone(); + let space = cs.ink_space(simulation.as_ref()); + let (inks, channels) = self.space_inks(&space, cs)?; + let stops = shade::sample_stops(space.len(), |s| { + let t = domain[0] + s * (domain[1] - domain[0]); + let components = shade::eval(function, &[t])?; + let mut values = Vec::new(); + cs.ink_values(&components, simulation.as_ref(), &mut values); + Some(values) + }); + let kind = inks.stop_inks(); + let stops = stops.into_iter().map(|(offset, values)| (offset, shade::stop_color(&kind, &channels.apply_f32(&values)))).collect(); + let c = coords.map(f64::from); + let geometry = if *axial { + GradientGeometry::Axial { start: Point::new(c[0], c[1]), end: Point::new(c[2], c[3]), extend: [true, true], transform: sp.matrix } + } else { + GradientGeometry::Radial { + start: Point::new(c[0], c[1]), + start_radius: c[2], + end: Point::new(c[3], c[4]), + end_radius: c[5], + extend: [true, true], + transform: sp.matrix, + } + }; + let extent = shade::gradient_extent(&geometry, *extend, self.clip_bounds()); + self.shared.report.gradients += 1; + Some((Paint::Gradient { stops, geometry }, extent)) + } + + fn fill(&mut self, path: BezPath, rule: FillRule, paint: &PdfPaint<'a>, stroke: bool) { + match self.paint(paint, stroke) { + Some(Painted::Flat(paint, look, overprint)) => { + self.flush(); + self.push(Item::Fill(FillItem { path, rule, paint, overprint }), &look); + } + Some(Painted::Area) => self.area_fill(path, rule, paint, stroke), + None => {} + } + } + + /// A shading or tiling pattern read over `path`: its items or an image, clipped to the + /// path. + fn area_fill(&mut self, path: BezPath, rule: FillRule, paint: &PdfPaint<'a>, stroke: bool) { + let PdfPaint::Pattern(pattern) = paint else { return }; + self.flush(); + let clip = nonzero(&path, rule); + let (op, big_op, _) = self.overprint; + let flag = if stroke { big_op } else { op }; + match pattern.as_ref() { + Pattern::Shading(sp) => self.shading_area(clip, sp, flag), + Pattern::Tiling(t) => self.tiling_area(clip, t, flag, stroke), + } + } + + fn shading_area(&mut self, clip: BezPath, sp: &ShadingPattern, flag: bool) { + let look = self.look(sp.opacity); + let cs = &sp.shading.color_space; + // Nonzero overprint mode is for colors set in DeviceCMYK, not shadings or images + // (ISO 32000-2 §8.6.7). + let overprint = Overprint { fill: flag, stroke: flag, nonzero: false }; + let simulation = self.shared.simulation.clone(); + let mut children = Vec::new(); + if let Some(bg) = &sp.shading.background { + let mut values = Vec::new(); + cs.ink_values(bg, simulation.as_ref(), &mut values); + let space = cs.ink_space(simulation.as_ref()); + if let Some((inks, channels)) = self.space_inks(&space, cs) { + let kind = inks.stop_inks(); + let paint = Paint::Solid(shade::stop_color(&kind, &channels.apply_f32(&values))); + children.push(Item::Fill(FillItem { path: clip.clone(), rule: FillRule::NonZero, paint, overprint })); + } + } + let shaded = if matches!(sp.shading.shading_type.as_ref(), ShadingType::RadialAxial { .. }) { + self.gradient(sp).map(|(paint, extent)| { + let fill = Item::Fill(FillItem { path: clip.clone(), rule: FillRule::NonZero, paint, overprint }); + match extent { + Some(extent) => Item::Group(GroupItem { children: vec![fill], ..plain_group(Some(extent)) }), + None => fill, + } + }) + } else { + self.shading_image(&clip, sp, overprint).map(Item::Image) + }; + if let Some(item) = shaded { + match &sp.shading.clip_path { + Some(bbox) => children.push(Item::Group(GroupItem { children: vec![item], ..plain_group(Some(bbox.clone())) })), + None => children.push(item), + } + } + if !children.is_empty() { + self.push(Item::Group(GroupItem { children, ..plain_group(Some(clip)) }), &look); + } + } + + /// A function-based or mesh shading sampled over the visible part of `clip`. + fn shading_image(&mut self, clip: &BezPath, sp: &ShadingPattern, overprint: Overprint) -> Option { + let mut area = clip.bounding_box().intersect(self.clip_bounds()); + if let Some(bbox) = &sp.shading.clip_path { + area = area.intersect(bbox.bounding_box()); + } + if !(area.width() > 0.0 && area.height() > 0.0) { + return None; + } + let scale = self.raster_scale(area); + let simulation = self.shared.simulation.clone(); + // The background is its own fill, under the image. + let raster = shade::rasterize_shading(sp, area, scale, false, simulation.as_ref())?; + let cs = &sp.shading.color_space; + let space = cs.ink_space(simulation.as_ref()); + let (inks, channels) = self.space_inks(&space, cs)?; + let samples: Vec = raster.values.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); + let samples = channels.apply(samples, space.len()); + let (w, h) = (raster.width as usize, raster.height as usize); + let smask = (!raster.alpha.iter().all(|a| *a == 255)).then(|| raster.alpha.into()); + self.shared.report.rasterized_shadings += 1; + Some(ImageItem { + space: inks.image_space(), + size: (raster.width, raster.height), + samples: samples.into(), + transform: shade::area_transform(area, scale, (w, h)), + smask, + overprint, + jpeg: None, + }) + } + + /// A tiling pattern over `clip`: its cells, each read like page content, or an image past + /// [`MAX_TILING_CELLS`] or [`MAX_TILING_ITEMS`]. + fn tiling_area(&mut self, clip: BezPath, t: &TilingPattern<'a>, flag: bool, stroke: bool) { + let area = clip.bounding_box().intersect(self.clip_bounds()); + if !(area.width() > 0.0 && area.height() > 0.0) { + return; + } + let m = t.matrix; + let inv = m.inverse(); + let (xs, ys) = (f64::from(t.x_step), f64::from(t.y_step)); + let b = t.bbox; + let pa = inv.transform_rect_bbox(area); + let (Some(is), Some(js)) = (cells(pa.x0, pa.x1, b.x0, b.x1, xs), cells(pa.y0, pa.y1, b.y0, b.y1, ys)) else { return }; + let count = (is.1 - is.0 + 1) as u128 * (js.1 - js.0 + 1) as u128; + + let mut reader = Reader::new(&mut *self.shared, false, Rect::new(-1e9, -1e9, 1e9, 1e9)); + reader.overprint = self.overprint; + t.interpret(&mut reader, m, stroke); + let cell = reader.finish(); + if cell.is_empty() { + return; + } + let look = self.look(1.0); + let offset = |i: i64, j: i64| m * Point::new(i as f64 * xs, j as f64 * ys) - m * Point::ORIGIN; + let items = count.saturating_mul(item_count(&cell) as u128); + let child = if count <= MAX_TILING_CELLS as u128 && items <= MAX_TILING_ITEMS as u128 { + let mut children = Vec::with_capacity(count as usize * cell.len()); + for j in js.0..=js.1 { + for i in is.0..=is.1 { + let mut copy = cell.clone(); + shade::translate(&mut copy, offset(i, j)); + children.extend(copy); + } + } + self.shared.report.tiling_cells += count as usize; + children + } else { + // Every pixel samples each cell over it: one, or more where cells are larger than + // their step and overlap. Cells stacked deeper than MAX_TILING_CELLS paint nothing, + // like fills past the range of `cells`. + let per_pixel = ((b.width().abs() / xs.abs()).floor() + 1.0) * ((b.height().abs() / ys.abs()).floor() + 1.0); + if !per_pixel.is_finite() || per_pixel > MAX_TILING_CELLS as f64 { + self.shared.report.dropped_tilings += 1; + return; + } + let inks = &self.shared.inks; + let cell_box = m.transform_rect_bbox(b); + let (scale, cell_scale) = tiling_scales(self.shared.scale, area, cell_box, per_pixel, inks.len()); + let (Some(mut drawn), Some(mut canvas)) = (Canvas::new(cell_box, cell_scale, inks), Canvas::new(area, scale, inks)) else { + self.shared.report.dropped_tilings += 1; + return; + }; + drawn.draw(&cell); + let (w, h) = canvas.size(); + for y in 0..h { + for x in 0..w { + let p = canvas.center(x, y); + let q = inv * p; + let (Some(ci), Some(cj)) = (cells(q.x, q.x, b.x0, b.x1, xs), cells(q.y, q.y, b.y0, b.y1, ys)) else { continue }; + for j in cj.0..=cj.1 { + for i in ci.0..=ci.1 { + if let Some((value, a)) = drawn.sample(p - offset(i, j)) + && a > 0.0 + { + let value = value.to_vec(); + canvas.over(x, y, &value, a); + } + } + } + } + } + let image = canvas.into_image(Overprint { fill: flag, stroke: flag, nonzero: false }); + self.shared.report.rasterized_tilings += 1; + vec![Item::Image(image)] + }; + self.push(Item::Group(GroupItem { children: child, ..plain_group(Some(clip)) }), &look); + } + + fn raster_image(&mut self, image: &RasterImage<'_>, transform: Affine) { + let mut decoded = None; + image.with_inks(self.shared.simulation.as_ref(), |im| decoded = Some(im)); + let Some(im) = decoded else { + self.shared.report.unreadable_images += 1; + return; + }; + let Some((inks, channels)) = self.space_inks(&im.space, &im.color_space) else { return }; + self.shared.report.images += 1; + if matches!(im.space, InkSpace::Cmyk { converted: true }) { + self.shared.report.converted_images += 1; + } + let (w, h) = (im.width as usize, im.height as usize); + let samples = channels.apply(im.samples, im.space.len()); + let smask = im.alpha.map(|a| { + if (a.width as usize, a.height as usize) == (w, h) { + a.data + } else { + self.shared.report.resampled_masks += 1; + shade::resample(&a.data, (a.width as usize, a.height as usize), (w, h)) + } + }); + let smask = smask.filter(|a| !a.iter().all(|v| *v == 255)).map(Arc::from); + let (sx, sy) = im.scale_factors; + let transform = transform * Affine::scale_non_uniform(w as f64 * f64::from(sx), h as f64 * f64::from(sy)); + let (op, _, _) = self.overprint; + let overprint = Overprint { fill: op, stroke: op, nonzero: false }; + self.flush(); + let device = matches!(im.space, InkSpace::Gray | InkSpace::Cmyk { converted: false }); + let jpeg = if device && smask.is_none() { dct_passthrough(image.stream(), im.width, im.height) } else { None }; + self.add(Item::Image(ImageItem { + space: inks.image_space(), + size: (im.width, im.height), + samples: samples.into(), + transform, + smask, + overprint, + jpeg, + })); + } + + /// A stencil mask: a fill traced from the mask, or past [`MAX_STENCIL_RECTS`] an image of + /// the paint with the mask as its alpha. + fn stencil(&mut self, image: &StencilImage<'a, '_>, transform: Affine) { + let mut read = None; + image.with_stencil(|luma, paint| read = Some((luma, paint.clone())), None); + let Some((luma, paint)) = read else { + self.shared.report.unreadable_images += 1; + return; + }; + self.shared.report.stencils += 1; + let (w, h) = (luma.width as usize, luma.height as usize); + let (sx, sy) = luma.scale_factors; + let to_page = transform * Affine::scale_non_uniform(f64::from(sx), f64::from(sy)); + if let Some(rects) = shade::trace_stencil(&luma.data, w, h, MAX_STENCIL_RECTS) { + let mut path = BezPath::new(); + for r in rects { + path.extend(shade::rect_path(r, to_page).iter()); + } + if !path.elements().is_empty() { + self.fill(path, FillRule::NonZero, &paint, false); + } + return; + } + let unit = to_page * Affine::scale_non_uniform(w as f64, h as f64); + let size = (luma.width, luma.height); + match self.paint(&paint, false) { + None => {} + Some(Painted::Flat(Paint::Solid(color), look, overprint)) => { + let (space, value): (ImageSpace, Vec) = match &color { + InkColor::Tints(tints) => (ImageSpace::Inks(tints.iter().map(|t| t.0).collect()), tints.iter().map(|t| to_byte(t.1)).collect()), + InkColor::All(t) => { + (ImageSpace::Inks(self.shared.inks.iter().map(|(id, _)| id).collect()), vec![to_byte(*t); self.shared.inks.len()]) + } + InkColor::None => return, + }; + let samples: Vec = value.iter().copied().cycle().take(value.len() * w * h).collect(); + self.flush(); + self.push( + Item::Image(ImageItem { + space, + size, + samples: samples.into(), + transform: unit, + smask: Some(luma.data.into()), + overprint, + jpeg: None, + }), + &look, + ); + } + Some(_) => { + // A pattern paint: the paint over the image's square, masked by the stencil. + let shape = ImageItem { + space: ImageSpace::Gray, + size, + samples: vec![0; w * h].into(), + transform: unit, + smask: Some(luma.data.into()), + overprint: Overprint::default(), + jpeg: None, + }; + let group = GroupItem { isolated: true, children: vec![Item::Image(shape)], ..plain_group(None) }; + let mask = Mask { kind: MaskKind::Alpha, group, backdrop: None, transfer: None }; + self.flush(); + self.frames.push(Frame { kind: FrameKind::Group, group: GroupItem { mask: Some(Box::new(mask)), ..plain_group(None) } }); + self.fill(shade::rect_path(Rect::new(0.0, 0.0, 1.0, 1.0), unit), FillRule::NonZero, &paint, false); + self.close(FrameKind::Group); + } + } + } + + fn outline_glyph(&mut self, glyph: &OutlineGlyph, matrix: Affine, pdf_paint: &PdfPaint<'a>) { + let (paint, look, overprint) = match self.paint(pdf_paint, false) { + Some(Painted::Flat(paint, look, overprint)) => (paint, look, overprint), + Some(Painted::Area) => return self.area_fill(matrix * glyph.outline(), FillRule::NonZero, pdf_paint, false), + None => return, + }; + let key = self.shared.fonts.note(glyph); + let unicode = glyph.as_unicode().map(unicode); + self.add_glyph(Some(key), glyph.glyph_id().to_u32(), matrix, matrix * glyph.outline(), unicode, paint, look, overprint); + } + + #[allow(clippy::too_many_arguments)] + fn add_glyph( + &mut self, + font: Option, + id: u32, + matrix: Affine, + outline: BezPath, + unicode: Option, + paint: Paint, + look: Look<'a>, + overprint: Overprint, + ) { + let linear = matrix * Affine::scale(1000.0); + let continues = self.run.as_ref().is_some_and(|run| { + run.font == font && same_linear(run.linear, linear) && run.item.paint == paint && run.item.overprint == overprint && run.look.same(&look) + }); + if !continues { + self.flush(); + let size = linear.determinant().abs().sqrt(); + if !size.is_finite() || size <= f64::EPSILON { + return; + } + let [a, b, c, d, ..] = linear.as_coeffs(); + let origin = matrix.translation(); + let transform = Affine::new([a / size, b / size, c / size, d / size, origin.x, origin.y]); + // A placeholder font carrying the key; `set_fonts` replaces it. + let placeholder = font.map(|key| Arc::new(TextFont { data: Arc::new(Vec::::new()), index: 0, key })); + let item = TextItem { + font: placeholder, + size: size as f32, + transform, + glyphs: Vec::new(), + text: Some(String::new()), + outlines: BezPath::new(), + paint, + overprint, + }; + self.run = Some(Run { font, linear, look, item }); + } + let Some(run) = self.run.as_mut() else { return }; + let item = &mut run.item; + let text = item.text.get_or_insert_with(String::new); + let start = text.len(); + text.push_str(unicode.as_deref().unwrap_or("")); + let origin = item.transform.inverse() * matrix.translation().to_point(); + item.glyphs.push(PlacedGlyph { id, origin, text: start..text.len() }); + item.outlines.extend(outline.iter()); + } + + fn type3_glyph(&mut self, glyph: &craft_pdf::interpret::font::Type3Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &PdfPaint<'a>) { + let (run_paint, look, overprint) = match self.paint(paint, false) { + Some(Painted::Flat(paint, look, overprint)) => (Some(paint), look, overprint), + Some(Painted::Area) => (None, self.look(1.0), Overprint::default()), + None => return, + }; + let mut reader = Reader::new(&mut *self.shared, true, self.bounds); + reader.overprint = self.overprint; + glyph.interpret(&mut reader, transform, glyph_transform, paint); + let items = reader.finish(); + let unicode = glyph.as_unicode().map(unicode); + // A glyph that only fills in the text's paint (`d1`, or `d0` painting that color) is a + // shape: it joins the run. + let shape = items.iter().all(|item| matches!(item, Item::Fill(f) if Some(&f.paint) == run_paint.as_ref())); + if let (true, Some(run_paint)) = (shape, run_paint) { + let mut outline = BezPath::new(); + for item in &items { + if let Item::Fill(f) = item { + outline.extend(nonzero(&f.path, f.rule).iter()); + } + } + self.add_glyph(None, 0, transform * glyph_transform, outline, unicode, run_paint, look, overprint); + } else if !items.is_empty() { + self.flush(); + let group = GroupItem { actual_text: unicode, children: items, ..plain_group(None) }; + self.push(Item::Group(group), &look); + } + } +} + +impl<'a> Device<'a> for Reader<'_, 'a> { + fn set_soft_mask(&mut self, mask: Option>) { + self.soft_mask = mask; + } + + fn set_blend_mode(&mut self, blend_mode: PdfBlend) { + self.blend = blend(blend_mode); + } + + fn set_overprint(&mut self, fill: bool, stroke: bool, mode: u8) { + self.overprint = (fill, stroke, mode); + } + + fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &PdfPaint<'a>, draw_mode: &PathDrawMode) { + match draw_mode { + PathDrawMode::Fill(rule) => self.fill(transform * path.clone(), fill_rule(*rule), paint, false), + PathDrawMode::Stroke(props) => self.fill(outline_stroke(path, transform, props), FillRule::NonZero, paint, true), + } + } + + fn push_clip_path(&mut self, clip_path: &ClipPath) { + self.flush(); + let clip = nonzero(&clip_path.path, fill_rule(clip_path.fill)); + self.frames.push(Frame { kind: FrameKind::Clip, group: plain_group(Some(clip)) }); + } + + fn push_transparency_group(&mut self, opacity: f32, mask: Option>, blend_mode: PdfBlend) { + self.flush(); + let mask = if self.glyph { None } else { mask.and_then(|m| self.mask(&m)) }; + let (opacity, blend_mode) = if self.glyph { (1.0, BlendMode::Normal) } else { (opacity, blend(blend_mode)) }; + let group = GroupItem { opacity, blend: blend_mode, mask: mask.map(Box::new), ..plain_group(None) }; + self.frames.push(Frame { kind: FrameKind::Group, group }); + } + + fn set_knockout(&mut self) { + if let Some(frame) = self.frames.last_mut().filter(|f| f.kind == FrameKind::Group) { + frame.group.knockout = true; + } + } + + fn set_isolated(&mut self) { + if let Some(frame) = self.frames.last_mut().filter(|f| f.kind == FrameKind::Group) { + frame.group.isolated = true; + } + } + + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &PdfPaint<'a>, draw_mode: &GlyphDrawMode) { + match (glyph, draw_mode) { + (_, GlyphDrawMode::Invisible) => self.shared.report.invisible_glyphs += 1, + // Clip-only text: the interpreter installs its clip, and the glyph paints nothing. + (_, GlyphDrawMode::Clip) => {} + (Glyph::Outline(o), GlyphDrawMode::Fill) => self.outline_glyph(o, transform * glyph_transform, paint), + (Glyph::Outline(o), GlyphDrawMode::Stroke(props)) => { + self.fill(outline_stroke(&(glyph_transform * o.outline()), transform, props), FillRule::NonZero, paint, true); + } + (Glyph::Type3(t), _) => self.type3_glyph(t, transform, glyph_transform, paint), + } + } + + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + match image { + Image::Raster(r) => self.raster_image(&r, transform), + Image::Stencil(s) => self.stencil(&s, transform), + } + } + + fn pop_clip_path(&mut self) { + self.close(FrameKind::Clip); + } + + fn pop_transparency_group(&mut self) { + self.close(FrameKind::Group); + } +} + +/// The inks of samples or stops. +enum Inks { + /// A gray level. + Gray, + /// The four process inks. + Cmyk(Vec), + List(Vec), +} + +impl Inks { + fn stop_inks(self) -> StopInks { + match self { + Self::Gray => StopInks::Gray, + Self::Cmyk(ids) | Self::List(ids) => StopInks::Inks(ids), + } + } + + fn image_space(self) -> ImageSpace { + match self { + Self::Gray => ImageSpace::Gray, + Self::Cmyk(_) => ImageSpace::Cmyk, + Self::List(ids) => ImageSpace::Inks(ids), + } + } +} + +/// Which of a space's values become samples. +enum Channels { + All, + /// Values of inks the set has room for. + Keep(Vec), + /// One value (the `All` colorant) for each of this many inks. + Spread(usize), +} + +impl Channels { + fn apply(&self, samples: Vec, width: usize) -> Vec { + match self { + Self::All => samples, + Self::Keep(keep) => samples.chunks_exact(width.max(1)).flat_map(|s| s.iter().zip(keep).filter(|(_, k)| **k).map(|(v, _)| *v)).collect(), + Self::Spread(n) => samples.iter().flat_map(|v| std::iter::repeat_n(*v, *n)).collect(), + } + } + + fn apply_f32(&self, values: &[f32]) -> Vec { + match self { + Self::All => values.to_vec(), + Self::Spread(n) => values.iter().flat_map(|v| std::iter::repeat_n(*v, *n)).collect(), + Self::Keep(keep) => values.iter().zip(keep).filter(|(_, k)| **k).map(|(v, _)| *v).collect(), + } + } +} + +/// The JPEG data of a `width` × `height` image that krilla's JPEG passthrough writes with the +/// same meaning: DCTDecode alone without parameters, 8 bits, DeviceGray with the default +/// `/Decode`, or DeviceCMYK with the inverted `/Decode` of Adobe CMYK JPEGs, which krilla +/// always writes for CMYK. +fn dct_passthrough(stream: &Stream<'_>, width: u32, height: u32) -> Option> { + let dict = stream.dict(); + let only_dct = stream.filters().as_slice() == [Filter::DctDecode] && !dict.contains_key(DECODE_PARMS); + let size = dict.get::(WIDTH) == Some(width) && dict.get::(HEIGHT) == Some(height); + if !(only_dct && size && dict.get::(BITS_PER_COMPONENT) == Some(8)) { + return None; + } + let decode: Option> = dict.get::>(DECODE).map(|a| a.iter::().collect()); + let ok = match dict.get::>(COLORSPACE)?.as_ref() { + b"DeviceGray" => decode.is_none_or(|d| d == [0.0, 1.0]), + b"DeviceCMYK" => decode.is_some_and(|d| d == [1.0, 0.0].repeat(4)), + _ => false, + }; + ok.then(|| Arc::from(stream.raw_data().as_ref())) +} + +/// Pixels per point of the image a tiling pattern over `area` is read as, from `scale`, and of +/// the drawing of its cell over `cell_box`, for `per_pixel` cells over each pixel and `inks` +/// values per pixel. +fn tiling_scales(scale: f64, area: Rect, cell_box: Rect, per_pixel: f64, inks: usize) -> (f64, f64) { + let ink_share = (CANVAS_INKS / inks.max(1) as f64).min(1.0); + let scale = limit_scale(scale, area, MAX_RASTER_PIXELS.min(MAX_TILING_SAMPLES / per_pixel) * ink_share); + (scale, limit_scale(scale * CELL_OVERSAMPLE, cell_box, MAX_CELL_PIXELS * ink_share)) +} + +/// `scale` (pixels per point), lowered so that `area` holds at most `pixels` pixels. +fn limit_scale(scale: f64, area: Rect, pixels: f64) -> f64 { + let have = area.area() * scale * scale; + if have > pixels { scale * (pixels / have).sqrt() } else { scale } +} + +/// `items` and every item inside their groups and masks. +fn item_count(items: &[Item]) -> usize { + items.iter().fold(0, |sum, item| { + let inner = match item { + Item::Group(g) => item_count(&g.children).saturating_add(g.mask.as_ref().map_or(0, |m| item_count(&m.group.children))), + Item::Fill(_) | Item::Text(_) | Item::Image(_) => 0, + }; + sum.saturating_add(1).saturating_add(inner) + }) +} + +/// The cell indices `k` whose span `[c0, c1] + k * step` meets `[lo, hi]`. +fn cells(lo: f64, hi: f64, c0: f64, c1: f64, step: f64) -> Option<(i64, i64)> { + let (a, z) = ((lo - c1) / step, (hi - c0) / step); + let (a, z) = if step > 0.0 { (a, z) } else { (z, a) }; + let (a, z) = (a.ceil(), z.floor()); + (a.is_finite() && z.is_finite() && a <= z && z - a < 1e9).then_some((a as i64, z as i64)) +} + +fn to_byte(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// Fills each text item's font from the prepared fonts, replacing the key placeholders. +fn set_fonts(items: &mut [Item], fonts: &HashMap>>) { + for item in items { + match item { + Item::Text(t) => { + t.font = t.font.as_ref().and_then(|f| fonts.get(&f.key).cloned().flatten()); + } + Item::Group(g) => set_group_fonts(g, fonts), + Item::Fill(_) | Item::Image(_) => {} + } + } +} + +fn set_group_fonts(group: &mut GroupItem, fonts: &HashMap>>) { + if let Some(mask) = group.mask.as_mut() { + set_group_fonts(&mut mask.group, fonts); + } + set_fonts(&mut group.children, fonts); +} + +fn unicode(s: BfString) -> String { + match s { + BfString::Char(c) => c.to_string(), + BfString::String(s) => s, + } +} + +fn same_linear(a: Affine, b: Affine) -> bool { + let (a, b) = (a.as_coeffs(), b.as_coeffs()); + let scale = a[..4].iter().fold(0.0_f64, |m, v| m.max(v.abs())); + a[..4].iter().zip(&b[..4]).all(|(x, y)| (x - y).abs() <= scale * 1e-6) +} + +fn fill_rule(rule: PdfFillRule) -> FillRule { + match rule { + PdfFillRule::NonZero => FillRule::NonZero, + PdfFillRule::EvenOdd => FillRule::EvenOdd, + } +} + +fn blend(mode: PdfBlend) -> BlendMode { + match mode { + PdfBlend::Normal => BlendMode::Normal, + PdfBlend::Multiply => BlendMode::Multiply, + PdfBlend::Screen => BlendMode::Screen, + PdfBlend::Overlay => BlendMode::Overlay, + PdfBlend::Darken => BlendMode::Darken, + PdfBlend::Lighten => BlendMode::Lighten, + PdfBlend::ColorDodge => BlendMode::ColorDodge, + PdfBlend::ColorBurn => BlendMode::ColorBurn, + PdfBlend::HardLight => BlendMode::HardLight, + PdfBlend::SoftLight => BlendMode::SoftLight, + PdfBlend::Difference => BlendMode::Difference, + PdfBlend::Exclusion => BlendMode::Exclusion, + PdfBlend::Hue => BlendMode::Hue, + PdfBlend::Saturation => BlendMode::Saturation, + PdfBlend::Color => BlendMode::Color, + PdfBlend::Luminosity => BlendMode::Luminosity, + } +} + +/// `path` (user space) stroked with `props` and taken to page space by `transform`. The stroke +/// is outlined in user space, where its width is given, so a skewed CTM skews it too. +fn outline_stroke(path: &BezPath, transform: Affine, props: &StrokeProps) -> BezPath { + let scale = transform.determinant().abs().sqrt(); + let scale = if scale.is_finite() && scale > f64::EPSILON { scale } else { 1.0 }; + let width = match f64::from(props.line_width) { + w if w > 0.0 && w.is_finite() => w, + _ => HAIRLINE / scale, + }; + let mut style = Stroke::new(width).with_caps(props.line_cap).with_join(props.line_join).with_miter_limit(f64::from(props.miter_limit)); + if let Some(dashes) = dash_pattern(path, &props.dash_array) { + style = style.with_dashes(f64::from(props.dash_offset), dashes); + } + let outline = kurbo::stroke(path.iter(), &style, &StrokeOpts::default(), STROKE_TOLERANCE / scale); + transform * outline +} + +/// The dash pattern to outline `path` with; `None` (solid) when the pattern is invalid or could +/// cut the path into more than [`MAX_DASHES_PER_STROKE`] pieces. The same estimate as hayro's. +fn dash_pattern(path: &BezPath, dashes: &[f32]) -> Option> { + if dashes.iter().any(|d| !d.is_finite() || *d < 0.0) { + return None; + } + let sum: f64 = dashes.iter().map(|d| f64::from(*d)).sum(); + // kurbo repeats an odd-length pattern once, so a period covers it twice. + let (period, per_period) = if dashes.len() % 2 == 1 { (2.0 * sum, dashes.len().saturating_mul(2)) } else { (sum, dashes.len()) }; + if !(period.is_finite() && period > 0.0) { + return None; + } + // A curve is no longer than its control polygon. kurbo restarts the pattern at every + // subpath and close, so each restart can begin a whole period however short its segment. + let (mut length, mut restarts) = (0.0, 0usize); + let (mut start, mut last) = (Point::ORIGIN, Point::ORIGIN); + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + (start, last) = (p, p); + restarts = restarts.saturating_add(1); + } + PathEl::LineTo(p) => { + length += last.distance(p); + last = p; + } + PathEl::QuadTo(c, p) => { + length += last.distance(c) + c.distance(p); + last = p; + } + PathEl::CurveTo(c1, c2, p) => { + length += last.distance(c1) + c1.distance(c2) + c2.distance(p); + last = p; + } + PathEl::ClosePath => { + length += last.distance(start); + last = start; + restarts = restarts.saturating_add(1); + } + } + } + let pieces = (length / period + restarts as f64) * per_period as f64; + (pieces.is_finite() && pieces <= MAX_DASHES_PER_STROKE).then(|| dashes.iter().map(|d| f64::from(*d)).collect()) +} + +/// `path` filled by `rule`, as a path filled nonzero. An even-odd path is rebuilt from its +/// boundary; if that fails it is kept as it is. +fn nonzero(path: &BezPath, rule: FillRule) -> BezPath { + if rule == FillRule::NonZero { + return path.clone(); + } + let closed = closed(path); + match linesweeper::binary_op(&closed, &BezPath::new(), linesweeper::FillRule::EvenOdd, linesweeper::BinaryOp::Union) { + Ok(contours) => { + let mut out = BezPath::new(); + for contour in contours.contours() { + out.extend(contour.path.iter()); + } + out + } + Err(_) => closed, + } +} + +/// `path` with every subpath closed, as a fill closes them. +fn closed(path: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let mut open = false; + for el in path.elements() { + match el { + PathEl::MoveTo(_) => { + if open { + out.close_path(); + } + open = true; + } + PathEl::ClosePath => open = false, + _ => {} + } + out.push(*el); + } + if open { + out.close_path(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tiling_canvases_hold_cmyk_memory_with_many_inks() { + let (page, cell) = (Rect::new(0.0, 0.0, 600.0, 800.0), Rect::new(0.0, 0.0, 300.0, 300.0)); + let dpi = 300.0 / 72.0; + let values = |area: Rect, scale: f64, inks: usize| { + let (w, h) = shade::pixel_size(area, scale).unwrap(); + (w * h * inks) as f64 + }; + // With 1,000 inks the page canvas would hold 8 billion values. + let (scale, cell_scale) = tiling_scales(dpi, page, cell, 1.0, 1000); + assert!(values(page, scale, 1000) <= MAX_RASTER_PIXELS * CANVAS_INKS * 1.01); + assert!(values(cell, cell_scale, 1000) <= MAX_CELL_PIXELS * CANVAS_INKS * 1.01); + // A CMYK page keeps its resolution. + assert_eq!(tiling_scales(dpi, page, cell, 1.0, 4).0, dpi); + } +} diff --git a/crates/prepress/src/flatten/raster.rs b/crates/prepress/src/flatten/raster.rs new file mode 100644 index 00000000..92c55162 --- /dev/null +++ b/crates/prepress/src/flatten/raster.rs @@ -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, +} + +/// 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, 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 = + region.items.iter().filter_map(|&i| self.list.items.get(i)).map(|i| self.alpha_masks(i, &grid)).collect::>()?; + 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 { + 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::>()?; + 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 { + 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 = (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>>, WriteError> { + let (w, h) = (grid.width() as usize, grid.height() as usize); + let mut planes: Vec>> = 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, 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)) +} diff --git a/crates/prepress/src/flatten/shade.rs b/crates/prepress/src/flatten/shade.rs new file mode 100644 index 00000000..08243afa --- /dev/null +++ b/crates/prepress/src/flatten/shade.rs @@ -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)> { + 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, f: &impl Fn(f32) -> Vec, out: &mut Vec<(f32, Vec)>) { + 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> { + 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, + /// 255 where the shading (or its background) paints. + pub alpha: Vec, +} + +/// 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 { + 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> { + 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 { + 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, + alpha: Vec, +} + +impl<'a> Canvas<'a> { + pub fn new(area: Rect, scale: f64, inks: &'a InkSet) -> Option { + 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, Vec)> { + 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, Vec)> = + 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 = 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 { + 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 = self.inks.iter().map(|(id, _)| id).collect(); + // Values are premultiplied by alpha; samples are not. + let n = self.n.max(1); + let samples: Vec = 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 { + 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, Vec)], 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), +} + +/// 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 { + 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 = corners.iter().map(|c| (*c - *start).dot(d) / (len * len)).collect(); + let t: Vec = 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 = Vec::new(); + for pass in 0..2 { + let floor = hull.len(); + let iter: Box> = 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 +} diff --git a/crates/prepress/src/flatten/write.rs b/crates/prepress/src/flatten/write.rs new file mode 100644 index 00000000..06fcc485 --- /dev/null +++ b/crates/prepress/src/flatten/write.rs @@ -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 }, + #[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::>().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, 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, 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>, +} + +type Shared = (FontBytes, Option); + +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 { + 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, 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, 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>, + /// 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 = 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 { + 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::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 = weights.iter().flatten().map(|(id, _)| *id).collect::>().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::, _>>()?; + 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> = + 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 { + 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 = + (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::>().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 { + let colorants = named.iter().map(|&i| Process::ALL.get(i).map(|p| process_colorant(*p))).collect::>>()?; + let tints = named.iter().map(|&i| cmyk.get(i).copied().map(byte)).collect::>>()?; + 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 { + let mut resolved: Vec<(f32, BTreeMap)> = 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 = 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 = 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 { + 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 { + 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>, + size: (u32, u32), + space: ImageColorspace, + device_n: Option, +} + +impl Hash for Samples { + fn hash(&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 { + 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 { + if im.smask.is_some() || !matches!(im.space, ImageSpace::Gray | ImageSpace::Cmyk) { + return None; + } + let data: Arc + 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) +} diff --git a/crates/prepress/src/ink.rs b/crates/prepress/src/ink.rs index 22dfa12f..2dc3b472 100644 --- a/crates/prepress/src/ink.rs +++ b/crates/prepress/src/ink.rs @@ -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) } } diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs index 24a7d941..4100696b 100644 --- a/crates/prepress/src/lib.rs +++ b/crates/prepress/src/lib.rs @@ -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; diff --git a/crates/prepress/src/plates.rs b/crates/prepress/src/plates.rs index 0f976a1b..332c8b6b 100644 --- a/crates/prepress/src/plates.rs +++ b/crates/prepress/src/plates.rs @@ -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, Conversion), Error> { +pub(crate) fn fallback(fallback: &Fallback) -> Result<(Option, 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 { /// 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 = 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>) -> Vec> { - intents.iter().flat_map(|a| a.iter::>()).take(MAX_OUTPUT_INTENTS).collect() - } - let xref = pdf.xref(); - let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS))); - if listed.is_empty() { - listed = list(xref.get::>(xref.root_id()).and_then(|catalog| catalog.get::>(OUTPUT_INTENTS))); - } - // A stable sort: the PDF/X intent first, the others in their order. - listed.sort_by_key(|d| d.get::>(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::>(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, + pub identifier: Option, + pub condition: Option, + pub registry_name: Option, + pub info: Option, +} + +/// 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 { + output_intents(pdf, page).iter().find_map(|intent| { + let icc = intent.get::>(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> { + fn list<'a>(intents: Option>) -> Vec> { + intents.iter().flat_map(|a| a.iter::>()).take(MAX_OUTPUT_INTENTS).collect() + } + let xref = pdf.xref(); + let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS))); + if listed.is_empty() { + listed = list(xref.get::>(xref.root_id()).and_then(|catalog| catalog.get::>(OUTPUT_INTENTS))); + } + // A stable sort: the PDF/X intent first, the others in their order. + listed.sort_by_key(|d| d.get::>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX")); + listed +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/prepress/tests/fixtures/cyan-cmyk.jpg b/crates/prepress/tests/fixtures/cyan-cmyk.jpg new file mode 100644 index 00000000..4cd81d13 Binary files /dev/null and b/crates/prepress/tests/fixtures/cyan-cmyk.jpg differ diff --git a/crates/prepress/tests/fixtures/gray-25.jpg b/crates/prepress/tests/fixtures/gray-25.jpg new file mode 100644 index 00000000..21fcda8f Binary files /dev/null and b/crates/prepress/tests/fixtures/gray-25.jpg differ diff --git a/crates/prepress/tests/flatten_document.rs b/crates/prepress/tests/flatten_document.rs new file mode 100644 index 00000000..165a80e9 --- /dev/null +++ b/crates/prepress/tests/flatten_document.rs @@ -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> { + 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 = 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 { + 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::(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::>(), [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::>(), [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::>(xref.root_id()).unwrap(); + let intents: Vec> = catalog.get::>(OUTPUT_INTENTS).unwrap().iter::>().collect(); + assert_eq!(intents.len(), 1); + let id = intents[0].get::>(OUTPUT_CONDITION_IDENTIFIER).unwrap(); + assert_eq!(id.as_bytes(), b"Test coated"); + let icc = intents[0].get::>(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"PDF/X-1:2003"), 1); + assert_eq!(count(&out, b"1.4"), 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); +} diff --git a/crates/prepress/tests/flatten_pdf.rs b/crates/prepress/tests/flatten_pdf.rs new file mode 100644 index 00000000..0f8dfc45 --- /dev/null +++ b/crates/prepress/tests/flatten_pdf.rs @@ -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 { + 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 { + 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> 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 = 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::() / 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 { + 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); +} diff --git a/crates/prepress/tests/flatten_plates.rs b/crates/prepress/tests/flatten_plates.rs new file mode 100644 index 00000000..91c11262 --- /dev/null +++ b/crates/prepress/tests/flatten_plates.rs @@ -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))); +} diff --git a/crates/prepress/tests/flatten_raster.rs b/crates/prepress/tests/flatten_raster.rs new file mode 100644 index 00000000..7f9af1c2 --- /dev/null +++ b/crates/prepress/tests/flatten_raster.rs @@ -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 = 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>> { + 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::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 { + 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, 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::>()); + 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"); +} diff --git a/crates/prepress/tests/flatten_timings.rs b/crates/prepress/tests/flatten_timings.rs new file mode 100644 index 00000000..842c9c50 --- /dev/null +++ b/crates/prepress/tests/flatten_timings.rs @@ -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 { + 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::>().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 = 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!("", 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)); + } +} diff --git a/crates/prepress/tests/flatten_write.rs b/crates/prepress/tests/flatten_write.rs new file mode 100644 index 00000000..8e0c9875 --- /dev/null +++ b/crates/prepress/tests/flatten_write.rs @@ -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::Group(GroupItem { + opacity, + blend: BlendMode::Normal, + isolated: false, + knockout: false, + clip: None, + mask: None, + actual_text: None, + children, + }) +} + +fn page(inks: InkSet, items: Vec) -> DisplayList { + DisplayList { page: Rect::new(0.0, 0.0, 100.0, 100.0), inks, items } +} + +fn pdf(list: &DisplayList) -> Vec { + // Reading the profile takes a quarter second in a debug build. + static SETTINGS: OnceLock = 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 { + 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> { + 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>) {} + 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>, _: 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::>()), + _ => 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::() > 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 { + 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::>(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, depth: usize) { + assert!(depth < 16); + for key in states.keys() { + let Some(state) = states.get::>(key.as_ref()) else { continue }; + if state.get::>(b"SMask".as_slice()).is_some() { + found.push("soft mask".to_owned()); + } + if let Some(bm) = state.get::>(b"BM".as_slice()) + && bm.as_str() != "Normal" + { + found.push(format!("blend mode {}", bm.as_str())); + } + for alpha in ["ca", "CA"] { + if state.get::(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::>(key.as_ref()) else { continue }; + let dict = x.dict(); + if dict.get::>(b"Group".as_slice()).is_some() { + found.push("group".to_owned()); + } + if let Some(res) = dict.get::>(b"Resources".as_slice()) { + let sub = |key: &[u8]| res.get::>(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::::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, 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)]); +} diff --git a/crates/prepress/tests/font_reembed.rs b/crates/prepress/tests/font_reembed.rs new file mode 100644 index 00000000..12288d29 --- /dev/null +++ b/crates/prepress/tests/font_reembed.rs @@ -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, what: &str) -> Option> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + let mut fonts: HashMap = 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 { + 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, + /// What hayro hands out for the font, for the checks. + data: Option, + id: u32, + /// Glyph space (1000 units per em, y up) to page space. + matrix: Affine, + unicode: Option, + vertical: bool, + type3: bool, + outline: BezPath, +} + +#[derive(Default)] +struct Recorder { + glyphs: Vec, + actual_text: String, + fonts: FontCache, +} + +#[derive(Default)] +struct PathSink(BezPath); + +impl Device<'_> for PathSink { + fn set_soft_mask(&mut self, _: Option>) {} + 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>, _: 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>) {} + 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>, _: 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 = 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>>) -> Vec { + let mut items: Vec = 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 { + 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::() / 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, 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> 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 = 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> 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 { + let (data, _) = StandardFont::Helvetica.get_font_data(); + let hex: String = (*data).as_ref().iter().map(|b| format!("{b:02x}")).collect(); + let widths: Vec = (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)); +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index b30fb177..573e3812 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -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"] diff --git a/crates/ui/README.md b/crates/ui/README.md index f7a7705d..6489442d 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -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: diff --git a/crates/ui/examples/docking_native.rs b/crates/ui/examples/docking_native.rs new file mode 100644 index 00000000..ccea71f5 --- /dev/null +++ b/crates/ui/examples/docking_native.rs @@ -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 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, +} + +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::>(), 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 })))) +} diff --git a/crates/ui/examples/ui_hotpaths.rs b/crates/ui/examples/ui_hotpaths.rs index 3ef36042..509fb519 100644 --- a/crates/ui/examples/ui_hotpaths.rs +++ b/crates/ui/examples/ui_hotpaths.rs @@ -390,7 +390,7 @@ fn dock_layout(panels: u32, with_floating: bool) -> Layout { }), 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 = (0..panels).collect(); @@ -662,7 +662,7 @@ fn run_case(case: Case<'_>, fonts: &egui::FontDefinitions) -> Result = layout.floating.iter().flat_map(|group| group.panels.iter().copied()).collect(); + let floating: BTreeSet = 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 = { diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs new file mode 100644 index 00000000..5f3ad2c5 --- /dev/null +++ b/crates/ui/src/docking/chrome.rs @@ -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, + limits: PanelLimits, + resizable: bool, + resize_label: &'a str, +} + +/// What a [`FloatingFrame`] did this frame. +pub struct FrameResponse { + 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, + /// The handle whose pointer resize started this frame. + pub resize_started: Option, + /// Every handle's response, for focus and gesture bookkeeping. + pub handles: Vec, +} + +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(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse { + 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, Option, Vec) { + 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::(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::(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 { + 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, + 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, + pub close: Option, +} + +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) -> 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 { + 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 +} diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs new file mode 100644 index 00000000..99825f1d --- /dev/null +++ b/crates/ui/src/docking/command.rs @@ -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 = 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

{ + #[serde(rename = "window.panel.layout")] + Layout { action: Action

}, + #[serde(rename = "window.panel.move")] + Move { + panel: P, + anchor: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + zone: Option, + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + before: Option>, + #[serde(default)] + group: bool, + /// A serialized [`Placement`]; it takes precedence over `zone` and `before`. + #[serde(default, skip_serializing_if = "Option::is_none")] + placement: Option>, + }, + #[serde(rename = "window.panel.float")] + Float { + panel: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + height: Option, + #[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

, + #[serde(default, skip_serializing_if = "Option::is_none")] + zone: Option, + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + before: Option>, + #[serde(default)] + group: bool, + #[serde(default)] + column: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + side: Option, + }, + /// 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, + }, + #[serde(rename = "window.panel.iconic")] + Iconic { + panel: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + iconic: Option, + }, + /// 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, + }, + /// 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, + }, + /// The `ui.dock` shape of LightCraft, PDFCraft, CADCraft and SoundCraft. + #[serde(rename = "ui.dock")] + UiDock(UiDock

), +} + +/// `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

{ + Action { action: Action

}, + Operation(DockOperation

), +} + +/// 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

{ + /// 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, + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + before: Option>, + }, + SetStackOpen { + panel: P, + open: bool, + }, + /// `height` is required; `null` clears it. + ResizeStack { + panel: P, + #[serde(deserialize_with = "required")] + height: Option, + }, + ResizeSplit { + path: Vec, + 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, 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::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

, + right_to_left: bool, +} + +impl Zoned<'_, P> { + fn placement(&self, anchor: &P, zone: Option, before: &Option>, default: ZoneName) -> Result, 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(self, layout: &Layout

, panel: &P, to: Target

) -> Result, 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

DockCommand

{ + /// 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 DockCommand

{ + /// 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

, right_to_left: bool, space: &FloatSpace

) -> Result>, 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

| 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

, + right_to_left: bool, + space: &FloatSpace

, + permissions: impl Fn(&P) -> Permissions, + ) -> Result, 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

DockCommand

{ + /// The outer edge `window.panel.dock` goes to: a side zone without an anchor or side. + fn edge(&self) -> Option { + 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(layout: &Layout

, side: Side) -> Option> { + 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(layout: &Layout

, panel: &P) -> Option { + layout.members(panel, super::dock_model::Unit::Group).ok().map(|members| members.len()) +} + +/// The panels that move with `panel`. +fn members(layout: &Layout

, panel: &P, unit: Unit) -> Vec

{ + 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()], + }, + } +} diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs new file mode 100644 index 00000000..78d406b1 --- /dev/null +++ b/crates/ui/src/docking/command_tests.rs @@ -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

{ + 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

{ + FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))) +} + +fn parse(params: Value) -> Result, serde_json::Error> { + serde_json::from_value(params) +} + +/// The actions `params` makes on a fresh workspace. +fn actions(params: Value) -> Vec> { + 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

, Vec

) { + 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

{ + 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 = DockCommand::

::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

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 { + (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::

(&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

, panel: &str) -> Vec> { + 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"))); +} diff --git a/crates/ui/src/docking/desktop.rs b/crates/ui/src/docking/desktop.rs new file mode 100644 index 00000000..7fb47211 --- /dev/null +++ b/crates/ui/src/docking/desktop.rs @@ -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, dragged: Option) -> 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))); + } +} diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs new file mode 100644 index 00000000..9abd4082 --- /dev/null +++ b/crates/ui/src/docking/dock_model.rs @@ -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

{ + pub panels: Vec

, + 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, +} + +impl

Group

{ + pub fn new(panels: Vec

, 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

{ + pub groups: Vec>, + /// 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, +} + +impl

Column

{ + pub fn new(groups: Vec>, 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

{ + /// Relative to the panel `anchor`, which must not be part of what moves. + At { anchor: P, placement: Placement

}, + /// 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 Placement

{ + /// 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

, anchor: &P, zone: Zone, right_to_left: bool) -> Result { + 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, + /// The column, when the group was docked. + pub column: Option, +} + +#[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, +} + +/// 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), + Dock { + path: Vec, + column: usize, + group: usize, + }, + /// A `Tabs` or `Stack` node of a floating window's tree. + Floating { + window: usize, + path: Vec, + }, +} + +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, &[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

{ + pub groups: Vec>, + /// 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>, + pub minimized: bool, + pub width: Option, + pub iconic: bool, + pub iconic_width: Option, + 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, +} + +impl

Moving

{ + pub fn group(group: Group

) -> 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

{ + 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

, path: &[bool]) -> Option<&'a Node

> { + 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

, path: &[bool]) -> Option<&'a mut Node

> { + 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

(root: &Node

) -> Vec<(&Node

, Vec)> { + 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

(root: &Node

) -> 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

(root: &Node

) -> 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(root: &Node

) -> Vec> { + 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

(groups: Vec>) -> Option> { + let height = |g: &Group

| g.height.filter(|h| h.is_finite() && *h > 0.0); + let known: Vec = groups.iter().filter_map(height).collect(); + let share = if known.is_empty() { 1.0 } else { known.iter().sum::() / known.len() as f32 }; + let weights: Vec = 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

(root: &mut Node

, path: &[bool], new: Node

, 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

(panels: &mut Vec

, active: &mut usize, index: usize, new: Vec

, 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 Layout

{ + pub(super) fn place(&self, panel: &P) -> Option { + 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 { + fn note(places: &mut HashMap, leaf: &Node

, 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) -> Option<&Node

> { + 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) -> Option<&mut Node

> { + 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> { + 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

> { + let (window, path) = place.tree_path()?; + node_at(self.tree(window)?, path) + } + + fn node_of_mut(&mut self, place: &Place) -> Option<&mut Node

> { + let (window, path) = place.tree_path()?; + node_at_mut(self.tree_mut(window)?, path) + } + + pub(super) fn dock_path(&self, side: Side) -> Option> { + let mut visit: Vec<(&Node

, Vec)> = 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 { + 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>> { + 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

> { + 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

> { + 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

> { + 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

> { + 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> { + 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, path: &[bool]) -> Option> { + 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, 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, 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, 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

| 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, 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, 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

, to: Target

) -> 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

) { + 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 { + 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, moving: Moving

) -> 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(leaf: &Node

, 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

(groups: Vec>) -> (Vec

, 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

(column: &Column

) -> bool { + valid_width(column.width) && column.iconic_width.is_none_or(valid_width) +} diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs new file mode 100644 index 00000000..97c5e9ba --- /dev/null +++ b/crates/ui/src/docking/dock_tests.rs @@ -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 { + 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 { + 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, windows: &[(u32, [f32; 4])]) -> Layout { + layout.floating.extend(windows.iter().map(|(panel, rect)| Floating::single(vec![*panel], 0, *rect))); + layout +} + +fn columns(layout: &Layout, side: Side) -> Vec>> { + fn find(node: &Node, side: Side) -> Option>>> { + 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, index: usize) -> Column { + fn find(node: &Node) -> Option<&Vec>> { + 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 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, + content: TestContent, + focused: bool, + right_to_left: bool, + shapes: Vec, + time: f64, +} + +impl Driver { + fn new(layout: Layout) -> Self { + Self::with(layout, TestContent::default(), false) + } + + fn with(layout: Layout, 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) -> 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) -> DockOutput { + 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) -> DockOutput { + 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 { + 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 { + self.hold(from, to); + self.step(vec![button(to, false)]) + } + + fn click(&mut self, at: Pos2) -> DockOutput { + 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]) -> Option<(u32, &Target)> { + 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) -> Rect { + Rect::from_x_y_ranges(x, (y - 2.0)..=(y + 2.0)) +} + +fn vline(x: f32, y: std::ops::RangeInclusive) -> 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::(&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::(&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::>(); + 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::(&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::(&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::() - 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::::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 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 { + 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; 3]) -> testing::DockDriver { + 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) -> [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| 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| 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, min: f32, content: Option| 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::() - 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]); +} diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs new file mode 100644 index 00000000..2796c883 --- /dev/null +++ b/crates/ui/src/docking/floating_tests.rs @@ -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 { + Node::Tabs { panels: panels.to_vec(), active } +} + +/// `[1] | [2, 3]` with panel 3 selected. +fn frames() -> Layout { + 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 { + 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, + /// Canvas 0 accepts side splits, as an app that opens panels beside it does. + beside: bool, + drawn: Vec, +} + +impl DockContent 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, + content: Content, + split_tree: bool, + maximized: Option, + hide: Hide, + modifiers: Modifiers, + time: f64, +} + +impl Driver { + fn new(layout: Layout, 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) -> DockOutput { + 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 = 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) -> DockOutput { + 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 { + 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 { + self.response(Id::unique(AREA).with(("tab", &panel))) + } + + fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + 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 { + self.hold(from, to); + self.step(vec![button(to, false)]) + } + + fn click(&mut self, at: egui::Pos2) -> DockOutput { + 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, panel: u32) -> Option<&Floating> { + layout.floating.iter().find(|w| w.contains(&panel)) +} + +fn groups_of(window: &Floating) -> Vec> { + 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>, Vec>); + 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::::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| { + 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); + let click = |frame: &mut dyn FnMut(Vec) -> 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::::hit_rects(&d.ctx, Id::unique(AREA)).iter().any(|(h, _)| *h == hit), "the hits name the layout path"); + let divider = d.response(DockArea::::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> = 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, Vec>); + 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![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); +} diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs new file mode 100644 index 00000000..a2a7a722 --- /dev/null +++ b/crates/ui/src/docking/group_tests.rs @@ -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 { + Node::Tabs { panels: panels.to_vec(), active } +} + +/// `[1] | [2, 3]` with panel 3 selected. +fn frames() -> Layout { + 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 { + 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, + header: f32, + header_clicks: usize, + /// The strip width the last header was sized for. + strip_width: std::cell::Cell, + floating_size: Option, + host_owned: bool, + whole_group: bool, + /// Height every body takes; `None` draws a one-line label. + body_height: Option, + /// Every body takes its whole rect. + fill: bool, +} + +impl DockContent 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 { + self.floating_size + } + fn resolve_drag(&mut self, _: &Context, _: &DockStyle, request: &DropRequest<'_, u32>) -> Option { + 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, + content: TestContent, + area: Rect, + modifiers: Modifiers, + focused: bool, + transparent: bool, + /// Applied to the style every frame. + tweak: fn(&mut DockStyle), + shapes: Vec, + time: f64, +} + +impl Driver { + fn new(layout: Layout) -> Self { + Self::with(layout, TestContent::default(), Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))) + } + + fn with(layout: Layout, 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) -> DockOutput { + 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) -> DockOutput { + 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 { + 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 { + 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 { + 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]) -> Option<&Target> { + 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 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| { + let mut out = Vec::new(); + fn walk(shape: &egui::Shape, out: &mut Vec) { + 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::(&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::(&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 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>)| { + 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::::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| { + 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::(&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| { + 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)); +} diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs new file mode 100644 index 00000000..3e59e022 --- /dev/null +++ b/crates/ui/src/docking/hits.rs @@ -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

{ + /// 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

), +} + +/// 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

{ + /// A split, by its path from the root (see [`super::Action::ResizeSplit`]). + Split(Vec), + /// 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, Vec), + /// A divider in the floating window holding the panel, by its path from the window's root. + WindowSplit(P, Vec), + WindowIntersection(P, Vec, Vec), +} + +fn key(area: Id) -> Id { + area.with("hit-rects") +} + +impl 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

, Rect)> { + let Some((layout, rects)) = ctx.data(|d| d.get_temp::>(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

) -> 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

) -> Option { + 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(area: Id, layout: &Layout

) -> HashMap> { + let mut names: HashMap> = HashMap::new(); + let mut name = |hit: Hit

| { + names.insert(DockArea::

::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

(root: &Node

, name: &mut dyn FnMut(Vec, Option>)) { + let mut nodes = vec![(root, Vec::::new())]; + let mut splits: Vec> = 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

= (Layout

, 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(ctx: &Context, area: Id, layout: &Layout

, 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::>(key(area), (layout.clone(), rects))); +} diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs new file mode 100644 index 00000000..56c29620 --- /dev/null +++ b/crates/ui/src/docking/iconic_tests.rs @@ -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 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; + +/// A name and a test for one kind of [`Hit`]. +type Check<'a> = (&'a str, &'a dyn Fn(&Hit) -> bool); + +/// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0. +fn docked(groups: &[&[u32]], iconic: bool) -> Layout { + 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) -> Column { + 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) -> Vec> { + first_column(layout).groups.iter().map(|g| g.panels.clone()).collect() +} + +fn hit(d: &Driver, hit: Hit) -> Rect { + d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits())) +} + +fn open(d: &Driver) -> Option { + flyout::(&d.ctx, d.id) +} + +fn texts(d: &Driver) -> Vec { + 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>| { + 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::>()), 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::(&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) -> 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::(&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 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 = 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 = 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::::hit_id(d.id, &hit))); + assert!(DockArea::::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:?}"); +} diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs new file mode 100644 index 00000000..3ad3a9ff --- /dev/null +++ b/crates/ui/src/docking/labels.rs @@ -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) -> 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), + ] + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index a7bf3f98..86198840 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -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; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 36c3bdbc..be2ae3a3 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -1,11 +1,14 @@ use std::{collections::HashSet, fmt, hash::Hash}; +use super::dock_model::{ + Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, split_frame, tree_panels, valid_column, +}; use crate::layout::{SplitAxis, SplitSize}; /// Limits are checked before cloning or recursively modifying an application layout. pub const MAX_DEPTH: usize = 64; pub const MAX_PANELS: usize = 4096; -const MAX_NODES: usize = MAX_PANELS * 2; +pub(super) const MAX_NODES: usize = MAX_PANELS * 2; const MAX_EXTENT: f32 = 1_000_000.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -20,6 +23,7 @@ pub enum Zone { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Node

{ Split { axis: SplitAxis, @@ -35,6 +39,13 @@ pub enum Node

{ Stack { entries: Vec>, }, + /// One window edge's dock, columns from the window edge inwards. A dock is the root or, + /// following only same-side children of horizontal splits, at its window edge. At most one + /// per side. An empty dock (no columns) is valid and accepts [`Target::NewDock`]. + Dock { + side: Side, + columns: Vec>, + }, } #[derive(Clone, Debug, PartialEq)] @@ -45,19 +56,237 @@ pub struct StackEntry

{ pub height: Option, } -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +/// A floating window: a tree of groups like the docked layout's, without docks. +/// +/// Serde reads the v1 shape `{panels, active, rect}` as one group, and a stack of groups +/// `{groups, rect}` as a vertical split of them, sized by their heights. A window with one +/// group (not minimized, no native rect) writes the v1 shape, so older builds can read files +/// that use no newer feature. A native window also writes its `id`; any other window gets a +/// fresh one on load. +/// +/// Equality ignores `id`: two windows with the same contents and place are the same layout. +#[derive(Clone, Debug)] pub struct Floating

{ - pub panels: Vec

, - pub active: usize, + /// Names the window across panel moves, and its OS window with feature `native-windows`. + /// Unique within a layout: [`Self::new`] and loading draw fresh ids, [`Self::with_id`] + /// sets one, and merging windows keeps the receiving window's. + pub id: u64, + /// `Split`, `Tabs` and `Stack` nodes; a `Dock` is invalid here. + pub root: Node

, /// `[x, y, width, height]` in logical points, in the application's egui viewport. pub rect: [f32; 4], + /// Desktop rect while the window is in its own OS window. + pub native: Option<[f32; 4]>, + /// The monitor holding the center of `native`, from [`super::DockArea::monitors`]. + pub monitor: Option, + /// Without a monitor list: the size of the monitor egui reported for the native window. + pub monitor_size: Option<[f32; 2]>, + /// Only the title bar shows. + pub minimized: bool, +} + +impl PartialEq for Floating

{ + fn eq(&self, other: &Self) -> bool { + self.root == other.root + && self.rect == other.rect + && self.native == other.native + && self.monitor == other.monitor + && self.monitor_size == other.monitor_size + && self.minimized == other.minimized + } +} + +/// A display, in desktop logical points. Applications on an egui with monitor reports fill +/// [`super::DockArea::monitors`] with these; native floating windows store the one they are on. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Monitor { + pub name: Option, + /// `[x, y, width, height]` on the desktop. + pub rect: [f32; 4], + /// Physical pixels per logical point. + pub scale: f32, +} + +/// A window id no other window of this process has drawn, from a random start so ids saved +/// by earlier runs are unlikely to repeat. +pub(super) fn fresh_window_id() -> u64 { + use std::hash::{BuildHasher, Hasher}; + use std::sync::{ + OnceLock, + atomic::{AtomicU64, Ordering}, + }; + static NEXT: OnceLock = OnceLock::new(); + let next = NEXT.get_or_init(|| AtomicU64::new(std::collections::hash_map::RandomState::new().build_hasher().finish() >> 1)); + next.fetch_add(1, Ordering::Relaxed) +} + +impl

Floating

{ + /// A window holding `root`. Each call makes a new window, with a fresh [`Self::id`]: build + /// a layout once and keep it, or give its windows ids with [`Self::with_id`]. A layout + /// built anew each frame without them opens new OS windows each frame. + pub fn new(root: Node

, rect: [f32; 4]) -> Self { + Self { id: fresh_window_id(), root, rect, native: None, monitor: None, monitor_size: None, minimized: false } + } + + /// A window with one group. Each call makes a new window, as [`Self::new`] does. + pub fn single(panels: Vec

, active: usize, rect: [f32; 4]) -> Self { + Self::new(Node::Tabs { panels, active }, rect) + } + + /// The window with `id`, for a layout the app builds in code, such as a default layout: + /// built with the same id, it is the same window, and the same OS window, on every frame. + /// Ids must be unique within a layout. + pub fn with_id(mut self, id: u64) -> Self { + self.id = id; + self + } + + pub fn panels(&self) -> impl Iterator { + tree_panels(&self.root).into_iter() + } + + /// Groups stacked top to bottom, as a v1 layout stored them: each group over a vertical + /// split of the ones below it, sized by their heights. `None` without groups. Each call + /// makes a new window, as [`Self::new`] does. + pub fn stack(groups: Vec>, rect: [f32; 4]) -> Option { + super::dock_model::stack_tree(groups).map(|root| Self::new(root, rect)) + } + + /// The window's groups, first to last. + #[cfg(test)] + pub(crate) fn groups(&self) -> Vec> + where + P: Clone, + { + super::dock_model::tree_groups(&self.root) + } + + /// True when a group of this window holds `panel`. Takes any borrowed form of `P`, such + /// as `&str` for `String` panels. + pub fn contains(&self, panel: &Q) -> bool + where + P: std::borrow::Borrow, + { + self.panels().any(|p| p.borrow() == panel) + } +} + +#[cfg(feature = "serde")] +mod floating_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use super::{Floating, Group, Monitor, Node, fresh_window_id}; + use crate::docking::dock_model::stack_tree; + + #[derive(Serialize)] + struct V1<'a, P> { + panels: &'a [P], + active: usize, + rect: [f32; 4], + } + + #[derive(Serialize)] + struct Tree<'a, P> { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + root: &'a Node

, + rect: [f32; 4], + #[serde(skip_serializing_if = "Option::is_none")] + native: Option<[f32; 4]>, + #[serde(skip_serializing_if = "Option::is_none")] + monitor: Option<&'a Monitor>, + #[serde(skip_serializing_if = "Option::is_none")] + monitor_size: Option<[f32; 2]>, + #[serde(skip_serializing_if = "std::ops::Not::not")] + minimized: bool, + } + + impl Serialize for Floating

{ + fn serialize(&self, serializer: S) -> Result { + match &self.root { + Node::Tabs { panels, active } if !self.minimized && self.native.is_none() => { + V1 { panels, active: *active, rect: self.rect }.serialize(serializer) + } + root => Tree { + id: self.native.map(|_| self.id), + root, + rect: self.rect, + native: self.native, + monitor: self.monitor.as_ref(), + monitor_size: self.monitor_size, + minimized: self.minimized, + } + .serialize(serializer), + } + } + } + + #[derive(Deserialize)] + #[serde(bound(deserialize = "P: Deserialize<'de>"))] + struct Repr

{ + #[serde(default)] + id: Option, + root: Option>, + groups: Option>>, + panels: Option>, + #[serde(default)] + active: usize, + rect: [f32; 4], + #[serde(default)] + native: Option<[f32; 4]>, + #[serde(default)] + monitor: Option, + #[serde(default)] + monitor_size: Option<[f32; 2]>, + #[serde(default)] + minimized: bool, + } + + /// Floating windows with a fresh id for each one whose id an earlier window took. + pub(super) fn unique_ids<'de, D: Deserializer<'de>, P: Deserialize<'de>>(deserializer: D) -> Result>, D::Error> { + let mut windows = Vec::>::deserialize(deserializer)?; + let mut seen = std::collections::HashSet::new(); + for window in &mut windows { + while !seen.insert(window.id) { + window.id = fresh_window_id(); + } + } + Ok(windows) + } + + impl<'de, P: Deserialize<'de>> Deserialize<'de> for Floating

{ + fn deserialize>(deserializer: D) -> Result { + let repr = Repr::

::deserialize(deserializer)?; + let mut minimized = repr.minimized; + let root = match (repr.root, repr.groups, repr.panels) { + (Some(root), None, None) => root, + (None, Some(groups), None) => { + minimized |= !groups.is_empty() && groups.iter().all(|g| g.minimized); + stack_tree(groups).ok_or_else(|| serde::de::Error::invalid_length(0, &"at least one group"))? + } + (None, None, Some(panels)) => Node::Tabs { panels, active: repr.active }, + (None, None, None) => return Err(serde::de::Error::missing_field("root")), + _ => return Err(serde::de::Error::duplicate_field("root")), + }; + Ok(Floating { + id: repr.id.unwrap_or_else(fresh_window_id), + root, + rect: repr.rect, + native: repr.native, + monitor: repr.monitor, + monitor_size: repr.monitor_size, + minimized, + }) + } + } } #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Layout

{ pub root: Option>, + #[cfg_attr(feature = "serde", serde(deserialize_with = "floating_serde::unique_ids"))] pub floating: Vec>, } @@ -67,13 +296,30 @@ impl

Default for Layout

{ } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Placement

{ /// Insert before this existing destination member, or append. Also reorders a group. Tab { before: Option

}, - /// Center inserts after the anchor; sides create a new half-size split. + /// Center inserts after the anchor; sides split the anchor's frame into two halves, and + /// every other frame keeps its size. Sides apply to `Tabs` and `Stack` nodes only. Split(Zone), + /// A new group above or below the anchor's group in a dock column. On an anchor in a + /// floating window, a split of the anchor's group above or below. + /// `height` is the inserted group's preferred height ([`super::Group::height`]); `None` + /// keeps a moved group's own height, or shares the free height for a new group. + Group { + below: bool, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + height: Option, + }, + /// A new column before (towards the window edge) or after the anchor's column. + Column { after: bool }, + /// A new frame along one edge of the docked layout, a quarter of its width or height: the + /// root is split, or in a layout with docks, the part between them. The anchor names any + /// docked panel. `Center` is invalid. + Edge(Zone), } /// A closed panel's former placement. Applications may retain this beside visibility flags. @@ -92,10 +338,16 @@ pub struct Location

{ /// rather than accidentally inserting beside only its first leaf when restoring. #[cfg_attr(feature = "serde", serde(default))] pub sibling: Vec

, + /// Group and column state when the panel was in a dock. Applies when + /// restoring creates a new group, column or dock for the panel. + #[cfg_attr(feature = "serde", serde(default))] + pub group: Option, } +/// Groups, columns and floating windows are named by a panel they contain. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Action

{ Move { panel: P, @@ -113,6 +365,11 @@ pub enum Action

{ panel: P, }, /// Reveal a registered, currently absent panel. Applications validate their panel registry. + /// `anchor` names the group it joins as the last tab. Without one it becomes the root of an + /// empty layout, else joins the first dock group that accepts tabs and is shown (not + /// minimized, not in an iconic column; the end-side dock's first), else gets a new frame a + /// quarter wide at the right edge of the docked area + /// ([`Placement::Edge`]). Open { panel: P, anchor: Option

, @@ -123,7 +380,7 @@ pub enum Action

{ anchor: P, placement: Placement

, }, - /// Reposition/resize a floating group, identified by one of its panels. + /// Reposition/resize a floating window, identified by one of its panels. MoveFloating { panel: P, rect: [f32; 4], @@ -133,6 +390,13 @@ pub enum Action

{ path: Vec, size: SplitSize, }, + /// [`Action::ResizeSplit`] in the floating window holding `panel`, with a path from its + /// root. + ResizeWindowSplit { + panel: P, + path: Vec, + size: SplitSize, + }, SetStackOpen { panel: P, open: bool, @@ -141,28 +405,122 @@ pub enum Action

{ panel: P, height: Option, }, + /// Move the group containing `panel` with its order, active tab and minimized state. + /// `Target::At` with `Tab` or `Split(Center)` merges every member. An accordion entry is a + /// group of one. A target inside the group is a no-op. + MoveGroup { + panel: P, + to: Target

, + }, + /// Move the dock column containing `panel`. `Float` makes a window of its groups. + MoveColumn { + panel: P, + to: Target

, + }, + /// Dock or merge the floating window containing `panel`, every group included. + MoveWindow { + panel: P, + to: Target

, + }, + /// Close Tab Group. + CloseGroup { + panel: P, + }, + /// × on a floating window: closes all its panels. + CloseWindow { + panel: P, + }, + /// Close Other Panels in Group. + CloseOthers { + panel: P, + }, + /// Minimize a dock group to its tab strip, or the floating window holding the panel to its + /// title bar (and the strip of its only group). On an accordion entry, closes it. + SetMinimized { + panel: P, + minimized: bool, + }, + SetIconic { + panel: P, + iconic: bool, + }, + ResizeColumn { + panel: P, + width: f32, + }, + /// Preferred height of a dock group or of an accordion entry. + ResizeGroup { + panel: P, + height: Option, + }, + ResizeIconic { + panel: P, + width: Option, + }, + /// Put the floating window in its own OS window at `rect`, or back in the app window. + SetNative { + panel: P, + rect: Option<[f32; 4]>, + }, + /// Record the monitor of the native window holding `panel`: from the application's + /// monitor list, or else only the size egui reported. + SetMonitor { + panel: P, + monitor: Option, + monitor_size: Option<[f32; 2]>, + }, + /// Move one panel, without the rest of its group. `Target::At` is [`Action::Move`]; + /// `NewDock` and `Float` give the panel a group of its own. + MovePanel { + panel: P, + to: Target

, + }, } -/// Rendering and command adapters can protect a document canvas while leaving panels movable. +/// What may happen to a panel. [`Layout::apply_with_permissions`] enforces these for every +/// action, whoever issues it. They also decide which gestures and menu items the renderer +/// offers; [`super::DockStyle::close_menu_items`] and [`super::DockStyle::close_button`] hide +/// the built-in close controls of a panel that stays closable by command. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Permissions { pub movable: bool, pub floatable: bool, pub closable: bool, + /// Other panels may join this panel's group as tabs. pub accepts_tabs: bool, + /// Other panels may split this panel's frame (`Placement::Split` with a side zone). + /// Outer-edge frames ([`Placement::Edge`]) are not splits of the anchor. + pub accepts_splits: bool, } impl Default for Permissions { fn default() -> Self { - Self { movable: true, floatable: true, closable: true, accepts_tabs: true } + Self { movable: true, floatable: true, closable: true, accepts_tabs: true, accepts_splits: true } } } impl Permissions { - pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false }; + /// A document canvas: it stays docked and open, and takes neither tabs nor side splits. + /// A group dropped on it floats there. An application that opens panels beside its canvas + /// uses `Permissions { accepts_splits: true, ..Permissions::PROTECTED }`. + pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false, accepts_splits: false }; + + /// A canvas: it can neither move, close nor take tabs. `accepts_splits` and `floatable` + /// do not matter, so a canvas that opens panels beside it still counts. + pub fn is_protected(self) -> bool { + !self.movable && !self.closable && !self.accepts_tabs + } + + /// Lock Workspace: nothing moves, floats or closes; tab and split drops keep their policy. + /// [`super::Workspace::apply`] applies this when the workspace is locked; an application + /// with a global lock preference applies it in its own permissions callback. + pub fn locked(self) -> Self { + Self { movable: false, floatable: false, closable: false, ..self } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum DockError { LimitExceeded, DuplicatePanel, @@ -172,6 +530,8 @@ pub enum DockError { InvalidGeometry, InvalidPlacement, NotAllowed, + /// Saved by a newer version of the docking format. + UnsupportedVersion, } impl fmt::Display for DockError { @@ -185,6 +545,7 @@ impl fmt::Display for DockError { Self::InvalidGeometry => "docking geometry is not finite and bounded", Self::InvalidPlacement => "docking destination does not support this placement", Self::NotAllowed => "this panel does not allow the requested docking operation", + Self::UnsupportedVersion => "docking data was saved by a newer version", }) } } @@ -195,6 +556,16 @@ pub(super) fn valid_rect(rect: [f32; 4]) -> bool { rect.iter().all(|v| v.is_finite() && v.abs() <= MAX_EXTENT) && rect.get(2).is_some_and(|v| *v > 0.0) && rect.get(3).is_some_and(|v| *v > 0.0) } +/// A floating window's saved monitor and monitor size, each where given. +fn valid_monitor(monitor: Option<&Monitor>, monitor_size: Option<[f32; 2]>) -> bool { + monitor.is_none_or(|m| valid_rect(m.rect) && m.scale.is_finite() && m.scale > 0.0) + && monitor_size.is_none_or(|[w, h]| valid_rect([0.0, 0.0, w, h])) +} + +pub(super) fn valid_width(width: f32) -> bool { + width.is_finite() && width > 0.0 && width <= MAX_EXTENT +} + fn valid_size(size: SplitSize) -> bool { match size { SplitSize::Ratio(r) => r.is_finite() && (0.0..=1.0).contains(&r), @@ -202,7 +573,7 @@ fn valid_size(size: SplitSize) -> bool { } } -fn valid_height(height: Option) -> bool { +pub(super) fn valid_height(height: Option) -> bool { height.is_none_or(|v| v.is_finite() && (0.0..=MAX_EXTENT).contains(&v)) } @@ -211,7 +582,8 @@ fn valid_active(len: usize, active: usize) -> bool { } impl Layout

{ - /// Check depth, allocation bounds, geometry, selection and globally unique panel ownership. + /// Check depth, allocation bounds, geometry, selection, globally unique panel ownership and + /// that no frame, group, column or window is empty. /// Traversal is iterative, including for an invalid, excessively deep supplied tree. pub fn validate(&self) -> Result<(), DockError> { if self.floating.len() > MAX_PANELS { @@ -219,41 +591,76 @@ impl Layout

{ } let mut seen = HashSet::new(); let mut count = 0usize; - let mut visit = self.root.as_ref().map(|n| vec![(n, 1usize)]).unwrap_or_default(); - while let Some((node, depth)) = visit.pop() { + let mut docks = (false, false); + // Each node carries whether it touches the window's start and end edges. + let mut visit = self.root.as_ref().map(|n| vec![(n, 1usize, true, true)]).unwrap_or_default(); + while let Some((node, depth, start, end)) = visit.pop() { count = count.saturating_add(1); if depth > MAX_DEPTH || count > MAX_NODES { return Err(DockError::LimitExceeded); } match node { - Node::Split { size, first, second, .. } => { + Node::Split { axis, size, first, second } => { if !valid_size(*size) { return Err(DockError::InvalidGeometry); } - visit.push((second, depth + 1)); - visit.push((first, depth + 1)); + let horizontal = *axis == SplitAxis::Horizontal; + visit.push((second, depth + 1, false, end && horizontal)); + visit.push((first, depth + 1, start && horizontal, false)); } - Node::Tabs { panels, active } => { - check_tabs(panels, *active, &mut seen)?; - } - Node::Stack { entries } => { - if entries.len() > MAX_PANELS { + Node::Dock { side, columns } => { + let (at_edge, taken) = match side { + Side::Start => (start, &mut docks.0), + Side::End => (end, &mut docks.1), + }; + if !at_edge || *taken { + return Err(DockError::InvalidPlacement); + } + *taken = true; + if columns.len() > MAX_PANELS { return Err(DockError::LimitExceeded); } - for entry in entries { - if !valid_height(entry.height) { + for column in columns { + if !valid_column(column) || column.groups.is_empty() { return Err(DockError::InvalidGeometry); } - check_panel(&entry.panel, &mut seen)?; + count = count.saturating_add(column.groups.len().saturating_add(1)); + if count > MAX_NODES { + return Err(DockError::LimitExceeded); + } + for group in &column.groups { + check_group(group, &mut seen)?; + } } } + Node::Tabs { .. } | Node::Stack { .. } => check_leaf(node, &mut seen)?, } } for floating in &self.floating { - if !valid_rect(floating.rect) || floating.panels.is_empty() { + if !valid_rect(floating.rect) + || floating.native.is_some_and(|r| !valid_rect(r)) + || !valid_monitor(floating.monitor.as_ref(), floating.monitor_size) + { return Err(DockError::InvalidGeometry); } - check_tabs(&floating.panels, floating.active, &mut seen)?; + let mut visit = vec![(&floating.root, 1usize)]; + while let Some((node, depth)) = visit.pop() { + count = count.saturating_add(1); + if depth > MAX_DEPTH || count > MAX_NODES { + return Err(DockError::LimitExceeded); + } + match node { + Node::Split { size, first, second, .. } => { + if !valid_size(*size) { + return Err(DockError::InvalidGeometry); + } + visit.push((second, depth + 1)); + visit.push((first, depth + 1)); + } + Node::Dock { .. } => return Err(DockError::InvalidPlacement), + Node::Tabs { .. } | Node::Stack { .. } => check_leaf(node, &mut seen)?, + } + } } Ok(()) } @@ -275,10 +682,14 @@ impl Layout

{ } Node::Tabs { panels: group, .. } => panels.extend(group.iter().take(MAX_PANELS - panels.len())), Node::Stack { entries } => panels.extend(entries.iter().map(|e| &e.panel).take(MAX_PANELS - panels.len())), + Node::Dock { columns, .. } => { + let members = columns.iter().flat_map(|c| c.groups.iter()).flat_map(|g| g.panels.iter()); + panels.extend(members.take(MAX_PANELS - panels.len())); + } } } - for group in self.floating.iter().take(MAX_PANELS) { - panels.extend(group.panels.iter().take(MAX_PANELS - panels.len())); + for window in self.floating.iter().take(MAX_PANELS) { + panels.extend(window.panels().take(MAX_PANELS - panels.len())); } panels } @@ -287,19 +698,94 @@ impl Layout

{ self.panels().contains(&panel) } + /// The panels `panel` can join as a tab: every panel in another group whose permissions + /// accept tabs. A "Move Panel to Group" menu with an empty answer has nothing to offer and + /// stays hidden. + pub fn move_targets(&self, panel: &P, permissions: impl Fn(&P) -> Permissions) -> Vec<&P> { + let own = self.place(panel); + self.panels() + .into_iter() + .filter(|target| *target != panel && permissions(target).accepts_tabs && (own.is_none() || self.place(target) != own)) + .collect() + } + /// Capture a panel's nearest surviving group/split neighbor before hiding it. pub fn location(&self, panel: &P) -> Result, DockError> { self.validate()?; - if let Some(group) = self.floating.iter().find(|g| g.panels.contains(panel)) { - let mut location = group_location(&group.panels, panel).ok_or(DockError::MissingPanel)?; - location.floating = Some(group.rect); + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + let (groups, group, column) = match &place { + Place::Node(_) => return self.root.as_ref().and_then(|node| node_location(node, panel)).ok_or(DockError::MissingPanel), + Place::Floating { window, .. } => { + let window = self.floating.get(*window).ok_or(DockError::MissingPanel)?; + let mut location = node_location(&window.root, panel).ok_or(DockError::MissingPanel)?; + location.floating = Some(window.rect); + return Ok(location); + } + Place::Dock { path, group, .. } => { + let side = match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Dock { side, .. }) => *side, + _ => return Err(DockError::MissingPanel), + }; + let column = self.column(&place).ok_or(DockError::MissingPanel)?; + let state = ColumnLocation { side, width: column.width, iconic: column.iconic, iconic_width: column.iconic_width }; + (&column.groups, *group, Some(state)) + } + }; + let own = groups.get(group).ok_or(DockError::MissingPanel)?; + let mut location = group_location(&own.panels, panel).ok_or(DockError::MissingPanel)?; + location.group = Some(GroupLocation { minimized: own.minimized, height: own.height, column }); + if location.anchor.is_some() { return Ok(location); } - self.root.as_ref().and_then(|node| node_location(node, panel)).ok_or(DockError::MissingPanel) + // Alone in its group: anchor to the neighboring group, then the neighboring column. + let first = |g: Option<&Group

>| g.and_then(|g| g.panels.first()).cloned(); + if let Some(anchor) = first(groups.get(group + 1)) { + location.anchor = Some(anchor); + location.placement = Placement::Group { below: false, height: None }; + } else if let Some(anchor) = first(group.checked_sub(1).and_then(|i| groups.get(i))) { + location.anchor = Some(anchor); + location.placement = Placement::Group { below: true, height: None }; + } else if groups.len() == 1 + && let Place::Dock { path, column, .. } = &place + && let Some(Node::Dock { columns, .. }) = self.root.as_ref().and_then(|root| node_at(root, path)) + { + let first_of = |c: Option<&Column

>| first(c.and_then(|c| c.groups.first())); + if let Some(anchor) = first_of(columns.get(column + 1)) { + location.anchor = Some(anchor); + location.placement = Placement::Column { after: false }; + } else if let Some(anchor) = first_of(column.checked_sub(1).and_then(|i| columns.get(i))) { + location.anchor = Some(anchor); + location.placement = Placement::Column { after: true }; + } + } + Ok(location) + } + + /// Hidden, open behind another tab or in a collapsed container, or showing. + pub fn panel_state(&self, panel: &P) -> PanelState { + let showing = match self.place(panel) { + None => return PanelState::Hidden, + Some(place @ (Place::Node(_) | Place::Floating { .. })) => { + let minimized = matches!(place, Place::Floating { window, .. } if self.floating.get(window).is_some_and(|w| w.minimized)); + !minimized + && match self.node_of(&place) { + Some(Node::Tabs { panels, active }) => panels.get(*active) == Some(panel), + Some(Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && e.open), + _ => false, + } + } + Some(place) => { + let iconic = self.column(&place).is_some_and(|c| c.iconic); + self.group(&place).is_some_and(|g| !g.minimized && g.active_panel() == Some(panel)) && !iconic + } + }; + if showing { PanelState::Front } else { PanelState::Open } } /// Restore an absent panel at a captured location. Missing anchors are reported without - /// changes, allowing the app to choose an explicit default. Unrelated tabs retain selection. + /// changes, allowing the app to choose an explicit default; a floating panel whose + /// window is gone floats at its last rect instead. A tab whose next tab is not in the + /// anchor's group goes last. Unrelated tabs retain selection. pub fn restore(&mut self, panel: P, location: &Location

) -> Result<(), DockError> { self.validate()?; if self.contains(&panel) { @@ -308,23 +794,54 @@ impl Layout

{ if location.size.is_some_and(|s| !valid_size(s)) || location.stack.is_some_and(|(_, h)| !valid_height(h)) { return Err(DockError::InvalidGeometry); } - let mut proposed = self.clone(); - if let Some(rect) = location.floating { - if !valid_rect(rect) { - return Err(DockError::InvalidGeometry); + let state = location.group.as_ref(); + if state.is_some_and(|s| { + !valid_height(s.height) || s.column.is_some_and(|c| !valid_width(c.width) || c.iconic_width.is_some_and(|w| !valid_width(w))) + }) { + return Err(DockError::InvalidGeometry); + } + // A restore that creates a group, column or dock gives it the saved state. + let moving = || { + let group = + Group { panels: vec![panel.clone()], active: 0, minimized: state.is_some_and(|s| s.minimized), height: state.and_then(|s| s.height) }; + let column = state.and_then(|s| s.column); + Moving { + width: column.map(|c| c.width), + iconic: column.is_some_and(|c| c.iconic), + iconic_width: column.and_then(|c| c.iconic_width), + ..Moving::group(group) } - if let Some(anchor) = &location.anchor { - if !proposed.floating.iter().any(|g| g.panels.contains(anchor)) { - return Err(DockError::MissingTarget); + }; + let mut proposed = self.clone(); + if location.floating.is_some_and(|rect| !valid_rect(rect)) { + return Err(DockError::InvalidGeometry); + } + let window_open = location.anchor.as_ref().is_some_and(|anchor| proposed.floating.iter().any(|w| w.contains(anchor))); + if let Some(rect) = location.floating.filter(|_| !window_open) { + // A window that closed as a whole reopens at its last rect. + proposed.insert_moving(moving(), Target::Float { rect: Some(rect) })?; + } else if let Some(anchor) = &location.anchor + && matches!(location.placement, Placement::Group { .. } | Placement::Column { .. }) + { + if !proposed.contains(anchor) { + return Err(DockError::MissingTarget); + } + proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; + } else if let Some(column) = state.and_then(|s| s.column).filter(|_| location.anchor.is_none()) { + // Alone in its dock: back at that edge, as the outermost column if a dock exists. + match proposed.dock_path(column.side) { + Some(path) => { + let columns = proposed.columns_mut(&path).ok_or(DockError::MissingTarget)?; + let Moving { groups, .. } = moving(); + columns.insert(0, Column { groups, width: column.width, iconic: column.iconic, iconic_width: column.iconic_width }); } - proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; - } else { - proposed.floating.push(Floating { panels: vec![panel.clone()], active: 0, rect }); + None => proposed.insert_moving(moving(), Target::NewDock { side: column.side })?, } } else if let Some(anchor) = &location.anchor { if !proposed.contains(anchor) { return Err(DockError::MissingTarget); } + let mut around = false; if let Placement::Split(zone @ (Zone::Left | Zone::Right | Zone::Top | Zone::Bottom)) = location.placement && !location.sibling.is_empty() { @@ -335,19 +852,19 @@ impl Layout

{ if members.len() != location.sibling.len() || !members.contains(anchor) { return Err(DockError::InvalidPlacement); } - let inserted = proposed - .root - .as_mut() - .is_some_and(|root| restore_around_subtree(root, &panel, &members, zone, location.size.unwrap_or(SplitSize::Ratio(0.5)))); - if !inserted { - return Err(DockError::MissingTarget); - } - } else { - proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; - if let Some(size) = location.size - && let Some(root) = proposed.root.as_mut() - { - restore_parent_size(root, &panel, size); + let size = location.size.unwrap_or(SplitSize::Ratio(0.5)); + around = proposed.trees_mut().any(|root| restore_around_subtree(root, &panel, &members, zone, size)); + } + // A subtree that changed since (a migrated or reconciled layout) splits the anchor. + if !around { + // The tab that followed may have closed or moved since; the panel then goes last. + let placement = match &location.placement { + Placement::Tab { before: Some(before) } if !proposed.same_group(before, anchor) => Placement::Tab { before: None }, + placement => placement.clone(), + }; + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement })?; + if let Some(size) = location.size { + proposed.trees_mut().any(|root| restore_parent_size(root, &panel, size)); } } } else if proposed.root.is_none() { @@ -355,13 +872,13 @@ impl Layout

{ } else { return Err(DockError::MissingTarget); } - if let Some((open, height)) = location.stack - && let Some(root) = proposed.root.as_mut() - { - restore_stack_kind(root, &panel); - if let Some(entry) = stack_entry_mut(root, &panel) { - entry.open = open; - entry.height = height; + if let Some((open, height)) = location.stack { + for root in proposed.trees_mut() { + restore_stack_kind(root, &panel); + if let Some(entry) = stack_entry_mut(root, &panel) { + entry.open = open; + entry.height = height; + } } } proposed.validate()?; @@ -369,41 +886,135 @@ impl Layout

{ Ok(()) } - pub fn apply(&mut self, action: Action

) -> Result<(), DockError> { + /// Open an absent panel as a frame at the outer edge of the docked area, taking most of it: + /// the last resort for a panel that must stay shown, such as a protected canvas. + pub(super) fn open_at_edge(&mut self, panel: P) -> Result<(), DockError> { + let mut proposed = self.clone(); + if self.root.is_none() { + proposed.root = Some(Node::Tabs { panels: vec![panel], active: 0 }); + } else { + let anchor = self + .panels() + .into_iter() + .find(|p| matches!(self.place(p), Some(Place::Dock { .. } | Place::Node(_)))) + .cloned() + .ok_or(DockError::MissingTarget)?; + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor, placement: Placement::Edge(Zone::Right) })?; + if let Some(root) = proposed.root.as_mut() { + restore_parent_size(root, &panel, SplitSize::Ratio(0.25)); + } + } + proposed.validate()?; + *self = proposed; + Ok(()) + } + + /// [`Self::apply_with_permissions`] with default permissions. + pub fn apply(&mut self, action: Action

) -> Result, DockError> { self.apply_with_permissions(action, |_| Permissions::default()) } - /// Check application policy before making an atomic change. The callback must be stable - /// throughout one call. Registry validation remains with the application, particularly Open. - pub fn apply_with_permissions(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + /// Check application policy before making an atomic change, and return the panels the + /// action closed (see [`Self::closed_by`]) so the application can release their state. + /// The callback must be stable throughout one call. Registry validation remains with the + /// application, particularly Open. + pub fn apply_with_permissions(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { self.validate()?; + let action = match action { + Action::Open { panel, anchor: None } if self.root.is_some() && !self.contains(&panel) => { + let (anchor, placement) = self.open_place(&permissions).ok_or(DockError::MissingTarget)?; + Action::OpenAt { panel, anchor, placement } + } + action => action, + }; match &action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { - if !permissions(panel).movable { + // Opening an absent panel moves nothing, so it works in a locked workspace too. + let opens = matches!(action, Action::OpenAt { .. }) && !self.contains(panel); + if !opens && !permissions(panel).movable { return Err(DockError::NotAllowed); } - let tab = matches!(placement, Placement::Tab { .. } | Placement::Split(Zone::Center)); - if tab && !permissions(anchor).accepts_tabs { + if !placement_allowed(placement, permissions(anchor)) { return Err(DockError::NotAllowed); } } Action::Float { panel, .. } if !permissions(panel).floatable || !permissions(panel).movable => return Err(DockError::NotAllowed), Action::Close { panel } if !permissions(panel).closable => return Err(DockError::NotAllowed), - // The rectangle belongs to the whole floating group, so every member must allow it. + // The rectangle belongs to the whole floating window, so every panel in it must allow it. Action::MoveFloating { panel, .. } if !permissions(panel).movable - || self.floating.iter().find(|g| g.panels.contains(panel)).is_some_and(|g| g.panels.iter().any(|p| !permissions(p).movable)) => + || self.floating.iter().find(|w| w.contains(panel)).is_some_and(|w| w.panels().any(|p| !permissions(p).movable)) => { return Err(DockError::NotAllowed); } Action::Open { anchor: Some(anchor), .. } if !permissions(anchor).accepts_tabs => return Err(DockError::NotAllowed), + Action::MovePanel { panel, to } => { + let policy = permissions(panel); + let allowed = policy.movable + && match to { + Target::Float { .. } => policy.floatable, + Target::At { anchor, placement } => placement_allowed(placement, permissions(anchor)), + Target::NewDock { .. } => true, + }; + if !allowed { + return Err(DockError::NotAllowed); + } + } + Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } | Action::MoveWindow { panel, to } => { + let members = self.members(panel, unit_of(&action))?; + if members.iter().any(|p| !permissions(p).movable) { + return Err(DockError::NotAllowed); + } + match to { + Target::Float { .. } if members.iter().any(|p| !permissions(p).floatable) => return Err(DockError::NotAllowed), + Target::At { anchor, placement } if !members.contains(anchor) && !placement_allowed(placement, permissions(anchor)) => { + return Err(DockError::NotAllowed); + } + _ => {} + } + } + Action::CloseGroup { panel } | Action::CloseWindow { panel } | Action::CloseOthers { panel } => { + let others = matches!(action, Action::CloseOthers { .. }); + let members = self.members(panel, unit_of(&action))?; + if members.iter().filter(|p| !others || *p != panel).any(|p| !permissions(p).closable) { + return Err(DockError::NotAllowed); + } + } + Action::SetNative { panel, rect: Some(_) } if self.members(panel, Unit::Window)?.iter().any(|p| !permissions(p).floatable) => { + return Err(DockError::NotAllowed); + } _ => {} } + let closed = self.closed_by(&action)?; let mut proposed = self.clone(); proposed.apply_inner(action)?; proposed.validate()?; *self = proposed; - Ok(()) + Ok(closed) + } + + /// Apply every action in order, or none: the first rejection leaves the layout unchanged. + /// Returns every panel the actions closed, in order. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { + let mut proposed = self.clone(); + let mut closed = Vec::new(); + for action in actions { + closed.extend(proposed.apply_with_permissions(action, &permissions)?); + } + *self = proposed; + Ok(closed) + } + + /// The panels a close action (`Close`, `CloseGroup`, `CloseWindow`, `CloseOthers`) would + /// remove, in group order. Empty for other actions. The apply methods return the same + /// list; this one answers before applying. + pub fn closed_by(&self, action: &Action

) -> Result, DockError> { + Ok(match action { + Action::Close { panel } => vec![panel.clone()], + Action::CloseGroup { panel } | Action::CloseWindow { panel } => self.members(panel, unit_of(action))?, + Action::CloseOthers { panel } => self.members(panel, Unit::Group)?.into_iter().filter(|p| p != panel).collect(), + _ => Vec::new(), + }) } fn apply_inner(&mut self, action: Action

) -> Result<(), DockError> { @@ -431,20 +1042,29 @@ impl Layout

{ if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } + // A window's only panel keeps the window's id. + let lone = self.floating.iter().find(|w| w.contains(&panel)).filter(|w| w.panels().count() == 1).map(|w| w.id); let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; - self.floating.push(Floating { panels: vec![entry.panel], active: 0, rect }); + let window = Floating::single(vec![entry.panel], 0, rect); + self.floating.push(Floating { id: lone.unwrap_or(window.id), ..window }); Ok(()) } Action::Close { panel } => self.take(&panel).map(|_| ()).ok_or(DockError::MissingPanel), Action::Activate { panel } => { - if self.root.as_mut().is_some_and(|node| activate(node, &panel)) { + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + // Selecting a panel shows it: a minimized group expands. + group.active = group.panels.iter().position(|p| p == &panel).ok_or(DockError::MissingPanel)?; + group.minimized = false; return Ok(()); } - for group in &mut self.floating { - if let Some(i) = group.panels.iter().position(|p| p == &panel) { - group.active = i; - return Ok(()); - } + if let Place::Floating { window, .. } = place + && let Some(window) = self.floating.get_mut(window) + { + window.minimized = false; + } + if self.trees_mut().any(|node| activate(node, &panel)) { + return Ok(()); } Err(DockError::MissingPanel) } @@ -477,8 +1097,8 @@ impl Layout

{ if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } - let group = self.floating.iter_mut().find(|g| g.panels.contains(&panel)).ok_or(DockError::MissingPanel)?; - group.rect = rect; + let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::MissingPanel)?; + window.rect = rect; Ok(()) } Action::ResizeSplit { path, size } => { @@ -488,17 +1108,20 @@ impl Layout

{ if !valid_size(size) { return Err(DockError::InvalidGeometry); } - let mut node = self.root.as_mut().ok_or(DockError::MissingTarget)?; - for second_child in path { - let Node::Split { first, second, .. } = node else { return Err(DockError::MissingTarget) }; - node = if second_child { second } else { first }; + resize_split(self.root.as_mut().ok_or(DockError::MissingTarget)?, &path, size) + } + Action::ResizeWindowSplit { panel, path, size } => { + if path.len() >= MAX_DEPTH { + return Err(DockError::LimitExceeded); } - let Node::Split { size: old, .. } = node else { return Err(DockError::MissingTarget) }; - *old = size; - Ok(()) + if !valid_size(size) { + return Err(DockError::InvalidGeometry); + } + let window = self.floating.iter_mut().find(|w| w.contains(&panel)).ok_or(DockError::MissingPanel)?; + resize_split(&mut window.root, &path, size) } Action::SetStackOpen { panel, open } => { - let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; + let entry = self.trees_mut().find_map(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; entry.open = open; Ok(()) } @@ -506,58 +1129,199 @@ impl Layout

{ if !valid_height(height) { return Err(DockError::InvalidGeometry); } - let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; + let entry = self.trees_mut().find_map(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; entry.height = height; Ok(()) } + Action::MoveGroup { ref panel, ref to } | Action::MoveColumn { ref panel, ref to } | Action::MoveWindow { ref panel, ref to } => { + let unit = unit_of(&action); + if let Target::At { anchor, .. } = to + && self.members(panel, unit)?.contains(anchor) + { + // Dropped on itself. + return Ok(()); + } + let moving = self.take_unit(panel, unit)?; + self.insert_moving(moving, to.clone()) + } + Action::MovePanel { panel, to } => match to { + Target::At { anchor, placement } => self.apply_inner(Action::Move { panel, anchor, placement }), + to => { + let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; + self.insert_moving(Moving::group(Group::new(vec![entry.panel], 0)), to) + } + }, + Action::CloseGroup { ref panel } | Action::CloseWindow { ref panel } => self.take_unit(panel, unit_of(&action)).map(|_| ()), + Action::CloseOthers { panel } => { + for other in self.members(&panel, Unit::Group)? { + if other != panel { + self.take(&other).ok_or(DockError::MissingPanel)?; + } + } + Ok(()) + } + Action::SetMinimized { panel, minimized } => { + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + group.minimized = minimized; + return Ok(()); + } + if let Place::Floating { window, .. } = place { + self.floating.get_mut(window).ok_or(DockError::MissingPanel)?.minimized = minimized; + return Ok(()); + } + let entry = self.trees_mut().find_map(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; + entry.open = !minimized; + Ok(()) + } + Action::ResizeGroup { panel, height } => { + if !valid_height(height) { + return Err(DockError::InvalidGeometry); + } + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + group.height = height; + return Ok(()); + } + let entry = self.trees_mut().find_map(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; + entry.height = height; + Ok(()) + } + Action::SetIconic { panel, iconic } => { + self.column_of(&panel)?.iconic = iconic; + Ok(()) + } + Action::ResizeColumn { panel, width } => { + if !valid_width(width) { + return Err(DockError::InvalidGeometry); + } + self.column_of(&panel)?.width = width; + Ok(()) + } + Action::ResizeIconic { panel, width } => { + if width.is_some_and(|w| !valid_width(w)) { + return Err(DockError::InvalidGeometry); + } + self.column_of(&panel)?.iconic_width = width; + Ok(()) + } + Action::SetNative { panel, rect } => { + if rect.is_some_and(|r| !valid_rect(r)) { + return Err(DockError::InvalidGeometry); + } + let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::InvalidPlacement)?; + window.native = rect; + if rect.is_none() { + window.monitor = None; + window.monitor_size = None; + } + Ok(()) + } + Action::SetMonitor { panel, monitor, monitor_size } => { + if !valid_monitor(monitor.as_ref(), monitor_size) { + return Err(DockError::InvalidGeometry); + } + let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::InvalidPlacement)?; + window.monitor = monitor; + window.monitor_size = monitor_size; + Ok(()) + } } } - fn same_group(&self, panel: &P, anchor: &P) -> bool { - fn in_node(node: &Node

, panel: &P, anchor: &P) -> bool { - match node { - Node::Tabs { panels, .. } => panels.contains(panel) && panels.contains(anchor), - Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel) && entries.iter().any(|e| &e.panel == anchor), - Node::Split { first, second, .. } => in_node(first, panel, anchor) || in_node(second, panel, anchor), - } - } - self.root.as_ref().is_some_and(|n| in_node(n, panel, anchor)) - || self.floating.iter().any(|g| g.panels.contains(panel) && g.panels.contains(anchor)) + fn column_of(&mut self, panel: &P) -> Result<&mut Column

, DockError> { + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + self.column_mut(&place).ok_or(DockError::InvalidPlacement) } - fn take(&mut self, panel: &P) -> Option> { + fn same_group(&self, panel: &P, anchor: &P) -> bool { + let place = self.place(panel); + place.is_some() && place == self.place(anchor) + } + + pub(super) fn take(&mut self, panel: &P) -> Option> { let mut removed = None; self.root = self.root.take().and_then(|n| remove_node(n, panel, &mut removed)); if removed.is_some() { return removed; } - for group in &mut self.floating { - if let Some(p) = remove_tab(&mut group.panels, &mut group.active, panel) { - removed = Some(StackEntry { panel: p, open: true, height: None }); - break; + if let Some(index) = self.floating.iter().position(|w| w.contains(panel)) + && let Some(window) = self.floating.get_mut(index) + { + let root = std::mem::replace(&mut window.root, Node::Tabs { panels: Vec::new(), active: 0 }); + match remove_node(root, panel, &mut removed) { + Some(root) => window.root = root, + None => { + self.floating.remove(index); + } } } - self.floating.retain(|g| !g.panels.is_empty()); removed } + /// Where [`Action::Open`] without an anchor puts an absent panel in a layout with a root: + /// the end of the first shown dock group that accepts tabs, in the end-side dock if one has + /// such a group, else a new frame at the right edge of the docked area. + fn open_place(&self, permissions: impl Fn(&P) -> Permissions) -> Option<(P, Placement

)> { + let docked: Vec<(&P, Place)> = self + .panels() + .into_iter() + .filter_map(|p| Some((p, self.place(p)?))) + .filter(|(_, place)| !matches!(place, Place::Floating { .. })) + .collect(); + let side = |place: &Place| match place { + Place::Dock { path, .. } => match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Dock { side, .. }) => Some(*side), + _ => None, + }, + _ => None, + }; + // A minimized group or an iconic column would leave the panel open but not shown. + let shown = |place: &Place| match place { + Place::Dock { group, .. } => self.column(place).is_some_and(|c| !c.iconic && c.groups.get(*group).is_some_and(|g| !g.minimized)), + _ => false, + }; + let groups = docked.iter().filter(|(p, place)| side(place).is_some() && shown(place) && permissions(p).accepts_tabs); + if let Some((panel, _)) = groups.clone().find(|(_, place)| side(place) == Some(Side::End)).or_else(|| groups.clone().next()) { + return Some(((*panel).clone(), Placement::Tab { before: None })); + } + docked.first().map(|(panel, _)| ((*panel).clone(), Placement::Edge(Zone::Right))) + } + fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { - if let Some(group) = self.floating.iter_mut().find(|g| g.panels.contains(anchor)) { - let index = match placement { - Placement::Tab { before } => before.as_ref().and_then(|p| group.panels.iter().position(|q| q == p)).unwrap_or(group.panels.len()), - Placement::Split(Zone::Center) => group.panels.iter().position(|p| p == anchor).map_or(group.panels.len(), |i| i + 1), - Placement::Split(_) => return Err(DockError::InvalidPlacement), - }; - group.panels.insert(index, entry.panel); - group.active = index; - return Ok(()); - } - if let Some(root) = self.root.as_mut() - && insert_node(root, &entry, anchor, &placement) + let place = self.place(anchor).ok_or(DockError::MissingTarget)?; + let tree = place.tree_path().map(|(window, path)| (window, path.to_vec())); + let Some((window, path)) = tree.filter(|_| !matches!(placement, Placement::Group { .. } | Placement::Column { .. } | Placement::Edge(_))) + else { + let group = Group::new(vec![entry.panel], 0); + return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }); + }; + let root = self.tree_mut(window).ok_or(DockError::MissingTarget)?; + if let Placement::Split(zone) = placement + && zone != Zone::Center { - return Ok(()); + let new = Node::Tabs { panels: vec![entry.panel], active: 0 }; + return if split_frame(root, &path, new, zone) { Ok(()) } else { Err(DockError::MissingTarget) }; } - Err(DockError::MissingTarget) + if insert_node(root, &entry, anchor, &placement) { Ok(()) } else { Err(DockError::MissingTarget) } + } +} + +/// Whether the anchor's permissions take this placement: tabs need `accepts_tabs`, side +/// splits `accepts_splits`. +fn placement_allowed

(placement: &Placement

, anchor: Permissions) -> bool { + match placement { + Placement::Tab { .. } | Placement::Split(Zone::Center) => anchor.accepts_tabs, + Placement::Split(_) => anchor.accepts_splits, + _ => true, + } +} + +fn unit_of

(action: &Action

) -> Unit { + match action { + Action::MoveColumn { .. } => Unit::Column, + Action::MoveWindow { .. } | Action::CloseWindow { .. } => Unit::Window, + _ => Unit::Group, } } @@ -571,6 +1335,41 @@ fn check_panel<'a, P: Eq + Hash>(panel: &'a P, seen: &mut HashSet<&'a P>) -> Res Ok(()) } +/// A `Tabs` or `Stack` node: not empty, a valid selection and heights, panels not seen before. +fn check_leaf<'a, P: Eq + Hash>(node: &'a Node

, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { + match node { + Node::Tabs { panels, active } => { + if panels.is_empty() { + return Err(DockError::InvalidGeometry); + } + check_tabs(panels, *active, seen) + } + Node::Stack { entries } => { + if entries.is_empty() { + return Err(DockError::InvalidGeometry); + } + if entries.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + for entry in entries { + if !valid_height(entry.height) { + return Err(DockError::InvalidGeometry); + } + check_panel(&entry.panel, seen)?; + } + Ok(()) + } + Node::Split { .. } | Node::Dock { .. } => Ok(()), + } +} + +fn check_group<'a, P: Eq + Hash>(group: &'a Group

, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { + if group.panels.is_empty() || !valid_height(group.height) { + return Err(DockError::InvalidGeometry); + } + check_tabs(&group.panels, group.active, seen) +} + fn check_tabs<'a, P: Eq + Hash>(panels: &'a [P], active: usize, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { if panels.len() > MAX_PANELS { return Err(DockError::LimitExceeded); @@ -614,9 +1413,33 @@ fn remove_node(node: Node

, panel: &P, removed: &mut Option node, } } + Node::Dock { side, mut columns } => { + // A dock that was already empty stays; one emptied by this removal collapses. + let had_columns = !columns.is_empty(); + for column in &mut columns { + for group in &mut column.groups { + if let Some(panel) = remove_tab(&mut group.panels, &mut group.active, panel) { + *removed = Some(StackEntry { panel, open: true, height: None }); + } + } + column.groups.retain(|g| !g.panels.is_empty()); + } + columns.retain(|c| !c.groups.is_empty()); + (!had_columns || !columns.is_empty()).then_some(Node::Dock { side, columns }) + } } } +fn resize_split

(mut node: &mut Node

, path: &[bool], size: SplitSize) -> Result<(), DockError> { + for second_child in path { + let Node::Split { first, second, .. } = node else { return Err(DockError::MissingTarget) }; + node = if *second_child { second } else { first }; + } + let Node::Split { size: old, .. } = node else { return Err(DockError::MissingTarget) }; + *old = size; + Ok(()) +} + fn activate(node: &mut Node

, panel: &P) -> bool { match node { Node::Tabs { panels, active } => { @@ -636,6 +1459,7 @@ fn activate(node: &mut Node

, panel: &P) -> bool { } } Node::Split { first, second, .. } => activate(first, panel) || activate(second, panel), + Node::Dock { .. } => false, } } @@ -643,7 +1467,7 @@ fn stack_entry_mut<'a, P: Eq>(node: &'a mut Node

, panel: &P) -> Option<&'a mu match node { Node::Stack { entries } => entries.iter_mut().find(|e| &e.panel == panel), Node::Split { first, second, .. } => stack_entry_mut(first, panel).or_else(|| stack_entry_mut(second, panel)), - Node::Tabs { .. } => None, + Node::Tabs { .. } | Node::Dock { .. } => None, } } @@ -652,23 +1476,16 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: Node::Split { first, second, .. } => return insert_node(first, entry, anchor, placement) || insert_node(second, entry, anchor, placement), Node::Tabs { panels, .. } => panels.contains(anchor), Node::Stack { entries } => entries.iter().any(|e| &e.panel == anchor), + Node::Dock { .. } => false, }; if !here { return false; } - if let Placement::Split(zone @ (Zone::Left | Zone::Right | Zone::Top | Zone::Bottom)) = placement { - let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); - let new = Node::Tabs { panels: vec![entry.panel.clone()], active: 0 }; - let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; - let (first, second) = if matches!(zone, Zone::Top | Zone::Left) { (new, old) } else { (old, new) }; - *node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; - return true; - } match node { Node::Tabs { panels, active } => { let index = match placement { Placement::Tab { before } => before.as_ref().and_then(|p| panels.iter().position(|q| q == p)).unwrap_or(panels.len()), - Placement::Split(_) => panels.iter().position(|p| p == anchor).map_or(panels.len(), |i| i + 1), + _ => panels.iter().position(|p| p == anchor).map_or(panels.len(), |i| i + 1), }; panels.insert(index, entry.panel.clone()); *active = index; @@ -676,11 +1493,11 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: Node::Stack { entries } => { let index = match placement { Placement::Tab { before } => before.as_ref().and_then(|p| entries.iter().position(|e| &e.panel == p)).unwrap_or(entries.len()), - Placement::Split(_) => entries.iter().position(|e| &e.panel == anchor).map_or(entries.len(), |i| i + 1), + _ => entries.iter().position(|e| &e.panel == anchor).map_or(entries.len(), |i| i + 1), }; entries.insert(index, entry.clone()); } - Node::Split { .. } => return false, + Node::Split { .. } | Node::Dock { .. } => return false, } true } @@ -689,7 +1506,7 @@ fn group_location(panels: &[P], panel: &P) -> Option> let index = panels.iter().position(|p| p == panel)?; let anchor = panels.iter().find(|p| *p != panel).cloned(); let before = panels.get(index + 1).cloned(); - Some(Location { floating: None, anchor, placement: Placement::Tab { before }, size: None, stack: None, sibling: Vec::new() }) + Some(Location { floating: None, anchor, placement: Placement::Tab { before }, size: None, stack: None, sibling: Vec::new(), group: None }) } fn first_panel

(node: &Node

) -> Option<&P> { @@ -697,6 +1514,7 @@ fn first_panel

(node: &Node

) -> Option<&P> { Node::Tabs { panels, .. } => panels.first(), Node::Stack { entries } => entries.first().map(|e| &e.panel), Node::Split { first, second, .. } => first_panel(first).or_else(|| first_panel(second)), + Node::Dock { columns, .. } => columns.iter().flat_map(|c| c.groups.iter()).find_map(|g| g.panels.first()), } } @@ -733,6 +1551,7 @@ fn node_location(node: &Node

, panel: &P) -> Option } Some(location) } + Node::Dock { .. } => None, } } @@ -773,6 +1592,7 @@ fn node_members<'a, P>(node: &'a Node

, members: &mut Vec<&'a P>) { node_members(first, members); node_members(second, members); } + Node::Dock { columns, .. } => members.extend(columns.iter().flat_map(|c| c.groups.iter()).flat_map(|g| g.panels.iter())), } } diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs new file mode 100644 index 00000000..9327fd4d --- /dev/null +++ b/crates/ui/src/docking/model_tests.rs @@ -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 { + Group::new(panels.to_vec(), active) +} + +fn tabs(panels: &[u32], active: usize) -> Node { + Node::Tabs { panels: panels.to_vec(), active } +} + +fn hsplit(size: SplitSize, first: Node, second: Node) -> Node { + 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 { + 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) -> Vec { + let mut panels: Vec = 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, panel: u32) -> (Vec, 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, side: Side) -> &Vec> { + 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]) -> Vec> { + 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::>(), [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::>(), [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, 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![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::>(), 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 = 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>| 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, 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 = 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| 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 = 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| 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::>(), [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::>(), [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 = 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 = 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::>(value).unwrap(), window); + } + assert!(serde_json::from_value::>(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 = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed, workspace); + assert_eq!(serde_json::from_str::(&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::>(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> = serde_json::from_value(value).unwrap(); + assert!(!hidden.is_empty(), "{app}{pointer}"); + continue; + } + }; + if let Workspace = kind { + let workspace: super::Workspace = 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 = 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 = 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); +} diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs new file mode 100644 index 00000000..3c18c37e --- /dev/null +++ b/crates/ui/src/docking/native_tests.rs @@ -0,0 +1,1851 @@ +//! Floating windows in their own OS windows (feature `native-windows`), drawn by the driver's +//! stand-in for OS windows. + +use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; + +use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands, wayland}; +use super::testing::DockDriver; +use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target, WindowKind, Workspace}; +use crate::layout::{SplitAxis, SplitSize}; + +/// Desktop position of the main window's content. +const ORIGIN: Pos2 = pos2(100.0, 50.0); + +#[derive(Default)] +struct Content { + /// Panels that may not close. + fixed: Vec, + /// Panels that stay in-window. + in_window: Vec, + /// Viewer panels: their windows are normal windows. + normal: Vec, + /// The panel with a text field, what it holds, and whether it takes focus next frame. + text: Option, + typed: String, + focus_text: bool, + /// The panel with a button that takes focus. + focused_button: Option, + /// The panel with a counter button, its clicks, and where it was drawn. + counter: Option, + count: usize, + counter_rect: Option, + /// Pointer buttons pressed in each panel's body. + pressed: Vec<(u32, PointerButton)>, + /// A panel body saw hovered or dropped files. + saw_files: bool, + /// Input visible to a selected panel, including native control events while blocked. + input_probe: Option, + events: Vec, + raw_events: Vec, +} + +impl DockContent for Content { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + ui.label(format!("panel {panel}")); + if self.input_probe == Some(*panel) { + (self.events, self.raw_events) = ui.input(|i| (i.events.clone(), i.raw.events.clone())); + } + if self.text == Some(*panel) { + let field = ui.text_edit_singleline(&mut self.typed); + if std::mem::take(&mut self.focus_text) { + field.request_focus(); + } + } + if self.focused_button == Some(*panel) { + ui.button("OK").request_focus(); + } + if self.counter == Some(*panel) { + let response = ui.button("Add"); + self.count += usize::from(response.clicked()); + self.counter_rect = Some(response.rect); + } + self.saw_files |= ui.input(|i| !i.raw.hovered_files.is_empty() || !i.raw.dropped_files.is_empty()); + if ui.rect_contains_pointer(ui.max_rect()) { + let pressed = [PointerButton::Primary, PointerButton::Secondary].into_iter().filter(|b| ui.input(|i| i.pointer.button_pressed(*b))); + self.pressed.extend(pressed.map(|b| (*panel, b))); + } + } + + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + + fn permissions(&self, panel: &u32) -> Permissions { + Permissions { closable: !self.fixed.contains(panel), ..Permissions::default() } + } + + fn allow_native(&self, panel: &u32) -> bool { + !self.in_window.contains(panel) + } + + fn window_kind(&self, panel: &u32) -> WindowKind { + if self.normal.contains(panel) { WindowKind::Normal } else { WindowKind::Palette } + } +} + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +/// Frames `[1, 2]` and `[3, 4]`, and window `[5, 6]` when `floating`. +fn layout(floating: bool) -> 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: if floating { vec![Floating::single(vec![5, 6], 0, [300.0, 80.0, 240.0, 300.0])] } else { Vec::new() }, + } +} + +fn driver(layout: Layout) -> DockDriver { + DockDriver::new(layout, Content::default()).with_style(|s| s.command_press_floats = true).with_native_host(ORIGIN) +} + +#[allow(clippy::expect_used)] +fn window(d: &DockDriver, panel: u32) -> &Floating { + d.layout.floating.iter().find(|w| w.contains(&panel)).expect("floating") +} + +/// The window's OS window rect, and how many frames it drew. +fn os_window(d: &DockDriver, panel: u32) -> Option<(Rect, usize)> { + let viewport = d.viewport(window(d, panel).id); + d.host().windows.get(&viewport).map(|w| (w.rect, w.frames)) +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn every_float_opens_in_its_own_os_window() { + // Undock Panel. + let mut d = driver(layout(false)); + d.layout.apply(Action::Float { panel: 2, rect: [200.0, 100.0, 300.0, 200.0] }).unwrap(); + let out = d.step(vec![]); + assert!(out.actions.contains(&Action::SetNative { panel: 2, rect: Some([300.0, 150.0, 300.0, 200.0]) }), "{:?}", out.actions); + assert_eq!(window(&d, 2).native, Some([300.0, 150.0, 300.0, 200.0])); + assert_eq!(os_window(&d, 2).map(|(rect, _)| rect), Some(Rect::from_min_size(pos2(300.0, 150.0), vec2(300.0, 200.0)))); + assert!(d.hit(&Hit::Title(2)).is_none(), "not drawn in the main window"); + + // Undock Panel Group. + let mut d = driver(layout(false)); + d.layout.apply(Action::MoveGroup { panel: 3, to: Target::Float { rect: Some([20.0, 30.0, 250.0, 200.0]) } }).unwrap(); + d.step(vec![]); + assert_eq!(window(&d, 3).native, Some([120.0, 80.0, 250.0, 200.0])); + assert!(os_window(&d, 4).is_some()); + + // Ctrl/Cmd-drag. + let mut d = driver(layout(false)); + let (tab, to) = (d.hit(&Hit::Tab(1)).unwrap().center(), pos2(600.0, 400.0)); + d.modifiers = Modifiers::COMMAND; + d.hold(tab, to); + d.modifiers = Modifiers::NONE; + let out = d.release(to); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 1, .. }]), "{:?}", out.actions); + d.step(vec![]); + let [x, y, ..] = window(&d, 1).rect; + assert_eq!(window(&d, 1).native.map(|r| [r[0], r[1]]), Some([x + ORIGIN.x, y + ORIGIN.y])); + assert!(os_window(&d, 1).is_some()); + + // A restored layout. + let mut d = driver(layout(true)); + d.settle(); + assert_eq!(window(&d, 5).native, Some([400.0, 130.0, 240.0, 300.0])); + assert!(os_window(&d, 5).is_some_and(|(_, frames)| frames > 0)); + + // A panel that must stay in-window keeps its window there. + let mut d = driver(layout(false)); + d.content.in_window = vec![5, 6]; + d.layout = layout(true); + d.settle(); + assert_eq!(window(&d, 5).native, None); + assert!(d.hit(&Hit::Title(5)).is_some()); + assert!(d.host().windows.is_empty()); +} + +#[test] +fn closing_the_os_window_closes_its_panels() { + let mut d = driver(layout(true)); + let viewport = d.viewport(window(&d, 5).id); + d.host().windows.get_mut(&viewport).unwrap().close = true; + let out = d.step(vec![]); + assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); + assert!(!d.layout.contains(&5) && !d.layout.contains(&6)); + let frames = d.host().windows[&viewport].frames; + d.settle(); + assert_eq!(d.host().windows[&viewport].frames, frames, "no longer drawn"); + + // The bar's × does the same. + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let out = d.window_click(id, pos2(230.0, 9.0)); + assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); +} + +#[test] +fn the_os_close_of_a_window_that_may_not_close_is_cancelled() { + // A panel that may not close, or a locked area. + for locked in [false, true] { + let mut d = driver(layout(true)); + if locked { + d.locked = true; + } else { + d.content.fixed = vec![6]; + } + let viewport = d.viewport(window(&d, 5).id); + let sent = d.host().commands.len(); + d.host().windows.get_mut(&viewport).unwrap().close = true; + let out = d.step(vec![]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::CloseWindow { .. })), "locked {locked}: {:?}", out.actions); + assert!(out.error.is_none(), "locked {locked}: {:?}", out.error); + assert!(d.host().commands[sent..].contains(&(viewport, ViewportCommand::CancelClose)), "locked {locked}: cancelled"); + assert!(d.layout.contains(&5) && d.layout.contains(&6)); + } +} + +#[test] +fn a_window_keeps_its_id_and_os_window_when_its_first_panel_moves_out() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let (_, frames) = os_window(&d, 6).unwrap(); + d.layout.apply(Action::Move { panel: 5, anchor: 1, placement: Placement::Tab { before: None } }).unwrap(); + d.settle(); + assert_eq!(window(&d, 6).id, id); + assert_eq!(d.host().windows.len(), 1, "the same OS window"); + assert!(os_window(&d, 6).unwrap().1 > frames); + + // Moving a whole window keeps its id; merging into another keeps the receiver's. + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [10.0, 10.0, 200.0, 200.0])); + let (first, second) = (layout.floating[0].id, layout.floating[1].id); + assert_ne!(first, second); + layout.apply(Action::MoveWindow { panel: 5, to: Target::Float { rect: Some([0.0, 0.0, 300.0, 300.0]) } }).unwrap(); + assert_eq!(layout.floating.iter().find(|w| w.contains(&5)).unwrap().id, first); + layout.apply(Action::Float { panel: 7, rect: [50.0, 50.0, 200.0, 200.0] }).unwrap(); + assert_eq!(layout.floating.iter().find(|w| w.contains(&7)).unwrap().id, second); + layout.apply(Action::MoveWindow { panel: 7, to: Target::At { anchor: 5, placement: Placement::Tab { before: None } } }).unwrap(); + assert_eq!(layout.floating.len(), 1); + assert_eq!(layout.floating[0].id, first); + // A group split out of a window makes a new one. + layout.apply(Action::MovePanel { panel: 7, to: Target::Float { rect: Some([0.0, 0.0, 200.0, 200.0]) } }).unwrap(); + let ids: Vec = layout.floating.iter().map(|w| w.id).collect(); + assert!(ids.contains(&first) && ids.len() == 2 && ids[0] != ids[1], "{ids:?}"); +} + +#[test] +fn a_moved_window_reports_its_place_and_monitor() { + let mut d = driver(layout(true)); + d.monitors = vec![Monitor { name: Some("left".into()), rect: [0.0, 0.0, 1920.0, 1080.0], scale: 1.0 }]; + let viewport = d.viewport(window(&d, 5).id); + d.host().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(pos2(700.0, 200.0), vec2(240.0, 300.0)); + let out = d.step(vec![]); + assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: Some([700.0, 200.0, 240.0, 300.0]) }), "{:?}", out.actions); + assert_eq!(window(&d, 5).rect, [600.0, 150.0, 240.0, 300.0], "the in-window rect follows"); + assert_eq!(window(&d, 5).monitor.as_ref().and_then(|m| m.name.as_deref()), Some("left")); + + // Without a monitor list, the size egui reports. + let mut d = driver(layout(true)); + d.host().monitor_size = Some(vec2(2560.0, 1440.0)); + d.settle(); + assert_eq!((window(&d, 5).monitor.clone(), window(&d, 5).monitor_size), (None, Some([2560.0, 1440.0]))); +} + +#[test] +fn a_restored_window_opens_on_a_connected_monitor() { + let left = Monitor { name: Some("left".into()), rect: [0.0, 0.0, 1920.0, 1080.0], scale: 1.0 }; + let right = Monitor { name: Some("right".into()), rect: [1920.0, 0.0, 2560.0, 1440.0], scale: 2.0 }; + let mut saved = layout(true); + saved.floating[0].native = Some([3000.0, 100.0, 240.0, 300.0]); + saved.floating[0].monitor = Some(right.clone()); + + // Its monitor is connected: where it was. + let mut d = DockDriver::new(Layout::default(), Content::default()).with_native_host(ORIGIN); + d.monitors = vec![left.clone(), right]; + d.layout = saved.clone(); + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0.min, pos2(3000.0, 100.0)); + + // Unplugged: the main window's monitor, at the same offset. + let mut d = DockDriver::new(Layout::default(), Content::default()).with_native_host(ORIGIN); + d.monitors = vec![left]; + d.layout = saved; + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0.min, pos2(1080.0, 100.0)); + assert_eq!(window(&d, 5).native, Some([1080.0, 100.0, 240.0, 300.0])); + assert_eq!(window(&d, 5).monitor.as_ref().and_then(|m| m.name.as_deref()), Some("left")); +} + +#[test] +fn a_locked_area_moves_no_native_window_and_records_where_the_os_put_it() { + let mut d = driver(layout(true)); + d.locked = true; + let id = window(&d, 5).id; + let viewport = d.viewport(id); + let sent = d.host().commands.len(); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + let out = d.window_step(id, vec![Event::PointerMoved(pos2(70.0, 9.0)), button(pos2(70.0, 9.0), false)]); + assert!(out.actions.is_empty(), "{:?}", out.actions); + let moves = d.host().commands[sent..] + .iter() + .filter(|(v, c)| *v == viewport && matches!(c, ViewportCommand::OuterPosition(_) | ViewportCommand::StartDrag)) + .count(); + assert_eq!(moves, 0, "the bar is inert"); + + // The window system moves the window anyway: the locked workspace takes its new place. + let mut workspace = Workspace { locked: true, ..Workspace::new(d.layout.clone()) }; + d.host().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(pos2(700.0, 200.0), vec2(240.0, 300.0)); + for _ in 0..60 { + let out = d.step(vec![]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveFloating { .. })), "{:?}", out.actions); + workspace.apply_all(out.actions, |_| Permissions::default()).unwrap(); + } + assert_eq!(workspace.layout.floating[0].native, Some([700.0, 200.0, 240.0, 300.0])); +} + +#[test] +fn dragging_the_bar_moves_the_os_window() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + // Each move is relative to where the window was last moved. + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + d.window_step(id, vec![Event::PointerMoved(pos2(70.0, 9.0))]); + let moves: Vec = d + .host() + .commands + .iter() + .filter_map(|(v, c)| match c { + ViewportCommand::OuterPosition(pos) if *v == viewport => Some(*pos), + _ => None, + }) + .collect(); + assert_eq!(moves.last(), Some(&pos2(450.0, 140.0)), "{moves:?}"); + // Past the main window's right edge, the desktop: released there, it stays. + d.window_step(id, vec![Event::PointerMoved(pos2(540.0, 9.0))]); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert!(out.actions.iter().all(|a| matches!(a, Action::SetNative { .. } | Action::MoveFloating { .. })), "{:?}", out.actions); + d.step(vec![]); + assert_eq!(window(&d, 5).native, Some([950.0, 140.0, 240.0, 300.0])); + + // Without a reported position (Wayland) the window system moves it. + let mut d = driver(layout(true)); + d.host().no_position = true; + d.step(vec![]); + d.window_step(id_of(&d), vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id_of(&d), vec![Event::PointerMoved(pos2(60.0, 19.0))]); + assert!(d.host().commands.iter().any(|(_, c)| *c == ViewportCommand::StartDrag)); +} + +fn id_of(d: &DockDriver) -> u64 { + window(d, 5).id +} + +#[test] +fn floating_windows_hide_while_the_app_is_inactive() { + assert!(floats_shown(true, 10.0, 10.0)); + assert!(floats_shown(false, 10.2, 10.0)); + assert!(!floats_shown(false, 10.3, 10.0)); + + let mut d = driver(layout(true)); + let viewport = d.viewport(window(&d, 5).id); + let hides = + |d: &DockDriver| d.host().commands.iter().filter(|(v, c)| *v == viewport && *c == ViewportCommand::Visible(false)).count(); + // Focus moving to the floating window keeps it. + d.host().focus = None; + d.step(vec![]); + d.host().focus = Some(viewport); + for _ in 0..30 { + d.step(vec![]); + } + assert_eq!(hides(&d), 0); + // Another application: hidden after the delay, shown again on return. + d.host().focus = None; + for _ in 0..10 { + d.step(vec![]); + } + assert_eq!(hides(&d), 0, "not yet"); + for _ in 0..10 { + d.step(vec![]); + } + assert_eq!(hides(&d), 1); + assert!(!d.host().windows[&viewport].visible); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + assert!(d.host().windows[&viewport].visible); +} + +#[test] +fn viewer_windows_stay_shown_at_normal_level() { + let mut layout = layout(false); + layout.floating = vec![ + Floating::single(vec![5], 0, [300.0, 80.0, 240.0, 300.0]), + Floating::single(vec![6], 0, [560.0, 80.0, 240.0, 300.0]), + // A palette in front of a viewer's tab: the viewer makes the window normal. + Floating::single(vec![7, 8], 0, [820.0, 80.0, 240.0, 300.0]), + ]; + let mut d = driver(layout); + d.content.normal = vec![5, 8]; + d.step(vec![]); + let [viewer, palette, mixed] = [5, 6, 7].map(|p| d.viewport(window(&d, p).id)); + let level = |d: &DockDriver, v| d.host().windows[&v].level; + assert_eq!(level(&d, viewer), Some(egui::WindowLevel::Normal)); + assert_eq!(level(&d, palette), Some(egui::WindowLevel::AlwaysOnTop)); + assert_eq!(level(&d, mixed), Some(egui::WindowLevel::Normal)); + // Another application: only the palette hides, and the others get no visibility command. + d.host().focus = None; + for _ in 0..20 { + d.step(vec![]); + } + let shown = |d: &DockDriver| [viewer, palette, mixed].map(|v| d.host().windows[&v].visible); + assert_eq!(shown(&d), [true, false, true]); + let told = |d: &DockDriver, v| d.host().commands.iter().any(|(w, c)| *w == v && matches!(c, ViewportCommand::Visible(_))); + assert!(!told(&d, viewer) && !told(&d, mixed)); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + assert_eq!(shown(&d), [true, true, true]); +} + +#[test] +fn the_app_comes_forward_with_its_viewers_when_it_is_activated() { + use egui::{ViewportId, WindowLevel}; + let start = || { + let mut layout = layout(false); + layout.floating = vec![ + Floating::single(vec![5], 0, [300.0, 80.0, 240.0, 300.0]), + Floating::single(vec![6], 0, [560.0, 80.0, 240.0, 300.0]), + Floating::single(vec![7], 0, [820.0, 80.0, 240.0, 300.0]), + ]; + let mut d = driver(layout); + d.content.normal = vec![5, 7]; + d.step(vec![]); + let [back, palette, front] = [5, 6, 7].map(|p| d.viewport(window(&d, p).id)); + (d, back, palette, front) + }; + let focus = |d: &mut DockDriver, window: Option, frames: usize| { + d.host().focus = window; + for _ in 0..frames { + d.step(vec![]); + } + }; + let levels = |d: &mut DockDriver| -> Vec<(ViewportId, WindowLevel)> { + let mut host = d.host(); + assert!(!host.commands.iter().any(|(_, c)| matches!(c, ViewportCommand::Focus)), "no window takes focus"); + let levels = host.commands.iter().filter_map(|(v, c)| if let ViewportCommand::WindowLevel(l) = c { Some((*v, *l)) } else { None }).collect(); + host.commands.clear(); + levels + }; + // Each window once, in this order: the main window's commands are the root's. + let raised = |order: &[ViewportId]| -> Vec<(ViewportId, WindowLevel)> { + if cfg!(target_os = "macos") { + return Vec::new(); + } + order.iter().flat_map(|v| [(*v, WindowLevel::AlwaysOnTop), (*v, WindowLevel::Normal)]).collect() + }; + const MAIN: ViewportId = ViewportId::ROOT; + + let (mut d, back, palette, front) = start(); + // Focus moving between the app's own windows, with a frame of none between two, raises + // nothing. + focus(&mut d, Some(back), 2); + focus(&mut d, None, 1); + focus(&mut d, Some(front), 2); + focus(&mut d, Some(MAIN), 2); + assert_eq!(levels(&mut d), []); + // Another application, then back through the main window: the viewers are raised back to + // front and the main window last, without focus; the palette only shows again. + focus(&mut d, None, 20); + focus(&mut d, Some(MAIN), 10); + assert_eq!(levels(&mut d), raised(&[back, front, MAIN])); + assert!(d.host().windows[&palette].visible); + // Back through the back viewer: it is raised last, so it stays in front, and the main + // window comes forward behind it. + focus(&mut d, None, 20); + focus(&mut d, Some(back), 10); + assert_eq!(levels(&mut d), raised(&[front, MAIN, back])); + // A queued viewer that turns into a palette before its turn is not raised: the raise + // would leave it at the normal level. + focus(&mut d, None, 20); + focus(&mut d, Some(MAIN), 1); + d.content.normal = vec![7]; + focus(&mut d, Some(MAIN), 10); + assert_eq!(levels(&mut d), raised(&[front, MAIN])); + + // A press in the main window brings it in front of the viewers that opened over it, though + // it kept focus. + let (mut d, back, _, front) = start(); + d.click(pos2(100.0, 300.0)); + levels(&mut d); + focus(&mut d, None, 20); + focus(&mut d, Some(back), 10); + assert_eq!(levels(&mut d), raised(&[front, MAIN, back])); + + // While a modal blocks input, the main window with the modal is raised last, even when + // the app is activated through a viewer. + d.input_blocked = true; + focus(&mut d, None, 20); + focus(&mut d, Some(front), 10); + assert_eq!(levels(&mut d), raised(&[back, front, MAIN])); +} + +#[test] +fn unused_keys_in_a_native_window_reach_the_app() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let key = Event::Key { key: Key::S, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + d.modifiers = Modifiers::COMMAND; + let out = d.window_step(id, vec![key.clone(), Event::Text("s".into()), Event::Copy, Event::PointerMoved(pos2(50.0, 50.0))]); + d.modifiers = Modifiers::NONE; + assert_eq!(out.forwarded_events, vec![key, Event::Text("s".into()), Event::Copy]); + assert_eq!(out.forwarded_modifiers, Some(Modifiers::COMMAND)); + // Keys of the main window stay its own. + let out = d.key(Key::A); + assert!(out.forwarded_events.is_empty()); +} + +#[cfg(feature = "serde")] +#[test] +fn a_native_window_saves_its_id_place_and_monitor() { + let mut layout = layout(true); + let monitor = Monitor { name: Some("DP-1".into()), rect: [-1920.0, 0.0, 1920.0, 1080.0], scale: 1.5 }; + layout.floating[0].native = Some([-1500.0, 100.0, 240.0, 300.0]); + layout.floating[0].monitor = Some(monitor); + layout.floating.push(Floating { + monitor_size: Some([2560.0, 1440.0]), + native: Some([5.0, 5.0, 300.0, 200.0]), + ..Floating::single(vec![7], 0, [0.0, 0.0, 300.0, 200.0]) + }); + let json = serde_json::to_string(&layout).unwrap(); + let back: Layout = serde_json::from_str(&json).unwrap(); + assert_eq!(back, layout); + assert_eq!(back.floating.iter().map(|w| w.id).collect::>(), layout.floating.iter().map(|w| w.id).collect::>()); + + // Old layouts get fresh ids, and copies of one id unique ones. + let old = r#"{"root": null, "floating": [{"panels": [5], "active": 0, "rect": [0, 0, 100, 100]}, {"panels": [6], "active": 0, "rect": [0, 0, 100, 100]}]}"#; + let old: Layout = serde_json::from_str(old).unwrap(); + assert_ne!(old.floating[0].id, old.floating[1].id); + let mut copies = layout.clone(); + copies.floating[1].id = copies.floating[0].id; + let copies: Layout = serde_json::from_str(&serde_json::to_string(&copies).unwrap()).unwrap(); + assert_eq!(copies.floating[0].id, layout.floating[0].id); + assert_ne!(copies.floating[1].id, copies.floating[0].id); + // An in-window window writes no id, as older builds did. + assert!(!serde_json::to_string(&Floating::single(vec![1u32], 0, [0.0, 0.0, 9.0, 9.0])).unwrap().contains("id")); +} + +/// Height of the bar at the top of a native window. +const BAR: f32 = 18.0; +/// A tab row's middle in a native window, below the bar. +const TAB_ROW: f32 = BAR + 13.0; + +/// Window `[5, 6]` of [`layout`] and window `[7]` below it, apart on the desktop. +fn two_windows() -> Layout { + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [20.0, 420.0, 240.0, 150.0])); + layout +} + +fn in_root(d: &DockDriver, panel: u32) -> bool { + d.layout.contains(&panel) && !d.layout.floating.iter().any(|w| w.contains(&panel)) +} + +#[test] +fn a_tab_dragged_past_the_main_windows_edge_floats_at_the_desktop_point() { + let mut d = driver(layout(false)); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + // Left of the main window, which starts at x = 100 on the desktop. + let out = pos2(-60.0, 120.0); + d.hold(tab, out); + // The pointer leaves the window: with native windows that is no release. + let gone = d.leave(); + assert!(gone.actions.is_empty(), "{:?}", gone.actions); + let released = d.release(out); + assert!(matches!(released.actions.as_slice(), [Action::Float { panel: 1, .. }]), "{:?}", released.actions); + d.step(vec![]); + // The point grabbed (where the drag began, past the nudge) stays under the pointer: the + // tab's group starts at the window's top-left, below the bar. + let (desktop, grab) = (ORIGIN + out.to_vec2(), tab + vec2(8.0, 6.0)); + let min = pos2(desktop.x - grab.x, desktop.y - grab.y - BAR); + let native = window(&d, 1).native.unwrap(); + assert_eq!(pos2(native[0], native[1]), min); + assert_eq!(os_window(&d, 1).unwrap().0.min, min); +} + +#[test] +fn a_tab_dragged_out_and_back_docks_in_the_main_window() { + let mut d = driver(layout(false)); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + d.hold(tab, pos2(-60.0, 120.0)); + d.leave(); + // Back over the middle of frame [3, 4]. + let back = pos2(600.0, 300.0); + d.move_to(back); + let out = d.release(back); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 3 | 4, .. }]), "{:?}", out.actions); + assert!(d.layout.floating.is_empty()); +} + +#[test] +fn a_tab_from_a_native_window_docks_in_a_frame_of_the_main_window() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + // Frame [1, 2] at x = 100 in the main window, on the desktop. + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), local); + let out = d.window_release(id, local); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 5, anchor: 1 | 2, .. }]), "{:?}", out.actions); + assert!(in_root(&d, 5)); + assert!(window(&d, 6).contains(&6)); +} + +#[test] +fn a_tab_from_a_native_window_joins_another_native_windows_tab_row() { + let mut d = driver(two_windows()); + let (from, to) = (window(&d, 5).id, window(&d, 7).id); + let row = d.host().windows[&d.viewport(to)].rect.min + vec2(200.0, TAB_ROW); + let local = d.window_local(from, row).unwrap(); + d.window_hold(from, pos2(15.0, TAB_ROW), local); + let out = d.window_release(from, local); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 5, anchor: 7, placement: Placement::Tab { .. } }]), "{:?}", out.actions); + assert!(window(&d, 7).contains(&5)); +} + +#[test] +fn dragging_the_bar_onto_the_main_window_docks_the_whole_window() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + // The pointer over the tab row of frame [3, 4], which starts at x = 400 in the main window. + // A moving window joins a group there; it splits only groups of other floating windows. + let local = d.window_local(id, ORIGIN + vec2(450.0, 13.0)).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert!(out.actions.iter().any(|a| matches!(a, Action::MoveWindow { panel: 5, to: Target::At { anchor: 3 | 4, .. } })), "{:?}", out.actions); + assert!(d.layout.floating.is_empty()); + assert!(in_root(&d, 5) && in_root(&d, 6)); +} + +#[test] +fn dragging_the_only_tab_of_a_native_window_moves_the_window() { + let mut layout = layout(false); + layout.floating.push(Floating::single(vec![7], 0, [300.0, 80.0, 240.0, 200.0])); + let mut d = driver(layout); + let id = window(&d, 7).id; + d.window_hold(id, pos2(15.0, TAB_ROW), pos2(1000.0, TAB_ROW)); + let out = d.window_release(id, pos2(15.0, TAB_ROW)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Float { .. } | Action::Move { .. })), "{:?}", out.actions); + d.step(vec![]); + assert_eq!(d.layout.floating.len(), 1); + assert_eq!(window(&d, 7).id, id, "the same window"); + // It followed the pointer onto the desktop, the pressed point under it: the nudge moved it + // by (8, 6), then the pointer went 1000 - 15 points right of the press. + assert_eq!(window(&d, 7).native.map(|r| [r[0], r[1]]), Some([408.0 + 985.0, 136.0])); +} + +#[test] +fn leaving_the_window_ends_a_drag_only_with_embedded_viewports() { + // Native viewports: the release still arrives, so leaving is no drop. + let mut d = driver(layout(false)); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + d.hold(tab, pos2(790.0, 300.0)); + let out = d.leave(); + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert!(super::is_dragging::(&d.ctx, d.id)); + d.step(vec![]); + assert!(super::is_dragging::(&d.ctx, d.id)); + + // Embedded: leaving floats the panel at the edge, as before. + let mut d = DockDriver::new(layout(false), Content::default()); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + d.hold(tab, pos2(790.0, 300.0)); + let out = d.leave(); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 1, .. }]), "{:?}", out.actions); +} + +#[test] +fn escape_cancels_a_drag_outside_the_window() { + let mut d = driver(layout(false)); + let before = d.layout.clone(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + d.hold(tab, pos2(-60.0, 120.0)); + d.leave(); + d.key(Key::Escape); + let out = d.release(pos2(-60.0, 120.0)); + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert_eq!(d.layout, before); +} + +/// Shapes drawn in `color`: a drop preview's outline, or a fill. +fn accented(shapes: &[ClippedShape], color: Color32) -> usize { + shapes + .iter() + .filter(|clipped| match &clipped.shape { + egui::Shape::Rect(rect) => rect.stroke.color == color || rect.fill == color, + egui::Shape::Path(path) => path.fill == color.gamma_multiply(0.25), + _ => false, + }) + .count() +} + +#[test] +fn the_preview_is_drawn_in_the_window_under_the_pointer() { + let accent = Color32::from_rgb(250, 10, 200); + let mut d = DockDriver::new(two_windows(), Content::default()) + .with_style(move |s| { + s.accent = accent; + s.float_hint = false; + }) + .with_native_host(ORIGIN); + let to = window(&d, 7).id; + let viewport = d.viewport(to); + let (main_before, window_before) = (accented(d.shapes(), accent), accented(&d.host().windows[&viewport].shapes, accent)); + // Tab 1 of the main window over window [7]'s body. + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.move_to(body); + assert!(accented(&d.host().windows[&viewport].shapes, accent) > window_before, "drawn in the destination"); + assert_eq!(accented(d.shapes(), accent), main_before, "not in the source"); + let out = d.release(body); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 7, .. }]), "{:?}", out.actions); +} + +/// The texts drawn in `shapes`. +fn texts(shapes: &[ClippedShape]) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + egui::Shape::Text(text) => out.push(text.galley.text().to_owned()), + _ => {} + } + } + let mut out = Vec::new(); + shapes.iter().for_each(|clipped| walk(&clipped.shape, &mut out)); + out +} + +#[test] +fn the_undock_hint_is_drawn_in_the_window_under_the_pointer() { + let key = if egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" }; + let hint = format!("Hold {key} to undock"); + let hinted = |shapes: &[ClippedShape]| texts(shapes).contains(&hint); + let driver = || { + DockDriver::new(two_windows(), Content::default()) + .with_style(|s| { + s.command_press_floats = true; + s.float_hint = true; + }) + .with_native_host(ORIGIN) + }; + + // Tab 1 of the main window over window [7]'s body. + let mut d = driver(); + let viewport = d.viewport(window(&d, 7).id); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.move_to(body); + assert!(hinted(&d.host().windows[&viewport].shapes), "{:?}", texts(&d.host().windows[&viewport].shapes)); + assert!(!hinted(d.shapes()), "not in the source"); + + // Tab 5 of window [5, 6] over frame [1, 2] of the main window. + let mut d = driver(); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), local); + assert!(hinted(d.shapes()), "{:?}", texts(d.shapes())); + assert!(!hinted(&d.host().windows[&viewport].shapes), "not in the source"); +} + +/// Zone outlines: closed 1 pt paths without a fill. +fn zone_outlines(shapes: &[ClippedShape]) -> usize { + shapes + .iter() + .filter(|clipped| matches!(&clipped.shape, egui::Shape::Path(path) if path.closed && path.fill == Color32::TRANSPARENT && path.stroke.width == 1.0)) + .count() +} + +#[test] +fn drop_zones_are_drawn_in_the_window_under_the_pointer() { + let mut d = DockDriver::new(two_windows(), Content::default()) + .with_style(|s| { + s.side_edge_zones = true; + s.float_hint = false; + }) + .with_native_host(ORIGIN); + let viewport = d.viewport(window(&d, 7).id); + // Tab 1 of the main window over window [7]'s body, too short to split top or bottom: its + // left and right sides, center and strip. + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.move_to(body); + assert_eq!(zone_outlines(&d.host().windows[&viewport].shapes), 4, "drawn in the destination"); + assert_eq!(zone_outlines(d.shapes()), 0, "not in the source"); +} + +#[test] +fn a_command_drag_floats_even_over_another_window() { + let mut d = driver(two_windows()); + let viewport = d.viewport(window(&d, 7).id); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let over = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.modifiers = Modifiers::COMMAND; + d.hold(tab, over); + let out = d.release(over); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 1, .. }]), "{:?}", out.actions); + d.step(vec![]); + let native = window(&d, 1).native.unwrap(); + let desktop = ORIGIN + over.to_vec2(); + assert_eq!(pos2(native[0], native[1]), pos2(desktop.x - tab.x - 8.0, desktop.y - tab.y - 6.0 - BAR)); +} + +#[test] +fn without_window_positions_a_drag_out_of_a_native_window_floats() { + let mut d = driver(two_windows()); + d.host().no_position = true; + d.settle(); + let id = window(&d, 5).id; + // Over window [7] on the desktop, which cannot be known: a new window. + d.window_hold(id, pos2(15.0, TAB_ROW), pos2(-200.0, 400.0)); + let out = d.window_release(id, pos2(-200.0, 400.0)); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 5, .. }]), "{:?}", out.actions); + // The only tab left moves its window, by handing the move to the window system. A press + // where the last one was, this soon, would be a double-click. + let id = window(&d, 6).id; + for _ in 0..40 { + d.step(vec![]); + } + d.window_hold(id, pos2(15.0, TAB_ROW), pos2(120.0, 200.0)); + assert!(d.host().commands.iter().any(|(v, c)| *v == d.viewport(id) && *c == ViewportCommand::StartDrag)); + let out = d.window_release(id, pos2(120.0, 200.0)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Float { .. } | Action::Move { .. })), "{:?}", out.actions); +} + +/// Drag window `panel`'s bar until the pointer is at `to` in the main window, and release. +fn drag_bar_to(d: &mut DockDriver, panel: u32, to: Pos2) -> super::DockOutput { + let id = window(d, panel).id; + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + let Some(local) = d.window_local(id, ORIGIN + to.to_vec2()) else { return Default::default() }; + d.window_step(id, vec![Event::PointerMoved(local)]); + d.window_step(id, vec![button(pos2(40.0, 9.0), false)]) +} + +fn window_move(out: &super::DockOutput) -> Option<&Target> { + out.actions.iter().find_map(|a| match a { + Action::MoveWindow { to, .. } => Some(to), + _ => None, + }) +} + +#[test] +fn a_native_windows_bar_docks_it_at_frame_edges_and_dock_lines_but_not_a_body_center() { + // The right edge zone of frame [3, 4], 25 pt in, clear of the new-dock band along the window's edge. + let mut d = driver(layout(true)); + let out = drag_bar_to(&mut d, 5, pos2(775.0, 300.0)); + assert!(matches!(window_move(&out), Some(Target::At { anchor: 3 | 4, placement: Placement::Split(super::Zone::Right) })), "{:?}", out.actions); + assert!(d.layout.floating.is_empty()); + assert!(in_root(&d, 5) && in_root(&d, 6)); + + // The middle of the frame is no target for a whole window. + let mut d = driver(layout(true)); + let out = drag_bar_to(&mut d, 5, pos2(600.0, 300.0)); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + assert!(window(&d, 5).contains(&6)); + + // The seam between the dock's two groups, which meet at y = 307. + let dock = Node::Dock { + side: super::Side::Start, + columns: vec![super::Column::new(vec![super::Group::new(vec![2], 0), super::Group::new(vec![3], 0)], 240.0)], + }; + let mut docked = layout(true); + docked.root = + Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(tabs(&[1])) }); + let mut d = driver(docked); + let out = drag_bar_to(&mut d, 5, pos2(120.0, 309.0)); + assert_eq!(window_move(&out), Some(&Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } }), "{:?}", out.actions); + assert!(d.layout.floating.is_empty()); + assert!(in_root(&d, 5) && in_root(&d, 6)); +} + +#[test] +fn escape_during_a_bar_drag_puts_the_window_back() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + d.window_step(id, vec![Event::PointerMoved(pos2(140.0, 59.0))]); + assert_ne!(d.host().windows[&viewport].rect.min, pos2(400.0, 130.0), "moved"); + let escape = Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + d.window_step(id, vec![escape]); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(400.0, 130.0)); + // The release that follows docks nothing, wherever it is. + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + d.step(vec![]); + assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([400.0, 130.0])); +} + +#[test] +fn the_window_focused_last_is_in_front_where_windows_overlap() { + // Window [7] overlaps window [5, 6] on the desktop, at (550, 280) among others. + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [350.0, 150.0, 240.0, 150.0])); + let overlap = pos2(550.0, 280.0) - ORIGIN.to_vec2(); + for (front, anchors) in [(7, vec![7]), (5, vec![5, 6]), (7, vec![7])] { + let mut d = driver(layout.clone()); + let viewport = d.viewport(window(&d, front).id); + d.host().focus = Some(viewport); + d.step(vec![]); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = d.drag(tab, overlap); + let anchor = out.actions.iter().find_map(|a| match a { + Action::Move { panel: 1, anchor, .. } => Some(*anchor), + _ => None, + }); + assert!(anchor.is_some_and(|a| anchors.contains(&a)), "front {front}: {:?}", out.actions); + } +} + +/// Every `OuterPosition` sent to `viewport`, in order. +fn moves(d: &DockDriver, viewport: egui::ViewportId) -> Vec { + d.host() + .commands + .iter() + .filter_map(|(v, c)| match c { + ViewportCommand::OuterPosition(pos) if *v == viewport => Some(*pos), + _ => None, + }) + .collect() +} + +fn escape() -> Event { + Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn a_window_made_this_session_opens_where_it_was_put() { + // egui reports the monitor's size but no monitor list. A tab dropped on the desktop right + // of the main window's monitor opens there. + let size = Some(vec2(1920.0, 1080.0)); + let mut d = driver(layout(false)); + d.host().monitor_size = size; + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = pos2(2400.0, 120.0); + d.hold(tab, out); + d.release(out); + d.settle(); + let desktop = ORIGIN + out.to_vec2(); + let min = pos2(desktop.x - tab.x - 8.0, desktop.y - tab.y - 6.0 - BAR); + let native = window(&d, 1).native.unwrap(); + assert_eq!(pos2(native[0], native[1]), min); + assert_eq!(os_window(&d, 1).unwrap().0.min, min); + + // Undock Panel, to a place off that monitor. + let mut d = driver(layout(false)); + d.host().monitor_size = size; + d.layout.apply(Action::Float { panel: 2, rect: [2400.0, 100.0, 300.0, 200.0] }).unwrap(); + d.settle(); + assert_eq!(window(&d, 2).native, Some([2500.0, 150.0, 300.0, 200.0])); + + // A loaded layout's window still opens on the main window's monitor. + let mut saved = layout(true); + saved.floating[0].native = Some([2500.0, 100.0, 240.0, 300.0]); + let mut d = driver(Layout::default()); + d.host().monitor_size = size; + d.layout = saved; + d.settle(); + assert_eq!(window(&d, 5).native, Some([1680.0, 100.0, 240.0, 300.0])); + assert_eq!(os_window(&d, 5).unwrap().0.min, pos2(1680.0, 100.0)); +} + +#[test] +fn a_changed_layout_moves_the_os_window() { + let mut d = driver(layout(true)); + let viewport = d.viewport(id_of(&d)); + assert_eq!(window(&d, 5).native, Some([400.0, 130.0, 240.0, 300.0])); + // An undo of a move, a reset, or a layout reloaded with the same ids. + d.layout.floating[0].native = Some([700.0, 200.0, 260.0, 320.0]); + let out = d.step(vec![]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::SetNative { .. })), "{:?}", out.actions); + d.settle(); + assert_eq!(d.host().windows[&viewport].rect, Rect::from_min_size(pos2(700.0, 200.0), vec2(260.0, 320.0))); + assert_eq!(window(&d, 5).native, Some([700.0, 200.0, 260.0, 320.0])); + + // Off every monitor: onto the nearest, and the layout follows. + d.monitors = vec![Monitor { name: Some("left".into()), rect: [0.0, 0.0, 1920.0, 1080.0], scale: 1.0 }]; + d.layout.floating[0].native = Some([5000.0, 200.0, 260.0, 320.0]); + d.settle(); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(1660.0, 200.0)); + assert_eq!(window(&d, 5).native, Some([1660.0, 200.0, 260.0, 320.0])); + + // A bar drag that leaves too little of the bar on a monitor ends with the window on it. + let id = id_of(&d); + let grab = pos2(40.0, 9.0); + d.window_step(id, vec![Event::PointerMoved(grab), button(grab, true)]); + let far = d.window_local(id, pos2(1950.0, 300.0)).unwrap(); + d.window_step(id, vec![Event::PointerMoved(far)]); + let far = d.window_local(id, pos2(1950.0, 300.0)).unwrap(); + d.window_step(id, vec![Event::PointerMoved(far), button(far, false)]); + d.settle(); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(1660.0, 291.0)); + assert_eq!(window(&d, 5).native, Some([1660.0, 291.0, 260.0, 320.0])); +} + +#[test] +fn a_bar_drag_follows_the_pointer_when_moves_land_late() { + let mut d = driver(layout(true)); + d.host().lag = true; + let id = id_of(&d); + let viewport = d.viewport(id); + let grab = pos2(40.0, 9.0); + let mut pointer = d.host().windows[&viewport].rect.min + grab.to_vec2(); + d.window_step(id, vec![Event::PointerMoved(grab), button(grab, true)]); + // On to the desktop right of the main window, where the release docks nothing. + for _ in 0..6 { + pointer += vec2(90.0, -20.0); + // The window system reports the pointer relative to where the window is now. + let local = d.window_local(id, pointer).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + assert_eq!(moves(&d, viewport).last(), Some(&(pointer - grab.to_vec2())), "no overshoot"); + } + // Frames with no pointer move leave it, though the last move has landed since. + let sent = moves(&d, viewport).len(); + for _ in 0..3 { + d.step(vec![]); + } + assert_eq!(moves(&d, viewport).len(), sent, "no creep"); + assert_eq!(d.host().windows[&viewport].rect.min, pointer - grab.to_vec2()); + let local = d.window_local(id, pointer).unwrap(); + d.window_step(id, vec![button(local, false)]); + d.settle(); + let to = pointer - grab.to_vec2(); + assert_eq!(d.host().windows[&viewport].rect.min, to); + assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([to.x, to.y])); +} + +#[test] +fn a_native_windows_content_is_its_origin_where_its_frame_differs() { + // A title bar's height between the window's outer position and its content. + let mut d = DockDriver::new(Layout::default(), Content::default()).with_native_host(ORIGIN); + d.host().frame = vec2(0.0, 28.0); + d.layout = layout(true); + d.settle(); + // It opened with its frame there, then moved so its content is. + let id = id_of(&d); + let viewport = d.viewport(id); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(400.0, 130.0)); + assert_eq!(window(&d, 5).native, Some([400.0, 130.0, 240.0, 300.0])); + + // Its bar moves it by its outer position, here onto the desktop right of the main window. + let grab = pos2(40.0, 9.0); + d.window_step(id, vec![Event::PointerMoved(grab), button(grab, true)]); + d.window_step(id, vec![Event::PointerMoved(grab + vec2(520.0, 20.0))]); + assert_eq!(moves(&d, viewport).last(), Some(&pos2(920.0, 122.0))); + d.window_step(id, vec![button(grab, false)]); + d.settle(); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(920.0, 150.0)); + assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([920.0, 150.0])); + + // Its tab drops where the pointer is on the desktop: the main window's tab row of [3, 4]. + let row = d.window_local(id, ORIGIN + vec2(450.0, 13.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), row); + let out = d.window_release(id, row); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 5, anchor: 3 | 4, placement: Placement::Tab { .. } }]), "{:?}", out.actions); +} + +#[test] +fn the_only_tab_of_a_native_window_docks_it_with_title_bar_docks_off() { + let mut layout = layout(false); + layout.floating.push(Floating::single(vec![7], 0, [300.0, 80.0, 240.0, 200.0])); + let driver = || DockDriver::new(layout.clone(), Content::default()).with_style(|s| s.title_bar_docks = false).with_native_host(ORIGIN); + let mut d = driver(); + let id = window(&d, 7).id; + let row = ORIGIN + vec2(450.0, 13.0); + let from = pos2(15.0, TAB_ROW); + d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); + d.window_step(id, vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + for _ in 0..2 { + let local = d.window_local(id, row).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + } + let local = d.window_local(id, row).unwrap(); + let out = d.window_step(id, vec![button(local, false)]); + assert!(matches!(window_move(&out), Some(Target::At { anchor: 3 | 4, .. })), "{:?}", out.actions); + assert!(in_root(&d, 7)); + + // Its bar keeps to `title_bar_docks`. + let mut d = driver(); + let out = drag_bar_to(&mut d, 7, pos2(450.0, 13.0)); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + assert!(!in_root(&d, 7)); +} + +#[test] +fn the_only_tab_of_a_native_window_fills_and_joins_a_frame_bodys_center() { + let mut layout = layout(false); + layout.floating.push(Floating::single(vec![7], 0, [300.0, 80.0, 240.0, 200.0])); + let style = |s: &mut super::DockStyle| { + s.side_edge_zones = true; + s.title_bar_docks = false; + s.float_hint = false; + }; + let mut d = DockDriver::new(layout, Content::default()).with_style(style).with_native_host(ORIGIN); + let id = window(&d, 7).id; + // The middle of frame [3, 4], in the main window. + let middle = pos2(600.0, 300.0); + let from = pos2(15.0, TAB_ROW); + d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); + d.window_step(id, vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + for _ in 0..2 { + let local = d.window_local(id, ORIGIN + middle.to_vec2()).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + } + // The center rectangle is the only zone around the pointer: outlined and filled. + let zone = |filled: bool| { + d.shapes().iter().any(|clipped| match &clipped.shape { + egui::Shape::Path(path) => { + path.closed && (path.fill != Color32::TRANSPARENT) == filled && Rect::from_points(&path.points).contains(middle) + } + _ => false, + }) + }; + assert!(zone(false), "outlined"); + assert!(zone(true), "filled"); + let local = d.window_local(id, ORIGIN + middle.to_vec2()).unwrap(); + let out = d.window_step(id, vec![button(local, false)]); + assert!(matches!(window_move(&out), Some(Target::At { anchor: 3 | 4, placement: Placement::Split(super::Zone::Center) })), "{:?}", out.actions); + assert!(in_root(&d, 7)); +} + +#[test] +fn a_native_window_forwards_no_keys_while_one_of_its_widgets_has_focus() { + let mut d = driver(layout(true)); + (d.content.text, d.content.focus_text) = (Some(5), true); + d.settle(); + let key = Event::Key { key: Key::S, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + let out = d.window_step(id_of(&d), vec![key, Event::Text("s".into())]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + assert_eq!(d.content.typed, "s"); + + // A focused button's Enter is its own. + let mut d = driver(layout(true)); + d.content.focused_button = Some(5); + d.settle(); + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let out = d.window_step(id_of(&d), vec![enter]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); +} + +#[test] +fn the_input_hook_rewrites_each_native_windows_input_before_its_widgets_see_it() { + // As an app maps Ctrl+click to a secondary click on macOS. + let mut d = driver(layout(true)).with_native_input_hook(|_, input| { + for event in &mut input.events { + if let Event::PointerButton { button, .. } = event { + *button = PointerButton::Secondary; + } + } + }); + let id = id_of(&d); + d.window_click(id, pos2(60.0, 150.0)); + assert_eq!(d.content.pressed, vec![(5, PointerButton::Secondary)]); + + // The main window's input is the app's own `raw_input_hook`'s. + d.content.pressed.clear(); + d.click(pos2(100.0, 300.0)); + assert_eq!(d.content.pressed, vec![(1, PointerButton::Primary)]); +} + +#[derive(Debug)] +struct File(std::path::PathBuf); + +impl egui::DroppedFile for File { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + Ok(Vec::new()) + } +} + +#[test] +fn files_dragged_onto_a_native_window_are_reported_with_its_panel() { + let mut d = driver(layout(true)); + let (id, viewport) = (id_of(&d), d.viewport(id_of(&d))); + let hovered = egui::HoveredFile { path: Some("a.png".into()), mime: String::new() }; + d.host().windows.get_mut(&viewport).unwrap().hovered_files = vec![hovered.clone()]; + let out = d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 150.0))]); + let [files] = out.window_files.as_slice() else { panic!("{:?}", out.window_files) }; + assert_eq!((files.window, files.viewport, files.panel, files.pos), (id, viewport, 5, Some(pos2(60.0, 150.0)))); + assert_eq!(files.hovered, vec![hovered]); + assert!(files.dropped.is_empty()); + + { + let mut host = d.host(); + let window = host.windows.get_mut(&viewport).unwrap(); + window.hovered_files.clear(); + window.dropped_files = vec![std::sync::Arc::new(File("a.png".into()))]; + } + let out = d.window_step(id, Vec::new()); + let [files] = out.window_files.as_slice() else { panic!("{:?}", out.window_files) }; + assert_eq!(files.panel, 5); + assert_eq!(files.dropped.iter().map(|f| f.path().to_path_buf()).collect::>(), vec![std::path::PathBuf::from("a.png")]); + assert!(d.step(Vec::new()).window_files.is_empty()); +} + +#[test] +fn a_blocked_native_window_reports_no_files() { + let mut d = driver(layout(true)); + let (id, viewport) = (id_of(&d), d.viewport(id_of(&d))); + d.input_blocked = true; + d.step(Vec::new()); + let drop = |d: &mut DockDriver| { + let mut host = d.host(); + let window = host.windows.get_mut(&viewport).unwrap(); + window.hovered_files = vec![egui::HoveredFile { path: Some("a.png".into()), mime: String::new() }]; + window.dropped_files = vec![std::sync::Arc::new(File("a.png".into()))]; + }; + drop(&mut d); + let out = d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 150.0))]); + assert!(out.window_files.is_empty(), "{:?}", out.window_files); + assert!(!d.content.saw_files); + + d.input_blocked = false; + drop(&mut d); + let out = d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 150.0))]); + assert_eq!(out.window_files.len(), 1); + assert!(d.content.saw_files); +} + +#[test] +fn escape_or_focus_loss_ends_a_drag_a_native_window_owns() { + // Escape cancels it, and is no shortcut. + let mut d = driver(layout(true)); + let id = id_of(&d); + let over = d.window_local(id, ORIGIN + vec2(600.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + let out = d.window_step(id, vec![escape()]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + let out = d.window_release(id, over); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + assert!(window(&d, 5).contains(&6)); + + // Escape during a bar drag is no shortcut either. + let mut d = driver(layout(true)); + d.window_step(id_of(&d), vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id_of(&d), vec![Event::PointerMoved(pos2(60.0, 19.0))]); + let out = d.window_step(id_of(&d), vec![escape()]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + + // Escape pressed in the main window, which has keyboard focus, cancels a drag the native + // window owns. + let mut d = driver(layout(true)); + let id = id_of(&d); + let viewport = d.viewport(id); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + d.window_step(id, vec![Event::PointerMoved(pos2(140.0, 59.0))]); + d.key(Key::Escape); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(400.0, 130.0), "back where it was"); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + + // Another application takes focus: cancelled. + let mut d = driver(layout(true)); + let id = id_of(&d); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + d.host().focus = None; + d.window_step(id, vec![Event::PointerMoved(over)]); + d.host().focus = Some(d.viewport(id)); + let out = d.window_release(id, over); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + assert!(!super::is_dragging::(&d.ctx, d.id)); +} + +#[test] +fn escape_in_a_focused_native_window_cancels_a_main_window_drag() { + let mut d = driver(two_windows()); + let id = window(&d, 7).id; + let viewport = d.viewport(id); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.host().focus = Some(viewport); + let out = d.window_step(id, vec![escape()]); + assert!(out.forwarded_events.is_empty(), "the Escape is no shortcut: {:?}", out.forwarded_events); + d.step(vec![Event::PointerMoved(body)]); + let out = d.release(body); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::MovePanel { .. } | Action::Float { .. })), "{:?}", out.actions); + assert!(!super::is_dragging::(&d.ctx, d.id)); +} + +#[test] +fn a_main_window_without_focus_still_drags_a_tab_into_another_group() { + let mut d = driver(layout(false)); + d.host().focus = None; + d.settle(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let over = d.hit(&Hit::Tab(3)).unwrap().center(); + d.hold(tab, over); + assert!(super::is_dragging::(&d.ctx, d.id)); + let out = d.release(over); + assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 1, .. })), "{:?}", out.actions); +} + +#[test] +fn focus_moving_to_a_native_window_mid_drag_keeps_the_drag() { + let mut d = driver(two_windows()); + let viewport = d.viewport(window(&d, 7).id); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.host().focus = Some(viewport); + d.step(vec![Event::WindowFocused(false), Event::PointerMoved(body)]); + assert!(super::is_dragging::(&d.ctx, d.id)); + let out = d.release(body); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 7, .. }]), "{:?}", out.actions); +} + +#[test] +fn a_drag_ends_when_its_native_window_or_panel_goes() { + let pointer = |d: &DockDriver| d.ctx.data(|data| data.get_temp::(d.id.with("drag-pointer"))); + let moving = |d: &DockDriver| d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))); + + // The window closes during a tab drag it owns. + let mut d = driver(layout(true)); + let id = id_of(&d); + let over = d.window_local(id, ORIGIN + vec2(600.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + let viewport = d.viewport(id); + d.host().windows.get_mut(&viewport).unwrap().close = true; + d.step(vec![]); + d.settle(); + assert!(!d.layout.contains(&5)); + assert!(!super::is_dragging::(&d.ctx, d.id)); + assert_eq!(pointer(&d), None); + + // The dragged panel is closed by the application. + let mut d = driver(layout(true)); + let id = id_of(&d); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + d.layout.apply(Action::Close { panel: 5 }).unwrap(); + let out = d.window_release(id, over); + assert!(out.error.is_none() && !out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + d.settle(); + assert!(!super::is_dragging::(&d.ctx, d.id)); + assert_eq!(pointer(&d), None); + + // The window closes during its own bar drag. + let mut d = driver(layout(true)); + let id = id_of(&d); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + assert!(moving(&d).is_some()); + d.layout.apply(Action::CloseWindow { panel: 5 }).unwrap(); + d.settle(); + assert_eq!(moving(&d), None); + assert_eq!(pointer(&d), None); + // The main window's drags work as before. + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = d.drag(tab, pos2(600.0, 300.0)); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 3 | 4, .. }]), "{:?}", out.actions); +} + +/// Press at `from` in the native window of panel 5, move the pointer by `by` on the desktop +/// and release. Returns the window's OS rect, the resize the window handed to the window +/// system, and the driver. +#[cfg(not(target_os = "macos"))] +fn press_and_drag(from: Pos2, by: egui::Vec2) -> (Rect, Option, DockDriver) { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + let press = os_window(&d, 5).map_or(Pos2::ZERO, |(rect, _)| rect.min) + from.to_vec2(); + d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); + // The pointer is where it was moved on the desktop, wherever the window is now. + let local = |d: &DockDriver, step: egui::Vec2| d.window_local(id, press + step).unwrap_or_default(); + for step in [vec2(3.0, 3.0), by] { + let pos = local(&d, step); + d.window_step(id, vec![Event::PointerMoved(pos)]); + } + let pos = local(&d, by); + d.window_step(id, vec![Event::PointerMoved(pos), button(pos, false)]); + d.step(vec![]); + let resize = d.host().commands.iter().find_map(|(v, c)| match c { + ViewportCommand::BeginResize(direction) if *v == viewport => Some(*direction), + _ => None, + }); + (os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), resize, d) +} + +// On macOS AppKit resizes the window and there are no bands; the macOS × layout has its own +// test. +#[cfg(not(target_os = "macos"))] +#[test] +fn the_bars_close_button_wins_over_the_resize_bands() { + use egui::ResizeDirection as D; + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + // The × of the 240 pt wide window spans 215..233 across and the whole bar down. Its top + // right is next to the window's top-right corner, but it closes the window. + let out = d.window_click(id, pos2(232.0, 1.0)); + assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); + assert!(!d.host().commands.iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::BeginResize(_))), "not resized"); + + // Beside it, at the window's edges, the bands hand the resize to the window system. + let resize = |from: Pos2| press_and_drag(from, vec2(20.0, 20.0)).1; + assert_eq!(resize(pos2(238.0, 2.0)), Some(D::NorthEast), "the top-right corner"); + assert_eq!(resize(pos2(237.0, 15.0)), Some(D::NorthEast), "the corner beside the ×"); + assert_eq!(resize(pos2(237.0, 100.0)), Some(D::East), "the right edge"); + assert_eq!(resize(pos2(120.0, 1.0)), Some(D::North), "the top edge above the bar"); + assert_eq!(resize(pos2(2.0, 298.0)), Some(D::SouthWest), "the bottom-left corner"); + // Inside a corner's L, the bar moves the window. + let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let (moved, resize, _) = press_and_drag(pos2(10.0, 10.0), vec2(20.0, 20.0)); + assert_eq!((moved, resize), (rect(420.0, 150.0, 660.0, 450.0), None)); +} + +#[test] +fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); + let close = Rect::from_center_size(pos2(224.0, 9.0), vec2(18.0, 18.0)); + let bands = resize_bands(rect, &[close]); + let at = |p: Pos2| bands.iter().find(|(_, band)| band.contains(p)).map(|(direction, _)| *direction); + for (_, band) in &bands { + assert!(!band.intersect(close).is_positive(), "{band:?} covers the ×"); + assert!(band.width().min(band.height()) <= 6.0, "{band:?} is deeper than an edge band"); + } + // Each edge resizes along its whole length, outside the ×, from 16 pt corners. + use egui::ResizeDirection as D; + // Which of an edge's three parts `v` is in: the first corner, the edge, the last corner. + let part = |v: f32, length: f32| usize::from(v >= 16.0) + usize::from(v > length - 16.0); + for i in 0..240 { + let x = i as f32 + 0.5; + let top = [D::NorthWest, D::North, D::NorthEast][part(x, 240.0)]; + assert_eq!(at(pos2(x, 0.5)), (!close.contains(pos2(x, 0.5))).then_some(top), "top at {x}"); + assert_eq!(at(pos2(x, 5.5)), (!close.contains(pos2(x, 5.5))).then_some(top), "6 pt down at {x}"); + assert_eq!(at(pos2(x, 299.5)), Some([D::SouthWest, D::South, D::SouthEast][part(x, 240.0)]), "bottom at {x}"); + } + for i in 0..300 { + let y = i as f32 + 0.5; + assert_eq!(at(pos2(0.5, y)), Some([D::NorthWest, D::West, D::SouthWest][part(y, 300.0)]), "left at {y}"); + assert_eq!(at(pos2(239.5, y)), Some([D::NorthEast, D::East, D::SouthEast][part(y, 300.0)]), "right at {y}"); + } + assert_eq!(at(pos2(10.0, 10.0)), None, "a corner is an L along its edges"); + assert_eq!(at(pos2(120.0, 6.5)), None, "the bar below the top band"); +} + +#[test] +fn on_macos_the_close_glyph_sits_by_the_corner_and_its_hit_area_reaches_away_from_it() { + use crate::docking::chrome::{ChromeStyle, TitleBar}; + use crate::docking::placement::BAR_HEIGHT; + let ctx = egui::Context::default(); + let full = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); + let bar = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT)); + for right_to_left in [false, true] { + let (hit, glyph) = close_rects(bar, MACOS_CLOSE, right_to_left); + // Distances from the corner the × sits by: across from the trailing edge, and down. + let across = |x: f32| if right_to_left { x - full.left() } else { full.right() - x }; + let (glyph_near, glyph_far) = (across(glyph.right()).min(across(glyph.left())), across(glyph.right()).max(across(glyph.left()))); + assert_eq!((glyph_near, glyph.top(), glyph.width(), glyph.height()), (8.0, 4.5, 9.0, 9.0), "the glyph by the corner"); + let (hit_near, hit_far) = (across(hit.right()).min(across(hit.left())), across(hit.right()).max(across(hit.left()))); + assert_eq!(hit_near, glyph_near, "the hit area reaches no closer to the corner than the glyph"); + assert_eq!(hit_far, glyph_far + 10.0, "the hit area reaches away from the corner"); + assert_eq!((hit.top(), hit.bottom()), (glyph.top(), BAR_HEIGHT), "the hit area reaches down to the bar's bottom, not below"); + // AppKit's corner area takes the presses nearest the corner; the rest reach the ×. + let [top_left, top_right, ..] = appkit_resize_areas(full); + let corner = if right_to_left { top_left } else { top_right }; + assert_eq!(corner.width(), APPKIT_CORNER); + assert!(hit_far - APPKIT_CORNER >= 10.0, "{hit:?} has too little room outside {corner:?}"); + + let mut shown = None; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(full), ..Default::default() }, |ui| { + let style = ChromeStyle { right_to_left, ..ChromeStyle::from_ui(ui) }; + let response = TitleBar::new(egui::Id::unique("bar"), "Move").title("Panel").close("Close").close_at(hit, glyph).show(ui, bar, &style); + shown = response.close.map(|close| (close.rect, response.bar.rect)); + }); + output.textures_delta.clear(); + let (close, drag) = shown.unwrap_or((Rect::NOTHING, Rect::NOTHING)); + assert_eq!(close, hit, "the bar takes clicks for the × in its hit area"); + assert!(!drag.intersect(hit).is_positive(), "the bar's drag area {drag:?} leaves the × alone"); + } +} + +/// [`layout`] built anew, its window with id 7 when `with_id`. +fn rebuilt_layout(with_id: bool) -> Layout { + let mut layout = layout(true); + if with_id { + layout.floating = layout.floating.into_iter().map(|w| w.with_id(7)).collect(); + } + layout +} + +#[test] +fn a_layout_built_each_frame_with_ids_keeps_its_os_window() { + let mut d = driver(rebuilt_layout(true)); + let viewport = d.viewport(7); + for _ in 0..4 { + d.layout = rebuilt_layout(true); + d.step(vec![]); + } + assert_eq!(d.host().windows.keys().copied().collect::>(), [viewport], "one OS window, the same each frame"); +} + +#[cfg(debug_assertions)] +#[test] +fn a_layout_built_each_frame_without_ids_is_warned_about() { + let warned = |d: &DockDriver| d.ctx.data(|m| m.get_temp::(d.id.with("native-rebuilt"))).unwrap_or(false); + let mut d = driver(rebuilt_layout(false)); + for _ in 0..4 { + d.layout = rebuilt_layout(false); + d.step(vec![]); + } + assert!(warned(&d)); + assert!(d.host().windows.len() > 1, "a new OS window each frame"); + + // A layout kept across frames, and one rebuilt with ids, are not. + let mut d = driver(layout(true)); + d.layout = rebuilt_layout(false); + for _ in 0..4 { + d.step(vec![]); + } + assert!(!warned(&d), "loaded once"); + let mut d = driver(rebuilt_layout(true)); + for _ in 0..4 { + d.layout = rebuilt_layout(true); + d.step(vec![]); + } + assert!(!warned(&d)); +} + +/// Fill of the overlay over a blocked native window in [`blocked_driver`]. +const DIM: Color32 = Color32::from_rgba_premultiplied(10, 20, 30, 120); + +fn blocked_driver() -> DockDriver { + let mut d = DockDriver::new(layout(true), Content { counter: Some(5), ..Content::default() }) + .with_style(|s| s.blocked_dim = DIM) + .with_native_host(ORIGIN); + d.settle(); + d +} + +/// Whether the native window `viewport` drew the overlay over all of its content. +fn dimmed(d: &DockDriver, viewport: egui::ViewportId) -> bool { + let full = Rect::from_min_size(Pos2::ZERO, d.host().windows[&viewport].rect.size()); + d.host().windows[&viewport].shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == DIM && r.rect == full)) +} + +#[test] +fn a_native_window_takes_no_input_while_the_app_blocks_it() { + let mut d = blocked_driver(); + let id = id_of(&d); + let viewport = d.viewport(id); + let counter = d.content.counter_rect.unwrap().center(); + let tab = pos2(90.0, TAB_ROW); + let key = Event::Key { key: Key::S, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + let level = |d: &DockDriver| d.host().windows[&viewport].level; + assert!(!dimmed(&d, viewport)); + assert_eq!(level(&d), Some(egui::WindowLevel::AlwaysOnTop)); + + d.input_blocked = true; + d.step(vec![]); + assert!(dimmed(&d, viewport), "drawn dimmed"); + assert_eq!(level(&d), Some(egui::WindowLevel::Normal), "the main window can cover the palette"); + let out = d.window_click(id, tab); + assert!(out.actions.is_empty(), "{:?}", out.actions); + d.window_click(id, counter); + assert_eq!(d.content.count, 0, "the body takes no click"); + let out = d.window_step(id, vec![key.clone(), Event::Text("s".into())]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + // A key released while blocked still reaches the app, which may have seen it go down. + let release = Event::Key { key: Key::S, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND }; + let out = d.window_step(id, vec![release.clone()]); + assert_eq!(out.forwarded_events, vec![release]); + // The bar starts no move, of the dock area's or the window system's. + let sent = d.host().commands.len(); + d.window_hold(id, pos2(40.0, 9.0), pos2(140.0, 59.0)); + assert!(d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))).is_none()); + assert!( + !d.host().commands[sent..].iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::StartDrag | ViewportCommand::OuterPosition(_))) + ); + let out = d.window_release(id, pos2(140.0, 59.0)); + assert!(out.actions.is_empty(), "{:?}", out.actions); + // The OS close request is cancelled. + d.host().windows.get_mut(&viewport).unwrap().close = true; + let out = d.step(vec![]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::CloseWindow { .. })), "{:?}", out.actions); + assert!(d.host().commands.contains(&(viewport, ViewportCommand::CancelClose))); + + // Unblocked, the same input works again. + d.input_blocked = false; + d.step(vec![]); + assert!(!dimmed(&d, viewport)); + assert_eq!(level(&d), Some(egui::WindowLevel::AlwaysOnTop)); + d.window_click(id, counter); + assert_eq!(d.content.count, 1); + let out = d.window_click(id, tab); + assert!(out.actions.contains(&Action::Activate { panel: 6 }), "{:?}", out.actions); + let out = d.window_step(id, vec![key.clone()]); + assert_eq!(out.forwarded_events, vec![key]); +} + +#[test] +fn a_blocked_native_window_preserves_ordered_ime_reset_completions() { + let mut d = blocked_driver(); + let id = id_of(&d); + let viewport = d.viewport(id); + d.content.input_probe = Some(5); + d.input_blocked = true; + let request_id = d.ctx.request_ime_reset(viewport).unwrap(); + let completed = Event::ImeResetCompleted { request_id }; + let focus = Event::WindowFocused(false); + let release = Event::Key { key: Key::S, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND }; + let out = d.window_step(id, vec![Event::Text("blocked".into()), completed.clone(), release.clone(), focus.clone()]); + assert_eq!(d.content.events, vec![completed.clone(), focus.clone()], "panel input keeps control order while rejecting typing"); + assert_eq!(d.content.raw_events, vec![completed, focus], "raw readers receive the same native controls"); + assert_eq!(out.forwarded_events, vec![release], "shortcut releases still reach the host"); + assert!(d.content.typed.is_empty()); +} + +#[test] +fn a_blocked_palette_stays_shown_and_behind_the_main_window_when_the_app_returns() { + use egui::{ViewportId, WindowLevel}; + let mut d = blocked_driver(); + let palette = d.viewport(id_of(&d)); + d.input_blocked = true; + d.step(vec![]); + d.host().commands.clear(); + let focus = |d: &mut DockDriver, window: Option| { + d.host().focus = window; + for _ in 0..20 { + d.step(vec![]); + } + }; + // Showing it again would raise it over the modal and, on macOS, give it focus. + focus(&mut d, None); + assert!(d.host().windows[&palette].visible); + focus(&mut d, Some(palette)); + let host = d.host(); + assert!(!host.commands.iter().any(|(v, c)| *v == palette && matches!(c, ViewportCommand::Visible(_))), "{:?}", host.commands); + // Activated through the palette, the main window comes in front of it, with no viewers. + let levels: Vec<_> = host.commands.iter().filter(|(_, c)| matches!(c, ViewportCommand::WindowLevel(_))).cloned().collect(); + let expected = if cfg!(target_os = "macos") { + Vec::new() + } else { + [WindowLevel::AlwaysOnTop, WindowLevel::Normal].map(|l| (ViewportId::ROOT, ViewportCommand::WindowLevel(l))).to_vec() + }; + assert_eq!(levels, expected); +} + +#[test] +fn a_focused_field_and_a_held_press_stay_inert_across_a_block() { + let mut d = blocked_driver(); + let id = id_of(&d); + d.content.text = Some(5); + d.content.focus_text = true; + d.step(vec![]); + d.window_step(id, vec![Event::Text("a".into())]); + assert_eq!(d.content.typed, "a"); + // The field loses focus when the block starts, and stays without it after. + d.input_blocked = true; + d.step(vec![]); + let out = d.window_step(id, vec![Event::Text("b".into())]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + d.input_blocked = false; + d.step(vec![]); + let out = d.window_step(id, vec![Event::Text("c".into())]); + assert_eq!(d.content.typed, "a"); + assert_eq!(out.forwarded_events, vec![Event::Text("c".into())]); + + // A press made while blocked and released after is no click. + let counter = d.content.counter_rect.unwrap().center(); + d.input_blocked = true; + d.step(vec![]); + d.window_step(id, vec![Event::PointerMoved(counter), button(counter, true)]); + d.input_blocked = false; + d.step(vec![]); + d.window_step(id, vec![button(counter, false)]); + assert_eq!(d.content.count, 0); +} + +#[test] +fn blocking_input_cancels_a_drag_in_progress() { + // A tab drag a native window owns. + let mut d = blocked_driver(); + let id = id_of(&d); + let over = d.window_local(id, ORIGIN + vec2(600.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + assert!(super::is_dragging::(&d.ctx, d.id)); + d.input_blocked = true; + d.step(vec![]); + assert!(!super::is_dragging::(&d.ctx, d.id)); + let out = d.window_release(id, over); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + + // A bar drag. + let mut d = blocked_driver(); + let id = id_of(&d); + d.window_hold(id, pos2(40.0, 9.0), pos2(140.0, 59.0)); + assert!(d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))).is_some()); + d.input_blocked = true; + d.step(vec![]); + assert!(d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))).is_none()); +} + +#[test] +fn replacing_the_layout_or_cancel_drag_ends_a_bar_drag() { + let moving = |d: &DockDriver| d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))); + for replace in [false, true] { + let mut d = driver(layout(true)); + let id = id_of(&d); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + assert!(moving(&d).is_some()); + d.step(vec![]); + if replace { + // The same window where the OS has it, beside swapped frames. + let mut replacement = layout(true); + replacement.root = Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(tabs(&[3, 4])), + second: Box::new(tabs(&[1, 2])), + }); + replacement.floating[0] = Floating { native: window(&d, 5).native, ..replacement.floating[0].clone().with_id(id) }; + d.layout = replacement; + } else { + super::cancel_drag::(&d.ctx, d.id); + } + d.settle(); + assert_eq!(moving(&d), None); + // The OS window goes where the layout has it. + let before = os_window(&d, 5).unwrap().0; + let native = window(&d, 5).native.unwrap(); + assert_eq!(before.min, pos2(native[0], native[1])); + d.window_step(id, vec![Event::PointerMoved(pos2(90.0, 39.0))]); + d.window_step(id, vec![Event::PointerMoved(pos2(120.0, 59.0))]); + assert_eq!(moving(&d), None); + assert_eq!(os_window(&d, 5).unwrap().0, before); + let out = d.window_step(id, vec![button(pos2(120.0, 59.0), false)]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::MoveWindow { .. })), "{:?}", out.actions); + } +} + +#[test] +fn replacing_the_layout_ends_a_divider_drag_in_a_native_window() { + let resizes = |out: &super::DockOutput| out.actions.iter().filter(|a| matches!(a, Action::ResizeWindowSplit { .. })).count(); + let split = |d: &DockDriver| match &window(d, 5).root { + Node::Split { size, .. } => *size, + root => panic!("{root:?}"), + }; + for replace in [false, true] { + let mut start = layout(false); + let mut float = Floating::single(vec![5], 0, [300.0, 80.0, 240.0, 300.0]); + float.root = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[5])), second: Box::new(tabs(&[6])) }; + start.floating.push(float); + let mut d = driver(start); + d.settle(); + let id = id_of(&d); + let out = d.window_hold(id, pos2(120.0, 150.0), pos2(150.0, 150.0)); + assert!(resizes(&out) > 0, "{:?}", out.actions); + if replace { + d.layout.root = Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(tabs(&[3, 4])), + second: Box::new(tabs(&[1, 2])), + }); + } + let before = split(&d); + let mut moved = 0; + for x in [160.0, 170.0, 180.0] { + moved += resizes(&d.window_step(id, vec![Event::PointerMoved(pos2(x, 150.0))])); + } + moved += resizes(&d.window_release(id, pos2(180.0, 150.0))); + if replace { + assert_eq!(moved, 0); + assert_eq!(split(&d), before); + } else { + assert!(moved > 0); + assert_ne!(split(&d), before); + } + } +} + +/// [`layout`] with window `[5, 6]` saved as a native window at `NATIVE`. +fn saved_native() -> Layout { + let mut layout = layout(true); + layout.floating[0].native = Some(NATIVE); + layout +} + +const NATIVE: [f32; 4] = [700.0, 400.0, 240.0, 300.0]; + +#[test] +fn with_native_windows_off_a_float_stays_in_the_app_window_and_keeps_its_native_rect() { + let mut d = DockDriver::new(saved_native(), Content::default()); + d.native_windows = false; + let mut d = d.with_native_host(ORIGIN); + assert!(d.host().windows.is_empty() && d.host().commands.is_empty()); + assert!(d.hit(&Hit::Title(5)).is_some(), "drawn in the main window"); + assert_eq!(window(&d, 5).native, Some(NATIVE)); + + // Its tab docks again by drag, in the main window. + let tab = d.hit(&Hit::Tab(5)).unwrap().center(); + let out = d.drag(tab, pos2(600.0, 300.0)); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 5, anchor: 3 | 4, .. }]), "{:?}", out.actions); + assert!(in_root(&d, 5)); + assert_eq!(window(&d, 6).native, Some(NATIVE)); + assert!(d.host().windows.is_empty() && d.host().commands.is_empty()); + + // The same workspace opens as a native window where native windows are on. + let mut d = driver(saved_native()); + d.settle(); + assert_eq!(os_window(&d, 5).map(|(rect, _)| rect), Some(Rect::from_min_size(pos2(700.0, 400.0), vec2(240.0, 300.0)))); +} + +#[test] +fn wayland_is_a_main_window_that_never_reported_a_position() { + use super::render::Session; + let mut d = driver(layout(false)); + d.host().no_position = true; + d.step(vec![]); + assert!(wayland(&d.ctx, Session::Wayland)); + assert!(wayland(&d.ctx, Session::Unknown), "a real window without a Wayland variable"); + assert!(!wayland(&d.ctx, Session::Other), "X11"); + // X11 (forced under a Wayland session) reports it; minimized, it reports none again. + d.host().no_position = false; + d.step(vec![]); + assert!(!wayland(&d.ctx, Session::Wayland)); + d.host().no_position = true; + d.step(vec![]); + assert!(!wayland(&d.ctx, Session::Wayland)); + // A context with no real window (a headless test) and no display variables at all. + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + assert!(!wayland(&ctx, Session::Unknown)); +} + +#[test] +fn the_session_follows_winits_environment_rule() { + use super::render::{Session, session}; + let with = |vars: &'static [&'static str]| move |name: &str| vars.contains(&name); + assert_eq!(session(true, with(&["WAYLAND_DISPLAY", "DISPLAY"])), Session::Wayland); + assert_eq!(session(true, with(&["WAYLAND_SOCKET"])), Session::Wayland); + assert_eq!(session(true, with(&["DISPLAY"])), Session::Other); + assert_eq!(session(true, with(&[])), Session::Unknown); + assert_eq!(session(false, with(&["WAYLAND_DISPLAY"])), Session::Other); +} diff --git a/crates/ui/src/docking/placement.rs b/crates/ui/src/docking/placement.rs new file mode 100644 index 00000000..82856c8d --- /dev/null +++ b/crates/ui/src/docking/placement.rs @@ -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, monitor_size: Option) -> [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 { + 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]); + } +} diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs new file mode 100644 index 00000000..4516da33 --- /dev/null +++ b/crates/ui/src/docking/render/cross.rs @@ -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, + /// Desktop position of the owner's (0, 0). `None` where the window system reports no + /// positions (Wayland). + pub origin: Option, + /// In the owner's coordinates. Kept while egui reports no position. + pub local: Option, + 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, + /// 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 { + 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

{ + pub window: u64, + /// Desktop position of the window's (0, 0), where known. + pub origin: Option, + /// The window's content, in its coordinates. + pub bounds: Rect, + pub targets: Vec>, + pub dock_targets: Vec>, + pub windows: Vec>, +} + +/// 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), +} + +/// What one frame of a dock area knows of its OS windows. +pub(in crate::docking) struct Cross

{ + /// 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, + /// 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>, + /// The drag's pointer, as its owner wrote it this frame. + pub pointer: Option, + /// 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, + /// 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

, +} + +impl

Default for Cross

{ + 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 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, origin: Option, window: Option<&WindowDrag>) -> DragPointer { + let key = self.id.with(POINTER); + let kept = ctx.data(|d| d.get_temp::(key)).filter(|p| p.owner == owner).and_then(|p| p.local); + let elsewhere = ctx.data_mut(|d| d.remove_temp::(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) { + let owner = ctx.data(|d| d.get_temp::(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

>) -> Option { + 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(&mut self, surface: &mut Surface

, 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(&mut self, window: u64, f: impl FnOnce(&mut Self) -> R) -> Option { + 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) -> Vec2 { + match (pointer.origin, to) { + (Some(from), Some(to)) => from - to, + _ => Vec2::ZERO, + } + } + + fn origin_of(&self, window: u64) -> Option { + 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

, input: DragInput) -> Option<(Drag

, 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::

(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

, input: DragInput) -> Option<(GroupDrag

, 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::

(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> { + let window: &Floating

= self.layout.floating.iter().find(|w| w.id == window)?; + let members: Vec

= 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

) { + let ctx = ui.ctx(); + let Some(pointer) = ctx.data(|d| d.get_temp::(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::>(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::>(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::>(id.with("drag")).is_some() + || d.get_temp::>(id.with("group-drag")).is_some() + || d.get_temp::(id.with("window-drag")).is_some() + }); + if !active { + ctx.data_mut(|d| d.remove::(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) -> 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>, 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()); + } + } +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs new file mode 100644 index 00000000..9fdbbc9a --- /dev/null +++ b/crates/ui/src/docking/render/dock.rs @@ -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

( + side: Side, + columns: &[Column

], + other: &Node

, + rect: Rect, + style: &DockStyle, + content: &dyn crate::docking::DockContent

, +) -> (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::().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

(column: &Column

, 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, + 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, +} + +/// 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 { + let saved = |g: &GroupSize| g.saved.filter(|h| h.is_finite()); + let open: Vec = (0..groups.len()).filter(|i| groups.get(*i).is_some_and(|g| !g.minimized)).collect(); + let unsaved = |i: &usize, test: fn(Option) -> bool| groups.get(*i).is_some_and(|g| saved(g).is_none() && test(g.content)); + let mut shared: Vec = 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 = open.iter().copied().filter(|i| !shared.contains(i)).collect(); + let mut defaulted: Vec = sized.iter().copied().filter(|i| groups.get(*i).is_some_and(|g| saved(g).is_none())).collect(); + let mut heights: Vec = 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 = groups.iter().map(|g| if g.minimized { strip } else { g.min }).collect(); + let compacts: Vec = 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::() + 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::()).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 = 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::() + 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)], room: f32) -> Vec { + let mut widths: Vec = 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 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

, + second: &Node

, + rect: Rect, + path: &mut Vec, + root: (&Node

, 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

], 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)> = + 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

) -> (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

) -> 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

) -> Option { + 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

, 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

, 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>, target: DockTarget

, 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 = (0..column.groups.len()).filter(|i| column.groups.get(*i).is_some_and(|g| !g.minimized)).collect(); + let row: Vec = 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

, 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> { + 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

, pointer: Pos2) -> Option<(Option>, 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>, 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

) -> Option>> { + 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(drag: &GroupDrag

, to: Target

) -> Action

{ + 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 }, + } +} diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs new file mode 100644 index 00000000..b1a2a419 --- /dev/null +++ b/crates/ui/src/docking/render/floating.rs @@ -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

{ + pub(super) window: Rect, + pub(super) members: Vec

, +} + +impl Renderer<'_, P> { + pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating

]) { + let screen = self.float_bounds; + if !crate::docking::targets::usable(screen) { + return; + } + let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); + 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

) -> 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

) -> 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

) -> 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

, 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::(key).is_some_and(|id| response.handles.iter().any(|h| h.id == id)) { + data.remove::(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

, 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

, 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

, 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::>(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> { + 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

, 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)), + } + } +} diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs new file mode 100644 index 00000000..618216ec --- /dev/null +++ b/crates/ui/src/docking/render/frame.rs @@ -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

{ + /// View path to layout path of each split, when the view is not the layout. + paths: Option, Vec>>, + /// The same for each floating window's splits, by the window's first shown panel. + window_paths: HashMap, Vec>>, + /// The view node whose rect is the outer edge area; none while a frame is maximized. + edge_path: Option>, + pub(super) edge: Option, + pub(super) maximized: Option

, + /// Floating windows as drawn, back to front. + pub(super) windows: Vec>, +} + +/// 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

, + maximized: Option<&P>, + hide: HidePanels<'_, P>, + content: &dyn DockContent

, +) -> (Cow<'a, Layout

>, FrameState

) { + 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 { + match place { + Place::Node(path) | Place::Dock { path, .. } => path.clone(), + Place::Floating { .. } => Vec::new(), + } +} + +fn visible

(node: &Node

, 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( + node: &Node

, + path: Vec, + at: Vec, + hidden: &dyn Fn(&P) -> bool, + paths: &mut HashMap, Vec>, +) -> Option> { + 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(group: &Group

, hidden: &dyn Fn(&P) -> bool) -> Option> { + let selected = group.active_panel(); + let panels: Vec

= 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 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 { + 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) { + 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::>(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 }); + } + } + } +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs new file mode 100644 index 00000000..8f737078 --- /dev/null +++ b/crates/ui/src/docking/render/group.rs @@ -0,0 +1,458 @@ +use std::{fmt::Debug, hash::Hash, sync::Arc}; + +use egui::{Color32, Galley, Id, Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; + +use super::{Renderer, clamp_float, stripless}; +use crate::docking::{Action, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; +use crate::tabs; + +/// Where a group is drawn. It decides the drop targets the group offers and its gestures. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::docking) enum GroupKind { + /// A `Tabs` node of the split tree: side drops split it. + Frame, + /// A group in a dock column. + Dock, + /// A `Tabs` node of the floating window drawn at `window`: side drops split it. + Floating { window: Rect }, +} + +/// The group a tab belongs to, for its menu and minimize gestures. +pub(super) struct TabGroup<'a, P> { + pub(super) panels: &'a [P], + pub(super) selected: &'a P, + pub(super) minimized: bool, + pub(super) kind: GroupKind, + pub(super) rect: Rect, +} + +/// The top band of a strip that drags the whole group. +const BORDER_BAND: f32 = 4.0; + +/// Width of the "…" button that lists the panels a strip has no room for. +const OVERFLOW_WIDTH: f32 = 22.0; + +/// Points of smooth (trackpad) scrolling that make one wheel notch. +const WHEEL_NOTCH: f32 = 40.0; + +impl Renderer<'_, P> { + pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { + let Some(selected) = panels.get(active) else { return }; + self.record_float(panels, rect.size(), rect.size()); + let policy = self.permissions(selected); + let splits = matches!(kind, GroupKind::Frame | GroupKind::Floating { .. }) && policy.accepts_splits; + let rtl = self.style.right_to_left; + if stripless(panels, &*self.content) { + self.contents(ui, selected, rect); + self.targets.push(DropTarget { + anchor: selected.clone(), + rect, + tab_strip: None, + tabs: vec![(selected.clone(), Rect::NOTHING)], + allow_splits: splits, + accepts_tabs: policy.accepts_tabs, + right_to_left: rtl, + }); + return; + } + let movable = panels.iter().all(|p| self.permissions(p).movable); + let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); + ui.painter().rect_filled(strip, 0.0, self.style.tab_background); + let floating = matches!(kind, GroupKind::Floating { .. }); + // Laid out left to right, then mirrored in a right-to-left layout: tabs from the right + // edge, the header (the host's ≡) at the left. + let mirror = |r: Rect| { + if rtl { + Rect::from_x_y_ranges((strip.left() + strip.right() - r.right())..=(strip.left() + strip.right() - r.left()), r.y_range()) + } else { + r + } + }; + let requested = self.content.group_header_width(panels, selected, floating, strip.width()); + let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; + if header_width > 0.0 { + let header = mirror(Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max)); + let mut child = ui.new_child(UiBuilder::new().scope_id(self.id.with(("header", selected))).max_rect(header)); + child.set_clip_rect(header.intersect(ui.clip_rect())); + self.content.group_header(&mut child, panels, selected, floating); + // A press in the header focuses the shown panel, as a press on its tab does. + if child.rect_contains_pointer(header) && child.input(|i| i.pointer.any_pressed()) { + self.output.focused = Some(selected.clone()); + } + } + // The drag bar keeps its reserved width: tabs shrink, then overflow, before it narrows. + let bar_width = self.style.drag_bar_width.clamp(0.0, (strip.width() - header_width).max(0.0)); + let tabs_width = (strip.width() - header_width - bar_width).max(0.0); + let widths: Vec = panels + .iter() + .map(|p| { + self.content + .label(ui, p, &self.label_of(p), &self.style.font, self.style.text) + .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone()) + .size() + .x + + TAB_PADDING + }) + .collect(); + // The widest tabs shrink first, to a common width no narrower than `min_tab_width`; only + // when every tab at that width cannot fit do the rest go to the overflow menu. A tab + // narrower than its name cuts it with "…" and names it in a tooltip. + let fit = tabs::fit(&widths, active, tabs_width, self.style.min_tab_width, OVERFLOW_WIDTH); + let owner = TabGroup { panels, selected, minimized, kind, rect }; + let mut x = strip.left(); + let mut shown = Vec::new(); + for (index, width) in &fit.shown { + let Some(panel) = panels.get(*index) else { continue }; + let tab = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height()))); + self.tab(ui, panel, tab, *index == active, rect, Some(&owner)); + shown.push((panel.clone(), tab)); + x += width; + } + if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { + let overflow = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height()))); + let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.panels_label)); + ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text); + egui::Popup::menu(&response).show(|ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for index in &fit.overflow { + if let Some(panel) = panels.get(*index) + && self.menu_label(ui, panel).clicked() + { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + ui.close(); + } + } + }); + }); + x += fit.overflow_width; + } + let bar = + mirror(Rect::from_min_max(pos2(x.min(strip.right() - header_width), strip.top()), pos2(strip.right() - header_width, strip.bottom()))); + self.group_handle(ui, "drag-bar", bar, &owner, movable); + // Registered last, so the band wins over the tabs below it. + let border = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom())))); + self.group_handle(ui, "drag-border", border, &owner, movable); + if self.style.wheel_selects_tabs { + let tabs_area = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, strip.bottom()))); + self.wheel_tabs(ui, tabs_area, panels, active); + } + // Logical order, hidden tabs included without a hit area, for no-op detection. + let tabs = + panels.iter().map(|panel| (panel.clone(), shown.iter().find(|(p, _)| p == panel).map_or(Rect::NOTHING, |(_, rect)| *rect))).collect(); + self.targets.push(DropTarget { + anchor: selected.clone(), + rect, + tab_strip: Some(strip), + tabs, + allow_splits: splits, + accepts_tabs: policy.accepts_tabs, + right_to_left: rtl, + }); + if !minimized { + self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); + } + ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + } + + /// The drag bar (`drag-bar`: drag, double-click, and the group menu on a right click, Enter + /// or Space) or the top border band (`drag-border`: drag only). Both move the whole group; + /// a left click on the bar only focuses it. + fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) { + if !crate::docking::targets::usable(hit) { + return; + } + let bar = name == "drag-bar"; + let sense = if bar { Sense::click_and_drag() } else { Sense::drag() }; + let response = ui.interact(hit, self.id.with((name, owner.panels.first())), sense); + 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_group_label)); + if movable && response.drag_started() { + self.start_group_drag(ui, owner.panels, self.drag_source(owner)); + } + if !bar { + return; + } + if self.minimizes(owner) && response.double_clicked() { + self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized }); + } + let active = response.has_focus() || response.hovered(); + if active { + ui.painter().with_clip_rect(hit).rect_stroke( + hit.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| self.group_menu(ui, owner, owner.selected)); + } + + /// The wheel over a group's strip: down selects the next tab, up the previous, one tab per + /// notch, stopping at the ends. Trackpad scrolling adds up to a notch. + fn wheel_tabs(&mut self, ui: &Ui, strip: Rect, panels: &[P], active: usize) { + let key = self.id.with(("tab-wheel", panels.first())); + if !ui.rect_contains_pointer(strip) { + ui.ctx().data_mut(|d| d.remove::(key)); + return; + } + let notches: f32 = ui.input(|i| { + i.events + .iter() + .map(|event| match event { + egui::Event::MouseWheel { unit, delta, modifiers, .. } if !modifiers.command && !modifiers.ctrl => match unit { + egui::MouseWheelUnit::Point => delta.y / WHEEL_NOTCH, + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y, + }, + _ => 0.0, + }) + .sum() + }); + if notches == 0.0 || !notches.is_finite() { + return; + } + let total = ui.ctx().data(|d| d.get_temp::(key)).unwrap_or(0.0) + notches; + let steps = total.trunc(); + ui.ctx().data_mut(|d| d.insert_temp(key, total - steps)); + // A wheel turned down scrolls content up: a negative delta selects the next tab. + let to = (active as i64).saturating_sub(steps as i64).clamp(0, panels.len().saturating_sub(1) as i64) as usize; + if to != active + && let Some(panel) = panels.get(to) + { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + } + } + + fn start_group_drag(&mut self, ui: &Ui, panels: &[P], rect: Rect) { + let Some(first) = panels.first() else { return }; + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(rect.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [rect.left(), rect.top(), rect.width(), rect.height()], + gesture: GroupGesture::Group, + members: panels.to_vec(), + origin, + source: rect, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + + /// The group items of the menu tail: Close Tab Group, Minimize or Expand, Float Tab Group + /// and Move Group To. `panel` is the panel whose menu this is. + fn group_menu(&mut self, ui: &mut Ui, owner: &TabGroup<'_, P>, panel: &P) { + let all = |check: &dyn Fn(crate::docking::Permissions) -> bool| owner.panels.iter().all(|p| check(self.permissions(p))); + let closable = self.style.close_menu_items && all(&|p| p.closable); + let movable = all(&|p| p.movable); + let floatable = movable && all(&|p| p.floatable); + if closable && self.menu_action(ui, &self.style.close_group_label.clone()) { + self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); + } + if self.minimizes(owner) { + let label = if owner.minimized { self.style.expand_label.clone() } else { self.style.minimize_label.clone() }; + if self.menu_action(ui, &label) { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); + } + } + if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) { + self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true }); + } + let whole_window = matches!(owner.kind, GroupKind::Floating { .. }) && self.whole_window(panel); + if floatable && !whole_window && self.menu_action(ui, &self.style.float_group_label.clone()) { + let size = self.group_float_size(owner.panels, owner.rect.size()); + let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds); + self.output.actions.push(Action::MoveGroup { + panel: panel.clone(), + to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }, + }); + } + let targets = if movable { self.group_targets(owner.panels) } else { Vec::new() }; + if !targets.is_empty() { + let logical = self.style.move_group_to_label.clone(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); + let response = ui.menu_button(text, |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for target in &targets { + if self.menu_label(ui, target).clicked() { + self.output.actions.push(Action::MoveGroup { + panel: panel.clone(), + to: Target::At { anchor: target.clone(), placement: Placement::Tab { before: None } }, + }); + ui.close(); + } + } + }); + }); + response.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &logical)); + } + self.frame_items(ui, owner, panel); + } + + /// A stock menu button with shaped text and its logical accessibility label; true when + /// clicked, after closing the menu. + pub(super) fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { + let text = self.content.action_label(ui, logical, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), logical)); + if response.clicked() { + ui.close(); + } + response.clicked() + } + + /// One anchor per other group that accepts a whole group as tabs, in layout order. + fn group_targets(&self, members: &[P]) -> Vec

{ + let Some(first) = members.first() else { return Vec::new() }; + let mut places = Vec::new(); + let mut anchors = Vec::new(); + for anchor in self.layout.move_targets(first, |p| self.permissions(p)) { + if members.contains(anchor) { + continue; + } + let Some(place) = self.place(anchor) else { continue }; + if !places.contains(&place) { + places.push(place); + anchors.push(anchor.clone()); + } + } + anchors + } + + pub(super) fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response { + let logical = self.label_of(panel); + let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &logical)); + response + } + + /// A tab's label, cut at the end with '…' to fit `width`. + pub(super) fn tab_label(&mut self, ui: &Ui, panel: &P, label: &str, color: Color32, width: f32) -> Arc { + let text = self.content.label(ui, panel, label, &self.style.font, color); + crate::docking::chrome::truncated(ui, text, width, self.style.font.clone()) + } + + /// True while a tab's focus came from the pointer, which shows no focus outline. Tab or an + /// arrow key (keyboard navigation) clears it. + fn pointer_focus(&self, ui: &Ui, tab: Id) -> bool { + let key = tab.with("pointer-focus"); + let navigated = ui.input(|i| [Key::Tab, Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown].iter().any(|k| i.key_pressed(*k))); + if navigated { + ui.ctx().data_mut(|d| d.remove_temp::(key)); + } + ui.ctx().data(|d| d.get_temp::(key)).unwrap_or(false) + } + + pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect, owner: Option<&TabGroup<'_, P>>) { + let minimized = owner.filter(|owner| self.minimizes(owner)).map(|owner| owner.minimized); + let policy = self.permissions(panel); + let label = self.label_of(panel); + let color = if active { self.style.text } else { self.style.inactive_text }; + let galley = self.tab_label(ui, panel, &label, color, (rect.width() - TAB_PADDING).max(0.0)); + let elided = galley.elided; + let tab_id = self.id.with(("tab", panel)); + let pointer_focus = self.pointer_focus(ui, tab_id); + let response = tabs::Tab::new(tab_id, &label, active) + .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) + .focus_stroke(if pointer_focus { Stroke::NONE } else { Stroke::new(1.0, self.style.accent) }) + .show_at(ui, rect, |ui, response| { + let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); + let at = pos2(rect.left() + TAB_PADDING * 0.5, rect.center().y - galley.size().y * 0.5); + painter.galley(at, galley.clone(), color); + }); + let response = if elided { response.on_hover_text(&label) } else { response }; + self.register_interaction(ui.ctx(), response.id); + if response.is_pointer_button_down_on() || response.clicked() { + ui.ctx().data_mut(|d| d.insert_temp(tab_id.with("pointer-focus"), true)); + } + // The click that expands a minimized group is not the first half of a double-click. + let expand_key = self.id.with(("expanded-by-click", owner.map(|owner| owner.panels.first()))); + if response.clicked() { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + if minimized == Some(true) { + // Activate already expands the group of its active tab. + if !active { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: false }); + } + let now = ui.input(|i| i.time); + ui.ctx().data_mut(|d| d.insert_temp(expand_key, now)); + } + } + if let Some(minimized) = minimized + && response.double_clicked() + { + let (now, window) = (ui.input(|i| i.time), ui.ctx().options(|o| o.input_options.max_double_click_delay)); + let expanded_by_first_click = ui.ctx().data_mut(|d| d.remove_temp::(expand_key)).is_some_and(|t| now - t <= window); + if !expanded_by_first_click { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); + } + } + if self.style.middle_click_closes_tabs && policy.closable && response.clicked_by(egui::PointerButton::Middle) { + self.output.actions.push(Action::Close { panel: panel.clone() }); + } + if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) { + self.latch_undock(ui.ctx(), owner.map(|owner| owner.kind)); + } + response.context_menu(|ui| { + self.content.panel_menu(ui, panel); + if policy.movable && policy.floatable { + let logical = self.style.float_label.clone(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &logical)); + if response.clicked() { + let size = self.float_size(panel, group.size()); + let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), self.float_bounds); + self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); + ui.close(); + } + } + let targets: Vec

= + if policy.movable { self.layout.move_targets(panel, |p| self.permissions(p)).into_iter().cloned().collect() } else { Vec::new() }; + if !targets.is_empty() { + let text = self.content.action_label(ui, &self.style.move_panel_to_label, &self.style.font, self.style.text); + let response = ui.menu_button(text, |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for target in &targets { + if self.menu_label(ui, target).clicked() { + self.output.actions.push(Action::Move { + panel: panel.clone(), + anchor: target.clone(), + placement: Placement::Tab { before: None }, + }); + ui.close(); + } + } + }); + }); + response.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_panel_to_label)); + } + if policy.closable && self.style.close_menu_items { + let text = self.content.action_label(ui, &self.style.close_label, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.close_label)); + if response.clicked() { + self.output.actions.push(Action::Close { panel: panel.clone() }); + ui.close(); + } + } + if let Some(owner) = owner { + ui.separator(); + self.group_menu(ui, owner, panel); + } + }); + } +} diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs new file mode 100644 index 00000000..360800d0 --- /dev/null +++ b/crates/ui/src/docking/render/iconic.rs @@ -0,0 +1,350 @@ +//! [D] Iconic columns: one icon per panel, groups apart, and the flyout an icon opens. +//! +//! Behavior follows VectorCraft `dock.rs` (icon column, flyouts beside it on the canvas side, +//! names when the column is widened) and PhotoCraft `dock.rs` (Auto-Collapse Iconic Panels). + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Id, Key, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; + +use super::{GroupKind, Renderer, dock::Edge}; +use crate::docking::{ + Column, DockStyle, Group, GroupDrag, GroupGesture, begin_drag, + chrome::{ChromeStyle, FloatingFrame}, + is_dragging, + targets::{DockDrop, DockTarget, horizontal_line, usable}, +}; + +/// How far an icon group's drag band reaches into its first icon. The band also covers the +/// separator room above the group; it reserves no height of its own. +const GROUP_BAND: f32 = 4.0; +/// Room between two icon groups, with the separator line in its middle. +const SEPARATOR: f32 = 7.0; +/// Height of a flyout, or of a panel floated from an icon, when nothing else sizes it. +const FLYOUT_HEIGHT: f32 = 320.0; + +/// The panel whose icon opened the flyout of an area. +#[derive(Clone)] +struct Open

(P); + +fn key(area: Id) -> Id { + area.with("flyout") +} + +/// [D] Open the flyout of the group holding `panel`, when that group is in an iconic column. +/// The flyout shows from the next frame on. [`crate::docking::DockArea::raise_panel`] does the +/// same for a panel the Window menu opens. +pub fn open_flyout(ctx: &Context, area_id: Id, panel: P) { + ctx.data_mut(|d| d.insert_temp(key(area_id), Open(panel))); +} + +/// [D] Close the area's flyout. +pub fn close_flyout(ctx: &Context, area_id: Id) { + ctx.data_mut(|d| d.remove::>(key(area_id))); +} + +/// [D] The panel whose icon opened the area's flyout, while one is open. +pub fn flyout(ctx: &Context, area_id: Id) -> Option

{ + ctx.data(|d| d.get_temp::>(key(area_id))).map(|open| open.0) +} + +/// The flyout last reported by [`crate::docking::DockEvent::FlyoutChanged`]. +#[derive(Clone)] +struct Reported

(Option

); + +/// The flyout an iconic column asks for this frame. +struct Request

{ + group: Group

, + /// The icons of the group, where the flyout's top aligns. + icons: Rect, + /// The iconic column, which a press does not close the flyout in. + column: Rect, + edge: Edge, + /// The column's expanded width. + width: f32, +} + +/// Per-frame iconic state of a renderer. +pub(in crate::docking) struct Iconic

{ + auto_collapse: bool, + /// Iconic column bodies drawn this frame, outlined by a drop into them (P9). + columns: Vec, + /// Expanded column width of each panel drawn as an icon: a panel floated from its icon + /// takes it. + widths: Vec<(P, f32)>, + request: Option>, + /// The flyout drawn this frame; it occludes dock targets behind it. + rect: Option, +} + +impl Iconic

{ + pub(in crate::docking) fn new(auto_collapse: bool) -> Self { + Self { auto_collapse, columns: Vec::new(), widths: Vec::new(), request: None, rect: None } + } + + pub(super) fn covers(&self, pointer: Pos2) -> bool { + self.rect.is_some_and(|rect| rect.contains(pointer)) + } + + /// Outline the iconic column an insertion line lies in. + pub(super) fn outline(&self, painter: &egui::Painter, line: Rect, style: &DockStyle) { + if let Some(column) = self.columns.iter().find(|column| column.shrink(1.0).contains(line.center())) { + painter.rect_stroke(column.shrink(1.0), 0.0, Stroke::new(2.0, style.accent), StrokeKind::Inside); + } + } + + pub(super) fn float_width(&self, panel: &P) -> Option { + self.widths.iter().find(|(p, _)| p == panel).map(|(_, width)| *width) + } +} + +impl Renderer<'_, P> { + fn row_height(&self) -> f32 { + self.style.icon_size + 10.0 + } + + fn icon_group_height(&self, group: &Group

) -> f32 { + group.panels.len() as f32 * self.row_height() + } + + /// The body of an iconic column: icon groups top to bottom, scrolling when they do not fit. + pub(super) fn iconic_column(&mut self, ui: &mut Ui, column: &Column

, body: Rect, edge: Edge) { + self.iconic.columns.push(body); + for panel in column.groups.iter().flat_map(|g| g.panels.iter()) { + self.iconic.widths.push((panel.clone(), column.width)); + } + // Opening a panel from the Window menu opens its flyout (U3). + if let Some(panel) = self.activation.as_ref().filter(|p| column.groups.iter().any(|g| g.panels.contains(p))) { + open_flyout(ui.ctx(), self.id, panel.clone()); + } + let total: f32 = + column.groups.iter().map(|g| self.icon_group_height(g)).sum::() + column.groups.len().saturating_sub(1) as f32 * SEPARATOR; + if total > body.height() + 0.5 { + let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { return }; + let mut child = + ui.new_child(UiBuilder::new().id_salt(("icon-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(("icon-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 (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover()); + self.icon_groups(ui, column, area, body, edge); + }); + } else { + self.icon_groups(ui, column, body, body, edge); + } + } + + /// Icon groups in `area` (the scrolled content when the column scrolls), their drop bands + /// clipped to the visible `view`. + fn icon_groups(&mut self, ui: &mut Ui, column: &Column

, area: Rect, view: Rect, edge: Edge) { + let open = flyout::

(ui.ctx(), self.id); + let labels = view.width() >= self.style.iconic_label_width; + let xs = view.x_range(); + let mut top = area.top(); + let mut bands = Vec::new(); + for (index, group) in column.groups.iter().enumerate() { + let Some(first) = group.panels.first() else { continue }; + let band_top = if index > 0 { top } else { area.top() }; + if index > 0 { + let y = top + SEPARATOR * 0.5; + ui.painter().with_clip_rect(view).hline(area.x_range().shrink(4.0), y, self.style.border); + top += SEPARATOR; + } + let rect = Rect::from_min_size(pos2(area.left(), top), vec2(area.width(), self.icon_group_height(group))); + let showing = open.as_ref().filter(|p| group.panels.contains(p)); + let mut y = rect.top(); + for panel in &group.panels { + let cell = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), self.row_height())); + self.icon(ui, panel, cell, labels, showing); + y = cell.bottom(); + } + // After the icons, so the band wins the top of the first icon. + let band = Rect::from_min_max(pos2(rect.left(), band_top), pos2(rect.right(), (rect.top() + GROUP_BAND).min(rect.bottom()))); + self.icon_grip(ui, group, band, rect.top(), column.width); + // The state again: a click above may have opened or closed this group's flyout. + if flyout::

(ui.ctx(), self.id).is_some_and(|p| group.panels.contains(&p)) { + self.iconic.request = Some(Request { group: group.clone(), icons: rect.intersect(view), column: view, edge, width: column.width }); + } + bands.push((first.clone(), rect)); + top = rect.bottom(); + } + // P9: the upper half of an icon group inserts above it, the lower half and the room + // under the last group below it. The line sits on the group's edge. + let count = bands.len(); + for (index, (anchor, rect)) in bands.into_iter().enumerate() { + let middle = rect.center().y; + let upper_top = if index == 0 { view.top() } else { rect.top() - SEPARATOR * 0.5 }; + let lower_bottom = if index + 1 == count { view.bottom() } else { rect.bottom() + SEPARATOR * 0.5 }; + for (below, ys, line) in [(false, upper_top..=middle, rect.top()), (true, middle..=lower_bottom, rect.bottom())] { + let band = Rect::from_x_y_ranges(xs, ys).intersect(view); + if usable(band) { + let line = horizontal_line(line.clamp(view.top() + 2.0, view.bottom() - 2.0), xs); + self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line }); + } + } + } + } + + /// The band on an icon group's top edge drags the whole group (S8). `top` is the group's + /// first icon's top, where a floated group's source starts. + fn icon_grip(&mut self, ui: &mut Ui, group: &Group

, grip: Rect, top: f32, width: f32) { + let Some(first) = group.panels.first() else { return }; + let movable = group.panels.iter().all(|p| self.permissions(p).movable); + let response = ui.interact(grip, self.id.with(("icon-grip", Some(first))), Sense::drag()); + let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; + self.register_interaction(ui.ctx(), response.id); + let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT); + let source = Rect::from_min_size(pos2(grip.left(), top), vec2(width.max(self.style.min_pane), height.max(self.style.tab_height))); + self.record_float(&group.panels, vec2(grip.width(), self.row_height()), source.size()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_group_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: [source.left(), source.top(), source.width(), source.height()], + gesture: GroupGesture::Group, + members: group.panels.clone(), + origin, + source, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + close_flyout::

(ui.ctx(), self.id); + } + } + + /// One panel's icon: a click opens, switches or closes the flyout (M5); a drag moves the + /// panel (S7). `showing` is the panel whose icon opened this group's flyout. + fn icon(&mut self, ui: &mut Ui, panel: &P, cell: Rect, labels: bool, showing: Option<&P>) { + let movable = self.permissions(panel).movable; + let label = self.label_of(panel); + let response = ui.interact(cell, self.id.with(("icon", panel)), if movable { Sense::click_and_drag() } else { Sense::click() }); + self.register_interaction(ui.ctx(), response.id); + let current = showing == Some(panel); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), current, &label)); + let painter = ui.painter().with_clip_rect(cell.intersect(ui.clip_rect())); + if current || response.hovered() { + painter.rect_filled(cell.shrink(2.0), 3.0, self.style.active_background); + } + if response.has_focus() { + painter.rect_stroke(cell.shrink(1.0), 3.0, Stroke::new(1.0, self.style.accent), StrokeKind::Inside); + } + let size = self.style.icon_size; + let inset = ((self.style.iconic_width - size) * 0.5).max(2.0); + let icon = Rect::from_min_size(pos2(cell.left() + inset, cell.center().y - size * 0.5), vec2(size, size)); + let color = if current { self.style.text } else { self.style.inactive_text }; + if !self.content.paint_icon(&painter, icon, panel, color) { + if let Some(source) = self.content.icon(panel) { + egui::Image::new(source).tint(color).paint_at(ui, icon.intersect(cell)); + } else { + let initial: String = label.chars().next().into_iter().flat_map(char::to_uppercase).collect(); + painter.rect_stroke(icon, 3.0, Stroke::new(1.0, color), StrokeKind::Inside); + painter.text(icon.center(), egui::Align2::CENTER_CENTER, initial, self.style.font.clone(), color); + } + } + if labels { + let text = self.content.label(ui, panel, &label, &self.style.font, color); + let galley = + text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone()); + painter.galley(pos2(icon.right() + 6.0, cell.center().y - galley.size().y * 0.5), galley, color); + } else if response.hovered() && !response.dragged() { + response.clone().on_hover_text(&label); + } + if response.clicked() { + if current { + close_flyout::

(ui.ctx(), self.id); + } else { + if showing.is_none() { + ui.ctx().move_to_top(FloatingFrame::layer(self.id.with("flyout"))); + } + open_flyout(ui.ctx(), self.id, panel.clone()); + self.output.actions.push(crate::docking::Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + } + } + if movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), cell) { + close_flyout::

(ui.ctx(), self.id); + } + } + + /// The Auto-Collapse Iconic Panels item of a column menu (U1): true when chosen. + pub(super) fn auto_collapse_item(&mut self, ui: &mut Ui) -> bool { + let logical = self.style.auto_collapse_label.clone(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); + let mut on = self.iconic.auto_collapse; + let response = ui.add(egui::Checkbox::new(&mut on, text)); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), self.iconic.auto_collapse, &logical)); + if response.clicked() { + ui.close(); + } + response.clicked() + } + + /// Draw the flyout an iconic column asked for, beside the column on the canvas side, or + /// forget a flyout whose group is no longer iconic. With Auto-Collapse, Escape or a press + /// outside the flyout and its column closes it. + pub(in crate::docking) fn flyout(&mut self, ctx: &Context) { + let Some(request) = self.iconic.request.take() else { + close_flyout::

(ctx, self.id); + return; + }; + let Some(active) = request.group.active_panel().cloned() else { return }; + let id = self.id.with("flyout"); + if self.iconic.auto_collapse && self.flyout_dismissed(ctx, id, request.column) { + close_flyout::

(ctx, self.id); + return; + } + let bounds = self.workspace.intersect(self.float_bounds); + let width = request.width.max(self.style.min_pane).min(bounds.width()); + let max = self + .content + .floating_size(&active) + .map(|size| size.y) + .or(request.group.height) + .filter(|h| h.is_finite() && *h > 0.0) + .unwrap_or(FLYOUT_HEIGHT); + // A flyout fits the content its panel last drew, up to that height. + let height = self + .fitted_height(&active, self.style.tab_height, max) + .unwrap_or(max) + .max(self.style.tab_height + self.style.min_pane) + .min(bounds.height()); + let left = match request.edge { + Edge::Left => request.column.right(), + Edge::Right => request.column.left() - width, + }; + let top = request.icons.top().min(bounds.bottom() - height).max(bounds.top()); + let rect = Rect::from_min_size(pos2(left, top), vec2(width, height)); + let style = ChromeStyle::from_dock(self.style); + let group = request.group; + let response = FloatingFrame::new(id, rect).bounds(bounds).resizable(false).show(ctx, &style, |ui, rect| { + self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock); + }); + self.iconic.rect = Some(response.rect); + } + + /// Report a flyout that differs from the last one reported. + pub(in crate::docking) fn report_flyout(&mut self, ctx: &Context) { + let shown = flyout::

(ctx, self.id); + let key = self.id.with("flyout-reported"); + let reported = ctx.data(|d| d.get_temp::>(key)).and_then(|r| r.0); + if shown != reported { + ctx.data_mut(|d| d.insert_temp(key, Reported(shown.clone()))); + self.output.events.push(crate::docking::DockEvent::FlyoutChanged { panel: shown }); + } + } + + /// Escape (outside a drag) or a primary press outside the flyout, its column and any menu. + fn flyout_dismissed(&self, ctx: &Context, id: Id, column: Rect) -> bool { + let escape = !is_dragging::

(ctx, self.id) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + let press = ctx.input(|i| i.pointer.primary_pressed().then(|| i.pointer.press_origin()).flatten()); + let outside = press.is_some_and(|pos| { + let inside = ctx.memory(|m| m.area_rect(id)).is_some_and(|r| r.contains(pos)) || column.contains(pos); + let menu = ctx.layer_id_at(pos).is_some_and(|layer| matches!(layer.order, Order::Foreground | Order::Tooltip)); + !inside && !menu + }); + escape || outside + } +} diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs new file mode 100644 index 00000000..de94342f --- /dev/null +++ b/crates/ui/src/docking/render/mod.rs @@ -0,0 +1,460 @@ +use std::{collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, CursorIcon, Id, Rect, Sense, Ui, UiBuilder, Vec2, pos2, vec2}; + +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; + +use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions, dock_model::Place}; + +#[cfg(feature = "native-windows")] +mod cross; +mod dock; +mod floating; +mod frame; +mod iconic; +#[cfg(feature = "native-windows")] +mod native; +#[cfg(feature = "native-windows")] +pub(in crate::docking) use native::WindowDrag; +#[cfg(all(test, feature = "native-windows"))] +pub(in crate::docking) use { + cross::DragPointer, + native::{APPKIT_CORNER, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, floats_shown, resize_bands, session, wayland}, +}; + +#[cfg(feature = "native-windows")] +pub(in crate::docking) use native::{InputHook, set_input_hook}; + +/// The viewport of the native window `window` of the dock area `area`. +#[cfg(feature = "native-windows")] +pub(in crate::docking) fn native_viewport(area: Id, window: u64) -> egui::ViewportId { + native::viewport_id(area, window, 0) +} + +pub(in crate::docking) use frame::{FrameState, view}; + +pub(in crate::docking) use group::GroupKind; + +pub(in crate::docking) use iconic::Iconic; +pub use iconic::{close_flyout, flyout, open_flyout}; + +#[cfg(test)] +pub(in crate::docking) use dock::{GroupSize, fit_widths, group_heights}; +pub(in crate::docking) use floating::RESIZE_HANDLES; +mod group; +mod preview; +mod sizes; + +pub(in crate::docking) use sizes::{DROP_MARGIN, Drawn, RowItem, Sizer, row_drag, row_range}; + +pub(super) struct Renderer<'a, P> { + pub(super) id: Id, + pub(super) style: &'a DockStyle, + pub(super) content: &'a mut dyn DockContent

, + pub(super) activation: Option

, + pub(super) blockers: Vec, + /// [`crate::docking::DockArea::locked`]. + pub(super) locked: bool, + pub(super) layout: &'a Layout

, + /// The docked area, for host drop requests. + pub(super) workspace: Rect, + /// Where floating windows, flyouts and drag ghosts go: [`crate::docking::DockArea::float_bounds`]. + pub(super) float_bounds: Rect, + pub(super) targets: Vec>, + /// [D] Seams, group bands, column edges and new-dock bands, see [`crate::docking::targets::DockDrop`]. + pub(super) dock_targets: Vec>, + pub(super) owned_interactions: Vec, + pub(super) output: DockOutput

, + pub(super) frame: FrameState

, + /// [D] Flyout and iconic column state for this frame. + pub(super) iconic: Iconic

, + /// What a drag would float from each drawn panel, group and column, for commands. + pub(super) float_space: crate::docking::FloatSpace

, + /// Each panel's place in `layout`, and its entry in `float_space.contents`. + pub(super) places: HashMap, + pub(super) contents: HashMap, + /// Split dividers drawn so far, by view path, for the intersections of their ancestors. + pub(super) dividers: Vec, + /// The floating window whose tree is being drawn: its first panel, which names it in + /// ids and actions, and its rect. `None` while the docked layout draws. + pub(super) tree: Option<(P, Rect)>, + /// [`crate::docking::DockArea::monitors`]. + #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] + pub(super) monitors: Vec, + /// [`crate::docking::DockArea::input_blocked`]. + #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] + pub(super) input_blocked: bool, + /// [`crate::docking::DockArea::native_windows`]. + #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] + pub(super) native_windows: Option, + /// The other OS windows a drag may cross into. + #[cfg(feature = "native-windows")] + pub(super) cross: cross::Cross

, +} + +pub(super) struct DrawnDivider { + path: Vec, + axis: SplitAxis, + divider: Rect, +} + +impl Renderer<'_, P> { + pub(super) fn place(&self, panel: &P) -> Option<&Place> { + self.places.get(panel) + } + + /// The height `panel`'s content last used: `Some(None)` when it filled its body. + pub(super) fn content_height(&self, panel: &P) -> Option> { + self.contents.get(panel).and_then(|i| self.float_space.contents.get(*i)).map(|(_, height)| *height) + } + + pub(super) fn label_of(&self, panel: &P) -> String { + self.content.logical_label(panel) + } + + pub(super) fn permissions(&self, panel: &P) -> Permissions { + let permissions = self.content.permissions(panel); + if self.locked { permissions.locked() } else { permissions } + } + + pub(super) fn limits(&self, panel: &P) -> PanelLimits { + self.content.limits(panel) + } + + pub(super) fn register_interaction(&mut self, ctx: &Context, id: Id) { + // A validated tree has at most 2*MAX_PANELS nodes. Tabs, stack dividers, + // floating grips/corners and splits fit comfortably inside this fixed bound. + const CAP: usize = 8 * crate::docking::MAX_PANELS; + if self.owned_interactions.len() < CAP && !self.owned_interactions.contains(&id) { + self.owned_interactions.push(id); + } + // Retain prior-frame controls until return, and publish current controls before + // app callbacks. No Context lock spans a callback or a stop_dragging call. + ctx.data_mut(|d| { + let ids = d.get_temp_mut_or_default::>(self.id.with("owned-interactions")); + if ids.len() < CAP && !ids.contains(&id) { + ids.push(id); + } + }); + } + + /// Draws `node` at view path `path` in `rect`, in the tree `root` (its root node and rect), + /// which a dragged divider resizes beyond `node`. + pub(in crate::docking) fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec, root: (&Node

, Rect)) { + if !crate::docking::targets::usable(rect) { + return; + } + self.note_edge(path, rect); + match node { + Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path, root) => {} + Node::Split { axis, size, first, second } => { + let sizer = self.sizer(); + let Some(split) = sizer.split(*axis, *size, first, second, rect) else { return }; + let (lo, hi) = sizer.range(root, path); + let mark = self.dividers.len(); + path.push(false); + self.node(ui, first, split.first, path, root); + path.pop(); + path.push(true); + self.node(ui, second, split.second, path, root); + path.pop(); + // The divider's inner-edge hit margin must win over child scrollbars, just + // as accordion dividers win over the adjacent panel's content. + // Named by its layout path, which `ResizeSplit` takes and hits report. + let layout_path = self.original_path(path); + let response = ResizeHandle::new( + self.split_id(&layout_path), + *axis, + split.divider, + &self.style.resize_label, + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + ) + .value(split.first_size(), (split.first_size() + lo)..=(split.first_size() + hi)) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + if response.delta != 0.0 { + let actions = self.divider_actions(root, path, response.delta); + self.output.actions.extend(actions); + } + self.intersections(ui, (node, rect), path, split.divider, mark, root); + if self.dividers.len() < crate::docking::model::MAX_NODES { + self.dividers.push(DrawnDivider { path: path.clone(), axis: *axis, divider: split.divider }); + } + } + Node::Tabs { panels, active } => { + let kind = match &self.tree { + None => GroupKind::Frame, + Some((_, window)) => GroupKind::Floating { window: *window }, + }; + self.group(ui, panels, *active, false, rect, kind); + } + Node::Dock { side, columns } => self.dock(ui, *side, columns, rect, rect.width()), + Node::Stack { entries } => { + let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); + let remaining = (rect.height() - total_header).max(0.0); + let total_wanted: f64 = entries.iter().filter(|e| e.open).map(|e| f64::from(e.height.unwrap_or(self.style.min_pane).max(1.0))).sum(); + let header = self.style.tab_height.min(rect.height() / entries.len().max(1) as f32); + let mut top = rect.top(); + let mut dividers = Vec::new(); + for (index, entry) in entries.iter().enumerate() { + let share = if entry.open && total_wanted > 0.0 { + (f64::from(remaining) * f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) / total_wanted) as f32 + } else { + 0.0 + }; + let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom()))); + let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom()))); + self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect, None); + let collapse = if self.style.right_to_left { + Rect::from_min_max(title_rect.min, pos2((title_rect.left() + 18.0).min(title_rect.right()), title_rect.bottom())) + } else { + Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max) + }; + let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); + self.register_interaction(ui.ctx(), response.id); + response + .widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), entry.open, self.label_of(&entry.panel))); + ui.painter().with_clip_rect(collapse).text( + collapse.center(), + egui::Align2::CENTER_CENTER, + if entry.open { "−" } else { "+" }, + self.style.font.clone(), + self.style.text, + ); + if response.clicked() { + self.output.actions.push(Action::SetStackOpen { panel: entry.panel.clone(), open: !entry.open }); + } + let body_rect = Rect::from_min_max(pos2(rect.left(), title_rect.bottom()), panel_rect.max); + if entry.open { + self.contents(ui, &entry.panel, body_rect); + } + self.targets.push(DropTarget { + anchor: entry.panel.clone(), + rect: panel_rect, + tab_strip: Some(title_rect), + tabs: vec![(entry.panel.clone(), title_rect)], + allow_splits: self.permissions(&entry.panel).accepts_splits, + accepts_tabs: self.permissions(&entry.panel).accepts_tabs, + right_to_left: self.style.right_to_left, + }); + top = panel_rect.bottom(); + if entry.open && remaining > 0.0 { + dividers.push((index, Rect::from_min_max(pos2(rect.left(), top - 1.0), pos2(rect.right(), top + 1.0)).intersect(rect))); + } + } + // Register dividers after the headers so their narrow hit regions win at the + // shared boundary. Closed entries retain their saved sizes and are skipped. + let scale = if total_wanted > 0.0 { f64::from(remaining) / total_wanted } else { 0.0 }; + let body_limits = |panel: &P| { + let mut limits = self.limits(panel); + limits.min.y = limits.min.y.max(self.style.min_pane); + limits.max = limits.max.max(limits.min); + limits + }; + let open: Vec<&crate::docking::StackEntry

> = entries.iter().filter(|entry| entry.open).collect(); + let row: Vec = open + .iter() + .map(|entry| { + let limits = body_limits(&entry.panel); + let size = (f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) * scale) as f32; + RowItem { size, min: limits.min.y, max: limits.max.y } + }) + .collect(); + for (index, divider) in dividers { + let Some(first) = entries.get(index) else { continue }; + let Some(position) = open.iter().position(|entry| std::ptr::eq(*entry, first)) else { continue }; + let Some(first_size) = row.get(position).map(|item| item.size) else { continue }; + if position + 1 >= row.len() { + continue; + } + let (lo, hi) = row_range(&row, position); + let response = ResizeHandle::new( + self.id.with(("stack-resize", &first.panel)), + SplitAxis::Vertical, + divider, + &self.style.resize_label, + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + ) + .value(first_size, (first_size + lo)..=(first_size + hi)) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + if response.delta != 0.0 && scale > 0.0 { + for ((entry, item), next) in open.iter().zip(&row).zip(row_drag(&row, position, response.delta)) { + if next != item.size { + let height = (f64::from(next) / scale).clamp(1.0, 1_000_000.0) as f32; + self.output.actions.push(Action::ResizeStack { panel: entry.panel.clone(), height: Some(height) }); + } + } + } + } + } + } + } + + pub(super) fn sizer(&self) -> Sizer<'_, P> { + Sizer { style: self.style, content: &*self.content } + } + + /// `ResizeSplit` actions that move the divider of the split at view path `path` in the + /// tree `root` by `delta` points. + fn divider_actions(&self, root: (&Node

, Rect), path: &[bool], delta: f32) -> Vec> { + let sizes = self.sizer().drag(root, path, delta); + let resize = |path: Vec, size| match &self.tree { + None => Action::ResizeSplit { path, size }, + Some((panel, _)) => Action::ResizeWindowSplit { panel: panel.clone(), path, size }, + }; + sizes.into_iter().map(|(path, size)| resize(self.original_path(&path), size)).collect() + } + + /// The id of the divider of the split at `path` in the tree being drawn, as + /// [`crate::docking::DockArea::hit_id`] names it. + fn split_id(&self, path: &[bool]) -> Id { + match &self.tree { + None => self.id.with(("split", path)), + Some((panel, _)) => self.id.with(("window-split", panel, path)), + } + } + + /// Four-way handles where a divider drawn inside the split `node` (since `mark`) ends on + /// the split's own divider: dragging one moves both dividers. + fn intersections(&mut self, ui: &mut Ui, (node, rect): (&Node

, Rect), path: &[bool], divider: Rect, mark: usize, root: (&Node

, Rect)) { + let Node::Split { axis, .. } = node else { return }; + let side = (self.style.gap * 3.0).max(12.0); + let mut found = Vec::new(); + for inner in self.dividers.get(mark..).unwrap_or_default() { + if inner.axis == *axis || !inner.divider.expand(1.0).intersects(divider) { + continue; + } + let center = match axis { + SplitAxis::Horizontal => pos2(divider.center().x, inner.divider.center().y), + SplitAxis::Vertical => pos2(inner.divider.center().x, divider.center().y), + }; + found.push((inner.path.clone(), Rect::from_center_size(center, Vec2::splat(side)).intersect(rect))); + } + for (inner_path, hit) in found { + let Some(inner) = inner_path.get(path.len()..).and_then(|rest| crate::docking::dock_model::node_at(node, rest)) else { continue }; + let (outer, inner_layout) = (self.original_path(path), self.original_path(&inner_path)); + let id = match &self.tree { + None => self.id.with(("intersection", outer, inner_layout)), + Some((panel, _)) => self.id.with(("window-intersection", panel, outer, inner_layout)), + }; + // Pointer only: each of the two dividers it joins already takes keyboard focus. + let response = ui.interact(hit, id, Sense::DRAG); + self.register_interaction(ui.ctx(), id); + if response.hovered() || response.dragged() { + ui.ctx().set_cursor_icon(CursorIcon::Move); + } + let delta = response.drag_delta(); + if delta != Vec2::ZERO && delta.is_finite() { + let along = |axis: SplitAxis| if axis == SplitAxis::Horizontal { delta.x } else { delta.y }; + let Node::Split { axis: inner_axis, .. } = inner else { continue }; + let mut actions = self.divider_actions(root, path, along(*axis)); + actions.extend(self.divider_actions(root, &inner_path, along(*inner_axis))); + self.output.actions.extend(actions); + } + } + } + + pub(super) fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) { + if !crate::docking::targets::usable(rect) { + return; + } + ui.painter().rect_filled(rect, 0.0, self.style.background); + let mut child = + ui.new_child(UiBuilder::new().scope_id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + self.content.body(&mut child, panel); + let at = self.contents.get(panel).copied(); + let at = self.float_space.record_content(at, panel.clone(), child.min_rect().height(), rect.height()); + self.contents.insert(panel.clone(), at); + // Any button: a canvas pans with the middle button and opens menus with the secondary. + if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { + self.output.focused = Some(panel.clone()); + } + #[cfg(feature = "native-windows")] + if child.rect_contains_pointer(rect) { + self.cross.pointed = Some(panel.clone()); + } + } +} + +/// A lone protected panel draws no tab strip and no drag bar. +pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

) -> bool { + match panels { + [panel] => content.permissions(panel).is_protected(), + _ => false, + } +} + +/// Size policy of a subtree, shared by the renderer and its measurements. +pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, content: &dyn DockContent

) -> PanelLimits { + match node { + Node::Dock { columns, .. } => { + let width: f32 = columns.iter().map(|c| dock::column_width(c, style)).sum(); + PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() } + } + // A group is as large as its largest panel allows: a panel without a maximum lifts the + // maximum of the panels it joins. + Node::Tabs { panels, .. } => { + let mut limits = PanelLimits { min: Vec2::ZERO, max: if panels.is_empty() { PanelLimits::default().max } else { Vec2::ZERO } }; + for panel in panels { + let policy = content.limits(panel); + limits.min = limits.min.max(policy.min).max(Vec2::splat(style.min_pane)); + limits.max = limits.max.max(policy.max); + } + limits.max = limits.max.max(limits.min); + if !stripless(panels, content) { + limits.min.y = (limits.min.y + style.tab_height).min(1_000_000.0); + limits.max.y = (limits.max.y + style.tab_height).min(1_000_000.0); + } + limits + } + Node::Stack { entries } => { + let mut limits = PanelLimits::default(); + if !entries.is_empty() { + limits.max.x = 0.0; + } + for entry in entries { + let policy = content.limits(&entry.panel); + limits.min.x = limits.min.x.max(policy.min.x).max(style.min_pane); + limits.min.y += if entry.open { policy.min.y.max(style.min_pane) } else { 0.0 } + style.tab_height; + limits.max.x = limits.max.x.max(policy.max.x); + } + limits.min = limits.min.min(Vec2::splat(1_000_000.0)); + limits.max = limits.max.max(limits.min); + limits + } + Node::Split { axis, first, second, .. } => { + let a = node_limits(first, style, content); + let b = node_limits(second, style, content); + let limits = match axis { + SplitAxis::Horizontal => PanelLimits { + min: vec2(a.min.x + b.min.x + style.gap, a.min.y.max(b.min.y)), + max: vec2(a.max.x + b.max.x + style.gap, a.max.y.min(b.max.y)), + }, + SplitAxis::Vertical => PanelLimits { + min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + style.gap), + max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + style.gap), + }, + }; + PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) } + } + } +} + +pub(super) fn clamp_float(rect: Rect, screen: Rect) -> Rect { + crate::docking::chrome::clamp(rect, screen) +} + +pub(super) fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) { + let (a_min, a_max, b_min, b_max) = match axis { + SplitAxis::Horizontal => (first.min.x, first.max.x, second.min.x, second.max.x), + SplitAxis::Vertical => (first.min.y, first.max.y, second.min.y, second.max.y), + }; + let scale = if a_min + b_min > available { available / (a_min + b_min).max(1.0) } else { 1.0 }; + let lo = (a_min * scale).min(available); + let hi = (available - b_min * scale).max(lo).min(available); + let constrained_lo = lo.max(available - b_max); + let constrained_hi = hi.min(a_max); + if constrained_lo <= constrained_hi { (constrained_lo, constrained_hi) } else { (lo, hi) } +} diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs new file mode 100644 index 00000000..0a6e6d33 --- /dev/null +++ b/crates/ui/src/docking/render/native.rs @@ -0,0 +1,1087 @@ +//! Floating windows in their own OS windows (feature `native-windows`). Where the backend has +//! viewports, every floating window whose panels allow it is drawn with +//! `show_viewport_immediate`. Palettes stay on top of other windows and hide while none of the +//! application's windows has focus; viewers are normal windows that come forward when the +//! application is activated. Where egui embeds viewports (wasm, tests) windows stay +//! in-window at their in-window rect, and so do they on Wayland, unless the app turns native +//! windows on ([`crate::docking::DockArea::native_windows`]). Tabs, groups and whole windows +//! drag between OS windows (see `cross.rs`). + +use std::{fmt::Debug, hash::Hash}; + +use egui::{ + Color32, Context, CursorIcon, Event, Id, Key, LayerId, Modifiers, Order, Pos2, RawInput, Rect, ResizeDirection, Sense, Ui, UiBuilder, Vec2, + ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, ViewportInfo, WindowLevel, pos2, vec2, +}; + +use super::Renderer; +use super::cross::{DragPointer, Surface}; +use crate::docking::chrome::{ChromeStyle, TitleBar}; +pub(in crate::docking) use crate::docking::placement::BAR_HEIGHT; +use crate::docking::placement::{kept_reachable, monitor_of, restore, sanitized}; +use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node, WindowFiles, WindowKind}; + +/// Smallest native window. +const MIN_SIZE: [f32; 2] = [160.0, 100.0]; +/// How long floating windows stay after the application's last window lost focus, so focus +/// moving between our own windows does not hide them. +const HIDE_DELAY: f64 = 0.25; +/// Whether normal windows are raised when the application is activated. macOS brings an +/// application's windows forward itself: all of them on activation from the Dock or the +/// application switcher, the clicked one alone on a click, as its own windows do. +const RAISE_ON_ACTIVATION: bool = !cfg!(target_os = "macos"); +/// How long reports of a native window's place may lag a move the dock area commanded. +const MOVE_WAIT: f64 = 0.5; +/// How far the resize bands reach into a native window from its edges, where the window has +/// no frame of its own to resize from. +const EDGE: f32 = 6.0; +/// How far a corner's grab runs along each of its two edges. +const CORNER: f32 = 16.0; +/// macOS keeps the window's frame, and AppKit resizes the window from its edges and corners. +/// Elsewhere the window is undecorated and resizes from our bands, which hand the resize to +/// the window system (`BeginResize`). +const OS_RESIZES: bool = cfg!(target_os = "macos"); +/// How far AppKit's corner resize area reaches into a titled, resizable window along each +/// edge. AppKit takes a press there before the content sees it, and shows its resize cursor. +/// Measured on macOS 26 as a 19 pt square at each corner, 62 % of which would lie inside a +/// square-cornered window: about 15 pt inside and 4 pt outside +/// (). +/// There is no published measurement for earlier releases. +pub(in crate::docking) const APPKIT_CORNER: f32 = 15.0; +/// How far AppKit's edge resize area reaches into the content between the corners: a few +/// points; most of it lies outside the window. +const APPKIT_EDGE: f32 = 4.0; + +/// Where the × of a native window's bar sits and where it takes clicks, in points from the +/// window's top and trailing edges, mirrored in a right-to-left layout. +#[derive(Clone, Copy, Debug)] +pub(in crate::docking) struct CloseLayout { + /// From the window's top edge to the top of the glyph. + pub glyph_top: f32, + /// From the window's trailing edge to the glyph's trailing side. + pub glyph_trailing: f32, + /// Width and height of the glyph. + pub glyph_size: f32, + /// How far the hit area reaches past the glyph to the left (to the right when mirrored). + pub hit_left: f32, + /// How far the hit area reaches past the glyph to the right (to the left when mirrored). + pub hit_right: f32, + /// How far the hit area reaches above the glyph. + pub hit_up: f32, + /// How far the hit area reaches below the glyph. Past the bar's bottom it takes clicks + /// from the tab strip under the bar. + pub hit_down: f32, +} + +/// The × on macOS, where macOS puts the close button of its own windows: centered in the +/// 18 pt bar, its trailing side 8 pt in. AppKit's corner resize area ([`APPKIT_CORNER`]) +/// covers its trailing half and takes presses there, so the hit area reaches away from the +/// corner: left of the glyph, and down to the bar's bottom. +pub(in crate::docking) const MACOS_CLOSE: CloseLayout = + CloseLayout { glyph_top: 4.5, glyph_trailing: 8.0, glyph_size: 9.0, hit_left: 10.0, hit_right: 0.0, hit_up: 0.0, hit_down: 4.5 }; + +/// The × elsewhere: an 18 pt square the height of the bar, its trailing side 7 pt in so the +/// side's resize band ([`EDGE`]) and the corner's grab run down beside it, with the glyph at +/// its center. +pub(in crate::docking) const OTHER_CLOSE: CloseLayout = + CloseLayout { glyph_top: 4.5, glyph_trailing: 11.5, glyph_size: 9.0, hit_left: 4.5, hit_right: 4.5, hit_up: 4.5, hit_down: 4.5 }; + +/// Opacity of a native window while its bar or only tab drags it: the window stays above the +/// main window, so the drop preview under the pointer shows only through it. +#[cfg(target_os = "macos")] +const DRAG_OPACITY: f64 = 0.5; + +/// Sets the opacity of the AppKit windows titled `title`, never one titled `main` (the main +/// window's title). egui has no viewport command for a window's opacity, and a transparent +/// surface would need the main window transparent too (eframe picks one alpha mode for every +/// viewport), so this goes to AppKit, which fades the content, corners and shadow together. +/// The window is found by title: its native handle is a raw `NSView` pointer, and turning that +/// into an `NSWindow` needs `unsafe`, which the workspace forbids. Two floats with one title +/// fade together. +#[cfg(target_os = "macos")] +fn set_window_opacity(title: &str, main: Option<&str>, opacity: f64) { + if title.is_empty() || main == Some(title) { + return; + } + let Some(mtm) = objc2::MainThreadMarker::new() else { return }; + let title = objc2_foundation::NSString::from_str(title); + for window in objc2_app_kit::NSApplication::sharedApplication(mtm).windows().iter() { + if window.title().isEqualToString(&title) && window.alphaValue() != opacity { + window.setAlphaValue(opacity); + } + } +} + +/// Whether native windows outline their ×'s hit area, and on macOS AppKit's resize areas: +/// `CRAFT_DOCK_DEBUG_CLOSE=1`, read once. +fn debug_close() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("CRAFT_DOCK_DEBUG_CLOSE").is_ok_and(|v| v == "1")) +} + +/// A drag of a native window's bar, in the context's data under the area's `"window-drag"`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::docking) struct WindowDrag { + /// [`Floating::id`] of the dragged window. + pub window: u64, + /// Where the pointer pressed, in the window's local points. + pub grab: Vec2, + /// Desktop position of the window's content as last commanded. + pub to: Pos2, + /// Desktop position of the window's content when the drag began, for Escape. + pub start: Pos2, + /// The drag began on the window's only tab or group, so it docks whatever + /// [`crate::docking::DockStyle::title_bar_docks`] says. + pub docks: bool, +} + +/// What the dock area knows of a shown native window's place, in the context's data under +/// the area's `("native-place", viewport)`. Rects are desktop rects of the window's content. +#[derive(Clone, Copy, Debug)] +struct Place { + /// Where it opened. The builder keeps it, so egui sends no move of its own. + spawn: [f32; 4], + /// As last reported or commanded. [`Floating::native`] follows it; a `native` that differs + /// was changed by the application (undo, reset, a reloaded layout), and the window moves. + known: [f32; 4], + /// Until when reports that differ from `known` are a commanded move that has not landed. + pending: Option, + /// The window system put the frame, not the content, where it opened: corrected once. + corrected: bool, + /// Shown, as it opened or was last told. + shown: bool, +} + +/// Whether two rects differ by more than rounding. +fn differs(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).any(|(a, b)| (a - b).abs() > 0.5) +} + +/// Desktop position of a viewport's content, and its offset from the window's outer +/// position, where the window system reports both. +fn content_of(info: &ViewportInfo) -> Option<(Pos2, Vec2)> { + let (outer, inner) = (info.outer_rect?, info.inner_rect?); + (outer.min.is_finite() && inner.min.is_finite()).then(|| (inner.min, inner.min - outer.min)) +} + +/// The viewport of the native window `window` of the dock area `area`. The `n`th window with +/// an id an earlier window of the same frame took gets its own. +pub(in crate::docking) fn viewport_id(area: Id, window: u64, n: usize) -> ViewportId { + match n { + 0 => ViewportId::from_hash_of((area, "native", window)), + n => ViewportId::from_hash_of((area, "native", window, n)), + } +} + +/// Whether floating windows show: while one of the application's windows has focus, and until +/// [`HIDE_DELAY`] after `last_focused`, when one last had it. +pub(in crate::docking) fn floats_shown(focused: bool, now: f64, last_focused: f64) -> bool { + focused || now - last_focused < HIDE_DELAY +} + +/// The desktop rect of a native viewport's content. None when the backend reports no +/// positions. +fn placed(info: &ViewportInfo) -> Option<[f32; 4]> { + let (min, _) = content_of(info)?; + let size = info.inner_rect?.size(); + size.is_finite().then(|| sanitized([min.x, min.y, size.x, size.y])) +} + +/// What a native window's frame reports back to the main window. +struct Report

{ + closed: bool, + placed: Option<[f32; 4]>, + /// From the window's outer position to its content. + frame: Vec2, + monitor_size: Option, + /// Its drop targets, for drags from other windows. + surface: Option>, +} + +impl

Default for Report

{ + fn default() -> Self { + Self { closed: false, placed: None, frame: Vec2::ZERO, monitor_size: None, surface: None } + } +} + +impl Renderer<'_, P> { + /// Show the native windows and return the windows to draw in-window. + pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { + let native = !ctx.embed_viewports() && self.native_windows.unwrap_or_else(|| !wayland(ctx, env_session())); + let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); + let (visible, activated) = if native { self.float_visibility(ctx) } else { (true, false) }; + self.cross.on = native; + let mut shown = Vec::new(); + let mut in_window = Vec::new(); + let mut ids = Vec::new(); + let mut natives = Vec::new(); + for window in windows { + // The application's own policy: a locked area keeps its native windows. + let allowed = window.panels().all(|p| self.content.allow_native(p) && self.content.permissions(p).floatable); + if !native || !allowed || window.panels().next().is_none() { + in_window.push(window); + continue; + } + // Every float is native: one still in-window opens over its place in the app window. + let rect = window.native.unwrap_or_else(|| { + let [x, y, w, h] = window.rect; + sanitized([origin.x + x, origin.y + y, w, h]) + }); + let n = ids.iter().filter(|id| **id == window.id).count(); + ids.push(window.id); + natives.push((window, rect, viewport_id(self.id, window.id, n))); + } + if native { + #[cfg(debug_assertions)] + self.warn_rebuilt(ctx, &natives); + self.escape_elsewhere(ctx, None); + // A window closed or docked during its own bar drag ends the drag. + let key = self.id.with("window-drag"); + if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { + ctx.data_mut(|d| d.remove::(key)); + } + self.map_desktop(ctx, &natives, visible, activated); + self.raise_next(ctx, &natives); + // The main window owns drags of the panels it draws. + let id = self.id; + let dragged = ctx.data(|d| { + d.get_temp::>(id.with("drag")).map(|d| d.panel).or(d.get_temp::>(id.with("group-drag")).map(|d| d.panel)) + }); + if dragged.is_some_and(|panel| !natives.iter().any(|(w, ..)| w.contains(&panel))) { + let origin = self.cross.main.map(|m| m.min); + self.record_pointer(ctx, None, origin, None); + } + } + for (window, rect, viewport) in natives { + if let Some(key) = self.native_window(ctx, window, rect, viewport, visible) { + shown.push(key); + } + } + // Forget where windows that are no longer native were told to open. + let key = self.id.with("native-shown"); + let previous = ctx.data_mut(|d| d.remove_temp::>(key)).unwrap_or_default(); + ctx.data_mut(|d| { + for id in previous.iter().filter(|id| !shown.contains(id)) { + d.remove::(*id); + } + d.insert_temp(key, shown); + }); + in_window + } + + /// Debug builds: warn once when for [`REBUILT_FRAMES`] frames running, every native window + /// is new, with the panels of the last frame's. The app builds its layout anew each frame + /// without [`Floating::with_id`], so each frame opens new OS windows. A layout loaded + /// again does it once. + #[cfg(debug_assertions)] + fn warn_rebuilt(&self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)]) { + let (key, warned) = (self.id.with("native-previous"), self.id.with("native-rebuilt")); + let current: Vec<(u64, Vec

)> = natives.iter().map(|(w, ..)| (w.id, w.panels().cloned().collect())).collect(); + let (runs, previous) = ctx.data_mut(|d| d.remove_temp::<(usize, Vec<(u64, Vec

)>)>(key)).unwrap_or_default(); + let runs = if rebuilt(&previous, ¤t) { runs + 1 } else { 0 }; + if runs >= REBUILT_FRAMES && !ctx.data(|d| d.get_temp::(warned).unwrap_or(false)) { + log::warn!( + "craft-ui docking: the native floating windows get new ids every frame, so each frame opens new OS windows. \ + Keep the layout across frames, or give its windows ids with Floating::with_id." + ); + ctx.data_mut(|d| d.insert_temp(warned, true)); + } + ctx.data_mut(|d| d.insert_temp(key, (runs, current))); + } + + /// The main window and the shown native windows on the desktop, front first: palettes + /// above the rest, each the window focused most recently first. A window whose position is + /// unknown is left out. `visible`: palettes show. `activated`: the application was just + /// activated, so the main window and its normal windows are raised (see + /// [`Self::raise_next`]). + fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)], visible: bool, activated: bool) { + let key = self.id.with("native-order"); + // Focus order, front first; `None` is the main window. + let mut order = ctx.data(|d| d.get_temp::>>(key)).unwrap_or_default(); + order.retain(|id| id.is_none_or(|id| natives.iter().any(|(w, ..)| w.id == id))); + // The main window comes to the front when it gains focus, and on a press in it: the + // window system raises it then, though it may have kept focus. + let main_key = self.id.with("native-main-focused"); + let (main_focused, pressed) = ctx.input(|i| (i.viewport().focused == Some(true), i.pointer.any_pressed())); + let gained = main_focused && !ctx.data(|d| d.get_temp::(main_key)).unwrap_or(false); + ctx.data_mut(|d| d.insert_temp(main_key, main_focused)); + if gained || pressed || !order.contains(&None) { + let front = order.contains(&None); + order.retain(Option::is_some); + if front { order.insert(0, None) } else { order.push(None) } + } + let mut windows = Vec::new(); + for (window, rect, viewport) in natives { + let info = ctx.input(|i| i.raw.viewports.get(viewport).cloned()); + // A new window opens in front. + if info.as_ref().is_some_and(|i| i.focused == Some(true)) || !order.contains(&Some(window.id)) { + order.retain(|id| *id != Some(window.id)); + order.insert(0, Some(window.id)); + } + let [x, y, w, h] = *rect; + let placed = match info { + None => Some(Rect::from_min_size(pos2(x, y), vec2(w, h))), + Some(info) => placed(&info).map(|[x, y, w, h]| Rect::from_min_size(pos2(x, y), vec2(w, h))), + }; + let normal = self.normal_window(window); + if let Some(placed) = placed.filter(|r| r.is_finite() && (visible || normal)) { + windows.push((window.id, placed, normal)); + } + } + if activated && RAISE_ON_ACTIVATION { + // The back one first, so each lands in front of those raised before it, and the + // window just focused, at the front of the order, last. + // While input is blocked the main window, with the modal, ends in front. + if self.input_blocked { + order.retain(Option::is_some); + order.insert(0, None); + } + let queue: Vec> = order + .iter() + .rev() + .filter(|id| id.is_none_or(|id| natives.iter().any(|(w, ..)| w.id == id && self.normal_window(w)))) + .copied() + .collect(); + // Blocked, the main window comes in front of a palette the app was activated through. + if self.input_blocked || queue.iter().any(Option::is_some) { + ctx.data_mut(|d| d.insert_temp(self.id.with("native-raise"), queue)); + } + } + let rank = |id: Option| order.iter().position(|o| *o == id); + windows.sort_by_key(|(id, _, normal)| (*normal, rank(Some(*id)))); + self.cross.front = windows.iter().filter(|(id, _, normal)| !normal || rank(Some(*id)) < rank(None)).count(); + self.cross.windows = windows.into_iter().map(|(id, rect, _)| (id, rect)).collect(); + ctx.data_mut(|d| d.insert_temp(key, order)); + self.cross.main = ctx.input(|i| i.viewport().outer_rect.and(i.viewport().inner_rect)).filter(|r| r.is_finite()); + } + + /// Raise the next window [`Self::map_desktop`] queued when the application was activated, + /// the main window (`None`) or a normal window: one per pass, since egui sends the commands + /// of different viewports in no fixed order. Toggling the window level raises a window without focusing it: on Windows + /// `HWND_NOTOPMOST` puts the window in front of every window that is not topmost, and on + /// X11 the window manager raises a window as it takes `_NET_WM_STATE_ABOVE`. winit has no + /// plain raise. `ViewportCommand::Focus` would take focus from the window the user + /// activated, and egui-winit ignores it for a window that has focus, so focus could not be + /// handed back. `Visible(true)` does nothing to a shown window. + fn raise_next(&self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)]) { + let key = self.id.with("native-raise"); + let Some(mut queue) = ctx.data_mut(|d| d.remove_temp::>>(key)) else { return }; + // A window that became a palette since activation keeps its `AlwaysOnTop` level. + let viewport = |id: Option| match id { + None => Some(ViewportId::ROOT), + Some(id) => natives.iter().find(|(w, ..)| w.id == id && self.normal_window(w)).map(|(.., v)| *v), + }; + queue.retain(|id| viewport(*id).is_some()); + if queue.is_empty() { + return; + } + if let Some(viewport) = viewport(queue.remove(0)) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::WindowLevel(WindowLevel::AlwaysOnTop)); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::WindowLevel(WindowLevel::Normal)); + } + if !queue.is_empty() { + ctx.data_mut(|d| d.insert_temp(key, queue)); + ctx.request_repaint(); + } + } + + /// Whether palettes show this frame, and whether the application was just activated: one + /// of its windows has focus after none had it for long enough to hide the palettes. + /// Losing focus again stops the raise of normal windows that activation started. + fn float_visibility(&mut self, ctx: &Context) -> (bool, bool) { + let (focused, now) = ctx.input(|i| { + let infos = || i.raw.viewports.values(); + // A backend that reports no focus at all keeps the windows. + let focused = i.focused || infos().any(|v| v.focused == Some(true)) || infos().all(|v| v.focused.is_none()); + (focused, i.time) + }); + self.cross.focus_lost = crate::focus::turned_false(ctx, self.id.with("app-focus"), focused); + let key = self.id.with("native-visibility"); + let last = if focused { now } else { ctx.data(|d| d.get_temp::(key)).unwrap_or(now) }; + let visible = floats_shown(focused, now, last); + if visible && !focused { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((HIDE_DELAY - (now - last)).max(0.0))); + } + ctx.data_mut(|d| d.insert_temp(key, last)); + let away_key = self.id.with("native-away"); + let away = ctx.data(|d| d.get_temp::(away_key)).unwrap_or(false); + ctx.data_mut(|d| { + d.insert_temp(away_key, !focused && (away || !visible)); + if !focused { + d.remove::>>(self.id.with("native-raise")); + } + }); + (visible, focused && away) + } + + /// A window holding a viewer is a normal window, so it neither vanishes nor sits over other + /// applications; the rest are palettes. + fn normal_window(&self, window: &Floating

) -> bool { + window.panels().any(|p| self.content.window_kind(p) == WindowKind::Normal) + } + + /// One floating window in its own viewport. `visible`: palettes show. Returns the key of + /// its [`Place`]. + fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, visible: bool) -> Option { + let normal = self.normal_window(window); + let visible = visible || normal; + let first = window.panels().next()?.clone(); + let key = self.id.with(("native-place", viewport)); + let now = ctx.input(|i| i.time); + let drag_key = self.id.with("window-drag"); + let moving = |ctx: &Context| ctx.data(|d| d.get_temp::(drag_key)).is_some_and(|d| d.window == window.id); + let info = ctx.input(|i| i.raw.viewports.get(&viewport).cloned()); + let frame = info.as_ref().and_then(content_of).map_or(Vec2::ZERO, |(_, frame)| frame); + let mut place = match ctx.data(|d| d.get_temp::(key)) { + Some(mut place) => { + // The application changed the window's place (undo, reset, a reloaded layout): + // the OS window moves there, kept where its bar can be grabbed. + if !moving(ctx) + && let Some(native) = window.native.filter(|native| differs(*native, place.known)) + { + let target = kept_reachable(native, &self.monitors); + place_window(ctx, viewport, target, frame); + (place.known, place.pending) = (target, Some(now + MOVE_WAIT)); + } + place + } + None => { + // A window of a loaded layout opens at its saved place, moved onto a connected + // monitor. One made this session opens where it was put. + let seen_key = self.id.with("native-seen"); + let mut seen = ctx.data(|d| d.get_temp::>(seen_key)).unwrap_or_default(); + let spawn = if window.native.is_some() && !seen.contains(&window.id) { + let (main, size) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size)); + restore(rect, window.monitor.as_ref(), &self.monitors, main, size) + } else { + sanitized(rect) + }; + seen.insert(window.id); + ctx.data_mut(|d| d.insert_temp(seen_key, seen)); + Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false, shown: true } + } + }; + let spawn = place.spawn; + let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); + let builder = ViewportBuilder::default() + .with_title(title.clone()) + .with_position([spawn[0], spawn[1]]) + .with_inner_size([spawn[2], spawn[3]]) + .with_min_inner_size(MIN_SIZE) + // A blocked palette could cover the modal, and its bar moves nothing: the main + // window can come in front of it at the normal level. + .with_window_level(if normal || self.input_blocked { WindowLevel::Normal } else { WindowLevel::AlwaysOnTop }) + .with_taskbar(false); + // The bar replaces the OS title bar. macOS keeps the frame, with its title bar and + // buttons hidden: AppKit resizes a movable window from its edges and corners with its + // own cursors, and draws its rounded corners and standard shadow. + #[cfg(target_os = "macos")] + let builder = builder + .with_titlebar_shown(false) + .with_title_shown(false) + .with_fullsize_content_view(true) + .with_titlebar_buttons_shown(false) + .with_resizable(!self.input_blocked && window.panels().all(|p| self.permissions(p).movable)); + #[cfg(not(target_os = "macos"))] + let builder = builder.with_decorations(false); + // A blocked palette keeps showing while the app is away: showing it on the app's return + // would raise it over the modal and, on macOS, give it focus. + let visible = visible || (self.input_blocked && place.shown); + // Only on a change: showing a shown window raises it and, on macOS, takes focus. + if place.shown != visible { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::Visible(visible)); + place.shown = visible; + } + if visible && !self.input_blocked && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); + } + let (targets, docks, windows) = (self.targets.len(), self.dock_targets.len(), self.frame.windows.len()); + let blocked = self.input_blocked; + ctx.with_plugin(|input: &mut NativeInput| input.windows.insert(viewport, self.id)); + let report = ctx.show_viewport_immediate(viewport, builder, |ui, class| { + // `cancel_drag` reaches this viewport's drag here. + let cancelled = ui.ctx().data(|d| d.get_temp::>(crate::docking::surface::native_cancel_key(self.id))); + if ui.ctx().dragged_id().is_some_and(|id| cancelled.is_some_and(|ids| ids.contains(&id))) { + ui.ctx().stop_dragging(); + } + let full = ui.max_rect(); + // An embedded fallback reads the main window's input: its close request, place, + // keys and resizes are not this window's. + let own = matches!(class, ViewportClass::Immediate | ViewportClass::Deferred); + let released = if blocked && own { block_input(ui) } else { Vec::new() }; + ui.painter().rect_filled(full, 0.0, self.style.floating_background); + let buttons = self.native_bar(ui, window); + let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); + self.cross.pointed = None; + self.floating_body(ui, window, body, full); + self.cross.held |= ui.ctx().dragged_id().is_some_and(|id| self.owned_interactions.contains(&id)); + if debug_close() { + debug_outlines(ui, full, &buttons); + } + if !own { + return Report::default(); + } + if window.panels().all(|p| self.permissions(p).movable) { + if OS_RESIZES { + leave_cursor_to_os(ui, full); + } else { + resize_edges(ui, self.id.with(("native-edges", window.id)), full, &buttons); + } + } + let mut surface = Surface { + window: window.id, + origin: None, + bounds: full, + targets: self.targets.split_off(targets), + dock_targets: self.dock_targets.split_off(docks), + windows: self.frame.windows.split_off(windows), + }; + surface.origin = self.window_gestures(ui, window); + if blocked { + (surface.targets, surface.dock_targets, surface.windows) = Default::default(); + cover(ui, self.id.with(("native-blocked", window.id)), full, self.style.blocked_dim); + if !released.is_empty() { + self.output.forwarded_modifiers = Some(ui.input(|i| i.modifiers)); + self.output.forwarded_events.extend(released); + } + } else { + self.paint_cross(ui, &mut surface); + self.forward_events(ui); + self.report_files(ui, window, viewport); + } + ui.input(|i| Report { + closed: i.viewport().close_requested(), + placed: placed(i.viewport()), + frame: content_of(i.viewport()).map_or(Vec2::ZERO, |(_, frame)| frame), + monitor_size: i.viewport().monitor_size, + surface: Some(surface), + }) + }); + // Targets in another OS window are in its coordinates. An embedded fallback drew its + // targets in the main window's. + if report.surface.is_some() { + self.targets.truncate(targets); + self.dock_targets.truncate(docks); + self.frame.windows.truncate(windows); + } + self.cross.surfaces.extend(report.surface); + if let Some(to) = self.cross.moved.take() { + (place.known[0], place.known[1], place.pending) = (to.x, to.y, Some(now + MOVE_WAIT)); + } + let moving = moving(ctx); + // Set every frame from the drag state, so any way a drag ends (a drop, Escape, a focus + // loss, the window closing) brings the window back to full opacity. AppKit finds the + // window by title, so a window sharing the dragged one's title stays faded with it. + #[cfg(target_os = "macos")] + { + let main = ctx.input(|i| i.raw.viewports.get(&ViewportId::ROOT).and_then(|v| v.title.clone())); + let dragged = ctx.data(|d| d.get_temp::(drag_key)).and_then(|drag| self.layout.floating.iter().find(|w| w.id == drag.window)); + let faded = dragged.and_then(|w| crate::docking::dock_model::first_active(&w.root)).is_some_and(|p| self.label_of(p) == title); + set_window_opacity(&title, main.as_deref(), if faded { DRAG_OPACITY } else { 1.0 }); + } + if let Some(placed) = report.placed.filter(|_| !moving) { + let waiting = place.pending.is_some_and(|until| now < until); + if !differs(placed, place.known) { + place.pending = None; + } else if waiting { + // The window system put the window's frame where its content should be. + let outer = pos2(placed[0], placed[1]) - report.frame; + if !place.corrected && report.frame.length() > 0.5 && outer.distance(pos2(place.known[0], place.known[1])) < 0.5 { + place.corrected = true; + ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(place.known[0], place.known[1]) - report.frame)); + } + } else { + (place.known, place.pending) = (placed, None); + } + } + ctx.data_mut(|d| d.insert_temp(key, place)); + if report.closed { + // Like the bar's ×: its panels close and reopen from the application's menu. A + // window the application would refuse to close stays open. + if !blocked && window.panels().all(|p| self.permissions(p).closable) { + self.output.actions.push(Action::CloseWindow { panel: first }); + return Some(key); + } + ctx.send_viewport_cmd_to(viewport, ViewportCommand::CancelClose); + } + // The layout follows the window's place, once a bar drag is over. + let known = place.known; + if !moving && window.native.is_none_or(|native| differs(native, known)) { + self.output.actions.push(Action::SetNative { panel: first.clone(), rect: Some(known) }); + // The in-window rect follows, for builds that draw the window in-window. + if window.panels().all(|p| self.permissions(p).movable) { + let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); + let [x, y, w, h] = known; + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [x - origin.x, y - origin.y, w, h] }); + } + } + let monitor = monitor_of(&self.monitors, known).cloned(); + let size = match monitor { + Some(_) => None, + None => report.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0).map(|s| [s.x, s.y]), + }; + if (monitor.is_some() || size.is_some()) && (monitor != window.monitor || size != window.monitor_size) { + self.output.actions.push(Action::SetMonitor { panel: first, monitor, monitor_size: size }); + } + Some(key) + } + + /// The bar at the top of a native window: its title and ×. Dragging it moves the window. + /// Returns the rects of its buttons. + fn native_bar(&mut self, ui: &mut Ui, window: &Floating

) -> Vec { + let Some(first) = window.panels().next().cloned() else { return Vec::new() }; + let full = ui.max_rect(); + let style = ChromeStyle::from_dock(self.style); + let rect = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT.min(full.height()))); + ui.painter().rect_filled(rect, 0.0, style.title_background); + let (close_hit, close_glyph) = close_rects(rect, if OS_RESIZES { MACOS_CLOSE } else { OTHER_CLOSE }, style.right_to_left); + 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 title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); + // On macOS the bar keeps the arrow, as native title bars do: AppKit's resize areas + // cover its ends and top edge, and a cursor set over them flickers with AppKit's. + let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable && !OS_RESIZES); + if closable { + bar = bar.close(&close_label).close_at(close_hit, close_glyph); + } + let response = bar.show(ui, rect, &style); + if response.close.as_ref().is_some_and(|close| close.clicked()) { + self.output.actions.push(Action::CloseWindow { panel: first }); + } + if movable && response.bar.drag_started() { + self.start_window_drag(ui, window.id, false); + } + response.close.iter().chain(&response.menu).map(|r| r.rect).collect() + } + + /// After a native window's tree drew: a drag of its only tab, or of its only group, moves + /// the window as its bar does. Then the window follows a bar drag, and writes the pointer + /// of a drag it owns. Returns the desktop position of the window's content, where known. + fn window_gestures(&mut self, ui: &Ui, window: &Floating

) -> Option { + let ctx = ui.ctx(); + let (drag_key, group_key) = (self.id.with("drag"), self.id.with("group-drag")); + let tab = ctx.data(|d| d.get_temp::>(drag_key)).filter(|d| window.contains(&d.panel)); + let group = ctx.data(|d| d.get_temp::>(group_key)).filter(|d| window.contains(&d.panel)); + let lone = window.panels().count() == 1 && tab.is_some(); + let whole = matches!(window.root, Node::Tabs { .. }) && group.as_ref().is_some_and(|g| g.gesture == GroupGesture::Group); + if (lone || whole) && window.panels().all(|p| self.permissions(p).movable) { + ctx.data_mut(|d| { + d.remove::>(drag_key); + d.remove::>(group_key); + }); + self.start_window_drag(ui, window.id, true); + } + self.escape_elsewhere(ctx, Some(window.id)); + let origin = ui.input(|i| content_of(i.viewport())).map(|(origin, _)| origin); + if self.follow_window_drag(ui, window.id) { + return origin; + } + let owns = ctx.data(|d| d.get_temp::>(drag_key).map(|d| d.panel).or(d.get_temp::>(group_key).map(|d| d.panel))); + if owns.is_some_and(|panel| window.contains(&panel)) && self.record_pointer(ctx, Some(window.id), origin, None).escape { + // The drag's cancel, not a shortcut. + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)); + } + origin + } + + /// Start moving the window with the pointer, from the position the window system reports, + /// or, where it reports none (Wayland), by handing the move to it. `docks`: the drag began + /// on the window's only tab or group. + fn start_window_drag(&mut self, ui: &Ui, window: u64, docks: bool) { + let press = ui.input(|i| i.pointer.press_origin().or(i.pointer.latest_pos())); + match (ui.input(|i| content_of(i.viewport())), press) { + (Some((origin, _)), Some(press)) => { + let drag = WindowDrag { window, grab: press.to_vec2(), to: origin, start: origin, docks }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("window-drag"), drag)); + } + _ => ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag), + } + } + + /// Move the window by `OuterPosition` while the button is held, so the pressed point stays + /// under the pointer. Escape puts it back; losing focus leaves it. Returns whether the + /// window is moving. + fn follow_window_drag(&mut self, ui: &Ui, window: u64) -> bool { + let key = self.id.with("window-drag"); + let Some(mut drag) = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window) else { return false }; + let content = ui.input(|i| content_of(i.viewport())); + let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame); + // A pointer move of this frame is relative to where the window was when the window + // system sent it, which is where it reports the window now: a move commanded earlier + // may not have landed yet. Without one the window stays, since the last position egui + // keeps (button events included) was measured against an older place. + let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); + let mut step = None; + if let (Some((origin, _)), Some(local)) = (content, moved.filter(|p| p.is_finite())) { + let to = origin + (local.to_vec2() - drag.grab); + if to.distance(drag.to) > 0.0 { + (drag.to, step) = (to, Some(to)); + ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + } + } + // The pointer on the desktop is the pressed point of the window where it was last + // put, so the origin is that place, less the local pointer. + let local = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite()); + let origin = local.map(|local| drag.to + drag.grab - local.to_vec2()).or(content.map(|(origin, _)| origin)); + let pointer = self.record_pointer(ui.ctx(), Some(window), origin, Some(&drag)); + let move_to = |r: &mut Self, to: Pos2| { + ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(to - frame)); + r.cross.moved = Some(to); + }; + if pointer.escape || self.cross.focus_lost { + if pointer.escape { + move_to(self, drag.start); + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)); + } + ui.ctx().data_mut(|d| d.remove::(key)); + return true; + } + if let Some(to) = step { + move_to(self, to); + } + if !pointer.down { + ui.ctx().data_mut(|d| d.remove::(key)); + // Left with too little of its bar on a monitor to grab it: back onto the nearest. + if let Some(size) = ui.input(|i| i.viewport().inner_rect.map(|r| r.size())) { + let [x, y, ..] = kept_reachable([drag.to.x, drag.to.y, size.x, size.y], &self.monitors); + if pos2(x, y).distance(drag.to) > 0.5 { + move_to(self, pos2(x, y)); + } + } + self.end_window_drag(drag, pointer); + } + true + } + + /// Release of a bar drag. The window already sits where it was dragged; over a target in + /// another window it docks there once every window has drawn (`cross.rs`). + fn end_window_drag(&mut self, drag: WindowDrag, pointer: DragPointer) { + self.cross.release = Some((drag, pointer)); + } + + /// Files dragged over or dropped on the window this frame, with the panel under the pointer. + fn report_files(&mut self, ui: &Ui, window: &Floating

, viewport: ViewportId) { + let (hovered, dropped, pos) = ui.input(|i| (i.raw.hovered_files.clone(), i.raw.dropped_files.clone(), i.pointer.latest_pos())); + if hovered.is_empty() && dropped.is_empty() { + return; + } + // The window system may send no pointer moves while files are dragged in. + let Some(panel) = self.cross.pointed.clone().or_else(|| crate::docking::dock_model::first_active(&window.root).cloned()) else { return }; + // A window whose own pass is discarded draws twice: keep one report, with the latest + // hovered files and every dropped one. + if let Some(files) = self.output.window_files.iter_mut().find(|f| f.viewport == viewport) { + files.panel = panel; + files.pos = pos; + files.hovered = hovered; + files.dropped.extend(dropped); + } else { + self.output.window_files.push(WindowFiles { window: window.id, viewport, panel, pos, hovered, dropped }); + } + } + + /// Keys, text and clipboard events the window's widgets left over, for the application's + /// shortcuts. Nothing while a widget of the window has keyboard focus: its keys are its own. + fn forward_events(&mut self, ui: &Ui) { + if ui.memory(|m| m.focused().is_some()) { + return; + } + let events = ui.input(|i| { + i.events + .iter() + .filter(|e| matches!(e, Event::Key { .. } | Event::Text(_) | Event::Copy | Event::Cut | Event::Paste(_))) + .cloned() + .collect::>() + }); + if !events.is_empty() { + self.output.forwarded_modifiers = Some(ui.input(|i| i.modifiers)); + self.output.forwarded_events.extend(events); + } + } +} + +/// Input hooks of dock areas for their native windows, run by egui before it reads each +/// window's input. eframe runs `App::raw_input_hook` for the main window only. +#[derive(Default)] +struct NativeInput { + hooks: std::collections::HashMap>, + /// The dock area of each native window shown this frame. + windows: std::collections::HashMap, +} + +pub(in crate::docking) type InputHook = Box; + +impl egui::Plugin for NativeInput { + fn debug_name(&self) -> &'static str { + "craft-ui docking native input" + } + + fn input_hook(&mut self, ctx: &Context, input: &mut RawInput) { + if let Some(hook) = self.windows.get(&input.viewport_id).and_then(|area| self.hooks.get(area)) { + (hook.lock())(ctx, input); + } + } +} + +/// Set or clear the input hook of the dock area `area` for this frame, and forget its native +/// windows until they show again. +pub(in crate::docking) fn set_input_hook(ctx: &Context, area: Id, hook: Option) { + if hook.is_some() && ctx.plugin_opt::().is_none() { + ctx.add_plugin(NativeInput::default()); + } + ctx.with_plugin(|input: &mut NativeInput| { + input.windows.retain(|_, a| *a != area); + match hook { + Some(hook) => input.hooks.insert(area, egui::mutex::Mutex::new(hook)), + None => input.hooks.remove(&area), + } + }); +} + +/// Frames running whose native windows are all new, with the last frame's panels, before +/// [`Renderer::warn_rebuilt`] warns. +#[cfg(debug_assertions)] +const REBUILT_FRAMES: usize = 3; + +/// What the environment says of winit's backend. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::docking) enum Session { + /// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set: winit picks Wayland unless the app forces + /// X11 in code (`EventLoopBuilderExtX11::with_x11`); winit reads no variable that does. + Wayland, + /// None of those nor `DISPLAY` is set on a Unix desktop: a Wayland app started with only + /// `WAYLAND_SOCKET`, which winit unsets as it connects, or no display at all (tests, CI). + Unknown, + /// X11 or another backend. + Other, +} + +/// The [`Session`] of a Unix desktop (`unix`) whose environment variable `name` is set and +/// not empty when `set(name)`. +pub(in crate::docking) fn session(unix: bool, set: impl Fn(&str) -> bool) -> Session { + if !unix { + Session::Other + } else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") { + Session::Wayland + } else if set("DISPLAY") { + Session::Other + } else { + Session::Unknown + } +} + +/// This process's [`Session`], read once. +fn env_session() -> Session { + static SESSION: std::sync::OnceLock = std::sync::OnceLock::new(); + *SESSION.get_or_init(|| { + session(cfg!(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")), |name| { + std::env::var_os(name).is_some_and(|value| !value.is_empty()) + }) + }) +} + +/// Whether the main window runs on Wayland, given the environment's `session`. egui does not +/// name the backend, but on Wayland the main window never reports its outer rect, while under +/// X11 it does from the first frame on (eframe reads it as the window opens). A minimized X11 +/// window reports none either, so once one has, the backend is not Wayland. Without a Wayland +/// variable, only a real OS window (one that reports its native pixels per point) counts. +pub(in crate::docking) fn wayland(ctx: &Context, session: Session) -> bool { + let root = |test: fn(&ViewportInfo) -> bool| ctx.input(|i| i.raw.viewports.get(&ViewportId::ROOT).is_some_and(test)); + match session { + Session::Other => return false, + Session::Unknown if !root(|v| v.native_pixels_per_point.is_some()) => return false, + Session::Wayland | Session::Unknown => {} + } + let key = Id::unique("craft-ui-dock-window-positions"); + if ctx.data(|d| d.get_temp::(key).unwrap_or(false)) { + return false; + } + let reported = root(|v| v.outer_rect.is_some()); + if reported { + ctx.data_mut(|d| d.insert_temp(key, true)); + } + !reported +} + +/// Whether every window of `current` is new to `previous`, and they hold the same panels: +/// windows, with their panels, as `(id, panels)`. +#[cfg(debug_assertions)] +fn rebuilt(previous: &[(u64, Vec

)], current: &[(u64, Vec

)]) -> bool { + !current.is_empty() && current.iter().all(|(id, _)| previous.iter().all(|(p, _)| p != id)) && panels(previous) == panels(current) +} + +#[cfg(debug_assertions)] +fn panels(windows: &[(u64, Vec

)]) -> std::collections::HashSet<&Vec

> { + windows.iter().map(|(_, panels)| panels).collect() +} + +/// The start of a blocked native window's frame: its widgets are disabled, and the pointer, +/// keys, text and scrolling are taken out of its input before anything reads them, so a panel +/// that reads input directly sees none either. Returns the key releases it took out, for the +/// app: a key pressed before the block would otherwise stay down in the app's shortcuts. +fn block_input(ui: &mut Ui) -> Vec { + // Disabling fades what the window paints, its opaque background too; the cover dims it. + let opacity = ui.opacity(); + ui.disable(); + ui.set_opacity(opacity); + // Native reset acknowledgements are ordered control messages, even while widgets are blocked. + let kept = |e: &Event| matches!(e, Event::WindowFocused(_) | Event::ImeResetCompleted { .. }); + let released = ui.input(|i| i.events.iter().filter(|e| matches!(e, Event::Key { pressed: false, .. })).cloned().collect()); + ui.input_mut(|i| { + i.events.retain(kept); + i.raw.events.retain(kept); + // File drops are fields of their own, not events. + i.raw.dropped_files.clear(); + i.raw.hovered_files.clear(); + i.keys_down.clear(); + i.smooth_scroll_delta = Vec2::ZERO; + i.pointer = Default::default(); + }); + ui.memory_mut(|m| m.stop_text_input()); + released +} + +/// The end of a blocked native window's frame: `dim` over its content `full`, in a layer above +/// its popups that takes the pointer, so a popup left open takes no click either. +fn cover(ui: &Ui, id: Id, full: Rect, dim: Color32) { + let cover = Ui::new(ui.ctx().clone(), id, UiBuilder::new().layer_id(LayerId::new(Order::Tooltip, id)).max_rect(full)); + cover.painter().rect_filled(full, 0.0, dim); + cover.interact(full, id.with("sink"), Sense::CLICK | Sense::DRAG); +} + +/// Move and size a native window so its content is the desktop rect `to`. The size goes +/// first, so the move puts the top-left corner in place whichever corner the window system +/// keeps on a resize (AppKit's frame origin is the bottom left). +fn place_window(ctx: &Context, viewport: ViewportId, to: [f32; 4], frame: Vec2) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::InnerSize(vec2(to[2], to[3]))); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(to[0], to[1]) - frame)); +} + +/// The resize bands of a movable native window without a frame of its own. Drawn after its +/// content, so they take the pointer at its edges; the bar's `buttons` are left out. +fn resize_edges(ui: &Ui, id: Id, full: Rect, buttons: &[Rect]) { + for (i, (direction, band)) in resize_bands(full, buttons).into_iter().enumerate() { + let response = ui.interact(band, id.with(i), Sense::drag()).on_hover_cursor(resize_cursor(direction)); + if response.drag_started() { + ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)); + } + } +} + +/// Over AppKit's resize areas of the window's content `full`, the default cursor whatever the +/// content asked for: AppKit shows its resize cursor there, and any other cursor we set fights +/// it. +fn leave_cursor_to_os(ui: &Ui, full: Rect) { + if ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| appkit_resize_areas(full).iter().any(|area| area.contains(p))) { + ui.ctx().set_cursor_icon(CursorIcon::Default); + } +} + +/// AppKit's resize areas inside a window's content `rect`: a square at each corner and a strip +/// along each edge. +pub(in crate::docking) fn appkit_resize_areas(rect: Rect) -> [Rect; 8] { + let (c, e) = (Vec2::splat(APPKIT_CORNER), APPKIT_EDGE); + [ + Rect::from_min_size(rect.left_top(), c), + Rect::from_min_size(rect.right_top() - vec2(c.x, 0.0), c), + Rect::from_min_size(rect.left_bottom() - vec2(0.0, c.y), c), + Rect::from_min_size(rect.right_bottom() - c, c), + rect.with_max_y(rect.top() + e), + rect.with_min_y(rect.bottom() - e), + rect.with_max_x(rect.left() + e), + rect.with_min_x(rect.right() - e), + ] +} + +/// Over a native window's content `full`: its bar's `buttons` outlined in red and, on macOS, +/// AppKit's resize areas in orange. +fn debug_outlines(ui: &Ui, full: Rect, buttons: &[Rect]) { + let stroke = |color| egui::Stroke::new(1.0, color); + for button in buttons { + ui.painter().rect_stroke(*button, 0.0, stroke(egui::Color32::RED), egui::StrokeKind::Inside); + } + if OS_RESIZES { + for area in appkit_resize_areas(full) { + ui.painter().rect_stroke(area, 0.0, stroke(egui::Color32::ORANGE), egui::StrokeKind::Inside); + } + } +} + +/// The hit area and glyph of the × in a native window's bar `bar`, laid out by `layout` from +/// the bar's top and trailing edges. +pub(in crate::docking) fn close_rects(bar: Rect, layout: CloseLayout, right_to_left: bool) -> (Rect, Rect) { + let l = layout; + let glyph = Rect::from_min_size(pos2(bar.right() - l.glyph_trailing - l.glyph_size, bar.top() + l.glyph_top), Vec2::splat(l.glyph_size)); + let hit = Rect::from_min_max(glyph.min - vec2(l.hit_left, l.hit_up), glyph.max + vec2(l.hit_right, l.hit_down)); + let mirror = |r: Rect| { + if right_to_left { + Rect::from_x_y_ranges((bar.left() + bar.right() - r.right())..=(bar.left() + bar.right() - r.left()), r.y_range()) + } else { + r + } + }; + (mirror(hit), mirror(glyph)) +} + +/// The bands along the edges of a native window's content `rect` that resize it: an L at each +/// corner, [`EDGE`] deep and [`CORNER`] along both edges, and the edges between them. They +/// leave out the bar's `buttons`, which take their own clicks. +pub(in crate::docking) fn resize_bands(rect: Rect, buttons: &[Rect]) -> Vec<(ResizeDirection, Rect)> { + use ResizeDirection as D; + let (l, r, t, b, e, c) = (rect.left(), rect.right(), rect.top(), rect.bottom(), EDGE, CORNER); + let band = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let bands = [ + (D::NorthWest, band(l, t, l + c, t + e)), + (D::NorthWest, band(l, t, l + e, t + c)), + (D::NorthEast, band(r - c, t, r, t + e)), + (D::NorthEast, band(r - e, t, r, t + c)), + (D::SouthWest, band(l, b - e, l + c, b)), + (D::SouthWest, band(l, b - c, l + e, b)), + (D::SouthEast, band(r - c, b - e, r, b)), + (D::SouthEast, band(r - e, b - c, r, b)), + (D::North, band(l + c, t, r - c, t + e)), + (D::South, band(l + c, b - e, r - c, b)), + (D::West, band(l, t + c, l + e, b - c)), + (D::East, band(r - e, t + c, r, b - c)), + ]; + let mut out = Vec::new(); + for (direction, band) in bands { + let mut pieces = vec![band]; + for button in buttons { + pieces = pieces.into_iter().flat_map(|piece| outside(piece, *button)).collect(); + } + out.extend(pieces.into_iter().filter(|p| p.is_positive()).map(|p| (direction, p))); + } + out +} + +/// The parts of `rect` outside `hole`: up to four rects. +fn outside(rect: Rect, hole: Rect) -> Vec { + let overlap = rect.intersect(hole); + if !overlap.is_positive() { + return vec![rect]; + } + [ + Rect::from_min_max(rect.min, pos2(rect.right(), overlap.top())), + Rect::from_min_max(pos2(rect.left(), overlap.bottom()), rect.max), + Rect::from_min_max(pos2(rect.left(), overlap.top()), overlap.left_bottom()), + Rect::from_min_max(overlap.right_top(), pos2(rect.right(), overlap.bottom())), + ] + .into_iter() + .filter(|r| r.is_positive()) + .collect() +} + +fn resize_cursor(direction: ResizeDirection) -> CursorIcon { + use {CursorIcon as C, ResizeDirection as D}; + match direction { + D::North => C::ResizeNorth, + D::South => C::ResizeSouth, + D::East => C::ResizeEast, + D::West => C::ResizeWest, + D::NorthEast => C::ResizeNorthEast, + D::NorthWest => C::ResizeNorthWest, + D::SouthEast => C::ResizeSouthEast, + D::SouthWest => C::ResizeSouthWest, + } +} diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs new file mode 100644 index 00000000..5488850a --- /dev/null +++ b/crates/ui/src/docking/render/preview.rs @@ -0,0 +1,654 @@ +use std::{collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, Key, Pos2, Rect, Ui, Vec2, pos2, vec2}; + +use super::{DROP_MARGIN, Drawn, Renderer, clamp_float, dock::group_action}; +use crate::docking::chrome::drop_preview; +use crate::docking::{ + Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, + dock_model::Place, resolve_drop, surface::TAB_PADDING, +}; +use crate::layout::SplitAxis; + +/// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. +fn float_only(ctx: &Context) -> bool { + ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd) +} + +/// Height of a panel floated out of a dock column when the host gives no floating size. +const DOCKED_FLOAT_HEIGHT: f32 = 320.0; + +/// Least height of a floating window or flyout fitted to a short panel's content. +pub(super) const MIN_FITTED_HEIGHT: f32 = 120.0; + +/// What finishing a drag reads of the pointer, in the coordinates of the window finishing it. +#[derive(Clone, Copy, Debug)] +pub(super) struct DragInput { + pub pointer: Option, + pub down: bool, + pub released: bool, + /// The window kept its focus: false on the pass it loses it (see [`crate::focus`]). + pub focused: bool, + /// The pointer left the window. + pub gone: bool, + pub escape: bool, + /// Ctrl or Cmd held. + pub command: bool, + /// A touch ended this frame. + pub touch_ended: bool, +} + +impl DragInput { + /// This window's own input. + fn of(ctx: &Context) -> Self { + let command = float_only(ctx); + let focus_lost = crate::focus::lost(ctx); + ctx.input(|i| Self { + pointer: i.pointer.interact_pos().filter(|p| p.is_finite()), + down: i.pointer.primary_down(), + released: i.pointer.primary_released(), + focused: !focus_lost, + gone: !i.pointer.has_pointer(), + escape: i.key_pressed(Key::Escape), + command, + touch_ended: i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. })), + }) + } +} + +impl Renderer<'_, P> { + pub(in crate::docking) fn finish_group_drag(&mut self, ui: &Ui) { + let ctx = ui.ctx(); + let key = self.id.with("group-drag"); + let Some(drag) = ctx.data(|d| d.get_temp::>(key)) else { return }; + if !drag.members.iter().all(|p| self.layout.contains(p) && self.permissions(p).movable) { + ctx.data_mut(|d| d.remove::>(key)); + return; + } + let input = DragInput::of(ctx); + #[cfg(feature = "native-windows")] + let Some((drag, input)) = self.cross_group(ui, &drag, input) else { return }; + let DragInput { escape, focused, down, released, pointer, .. } = input; + let ghost = pointer.map(|pointer| self.group_ghost(&drag, pointer)); + if !escape + && focused + && (down || released) + && let Some(pointer) = pointer + && !self.blockers.iter().any(|r| r.contains(pointer)) + && self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture != GroupGesture::Window)) + { + if released { + ctx.data_mut(|d| d.remove::>(key)); + } + return; + } + // A button release outside the window may never reach egui. + let has_pointer = !input.gone; + // Touch-up also removes the pointer, but still completes a drop. + let touch_ended = input.touch_ended; + let float_only = input.command; + if escape || !focused || !down || !has_pointer { + cancel_drag::

(ctx, self.id); + if escape || !focused { + if drag.gesture == GroupGesture::Window { + self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); + } + return; + } + // Leaving the window ends a group drag where it is, with no drop, unless only a + // release outside the window floats. + let gone = !has_pointer && !touch_ended; + let floats_outside = !self.style.float_on_empty_drop && drag.gesture != GroupGesture::Window; + if !(released && !gone) && !(gone && floats_outside) { + return; + } + let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; + let outside = gone || !ctx.content_rect().contains(pointer); + match drag.gesture { + GroupGesture::Window => self.drop_window(&drag, pointer, float_only), + GroupGesture::Group | GroupGesture::Column => self.drop_group(&drag, pointer, float_only || outside), + } + } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { + self.output.group_drag = Some(drag.panel.clone()); + self.paint_group_drag(ui, &drag, pointer, float_only); + } + } + + /// The valid target under the pointer for a whole group. A target holding the group + /// itself is a no-op proposal; a target the model would reject is no target. A window + /// moving live looks through itself and does not split frames or, moved by its bar, join + /// a body's center. + pub(super) fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { + let window = drag.gesture == GroupGesture::Window; + if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) { + return self.valid_group(drag, proposal); + } + let target = self.group_target(drag, pointer)?; + self.group_proposal_at(drag, pointer, target) + } + + /// The group under the pointer that a group drag addresses. A window moving live looks + /// through itself. + fn group_target(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<&DropTarget

> { + let window = drag.gesture == GroupGesture::Window; + let own = |t: &DropTarget

| t.tabs.iter().any(|(p, _)| drag.members.contains(p)); + self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t))) + } + + /// [`Self::group_proposal`] on `target` alone. + fn group_proposal_at(&self, drag: &GroupDrag

, pointer: Pos2, target: &DropTarget

) -> Option> { + let window = drag.gesture == GroupGesture::Window; + if target.tabs.iter().any(|(p, _)| drag.members.contains(p)) { + return Some(DropProposal { + anchor: drag.panel.clone(), + placement: Placement::Tab { before: None }, + preview: target.rect, + insertion: None, + noop: true, + }); + } + let proposal = self.resolve_roomy(&drag.panel, pointer, std::slice::from_ref(target), |to| group_action(drag, to))?; + // A window moving live splits only groups of other floating windows. A native + // window's bar also takes the main window's frame edges, but not a body's center. A + // native window moved by its only tab or group joins a body's center too, as that tab + // or group drag would. + let into_window = matches!(self.place(&proposal.anchor), Some(Place::Floating { .. })); + #[cfg(feature = "native-windows")] + let (edges, joins) = (self.cross.bar, self.cross.docks); + #[cfg(not(feature = "native-windows"))] + let (edges, joins) = (false, false); + let split = match proposal.placement { + Placement::Split(Zone::Center) => !joins, + Placement::Split(_) => !edges, + _ => false, + }; + if window && !into_window && split { + return None; + } + self.valid_group(drag, proposal) + } + + /// `proposal` when it is a no-op or the model takes it for the whole group. + fn valid_group(&self, drag: &GroupDrag

, proposal: DropProposal

) -> Option> { + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + (proposal.noop || self.layout.clone().apply_with_permissions(group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) + } + + /// [`resolve_drop`], where a split that would leave a group without room becomes a join + /// of the target group. + fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget

], action: impl Fn(Target

) -> Action

) -> Option> { + let proposal = resolve_drop(panel, pointer, targets, self.style.zone_depth)?; + if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) { + return Some(proposal); + } + if self.leaves_room(action(Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() })) { + return Some(proposal); + } + let target = targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; + resolve_drop(panel, pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), self.style.zone_depth) + } + + /// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group + /// it makes smaller keeps its minimum, and each divider can still travel both ways. A group + /// within [`DROP_MARGIN`] of its minimum along an axis passes when the dividers along its + /// sides can travel, the groups beyond it giving way as they do for a dragged divider, so a + /// narrow group still splits when its neighbours have room for both halves. An action the + /// model rejects is left to the model's check. + pub(super) fn leaves_room(&self, action: Action

) -> bool { + let mut candidate = self.layout.clone(); + if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() { + return true; + } + let sizer = self.sizer(); + let drawn = |root: &Node

, area: Rect| { + let mut out: Vec> = Vec::new(); + sizer.groups(root, area, &mut out); + out.into_iter().filter_map(|d| Some((lead(d.node)?.clone(), d.rect, d.limits.min))).collect::>() + }; + let before: HashMap = + self.trees(self.layout).into_iter().flat_map(|(root, area)| drawn(root, area)).map(|(panel, rect, _)| (panel, rect.size())).collect(); + self.trees(&candidate).into_iter().all(|(root, area)| { + drawn(root, area).into_iter().all(|(panel, rect, min)| { + let (size, room) = (rect.size(), rect.size() - min); + let fits = |room: f32, axis| room >= DROP_MARGIN || sizer.frees((root, area), rect, axis); + before.get(&panel).is_some_and(|old| size.x >= old.x - 0.5 && size.y >= old.y - 0.5) + || (room.min_elem() > -0.01 && fits(room.x, SplitAxis::Horizontal) && fits(room.y, SplitAxis::Vertical)) + }) + }) + } + + /// The trees of `layout` as drawn: the docked root in the workspace and each open floating + /// window's tree in its body. + fn trees<'l>(&self, layout: &'l Layout

) -> Vec<(&'l Node

, Rect)> { + let windows = layout.floating.iter().filter(|w| !w.minimized).map(|w| (&w.root, self.window_body(w))); + layout.root.iter().map(|root| (root, self.workspace)).chain(windows).collect() + } + + /// Release of a group drag: join or split at the target, or float at the ghost with the + /// group's size. Over its own docked position nothing changes; a floating group released + /// over itself moves there. + fn drop_group(&mut self, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + if pointer.distance(drag.origin) < 2.0 { + return; + } + if !float_only && let Some((action, _)) = self.dock_group_proposal(drag, pointer) { + self.output.actions.extend(action); + return; + } + let floating = matches!(self.place(&drag.panel), Some(Place::Floating { .. })); + let proposal = if float_only { None } else { self.group_proposal(drag, pointer) }; + if let Some(proposal) = proposal.as_ref().filter(|p| !p.noop) { + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + self.output.actions.push(group_action(drag, to)); + return; + } + if proposal.is_some() && !floating { + return; + } + if !float_only && !self.style.float_on_empty_drop { + return; + } + // No target: hosts that still apply their own policy for a whole-group release get it. + self.output.group_drop = Some((drag.panel.clone(), pointer)); + let rect = self.group_ghost(drag, pointer); + let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }); + if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { + self.output.actions.push(action); + } + } + + /// The ghost at the pointer (a window moving live is its own ghost) under one destination + /// preview. The ghost has a translucent fill, a 2 pt outline and the members' labels. + pub(super) fn paint_group_drag(&mut self, ui: &Ui, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + let ctx = ui.ctx(); + let painter = self.preview_painter(ctx); + if drag.gesture != GroupGesture::Window { + let ghost = self.group_ghost(drag, pointer); + drop_preview(&painter, ghost, self.style.accent, true); + let strip = Rect::from_min_size(ghost.min, vec2(ghost.width(), self.style.tab_height.min(ghost.height()))); + let labels = painter.with_clip_rect(strip.shrink(2.0)); + let mut x = strip.left() + TAB_PADDING * 0.5; + for panel in &drag.members { + let width = strip.right() - TAB_PADDING * 0.5 - x; + if width <= 0.0 { + break; + } + let label = self.label_of(panel); + let galley = self.tab_label(ui, panel, &label, self.style.text, width); + let advance = galley.size().x + TAB_PADDING; + labels.galley(pos2(x, strip.center().y - galley.size().y * 0.5), galley, self.style.text); + x += advance; + } + } + if float_only || (drag.gesture == GroupGesture::Window && !self.title_bar_docks()) { + return; + } + if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { + if action.is_some() { + self.paint_dock_line(ctx, line); + if drag.gesture != GroupGesture::Window { + self.paint_float_hint(ctx, pointer); + } + } + return; + } + let proposal = self.group_proposal(drag, pointer).filter(|p| !p.noop); + let zones = self.paint_group_zones(&painter, drag, pointer, proposal.as_ref()); + if let Some(proposal) = proposal { + let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); + if crate::docking::targets::usable(proposal.preview) && !zones { + drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, split); + } + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); + } + if drag.gesture != GroupGesture::Window { + self.paint_float_hint(ctx, pointer); + } + } + } + + pub(in crate::docking) fn finish_drag(&mut self, ctx: &Context) { + let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("drag"))) else { return }; + let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + let input = DragInput { escape, ..DragInput::of(ctx) }; + #[cfg(feature = "native-windows")] + let Some((drag, input)) = self.cross_tab(ctx, &drag, input) else { return }; + let DragInput { pointer, down, released, focused, gone, escape, .. } = input; + // Leaving the window is the drop: on X11 and Windows the release outside never reaches + // egui, so the drag would otherwise stay on the pointer. `interact_pos` still holds the + // last position this frame, and the panel floats there rather than docking at the edge. + // With native viewports the release does arrive, and `gone` is false. + let released = released || gone; + if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) { + cancel_drag::

(ctx, self.id); + return; + } + let Some(pointer) = pointer.filter(|p| p.is_finite()) else { + if released { + cancel_drag::

(ctx, self.id); + } + return; + }; + if self.blockers.iter().any(|rect| rect.contains(pointer)) { + if released { + cancel_drag::

(ctx, self.id); + } + return; + } + let float_only = input.command || drag.undock; + let outside = gone || !ctx.content_rect().contains(pointer); + let empty_drop = + self.style.float_on_empty_drop && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); + let floatable = self.permissions(&drag.panel).floatable && (float_only || outside || empty_drop); + let floating_rect = floatable.then(|| self.tab_float_rect(&drag, pointer)); + if self.host_drop(ctx, &drag.panel, pointer, false, released, floating_rect) { + if released { + cancel_drag::

(ctx, self.id); + } + return; + } + let dock = if float_only || outside { None } else { self.dock_tab_proposal(&drag.panel, pointer) }; + if let Some((action, line)) = dock { + if released { + cancel_drag::

(ctx, self.id); + if let Some(action) = action.filter(|_| pointer.distance(drag.origin) >= 2.0) { + self.output.actions.push(action); + self.output.focused = Some(drag.panel); + } + } else if action.is_some() { + self.paint_dock_line(ctx, line); + self.paint_float_hint(ctx, pointer); + } + return; + } + let proposal = if float_only { None } else { self.tab_proposal(&drag.panel, pointer) }; + if released { + cancel_drag::

(ctx, self.id); + if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !outside) { + return; + } + let action = if let Some(proposal) = proposal.filter(|_| !outside) { + Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) + } else if let Some(rect) = floating_rect { + Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) + } else { + None + }; + if let Some(action) = action { + // Validate the proposed gesture against the same complete snapshot. The app + // still applies it via its own command path after body rendering finishes. + let mut candidate = self.layout.clone(); + if candidate.apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { + self.output.actions.push(action); + self.output.focused = Some(drag.panel); + } + } + return; + } + let painter = self.preview_painter(ctx); + if !float_only { + self.paint_tab_proposal(ctx, &painter, &drag, proposal.as_ref(), pointer); + } + if let (None, Some(rect)) = (&proposal, floating_rect) { + drop_preview(&painter, rect, self.style.accent, false); + } + } + + /// The group target a single tab drag would take at `pointer`. + pub(super) fn tab_proposal(&self, panel: &P, pointer: Pos2) -> Option> { + self.extra_proposal(std::slice::from_ref(panel), pointer, false).or_else(|| { + self.resolve_roomy(panel, pointer, &self.targets, |to| match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + }) + }) + } + + /// The preview of a tab drag's group target (with + /// [`crate::docking::DockStyle::side_edge_zones`], the zones of the group under the pointer), + /// and the undock hint. + pub(super) fn paint_tab_proposal( + &self, + ctx: &Context, + painter: &egui::Painter, + drag: &Drag

, + proposal: Option<&DropProposal

>, + pointer: Pos2, + ) { + let zones = self.paint_tab_zones(painter, &drag.panel, pointer, proposal); + let Some(proposal) = proposal else { return }; + // Reordering inside the source strip shows only the insertion line. + let same_group = proposal.insertion.is_some() + && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); + if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) && !zones { + drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, true); + } + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); + } + if !proposal.noop { + self.paint_float_hint(ctx, pointer); + } + } + + /// [`Self::paint_zones`] for a tab drag of `panel`. The center of the group holding the panel + /// is not a target: a drop there reorders or changes nothing. + pub(super) fn paint_tab_zones(&self, painter: &egui::Painter, panel: &P, pointer: Pos2, proposal: Option<&DropProposal

>) -> bool { + if !self.style.side_edge_zones || self.extra_proposal(std::slice::from_ref(panel), pointer, false).is_some() { + return false; + } + let Some(target) = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer)) else { + return false; + }; + let own = target.tabs.iter().any(|(p, _)| p == panel); + let at = |at: Pos2| { + let move_to = |to| match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + }; + let proposal = self.resolve_roomy(panel, at, std::slice::from_ref(target), move_to)?; + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + let joins = matches!(proposal.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)); + (!(own && joins) && self.layout.clone().apply_with_permissions(move_to(to), |p| self.permissions(p)).is_ok()).then_some(proposal) + }; + self.paint_zones(painter, target, proposal, at) + } + + /// [`Self::paint_zones`] for a group drag. + pub(super) fn paint_group_zones(&self, painter: &egui::Painter, drag: &GroupDrag

, pointer: Pos2, proposal: Option<&DropProposal

>) -> bool { + let window = drag.gesture == GroupGesture::Window; + if !self.style.side_edge_zones || self.extra_proposal(&drag.members, pointer, window).is_some() { + return false; + } + let Some(target) = self.group_target(drag, pointer) else { return false }; + self.paint_zones(painter, target, proposal, |at| self.group_proposal_at(drag, at, target)) + } + + /// With [`crate::docking::DockStyle::side_edge_zones`], a drag over a group outlines each + /// of its drop zones that a drop would change something in (a side's trapezoid, the center + /// rectangle, the tab strip as part of the center) and fills the zone of `proposal`, the + /// drop under the pointer. `at` is the drop a point of `target` would get. False when it + /// painted nothing. + fn paint_zones( + &self, + painter: &egui::Painter, + target: &DropTarget

, + proposal: Option<&DropProposal

>, + at: impl Fn(Pos2) -> Option>, + ) -> bool { + use crate::docking::targets::{header_height, usable, zone_shape}; + let zone_of = |proposal: &DropProposal

| match proposal.placement { + _ if proposal.noop => None, + Placement::Split(zone) => Some(zone), + Placement::Tab { .. } => Some(Zone::Center), + _ => None, + }; + let header = header_height(target); + let centroid = |points: &[Pos2]| points.iter().fold(Vec2::ZERO, |sum, p| sum + p.to_vec2()) / points.len() as f32; + let takes = |zone: Zone, point: Pos2| at(point).as_ref().and_then(zone_of) == Some(zone); + let mut shapes: Vec<(Zone, Vec)> = [Zone::Left, Zone::Right, Zone::Top, Zone::Bottom, Zone::Center] + .into_iter() + .filter_map(|zone| Some((zone, zone_shape(target.rect, header, self.style.zone_depth, zone)?))) + .filter(|(zone, points)| takes(*zone, centroid(points).to_pos2())) + .collect(); + let strip = target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|strip| usable(*strip) && takes(Zone::Center, strip.center())); + if let Some(strip) = strip { + shapes.push((Zone::Center, vec![strip.left_top(), strip.right_top(), strip.right_bottom(), strip.left_bottom()])); + } + if shapes.is_empty() { + return false; + } + let hovered = proposal.and_then(zone_of); + let fill = self.style.accent.gamma_multiply(0.3); + for (_, points) in shapes.iter().filter(|(zone, _)| Some(*zone) == hovered) { + painter.add(egui::Shape::convex_polygon(points.clone(), fill, egui::Stroke::NONE)); + } + let stroke = egui::Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)); + for (_, points) in shapes { + painter.add(egui::Shape::closed_line(points, stroke)); + } + true + } + + /// The single drop preview layer, above every floating window. + pub(super) fn preview_painter(&self, ctx: &Context) -> egui::Painter { + let layer = egui::LayerId::new(egui::Order::Tooltip, self.id.with("drop-preview")); + ctx.move_to_top(layer); + ctx.layer_painter(layer) + } + + /// Size of a panel floated by a drag or the Float menu: the host's floating size, else the + /// source size within the panel limits. Out of a dock or frame, or with a floating size, the + /// height fits the content the panel last drew, up to that height (else 320). Content that + /// filled its body floats at that height. + pub(super) fn float_size(&self, panel: &P, source: Vec2) -> Vec2 { + let chrome = self.style.title_height + self.style.tab_height; + if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) { + return vec2(size.x, self.fitted_height(panel, chrome, size.y).unwrap_or(size.y)); + } + let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT)); + if matches!(self.place(panel), Some(Place::Dock { .. } | Place::Node(_))) { + size.y = match self.fitted_height(panel, chrome, DOCKED_FLOAT_HEIGHT) { + Some(height) => height, + None if self.content_height(panel) == Some(None) => DOCKED_FLOAT_HEIGHT, + None => size.y.min(DOCKED_FLOAT_HEIGHT), + }; + } + let limits = self.limits(panel); + let header = vec2(0.0, self.style.tab_height); + size.max(limits.min + header).min(limits.max + header) + } + + /// Height of a window around `panel`'s group: `chrome` (title bar and tab strip) and the + /// content height the panel last drew, at least [`MIN_FITTED_HEIGHT`] and at most `max`. + /// `None` for a panel the area has not drawn. + pub(super) fn fitted_height(&self, panel: &P, chrome: f32, max: f32) -> Option { + let content = self.content_height(panel).flatten()?; + Some((chrome + content).max(MIN_FITTED_HEIGHT).min(max)) + } + + /// Record what a drag floats from `panels`, one group drawn at `group` whose panels drag + /// from `panel` rects, for commands that float without a rect. + pub(super) fn record_float(&mut self, panels: &[P], panel: Vec2, group: Vec2) { + let group = self.group_float_size(panels, group); + for p in panels { + let size = self.float_size(p, panel); + self.float_space.panels.push((p.clone(), size)); + self.float_space.groups.push((p.clone(), group)); + } + } + + /// Record what a drag floats from a dock column drawn at `rect`. + pub(super) fn record_column_float(&mut self, members: Vec

, rect: Rect) { + let size = self.group_float_size(&members, rect.size()); + self.float_space.columns.extend(members.into_iter().map(|p| (p, size))); + } + + /// Size of a group or column floated by a drag or Float Tab Group: the source width, and the + /// tallest member's floating height, else the source height capped like a panel leaving a + /// dock column. A group fits that height to its active panel's content. A group that already + /// floats keeps its size. + pub(super) fn group_float_size(&self, members: &[P], source: Vec2) -> Vec2 { + let Some(place) = members.first().and_then(|panel| self.place(panel)) else { return source }; + if matches!(place, Place::Floating { .. }) { + return source; + } + let valid = |s: &Vec2| s.is_finite() && s.x > 0.0 && s.y > 0.0; + let host = members.iter().filter_map(|panel| self.content.floating_size(panel).filter(valid)).map(|s| s.y).reduce(f32::max); + let docked = matches!(place, Place::Dock { .. }); + let chrome = self.style.title_height + self.style.tab_height; + let max = host.or(docked.then_some(DOCKED_FLOAT_HEIGHT)); + let fitted = max.and_then(|max| self.group_active(members, place).and_then(|active| self.fitted_height(active, chrome, max))); + vec2(source.x, fitted.or(host).unwrap_or(if docked { source.y.min(DOCKED_FLOAT_HEIGHT) } else { source.y })) + } + + /// The active panel of the group at `place` when `members` are that whole group. + fn group_active<'a>(&'a self, members: &[P], place: &Place) -> Option<&'a P> { + let (panels, active) = match place { + Place::Node(_) | Place::Floating { .. } => match self.layout.node_of(place) { + Some(Node::Tabs { panels, active }) => (panels, *active), + _ => return None, + }, + _ => self.layout.group(place).map(|group| (&group.panels, group.active))?, + }; + (panels.len() == members.len() && members.iter().all(|m| panels.contains(m))).then(|| panels.get(active)).flatten() + } + + /// Where a group or column drag floats with the pointer at `pointer`, keeping the grab + /// offset, inside the window (Z2). + fn group_ghost(&self, drag: &GroupDrag

, pointer: Pos2) -> Rect { + let size = if drag.gesture == GroupGesture::Window { drag.source.size() } else { self.group_float_size(&drag.members, drag.source.size()) }; + let grab = (drag.origin - drag.source.min).clamp(Vec2::ZERO, size); + clamp_float(Rect::from_min_size(pointer - grab, size), self.float_bounds) + } + + fn tab_float_rect(&self, drag: &Drag

, pointer: Pos2) -> Rect { + let size = self.float_size(&drag.panel, drag.source.size()); + let grab = drag.grab.min(vec2(size.x, self.style.tab_height)); + clamp_float(Rect::from_min_size(pointer - grab, size), self.float_bounds) + } + + /// Offer the gesture to [`crate::docking::DockContent::resolve_drag`]. True when the host + /// owns it; its feedback replaces the renderer's preview. + pub(super) fn host_drop( + &mut self, + ctx: &Context, + panel: &P, + pointer: Pos2, + whole_group: bool, + released: bool, + floating_rect: Option, + ) -> bool { + let request = DropRequest { + panel, + pointer, + targets: &self.targets, + floating_rect, + whole_group, + released, + workspace: self.workspace, + screen: self.float_bounds, + }; + let Some(feedback) = self.content.resolve_drag(ctx, self.style, &request) else { return false }; + if !released { + let painter = self.preview_painter(ctx); + if let Some(rect) = feedback.preview.filter(|rect| crate::docking::targets::usable(*rect)) { + drop_preview(&painter, rect, self.style.accent, true); + } + if let Some(rect) = feedback.insertion.filter(|rect| crate::docking::targets::usable(*rect)) { + painter.rect_filled(rect, 0.0, self.style.accent); + } + } + true + } +} + +/// The panel that names a drawn group: its first panel; none for a dock or a split. +fn lead

(node: &Node

) -> Option<&P> { + match node { + Node::Tabs { panels, .. } => panels.first(), + Node::Stack { entries } => entries.first().map(|e| &e.panel), + Node::Split { .. } | Node::Dock { .. } => None, + } +} diff --git a/crates/ui/src/docking/render/sizes.rs b/crates/ui/src/docking/render/sizes.rs new file mode 100644 index 00000000..b1c9fd49 --- /dev/null +++ b/crates/ui/src/docking/render/sizes.rs @@ -0,0 +1,365 @@ +//! Split-tree geometry: where each split draws its children, how far a divider can travel, +//! and the sizes that move a divider. +//! +//! A dragged divider grows the groups touching it on one side, up to their maximums, and +//! shrinks the groups on the other side in turn: the group touching the divider gives way +//! down to its minimum, then the next group along the axis, and so on, until every group on +//! that side is at its minimum. Groups side by side across the axis shrink together, and the +//! line goes on past them once one is at its minimum. The line follows splits of the drag's +//! axis up the tree and ends at a split across it or beside a dock. Every other group keeps +//! its size. + +use egui::Rect; + +use super::{dock::dock_rects, node_limits, split_bounds}; +use crate::docking::{DockContent, DockStyle, Node, PanelLimits}; +use crate::layout::{SplitAxis, SplitLayout, SplitSize, split_rect}; + +/// A split's two children with the rects they are drawn in. +type Children<'n, P> = [(&'n Node

, Rect); 2]; + +/// How far each divider beside a group that a drop makes smaller can still travel both ways: +/// the group keeps this much above its minimum, or the groups beyond it give the room. +pub(in crate::docking) const DROP_MARGIN: f32 = 1.0; + +pub(in crate::docking) struct Sizer<'a, P> { + pub(in crate::docking) style: &'a DockStyle, + pub(in crate::docking) content: &'a dyn DockContent

, +} + +/// One group as drawn: its node, rect and limits. +pub(in crate::docking) struct Drawn<'a, P> { + pub(in crate::docking) node: &'a Node

, + pub(in crate::docking) rect: Rect, + pub(in crate::docking) limits: PanelLimits, +} + +fn extent(rect: Rect, axis: SplitAxis) -> f32 { + match axis { + SplitAxis::Horizontal => rect.width(), + SplitAxis::Vertical => rect.height(), + } +} + +fn along(v: egui::Vec2, axis: SplitAxis) -> f32 { + match axis { + SplitAxis::Horizontal => v.x, + SplitAxis::Vertical => v.y, + } +} + +impl

Sizer<'_, P> { + fn limits(&self, node: &Node

) -> PanelLimits { + node_limits(node, self.style, self.content) + } + + /// The split as drawn: its stored size, clamped so both children fit their limits. + pub(in crate::docking) fn split(&self, axis: SplitAxis, size: SplitSize, first: &Node

, second: &Node

, rect: Rect) -> Option { + let wanted = split_rect(rect, axis, size, self.style.gap, 0.0)?; + let (lo, hi) = split_bounds(wanted.available(), axis, self.limits(first), self.limits(second)); + split_rect(rect, axis, SplitSize::FixedFirst(wanted.first_size().clamp(lo, hi)), self.style.gap, 0.0) + } + + /// The children of a split drawn in `rect`, with their rects; a dock beside its neighbour + /// is drawn by the dock renderer. + pub(in crate::docking) fn children<'n>(&self, node: &'n Node

, rect: Rect) -> Option<[(&'n Node

, Rect); 2]> { + let Node::Split { axis, size, first, second } = node else { return None }; + match (&**first, &**second) { + (Node::Dock { side, columns }, other) if *axis == SplitAxis::Horizontal => { + let (dock, rest, _) = dock_rects(*side, columns, other, rect, self.style, self.content); + Some([(first, dock), (second, rest)]) + } + (other, Node::Dock { side, columns }) if *axis == SplitAxis::Horizontal => { + let (dock, rest, _) = dock_rects(*side, columns, other, rect, self.style, self.content); + Some([(first, rest), (second, dock)]) + } + _ => { + let split = self.split(*axis, *size, first, second, rect)?; + Some([(first, split.first), (second, split.second)]) + } + } + } + + /// Every group of `node` drawn in `rect`; a dock counts as one. + pub(in crate::docking) fn groups<'n>(&self, node: &'n Node

, rect: Rect, out: &mut Vec>) { + match self.children(node, rect) { + Some(children) => children.into_iter().for_each(|(child, rect)| self.groups(child, rect, out)), + None => out.push(Drawn { node, rect, limits: self.limits(node) }), + } + } + + /// How far the end edge (`end`) or start edge of `node` drawn in `rect` can move along + /// `axis`: inward until every group along the axis from that edge is at its minimum, and + /// outward while the groups touching it stay below their maximums. Groups side by side + /// across the axis move together, so the first of them to reach its limit stops them. + pub(in crate::docking) fn travel(&self, node: &Node

, rect: Rect, axis: SplitAxis, end: bool) -> (f32, f32) { + let docked = + matches!(node, Node::Split { first, second, .. } if matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. })); + match (node, self.children(node, rect)) { + (Node::Split { first, .. }, Some([a, b])) if docked => { + let (other, rect) = if matches!(**first, Node::Dock { .. }) { b } else { a }; + self.travel(other, rect, axis, end) + } + (Node::Split { axis: split_axis, .. }, Some([a, b])) if *split_axis == axis => { + let (near, far) = if end { (b, a) } else { (a, b) }; + let (near, far) = (self.travel(near.0, near.1, axis, end), self.travel(far.0, far.1, axis, end)); + (near.0 + far.0, near.1) + } + (_, Some([a, b])) => { + let (a, b) = (self.travel(a.0, a.1, axis, end), self.travel(b.0, b.1, axis, end)); + (a.0.min(b.0), a.1.min(b.1)) + } + (_, None) => { + let limits = self.limits(node); + let size = extent(rect, axis); + ((size - along(limits.min, axis)).max(0.0), (along(limits.max, axis) - size).max(0.0)) + } + } + } + + /// The sizes that move the end (`end`) or start edge of `node` drawn in `rect` outward by + /// `grow` points. Growing, only the groups touching that edge change. Shrinking, the group + /// at the edge gives way down to its minimum, then the next one along the axis, and so on. + /// Paths are relative to `path`. + pub(in crate::docking) fn follow( + &self, + (node, rect): (&Node

, Rect), + axis: SplitAxis, + end: bool, + grow: f32, + path: &mut Vec, + out: &mut Vec<(Vec, SplitSize)>, + ) { + let Some([a, b]) = self.children(node, rect) else { return }; + let Node::Split { axis: split_axis, size, first, second } = node else { return }; + let docked = matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }); + let visit = |child: (&Node

, Rect), second_child: bool, grow: f32, path: &mut Vec, out: &mut Vec<(Vec, SplitSize)>| { + path.push(second_child); + self.follow(child, axis, end, grow, path, out); + path.pop(); + }; + if docked { + let dock_first = matches!(**first, Node::Dock { .. }); + visit(if dock_first { b } else { a }, dock_first, grow, path, out); + } else if *split_axis == axis { + let (near, far) = if end { (b, a) } else { (a, b) }; + let near_grow = if grow < 0.0 { grow.max(-self.travel(near.0, near.1, axis, end).0) } else { grow }; + let far_grow = grow - near_grow; + let (near_size, far_size) = (extent(near.1, axis) + near_grow, extent(far.1, axis) + far_grow); + let first_size = if end { far_size } else { near_size }; + push_size(path, *size, first_size, near_size + far_size, out); + visit(near, end, near_grow, path, out); + if far_grow != 0.0 { + visit(far, !end, far_grow, path, out); + } + } else { + visit(a, false, grow, path, out); + visit(b, true, grow, path, out); + } + } + + /// The drawn splits from `root` down to the one at `path`, each with its rect. + fn chain<'n>(&self, root: (&'n Node

, Rect), path: &[bool]) -> Option, Rect)>> { + let mut chain = vec![root]; + for &second in path { + let (node, rect) = *chain.last()?; + let [a, b] = self.children(node, rect)?; + chain.push(if second { b } else { a }); + } + Some(chain) + } + + /// The ancestors that continue the line of the split at the end of `chain` along `axis`: + /// each with its children and whether the line runs through its second child, nearest first. + /// The line ends at a split across the axis or beside a dock. + fn line<'n>(&self, chain: &[(&'n Node

, Rect)], path: &[bool], axis: SplitAxis) -> Vec<(usize, Children<'n, P>, bool)> { + let mut line = Vec::new(); + for depth in (0..path.len()).rev() { + let Some(&(node, rect)) = chain.get(depth) else { break }; + let Node::Split { axis: split_axis, first, second, .. } = node else { break }; + if *split_axis != axis || matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }) { + break; + } + let (Some(children), Some(&second_child)) = (self.children(node, rect), path.get(depth)) else { break }; + line.push((depth, children, second_child)); + } + line + } + + /// The divider of the split at `path` under `root` (a node and the rect it is drawn in) + /// moved by `delta` points, clamped to its travel. The groups touching the divider on the + /// side it moves toward give way, then the groups beyond them along the axis, one after + /// another, as far as the line of same-axis splits reaches; the groups on the other side + /// that touch the divider take the room. Paths are absolute, like `path`. + pub(in crate::docking) fn drag(&self, root: (&Node

, Rect), path: &[bool], delta: f32) -> Vec<(Vec, SplitSize)> { + let Some(chain) = self.chain(root, path) else { return Vec::new() }; + let Some(&(node, rect)) = chain.last() else { return Vec::new() }; + let Node::Split { axis, size, first, second } = node else { return Vec::new() }; + let axis = *axis; + let Some(split) = self.split(axis, *size, first, second, rect) else { return Vec::new() }; + let (lo, hi) = self.range(root, path); + let delta = delta.clamp(lo, hi); + if delta == 0.0 || !delta.is_finite() || split.available() <= 0.0 { + return Vec::new(); + } + // Toward the end edge the second child gives way, toward the start the first. + let forward = delta > 0.0; + let moved = delta.abs(); + let given = if forward { + moved.min(self.travel(second, split.second, axis, false).0) + } else { + moved.min(self.travel(first, split.first, axis, true).0) + }; + let (first_grow, second_grow) = if forward { (moved, -given) } else { (-given, moved) }; + let mut out = Vec::new(); + let mut path = path.to_vec(); + let first_size = split.first_size() + first_grow; + push_size(&path, *size, first_size, first_size + extent(split.second, axis) + second_grow, &mut out); + path.push(false); + self.follow((first, split.first), axis, true, first_grow, &mut path, &mut out); + path.pop(); + path.push(true); + self.follow((second, split.second), axis, false, second_grow, &mut path, &mut out); + path.pop(); + // What the split's own child could not give, the ancestors' children beyond it give: + // the split grows by `rest` toward them, and so does each ancestor until one absorbs it. + let mut rest = moved - given; + for (depth, [a, b], second_child) in self.line(&chain, &path, axis) { + if rest <= 0.0 { + break; + } + path.truncate(depth); + let (a_size, b_size) = (extent(a.1, axis), extent(b.1, axis)); + let (first_size, second_size) = match (forward, second_child) { + (true, false) => { + let taken = rest.min(self.travel(b.0, b.1, axis, false).0); + path.push(true); + self.follow(b, axis, false, -taken, &mut path, &mut out); + path.pop(); + let sizes = (a_size + rest, b_size - taken); + rest -= taken; + sizes + } + (false, true) => { + let taken = rest.min(self.travel(a.0, a.1, axis, true).0); + path.push(false); + self.follow(a, axis, true, -taken, &mut path, &mut out); + path.pop(); + let sizes = (a_size - taken, b_size + rest); + rest -= taken; + sizes + } + (true, true) => (a_size, b_size + rest), + (false, false) => (a_size + rest, b_size), + }; + if let Some((Node::Split { size, .. }, _)) = chain.get(depth) { + push_size(&path, *size, first_size, first_size + second_size, &mut out); + } + } + out + } + + /// How far the divider of the split at `path` under `root` can move: `(lo, hi)`, with + /// `lo <= 0 <= hi`. Each way it is the lesser of the room the groups touching the divider + /// have to grow and the room every group beyond the divider, along the line of same-axis + /// splits, has to shrink. + pub(in crate::docking) fn range(&self, root: (&Node

, Rect), path: &[bool]) -> (f32, f32) { + let Some(chain) = self.chain(root, path) else { return (0.0, 0.0) }; + let Some(&(node, rect)) = chain.last() else { return (0.0, 0.0) }; + let Node::Split { axis, size, first, second } = node else { return (0.0, 0.0) }; + let Some(split) = self.split(*axis, *size, first, second, rect) else { return (0.0, 0.0) }; + let (mut first_in, first_out) = self.travel(first, split.first, *axis, true); + let (mut second_in, second_out) = self.travel(second, split.second, *axis, false); + for (_, [a, b], second_child) in self.line(&chain, path, *axis) { + if second_child { + first_in += self.travel(a.0, a.1, *axis, true).0; + } else { + second_in += self.travel(b.0, b.1, *axis, false).0; + } + } + (-first_in.min(second_out), first_out.min(second_in)) + } + + /// Whether every divider of `root` across `axis` that runs along a side of the group drawn + /// at `group` can travel [`DROP_MARGIN`] both ways, by [`Self::range`]: a group at its + /// minimum holds no divider still while the groups beyond it can give way. + pub(in crate::docking) fn frees(&self, root: (&Node

, Rect), group: Rect, axis: SplitAxis) -> bool { + let edges = |rect: Rect| match axis { + SplitAxis::Horizontal => (rect.left(), rect.right()), + SplitAxis::Vertical => (rect.top(), rect.bottom()), + }; + let (start, end) = edges(group); + let beside = |child: Rect, edge: f32, own: f32| child.contains(group.center()) && (edge - own).abs() < 0.5; + let mut nodes = vec![(root.0, root.1, Vec::new())]; + while let Some((node, rect, path)) = nodes.pop() { + let (Node::Split { axis: split_axis, .. }, Some([a, b])) = (node, self.children(node, rect)) else { continue }; + if *split_axis == axis && (beside(a.1, edges(a.1).1, end) || beside(b.1, edges(b.1).0, start)) { + let (lo, hi) = self.range(root, &path); + if -lo < DROP_MARGIN || hi < DROP_MARGIN { + return false; + } + } + for (second, (child, rect)) in [(false, a), (true, b)] { + let mut path = path.clone(); + path.push(second); + nodes.push((child, rect, path)); + } + } + true + } +} + +/// The size of a split at `path` whose first child is `first` of `available` points, in the +/// split's own mode `size`: a fixed side keeps its points, a ratio stays a ratio. +fn push_size(path: &[bool], size: SplitSize, first: f32, available: f32, out: &mut Vec<(Vec, SplitSize)>) { + if available <= 0.0 || !available.is_finite() || !first.is_finite() { + return; + } + let first = first.clamp(0.0, available); + let size = match size { + SplitSize::Ratio(_) => SplitSize::Ratio((first / available).clamp(0.0, 1.0)), + SplitSize::FixedFirst(_) => SplitSize::FixedFirst(first), + SplitSize::FixedSecond(_) => SplitSize::FixedSecond(available - first), + }; + out.push((path.to_vec(), size)); +} + +/// One group of a flat row, such as the panels of a stack or the groups of a dock column: +/// its size along the row and its limits. +#[derive(Clone, Copy)] +pub(in crate::docking) struct RowItem { + pub(in crate::docking) size: f32, + pub(in crate::docking) min: f32, + pub(in crate::docking) max: f32, +} + +/// How far the divider after `row[index]` can move: `(lo, hi)`, with `lo <= 0 <= hi`, by the +/// rule of [`row_drag`]. +pub(in crate::docking) fn row_range(row: &[RowItem], index: usize) -> (f32, f32) { + let room = |items: &[RowItem]| items.iter().map(|item| (item.size - item.min).max(0.0)).sum::(); + let grow = |item: Option<&RowItem>| item.map_or(0.0, |item| (item.max - item.size).max(0.0)); + let (before, after) = row.split_at((index + 1).min(row.len())); + (-room(before).min(grow(after.first())), room(after).min(grow(before.last()))) +} + +/// The sizes of `row` after the divider after `row[index]` moves by `delta`, clamped to its +/// travel: the item touching the divider on the side it moves toward gives way down to its +/// minimum, then the next one, and so on; the item on the other side takes the room. +pub(in crate::docking) fn row_drag(row: &[RowItem], index: usize, delta: f32) -> Vec { + let (lo, hi) = row_range(row, index); + let delta = if delta.is_finite() { delta.clamp(lo, hi) } else { 0.0 }; + let mut sizes: Vec = row.iter().map(|item| item.size).collect(); + let (grows, givers): (usize, Vec) = + if delta > 0.0 { (index, (index + 1..row.len()).collect()) } else { (index + 1, (0..=index).rev().collect()) }; + let mut rest = delta.abs(); + for giver in givers { + let (Some(size), Some(item)) = (sizes.get_mut(giver), row.get(giver)) else { continue }; + let taken = rest.min((item.size - item.min).max(0.0)); + *size -= taken; + rest -= taken; + } + if let Some(size) = sizes.get_mut(grows) { + *size += delta.abs() - rest; + } + sizes +} diff --git a/crates/ui/src/docking/resize.rs b/crates/ui/src/docking/resize.rs new file mode 100644 index 00000000..24d62c29 --- /dev/null +++ b/crates/ui/src/docking/resize.rs @@ -0,0 +1,143 @@ +use egui::{Pos2, Rect, Vec2}; + +use super::PanelLimits; + +/// A pointer-anchored resize. The opposite edges and handle grab offset stay fixed +/// through boundary overshoot; viewport constraints never translate the window. +#[derive(Clone, Copy, Debug)] +pub struct AnchoredResize { + start: Rect, + offset: Vec2, + direction: Vec2, +} + +impl AnchoredResize { + pub fn new(start: Rect, pointer: Pos2, direction: Vec2) -> Option { + if !start.is_finite() + || !start.is_positive() + || !pointer.is_finite() + || ![-1.0, 0.0, 1.0].contains(&direction.x) + || ![-1.0, 0.0, 1.0].contains(&direction.y) + || direction == Vec2::ZERO + { + return None; + } + let edge = + egui::pos2(if direction.x < 0.0 { start.left() } else { start.right() }, if direction.y < 0.0 { start.top() } else { start.bottom() }); + Some(Self { start, offset: pointer - edge, direction }) + } + + /// None suspends resizing when the fixed anchor cannot accommodate the minimum. + pub fn rect(self, pointer: Pos2, viewport: Rect, limits: PanelLimits) -> Option { + if !pointer.is_finite() + || !viewport.is_finite() + || !viewport.is_positive() + || !limits.min.is_finite() + || !limits.max.is_finite() + || limits.min.x <= 0.0 + || limits.min.y <= 0.0 + || limits.max.x < limits.min.x + || limits.max.y < limits.min.y + || !viewport.contains_rect(self.start) + || self.start.width() < limits.min.x + || self.start.height() < limits.min.y + { + return None; + } + let desired = pointer - self.offset; + if !desired.is_finite() { + return None; + } + let mut rect = self.start; + for (axis, direction) in [(0, self.direction.x), (1, self.direction.y)] { + if direction == 0.0 { + continue; + } + let (lo, hi) = if direction < 0.0 { + ((self.start.max[axis] - limits.max[axis]).max(viewport.min[axis]), self.start.max[axis] - limits.min[axis]) + } else { + (self.start.min[axis] + limits.min[axis], (self.start.min[axis] + limits.max[axis]).min(viewport.max[axis])) + }; + if !lo.is_finite() || !hi.is_finite() || lo > hi { + return None; + } + if direction < 0.0 { + rect.min[axis] = desired[axis].clamp(lo, hi); + } else { + rect.max[axis] = desired[axis].clamp(lo, hi); + } + } + Some(rect) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{pos2, vec2}; + + #[test] + fn every_handle_tracks_absolute_pointer_and_keeps_opposite_edges() { + let start = Rect::from_min_max(pos2(100.0, 80.0), pos2(400.0, 300.0)); + let viewport = Rect::from_min_max(Pos2::ZERO, pos2(800.0, 600.0)); + let limits = PanelLimits { min: vec2(160.0, 120.0), max: vec2(500.0, 400.0) }; + for direction in + [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 1.0), vec2(1.0, 1.0), vec2(0.0, -1.0), vec2(-1.0, -1.0), vec2(1.0, -1.0)] + { + let edge = + pos2(if direction.x < 0.0 { start.left() } else { start.right() }, if direction.y < 0.0 { start.top() } else { start.bottom() }); + let pointer = edge + vec2(2.0, -3.0); + let resize = AnchoredResize::new(start, pointer, direction).unwrap(); + let rect = resize.rect(pointer + vec2(20.0, 25.0), viewport, limits).unwrap(); + // A moving edge follows the pointer; every other edge stays. + let moved = |d: f32, lo: f32, hi: f32, by: f32| match d { + d if d < 0.0 => (lo + by, hi), + d if d > 0.0 => (lo, hi + by), + _ => (lo, hi), + }; + assert_eq!((rect.left(), rect.right()), moved(direction.x, start.left(), start.right(), 20.0), "{direction:?}"); + assert_eq!((rect.top(), rect.bottom()), moved(direction.y, start.top(), start.bottom(), 25.0), "{direction:?}"); + } + } + + #[test] + fn overshoot_requires_pointer_to_return_to_the_valid_range() { + let start = Rect::from_min_max(pos2(100.0, 80.0), pos2(400.0, 300.0)); + let viewport = Rect::from_min_max(Pos2::ZERO, pos2(600.0, 500.0)); + let limits = PanelLimits { min: vec2(160.0, 120.0), max: vec2(1000.0, 1000.0) }; + let resize = AnchoredResize::new(start, pos2(398.0, 200.0), vec2(1.0, 0.0)).unwrap(); + for x in [800.0, 750.0, 598.0] { + let rect = resize.rect(pos2(x, 200.0), viewport, limits).unwrap(); + assert_eq!((rect.left(), rect.right()), (100.0, 600.0)); + } + for x in [120.0, 200.0, 258.0] { + assert_eq!(resize.rect(pos2(x, 200.0), viewport, limits).unwrap().right(), 260.0); + } + assert_eq!(resize.rect(pos2(278.0, 200.0), viewport, limits).unwrap().right(), 280.0); + let left = AnchoredResize::new(start, pos2(102.0, 200.0), vec2(-1.0, 0.0)).unwrap(); + assert_eq!(left.rect(pos2(-300.0, 200.0), viewport, limits).unwrap().min.x, 0.0); + assert_eq!(left.rect(pos2(350.0, 200.0), viewport, limits).unwrap().min.x, 240.0); + assert_eq!(left.rect(pos2(300.0, 200.0), viewport, limits).unwrap().min.x, 240.0); + assert_eq!(left.rect(pos2(222.0, 200.0), viewport, limits).unwrap().min.x, 220.0); + } + + #[test] + fn panel_maximum_caps_edges_without_losing_the_absolute_anchor() { + let start = Rect::from_min_max(pos2(100.0, 80.0), pos2(400.0, 300.0)); + let viewport = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let limits = PanelLimits { min: vec2(160.0, 120.0), max: vec2(320.0, 240.0) }; + let resize = AnchoredResize::new(start, start.max, Vec2::splat(1.0)).unwrap(); + for pointer in [pos2(700.0, 550.0), pos2(600.0, 450.0)] { + assert_eq!(resize.rect(pointer, viewport, limits).unwrap(), Rect::from_min_max(start.min, pos2(420.0, 320.0))); + } + assert_eq!(resize.rect(pos2(410.0, 310.0), viewport, limits).unwrap(), Rect::from_min_max(start.min, pos2(410.0, 310.0))); + } + + #[test] + fn constrained_viewports_and_invalid_geometry_suspend_resize() { + let start = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 90.0)); + let resize = AnchoredResize::new(start, start.max, Vec2::splat(1.0)).unwrap(); + assert!(resize.rect(start.max, start, PanelLimits { min: vec2(160.0, 120.0), ..Default::default() }).is_none()); + assert!(AnchoredResize::new(start, pos2(f32::NAN, 0.0), Vec2::splat(1.0)).is_none()); + } +} diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs new file mode 100644 index 00000000..dbfccf97 --- /dev/null +++ b/crates/ui/src/docking/split_tests.rs @@ -0,0 +1,898 @@ +//! A split tree of tab groups in an editing layout like FilmCraft's: Source | Program | +//! Properties over Project | Tools | Timeline | Audio Meters, every group an ordinary +//! resizable one. One test per docking rule, the stuck cases of the FilmCraft map, and a +//! property test over random drops. + +use std::collections::HashMap; + +use super::floating_tests::frame_style; +use super::render::{Drawn, Sizer}; +use super::testing::DockDriver; +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Modifiers, Pos2, Rect, Vec2, pos2, vec2}; + +const SOURCE: [u32; 4] = [1, 11, 12, 13]; +const PROGRAM: u32 = 2; +const PROPERTIES: [u32; 3] = [3, 31, 32]; +const PROJECT: [u32; 7] = [4, 41, 42, 43, 44, 45, 46]; +const TOOLS: u32 = 5; +const TIMELINE: u32 = 6; +const METERS: u32 = 7; +const INFO: u32 = 9; + +const SCREEN: Vec2 = Vec2::new(1440.0, 860.0); + +/// Panel bodies by their rects; `caps` are maximum widths a host asks for, `rules` the +/// permissions of panels that differ from the default. +#[derive(Default)] +struct Film { + bodies: HashMap, + caps: HashMap, + rules: HashMap, +} + +impl DockContent for Film { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + self.bodies.insert(*panel, ui.max_rect()); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn limits(&self, panel: &u32) -> PanelLimits { + match self.caps.get(panel) { + Some(width) => PanelLimits { min: Vec2::ZERO, max: vec2(*width, 1_000_000.0) }, + None => PanelLimits::default(), + } + } + fn permissions(&self, panel: &u32) -> Permissions { + self.rules.get(panel).copied().unwrap_or_default() + } +} + +type Driver = DockDriver; + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +fn split(axis: SplitAxis, ratio: f32, first: Node, second: Node) -> Node { + Node::Split { axis, size: SplitSize::Ratio(ratio), first: Box::new(first), second: Box::new(second) } +} + +fn hsplit(ratio: f32, first: Node, second: Node) -> Node { + split(SplitAxis::Horizontal, ratio, first, second) +} + +/// The Editing workspace, with Info floating over Properties. +fn editing() -> Layout { + let top = hsplit(0.69, hsplit(0.5, tabs(&SOURCE), tabs(&[PROGRAM])), tabs(&PROPERTIES)); + let row = hsplit(0.06, tabs(&[TOOLS]), hsplit(0.88, tabs(&[TIMELINE]), tabs(&[METERS]))); + let bottom = hsplit(0.29, tabs(&PROJECT), row); + Layout { + root: Some(split(SplitAxis::Vertical, 0.57, top, bottom)), + floating: vec![Floating::single(vec![INFO], 0, [1120.0, 60.0, 280.0, 200.0])], + } +} + +fn driver(layout: Layout, content: Film) -> Driver { + let mut d = DockDriver::new(layout, content).with_style(frame_style); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = d.screen; + d.settle(); + d +} + +fn film() -> Driver { + driver(editing(), Film::default()) +} + +/// A group's rect: its active body and its tab strip. +fn group(d: &Driver, panel: u32) -> Rect { + let body = d.content.bodies.get(&panel).copied().unwrap_or_else(|| panic!("panel {panel} not drawn")); + Rect::from_min_max(pos2(body.left(), body.top() - 26.0), body.max) +} + +fn tab(d: &Driver, panel: u32) -> Pos2 { + d.hit(&Hit::Tab(panel)).unwrap_or_else(|| panic!("tab {panel}")).center() +} + +/// Drag a divider by `delta`, hovering it first as a pointer does. +fn drag_hit(d: &mut Driver, hit: Hit, delta: Vec2) { + let at = d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits())).center(); + d.move_to(at); + d.drag(at, at + delta); + d.settle(); +} + +fn divider(path: &[bool]) -> Hit { + Hit::Divider(Divider::Split(path.to_vec())) +} + +fn sizes(d: &Driver, panels: &[u32]) -> Vec { + panels.iter().map(|p| group(d, *p).size()).collect() +} + +fn close(a: Rect, b: Rect) -> bool { + (a.min - b.min).length() < 1.0 && (a.max - b.max).length() < 1.0 +} + +#[test] +fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_joins_it() { + let base = film(); + let timeline = group(&base, TIMELINE); + let others = [PROJECT[0], TOOLS, METERS, SOURCE[0], PROGRAM, PROPERTIES[0]]; + // Halfway into each 40 pt band of the body below the tab strip. + let inset = |zone| { + let (w, h) = (20.0, 20.0); + match zone { + Zone::Left => pos2(timeline.left() + w, timeline.center().y + 13.0), + Zone::Right => pos2(timeline.right() - w, timeline.center().y + 13.0), + Zone::Top => pos2(timeline.center().x, timeline.top() + 26.0 + h), + Zone::Bottom => pos2(timeline.center().x, timeline.bottom() - h), + Zone::Center => pos2(timeline.center().x, timeline.center().y + 13.0), + } + }; + for zone in [Zone::Left, Zone::Right, Zone::Top, Zone::Bottom] { + let mut d = film(); + let before = sizes(&d, &others); + let at = inset(zone); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, zone).unwrap()), "{zone:?}: the preview fills that band"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(zone) }], "{zone:?}"); + d.settle(); + let (info, rest) = (group(&d, INFO), group(&d, TIMELINE)); + assert!(close(info.union(rest), timeline), "{zone:?}: the two halves fill the group: {info:?} {rest:?} in {timeline:?}"); + assert!((info.area() - rest.area()).abs() < info.area() * 0.05, "{zone:?}: about halves"); + assert_eq!(sizes(&d, &others), before, "{zone:?}: no other group changes"); + } + // The middle and the tab row join the group. + let mut d = film(); + let out = d.drag(tab(&d, INFO), inset(Zone::Center)); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(Zone::Center) }]); + let mut d = film(); + let out = d.drag(tab(&d, INFO), pos2(timeline.center().x, timeline.top() + 10.0)); + assert!(matches!(&out.actions[..], [Action::Move { panel: INFO, placement: Placement::Tab { .. }, .. }]), "{:?}", out.actions); + // The gap between two groups belongs to neither: the drag cancels. + let mut d = film(); + let tools = group(&d, TOOLS); + let gap = pos2(tools.right() + 2.0, tools.center().y); + let out = d.drag(tab(&d, INFO), gap); + assert!(out.actions.is_empty(), "{:?}", out.actions); +} + +#[test] +fn window_edges_add_a_full_column_or_row_without_taking_the_groups_edges() { + let mut d = film(); + let project = group(&d, PROJECT[0]); + let out = d.drag(tab(&d, INFO), pos2(3.0, project.center().y)); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: SOURCE[0], placement: Placement::Edge(Zone::Left) }]); + d.settle(); + let info = group(&d, INFO); + assert_eq!((info.top(), info.bottom(), info.left()), (0.0, SCREEN.y, 0.0), "a full-height column at the left"); + assert!((info.width() - SCREEN.x * 0.25).abs() < 4.0, "{info:?}"); + + for (at, zone) in [(pos2(SCREEN.x - 3.0, 300.0), Zone::Right), (pos2(700.0, 3.0), Zone::Top), (pos2(700.0, SCREEN.y - 3.0), Zone::Bottom)] { + let mut d = film(); + let out = d.drag(tab(&d, INFO), at); + assert!(matches!(&out.actions[..], [Action::Move { placement: Placement::Edge(z), .. }] if *z == zone), "{zone:?}: {:?}", out.actions); + } + // Past the window band, the group's own edge band splits that group. + let mut d = film(); + let out = d.drag(tab(&d, INFO), pos2(project.left() + 20.0, project.center().y + 13.0)); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: PROJECT[0], placement: Placement::Split(Zone::Left) }]); +} + +#[test] +fn groups_have_one_small_minimum_and_no_maximum() { + let mut d = film(); + let tools = group(&d, TOOLS).width(); + drag_hit(&mut d, divider(&[true, true]), vec2(400.0, 0.0)); + assert!((group(&d, TOOLS).width() - (tools + 400.0)).abs() < 1.0, "Tools grows as wide as it is dragged"); + drag_hit(&mut d, divider(&[true, true]), vec2(-2000.0, 0.0)); + assert_eq!(group(&d, TOOLS).width(), 40.0, "and shrinks to the minimum, no further"); + + // At its minimum, with Project left of it at its own, it has no room for two halves and + // takes a drop on its edge as a tab. + let tools = group(&d, TOOLS); + assert_eq!(group(&d, PROJECT[0]).width(), 40.0); + let out = d.drag(tab(&d, INFO), pos2(tools.right() - 4.0, tools.center().y + 30.0)); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Center) | Placement::Tab { .. } }]), + "{:?}", + out.actions + ); +} + +#[test] +fn a_divider_resizes_only_the_groups_that_share_it() { + // Timeline | Meters: Project and Tools keep their widths. + let mut d = film(); + let (project, tools, timeline, meters) = (group(&d, PROJECT[0]), group(&d, TOOLS), group(&d, TIMELINE), group(&d, METERS)); + drag_hit(&mut d, divider(&[true, true, true]), vec2(-100.0, 0.0)); + assert_eq!((group(&d, PROJECT[0]), group(&d, TOOLS)), (project, tools)); + assert!((group(&d, TIMELINE).width() - (timeline.width() - 100.0)).abs() < 1.0); + assert!((group(&d, METERS).width() - (meters.width() + 100.0)).abs() < 1.0); + + // Project | the rest: only Tools, on the other side, gives way; Timeline and Meters stay. + let mut d = film(); + drag_hit(&mut d, divider(&[true]), vec2(15.0, 0.0)); + assert!((group(&d, PROJECT[0]).width() - (project.width() + 15.0)).abs() < 1.0); + assert!((group(&d, TOOLS).width() - (tools.width() - 15.0)).abs() < 1.0); + assert!(close(group(&d, TIMELINE), timeline) && close(group(&d, METERS), meters), "{:?}", d.layout); + + // Top row: Source | Program leaves Properties alone. + let mut d = film(); + let properties = group(&d, PROPERTIES[0]); + drag_hit(&mut d, divider(&[false, false]), vec2(80.0, 0.0)); + assert_eq!(group(&d, PROPERTIES[0]), properties); +} + +#[test] +fn a_divider_dragged_past_a_groups_minimum_shrinks_the_next_group_along_the_row() { + // A | B | C | D, 357 pt each, as (A | B) | (C | D): the cascade runs on past B's split. + let row = hsplit(0.5, hsplit(0.5, tabs(&[1]), tabs(&[2])), hsplit(0.5, tabs(&[3]), tabs(&[4]))); + let mut d = driver(Layout { root: Some(row), floating: Vec::new() }, Film::default()); + let widths = |d: &Driver| [1, 2, 3, 4].map(|panel| group(d, panel).width().round()); + assert_eq!(widths(&d), [357.0, 357.0, 357.0, 357.0]); + // B gives way down to its 40 pt minimum, then C. + drag_hit(&mut d, divider(&[false]), vec2(400.0, 0.0)); + assert_eq!(widths(&d), [757.0, 40.0, 274.0, 357.0]); + // The drag stops when B, C and D are all at their minimums. + drag_hit(&mut d, divider(&[false]), vec2(670.0, 0.0)); + assert_eq!(widths(&d), [1308.0, 40.0, 40.0, 40.0]); + // Back the other way, A gives way and only B, which touches the divider, grows. + drag_hit(&mut d, divider(&[false]), vec2(-1300.0, 0.0)); + assert_eq!(widths(&d), [40.0, 1308.0, 40.0, 40.0]); +} + +#[test] +fn a_divider_drag_keeps_the_splits_fixed_or_ratio_size() { + let hsized = |size, first, second| Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }; + let start = |d: &Driver| match &d.layout.root { + Some(Node::Split { size, .. }) => *size, + other => panic!("{other:?}"), + }; + let wider = |d: &mut Driver| { + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN + vec2(400.0, 0.0)); + d.area = d.screen; + d.settle(); + }; + let layout = |root| Layout { root: Some(root), floating: Vec::new() }; + + // A fixed start panel keeps its dragged width when the window grows. + let mut d = driver(layout(hsized(SplitSize::FixedFirst(240.0), tabs(&[1]), tabs(&[2]))), Film::default()); + drag_hit(&mut d, divider(&[]), vec2(60.0, 0.0)); + let SplitSize::FixedFirst(width) = start(&d) else { panic!("{:?}", d.layout) }; + assert!((width - 300.0).abs() < 1.0, "{width}"); + let panel = group(&d, 1).width(); + wider(&mut d); + assert!((group(&d, 1).width() - panel).abs() < 1.0, "{} {panel}", group(&d, 1).width()); + + // A fixed end panel too. + let mut d = driver(layout(hsized(SplitSize::FixedSecond(240.0), tabs(&[1]), tabs(&[2]))), Film::default()); + drag_hit(&mut d, divider(&[]), vec2(60.0, 0.0)); + let SplitSize::FixedSecond(width) = start(&d) else { panic!("{:?}", d.layout) }; + assert!((width - 180.0).abs() < 1.0, "{width}"); + let panel = group(&d, 2).width(); + wider(&mut d); + assert!((group(&d, 2).width() - panel).abs() < 1.0, "{} {panel}", group(&d, 2).width()); + + // A ratio stays a ratio, and both sides grow with the window. + let mut d = driver(layout(hsplit(0.5, tabs(&[1]), tabs(&[2]))), Film::default()); + drag_hit(&mut d, divider(&[]), vec2(60.0, 0.0)); + let SplitSize::Ratio(ratio) = start(&d) else { panic!("{:?}", d.layout) }; + assert!(ratio > 0.5 && ratio < 1.0, "{ratio}"); + let (first, second) = (group(&d, 1).width(), group(&d, 2).width()); + wider(&mut d); + assert!(group(&d, 1).width() > first + 100.0 && group(&d, 2).width() > second + 100.0); + + // A drag that runs on past its own split into a fixed ancestor keeps the ancestor fixed. + let mut d = driver(layout(hsized(SplitSize::FixedSecond(300.0), hsplit(0.5, tabs(&[1]), tabs(&[2])), tabs(&[3]))), Film::default()); + drag_hit(&mut d, divider(&[false]), vec2(600.0, 0.0)); + let SplitSize::FixedSecond(width) = start(&d) else { panic!("{:?}", d.layout) }; + assert!(width < 299.0, "{width}"); + let panel = group(&d, 3).width(); + wider(&mut d); + assert!((group(&d, 3).width() - panel).abs() < 1.0, "{} {panel}", group(&d, 3).width()); +} + +#[test] +fn an_intersection_moves_both_dividers() { + let mut d = film(); + let (project, tools, source) = (group(&d, PROJECT[0]), group(&d, TOOLS), group(&d, SOURCE[0])); + let hit = Hit::Divider(Divider::Intersection(vec![], vec![true])); + let at = d.hit(&hit).unwrap_or_else(|| panic!("{:?}", d.hits())); + assert!(at.contains(pos2(project.right() + 2.0, project.top() - 2.0)), "{at:?} at Project's top right corner"); + drag_hit(&mut d, hit, vec2(15.0, -60.0)); + assert!((group(&d, PROJECT[0]).width() - (project.width() + 15.0)).abs() < 1.0); + assert!((group(&d, PROJECT[0]).height() - (project.height() + 60.0)).abs() < 1.0); + assert!((group(&d, TOOLS).width() - (tools.width() - 15.0)).abs() < 1.0); + assert!((group(&d, SOURCE[0]).height() - (source.height() - 60.0)).abs() < 1.0); + assert_eq!(group(&d, SOURCE[0]).width(), source.width()); +} + +#[test] +fn closing_a_group_gives_its_space_to_its_neighbours() { + let mut d = film(); + let (tools, meters) = (group(&d, TOOLS), group(&d, METERS)); + d.layout.apply(Action::CloseGroup { panel: TIMELINE }).unwrap(); + d.settle(); + let meters_now = group(&d, METERS); + assert_eq!(group(&d, TOOLS), tools); + assert_eq!((meters_now.right(), meters_now.top(), meters_now.bottom()), (meters.right(), meters.top(), meters.bottom())); + assert!((meters_now.left() - (tools.right() + 4.0)).abs() < 1.0, "the row has no hole: {meters_now:?}"); +} + +// The stuck cases of the FilmCraft map (map.md section 3). + +#[test] +fn info_dropped_onto_a_capped_group_lifts_the_cap() { + let content = Film { caps: HashMap::from([(METERS, 116.0)]), ..Film::default() }; + let mut d = driver(editing(), content); + let meters = group(&d, METERS); + d.drag(tab(&d, INFO), pos2(meters.center().x, meters.center().y + 13.0)); + d.settle(); + assert!(d.layout.location(&INFO).is_ok_and(|l| l.anchor == Some(METERS)), "{:?}", d.layout); + // Info is the group's shown panel. + let width = group(&d, INFO).width(); + drag_hit(&mut d, divider(&[true, true, true]), vec2(-200.0, 0.0)); + assert!((group(&d, INFO).width() - (width + 200.0)).abs() < 1.0, "the group grows past the cap of one of its panels"); + drag_hit(&mut d, divider(&[true, true, true]), vec2(150.0, 0.0)); + assert!((group(&d, INFO).width() - (width + 50.0)).abs() < 1.0, "and shrinks again"); +} + +#[test] +fn a_drop_beside_a_narrow_group_splits_that_group_only() { + let mut d = film(); + drag_hit(&mut d, divider(&[true, true]), vec2(80.0, 0.0)); + let (tools, project, timeline, meters) = (group(&d, TOOLS), group(&d, PROJECT[0]), group(&d, TIMELINE), group(&d, METERS)); + assert!(tools.width() > 120.0, "{tools:?}"); + let at = pos2(tools.right() - 8.0, tools.center().y + 13.0); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(tools, 26.0, 40.0, Zone::Right).unwrap()), "the preview fills Tools' right band"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Right) }]); + d.settle(); + assert!(close(group(&d, TOOLS).union(group(&d, INFO)), tools)); + assert_eq!((group(&d, PROJECT[0]), group(&d, TIMELINE), group(&d, METERS)), (project, timeline, meters)); +} + +#[test] +fn every_divider_travels_after_drops() { + let mut d = film(); + let tools = group(&d, TOOLS); + d.drag(tab(&d, INFO), pos2(tools.right() - 8.0, tools.center().y + 13.0)); + d.settle(); + let meters = group(&d, METERS); + d.drag(tab(&d, PROGRAM), pos2(meters.center().x, meters.center().y + 13.0)); + d.settle(); + let timeline = group(&d, TIMELINE); + d.drag(tab(&d, PROPERTIES[0]), pos2(timeline.center().x, timeline.bottom() - 10.0)); + d.settle(); + assert_dividers_travel(&mut d); +} + +/// Each split divider, in the docked layout and in floating windows, moves both ways when +/// dragged, from the layout as it is. +fn assert_dividers_travel(d: &mut Driver) { + let start = d.layout.clone(); + let dividers: Vec> = d + .hits() + .into_iter() + .filter_map(|(hit, _)| matches!(hit, Hit::Divider(Divider::Split(_) | Divider::WindowSplit(..))).then_some(hit)) + .collect(); + assert!(!dividers.is_empty()); + // A floating window may cover the dividers of the main area and of other windows. + let covers: Vec<(Rect, &Floating)> = start + .floating + .iter() + .map(|w| { + let drawn = w.panels().filter(|p| d.content.bodies.contains_key(p)).map(|p| group(d, *p)); + let rect = Rect::from_min_size(pos2(w.rect[0], w.rect[1]), vec2(w.rect[2], w.rect[3])); + (drawn.fold(rect, |a, b| a.union(b)), w) + }) + .collect(); + for hit in dividers { + let own = |w: &Floating| matches!(&hit, Hit::Divider(Divider::WindowSplit(key, _)) if w.contains(key)); + if d.hit(&hit).is_some_and(|r| covers.iter().any(|(c, w)| c.intersects(r) && !own(w))) { + continue; + } + for step in [12.0, -24.0] { + let Some(rect) = d.hit(&hit) else { continue }; + let delta = if rect.width() < rect.height() { vec2(step, 0.0) } else { vec2(0.0, step) }; + let before = d.layout.clone(); + drag_hit(d, hit.clone(), delta); + assert_ne!(d.layout, before, "divider {hit:?} does not move by {step}"); + } + d.layout = start.clone(); + d.settle(); + } +} + +/// Every group at or above its minimum, and every divider with room both ways, in the docked +/// layout and in each floating window. Reads the bodies drawn last. +fn assert_sizes(d: &Driver, context: &str) { + let mut style = DockStyle::default(); + frame_style(&mut style); + let sizer = Sizer { style: &style, content: &d.content }; + let mut trees: Vec<(&Node, Rect)> = d.layout.root.iter().map(|root| (root, d.area)).collect(); + for window in d.layout.floating.iter().filter(|w| !w.minimized) { + // The window's body is the union of its groups. + let drawn = window.panels().filter(|p| d.content.bodies.contains_key(p)).map(|p| group(d, *p)); + if let Some(rect) = drawn.reduce(|a, b| a.union(b)) { + trees.push((&window.root, rect)); + } + } + for (root, area) in trees { + let mut groups: Vec> = Vec::new(); + sizer.groups(root, area, &mut groups); + for g in &groups { + let room = g.rect.size() - g.limits.min; + assert!(room.x >= -0.01 && room.y >= -0.01, "{context}: {:?} is below its minimum {:?}", g.rect, g.limits.min); + } + let mut nodes = vec![(root, area, Vec::new())]; + while let Some((node, rect, path)) = nodes.pop() { + let Some([a, b]) = sizer.children(node, rect) else { continue }; + let (lo, hi) = sizer.range((root, area), &path); + assert!(lo < -0.5 && hi > 0.5, "{context}: a divider is stuck ({lo}, {hi}) at {rect:?} in {:?}", d.layout); + for (second, (child, rect)) in [(false, a), (true, b)] { + let mut path = path.clone(); + path.push(second); + nodes.push((child, rect, path)); + } + } + } +} + +/// A small deterministic generator, so a failing sequence repeats. +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + self.0 >> 33 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } + fn unit(&mut self) -> f32 { + (self.next() % 1000) as f32 / 1000.0 + } +} + +#[test] +fn random_drops_leave_every_group_its_minimum_and_every_divider_travel() { + // Drops land in the docked layout and in two floating windows, one of them split; tabs and + // drag bars move panels and groups between all three, and Ctrl/Cmd floats some. + let windows = || { + let mut layout = editing(); + layout.floating.push(Floating::new(hsplit(0.5, tabs(&[8]), tabs(&[50, 51])), [80.0, 420.0, 640.0, 380.0])); + layout + }; + for seed in 1..=6u64 { + let mut rng = Lcg(seed); + let mut d = driver(windows(), Film::default()); + let all = panel_set(&d.layout); + for step in 0..16 { + // Only the bodies drawn now. + d.content.bodies.clear(); + d.settle(); + let panels: Vec = d.layout.panels().into_iter().copied().collect(); + let panel = panels[rng.below(panels.len())]; + let bar = d.hit(&Hit::DragBar(panel)).filter(|_| rng.below(3) == 0); + let Some(from) = bar.or_else(|| d.hit(&Hit::Tab(panel))).map(|r| r.center()) else { continue }; + let target = panels[rng.below(panels.len())]; + let Some(rect) = d.content.bodies.get(&target).copied() else { continue }; + let to = match rng.below(8) { + // A window edge. + 0 => { + let edges = [ + pos2(3.0, rect.center().y), + pos2(SCREEN.x - 3.0, rect.center().y), + pos2(rect.center().x, 3.0), + pos2(rect.center().x, SCREEN.y - 3.0), + ]; + edges[rng.below(4)] + } + // The tab row. + 1 => pos2(rect.center().x, rect.top() - 13.0), + // Anywhere in the body: an edge band or the middle. + _ => rect.lerp_inside(vec2(0.02 + rng.unit() * 0.96, 0.02 + rng.unit() * 0.96)), + }; + if rng.below(12) == 0 { + d.modifiers = Modifiers::COMMAND; + } + d.drag(from, to); + d.modifiers = Modifiers::NONE; + d.content.bodies.clear(); + d.settle(); + let context = format!("seed {seed}, step {step}"); + assert_eq!(d.layout.validate(), Ok(()), "{context}"); + assert_eq!(panel_set(&d.layout), all, "{context}: a panel was lost"); + assert_sizes(&d, &context); + } + assert_dividers_travel(&mut d); + } +} + +#[test] +fn ctrl_or_cmd_floats_from_a_tab_or_a_drag_bar_and_the_preview_says_so() { + // The hint names the platform's key, as `ctx.os()` reports it in tests. + let key = if egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" }; + let hint = format!("Hold {key} to undock"); + let floated = |actions: &[Action], panel: u32| match actions { + [Action::Float { panel: p, .. }] => *p == panel, + [Action::MoveGroup { panel: p, to: Target::Float { .. } }] => *p == panel, + _ => false, + }; + fn texts(d: &Driver) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + egui::Shape::Text(text) => out.push(text.galley.text().to_owned()), + _ => {} + } + } + let mut out = Vec::new(); + d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out)); + out + } + let d = film(); + let over = group(&d, TIMELINE).center() + vec2(0.0, 13.0); + let from_tab = tab(&d, PROJECT[0]); + let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + for from in [from_tab, from_bar] { + // Without the modifier, a release over a group docks there; the preview names the modifier. + let mut d = film(); + d.hold(from, over); + assert!(texts(&d).contains(&hint), "{from:?}: {:?}", texts(&d)); + let out = d.release(over); + assert!(!out.actions.is_empty() && !floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); + // Held, it floats, and the hint is gone. + let mut d = film(); + d.modifiers = Modifiers::COMMAND; + d.hold(from, over); + assert!(!texts(&d).contains(&hint), "{from:?}"); + let out = d.release(over); + assert!(floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); + } +} + +/// The Editing workspace under a 24 pt menu bar and above a 20 pt status bar. +fn film_in_window() -> Driver { + let mut d = DockDriver::new(editing(), Film::default()).with_style(frame_style); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = Rect::from_min_max(pos2(0.0, 24.0), pos2(SCREEN.x, SCREEN.y - 20.0)); + d.settle(); + d +} + +#[test] +fn a_release_over_no_target_inside_the_window_cancels_the_drag() { + let d = film_in_window(); + let (source, program) = (group(&d, SOURCE[0]), group(&d, PROGRAM)); + let gap = pos2((source.right() + program.left()) * 0.5, source.center().y); + let from_tab = tab(&d, PROJECT[0]); + let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + for from in [from_tab, from_bar] { + for to in [pos2(600.0, 10.0), pos2(600.0, SCREEN.y - 8.0), gap] { + let mut d = film_in_window(); + let before = d.layout.clone(); + let out = d.drag(from, to); + d.settle(); + assert!(out.actions.is_empty(), "{from:?} to {to:?}: {:?}", out.actions); + assert_eq!(d.layout, before, "{from:?} to {to:?}"); + } + // Ctrl/Cmd held floats it there. + let mut d = film_in_window(); + d.modifiers = Modifiers::COMMAND; + d.drag(from, pos2(600.0, 10.0)); + d.settle(); + assert!(d.layout.floating.iter().any(|w| w.contains(&PROJECT[0])), "{from:?}: {:?}", d.layout.floating); + } +} + +#[test] +fn a_release_outside_the_window_floats() { + let d = film_in_window(); + let from_tab = tab(&d, PROJECT[0]); + let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + for from in [from_tab, from_bar] { + let mut d = film_in_window(); + d.hold(from, pos2(600.0, 10.0)); + d.leave(); + d.settle(); + let window = d.layout.floating.iter().find(|w| w.contains(&PROJECT[0])); + assert!(window.is_some(), "{from:?}: {:?}", d.layout); + // A tab takes one panel; a drag bar takes the group. + assert_eq!(window.unwrap().contains(&PROJECT[1]), from == from_bar, "{from:?}"); + } +} + +#[test] +fn the_title_bar_moves_a_floating_window_without_docking() { + let mut d = film(); + let title = d.hit(&Hit::Title(INFO)).unwrap().center(); + let timeline = group(&d, TIMELINE); + d.drag(title, timeline.center()); + d.settle(); + let window = d.layout.floating.iter().find(|w| w.contains(&INFO)).expect("still floating"); + assert!(window.rect[0] < 1000.0 && window.rect[1] > 400.0, "moved towards the drop: {:?}", window.rect); + assert_eq!(d.layout.root, editing().root); +} + +fn panel_set(layout: &Layout) -> Vec { + let mut panels: Vec = layout.panels().into_iter().copied().collect(); + panels.sort_unstable(); + panels +} + +fn window_of(layout: &Layout, panel: u32) -> &Floating { + layout.floating.iter().find(|w| w.contains(&panel)).unwrap_or_else(|| panic!("{panel} does not float: {layout:?}")) +} + +#[test] +fn tabs_and_drag_bars_dock_into_a_floating_window_and_out_of_it() { + let mut d = film(); + let all = panel_set(&d.layout); + let info = group(&d, INFO); + let out = d.drag(tab(&d, PROGRAM), pos2(info.left() + 10.0, info.center().y + 13.0)); + assert_eq!(out.actions, vec![Action::Move { panel: PROGRAM, anchor: INFO, placement: Placement::Split(Zone::Left) }]); + d.settle(); + assert!(matches!(window_of(&d.layout, INFO).root, Node::Split { axis: SplitAxis::Horizontal, .. }), "{:?}", d.layout.floating); + let (program, info) = (group(&d, PROGRAM), group(&d, INFO)); + assert!(program.right() < info.left() && (program.width() - info.width()).abs() < 2.0, "{program:?} {info:?}"); + + // A drag bar inside the window moves its group to a group of the main area. + let timeline = group(&d, TIMELINE); + let bar = d.hit(&Hit::DragBar(INFO)).unwrap().center(); + let out = d.drag(bar, timeline.center() + vec2(0.0, 13.0)); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: INFO, to: Target::At { anchor: TIMELINE, placement: Placement::Split(Zone::Center) } }]); + d.settle(); + assert_eq!(window_of(&d.layout, PROGRAM).root, tabs(&[PROGRAM])); + assert_eq!(panel_set(&d.layout), all); +} + +#[test] +fn a_floating_window_tree_resizes_by_its_dividers_and_intersections() { + let mut layout = editing(); + let tree = hsplit(0.5, tabs(&[INFO]), split(SplitAxis::Vertical, 0.5, tabs(&[50]), tabs(&[51]))); + layout.floating = vec![Floating::new(tree, [700.0, 100.0, 600.0, 500.0])]; + let mut d = driver(layout, Film::default()); + let ratios = |d: &Driver| match &d.layout.floating[0].root { + Node::Split { size: SplitSize::Ratio(outer), second, .. } => match &**second { + Node::Split { size: SplitSize::Ratio(inner), .. } => (*outer, *inner), + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + }; + let outer = Hit::Divider(Divider::WindowSplit(INFO, Vec::new())); + let inner = Hit::Divider(Divider::WindowSplit(INFO, vec![true])); + for (hit, delta) in [(&outer, vec2(30.0, 0.0)), (&outer, vec2(-60.0, 0.0)), (&inner, vec2(0.0, 30.0)), (&inner, vec2(0.0, -60.0))] { + let before = ratios(&d); + drag_hit(&mut d, hit.clone(), delta); + let after = ratios(&d); + let moved = if hit == &outer { (after.0 - before.0) * delta.x } else { (after.1 - before.1) * delta.y }; + assert!(moved > 0.0, "{hit:?} by {delta:?}: {before:?} to {after:?}"); + assert_eq!(d.layout.root, editing().root, "the main area keeps its sizes"); + } + // The four-way handle where the inner divider meets the outer one moves both. + let before = ratios(&d); + drag_hit(&mut d, Hit::Divider(Divider::WindowIntersection(INFO, Vec::new(), vec![true])), vec2(25.0, 25.0)); + let after = ratios(&d); + assert!(after.0 > before.0 && after.1 > before.1, "{before:?} to {after:?}"); +} + +#[test] +fn moves_between_windows_are_one_action_and_lose_no_panel() { + let mut layout = editing(); + layout.floating.push(Floating::single(vec![50, 51], 0, [200.0, 520.0, 320.0, 260.0])); + let mut d = driver(layout, Film::default()); + let all = panel_set(&d.layout); + // Floating to floating: a whole group, by its drag bar, beside Info. + let info = group(&d, INFO); + let bar = d.hit(&Hit::DragBar(50)).unwrap().center(); + let out = d.drag(bar, pos2(info.right() - 10.0, info.center().y + 13.0)); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: 50, to: Target::At { anchor: INFO, placement: Placement::Split(Zone::Right) } }]); + d.settle(); + assert_eq!(d.layout.floating.len(), 1); + assert_eq!(panel_set(&d.layout), all); + // Main to floating: a tab joins the window's group. + let fifty = group(&d, 50); + let out = d.drag(tab(&d, SOURCE[1]), fifty.center() + vec2(0.0, 13.0)); + assert_eq!(out.actions, vec![Action::Move { panel: SOURCE[1], anchor: 50, placement: Placement::Split(Zone::Center) }]); + d.settle(); + assert!(window_of(&d.layout, 50).contains(&SOURCE[1])); + assert_eq!(panel_set(&d.layout), all); + assert_eq!(d.layout.validate(), Ok(())); +} + +#[test] +fn a_closed_floating_window_closes_its_panels_and_each_reopens_where_it_was() { + let mut layout = editing(); + let tree = hsplit(0.4, tabs(&[INFO]), tabs(&[50])); + layout.floating = vec![Floating::new(tree.clone(), [700.0, 100.0, 600.0, 400.0])]; + let mut d = driver(layout, Film::default()); + let locations: Vec<_> = [INFO, 50].into_iter().map(|p| (p, d.layout.location(&p).unwrap())).collect(); + let close = d.hit(&Hit::Close(INFO)).unwrap().center(); + let out = d.click(close); + assert_eq!(out.actions, vec![Action::CloseWindow { panel: INFO }]); + assert!(d.layout.floating.is_empty() && !d.layout.contains(&50)); + for (panel, location) in &locations { + d.layout.restore(*panel, location).unwrap(); + } + let window = &d.layout.floating[0]; + assert_eq!((&window.root, window.rect), (&tree, [700.0, 100.0, 600.0, 400.0])); +} + +/// The zone outlines of the last frame: closed 1 pt paths without a fill. +fn zone_outlines(d: &Driver) -> Vec> { + d.shapes() + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Path(path) if path.closed && path.fill == egui::Color32::TRANSPARENT && path.stroke.width == 1.0 => { + Some(path.points.clone()) + } + _ => None, + }) + .collect() +} + +fn same_polygon(a: &[Pos2], b: &[Pos2]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.distance(*b) <= 0.5) +} + +fn corners(rect: Rect) -> Vec { + vec![rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()] +} + +#[test] +fn the_preview_fills_the_zone_under_the_pointer_and_no_result_rect() { + let d = film(); + let timeline = group(&d, TIMELINE); + let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); + // A side band: its trapezoid, without the half the panel takes. + for (zone, at) in + [(Zone::Left, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)), (Zone::Bottom, pos2(timeline.center().x, timeline.bottom() - 20.0))] + { + let mut d = film(); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, zone).unwrap()), "{zone:?}"); + assert!(!d.outlined(zone_rect(timeline, zone).unwrap().shrink(1.0)), "{zone:?}"); + d.key(egui::Key::Escape); + } + // The middle and the tab row: the center rectangle and the strip. + for at in [timeline.center() + vec2(0.0, 13.0), strip.center()] { + let mut d = film(); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, Zone::Center).unwrap()), "{at:?}"); + assert!(d.filled_polygon(&corners(strip)), "{at:?}"); + assert!(!d.outlined(timeline.shrink(1.0)), "{at:?}"); + d.key(egui::Key::Escape); + } + // A group's drag bar shows the same. + let mut d = film(); + let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + d.hold(bar, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); + assert!(d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, Zone::Left).unwrap())); + assert_eq!(zone_outlines(&d).len(), 6); + d.key(egui::Key::Escape); + + // The dock-and-canvas arrangement keeps its filled half. + let mut d = DockDriver::new(editing(), Film::default()); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = d.screen; + d.settle(); + d.hold(tab(&d, INFO), pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); + assert!(!d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, Zone::Left).unwrap())); + assert!(zone_outlines(&d).is_empty()); + assert!(d.filled(zone_rect(timeline, Zone::Left).unwrap().shrink(1.0))); +} + +#[test] +fn a_drag_over_a_group_outlines_all_its_zones_and_fills_the_one_drop_zone_names() { + let d = film(); + let timeline = group(&d, TIMELINE); + let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); + let shape = |zone| zone_shape(timeline, 26.0, 40.0, zone).unwrap(); + let all: Vec> = + [Zone::Left, Zone::Right, Zone::Top, Zone::Bottom, Zone::Center].map(shape).into_iter().chain([corners(strip)]).collect(); + let body = Rect::from_min_max(pos2(timeline.left(), strip.bottom()), timeline.max); + // Each band, near its corners too, the middle, and the tab row. + let points = [ + pos2(body.left() + 30.0, body.center().y), + pos2(body.left() + 30.0, body.top() + 34.0), + pos2(body.right() - 2.0, body.bottom() - 12.0), + pos2(body.center().x, body.top() + 5.0), + pos2(body.left() + 50.0, body.bottom() - 30.0), + body.center(), + pos2(body.left() + 45.0, body.top() + 45.0), + strip.center(), + ]; + for at in points { + let mut d = film(); + d.hold(tab(&d, INFO), at); + let outlines = zone_outlines(&d); + assert_eq!(outlines.len(), all.len(), "{at:?}: four sides, the center and the strip"); + assert!(all.iter().all(|zone| outlines.iter().any(|o| same_polygon(o, zone))), "{at:?}: only Timeline's zones"); + let zone = drop_zone(timeline, 26.0, 40.0, at).unwrap(); + assert!(d.filled_polygon(&shape(zone)), "{at:?}: {zone:?} is filled"); + let filled = all.iter().filter(|z| d.filled_polygon(z)).count(); + assert_eq!(filled, if zone == Zone::Center { 2 } else { 1 }, "{at:?}: only {zone:?}"); + d.key(egui::Key::Escape); + } +} + +#[test] +fn zones_a_group_does_not_take_are_not_drawn() { + let timeline = group(&film(), TIMELINE); + let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); + let shape = |zone| zone_shape(timeline, 26.0, 40.0, zone).unwrap(); + let at = pos2(timeline.left() + 8.0, timeline.center().y + 13.0); + let content = |rules: Permissions| Film { rules: HashMap::from([(TIMELINE, rules)]), ..Film::default() }; + // No splits: the center and the strip only, and the left band under the pointer neither + // outlined nor filled. + let mut d = driver(editing(), content(Permissions { accepts_splits: false, ..Permissions::default() })); + d.hold(tab(&d, INFO), at); + let outlines = zone_outlines(&d); + assert_eq!(outlines.len(), 2, "{outlines:?}"); + assert!(outlines.iter().any(|o| same_polygon(o, &shape(Zone::Center))) && outlines.iter().any(|o| same_polygon(o, &corners(strip)))); + assert!(!d.filled_polygon(&shape(Zone::Left))); + d.key(egui::Key::Escape); + // No tabs: the four sides only. + let mut d = driver(editing(), content(Permissions { accepts_tabs: false, ..Permissions::default() })); + d.hold(tab(&d, INFO), at); + let outlines = zone_outlines(&d); + assert_eq!(outlines.len(), 4, "{outlines:?}"); + assert!([Zone::Left, Zone::Right, Zone::Top, Zone::Bottom].into_iter().all(|z| outlines.iter().any(|o| same_polygon(o, &shape(z))))); + assert!(d.filled_polygon(&shape(Zone::Left))); + d.key(egui::Key::Escape); + // A tab over its own single-tab group: nothing to drop on. + let mut d = film(); + d.hold(tab(&d, TIMELINE), timeline.center() + vec2(0.0, 13.0)); + assert!(zone_outlines(&d).is_empty()); +} + +#[test] +fn a_narrow_group_splits_on_every_side_and_its_neighbours_give_the_room() { + // Tools at 80 pt: two halves need 84, so a side split takes the rest from Timeline. + let narrow = || { + let mut d = film(); + let width = group(&d, TOOLS).width(); + drag_hit(&mut d, divider(&[true, true]), vec2(80.0 - width, 0.0)); + d + }; + let d = narrow(); + let (tools, timeline) = (group(&d, TOOLS), group(&d, TIMELINE)); + assert_eq!(tools.width().round(), 80.0); + let body_top = tools.top() + 26.0; + for (zone, at) in [ + (Zone::Left, pos2(tools.left() + 5.0, tools.center().y)), + (Zone::Right, pos2(tools.right() - 5.0, tools.center().y)), + (Zone::Top, pos2(tools.center().x, body_top + 5.0)), + (Zone::Bottom, pos2(tools.center().x, tools.bottom() - 15.0)), + ] { + let mut d = narrow(); + d.hold(tab(&d, INFO), at); + assert_eq!(zone_outlines(&d).len(), 6, "{zone:?}: four sides, the center and the strip"); + assert!(d.filled_polygon(&zone_shape(tools, 26.0, 40.0, zone).unwrap()), "{zone:?} is filled"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(zone) }]); + d.settle(); + let (info, split) = (group(&d, INFO), group(&d, TOOLS)); + let beside = match zone { + Zone::Left => info.right() < split.left(), + Zone::Right => info.left() > split.right(), + Zone::Top => info.bottom() < split.top(), + _ => info.top() > split.bottom(), + }; + assert!(beside, "{zone:?}: Info {info:?}, Tools {split:?}"); + if matches!(zone, Zone::Left | Zone::Right) { + assert_eq!((info.width(), split.width()), (40.0, 40.0), "{zone:?}"); + assert!((group(&d, TIMELINE).width() - (timeline.width() - 4.0)).abs() < 1.0, "{zone:?}: Timeline gives the room"); + } + assert_sizes(&d, &format!("{zone:?}")); + } +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 00b56b07..3e994ebc 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -1,16 +1,15 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Color32, Context, FontId, Id, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, vec2}; +use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2}; -use crate::{ - layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect}, - tabs, -}; +use super::{Action, DockError, DropTarget, Layout, Monitor, Permissions, render::Renderer, resize::AnchoredResize}; -use super::{Action, DockError, DropTarget, Layout, Node, Permissions, Placement, resolve_drop}; +/// Horizontal room around a tab's label, both when measuring its natural width and when cutting it. +pub(super) const TAB_PADDING: f32 = 24.0; /// Application-supplied chrome. Body renderers retain their own UI style and contents. #[derive(Clone, Debug)] +#[non_exhaustive] pub struct DockStyle { pub tab_height: f32, pub gap: f32, @@ -26,15 +25,124 @@ pub struct DockStyle { /// Localize these action labels in the application adapter. pub float_label: String, pub close_label: String, - pub move_label: String, + /// Tab menu item that moves one panel into another group (submenu of target panels). + pub move_panel_to_label: String, pub panels_label: String, pub resize_label: String, pub resize_window_label: String, + /// Accessibility label of a group's drag bar and top border. + pub move_group_label: String, + pub close_group_label: String, + pub minimize_label: String, + pub expand_label: String, + pub float_group_label: String, + /// Group menu item that moves the whole group into another group (submenu of targets). + pub move_group_to_label: String, + /// Minimum width of the drag bar left of the group header. Tabs shrink, then overflow, + /// before the bar narrows; a strip narrower than the header plus this width narrows it. + pub drag_bar_width: f32, + /// Narrowest a tab gets before the tabs that do not fit go to the overflow menu: about + /// three characters and the "…" at [`Self::font`]'s size, plus the tab's 24 pt padding + /// (54 pt at the default 12 pt font). Tabs whose names are shorter keep their natural + /// width. Set it again after changing the font. + pub min_tab_width: f32, + /// Painted under every floating window, so it stays opaque when the panel colors are not. + pub floating_background: Color32, + pub floating_stroke: Stroke, + /// Painted over a native floating window while [`DockArea::input_blocked`] is on. + pub blocked_dim: Color32, + /// Height of a floating window's title bar. + pub title_height: f32, + /// Width of the band along the layout's outer edge that splits at the root. + pub edge_band: f32, + /// The outer edge band also runs along the left and right edges, where a drop adds a + /// full-height column. Off: only the top and bottom edges add a full-width row. + pub side_edge_zones: bool, + /// How far a group's side drop zones reach into its body, below the tab strip. All four + /// zones share one depth: this, or a quarter of the body's smaller side when that is less, + /// so the body keeps a center. + pub zone_depth: f32, + /// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new + /// dock (see [`Self::new_dock_band`]). + pub new_dock_zones: bool, + /// [D] Height of the bar above each dock column. It drags the column and holds its »/« button. + pub column_bar_height: f32, + /// [D] Height of the band around the seam between two stacked groups in a dock column. + pub seam_band: f32, + /// [D] Width of the band along the docked area's left and right edges that makes a new dock. + pub new_dock_band: f32, + /// [D] Side of a panel icon in an iconic column; each icon row is 10 pt taller. + pub icon_size: f32, + /// [D] Width of an iconic column without a saved width, and the narrowest it gets. + pub iconic_width: f32, + /// [D] Widest an iconic column gets by dragging its inner edge. + pub iconic_max_width: f32, + /// [D] An iconic column at least this wide shows panel names beside the icons. + pub iconic_label_width: f32, + /// [D] Column menu item that toggles Auto-Collapse Iconic Panels. + pub auto_collapse_label: String, + /// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide + /// layout direction, so the host sets this from its locale. Splits keep their sides. + pub right_to_left: bool, + /// [D] Accessibility label of a column bar. + pub move_column_label: String, + /// [D] Column menu and group menu items. + pub collapse_to_icons_label: String, + pub expand_panels_label: String, + /// [D] Accessibility labels of the column bar's »/« button, distinct from the menu items. + pub collapse_column_label: String, + pub expand_column_label: String, + pub move_window_label: String, + pub close_window_label: String, + /// Group menu items Close Other Panels in Group, and Maximize Frame / Restore Frame Size + /// for groups of the split tree, each shown when its flag is on. + pub close_others_label: String, + pub close_others_item: bool, + pub maximize_label: String, + pub restore_frame_label: String, + pub maximize_item: bool, + /// Shown under the pointer while a drag offers a docking target, when [`Self::float_hint`] + /// is on. `{key}` stands for the modifier that floats the drop instead: ⌘ on macOS, Ctrl + /// elsewhere. + pub float_hint_label: String, + pub float_hint: bool, + /// Ctrl/Cmd held when a tab drag of a split-tree group starts makes its drop float, even + /// when the key is let go during the drag. Off: only the key held at the drop counts. + pub command_press_floats: bool, + /// A drag released over no drop target inside the window floats what it carries, such as + /// a panel dropped on a protected canvas. Off: that release cancels the drag; only + /// Ctrl/Cmd or a release outside the window floats. + pub float_on_empty_drop: bool, + /// Dragging a floating window's title bar onto a drop target docks the whole window there. + /// Off: the title bar only moves the window; tabs and drag bars dock. + pub title_bar_docks: bool, + /// Show the built-in Close items in tab and group menus (Close Panel, Close Tab Group, + /// Close Other Panels in Group) for closable panels. A host that draws its own Close items + /// turns this off and keeps [`Permissions::closable`], so its close actions still apply. + pub close_menu_items: bool, + /// Show × on the title bar of a floating window whose panels are all closable. + pub close_button: bool, + /// The mouse wheel over a tab strip selects the next or previous tab, one per notch. + pub wheel_selects_tabs: bool, + /// A middle click on a tab closes its panel when the panel is closable. + pub middle_click_closes_tabs: bool, +} + +impl Default for DockStyle { + /// [`Self::from_visuals`] with egui's default visuals. + fn default() -> Self { + Self::from_visuals(&egui::Visuals::default()) + } } impl DockStyle { + /// Colors from the `ui`'s visuals; adjust fields afterwards. pub fn from_ui(ui: &Ui) -> Self { - let visuals = ui.visuals(); + Self::from_visuals(ui.visuals()) + } + + pub fn from_visuals(visuals: &egui::Visuals) -> Self { + let font = FontId::proportional(12.0); Self { tab_height: 26.0, gap: 4.0, @@ -46,18 +154,86 @@ impl DockStyle { inactive_text: visuals.weak_text_color(), border: visuals.widgets.noninteractive.bg_stroke, accent: visuals.selection.stroke.color, - font: FontId::proportional(12.0), - float_label: "Float panel".into(), - close_label: "Close panel".into(), - move_label: "Move to group".into(), + min_tab_width: font.size * 2.5 + TAB_PADDING, + font, + float_label: "Float Panel".into(), + close_label: "Close Panel".into(), + move_panel_to_label: "Move Panel to Group".into(), panels_label: "Panels".into(), resize_label: "Resize panels".into(), resize_window_label: "Resize panel window".into(), + move_group_label: "Move tab group".into(), + close_group_label: "Close Tab Group".into(), + minimize_label: "Minimize".into(), + expand_label: "Expand".into(), + float_group_label: "Float Tab Group".into(), + move_group_to_label: "Move Group To".into(), + drag_bar_width: 40.0, + floating_background: visuals.window_fill, + floating_stroke: visuals.window_stroke, + blocked_dim: visuals.panel_fill.gamma_multiply(0.6), + title_height: super::chrome::TITLE_HEIGHT, + edge_band: 8.0, + side_edge_zones: false, + zone_depth: 40.0, + new_dock_zones: true, + column_bar_height: 14.0, + seam_band: 8.0, + new_dock_band: 12.0, + icon_size: 20.0, + iconic_width: 36.0, + iconic_max_width: 240.0, + iconic_label_width: 96.0, + auto_collapse_label: "Auto-Collapse Iconic Panels".into(), + right_to_left: false, + move_column_label: "Move panel column".into(), + collapse_to_icons_label: "Collapse to Icons".into(), + expand_panels_label: "Expand Panels".into(), + collapse_column_label: "Collapse column to icons".into(), + expand_column_label: "Expand column".into(), + move_window_label: "Move panel window".into(), + close_window_label: "Close panel window".into(), + close_others_label: "Close Other Panels in Group".into(), + close_others_item: false, + maximize_label: "Maximize Frame".into(), + restore_frame_label: "Restore Frame Size".into(), + maximize_item: false, + float_hint_label: "Hold {key} to undock".into(), + float_hint: false, + command_press_floats: false, + float_on_empty_drop: true, + title_bar_docks: true, + close_menu_items: true, + close_button: true, + wheel_selects_tabs: false, + middle_click_closes_tabs: false, } } - fn valid(&self) -> bool { - [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size].iter().all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + pub(super) fn valid(&self) -> bool { + [ + self.tab_height, + self.gap, + self.min_pane, + self.border.width, + self.font.size, + self.drag_bar_width, + self.min_tab_width, + self.floating_stroke.width, + self.title_height, + self.edge_band, + self.zone_depth, + self.column_bar_height, + self.seam_band, + self.new_dock_band, + self.icon_size, + self.iconic_width, + self.iconic_max_width, + self.iconic_label_width, + ] + .iter() + .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + && self.iconic_width <= self.iconic_max_width && self.tab_height > 0.0 && self.font.size > 0.0 } @@ -67,21 +243,334 @@ impl DockStyle { pub struct DockOutput

{ pub actions: Vec>, pub error: Option, + /// The panel the user focused this frame: a clicked tab or menu entry, a drop, or a press + /// of any pointer button in a panel body (stripless panels included) or in a group header, + /// which focuses the group's shown panel. Only user input sets + /// it; [`DockArea::raise_panel`] and host commands do not. pub focused: Option

, /// Whole-floating-group gesture identity; host may display its own group drop preview. pub group_drag: Option

, /// Whole-group release for host-owned atomic docking policy. pub group_drop: Option<(P, Pos2)>, + /// Requests that are not layout actions. + pub events: Vec>, + /// Key, text and clipboard events (`Key`, `Text`, `Copy`, `Cut`, `Paste`) that reached a + /// floating window in its own OS window and that none of its widgets used (feature + /// `native-windows`). egui hands each OS window only its own input, so an application + /// passes these to the shortcut handling it runs on the main window's input. A window + /// where a widget has keyboard focus forwards nothing. + pub forwarded_events: Vec, + /// The modifiers held in the native window that forwarded `forwarded_events`, where it + /// forwarded any. egui-winit turns Cmd/Ctrl+C, X and V into `Copy`, `Cut` and `Paste` + /// without the key, so these tell them from the keyboard's own Copy, Cut and Paste keys. + pub forwarded_modifiers: Option, + /// Files dragged over or dropped on floating windows in their own OS windows this frame, + /// one entry per window (feature `native-windows`). egui hands each OS window only its own + /// input, so the main window's `RawInput` never holds them. + pub window_files: Vec>, +} + +/// Files dragged over or dropped on a native floating window in one frame. +#[derive(Clone, Debug)] +pub struct WindowFiles

{ + /// [`super::Floating::id`] of the window. + pub window: u64, + /// The window's egui viewport. + pub viewport: egui::ViewportId, + /// The panel whose body is under the pointer, else the window's shown panel: the window + /// system may send no pointer moves while files are dragged in. + pub panel: P, + /// The pointer's last position in the window's coordinates, where egui has one. + pub pos: Option, + /// The window's `RawInput::hovered_files`. + pub hovered: Vec, + /// The window's `RawInput::dropped_files`. + pub dropped: Vec, } impl

Default for DockOutput

{ fn default() -> Self { - Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None } + Self { + actions: Vec::new(), + error: None, + focused: None, + group_drag: None, + group_drop: None, + events: Vec::new(), + forwarded_events: Vec::new(), + forwarded_modifiers: None, + window_files: Vec::new(), + } } } +/// Room between the dock area's top-left corner and a unit a command floats. +const FLOAT_INSET: f32 = 24.0; + +/// Size a command floats a panel at when its [`FloatSpace`] never drew it. +#[cfg_attr(not(feature = "serde"), allow(dead_code))] +pub(super) const DEFAULT_FLOAT: Vec2 = Vec2::new(260.0, 320.0); + +/// Where a docking command floats a panel, group or column when it gives no rect (see +/// `DockCommand::to_actions`, feature `serde`). [`DockArea`] records one each frame; read it +/// with [`float_space`]. +#[derive(Clone, Debug, PartialEq)] +pub struct FloatSpace

{ + /// The rect the area drew in. A floated unit goes inside it, 24 points from its top-left + /// corner, so it stays clear of the app's menu bar. + pub area: Rect, + /// What a drag floats from each drawn panel: the panel alone, its group and its dock column. + pub(super) panels: Vec<(P, Vec2)>, + pub(super) groups: Vec<(P, Vec2)>, + pub(super) columns: Vec<(P, Vec2)>, + /// The height each panel's content used when it was last drawn, which fits a floated group + /// to its content; `None` for content that filled its body. + pub(super) contents: Vec<(P, Option)>, +} + +impl FloatSpace

{ + /// An area with no recorded sizes: commands float a panel at 260x320, and a group or + /// column at its dock column's width (else 260) by 320. + pub fn new(area: Rect) -> Self { + Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new() } + } + + /// Adds the size a command floats `panel` at alone when the area has not drawn it, such as + /// a hidden panel or a headless run. A size the area recorded for the panel takes precedence. + pub fn with_panel_size(mut self, panel: P, size: Vec2) -> Self { + self.panels.push((panel, size)); + self + } +} + +impl FloatSpace

{ + /// Record the height `panel`'s content used in a body `available` tall. Content that + /// reaches the body's bottom filled it and has no height of its own, unless the body was + /// sized to the height recorded before (a fitted window). `at` is the panel's entry in + /// `contents` if it has one; returns where the entry is. + pub(super) fn record_content(&mut self, at: Option, panel: P, used: f32, available: f32) -> usize { + const TOLERANCE: f32 = 1.0; + let used = if used.is_finite() { used.max(0.0) } else { 0.0 }; + let recorded = at.and_then(|i| self.contents.get_mut(i)).filter(|(p, _)| *p == panel); + let fitted = recorded.as_ref().and_then(|(_, height)| *height).is_some_and(|height| height >= available - TOLERANCE); + let height = (used < available - TOLERANCE || fitted).then_some(used); + match (recorded, at) { + (Some((_, recorded)), Some(at)) => { + *recorded = height; + at + } + _ => { + self.contents.push((panel, height)); + self.contents.len() - 1 + } + } + } +} + +/// Lookups for `DockCommand`, which needs feature `serde`. +#[cfg_attr(not(feature = "serde"), allow(dead_code))] +impl FloatSpace

{ + pub(super) fn panel(&self, panel: &P) -> Option { + Self::find(&self.panels, panel) + } + + pub(super) fn group(&self, panel: &P) -> Option { + Self::find(&self.groups, panel) + } + + pub(super) fn column(&self, panel: &P) -> Option { + Self::find(&self.columns, panel) + } + + /// The first record wins: an iconic panel is recorded at its icon before its flyout. + fn find(sizes: &[(P, Vec2)], panel: &P) -> Option { + sizes.iter().find(|(p, _)| p == panel).map(|(_, size)| *size) + } + + /// `[x, y, width, height]` of `size` inside the area, inset from its top-left corner and + /// shrunk to fit. + pub(super) fn place(&self, size: Vec2) -> [f32; 4] { + let size = size.min(self.area.size()); + let min = (self.area.min + Vec2::splat(FLOAT_INSET)).min(self.area.max - size).max(self.area.min); + [min.x, min.y, size.x, size.y] + } +} + +/// The [`FloatSpace`] the area recorded on its last frame. +pub fn float_space(ctx: &Context, area_id: Id) -> Option> { + ctx.data(|d| d.get_temp(float_space_key(area_id))) +} + +fn float_space_key(area: Id) -> Id { + area.with("float-space") +} + +/// The panel whose tab held keyboard focus at the end of the last frame, where it was, and +/// whether that frame's own actions may have moved it. +type TabFocus

= (P, super::dock_model::Place, bool); + +fn tab_focus_key(area: Id) -> Id { + area.with("tab-focus") +} + +/// The layout an area drew while one of its drags was in progress, and the actions it returned. +#[derive(Clone)] +struct DragFrame

{ + layout: Layout

, + actions: Vec>, +} + +fn drag_frame_key(area: Id) -> Id { + area.with("drag-frame") +} + +/// The controls [`cancel_drag`] ended drags of, until the next frame's native windows, each +/// with its own egui drag, have drawn. +#[cfg(feature = "native-windows")] +pub(in crate::docking) fn native_cancel_key(area: Id) -> Id { + area.with("cancel-native") +} + +/// A drag ends when its layout changes other than by the area's own actions: a workspace +/// switch, Reset or an app command. The app applies all of them or none; an action that +/// fails on its own is skipped, as it is for an app that applies them one at a time. Sizes +/// and rectangles are not compared, so an app that clamps a moved window or adjusts a resized +/// split keeps the drag going. +fn cancel_drag_if_replaced(ctx: &Context, area: Id, layout: &Layout

) { + let Some(last) = ctx.data_mut(|d| { + let last = d.get_temp::>(drag_frame_key(area)); + d.remove::>(drag_frame_key(area)); + last + }) else { + return; + }; + let now = shape(layout); + if now == shape(&last.layout) { + return; + } + let mut applied = last.layout; + for action in last.actions { + let _ = applied.apply(action); + } + if now != shape(&applied) { + cancel_drag::

(ctx, area); + } +} + +/// `layout` without its sizes and rectangles: which panels are where, and their state. +fn shape(layout: &Layout

) -> Layout

{ + use super::{Column, Floating, Group, Node, StackEntry}; + use crate::layout::SplitSize; + fn node(n: &Node

) -> Node

{ + match n { + Node::Split { axis, first, second, .. } => { + Node::Split { axis: *axis, size: SplitSize::Ratio(0.5), first: Box::new(node(first)), second: Box::new(node(second)) } + } + Node::Tabs { panels, active } => Node::Tabs { panels: panels.clone(), active: *active }, + Node::Stack { entries } => Node::Stack { entries: entries.iter().map(|e| StackEntry { height: None, ..e.clone() }).collect() }, + Node::Dock { side, columns } => Node::Dock { + side: *side, + columns: columns + .iter() + .map(|c| Column { + groups: c.groups.iter().map(|g| Group { height: None, ..g.clone() }).collect(), + width: 0.0, + iconic: c.iconic, + iconic_width: None, + }) + .collect(), + }, + } + } + Layout { + root: layout.root.as_ref().map(node), + floating: layout + .floating + .iter() + .map(|w| Floating { root: node(&w.root), rect: [0.0; 4], native: None, monitor: None, monitor_size: None, ..w.clone() }) + .collect(), + } +} + +/// Keep what [`cancel_drag_if_replaced`] compares the next frame against, while a drag of this +/// area goes on. +fn record_drag_frame( + ctx: &Context, + area: Id, + layout: &Layout

, + owned: &[Id], + native_held: bool, + actions: &[Action

], +) { + let held = native_held || ctx.dragged_id().is_some_and(|id| owned.contains(&id)); + #[cfg(feature = "native-windows")] + let held = held || ctx.data(|d| d.get_temp::(area.with("window-drag")).is_some()); + if held || is_dragging::

(ctx, area) { + let frame = DragFrame { layout: layout.clone(), actions: actions.to_vec() }; + ctx.data_mut(|d| d.insert_temp(drag_frame_key(area), frame)); + } +} + +/// Keyboard focus stays on a tab only while its panel stays where the user put it: a panel +/// hidden, closed or moved by the host (a command or a workspace reset) gives focus up. +fn release_moved_tab_focus(ctx: &Context, area: Id, layout: &Layout

) { + let Some((panel, place, moved_by_area)) = ctx.data(|d| d.get_temp::>(tab_focus_key(area))) else { return }; + let tab = area.with(("tab", &panel)); + let place_now = layout.place(&panel); + if ctx.memory(|m| m.has_focus(tab)) && (place_now.is_none() || (place_now != Some(place) && !moved_by_area)) { + ctx.memory_mut(|m| m.surrender_focus(tab)); + } +} + +fn record_tab_focus(ctx: &Context, area: Id, layout: &Layout

, acted: bool) { + let focused = ctx.memory(|m| m.focused()); + let tab = focused.and_then(|id| layout.panels().into_iter().find(|panel| area.with(("tab", *panel)) == id)); + let record = tab.and_then(|panel| Some((panel.clone(), layout.place(panel)?, acted))); + ctx.data_mut(|d| match record { + Some(record) => { + d.insert_temp(tab_focus_key(area), record); + } + None => d.remove::>(tab_focus_key(area)), + }); +} + +/// A request for host state outside the layout. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DockEvent

{ + /// Maximize Frame: maximize the frame holding `panel`, or restore it when it is the one + /// passed to [`DockArea::maximized`]. + ToggleMaximize { panel: P }, + /// [D] Auto-Collapse Iconic Panels was chosen in a column menu. The host flips its + /// preference and passes it to [`DockArea::auto_collapse_iconic`]. + ToggleAutoCollapse, + /// [D] The area's flyout opened, switched to another panel or closed (`None`), whatever + /// changed it: an icon, Escape, a press outside, a raised panel, a group that is no longer + /// iconic, or [`super::open_flyout`] and [`super::close_flyout`]. It comes once per change, + /// in the frame that draws the change. + FlyoutChanged { panel: Option

}, +} + +/// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay. +pub enum HidePanels<'a, P> { + None, + All, + /// Every panel except those for which the function returns true. + Except(&'a dyn Fn(&P) -> bool), +} + +impl

Clone for HidePanels<'_, P> { + fn clone(&self) -> Self { + *self + } +} + +impl

Copy for HidePanels<'_, P> {} + /// Preferred body bounds. Tiny viewports relax minima proportionally rather than producing -/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder. +/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder. A +/// tab group takes the largest minimum and the largest maximum of its panels, so a panel without +/// a maximum lifts the maximum of the panels it joins. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PanelLimits { pub min: Vec2, @@ -95,7 +584,7 @@ impl Default for PanelLimits { } impl PanelLimits { - fn valid(self) -> bool { + pub(super) fn valid(self) -> bool { self.min.is_finite() && self.max.is_finite() && self.min.x >= 0.0 @@ -107,18 +596,91 @@ impl PanelLimits { } } -#[derive(Clone)] -struct Drag

{ - panel: P, - origin: Pos2, - source: Rect, - grab: Vec2, +/// Body heights a panel asks for in a dock column while the user has not resized its group +/// (`Group::height` is `None`). Like [`PanelLimits`] they exclude the tab strip. A group takes +/// the largest of each among its panels. +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub struct DockHeights { + /// The height the group gets when the column has room. + pub default: f32, + /// The height it gives way down to, so the group that fills the column keeps its `fill`. + pub compact: f32, + /// The height it asks for when it fills the column: groups at their default shrink towards + /// `compact` before it gets less. + pub fill: f32, +} + +impl DockHeights { + /// A `default` height that does not give way (`compact` is `default`) and, filling the + /// column, asks for no more than its minimum (`fill` is 0). + pub fn new(default: f32) -> Self { + Self { default, compact: default, fill: 0.0 } + } + + pub fn compact(mut self, compact: f32) -> Self { + self.compact = compact; + self + } + + pub fn fill(mut self, fill: f32) -> Self { + self.fill = fill; + self + } + + pub(super) fn valid(self) -> bool { + [self.default, self.compact, self.fill].iter().all(|h| h.is_finite() && *h >= 0.0 && *h <= 1_000_000.0) + } +} + +/// How a floating window in its own OS window behaves (feature `native-windows`), see +/// [`DockContent::window_kind`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum WindowKind { + /// A tool palette: it stays above other windows and hides while none of the app's windows + /// has focus. + #[default] + Palette, + /// A window of its own, such as a viewer on a second display: normal window level, and it + /// stays shown while the app is in the background. When the app is activated, these + /// windows and the main window come forward, and the main window is left at the normal + /// level. + Normal, } #[derive(Clone)] -struct GroupDrag

{ - panel: P, - original: [f32; 4], +pub(super) struct Drag

{ + pub(super) panel: P, + pub(super) origin: Pos2, + pub(super) source: Rect, + pub(super) grab: Vec2, + /// Ctrl/Cmd held at the press, with [`DockStyle::command_press_floats`]: the drop floats. + pub(super) undock: bool, +} + +/// What a group gesture moves. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum GroupGesture { + /// A floating window's grip: the window follows the pointer. + Window, + /// A drag bar or top border: a ghost follows the pointer, the release picks the target. + Group, + /// [D] A column bar: like `Group`, for the whole dock column. + Column, +} + +#[derive(Clone)] +pub(super) struct GroupDrag

{ + /// The first member; identifies the gesture. + pub(super) panel: P, + /// The floating window rect at the press, restored when a window move is canceled. + pub(super) original: [f32; 4], + pub(super) gesture: GroupGesture, + pub(super) members: Vec

, + pub(super) origin: Pos2, + /// The group (or window) rect at the press. A floated group keeps this size. + pub(super) source: Rect, } /// Start a docking drag from an application's existing tab/header. Use the same area ID when @@ -129,32 +691,111 @@ pub fn begin_drag(ctx: &Context, area_id: Id, return false; } let grab = (origin - source_rect.min).clamp(Vec2::ZERO, source_rect.size()); - ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab })); + ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab, undock: false })); true } -/// Discard pending tab/group gestures and an actively dragged renderer-owned control before -/// replacing a workspace. Body/custom-header/menu gestures and other areas are untouched. +/// Whether this area's tab or group gesture is active. +pub fn is_dragging(ctx: &Context, area_id: Id) -> bool { + ctx.data(|data| data.get_temp::>(area_id.with("drag")).is_some() || data.get_temp::>(area_id.with("group-drag")).is_some()) +} + +/// Discard pending tab/group gestures, a native window's bar drag and an actively dragged +/// renderer-owned control, in the main window now and in a native window when it next draws. +/// Body/custom-header/menu gestures and other areas are untouched. /// Ownership is recorded as controls render; before an initial render or drag threshold there -/// may be no active egui drag to cancel. Keyboard focus is unchanged. The host owns the -/// replacement snapshot, so this does not restore a group's previous rectangle. +/// may be no active egui drag to cancel. Keyboard focus is unchanged. Nothing is restored from +/// before the drag: floating and native windows go where the next layout drawn has them. +/// +/// An area cancels its drags itself when the layout it is given is not the one it drew last +/// frame with its own actions applied (see [`DockArea::show`]), so replacing the workspace +/// needs no call. pub fn cancel_drag(ctx: &Context, area_id: Id) { let dragged = ctx.dragged_id(); let owned = ctx.data_mut(|d| { d.remove::>(area_id.with("drag")); d.remove::>(area_id.with("group-drag")); + #[cfg(feature = "native-windows")] + d.remove::(area_id.with("window-drag")); let ids = d.remove_temp::>(area_id.with("owned-interactions")); - dragged.is_some_and(|id| ids.is_some_and(|ids| ids.contains(&id))) + let resize = d.get_temp::(area_id.with("pointer-resize")); + d.remove::(area_id.with("pointer-resize")); + for id in resize.iter().chain(ids.iter().flatten()) { + d.remove::(id.with("pointer-anchor")); + } + #[cfg(feature = "native-windows")] + d.get_temp_mut_or_default::>(native_cancel_key(area_id)).extend(ids.iter().flatten()); + dragged.is_some_and(|id| resize == Some(id) || ids.is_some_and(|ids| ids.contains(&id))) }); if owned { ctx.stop_dragging(); } } -/// One mutable application adapter owns bodies, menus and shaped text, avoiding competing -/// mutable closures. Logical labels remain separate and are used for tab accessibility. +/// One mutable application adapter owns bodies, menus, shaped text and per-panel policy, +/// avoiding competing mutable closures. Logical labels are used for tab accessibility. +/// [`DockArea::show_content`] reads every policy from here; the closures passed to the older +/// `show*` functions override `logical_label`, `permissions` and `limits`. pub trait DockContent

{ fn body(&mut self, ui: &mut Ui, panel: &P); + /// Plain text name for accessibility, menus and the default visual label. + fn logical_label(&self, panel: &P) -> String + where + P: Debug, + { + format!("{panel:?}") + } + fn permissions(&self, _panel: &P) -> Permissions { + Permissions::default() + } + fn limits(&self, _panel: &P) -> PanelLimits { + PanelLimits::default() + } + /// How the panel's group sizes itself in a dock column until the user resizes it. Groups + /// with a [`DockHeights::fill`] above 0 share the column's free height. `None` (the + /// default) shares it with the other groups that have none when no group asks for a fill, + /// and takes the group's minimum when one does. + /// Groups at their [`DockHeights::default`] give way down to their `compact` height, the one + /// nearest the filler first, so the group that fills the column gets its `fill` height; when + /// the column is still too short, + /// every group gives way down to its minimum. A height the user set is kept. + fn dock_heights(&self, _panel: &P) -> Option { + None + } + /// Preferred floating size, independent of the current dock geometry: the width of a + /// floated panel and the most height it takes. A floated panel or group, and a flyout, fit + /// their height to the content the active panel last drew (at least 120 points), up to this + /// height, or up to 320 out of a dock when this is `None`. Content that filled its body + /// takes this height, else 320. A rect a command gives and a window the user resized keep + /// their size. + fn floating_size(&self, _panel: &P) -> Option { + None + } + /// An image for the panel's icon, tinted with the icon color. [`Self::paint_icon`] goes first. + fn icon(&self, _panel: &P) -> Option> { + None + } + /// Paint the panel's icon in code into `rect` with `color`, and return true. False draws + /// [`Self::icon`], or the label's initial when that is `None`. `painter` is clipped to the + /// icon's cell. + fn paint_icon(&mut self, _painter: &egui::Painter, _rect: Rect, _panel: &P, _color: Color32) -> bool { + false + } + /// Whether the panel's floating window may be its own OS window (feature + /// `native-windows`). A panel that returns false keeps its window in-window. + fn allow_native(&self, _panel: &P) -> bool { + true + } + /// How the panel's native floating window behaves (feature `native-windows`). A window + /// holding any [`WindowKind::Normal`] panel is a normal window; the rest are palettes. + fn window_kind(&self, _panel: &P) -> WindowKind { + WindowKind::Palette + } + /// Host-owned drop resolution for targets the renderer cannot know about. Returning + /// feedback consumes the gesture, including no-op targets; the renderer then emits no move. + fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, _request: &DropRequest<'_, P>) -> Option { + None + } fn label(&mut self, _ui: &Ui, _panel: &P, logical: &str, _font: &FontId, _color: Color32) -> egui::WidgetText { logical.into() } @@ -164,12 +805,34 @@ pub trait DockContent

{ logical.into() } fn panel_menu(&mut self, _ui: &mut Ui, _panel: &P) {} - fn group_header_width(&self, _panels: &[P], _active: &P, _floating: bool) -> f32 { + /// Width of the group header at the strip's end (the host's ≡ and its own tabs). + /// `strip_width` is the whole tab strip; the header gets at most that, and the drag bar and + /// the group's tabs share what is left. + fn group_header_width(&self, _panels: &[P], _active: &P, _floating: bool, _strip_width: f32) -> f32 { 0.0 } + /// Draw the group header into `ui.max_rect()`, sized by [`Self::group_header_width`]. fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {} } +/// Host-owned drop resolution request, see [`DockContent::resolve_drag`]. +pub struct DropRequest<'a, P> { + pub panel: &'a P, + pub pointer: Pos2, + pub targets: &'a [DropTarget

], + pub floating_rect: Option, + pub whole_group: bool, + pub released: bool, + pub workspace: Rect, + pub screen: Rect, +} + +#[derive(Clone, Debug, Default)] +pub struct DropFeedback { + pub preview: Option, + pub insertion: Option, +} + struct BodyContent(F); impl DockContent

for BodyContent { fn body(&mut self, ui: &mut Ui, panel: &P) { @@ -177,17 +840,173 @@ impl DockContent

for BodyContent { } } +/// The older `show*` closures in front of an application's content. +struct Overrides<'a, P, C: ?Sized> { + label: &'a dyn Fn(&P) -> String, + permissions: &'a dyn Fn(&P) -> Permissions, + limits: &'a dyn Fn(&P) -> PanelLimits, + inner: &'a mut C, +} + +impl + ?Sized> DockContent

for Overrides<'_, P, C> { + fn body(&mut self, ui: &mut Ui, panel: &P) { + self.inner.body(ui, panel); + } + fn logical_label(&self, panel: &P) -> String { + (self.label)(panel) + } + fn permissions(&self, panel: &P) -> Permissions { + (self.permissions)(panel) + } + fn limits(&self, panel: &P) -> PanelLimits { + (self.limits)(panel) + } + fn dock_heights(&self, panel: &P) -> Option { + self.inner.dock_heights(panel) + } + fn floating_size(&self, panel: &P) -> Option { + self.inner.floating_size(panel) + } + fn icon(&self, panel: &P) -> Option> { + self.inner.icon(panel) + } + fn paint_icon(&mut self, painter: &egui::Painter, rect: Rect, panel: &P, color: Color32) -> bool { + self.inner.paint_icon(painter, rect, panel, color) + } + fn allow_native(&self, panel: &P) -> bool { + self.inner.allow_native(panel) + } + fn window_kind(&self, panel: &P) -> WindowKind { + self.inner.window_kind(panel) + } + fn resolve_drag(&mut self, ctx: &Context, style: &DockStyle, request: &DropRequest<'_, P>) -> Option { + self.inner.resolve_drag(ctx, style, request) + } + fn label(&mut self, ui: &Ui, panel: &P, logical: &str, font: &FontId, color: Color32) -> egui::WidgetText { + self.inner.label(ui, panel, logical, font, color) + } + fn action_label(&mut self, ui: &Ui, logical: &str, font: &FontId, color: Color32) -> egui::WidgetText { + self.inner.action_label(ui, logical, font, color) + } + fn panel_menu(&mut self, ui: &mut Ui, panel: &P) { + self.inner.panel_menu(ui, panel); + } + fn group_header_width(&self, panels: &[P], active: &P, floating: bool, strip_width: f32) -> f32 { + self.inner.group_header_width(panels, active, floating, strip_width) + } + fn group_header(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) { + self.inner.group_header(ui, panels, active, floating); + } +} + /// Optional renderer for applications without an existing docking surface. Apps with custom /// chrome can instead use the model, target resolver and resize/tab primitives independently. -pub struct DockArea

{ +pub struct DockArea<'a, P = ()> { id: Id, activation: Option

, blockers: Vec, + locked: bool, + maximized: Option

, + hide: HidePanels<'a, P>, + auto_collapse: bool, + float_bounds: Option, + monitors: Vec, + input_blocked: bool, + native_windows: Option, + #[cfg(feature = "native-windows")] + input_hook: Option, } -impl DockArea

{ +impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { pub fn new(id: Id) -> Self { - Self { id, activation: None, blockers: Vec::new() } + Self { + id, + activation: None, + blockers: Vec::new(), + locked: false, + maximized: None, + hide: HidePanels::None, + auto_collapse: true, + float_bounds: None, + monitors: Vec::new(), + input_blocked: false, + native_windows: None, + #[cfg(feature = "native-windows")] + input_hook: None, + } + } + + /// The connected monitors (feature `native-windows`). A native floating window stores the + /// one it is on, and a restored layout puts each window back on its monitor, or on the + /// main window's when that monitor is gone. egui 0.36 reports no monitors; without a list + /// windows store the monitor size egui reports and restore onto the main window's monitor. + pub fn monitors(mut self, monitors: Vec) -> Self { + self.monitors = monitors; + self + } + + /// Native floating windows ignore input while `blocked` (feature `native-windows`): pass + /// true while the application shows a modal dialog. egui's `Modal` blocks only the + /// viewport it is shown in, so the OS windows of floating panels would otherwise still take + /// clicks and keys. A blocked window is drawn under [`DockStyle::blocked_dim`]; its bar, + /// tabs, body and resize edges take no pointer, keys, text, scrolling or files, forward only + /// key releases, and offer no drop targets, and its OS close request is cancelled. Turning + /// it on cancels any drag in progress. Floating windows drawn in the main window need + /// nothing from this: the modal covers them. + pub fn input_blocked(mut self, blocked: bool) -> Self { + self.input_blocked = blocked; + self + } + + /// Whether floating windows open in their own OS windows (feature `native-windows`), where + /// the backend has viewports. Off, they stay in the app window, and their native rects are + /// kept for a later run with it on. By default it is on, except on Wayland: the compositor + /// reports no window positions, so a native window could not dock again by drag or open + /// where it was. + pub fn native_windows(mut self, on: bool) -> Self { + self.native_windows = Some(on); + self + } + + /// The egui viewport of the native window of the floating window with [`super::Floating::id`] + /// `window`, in the dock area `id` (feature `native-windows`). + #[cfg(feature = "native-windows")] + pub fn window_viewport(id: Id, window: u64) -> egui::ViewportId { + super::render::native_viewport(id, window) + } + + /// Rewrite the input of each native window of this area before egui reads it (feature + /// `native-windows`), as `eframe::App::raw_input_hook` does for the main window only. Pass + /// it every frame; a frame without it runs none. The hook runs for every pass of each + /// window's frame, before any widget sees the input, so rewritten pointer events count as + /// sent. `RawInput::viewport_id` names the window (see [`Self::window_viewport`]). State + /// shared with the app's own `raw_input_hook`, which takes `&mut self`, goes in an + /// `Arc>`. + #[cfg(feature = "native-windows")] + pub fn native_input_hook(mut self, hook: impl FnMut(&Context, &mut egui::RawInput) + Send + 'static) -> Self { + self.input_hook = Some(Box::new(hook)); + self + } + + /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press + /// outside it. Off, it stays open until its icon or another icon is clicked. + pub fn auto_collapse_iconic(mut self, on: bool) -> Self { + self.auto_collapse = on; + self + } + + /// Maximize Frame: the docked frame holding `panel` fills the area; other frames and + /// floating windows do not render or take input. A floating or absent panel is ignored. + pub fn maximized(mut self, panel: Option

) -> Self { + self.maximized = panel; + self + } + + /// Hide panels (Tab, Shift+Tab); their space goes to the panels that stay. Protected + /// panels always stay. The layout is unchanged. + pub fn hide_panels(mut self, hide: HidePanels<'a, P>) -> Self { + self.hide = hide; + self } /// Whether keyboard focus belongs to a rendered docking control in this layout. @@ -199,53 +1018,35 @@ impl DockArea

{ if layout.validate().is_err() || ctx.read_response(focused).is_none() { return false; } - let matches = |id: Id| id == focused; - let group = |panels: &[P]| { - panels.iter().any(|panel| matches(self.id.with(("tab", panel))) || matches(self.id.with(("overflow", panel)))) - || matches(self.id.with(("move-group", panels.first()))) - }; - for floating in &layout.floating { - if group(&floating.panels) || floating.panels.first().is_some_and(|first| matches(self.id.with(("floating", first)).with("resize"))) { - return true; - } - } - let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::::new())]).unwrap_or_default(); - while let Some((node, path)) = nodes.pop() { - match node { - Node::Tabs { panels, .. } if group(panels) => return true, - Node::Stack { entries } - if entries.iter().any(|entry| { - matches(self.id.with(("tab", &entry.panel))) - || matches(self.id.with(("collapse", &entry.panel))) - || matches(self.id.with(("stack-resize", &entry.panel))) - }) => - { - return true; - } - Node::Split { first, second, .. } => { - if matches(self.id.with(("split", &path))) { - return true; - } - 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)); - } - _ => {} - } - } - false + super::hits::named(self.id, layout).contains_key(&focused) } - /// Raise and focus a panel's owning floating area on this frame. Apply Activate through - /// the application's command path as well to select its tab. + /// Raise a panel's floating window (or open its flyout) on this frame. Apply Activate + /// through the application's command path as well to select its tab. A raise is not user + /// input: it moves no keyboard focus and is not reported as [`DockOutput::focused`]. pub fn raise_panel(mut self, panel: P) -> Self { self.activation = Some(panel); self } + /// Where floating windows, flyouts and drag ghosts may go (default: the whole viewport). + /// An app whose own title bar sits under a transparent OS title bar (macOS full-size + /// content) passes the viewport below that strip: a window drawn there would take presses + /// that the OS or the app turns into moving the whole app window. Windows outside the + /// bounds, such as ones restored from a saved layout, are drawn moved inside. + pub fn float_bounds(mut self, bounds: Rect) -> Self { + self.float_bounds = Some(bounds); + self + } + + /// Draw a locked workspace: pass [`super::Workspace::locked`]. Every panel gets + /// [`Permissions::locked`], so no tab, bar or native window starts a move, float or close + /// that the workspace would refuse. + pub fn locked(mut self, locked: bool) -> Self { + self.locked = locked; + self + } + /// Host overlays that must reject docking gestures, including floating fallback. pub fn blocked_rects(mut self, rects: &[Rect]) -> Self { self.blockers = rects.to_vec(); @@ -254,7 +1055,11 @@ impl DockArea

{ /// Render a validated snapshot and return requested actions for the app's command handler. /// No action is applied here. Panel contents get a global stable egui ID across moves. - /// Floating groups are egui areas inside this viewport; this does not create native windows. + /// A drag in progress (tab, group, column, window bar, divider) ends when `layout` is not + /// the one drawn last frame, with or without the returned actions applied, apart from sizes + /// and rectangles: a workspace switch, Reset or a command changed it. + /// Floating windows are egui areas inside this viewport; with feature `native-windows`, each + /// gets its own OS window where the backend has viewports (see docs/ui-docking.md). pub fn show( self, ui: &mut Ui, @@ -295,624 +1100,98 @@ impl DockArea

{ limits: impl Fn(&P) -> PanelLimits, content: &mut impl DockContent

, ) -> DockOutput

{ + let mut content = Overrides { label: &label, permissions: &permissions, limits: &limits, inner: content }; + self.show_content(ui, layout, style, &mut content) + } + + /// Render with every label, policy and size from `content`. + pub fn show_content(self, ui: &mut Ui, layout: &Layout

, style: &DockStyle, content: &mut dyn DockContent

) -> DockOutput

{ + #[cfg(feature = "native-windows")] + super::render::set_input_hook(ui.ctx(), self.id, self.input_hook); let rect = ui.available_rect_before_wrap(); if let Err(error) = layout.validate() { cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(error), ..Default::default() }; } - if !style.valid() || !super::targets::usable(rect) || layout.panels().into_iter().any(|p| !limits(p).valid()) { + if !style.valid() || !super::targets::usable(rect) || layout.panels().into_iter().any(|p| !content.limits(p).valid()) { cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() }; } + // Blocking starts while the pointer may be down: a drag that went on would act on its + // release, under the modal. + let blocked_key = self.id.with("input-blocked"); + if self.input_blocked && !ui.ctx().data(|d| d.get_temp::(blocked_key).unwrap_or(false)) { + cancel_drag::

(ui.ctx(), self.id); + } + ui.ctx().data_mut(|d| d.insert_temp(blocked_key, self.input_blocked)); + cancel_drag_if_replaced(ui.ctx(), self.id, layout); + release_moved_tab_focus(ui.ctx(), self.id, layout); + let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content); + let places = layout.places(); + // A panel keeps the content height it last drew while hidden or minimized. + let contents: Vec<(P, Option)> = float_space::

(ui.ctx(), self.id) + .map(|space| space.contents.into_iter().filter(|(panel, _)| places.contains_key(panel)).collect()) + .unwrap_or_default(); + let content_index = contents.iter().enumerate().map(|(i, (panel, _))| (panel.clone(), i)).collect(); let mut renderer = Renderer { id: self.id, style, - label: &label, - permissions: &permissions, - limits: &limits, content, activation: self.activation, blockers: self.blockers, + locked: self.locked, layout, + workspace: rect, + float_bounds: self.float_bounds.map_or(ui.ctx().content_rect(), |bounds| bounds.intersect(ui.ctx().content_rect())), targets: Vec::new(), + dock_targets: Vec::new(), owned_interactions: Vec::new(), output: DockOutput::default(), + frame, + iconic: super::render::Iconic::new(self.auto_collapse), + float_space: FloatSpace { contents, ..FloatSpace::new(rect) }, + places, + contents: content_index, + dividers: Vec::new(), + tree: None, + monitors: self.monitors, + input_blocked: self.input_blocked, + native_windows: self.native_windows, + #[cfg(feature = "native-windows")] + cross: Default::default(), }; - if let Some(root) = &layout.root { - renderer.node(ui, root, rect, &mut Vec::new()); + if let Some(root) = &view.root { + renderer.node(ui, root, rect, &mut Vec::new(), (root, rect)); ui.allocate_rect(rect, Sense::hover()); } - renderer.floating(ui.ctx()); - renderer.finish_group_drag(ui.ctx()); + renderer.flyout(ui.ctx()); + renderer.floating(ui.ctx(), &view.floating); + #[cfg(feature = "native-windows")] + renderer.finish_window_drag(ui); + renderer.finish_group_drag(ui); renderer.finish_drag(ui.ctx()); + #[cfg(feature = "native-windows")] + renderer.end_cross(ui.ctx()); + renderer.report_flyout(ui.ctx()); + super::hits::record(ui.ctx(), self.id, layout, &renderer.owned_interactions); + record_tab_focus(ui.ctx(), self.id, layout, !renderer.output.actions.is_empty()); + #[cfg(feature = "native-windows")] + let native_held = renderer.cross.held; + #[cfg(not(feature = "native-windows"))] + let native_held = false; + record_drag_frame(ui.ctx(), self.id, layout, &renderer.owned_interactions, native_held, &renderer.output.actions); + #[cfg(feature = "native-windows")] + ui.ctx().data_mut(|d| d.remove::>(native_cancel_key(self.id))); + ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space)); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); + // egui runs a discarded pass again with the same hovered files (dropped ones are + // taken), so only the next pass reports them. + if ui.ctx().will_discard() { + renderer.output.window_files.retain_mut(|files| { + files.hovered.clear(); + !files.dropped.is_empty() + }); + } renderer.output } } - -struct Renderer<'a, P> { - id: Id, - style: &'a DockStyle, - label: &'a dyn Fn(&P) -> String, - permissions: &'a dyn Fn(&P) -> Permissions, - limits: &'a dyn Fn(&P) -> PanelLimits, - content: &'a mut dyn DockContent

, - activation: Option

, - blockers: Vec, - layout: &'a Layout

, - targets: Vec>, - owned_interactions: Vec, - output: DockOutput

, -} - -impl Renderer<'_, P> { - fn register_interaction(&mut self, ctx: &Context, id: Id) { - // A validated tree has at most 2*MAX_PANELS nodes. Tabs, stack dividers, - // floating grips/corners and splits fit comfortably inside this fixed bound. - const CAP: usize = 8 * super::MAX_PANELS; - if self.owned_interactions.len() < CAP && !self.owned_interactions.contains(&id) { - self.owned_interactions.push(id); - } - // Retain prior-frame controls until return, and publish current controls before - // app callbacks. No Context lock spans a callback or a stop_dragging call. - ctx.data_mut(|d| { - let ids = d.get_temp_mut_or_default::>(self.id.with("owned-interactions")); - if ids.len() < CAP && !ids.contains(&id) { - ids.push(id); - } - }); - } - - fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec) { - if !super::targets::usable(rect) { - return; - } - match node { - Node::Split { axis, size, first, second } => { - let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; - let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second)); - let first_size = wanted.first_size().clamp(lo, hi); - let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return }; - path.push(false); - self.node(ui, first, split.first, path); - path.pop(); - path.push(true); - self.node(ui, second, split.second, path); - path.pop(); - // The divider's inner-edge hit margin must win over child scrollbars, just - // as accordion dividers win over the adjacent panel's content. - let response = ResizeHandle::new( - self.id.with(("split", &*path)), - *axis, - split.divider, - &self.style.resize_label, - ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, - ) - .value(split.first_size(), lo..=hi) - .show(ui); - self.register_interaction(ui.ctx(), response.response.id); - if response.delta != 0.0 - && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) - { - self.output.actions.push(Action::ResizeSplit { path: path.clone(), size }); - } - } - Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false), - Node::Stack { entries } => { - let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); - let remaining = (rect.height() - total_header).max(0.0); - let total_wanted: f64 = entries.iter().filter(|e| e.open).map(|e| f64::from(e.height.unwrap_or(self.style.min_pane).max(1.0))).sum(); - let header = self.style.tab_height.min(rect.height() / entries.len().max(1) as f32); - let mut top = rect.top(); - let mut dividers = Vec::new(); - for (index, entry) in entries.iter().enumerate() { - let share = if entry.open && total_wanted > 0.0 { - (f64::from(remaining) * f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) / total_wanted) as f32 - } else { - 0.0 - }; - let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom()))); - let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom()))); - self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect); - let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); - let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); - response - .widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), entry.open, (self.label)(&entry.panel))); - ui.painter().with_clip_rect(collapse).text( - collapse.center(), - egui::Align2::CENTER_CENTER, - if entry.open { "−" } else { "+" }, - self.style.font.clone(), - self.style.text, - ); - if response.clicked() { - self.output.actions.push(Action::SetStackOpen { panel: entry.panel.clone(), open: !entry.open }); - } - let body_rect = Rect::from_min_max(pos2(rect.left(), title_rect.bottom()), panel_rect.max); - if entry.open { - self.contents(ui, &entry.panel, body_rect); - } - self.targets.push(DropTarget { - anchor: entry.panel.clone(), - rect: panel_rect, - tab_strip: Some(title_rect), - tabs: vec![(entry.panel.clone(), title_rect)], - allow_splits: true, - accepts_tabs: (self.permissions)(&entry.panel).accepts_tabs, - }); - top = panel_rect.bottom(); - if entry.open && remaining > 0.0 { - dividers.push((index, Rect::from_min_max(pos2(rect.left(), top - 1.0), pos2(rect.right(), top + 1.0)).intersect(rect))); - } - } - // Register dividers after the headers so their narrow hit regions win at the - // shared boundary. Closed entries retain their saved sizes and are skipped. - for (index, divider) in dividers { - let Some(first) = entries.get(index) else { continue }; - let Some(second) = entries.iter().skip(index.saturating_add(1)).find(|entry| entry.open) else { continue }; - let a = f64::from(first.height.unwrap_or(self.style.min_pane).max(1.0)); - let b = f64::from(second.height.unwrap_or(self.style.min_pane).max(1.0)); - let scale = f64::from(remaining) / total_wanted; - let first_size = (a * scale) as f32; - let pair_size = ((a + b) * scale) as f32; - let body_limits = |panel: &P| { - let mut limits = (self.limits)(panel); - limits.min.y = limits.min.y.max(self.style.min_pane); - limits.max = limits.max.max(limits.min); - limits - }; - let (lo, hi) = split_bounds(pair_size, SplitAxis::Vertical, body_limits(&first.panel), body_limits(&second.panel)); - let response = ResizeHandle::new( - self.id.with(("stack-resize", &first.panel)), - SplitAxis::Vertical, - divider, - &self.style.resize_label, - ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, - ) - .value(first_size, lo..=hi) - .show(ui); - self.register_interaction(ui.ctx(), response.response.id); - if response.delta != 0.0 && scale > 0.0 { - let delta = f64::from((first_size + response.delta).clamp(lo, hi) - first_size) / scale; - let next_a = (a + delta).clamp((a + b - 1_000_000.0).max(1.0), (a + b - 1.0).min(1_000_000.0)); - self.output.actions.push(Action::ResizeStack { panel: first.panel.clone(), height: Some(next_a as f32) }); - self.output.actions.push(Action::ResizeStack { panel: second.panel.clone(), height: Some((a + b - next_a) as f32) }); - } - } - } - } - } - - fn node_limits(&self, node: &Node

) -> PanelLimits { - match node { - Node::Tabs { panels, .. } => { - let mut limits = PanelLimits::default(); - for panel in panels { - let policy = (self.limits)(panel); - limits.min = limits.min.max(policy.min).max(Vec2::splat(self.style.min_pane)); - limits.max = limits.max.min(policy.max); - } - limits.max = limits.max.max(limits.min); - if panels.len() != 1 || panels.first().is_some_and(|p| (self.permissions)(p) != Permissions::PROTECTED) { - limits.min.y = (limits.min.y + self.style.tab_height).min(1_000_000.0); - limits.max.y = (limits.max.y + self.style.tab_height).min(1_000_000.0); - } - limits - } - Node::Stack { entries } => { - let mut limits = PanelLimits::default(); - for entry in entries { - let policy = (self.limits)(&entry.panel); - limits.min.x = limits.min.x.max(policy.min.x).max(self.style.min_pane); - limits.min.y += if entry.open { policy.min.y.max(self.style.min_pane) } else { 0.0 } + self.style.tab_height; - limits.max.x = limits.max.x.min(policy.max.x); - } - limits.min = limits.min.min(Vec2::splat(1_000_000.0)); - limits.max = limits.max.max(limits.min); - limits - } - Node::Split { axis, first, second, .. } => { - let a = self.node_limits(first); - let b = self.node_limits(second); - let limits = match axis { - SplitAxis::Horizontal => PanelLimits { - min: vec2(a.min.x + b.min.x + self.style.gap, a.min.y.max(b.min.y)), - max: vec2(a.max.x + b.max.x + self.style.gap, a.max.y.min(b.max.y)), - }, - SplitAxis::Vertical => PanelLimits { - min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + self.style.gap), - max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + self.style.gap), - }, - }; - PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) } - } - } - } - - fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) { - if !super::targets::usable(rect) { - return; - } - ui.painter().rect_filled(rect, 0.0, self.style.background); - let mut child = - ui.new_child(UiBuilder::new().scope_id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); - child.set_clip_rect(rect.intersect(ui.clip_rect())); - self.content.body(&mut child, panel); - if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) { - self.output.focused = Some(panel.clone()); - } - } - - fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) { - let Some(selected) = panels.get(active) else { return }; - let policy = (self.permissions)(selected); - let bare = panels.len() == 1 && policy == Permissions::PROTECTED; - if bare { - self.contents(ui, selected, rect); - self.targets.push(DropTarget { - anchor: selected.clone(), - rect, - tab_strip: None, - tabs: Vec::new(), - allow_splits: !floating, - accepts_tabs: false, - }); - return; - } - let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); - ui.painter().rect_filled(strip, 0.0, self.style.tab_background); - if floating { - let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom())); - let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_label)); - if panels.iter().all(|p| (self.permissions)(p).movable) - && response.drag_started() - && let Some(first) = panels.first() - { - ui.ctx().data_mut(|d| { - d.insert_temp( - self.id.with("group-drag"), - GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] }, - ) - }); - } - if panels.iter().all(|p| (self.permissions)(p).movable) - && response.dragged() - && ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))).is_some() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) - { - let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect()); - if let Some(first) = panels.first() { - self.output - .actions - .push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); - } - } - ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); - strip.min.x = grip.right(); - } - let requested = self.content.group_header_width(panels, selected, floating); - let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; - if header_width > 0.0 { - let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max); - let mut child = ui.new_child(UiBuilder::new().scope_id(self.id.with(("header", selected))).max_rect(header)); - child.set_clip_rect(header.intersect(ui.clip_rect())); - self.content.group_header(&mut child, panels, selected, floating); - } - let widths: Vec = panels - .iter() - .map(|p| { - self.content - .label(ui, p, &(self.label)(p), &self.style.font, self.style.text) - .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone()) - .size() - .x - + 24.0 - }) - .collect(); - let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0); - let mut x = strip.left(); - let mut targets = Vec::new(); - for (index, width) in &fit.shown { - let Some(panel) = panels.get(*index) else { continue }; - let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height())); - self.tab(ui, panel, tab, *index == active, rect); - targets.push((panel.clone(), tab)); - x += width; - } - if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { - let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())); - let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.panels_label)); - ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text); - egui::Popup::menu(&response).show(|ui| { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for index in &fit.overflow { - if let Some(panel) = panels.get(*index) - && self.menu_label(ui, panel).clicked() - { - self.output.actions.push(Action::Activate { panel: panel.clone() }); - self.output.focused = Some(panel.clone()); - ui.close(); - } - } - }); - }); - } - // Include hidden tab identities for source-group detection without creating hit areas. - for panel in panels { - if !targets.iter().any(|(p, _)| p == panel) { - targets.push((panel.clone(), Rect::NOTHING)); - } - } - self.targets.push(DropTarget { - anchor: selected.clone(), - rect, - tab_strip: Some(strip), - tabs: targets, - allow_splits: !floating, - accepts_tabs: policy.accepts_tabs, - }); - self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); - ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); - } - - fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response { - let logical = (self.label)(panel); - let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text); - let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &logical)); - response - } - - fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) { - let policy = (self.permissions)(panel); - let label = (self.label)(panel); - let color = if active { self.style.text } else { self.style.inactive_text }; - let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley( - ui, - Some(egui::TextWrapMode::Extend), - f32::INFINITY, - self.style.font.clone(), - ); - let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active) - .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) - .focus_stroke(Stroke::new(1.0, self.style.accent)) - .show_at(ui, rect, |ui, response| { - let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); - painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); - painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color); - }); - self.register_interaction(ui.ctx(), response.id); - if response.clicked() { - self.output.actions.push(Action::Activate { panel: panel.clone() }); - self.output.focused = Some(panel.clone()); - } - if policy.movable && response.drag_started() { - begin_drag(ui.ctx(), self.id, panel.clone(), group); - } - response.context_menu(|ui| { - self.content.panel_menu(ui, panel); - if policy.movable && policy.floatable { - let text = self.content.action_label(ui, &self.style.float_label, &self.style.font, self.style.text); - let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.float_label)); - if response.clicked() { - let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); - self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); - ui.close(); - } - } - if policy.movable { - let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text); - let response = ui.menu_button(text, |ui| { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for target in self.layout.panels() { - if target != panel && (self.permissions)(target).accepts_tabs && self.menu_label(ui, target).clicked() { - self.output.actions.push(Action::Move { - panel: panel.clone(), - anchor: target.clone(), - placement: Placement::Tab { before: None }, - }); - ui.close(); - } - } - }); - }); - response.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_label)); - } - if policy.closable { - let text = self.content.action_label(ui, &self.style.close_label, &self.style.font, self.style.text); - let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.close_label)); - if response.clicked() { - self.output.actions.push(Action::Close { panel: panel.clone() }); - ui.close(); - } - } - }); - } - - fn floating(&mut self, ctx: &Context) { - let screen = ctx.content_rect(); - if !super::targets::usable(screen) { - return; - } - let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); - let mut groups: Vec<_> = self.layout.floating.iter().collect(); - groups.sort_by_key(|group| { - let id = group.panels.first().map(|panel| self.id.with(("floating", panel))); - layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX) - }); - for group in groups { - let Some(first) = group.panels.first() else { continue }; - let [x, y, width, height] = group.rect; - let limits = self.node_limits(&Node::Tabs { panels: group.panels.clone(), active: group.active }); - let size = vec2(width, height).max(limits.min).min(limits.max); - let rect = clamp_float(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| group.panels.contains(panel)) { - ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); - ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); - self.output.focused = self.activation.clone(); - } - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { - ui.set_min_size(rect.size()); - let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); - ui.allocate_rect(rect, Sense::hover()); - self.group(ui, &group.panels, group.active, rect, true); - let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max); - let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.resize_window_label)); - if response.drag_started() { - response.request_focus(); - } - let mut delta = if response.dragged() { ui.input(|i| i.pointer.delta()) } else { 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| { - if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) { - delta.x -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) { - delta.x += 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) { - delta.y -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) { - delta.y += 8.0; - } - }); - } - // Resizing changes the group's shared rectangle, which the model allows only when - // every member is movable. - if response.enabled() && delta != Vec2::ZERO && group.panels.iter().all(|p| (self.permissions)(p).movable) { - let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size()); - self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] }); - } - if response.hovered() || response.dragged() || response.has_focus() { - ui.painter().line_segment([corner.left_bottom(), corner.right_top()], Stroke::new(2.0, self.style.accent)); - } - }); - } - } - - fn finish_group_drag(&mut self, ctx: &Context) { - let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))) else { return }; - if !self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel) && g.panels.iter().all(|p| (self.permissions)(p).movable)) { - ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); - return; - } - let (escape, focused, down, released, pointer) = - ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); - if escape || !focused || !down { - ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); - if escape || !focused { - self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); - } else if released - && let Some(pointer) = pointer.filter(|p| p.is_finite()) - && !self.blockers.iter().any(|r| r.contains(pointer)) - { - self.output.group_drop = Some((drag.panel, pointer)); - } - } else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) { - self.output.group_drag = Some(drag.panel); - } - } - - fn finish_drag(&mut self, ctx: &Context) { - let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("drag"))) else { return }; - let (pointer, down, released, focused) = - ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused)); - let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); - if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) { - cancel_drag::

(ctx, self.id); - return; - } - let Some(pointer) = pointer.filter(|p| p.is_finite()) else { - if released { - cancel_drag::

(ctx, self.id); - } - return; - }; - if self.blockers.iter().any(|rect| rect.contains(pointer)) { - if released { - cancel_drag::

(ctx, self.id); - } - return; - } - let proposal = resolve_drop(&drag.panel, pointer, &self.targets); - if released { - cancel_drag::

(ctx, self.id); - if pointer.distance(drag.origin) < 2.0 { - return; - } - let action = if let Some(proposal) = proposal { - Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) - } else if (self.permissions)(&drag.panel).floatable - && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel))) - { - let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); - Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) - } else { - None - }; - if let Some(action) = action { - // Validate the proposed gesture against the same complete snapshot. The app - // still applies it via its own command path after body rendering finishes. - let mut candidate = self.layout.clone(); - if candidate.apply_with_permissions(action.clone(), self.permissions).is_ok() { - self.output.actions.push(action); - self.output.focused = Some(drag.panel); - } - } - return; - } - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, self.id.with("drop-preview"))); - if let Some(proposal) = proposal { - painter.rect( - proposal.preview.shrink(1.0), - 0.0, - self.style.accent.gamma_multiply(0.15), - Stroke::new(2.0, self.style.accent), - StrokeKind::Inside, - ); - if let Some(marker) = proposal.insertion { - painter.rect_filled(marker, 0.0, self.style.accent); - } - } else if (self.permissions)(&drag.panel).floatable { - let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); - painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); - } - } -} - -fn clamp_float(rect: Rect, screen: Rect) -> Rect { - if !super::targets::usable(screen) { - return rect; - } - let size = rect.size().max(vec2(1.0, 1.0)).min(screen.size()); - let min = rect.min.max(screen.min).min(screen.max - size); - Rect::from_min_size(min, size) -} - -fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) { - let (a_min, a_max, b_min, b_max) = match axis { - SplitAxis::Horizontal => (first.min.x, first.max.x, second.min.x, second.max.x), - SplitAxis::Vertical => (first.min.y, first.max.y, second.min.y, second.max.y), - }; - let scale = if a_min + b_min > available { available / (a_min + b_min).max(1.0) } else { 1.0 }; - let lo = (a_min * scale).min(available); - let hi = (available - b_min * scale).max(lo).min(available); - let constrained_lo = lo.max(available - b_max); - let constrained_hi = hi.min(a_max); - if constrained_lo <= constrained_hi { (constrained_lo, constrained_hi) } else { (lo, hi) } -} diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 515b9c65..82eec325 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -1,6 +1,6 @@ -use egui::{Pos2, Rect, pos2}; +use egui::{Pos2, Rangef, Rect, Vec2, pos2}; -use super::{Placement, Zone}; +use super::{Placement, Side, Target, Zone}; /// One visible group, supplied in back-to-front order to [`resolve_drop`]. An empty tab strip /// or a group that rejects tabs may still accept side splits, e.g. a protected document canvas. @@ -12,6 +12,9 @@ pub struct DropTarget

{ pub tabs: Vec<(P, Rect)>, pub allow_splits: bool, pub accepts_tabs: bool, + /// The strip runs right to left: the first tab is rightmost, and an insertion slot sits + /// at the right edge of the tab it precedes. + pub right_to_left: bool, } #[derive(Clone, Debug, PartialEq)] @@ -20,26 +23,87 @@ pub struct DropProposal

{ pub placement: Placement

, pub preview: Rect, pub insertion: Option, + /// The pointer addresses the tab's current insertion slot. + pub noop: bool, } pub(super) fn usable(rect: Rect) -> bool { rect.is_finite() && rect.width() > 0.0 && rect.height() > 0.0 && rect.width() <= 1_000_000.0 && rect.height() <= 1_000_000.0 } -/// Tab header is the center; otherwise the nearest quarter-width/height edge wins. -/// Outside, non-finite, empty and inverted rectangles have no target. -pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { +/// Tab header (the top `tab_height` of `rect`) is the center; otherwise the nearest side zone of +/// the body below it wins. Every side zone reaches the same depth into the body: `depth`, or a +/// quarter of the body's smaller side when that is less, so a body always keeps a center and a +/// narrow body's top and bottom zones are no deeper than its left and right ones. A `depth` of +/// zero or less leaves only the center. Outside, non-finite, empty and inverted rectangles have +/// no target. +pub fn drop_zone(rect: Rect, tab_height: f32, depth: f32, pointer: Pos2) -> Option { if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { return None; } - if pointer.y < rect.top() + tab_height.min(rect.height()) { + let body = body(rect, tab_height); + if pointer.y < body.top() || !usable(body) { return Some(Zone::Center); } - let x = (pointer.x - rect.left()) / rect.width(); - let y = (pointer.y - rect.top()) / rect.height(); - let distances = [(x, Zone::Left), (1.0 - x, Zone::Right), (y, Zone::Top), (1.0 - y, Zone::Bottom)]; - let (distance, zone) = distances.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; - Some(if distance > 0.25 { Zone::Center } else { zone }) + let Vec2 { x, y } = side_depths(body, depth); + let sides = [ + (pointer.x - body.left(), x, Zone::Left), + (body.right() - pointer.x, x, Zone::Right), + (pointer.y - body.top(), y, Zone::Top), + (body.bottom() - pointer.y, y, Zone::Bottom), + ]; + let nearest = + sides.into_iter().filter(|(_, depth, _)| *depth > 0.0).map(|(gap, depth, zone)| (gap / depth, zone)).min_by(|a, b| a.0.total_cmp(&b.0)); + Some(match nearest { + Some((distance, zone)) if distance <= 1.0 => zone, + _ => Zone::Center, + }) +} + +/// The part of a group drawn at `rect` below its tab header. +fn body(rect: Rect, tab_height: f32) -> Rect { + Rect::from_min_max(pos2(rect.left(), rect.top() + tab_height.min(rect.height())), rect.max) +} + +/// How deep the side zones of `body` reach in from its left and right edges (`x`) and its top +/// and bottom edges (`y`): the same on all four sides. +fn side_depths(body: Rect, depth: f32) -> Vec2 { + let depth = if depth > 0.0 { depth } else { 0.0 }; + Vec2::splat(depth.min(body.width().min(body.height()) / 4.0)) +} + +/// The outline of a drop zone of the group drawn at `rect`, as [`drop_zone`] finds it with the +/// same `tab_height` and `depth`: a side's trapezoid (its outer edge the whole side of the body, +/// its inner edge on the center rectangle), or the center rectangle. The tab header, a grouping +/// zone too, is not part of any shape. None for a side without depth. +pub fn zone_shape(rect: Rect, tab_height: f32, depth: f32, zone: Zone) -> Option> { + if !usable(rect) || !tab_height.is_finite() || tab_height < 0.0 { + return None; + } + let body = body(rect, tab_height); + let depths = side_depths(body, depth); + let flat = match zone { + Zone::Left | Zone::Right => depths.x <= 0.0, + Zone::Top | Zone::Bottom => depths.y <= 0.0, + Zone::Center => false, + }; + if !usable(body) || flat { + return None; + } + let inner = body.shrink2(depths); + Some(match zone { + Zone::Left => vec![body.left_top(), inner.left_top(), inner.left_bottom(), body.left_bottom()], + Zone::Right => vec![inner.right_top(), body.right_top(), body.right_bottom(), inner.right_bottom()], + Zone::Top => vec![body.left_top(), body.right_top(), inner.right_top(), inner.left_top()], + Zone::Bottom => vec![inner.left_bottom(), inner.right_bottom(), body.right_bottom(), body.left_bottom()], + Zone::Center => vec![inner.left_top(), inner.right_top(), inner.right_bottom(), inner.left_bottom()], + }) +} + +/// Height of the tab header of `target` that [`resolve_drop`] treats as a grouping zone: from the +/// top of its rect to the bottom of its tab strip. +pub(super) fn header_height

(target: &DropTarget

) -> f32 { + target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|strip| usable(*strip)).map_or(0.0, |strip| strip.bottom() - target.rect.top()) } /// Preview of the region occupied after a split or group merge. @@ -57,10 +121,41 @@ pub fn zone_rect(rect: Rect, zone: Zone) -> Option { }) } +/// Bounded insertion marker for a logical slot in a measured tab strip. Hidden tabs +/// retain their order; their slot uses the next visible boundary or the visible strip end. +pub fn tab_insertion_marker(target: &DropTarget

, before: Option<&P>) -> Option { + if target.tabs.len() > super::MAX_PANELS || !target.accepts_tabs || !usable(target.rect) { + return None; + } + let strip = target.tab_strip?.intersect(target.rect); + if !usable(strip) { + return None; + } + let index = before.and_then(|before| target.tabs.iter().position(|(panel, _)| panel == before)); + let rtl = target.right_to_left; + let leading = |rect: &Rect| if rtl { rect.right() } else { rect.left() }; + let visible = || target.tabs.iter().filter(|(_, rect)| usable(*rect)).map(|(_, rect)| rect); + let x = index + .and_then(|index| target.tabs.get(index..)) + .and_then(|tabs| tabs.iter().find(|(_, rect)| usable(*rect)).map(|(_, rect)| leading(rect))) + .unwrap_or_else(|| { + if rtl { + visible().map(|rect| rect.left()).fold(strip.right(), f32::min).max(strip.left()) + } else { + visible().map(|rect| rect.right()).fold(strip.left(), f32::max).min(strip.right()) + } + }); + let width = strip.width().min(2.0); + let left = (x - width * 0.5).max(strip.left()).min((strip.right() - width).max(strip.left())); + Some(Rect::from_min_max(pos2(left, strip.top()), pos2((left + width).min(strip.right()), strip.bottom()))) +} + /// Resolve a pointer against visible groups. The frontmost containing group occludes groups /// behind it even if it rejects the drop. A strip reorders by midpoint; the body of the source -/// group is a no-op at center. Floating groups normally set `allow_splits` to false. -pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarget

]) -> Option> { +/// group is a no-op at center. The body's side zones are [`drop_zone`]'s with `zone_depth` (see +/// [`crate::docking::DockStyle::zone_depth`]): one depth on all four sides, at most a quarter of +/// the body's smaller side. Floating groups normally set `allow_splits` to false. +pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarget

], zone_depth: f32) -> Option> { if !pointer.is_finite() || targets.len() > super::MAX_PANELS { return None; } @@ -68,26 +163,49 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg if target.tabs.len() > super::MAX_PANELS { return None; } - let anchor = if &target.anchor == panel { target.tabs.iter().find(|(p, _)| p != panel).map(|(p, _)| p)? } else { &target.anchor }; - let strip = target.tab_strip.filter(|r| usable(*r) && r.contains(pointer)); - if let Some(strip) = strip { + let anchor = if &target.anchor == panel { target.tabs.iter().find(|(p, _)| p != panel).map(|(p, _)| p).unwrap_or(panel) } else { &target.anchor }; + let strip = target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|r| usable(*r) && r.contains(pointer)); + if strip.is_some() { if !target.accepts_tabs { return None; } - let before = target.tabs.iter().filter(|(_, r)| usable(*r)).find(|(_, r)| pointer.x < r.center().x); + let ahead = |r: &Rect| if target.right_to_left { pointer.x > r.center().x } else { pointer.x < r.center().x }; + let before = target.tabs.iter().filter(|(_, r)| usable(*r)).find(|(_, r)| ahead(r)); let before_panel = before.map(|(p, _)| p.clone()); - let x = before.map(|(_, r)| r.left()).unwrap_or_else(|| { - target.tabs.iter().filter(|(_, r)| usable(*r)).map(|(_, r)| r.right()).fold(strip.left(), f32::max).min(strip.right()) - }); - let insertion = Rect::from_min_max(pos2((x - 1.0).max(strip.left()), strip.top()), pos2((x + 1.0).min(strip.right()), strip.bottom())); + let insertion = tab_insertion_marker(target, before_panel.as_ref())?; + let old_order: Vec<_> = target.tabs.iter().map(|(p, _)| p).collect(); + let mut order = old_order.clone(); + let noop = if before_panel.as_ref() == Some(panel) { + old_order.contains(&panel) + } else if old_order.contains(&panel) { + order.retain(|p| *p != panel); + let at = before_panel.as_ref().and_then(|before| order.iter().position(|p| *p == before)).unwrap_or(order.len()); + order.insert(at, panel); + order == old_order + } else { + false + }; return Some(DropProposal { anchor: anchor.clone(), placement: Placement::Tab { before: before_panel }, preview: target.rect, insertion: Some(insertion), + noop, }); } - let zone = if target.allow_splits { drop_zone(target.rect, 0.0, pointer)? } else { Zone::Center }; + let zone = if target.allow_splits { drop_zone(target.rect, header_height(target), zone_depth, pointer)? } else { Zone::Center }; + if zone == Zone::Center && target.accepts_tabs && target.tabs.iter().any(|(p, _)| p == panel) { + return Some(DropProposal { + anchor: anchor.clone(), + placement: Placement::Tab { before: Some(panel.clone()) }, + preview: target.rect, + insertion: None, + noop: true, + }); + } + if anchor == panel { + return None; + } if zone == Zone::Center { if !target.accepts_tabs || target.tabs.iter().any(|(p, _)| p == panel) { return None; @@ -95,5 +213,83 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg } else if !target.allow_splits { return None; } - Some(DropProposal { anchor: anchor.clone(), placement: Placement::Split(zone), preview: zone_rect(target.rect, zone)?, insertion: None }) + Some(DropProposal { + anchor: anchor.clone(), + placement: Placement::Split(zone), + preview: zone_rect(target.rect, zone)?, + insertion: None, + noop: false, + }) +} + +/// [D] A dock drop target, built once per frame next to the [`DropTarget`]s. The pointer selects +/// it inside `band`; the preview is the line `line`. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct DockDrop

{ + pub(super) target: DockTarget

, + pub(super) band: Rect, + pub(super) line: Rect, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum DockTarget

{ + /// Column edge: a new column beside the column of `anchor`, further from the window edge + /// when `after` (P5). + ColumnEdge { anchor: P, after: bool }, + /// The seam between two stacked groups: a new group below the group of `anchor` (P4). + Seam { anchor: P }, + /// The top or bottom quarter of a group body, or the free room under a column's last group (P3). + GroupBand { anchor: P, below: bool }, + /// A window edge without a dock, or an empty dock (P5, P6). + NewDock { side: Side }, +} + +impl DockTarget

{ + pub(super) fn to(&self) -> Target

{ + match self { + Self::ColumnEdge { anchor, after } => Target::At { anchor: anchor.clone(), placement: Placement::Column { after: *after } }, + Self::Seam { anchor } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: true, height: None } }, + Self::GroupBand { anchor, below } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: *below, height: None } }, + Self::NewDock { side } => Target::NewDock { side: *side }, + } + } + + /// Rank in resolution order: column edges reach past their columns, so they come first. + fn rank(&self) -> u8 { + match self { + Self::ColumnEdge { .. } => 0, + Self::Seam { .. } => 1, + Self::GroupBand { .. } => 2, + Self::NewDock { .. } => 3, + } + } +} + +/// The dock target under the pointer: column edges, then seams, then group bands, then new docks. +pub(super) fn resolve_dock(pointer: Pos2, targets: &[DockDrop

]) -> Option<&DockDrop

> { + if !pointer.is_finite() { + return None; + } + targets.iter().filter(|t| usable(t.band) && t.band.contains(pointer)).min_by_key(|t| t.target.rank()) +} + +/// Width of the insertion lines dock targets preview with. +const LINE: f32 = 4.0; +/// How far a column edge band reaches into the column, and past the edge out of it. +const EDGE_IN: f32 = 6.0; +const EDGE_OUT: f32 = 10.0; + +/// Band and line of a column edge at `x`. `inward` is +1 when the column lies right of the edge, +/// -1 when it lies left of it. The band reaches further out of the column than into it. +pub(super) fn column_edge(x: f32, inward: f32, ys: Rangef) -> (Rect, Rect) { + let (a, b) = (x + inward * EDGE_IN, x - inward * EDGE_OUT); + (Rect::from_x_y_ranges(a.min(b)..=a.max(b), ys), vertical_line(x, ys)) +} + +pub(super) fn vertical_line(x: f32, ys: Rangef) -> Rect { + Rect::from_x_y_ranges((x - LINE * 0.5)..=(x + LINE * 0.5), ys) +} + +pub(super) fn horizontal_line(y: f32, xs: Rangef) -> Rect { + Rect::from_x_y_ranges(xs, (y - LINE * 0.5)..=(y + LINE * 0.5)) } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs new file mode 100644 index 00000000..96adf207 --- /dev/null +++ b/crates/ui/src/docking/testing.rs @@ -0,0 +1,555 @@ +//! Drive a [`DockArea`] with real egui input in application tests (feature `testing`). +//! +//! ```ignore +//! let mut driver = DockDriver::new(app_layout(), Panels::default()); +//! let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap(); +//! driver.drag(bar.center(), egui::pos2(2.0, 300.0)); +//! driver.settle(); +//! assert!(driver.hit(&Hit::ColumnBar(PanelId::Layers)).is_some()); +//! ``` + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, epaint::ClippedShape, vec2}; + +use super::{DockArea, DockContent, DockOutput, DockStyle, Hit, Layout, Monitor}; + +/// Pointer travel that starts a drag before moving to its destination. +const NUDGE: egui::Vec2 = egui::Vec2::new(8.0, 6.0); + +type StyleFn = Box; +#[cfg(feature = "native-windows")] +type InputHook = std::sync::Arc; + +/// A [`DockArea`] in a headless egui context, with its layout and content. Each frame advances +/// the clock by 1/60 s, so double-clicks register. [`Self::step`] applies what a frame emits, +/// the way an application's command path would. +pub struct DockDriver { + pub ctx: Context, + pub layout: Layout

, + pub content: C, + /// The area's id, for [`DockArea::hit_rects`] and the free docking functions. + pub id: Id, + /// The window, 800x600 by default. + pub screen: Rect, + /// Where the area draws inside the window; the whole window by default. + pub area: Rect, + /// Whether the window has focus. Losing it cancels gestures (C2). + pub focused: bool, + /// Modifiers held for the following events. + pub modifiers: Modifiers, + /// Passed to [`DockArea::auto_collapse_iconic`]. + pub auto_collapse: bool, + /// Passed to [`DockArea::maximized`]. + pub maximized: Option

, + /// Passed to [`DockArea::raise_panel`] on the next frame only. + pub raise: Option

, + /// Passed to [`DockArea::float_bounds`]. + pub float_bounds: Option, + /// Passed to [`DockArea::monitors`]. + pub monitors: Vec, + /// Passed to [`DockArea::locked`]. + pub locked: bool, + /// Passed to [`DockArea::input_blocked`]. + pub input_blocked: bool, + /// Passed to [`DockArea::native_windows`]: on by default, whatever the test's desktop. + pub native_windows: bool, + /// The stand-in for OS windows, in use after [`Self::with_native_host`]. + #[cfg(feature = "native-windows")] + host: Option>>, + #[cfg(feature = "native-windows")] + no_host: std::cell::RefCell, + /// Passed to [`DockArea::native_input_hook`]. + #[cfg(feature = "native-windows")] + input_hook: Option, + style: Option, + shapes: Vec, + time: f64, +} + +impl> DockDriver { + /// A driver whose first frames have settled, so every control has its rect. + pub fn new(layout: Layout

, content: C) -> Self { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut driver = Self { + ctx: Context::default(), + layout, + content, + id: Id::unique("dock-driver"), + screen, + area: screen, + focused: true, + modifiers: Modifiers::NONE, + auto_collapse: true, + maximized: None, + raise: None, + float_bounds: None, + monitors: Vec::new(), + locked: false, + input_blocked: false, + native_windows: true, + #[cfg(feature = "native-windows")] + host: None, + #[cfg(feature = "native-windows")] + no_host: std::cell::RefCell::new(NativeHost::new(Pos2::ZERO)), + #[cfg(feature = "native-windows")] + input_hook: None, + style: None, + shapes: Vec::new(), + time: 0.0, + }; + driver.settle(); + driver + } + + /// Adjust the style each frame, after [`DockStyle::from_ui`]. Settles again. + pub fn with_style(mut self, style: impl Fn(&mut DockStyle) + 'static) -> Self { + self.style = Some(Box::new(style)); + self.settle(); + self + } + + /// Draw in `area` of the window. Settles again. + pub fn with_area(mut self, area: Rect) -> Self { + self.area = area; + self.settle(); + self + } + + /// Render one frame with `events` and return its output. Nothing is applied. + pub fn frame(&mut self, events: Vec) -> DockOutput

{ + let mut output = DockOutput::default(); + self.time += 1.0 / 60.0; + #[allow(unused_mut)] + let mut input = RawInput { + screen_rect: Some(self.screen), + events: std::iter::once(Event::ModifiersChanged(self.modifiers)).chain(events).collect(), + focused: self.focused, + time: Some(self.time), + ..Default::default() + }; + #[cfg(feature = "native-windows")] + if let Some(host) = &self.host { + let mut host = host.borrow_mut(); + (host.time, host.screen, host.modifiers) = (self.time, self.screen, self.modifiers); + host.frames += 1; + input.viewports = host.infos(); + input.focused = host.focus == Some(egui::ViewportId::ROOT); + } + let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); + let monitors = self.monitors.clone(); + #[cfg(feature = "native-windows")] + let input_hook = self.input_hook.clone(); + let (style_fn, maximized, raise, auto, bounds, locked, native, blocked) = ( + &self.style, + self.maximized.clone(), + self.raise.take(), + self.auto_collapse, + self.float_bounds, + self.locked, + self.native_windows, + self.input_blocked, + ); + let mut full = self.ctx.run_ui(input, |ui| { + let mut style = DockStyle::from_ui(ui); + if let Some(adjust) = style_fn { + adjust(&mut style); + } + ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { + let mut dock = DockArea::new(id) + .maximized(maximized.clone()) + .auto_collapse_iconic(auto) + .monitors(monitors.clone()) + .locked(locked) + .native_windows(native) + .input_blocked(blocked); + if let Some(panel) = raise.clone() { + dock = dock.raise_panel(panel); + } + if let Some(bounds) = bounds { + dock = dock.float_bounds(bounds); + } + #[cfg(feature = "native-windows")] + if let Some(hook) = input_hook.clone() { + dock = dock.native_input_hook(move |ctx, input| hook(ctx, input)); + } + output = dock.show_content(ui, layout, &style, content); + }); + }); + full.textures_delta.clear(); + #[cfg(feature = "native-windows")] + if let Some(host) = &self.host { + let mut host = host.borrow_mut(); + host.apply(&full); + host.land(); + } + self.shapes = full.shapes; + output + } + + /// Draw native floating windows in a stand-in for OS windows (feature `native-windows`): + /// the main window's content sits at `origin` on the desktop, and each native window runs + /// its own frame on this context with its own input and desktop rect. Settles again. + #[cfg(feature = "native-windows")] + pub fn with_native_host(mut self, origin: Pos2) -> Self { + let host = std::rc::Rc::new(std::cell::RefCell::new(NativeHost::new(origin))); + let shared = host.clone(); + Context::set_immediate_viewport_renderer(move |ctx, viewport| NativeHost::render(&shared, ctx, viewport)); + self.ctx.set_embed_viewports(false); + self.host = Some(host); + self.settle(); + self + } + + /// Pass `hook` to [`DockArea::native_input_hook`] every frame (feature `native-windows`). + #[cfg(feature = "native-windows")] + pub fn with_native_input_hook(mut self, hook: impl Fn(&Context, &mut RawInput) + Send + Sync + 'static) -> Self { + self.input_hook = Some(std::sync::Arc::new(hook)); + self + } + + /// The stand-in for OS windows: without [`Self::with_native_host`], one with no windows + /// that nothing uses. + #[cfg(feature = "native-windows")] + pub fn host(&self) -> std::cell::RefMut<'_, NativeHost> { + self.host.as_deref().unwrap_or(&self.no_host).borrow_mut() + } + + /// The viewport of the floating window with this [`super::Floating::id`]. + #[cfg(feature = "native-windows")] + pub fn viewport(&self, window: u64) -> egui::ViewportId { + DockArea::

::window_viewport(self.id, window) + } + + /// Give `events`, in its own coordinates, to the native window of the floating window + /// `window` on the next frame, and step. + #[cfg(feature = "native-windows")] + pub fn window_step(&mut self, window: u64, events: Vec) -> DockOutput

{ + let viewport = self.viewport(window); + if let Some(target) = self.host().windows.get_mut(&viewport) { + target.events.extend(events); + } + self.step(Vec::new()) + } + + /// Press and release at `pos` in a native window; the output is the release frame's. + #[cfg(feature = "native-windows")] + pub fn window_click(&mut self, window: u64, pos: Pos2) -> DockOutput

{ + self.window_step(window, vec![Event::PointerMoved(pos), self.button(pos, true)]); + self.window_step(window, vec![Event::PointerMoved(pos), self.button(pos, false)]) + } + + /// In a native window, press at `from`, move past the drag threshold and on to `to`, in + /// its coordinates at each frame, without releasing. The window system keeps sending the + /// pointer to the window where it was pressed, inside it or not. + #[cfg(feature = "native-windows")] + pub fn window_hold(&mut self, window: u64, from: Pos2, to: Pos2) -> DockOutput

{ + self.window_step(window, vec![Event::PointerMoved(from), self.button(from, true)]); + self.window_step(window, vec![Event::PointerMoved(from + NUDGE)]); + self.window_step(window, vec![Event::PointerMoved(to)]) + } + + /// Release at `pos` in a native window, in its coordinates. + #[cfg(feature = "native-windows")] + pub fn window_release(&mut self, window: u64, pos: Pos2) -> DockOutput

{ + self.window_step(window, vec![Event::PointerMoved(pos), self.button(pos, false)]) + } + + /// The desktop point `point` in the coordinates of the native window of `window`, as the + /// host placed it. + #[cfg(feature = "native-windows")] + pub fn window_local(&self, window: u64, point: Pos2) -> Option { + let viewport = self.viewport(window); + self.host().windows.get(&viewport).map(|w| point - w.rect.min.to_vec2()) + } + + /// Render one frame and apply its actions, all or none, with the content's permissions. A + /// rejected batch is reported in the output's `error`. + pub fn step(&mut self, events: Vec) -> DockOutput

{ + let mut output = self.frame(events); + if output.error.is_none() + && let Err(error) = self.layout.apply_all(output.actions.clone(), |p| self.content.permissions(p)) + { + output.error = Some(error); + } + output + } + + /// Empty frames until moved widgets report their rects; egui reports them a frame late. + pub fn settle(&mut self) { + for _ in 0..3 { + self.step(Vec::new()); + } + } + + pub fn move_to(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos)]) + } + + pub fn press(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos), self.button(pos, true)]) + } + + pub fn release(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos), self.button(pos, false)]) + } + + /// Press and release at `pos`; the output is the release frame's. + pub fn click(&mut self, pos: Pos2) -> DockOutput

{ + self.press(pos); + self.release(pos) + } + + /// Press at `from`, move past the drag threshold and on to `to`, without releasing. + pub fn hold(&mut self, from: Pos2, to: Pos2) -> DockOutput

{ + self.press(from); + self.move_to(from + NUDGE); + self.move_to(to) + } + + /// A whole drag; the output is the release frame's. + pub fn drag(&mut self, from: Pos2, to: Pos2) -> DockOutput

{ + self.hold(from, to); + self.release(to) + } + + /// Press and release `key` in one frame. + pub fn key(&mut self, key: Key) -> DockOutput

{ + let modifiers = self.modifiers; + self.step(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]) + } + + /// Give or take the window's focus for this and the following frames. + pub fn focus(&mut self, focused: bool) -> DockOutput

{ + self.focused = focused; + self.step(vec![Event::WindowFocused(focused)]) + } + + /// The pointer leaves the window. + pub fn leave(&mut self) -> DockOutput

{ + self.step(vec![Event::PointerGone]) + } + + /// Every gesture source of the last frame. + pub fn hits(&self) -> Vec<(Hit

, Rect)> { + DockArea::

::hit_rects(&self.ctx, self.id) + } + + /// The rect of one gesture source in the last frame. + pub fn hit(&self, hit: &Hit

) -> Option { + self.hits().into_iter().find(|(h, _)| h == hit).map(|(_, rect)| rect) + } + + /// The shapes of the last frame. + pub fn shapes(&self) -> &[ClippedShape] { + &self.shapes + } + + /// Whether the last frame filled `rect` (an insertion line or a split preview). + pub fn filled(&self, rect: Rect) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect == rect && shape.fill != egui::Color32::TRANSPARENT, + _ => false, + }) + } + + /// Whether the last frame filled a polygon with these corners, each within half a point. + pub fn filled_polygon(&self, corners: &[Pos2]) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Path(path) => { + path.fill != egui::Color32::TRANSPARENT + && path.points.len() == corners.len() + && path.points.iter().zip(corners).all(|(a, b)| a.distance(*b) <= 0.5) + } + _ => false, + }) + } + + /// Whether the last frame outlined `rect` without filling it. + pub fn outlined(&self, rect: Rect) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == egui::Color32::TRANSPARENT && shape.stroke.width > 0.0, + _ => false, + }) + } + + fn button(&self, pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: self.modifiers } + } +} + +/// OS windows for [`DockDriver::with_native_host`] (feature `native-windows`). +#[cfg(feature = "native-windows")] +pub struct NativeHost { + /// Desktop position of the main window's content. + pub origin: Pos2, + /// The monitor size egui reports for every window. + pub monitor_size: Option, + /// The window with focus: the main window (`ViewportId::ROOT`, the default), a native + /// window, or `None` while another application is active. + pub focus: Option, + /// Windows report no position, as on Wayland. + pub no_position: bool, + /// From a native window's outer position to its content, as a title bar puts it. The + /// window opens with its outer position where it was asked to, and `OuterPosition` moves + /// that. + pub frame: egui::Vec2, + /// `OuterPosition` lands a frame late, at the end of the frame after the command. That + /// frame still reports the old place, and gets pointer events relative to it. + pub lag: bool, + pub windows: std::collections::HashMap, + /// Every command sent to a native window or the main window, in order. + pub commands: Vec<(egui::ViewportId, egui::ViewportCommand)>, + /// Moves waiting to land under `lag`: window, content position, frame sent. + late: Vec<(egui::ViewportId, Pos2, usize)>, + frames: usize, + /// The driver's modifiers: the keyboard's, whichever window has focus. + modifiers: Modifiers, + screen: Rect, + time: f64, +} + +/// One OS window of a [`NativeHost`]. +#[cfg(feature = "native-windows")] +#[derive(Debug)] +pub struct HostWindow { + /// Desktop rect of its content: where it opened, then moved by `OuterPosition` and + /// `InnerSize`. + pub rect: Rect, + pub visible: bool, + /// The window level its builder asked for on its last frame. + pub level: Option, + /// Frames it drew. + pub frames: usize, + /// Events for its next frame, in its own coordinates. + pub events: Vec, + /// Ask to close it on its next frame, as the OS close button does. + pub close: bool, + /// Files dragged over it, in every frame's input until cleared. + pub hovered_files: Vec, + /// Files dropped on it on its next frame. + pub dropped_files: Vec, + /// Shapes of its last frame. + pub shapes: Vec, +} + +#[cfg(feature = "native-windows")] +impl NativeHost { + fn new(origin: Pos2) -> Self { + Self { + origin, + monitor_size: None, + focus: Some(egui::ViewportId::ROOT), + no_position: false, + frame: egui::Vec2::ZERO, + lag: false, + windows: Default::default(), + commands: Vec::new(), + late: Vec::new(), + frames: 0, + modifiers: Modifiers::NONE, + screen: Rect::NOTHING, + time: 0.0, + } + } + + /// What egui knows of every window. + fn infos(&self) -> egui::ViewportIdMap { + let info = |id: egui::ViewportId, rect: Rect| egui::ViewportInfo { + inner_rect: Some(rect), + outer_rect: (!self.no_position) + .then(|| if id == egui::ViewportId::ROOT { rect } else { Rect::from_min_max(rect.min - self.frame, rect.max) }), + focused: Some(self.focus == Some(id)), + monitor_size: self.monitor_size, + native_pixels_per_point: Some(1.0), + ..Default::default() + }; + let main = Rect::from_min_size(self.origin, self.screen.size()); + std::iter::once((egui::ViewportId::ROOT, info(egui::ViewportId::ROOT, main))) + .chain(self.windows.iter().map(|(id, window)| (*id, info(*id, window.rect)))) + .collect() + } + + /// Run one native window's frame. + fn render(host: &std::cell::RefCell, ctx: &Context, mut viewport: egui::ImmediateViewport<'_>) { + let id = viewport.ids.this; + let input = { + let mut host = host.borrow_mut(); + let builder = &viewport.builder; + let rect = Rect::from_min_size(builder.position.unwrap_or_default() + host.frame, builder.inner_size.unwrap_or(vec2(300.0, 200.0))); + let visible = builder.visible.unwrap_or(true); + let level = builder.window_level; + host.windows.entry(id).or_insert_with(|| HostWindow { + rect, + visible, + level, + frames: 0, + events: Vec::new(), + close: false, + hovered_files: Vec::new(), + dropped_files: Vec::new(), + shapes: Vec::new(), + }); + let mut viewports = host.infos(); + let (time, focused, modifiers) = (host.time, host.focus == Some(id), host.modifiers); + let Some(window) = host.windows.get_mut(&id) else { return }; + window.frames += 1; + window.level = level; + if std::mem::take(&mut window.close) + && let Some(info) = viewports.get_mut(&id) + { + info.events.push(egui::ViewportEvent::Close); + } + RawInput { + viewport_id: id, + viewports, + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, window.rect.size())), + events: std::iter::once(Event::ModifiersChanged(modifiers)).chain(std::mem::take(&mut window.events)).collect(), + hovered_files: window.hovered_files.clone(), + dropped_files: std::mem::take(&mut window.dropped_files), + focused, + time: Some(time), + ..Default::default() + } + }; + let mut full = ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)); + full.textures_delta.clear(); + let mut host = host.borrow_mut(); + host.apply(&full); + if let Some(window) = host.windows.get_mut(&id) { + window.shapes = full.shapes; + } + } + + /// End a frame: moves sent in an earlier one land. + fn land(&mut self) { + let frames = self.frames; + let (landed, late) = std::mem::take(&mut self.late).into_iter().partition::, _>(|(.., sent)| *sent < frames); + self.late = late; + for (id, pos, _) in landed { + if let Some(window) = self.windows.get_mut(&id) { + window.rect = Rect::from_min_size(pos, window.rect.size()); + } + } + } + + /// Carry out the commands a frame sent to native windows. + fn apply(&mut self, full: &egui::FullOutput) { + for (id, output) in &full.viewport_output { + for command in &output.commands { + self.commands.push((*id, command.clone())); + let Some(window) = self.windows.get_mut(id) else { continue }; + match command { + egui::ViewportCommand::OuterPosition(pos) if self.lag => self.late.push((*id, *pos + self.frame, self.frames)), + egui::ViewportCommand::OuterPosition(pos) => window.rect = Rect::from_min_size(*pos + self.frame, window.rect.size()), + egui::ViewportCommand::InnerSize(size) => window.rect = Rect::from_min_size(window.rect.min, *size), + egui::ViewportCommand::Visible(visible) => window.visible = *visible, + _ => {} + } + } + } + } +} diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index e5841771..ce0700f2 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::layout::{SplitAxis, SplitSize}; -use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, pos2, vec2}; +use egui::{Context, Event, Id, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; fn tabs(panels: &[u32], active: usize) -> Node { Node::Tabs { panels: panels.to_vec(), active } @@ -14,7 +14,7 @@ fn sample() -> Layout { first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[2, 3, 4], 1)), }), - floating: vec![Floating { panels: vec![5, 6], active: 1, rect: [80.0, 70.0, 300.0, 220.0] }], + floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])], } } @@ -29,8 +29,8 @@ fn floating_brush_panel_joins_main_tabs_without_losing_other_panels() { let Node::Split { size, second, .. } = layout.root.as_ref().unwrap() else { panic!() }; assert_eq!(*size, SplitSize::FixedSecond(240.0)); assert_eq!(**second, tabs(&[2, 3, 5, 4], 2)); - assert_eq!(layout.floating[0].panels, vec![6]); - assert_eq!(layout.floating[0].active, 0); + assert_eq!(layout.floating[0].groups()[0].panels, vec![6]); + assert_eq!(layout.floating[0].groups()[0].active, 0); layout.validate().unwrap(); assert_eq!(layout.panels().len(), 6); } @@ -43,21 +43,46 @@ fn detach_redock_merge_and_collapse_empty_source_containers() { layout.apply(move_to(1, 2, Zone::Left)).unwrap(); assert_eq!(layout.floating.len(), 1); layout.apply(move_to(5, 6, Zone::Center)).unwrap(); - assert_eq!(layout.floating[0].panels, vec![6, 5]); - assert_eq!(layout.floating[0].active, 1); + assert_eq!(layout.floating[0].groups()[0].panels, vec![6, 5]); + assert_eq!(layout.floating[0].groups()[0].active, 1); layout.apply(move_to(6, 3, Zone::Center)).unwrap(); layout.apply(move_to(5, 3, Zone::Center)).unwrap(); assert!(layout.floating.is_empty()); assert_eq!(layout.panels().len(), 6); } +#[test] +fn move_targets_skip_the_panel_itself_and_protected_anchors() { + let layout = sample(); + let all = |_: &u32| Permissions::default(); + let targets = layout.move_targets(&1, all); + assert_eq!(targets.len(), 5); + assert!(!targets.contains(&&1)); + let guarded = |p: &u32| if *p == 3 { Permissions::PROTECTED } else { Permissions::default() }; + assert!(!layout.move_targets(&1, guarded).contains(&&3)); + + // A lone panel has nowhere to go — its "Move Panel to Group" menu hides + // (storytold/lightcraft#1768). + let single = Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() }; + assert!(single.move_targets(&1, all).is_empty()); +} + +#[test] +fn move_targets_skip_the_panels_own_group() { + let layout = sample(); + let all = |_: &u32| Permissions::default(); + assert_eq!(layout.move_targets(&3, all), vec![&1, &5, &6]); + let pair = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() }; + assert!(pair.move_targets(&1, all).is_empty()); +} + #[test] fn invalid_moves_are_atomic_including_after_a_source_would_have_been_removed() { let layout = sample(); for action in [ move_to(5, 99, Zone::Center), move_to(99, 2, Zone::Center), - move_to(5, 6, Zone::Left), + Action::Move { panel: 5, anchor: 6, placement: Placement::Column { after: true } }, move_to(5, 5, Zone::Center), Action::Move { panel: 5, anchor: 2, placement: Placement::Tab { before: Some(6) } }, Action::Float { panel: 3, rect: [f32::NAN, 0.0, 100.0, 100.0] }, @@ -119,13 +144,15 @@ fn protected_canvas_remains_present_and_cannot_be_grouped_with_panels() { move_to(1, 3, Zone::Center), move_to(3, 1, Zone::Center), Action::OpenAt { panel: 1, anchor: 3, placement: Placement::Tab { before: None } }, + Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, ] { let mut next = layout.clone(); assert_eq!(next.apply_with_permissions(action, permissions), Err(DockError::NotAllowed)); assert_eq!(next, layout); } + let beside = |panel: &u32| if *panel == 1 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }; let mut next = layout; - next.apply_with_permissions(Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, permissions).unwrap(); + next.apply_with_permissions(Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, beside).unwrap(); assert!(next.contains(&1) && next.contains(&7)); } @@ -142,6 +169,17 @@ fn moving_a_floating_group_needs_every_member_movable() { assert_eq!(next, layout); next.apply_with_permissions(Action::MoveFloating { panel: 5, rect: [0.0, 0.0, 300.0, 220.0] }, |_: &u32| Permissions::default()).unwrap(); assert_eq!(next.floating[0].rect, [0.0, 0.0, 300.0, 220.0]); + + // The same holds when 6 sits in another group of the window's tree. + let mut split = sample(); + split.floating[0].root = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[5], 0)), second: Box::new(tabs(&[6], 0)) }; + let before = split.clone(); + assert_eq!( + split.apply_with_permissions(Action::MoveFloating { panel: 5, rect: [0.0, 0.0, 300.0, 220.0] }, permissions), + Err(DockError::NotAllowed) + ); + assert_eq!(split, before); } #[test] @@ -153,7 +191,7 @@ fn close_and_restore_preserve_fixed_side_and_floating_geometry() { first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[2], 0)), }), - floating: vec![Floating { panels: vec![3], active: 0, rect: [41.0, 52.0, 233.0, 244.0] }], + floating: vec![Floating::single(vec![3], 0, [41.0, 52.0, 233.0, 244.0])], }; let saved = layout.clone(); let first = layout.location(&1).unwrap(); @@ -196,12 +234,18 @@ fn closing_and_reopening_an_entire_accordion_restores_its_kind_and_heights() { #[test] fn invalid_saved_layouts_reject_duplicates_selection_depth_and_nonfinite_geometry() { let mut duplicate = sample(); - duplicate.floating[0].panels.push(1); + if let Node::Tabs { panels, .. } = &mut duplicate.floating[0].root { + panels.push(1); + } assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel)); let mut invalid = sample(); - invalid.floating[0].active = usize::MAX; + if let Node::Tabs { active, .. } = &mut invalid.floating[0].root { + *active = usize::MAX; + } assert_eq!(invalid.validate(), Err(DockError::InvalidSelection)); - invalid.floating[0].active = 0; + if let Node::Tabs { active, .. } = &mut invalid.floating[0].root { + *active = 0; + } invalid.floating[0].rect[3] = f32::INFINITY; assert_eq!(invalid.validate(), Err(DockError::InvalidGeometry)); let mut deep = tabs(&[0], 0); @@ -213,6 +257,13 @@ fn invalid_saved_layouts_reject_duplicates_selection_depth_and_nonfinite_geometr assert_eq!(deep.apply(Action::Activate { panel: 0 }), Err(DockError::LimitExceeded)); let oversized = Layout { root: Some(Node::Tabs { panels: (0..=MAX_PANELS as u32).collect(), active: 0 }), floating: Vec::new() }; assert_eq!(oversized.validate(), Err(DockError::LimitExceeded)); + for empty in [tabs(&[], 0), Node::Stack { entries: Vec::new() }] { + let root = Layout { root: Some(empty.clone()), floating: Vec::new() }; + assert_eq!(root.validate(), Err(DockError::InvalidGeometry)); + let beside = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[1], 0)), second: Box::new(empty) }; + assert_eq!(Layout { root: Some(beside), floating: Vec::new() }.validate(), Err(DockError::InvalidGeometry)); + } } fn target(anchor: u32, members: &[u32], rect: Rect) -> DropTarget { @@ -228,6 +279,7 @@ fn target(anchor: u32, members: &[u32], rect: Rect) -> DropTarget { .collect(), allow_splits: true, accepts_tabs: true, + right_to_left: false, } } @@ -235,52 +287,109 @@ fn target(anchor: u32, members: &[u32], rect: Rect) -> DropTarget { fn pointer_targets_distinguish_reorder_center_split_and_occlusion() { let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(300.0, 200.0)); let group = target(2, &[2, 3], rect); - let placement = resolve_drop(&5, pos2(90.0, 30.0), std::slice::from_ref(&group)).unwrap(); + let placement = resolve_drop(&5, pos2(90.0, 30.0), std::slice::from_ref(&group), 40.0).unwrap(); assert_eq!(placement.placement, Placement::Tab { before: Some(3) }); assert!(placement.insertion.unwrap().is_finite()); - assert_eq!(resolve_drop(&5, rect.center(), std::slice::from_ref(&group)).unwrap().placement, Placement::Split(Zone::Center)); - assert_eq!(resolve_drop(&5, pos2(11.0, 90.0), std::slice::from_ref(&group)).unwrap().placement, Placement::Split(Zone::Left)); - assert!(resolve_drop(&3, rect.center(), std::slice::from_ref(&group)).is_none()); - assert!(resolve_drop(&3, pos2(11.0, 90.0), std::slice::from_ref(&group)).is_some()); + assert_eq!(resolve_drop(&5, rect.center(), std::slice::from_ref(&group), 40.0).unwrap().placement, Placement::Split(Zone::Center)); + assert_eq!(resolve_drop(&5, pos2(11.0, 90.0), std::slice::from_ref(&group), 40.0).unwrap().placement, Placement::Split(Zone::Left)); + assert!(resolve_drop(&3, rect.center(), std::slice::from_ref(&group), 40.0).unwrap().noop); + assert!(resolve_drop(&3, pos2(11.0, 90.0), std::slice::from_ref(&group), 40.0).is_some()); let mut overlay = target(5, &[5], rect); overlay.accepts_tabs = false; overlay.allow_splits = false; - assert!(resolve_drop(&3, rect.center(), &[group, overlay]).is_none()); - assert!(drop_zone(Rect::NOTHING, 24.0, pos2(0.0, 0.0)).is_none()); - assert!(drop_zone(rect, 24.0, pos2(f32::NAN, 0.0)).is_none()); + assert!(resolve_drop(&3, rect.center(), &[group, overlay], 40.0).is_none()); + assert!(drop_zone(Rect::NOTHING, 24.0, 40.0, pos2(0.0, 0.0)).is_none()); + assert!(drop_zone(rect, 24.0, 40.0, pos2(f32::NAN, 0.0)).is_none()); +} + +#[test] +fn side_zones_are_40_pt_deep_or_a_quarter_of_the_bodys_smaller_side() { + let depth = DockStyle::default().zone_depth; + assert_eq!(depth, 40.0); + // The inner corner of the left trapezoid is the depth of both bands. + let inner = |rect: Rect| zone_shape(rect, 0.0, depth, Zone::Left).unwrap()[1] - rect.min; + for size in [vec2(600.0, 400.0), vec2(160.0, 160.0)] { + let rect = Rect::from_min_size(pos2(10.0, 20.0), size); + assert_eq!(inner(rect), vec2(40.0, 40.0), "{size:?}"); + let y = rect.center().y; + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 39.0, y)), Some(Zone::Left), "{size:?}"); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 41.0, y)), Some(Zone::Center), "{size:?}"); + } + // A 90 pt body keeps a 45 pt center. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(90.0, 90.0)); + assert_eq!(inner(rect), vec2(22.5, 22.5)); + assert_eq!(drop_zone(rect, 0.0, depth, rect.center()), Some(Zone::Center)); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 22.0, rect.center().y)), Some(Zone::Left)); + // A tall narrow body: its top and bottom zones are as deep as its left and right ones. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(120.0, 600.0)); + assert_eq!(inner(rect), vec2(30.0, 30.0)); + let top = zone_shape(rect, 0.0, depth, Zone::Top).unwrap(); + assert_eq!(top[2].y - rect.top(), 30.0); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.center().x, rect.top() + 29.0)), Some(Zone::Top)); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.center().x, rect.top() + 31.0)), Some(Zone::Center)); + // The bands start below the tab header. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(600.0, 400.0)); + assert_eq!(drop_zone(rect, 24.0, depth, pos2(rect.center().x, rect.top() + 60.0)), Some(Zone::Top)); + assert_eq!(zone_shape(rect, 24.0, depth, Zone::Top).unwrap()[0], rect.left_top() + vec2(0.0, 24.0)); + // Without depth there are no sides. + assert_eq!(drop_zone(rect, 0.0, 0.0, pos2(rect.left() + 1.0, rect.center().y)), Some(Zone::Center)); + assert!(zone_shape(rect, 0.0, 0.0, Zone::Left).is_none()); } #[test] fn long_action_sequence_never_loses_or_duplicates_a_panel() { - let mut layout = sample(); - let mut seed = 72u64; - for step in 0..800 { - seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); - let panel = (seed % 6 + 1) as u32; - let anchor = ((seed >> 16) % 6 + 1) as u32; - let action = if step % 7 == 0 { - Action::Float { panel, rect: [10.0, 20.0, 200.0, 220.0] } - } else { - move_to( - panel, - anchor, - match seed % 5 { - 0 => Zone::Left, - 1 => Zone::Right, - 2 => Zone::Top, - 3 => Zone::Bottom, - _ => Zone::Center, - }, - ) - }; - let before = layout.clone(); - if layout.apply(action).is_err() { - assert_eq!(layout, before); + for (start, ids) in [(sample(), vec![1, 2, 3, 4, 5, 6]), (super::model_tests::docked(), super::model_tests::ALL.to_vec())] { + let mut workspace = Workspace::new(start); + let mut seed = 72u64; + for step in 0..800 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let pick = |bits: u64| ids[(bits % ids.len() as u64) as usize]; + let (panel, anchor) = (pick(seed >> 8), pick(seed >> 24)); + let placement = match (seed >> 40) % 7 { + 0 => Placement::Split(Zone::Left), + 1 => Placement::Split(Zone::Right), + 2 => Placement::Split(Zone::Top), + 3 => Placement::Split(Zone::Bottom), + 4 => Placement::Split(Zone::Center), + 5 => Placement::Group { below: seed & 1 == 0, height: None }, + _ => Placement::Column { after: seed & 2 == 0 }, + }; + let to = match (seed >> 32) % 4 { + 0 => Target::Float { rect: None }, + 1 => Target::NewDock { side: if seed & 4 == 0 { Side::Start } else { Side::End } }, + _ => Target::At { anchor, placement: placement.clone() }, + }; + let action = match step % 13 { + 0 => Action::Float { panel, rect: [10.0, 20.0, 200.0, 220.0] }, + 1 => Action::MoveGroup { panel, to }, + 2 => Action::MoveColumn { panel, to }, + 3 => Action::MoveWindow { panel, to }, + 4 => Action::CloseGroup { panel }, + 5 => Action::CloseWindow { panel }, + 6 => Action::CloseOthers { panel }, + 7 => Action::Close { panel }, + 8 => Action::SetMinimized { panel, minimized: seed & 8 == 0 }, + 9 => Action::SetIconic { panel, iconic: seed & 16 == 0 }, + _ => Action::Move { panel, anchor, placement }, + }; + let before = workspace.clone(); + if workspace.apply(action, |_| Permissions::default()).is_err() { + assert_eq!(workspace, before); + } + if step % 3 == 0 + && let Some((hidden, _)) = workspace.hidden.first().cloned() + { + let fallback = workspace.layout.panels().first().map(|p| **p); + let before = workspace.clone(); + if workspace.set_visible(hidden, true, fallback, |_| Permissions::default()).is_err() { + assert_eq!(workspace, before); + } + } + workspace.validate().unwrap(); + let mut panels: Vec = workspace.layout.panels().into_iter().copied().chain(workspace.hidden.iter().map(|(p, _)| *p)).collect(); + panels.sort_unstable(); + assert_eq!(panels, ids, "step {step}"); } - layout.validate().unwrap(); - let mut panels: Vec = layout.panels().into_iter().copied().collect(); - panels.sort_unstable(); - assert_eq!(panels, vec![1, 2, 3, 4, 5, 6]); } } @@ -331,6 +440,8 @@ fn frame(ctx: &Context, layout: &Layout, events: Vec, focused: bool) ui.label("Actual panel content"); }, ); + let cursor = ctx.output(|o| o.cursor_icon); + ctx.data_mut(|d| d.insert_temp(Id::unique("test-cursor"), cursor)); }) .textures_delta .clear(); @@ -403,13 +514,37 @@ fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() { step(&ctx, &mut layout, vec![Event::PointerMoved(target)]); let actions = step(&ctx, &mut layout, vec![pointer_button(target, false)]); assert!(actions.iter().any(|a| matches!(a, Action::Move { panel: 5, .. })), "{actions:?}"); - assert!(!layout.floating.iter().any(|g| g.panels.contains(&5))); + assert!(!layout.floating.iter().any(|g| g.contains(&5))); assert_eq!(layout.panels().len(), 6); let Node::Split { second, .. } = layout.root.as_ref().unwrap() else { panic!() }; let Node::Tabs { panels, active } = second.as_ref() else { panic!() }; assert_eq!(panels.get(*active), Some(&5)); } +/// On X11 and Windows a release outside the window never reaches egui (egui-winit drops a +/// button event once the pointer has left), so a tab dragged out of the window used to stay on +/// the pointer for good. Leaving the window detaches the panel where it left, even over a dock +/// target, and ends the drag. +#[test] +fn tab_dragged_out_of_the_window_floats_where_it_left() { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..3 { + step(&ctx, &mut layout, Vec::new()); + } + let tab = ctx.read_response(Id::unique("test-area").with(("tab", &3u32))).unwrap().rect.center(); + let exit = pos2(4.0, 300.0); + step(&ctx, &mut layout, vec![Event::PointerMoved(tab), pointer_button(tab, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(-8.0, 0.0))]); + step(&ctx, &mut layout, vec![Event::PointerMoved(exit)]); + let actions = step(&ctx, &mut layout, vec![Event::PointerGone]); + assert!(actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{actions:?}"); + assert!(layout.floating.iter().any(|g| g.panels().eq([3].iter())), "{layout:?}"); + let back = pos2(400.0, 300.0); + assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(back)]).is_empty()); + assert!(step(&ctx, &mut layout, vec![pointer_button(back, false)]).is_empty(), "the drag ended when the pointer left"); +} + #[test] fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() { let layout = Layout { @@ -475,7 +610,10 @@ fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() { for _ in 0..3 { step(&ctx, &mut layout, Vec::new()); } - let id = Id::unique("test-area").with(("floating", &5u32)).with("resize"); + let id = DockArea::hit_id(Id::unique("test-area"), &Hit::Resize(5u32, ResizeEdge::BottomRight)); + let layer = DockArea::floating_layer(Id::unique("test-area"), &layout.floating[0]).unwrap(); + let [x, y, width, height] = layout.floating[0].rect; + assert_eq!(ctx.memory(|m| m.area_rect(layer.id)), Some(Rect::from_min_size(pos2(x, y), vec2(width, height)))); ctx.memory_mut(|memory| memory.request_focus(id)); let key = |key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; step(&ctx, &mut layout, vec![key(egui::Key::ArrowRight), key(egui::Key::ArrowDown)]); @@ -522,6 +660,17 @@ fn stack_dividers_resize_adjacent_open_bodies_by_pointer_and_keyboard() { let Node::Stack { entries } = layout.root.as_ref().unwrap() else { panic!() }; assert!(entries[0].height.unwrap() > after_pointer); assert!((entries[0].height.unwrap() + entries[2].height.unwrap() - 300.0).abs() < 0.001); + // Dragged past the next body's minimum, the one after it gives way too, down to the same + // minimum; the closed entry keeps its height. + let start = ctx.read_response(id).unwrap().rect.center(); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(0.0, 600.0))]); + step(&ctx, &mut layout, vec![pointer_button(start + vec2(0.0, 600.0), false)]); + let Node::Stack { entries } = layout.root.as_ref().unwrap() else { panic!() }; + let heights = entries.iter().map(|entry| entry.height.unwrap()).collect::>(); + assert!((heights[2] - heights[3]).abs() < 0.01 && heights[3] < 120.0, "{heights:?}"); + assert!((heights[0] + heights[2] + heights[3] - 420.0).abs() < 0.01, "{heights:?}"); + assert_eq!(heights[1], 91.0); } #[test] @@ -580,7 +729,7 @@ fn split_inner_edge_remains_draggable_beside_a_scrollbar() { frame(&mut layout, vec![Event::PointerMoved(start + vec2(40.0, 0.0))]); frame(&mut layout, vec![pointer_button(start + vec2(40.0, 0.0), false)]); let Node::Split { size: SplitSize::FixedFirst(width), .. } = layout.root.unwrap() else { panic!() }; - assert!((width - 260.0).abs() < 0.001, "sidebar width after inner-edge drag: {width}"); + assert!((width - 260.0).abs() < 0.01, "sidebar width after inner-edge drag: {width}"); } #[test] @@ -609,7 +758,7 @@ fn restoring_a_panel_beside_a_nested_subtree_preserves_the_complete_workspace() #[test] fn floating_only_surface_does_not_allocate_the_callers_document_region() { let ctx = Context::default(); - let layout = Layout { root: None, floating: vec![Floating { panels: vec![1u32], active: 0, rect: [30.0, 40.0, 200.0, 200.0] }] }; + let layout = Layout { root: None, floating: vec![Floating::single(vec![1u32], 0, [30.0, 40.0, 200.0, 200.0])] }; ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { let before = ui.available_rect_before_wrap(); let out = DockArea::new(Id::unique("float-only")).show( @@ -641,8 +790,8 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]); assert!(actions.iter().any(|a| matches!(a, Action::MoveFloating { .. }))); - assert_eq!(layout.floating[0].panels, original.panels); - assert_eq!(layout.floating[0].active, original.active); + assert_eq!(layout.floating[0].groups()[0].panels, original.groups()[0].panels); + assert_eq!(layout.floating[0].groups()[0].active, original.groups()[0].active); assert_ne!(layout.floating[0].rect, original.rect); step(&ctx, &mut layout, vec![pointer_button(start + vec2(50.0, 40.0), false)]); for _ in 0..2 { @@ -651,11 +800,115 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { let tab = ctx.read_response(Id::unique("test-area").with(("tab", &5u32))).unwrap().rect.center(); step(&ctx, &mut layout, vec![Event::PointerMoved(tab), pointer_button(tab, true)]); step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(8.0, 0.0))]); - step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(100.0, 100.0))]); - let detached = step(&ctx, &mut layout, vec![pointer_button(tab + vec2(100.0, 100.0), false)]); + // Inside the source window the release is a no-op; outside it floats. + let outside = pos2(780.0, 650.0); + step(&ctx, &mut layout, vec![Event::PointerMoved(outside)]); + let detached = step(&ctx, &mut layout, vec![pointer_button(outside, false)]); assert!(detached.iter().any(|a| matches!(a, Action::Float { panel: 5, .. }))); assert_eq!(layout.floating.len(), 2); - assert_eq!(layout.floating[0].panels, vec![6]); + assert_eq!(layout.floating[0].groups()[0].panels, vec![6]); +} + +#[test] +fn floating_group_drag_ends_on_release_or_pointer_exit() { + for (released, leaves) in [(false, true), (true, true), (true, false)] { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..2 { + step(&ctx, &mut layout, vec![]); + } + let start = pos2(86.0, 82.0); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + let moved = start + vec2(50.0, 40.0); + let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(moved)]); + assert!(actions.iter().any(|action| matches!(action, Action::MoveFloating { .. }))); + let last = layout.clone(); + let mut events = vec![]; + if released { + events.push(pointer_button(moved, false)); + } + if leaves { + events.push(Event::PointerGone); + } + let (out, _) = frame(&ctx, &layout, events, true); + assert_eq!(out.group_drop, if leaves { None } else { Some((5, moved)) }); + for action in out.actions { + layout.apply(action).unwrap(); + } + assert_eq!(layout, last); + for pointer in [moved + vec2(20.0, 20.0), moved + vec2(30.0, 30.0)] { + let (out, _) = frame(&ctx, &layout, vec![Event::PointerMoved(pointer)], true); + assert!(out.actions.is_empty(), "{out:?}"); + assert!(out.group_drag.is_none() && out.group_drop.is_none()); + } + assert!(out.group_drag.is_none()); + assert_eq!(ctx.dragged_id(), None); + } +} + +#[test] +fn floating_group_touch_release_reports_drop_after_pointer_gone() { + for web_order in [false, true] { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..2 { + step(&ctx, &mut layout, vec![]); + } + let touch = |phase, pos| Event::Touch { device_id: egui::TouchDeviceId(0), id: egui::TouchId(1), phase, pos, force: None }; + let start = pos2(86.0, 82.0); + let press = pointer_button(start, true); + let events = if web_order { + vec![press, touch(egui::TouchPhase::Start, start)] + } else { + vec![touch(egui::TouchPhase::Start, start), Event::PointerMoved(start), press] + }; + step(&ctx, &mut layout, events); + let moved = start + vec2(50.0, 40.0); + let events = if web_order { + vec![Event::PointerMoved(moved), touch(egui::TouchPhase::Move, moved)] + } else { + vec![touch(egui::TouchPhase::Move, moved), Event::PointerMoved(moved)] + }; + assert!(step(&ctx, &mut layout, events).iter().any(|action| matches!(action, Action::MoveFloating { .. }))); + // egui-winit emits Touch before release/gone; eframe web emits it afterwards. + let events = if web_order { + vec![pointer_button(moved, false), Event::PointerGone, touch(egui::TouchPhase::End, moved)] + } else { + vec![touch(egui::TouchPhase::End, moved), pointer_button(moved, false), Event::PointerGone] + }; + let (out, _) = frame(&ctx, &layout, events, true); + assert!(!ctx.input(|i| i.pointer.has_pointer())); + assert_eq!(out.group_drop, Some((5, moved))); + assert!(out.group_drag.is_none()); + assert_eq!(ctx.dragged_id(), None); + assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(moved + vec2(30.0, 30.0))]).is_empty()); + } +} + +#[test] +fn floating_group_exit_with_escape_or_focus_loss_restores_original_geometry() { + for escape in [false, true] { + let ctx = Context::default(); + let mut layout = sample(); + let original = layout.clone(); + for _ in 0..2 { + step(&ctx, &mut layout, vec![]); + } + let start = pos2(86.0, 82.0); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]); + assert_ne!(layout.floating[0].rect, original.floating[0].rect); + let mut events = vec![Event::PointerGone]; + if escape { + events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + } + let (out, _) = frame(&ctx, &layout, events, escape); + assert!(out.group_drag.is_none() && out.group_drop.is_none()); + for action in out.actions { + layout.apply(action).unwrap(); + } + assert_eq!(layout.floating[0].rect, original.floating[0].rect); + } } struct CustomContent { @@ -705,7 +958,7 @@ impl DockContent for CustomContent { self.menus += 1; ui.button("App menu").clicked(); } - fn group_header_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + fn group_header_width(&self, _: &[u32], _: &u32, _: bool, _: f32) -> f32 { 40.0 } fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { @@ -746,11 +999,12 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() { custom_frame(&ctx, &layout, &mut content, vec![], &[], false); } let out = custom_frame(&ctx, &layout, &mut content, vec![], &[], true); - assert_eq!(out.focused, Some(5)); + assert_eq!(out.focused, None, "a programmatic raise is not user focus"); + assert_eq!(ctx.memory(|m| m.focused()), None, "a raise draws no keyboard-focus ring"); assert!(out.actions.is_empty()); assert!(content.headers >= 3 && content.bodies >= 3); assert!(content.labels.iter().any(|s| s == "Logical 5")); - let at = pos2(130.0, 82.0); + let at = pos2(130.0, 96.0); custom_frame( &ctx, &layout, @@ -941,7 +1195,7 @@ fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid assert!(!DockArea::new(Id::unique("focus-area")).chrome_has_focus(&ctx, &layout)); paint(tab); let mut invalid = layout.clone(); - invalid.floating.push(Floating { panels: vec![3], active: 0, rect: [10.0, 10.0, 100.0, 100.0] }); + invalid.floating.push(Floating::single(vec![3], 0, [10.0, 10.0, 100.0, 100.0])); assert!(!DockArea::new(Id::unique("focus-area")).chrome_has_focus(&ctx, &invalid)); assert!(!DockArea::::new(Id::unique("focus-area")).chrome_has_focus(&ctx, &Layout::default())); ctx.memory_mut(|memory| memory.request_focus(Id::unique("unrendered"))); @@ -1122,7 +1376,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 }, ); h.run_steps(2); - let tab = Id::unique("action-label-test").with(("tab", &5_u32)); + let tab = Id::unique("action-label-test").with(("tab", &6_u32)); let at = h.ctx.read_response(tab).unwrap().rect.center(); h.input_mut().events.extend([ Event::PointerMoved(at), @@ -1132,14 +1386,12 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx)); - for logical in ["Float panel", "Move to group", "Close panel"] { + for logical in ["Float Panel", "Move Panel to Group", "Close Panel"] { assert!( h.output().shapes.iter().any(|shape| paints(&shape.shape, &format!("Action visual: {logical}"))), "custom action must paint: {logical}" ); - // This sample also has a floating-group grip labeled Move to group. - let expected = if logical == "Move to group" { 2 } else { 1 }; - assert_eq!(h.query_all_by_label(logical).count(), expected, "popup action must retain its own logical accessibility: {logical}"); + assert_eq!(h.query_all_by_label(logical).count(), 1, "popup action must retain its own logical accessibility: {logical}"); assert_eq!( h.query_all_by_label(&format!("Action visual: {logical}")).count(), 0, @@ -1147,3 +1399,444 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { ); } } + +#[test] +fn a_strip_too_narrow_for_its_tabs_at_their_minimum_width_lists_the_others_in_the_overflow_menu() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let area = Id::unique("whole-tabs"); + let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6], 5)), floating: vec![] }; + let mut h = Harness::builder().with_size(vec2(260.0, 240.0)).build_ui(move |ui| { + DockArea::new(area).show( + ui, + &layout, + &DockStyle::from_ui(ui), + |panel| format!("Panel {panel}"), + |_| Permissions::default(), + |_: &mut egui::Ui, _: &u32| {}, + ); + }); + h.run_steps(2); + let shown: Vec = (1..=6).filter(|p| h.ctx.read_response(area.with(("tab", p))).is_some()).collect(); + assert!((2..6).contains(&shown.len()), "{shown:?}"); + // The active last tab takes the place of the last tab that fits; the others keep their order. + let mut expected: Vec = (1..shown.len() as u32).collect(); + expected.push(6); + assert_eq!(shown, expected); + // As many tabs show as fit at the minimum width beside the drag bar and the "…" button. + let style = DockStyle::default(); + let room = 260.0 - style.drag_bar_width - 22.0; + assert!(shown.len() as f32 * style.min_tab_width <= room && (shown.len() + 1) as f32 * style.min_tab_width > room, "{shown:?}"); + h.get_by_role_and_label(Role::Button, "Panels").click(); + h.run_steps(2); + let listed: Vec = (1..=6).filter(|p| h.query_by_role_and_label(Role::Button, &format!("Panel {p}")).is_some()).collect(); + let hidden: Vec = (1..=6).filter(|p| !shown.contains(p)).collect(); + assert_eq!(listed, hidden, "the overflow menu lists exactly the hidden panels"); +} + +#[test] +fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tooltip() { + let ctx = Context::default(); + ctx.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0); + let layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: vec![] }; + let label = |p: &u32| format!("Eigenschaften und Anpassungen {p}"); + let run = |events: Vec| { + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 200.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + DockArea::new(Id::unique("truncate")).show( + ui, + &layout, + &DockStyle::from_ui(ui), + label, + |_| Permissions::default(), + |ui, _| { + ui.label("Body"); + }, + ); + }); + output.textures_delta.clear(); + output.shapes + }; + run(vec![]); + let shapes = run(vec![]); + let tab = |p: u32| ctx.read_response(Id::unique("truncate").with(("tab", &p))).expect("every tab shrinks to fit").rect; + for p in 1..=3 { + let tab = tab(p); + // The clip rect would hide an overflowing label; what is laid out must itself fit. + let painted: Vec<&egui::epaint::TextShape> = shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if tab.contains(text.visual_bounding_rect().center()) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(painted.len(), 1, "tab {p}: {painted:?}"); + assert!(painted[0].galley.elided, "tab {p}"); + assert!(tab.contains_rect(painted[0].visual_bounding_rect()), "tab {p}: label {:?} overflows {tab:?}", painted[0].visual_bounding_rect()); + } + run(vec![Event::PointerMoved(tab(2).center())]); + let shapes = run(vec![]); + let full = label(&2); + // An elided galley still reports its whole text, so the cut tab label alone would match. + assert!( + shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == full && !text.galley.elided)), + "a truncated tab names its panel in full on hover" + ); +} + +// Ported from storytold/craft-libs#110 by @Zenodeon. + +#[test] +fn resize_cancellation_retains_ownership_across_other_host_rendering() { + let ctx = Context::default(); + let area = Id::unique("test-area"); + let mut layout = sample(); + for _ in 0..3 { + step(&ctx, &mut layout, vec![]); + } + let id = area.with(("floating", &5u32)).with("resize-right"); + let start = ctx.read_response(id).unwrap().rect.center(); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(20.0, 0.0))]); + assert_eq!(ctx.dragged_id(), Some(id)); + ctx.data_mut(|data| data.insert_temp(area.with("owned-interactions"), vec![Id::unique("another-host-control")])); + cancel_drag::(&ctx, area); + assert!(ctx.dragged_id().is_none()); + let before = layout.clone(); + assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(100.0, 0.0))]).is_empty()); + assert_eq!(layout, before); +} + +#[test] +fn collapsed_floating_height_cap_does_not_disable_keyboard_width_resizing() { + let ctx = Context::default(); + let mut layout = Layout { root: None, floating: vec![Floating::single(vec![1u32], 0, [80.0, 70.0, 300.0, 26.0])] }; + let id = Id::unique("collapsed-resize").with(("floating", &1u32)).with("resize-right"); + let mut frame = |events| { + let mut actions = Vec::new(); + ctx.run_ui( + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), focused: true, events, ..Default::default() }, + |ui| { + let mut style = DockStyle::from_ui(ui); + style.min_pane = 0.0; + actions = DockArea::new(Id::unique("collapsed-resize")) + .show_with_limits( + ui, + &layout, + &style, + |p| p.to_string(), + |_| Permissions::default(), + |_| PanelLimits { min: vec2(220.0, 0.0), max: vec2(1000.0, 0.0) }, + |_, _| {}, + ) + .actions; + }, + ) + .textures_delta + .clear(); + for action in actions { + layout.apply(action).unwrap(); + } + }; + for _ in 0..3 { + frame(vec![]); + } + ctx.memory_mut(|memory| memory.request_focus(id)); + frame(vec![Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 44.0]); +} + +#[test] +fn returning_to_a_singleton_or_floating_source_body_is_an_explicit_noop() { + for panels in [vec![1u32], vec![1, 2, 3]] { + for allow_splits in [false, true] { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(300.0, 300.0)); + let target = DropTarget { + anchor: 1, + rect, + tab_strip: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 26.0))), + tabs: panels.iter().map(|id| (*id, Rect::NOTHING)).collect(), + allow_splits, + accepts_tabs: true, + right_to_left: false, + }; + let proposal = resolve_drop(&1, rect.center(), &[target], 40.0).unwrap(); + assert!(proposal.noop); + assert!(proposal.insertion.is_none()); + } + } +} + +#[test] +fn pointer_floating_resize_stops_at_edges_and_waits_for_cursor_to_return() { + for name in ["resize-right", "resize-left", "resize-bottom", "resize", "resize-bottom-left"] { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..3 { + step(&ctx, &mut layout, vec![]); + } + let id = Id::unique("test-area").with(("floating", &5u32)).with(name); + let start = ctx.read_response(id).unwrap().rect.center(); + let original = layout.floating[0].rect; + let horizontal = name != "resize-bottom"; + let left = name.contains("left"); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + let outside = if horizontal { pos2(if left { -200.0 } else { 1100.0 }, start.y) } else { pos2(start.x, 1000.0) }; + step(&ctx, &mut layout, vec![Event::PointerMoved(outside)]); + let stopped = layout.floating[0].rect; + if horizontal { + if left { + assert_eq!(stopped[0] + stopped[2], original[0] + original[2]); + } else { + assert_eq!(stopped[0], original[0]); + assert_eq!(stopped[0] + stopped[2], 800.0); + } + } else { + assert_eq!(stopped[1], original[1]); + assert_eq!(stopped[1] + stopped[3], 600.0); + } + let still_outside = if horizontal { outside + vec2(if left { 100.0 } else { -100.0 }, 0.0) } else { outside - vec2(0.0, 100.0) }; + step(&ctx, &mut layout, vec![Event::PointerMoved(still_outside)]); + assert_eq!(layout.floating[0].rect, stopped, "{name}: reversal outside the edge must not resize"); + step(&ctx, &mut layout, vec![Event::PointerMoved(start)]); + assert_eq!(layout.floating[0].rect, original, "{name}: returning to the original handle restores its geometry"); + step(&ctx, &mut layout, vec![pointer_button(start, false)]); + assert!(ctx.memory(|memory| memory.focused()).is_none(), "{name}: the grip gives up focus after a pointer drag"); + } +} + +#[test] +fn unchanged_tab_slots_are_explicit_noops_with_bounded_insertion_markers() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 200.0)); + let target = DropTarget { + anchor: 1, + rect, + tab_strip: Some(Rect::from_min_size(rect.min, vec2(300.0, 30.0))), + tabs: vec![ + (1, Rect::from_min_size(pos2(0.0, 0.0), vec2(60.0, 30.0))), + (2, Rect::from_min_size(pos2(60.0, 0.0), vec2(60.0, 30.0))), + (3, Rect::from_min_size(pos2(120.0, 0.0), vec2(60.0, 30.0))), + ], + allow_splits: true, + accepts_tabs: true, + right_to_left: false, + }; + for (panel, positions) in [(1, [10.0, 50.0]), (2, [70.0, 110.0]), (3, [130.0, 210.0])] { + for x in positions { + let proposal = resolve_drop(&panel, pos2(x, 12.0), std::slice::from_ref(&target), 40.0).unwrap(); + assert!(proposal.noop, "{panel}/{x}"); + assert!(target.tab_strip.unwrap().contains_rect(proposal.insertion.unwrap())); + } + } + for (panel, x) in [(1, 140.0), (3, 10.0), (2, 210.0)] { + let proposal = resolve_drop(&panel, pos2(x, 12.0), std::slice::from_ref(&target), 40.0).unwrap(); + assert!(!proposal.noop); + assert!(proposal.insertion.is_some()); + } +} + +#[test] +fn host_owned_noop_consumes_release_without_a_fallback_float() { + struct Content(usize); + impl DockContent for Content { + fn body(&mut self, _ui: &mut egui::Ui, _panel: &u32) {} + fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, request: &DropRequest<'_, u32>) -> Option { + if request.released { + self.0 += 1; + } + Some(DropFeedback::default()) + } + } + let ctx = Context::default(); + let area = Id::unique("host-owned"); + let layout = sample(); + let mut content = Content(0); + let from = pos2(30.0, 20.0); + let to = pos2(400.0, 300.0); + let render = |events: Vec, content: &mut Content| { + let mut output = DockOutput::default(); + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { + output = DockArea::new(area).show_customized( + ui, + &layout, + &DockStyle::from_ui(ui), + |p| p.to_string(), + |_| Permissions::default(), + |_| PanelLimits::default(), + content, + ); + }) + .textures_delta + .clear(); + output + }; + render( + vec![ + Event::PointerMoved(from), + Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ], + &mut content, + ); + assert!(begin_drag(&ctx, area, 1u32, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)))); + render(vec![Event::PointerMoved(to)], &mut content); + let output = render( + vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }], + &mut content, + ); + assert_eq!(content.0, 1); + assert!(output.actions.iter().all(|action| !matches!(action, Action::Move { .. } | Action::Float { .. }))); +} + +#[test] +fn overflow_noop_detection_uses_logical_order_including_hidden_tabs() { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(140.0, 200.0)); + let target = DropTarget { + anchor: 1, + rect, + tab_strip: Some(Rect::from_min_size(Pos2::ZERO, vec2(140.0, 30.0))), + tabs: vec![ + (1, Rect::from_min_size(Pos2::ZERO, vec2(60.0, 30.0))), + (2, Rect::NOTHING), + (3, Rect::from_min_size(pos2(60.0, 0.0), vec2(60.0, 30.0))), + ], + allow_splits: true, + accepts_tabs: true, + right_to_left: false, + }; + assert!(resolve_drop(&1, pos2(10.0, 12.0), std::slice::from_ref(&target), 40.0).unwrap().noop); + // Before the next visible tab crosses hidden tab 2 and really changes the order. + assert!(!resolve_drop(&1, pos2(65.0, 12.0), std::slice::from_ref(&target), 40.0).unwrap().noop); + assert!(resolve_drop(&3, pos2(130.0, 12.0), std::slice::from_ref(&target), 40.0).unwrap().noop); +} + +#[test] +fn insertion_markers_stay_inside_clipped_and_singleton_strips() { + for width in [0.5, 1.0, 20.0, 140.0] { + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(width, 200.0)); + let strip = Rect::from_min_size(rect.min, vec2(width, 30.0)); + for tab_left in [-100.0, 10.0, 300.0] { + let target = DropTarget { + anchor: 1, + rect, + tab_strip: Some(strip.expand(5.0)), + tabs: vec![(1, Rect::from_min_size(pos2(tab_left, 20.0), vec2(60.0, 30.0)))], + allow_splits: true, + accepts_tabs: true, + right_to_left: false, + }; + for x in [rect.left(), rect.center().x, rect.right()] { + let proposal = resolve_drop(&1, pos2(x, 25.0), std::slice::from_ref(&target), 40.0).unwrap(); + assert!(proposal.noop); + let marker = proposal.insertion.unwrap(); + assert!(rect.intersect(target.tab_strip.unwrap()).contains_rect(marker)); + assert!(marker.width() > 0.0 && marker.width() <= 2.0); + } + } + } +} + +#[test] +fn insertion_marker_handles_subpixel_strip_crossing_zero_without_panicking() { + let rect = Rect::from_min_max(pos2(-0.001, 0.0), pos2(0.002, 30.0)); + let target = + DropTarget { anchor: 1, rect, tab_strip: Some(rect), tabs: vec![(1, rect)], allow_splits: true, accepts_tabs: true, right_to_left: false }; + let proposal = resolve_drop(&1, pos2(0.0, 12.0), &[target], 40.0).unwrap(); + assert!(proposal.noop); + assert!(rect.contains_rect(proposal.insertion.unwrap())); +} + +#[test] +fn overflowing_strip_paints_a_single_ellipsis_symbol() { + use egui_kittest::Harness; + fn paints(shape: &egui::Shape, text: &str) -> bool { + match shape { + egui::Shape::Text(shape) => shape.galley.text() == text, + egui::Shape::Vec(shapes) => shapes.iter().any(|shape| paints(shape, text)), + _ => false, + } + } + let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6], 0)), floating: vec![] }; + let mut h = Harness::builder().with_size(vec2(150.0, 240.0)).build_ui(move |ui| { + DockArea::new(Id::unique("overflow-symbol")).show( + ui, + &layout, + &DockStyle::from_ui(ui), + |panel| format!("Panel {panel}"), + |_| Permissions::default(), + |_ui: &mut egui::Ui, _panel: &u32| {}, + ); + }); + h.run_steps(2); + assert!(h.output().shapes.iter().any(|shape| paints(&shape.shape, "\u{2026}"))); +} + +struct Plain; + +impl DockContent for Plain { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} +} + +/// `sample` with its docked frames swapped: every panel stays, in other places. +fn swapped() -> Layout { + let mut layout = sample(); + layout.root = Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(240.0), + first: Box::new(tabs(&[2, 3, 4], 1)), + second: Box::new(tabs(&[1], 0)), + }); + layout +} + +#[test] +fn replacing_the_layout_ends_a_drag_in_progress() { + use super::testing::DockDriver; + let area = Id::unique("replaced"); + let driver = || { + let mut d = DockDriver::new(sample(), Plain); + d.id = area; + d.settle(); + d + }; + // Over frame [1], clear of the floating window. + let over = pos2(500.0, 450.0); + for replace in [false, true] { + let mut d = driver(); + let tab = d.hit(&Hit::Tab(2)).unwrap().center(); + d.hold(tab, over); + assert!(is_dragging::(&d.ctx, area)); + if replace { + d.layout = swapped(); + } + d.move_to(over + vec2(4.0, 0.0)); + assert_eq!(is_dragging::(&d.ctx, area), !replace); + let out = d.release(over); + if replace { + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert_eq!(d.layout, swapped()); + } else { + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 2, anchor: 1, .. }]), "{:?}", out.actions); + } + } + + // A window follows its bar through its own moves, and through an app that adjusts the + // moved rectangle or a split's size, and stops when the app switches workspaces. + let mut d = driver(); + let bar = d.hit(&Hit::Title(5)).unwrap().center(); + d.hold(bar, bar + vec2(60.0, 40.0)); + d.move_to(bar + vec2(90.0, 60.0)); + assert!(is_dragging::(&d.ctx, area)); + assert_ne!(d.layout.floating[0].rect, sample().floating[0].rect); + d.layout.floating[0].rect[0] += 3.0; + if let Some(Node::Split { size, .. }) = &mut d.layout.root { + *size = SplitSize::FixedSecond(260.0); + } + d.move_to(bar + vec2(100.0, 70.0)); + assert!(is_dragging::(&d.ctx, area)); + d.layout = swapped(); + let out = d.move_to(bar + vec2(120.0, 80.0)); + assert!(!is_dragging::(&d.ctx, area)); + assert!(out.actions.is_empty(), "{:?}", out.actions); + d.release(bar + vec2(120.0, 80.0)); + assert_eq!(d.layout, swapped()); +} diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs new file mode 100644 index 00000000..df34eb6f --- /dev/null +++ b/crates/ui/src/docking/trial_tests.rs @@ -0,0 +1,470 @@ +//! Fixes from the DesignCraft trial: group float size, tab label elision, the drag ghost and +//! grip, double-clicks on minimized groups, pointer focus, label keys and borrowed lookups; then +//! split permissions, body focus, right-to-left strips, close controls, menu and button names, +//! zone mapping, closed panels from `apply`, group heights and flyout events. + +use super::*; + +/// Every shape, with nested `Shape::Vec`s flattened. +fn flat(shapes: &[egui::epaint::ClippedShape]) -> Vec<(Rect, egui::Shape)> { + fn walk(clip: Rect, shape: &egui::Shape, out: &mut Vec<(Rect, egui::Shape)>) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(clip, s, out)), + shape => out.push((clip, shape.clone())), + } + } + let mut out = Vec::new(); + for clipped in shapes { + walk(clipped.clip_rect, &clipped.shape, &mut out); + } + out +} + +fn texts(d: &Driver) -> Vec<(Rect, egui::epaint::TextShape)> { + flat(&d.shapes) + .into_iter() + .filter_map(|(clip, shape)| match shape { + egui::Shape::Text(text) => Some((clip, text)), + _ => None, + }) + .collect() +} + +fn rects(d: &Driver) -> Vec { + flat(&d.shapes) + .into_iter() + .filter_map(|(_, shape)| match shape { + egui::Shape::Rect(rect) => Some(rect), + _ => None, + }) + .collect() +} + +fn last_char(galley: &egui::Galley) -> Option { + galley.rows.last().and_then(|row| row.glyphs.last()).map(|glyph| glyph.chr) +} + +#[test] +fn a_full_height_docked_group_floats_at_the_host_or_capped_height_inside_the_window() { + for size in [None, Some(vec2(200.0, 150.0))] { + 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(600.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 bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), pos2(420.0, 300.0)); + let Some(Target::Float { rect: Some([x, y, width, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; + match size { + Some(size) => assert_eq!(*height, size.y), + None => assert_eq!(*height, 320.0), + } + assert!(*width > 100.0 && *width <= 240.0, "{width}"); + let window = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + assert!(window.contains_rect(Rect::from_min_size(pos2(*x, *y), vec2(*width, *height))), "{x} {y} {width} {height}"); + } +} + +/// Labels as a host's pre-laid-out galleys, which egui does not wrap again. +struct GalleyLabels; + +impl DockContent for GalleyLabels { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} + fn logical_label(&self, panel: &u32) -> String { + format!("Character Styles {panel}") + } + fn label(&mut self, ui: &egui::Ui, _: &u32, logical: &str, font: &egui::FontId, color: Color32) -> egui::WidgetText { + egui::WidgetText::Galley(ui.painter().layout_no_wrap(logical.to_owned(), font.clone(), color)) + } +} + +#[test] +fn galley_labels_in_shrunk_tabs_end_with_an_ellipsis_inside_the_tab_active_or_not() { + let panels = [1, 2, 3, 4, 5, 6]; + let layout = Layout { root: Some(tabs(&panels, 3)), floating: Vec::new() }; + for width in [320.0, 90.0] { + let ctx = Context::default(); + let mut content = GalleyLabels; + let mut shapes = Vec::new(); + for _ in 0..2 { + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }; + let mut full = ctx.run_ui(input, |ui| { + let style = DockStyle::from_ui(ui); + ui.scope_builder(UiBuilder::new().max_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 400.0))), |ui| { + DockArea::new(Id::unique(AREA)).show_content(ui, &layout, &style, &mut content); + }); + }); + full.textures_delta.clear(); + shapes = full.shapes; + } + let texts: Vec<_> = flat(&shapes) + .into_iter() + .filter_map(|(clip, shape)| match shape { + egui::Shape::Text(text) => Some((clip, text)), + _ => None, + }) + .collect(); + let shown: Vec = panels.into_iter().filter(|p| ctx.read_response(Id::unique(AREA).with(("tab", p))).is_some()).collect(); + assert!(shown.contains(&4) && shown.len() < panels.len(), "{width}: {shown:?}"); + for panel in shown { + let tab = ctx.read_response(Id::unique(AREA).with(("tab", &panel))).unwrap().rect; + let Some((clip, text)) = texts.iter().find(|(_, t)| t.galley.text().ends_with(&format!(" {panel}")) && tab.contains(t.pos)) else { + panic!("no label in tab {panel}") + }; + assert_eq!(last_char(&text.galley), Some('…'), "{width}: tab {panel}"); + let drawn = Rect::from_min_size(text.pos, text.galley.size()); + assert!(drawn.right() <= tab.right() + 0.5 && drawn.left() >= tab.left(), "tab {panel}: {drawn:?} in {tab:?}"); + assert!(tab.contains_rect(clip.intersect(drawn)), "tab {panel}"); + } + } +} + +#[test] +fn drag_bar_draws_no_mark_at_rest_and_a_group_drag_paints_a_filled_labeled_ghost() { + let mut d = Driver::new(frames()); + let visuals = d.ctx.global_style().visuals.clone(); + let bar = d.rect("drag-bar", 2); + let mark = flat(&d.shapes).into_iter().any(|(_, shape)| match shape { + egui::Shape::LineSegment { points, .. } => bar.contains(points[0]) && bar.contains(points[1]), + _ => false, + }); + assert!(!mark, "a mark in the drag bar at rest"); + + d.hold(bar.center(), pos2(150.0, 300.0)); + let fill = visuals.selection.stroke.color.gamma_multiply(0.15); + let Some(ghost) = rects(&d).into_iter().find(|r| r.fill == fill && r.stroke.width == 2.0 && r.rect.contains(pos2(150.0, 300.0))) else { + panic!("no filled ghost under the pointer") + }; + for label in ["Panel 2", "Panel 3"] { + assert!(texts(&d).iter().any(|(_, t)| t.galley.text() == label && ghost.rect.contains(t.pos)), "{label} missing from the ghost"); + } + d.step(vec![key(egui::Key::Escape)]); +} + +#[test] +fn double_click_on_a_tab_of_a_minimized_group_ends_expanded() { + 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 active = d.tab(6).center(); + d.click(active); + d.click(active); + assert!(minimized(&d)); + d.wait(); + + let other = d.tab(5).center(); + d.click(other); + d.click(other); + assert!(!minimized(&d)); + assert_eq!(d.layout.floating[0].groups()[0].active_panel(), Some(&5)); +} + +#[test] +fn double_click_on_the_active_tab_of_a_minimized_group_or_window_ends_expanded() { + let window = Floating { minimized: true, ..Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0]) }; + let mut d = Driver::new(Layout { root: Some(tabs(&[1], 0)), floating: vec![window] }); + let active = d.tab(6).center(); + d.click(active); + d.click(active); + assert!(!d.layout.floating[0].minimized); + + let mut layout = docked(); + let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; + let group = Group { minimized: true, ..Group::new(vec![2, 3], 1) }; + **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group], 240.0)] }; + let mut d = Driver::new(layout); + let minimized = |d: &Driver| { + let Some(Node::Split { first, .. }) = &d.layout.root else { panic!() }; + let Node::Dock { columns, .. } = &**first else { panic!() }; + columns[0].groups[0].minimized + }; + let active = d.tab(3).center(); + d.click(active); + d.click(active); + assert!(!minimized(&d)); + d.wait(); + d.click(active); + d.click(active); + assert!(minimized(&d)); +} + +#[test] +fn a_clicked_tab_shows_no_focus_outline_but_tab_key_focus_does() { + let mut d = Driver::new(frames()); + let accent = d.ctx.global_style().visuals.selection.stroke.color; + let id = Id::unique(AREA).with(("tab", &3u32)); + let outline = d.tab(3).shrink(2.0); + let outlined = |d: &Driver| rects(d).iter().any(|r| r.rect == outline && r.stroke.width > 0.0 && r.stroke.color == accent); + + d.click(d.tab(3).center()); + // A platform may focus the clicked widget, as an AccessKit focus request does. + d.ctx.memory_mut(|m| m.request_focus(id)); + d.step(vec![]); + assert_eq!(d.ctx.memory(|m| m.focused()), Some(id)); + assert!(!outlined(&d)); + + d.ctx.memory_mut(|m| m.surrender_focus(id)); + for _ in 0..20 { + if d.ctx.memory(|m| m.focused()) == Some(id) { + break; + } + d.step(vec![key(egui::Key::Tab)]); + } + assert_eq!(d.ctx.memory(|m| m.focused()), Some(id)); + d.step(vec![]); + assert!(outlined(&d)); +} + +#[test] +fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() { + let mut debug = String::new(); + let mut full = Context::default().run_ui(RawInput::default(), |ui| { + debug = format!("{:?}", DockStyle::from_ui(ui).localized(|key| Some(key.to_owned()))); + }); + full.textures_delta.clear(); + let values: Vec<&str> = debug.split("_label: \"").skip(1).filter_map(|rest| rest.split('"').next()).collect(); + assert_eq!(values.len(), 24, "{debug}"); + assert!(values.iter().all(|v| v.starts_with("dock.")), "a label without a key: {values:?}"); + let unique: std::collections::HashSet<_> = values.iter().collect(); + assert_eq!(unique.len(), values.len()); + + let window = Floating::single(vec!["swatches".to_owned()], 0, [0.0, 0.0, 200.0, 200.0]); + assert!(window.contains("swatches")); + assert!(!window.contains("layers")); +} + +const AREA_600: Rect = Rect { min: pos2(0.0, 0.0), max: pos2(600.0, 600.0) }; + +#[test] +fn a_protected_canvas_takes_no_side_split_and_a_group_dropped_on_its_edge_floats() { + let protected = |p: &u32| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() }; + let split = Action::Move { panel: 4, anchor: 0, placement: Placement::Split(Zone::Left) }; + let mut layout = docked(); + assert_eq!(layout.apply_with_permissions(split.clone(), protected), Err(DockError::NotAllowed)); + let opt_in = |p: &u32| if *p == 0 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }; + assert!(layout.apply_with_permissions(split, opt_in).is_ok()); + + let mut d = Driver::with(docked(), TestContent { protected: vec![0], ..Default::default() }, AREA_600); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), pos2(300.0, 300.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); +} + +#[test] +fn a_press_in_a_stripless_panel_reports_it_focused() { + let mut d = Driver::with(docked(), TestContent { protected: vec![0], ..Default::default() }, AREA_600); + for pressed in [PointerButton::Primary, PointerButton::Secondary, PointerButton::Middle] { + let at = pos2(420.0, 300.0); + let press = Event::PointerButton { pos: at, button: pressed, pressed: true, modifiers: Modifiers::NONE }; + let out = d.step(vec![Event::PointerMoved(at), press]); + assert_eq!(out.focused, Some(0), "{pressed:?}"); + d.step(vec![Event::PointerButton { pos: at, button: pressed, pressed: false, modifiers: Modifiers::NONE }]); + d.step(vec![key(egui::Key::Escape)]); + } +} + +#[test] +fn right_to_left_mirrors_the_tab_strip_and_its_insertion_slots() { + let mut d = Driver::with(frames(), TestContent { header: 30.0, ..Default::default() }, AREA_600); + d.tweak = |style| style.right_to_left = true; + d.step(vec![]); + d.step(vec![]); + let (first, second, bar) = (d.tab(2), d.tab(3), d.rect("drag-bar", 2)); + assert!((first.right() - 600.0).abs() < 1.0, "the first tab starts at the right edge: {first:?}"); + assert!(second.right() <= first.left() + 0.5, "{first:?} {second:?}"); + assert!(bar.right() <= second.left() + 0.5 && bar.left() >= 304.0 + 30.0 - 0.5, "the bar sits between the tabs and the header: {bar:?}"); + + // Just right of the first tab's center is before it in a right-to-left strip. + let out = d.drag(d.tab(1).center(), pos2(first.center().x + 6.0, first.center().y)); + assert!(out.actions.contains(&Action::Move { panel: 1, anchor: 3, placement: Placement::Tab { before: Some(2) } }), "{:?}", out.actions); + + let strip = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let target = DropTarget { + anchor: 1, + rect: Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 80.0)), + tab_strip: Some(strip), + tabs: vec![(1, Rect::from_min_max(pos2(60.0, 0.0), pos2(100.0, 20.0))), (2, Rect::from_min_max(pos2(20.0, 0.0), pos2(60.0, 20.0)))], + allow_splits: true, + accepts_tabs: true, + right_to_left: true, + }; + let drop = |x: f32| resolve_drop(&9, pos2(x, 10.0), std::slice::from_ref(&target), 40.0).unwrap(); + assert_eq!((drop(90.0).placement, drop(90.0).insertion.unwrap().center().x), (Placement::Tab { before: Some(1) }, 99.0)); + assert_eq!((drop(70.0).placement, drop(70.0).insertion.unwrap().center().x), (Placement::Tab { before: Some(2) }, 60.0)); + assert_eq!((drop(10.0).placement, drop(10.0).insertion.unwrap().center().x), (Placement::Tab { before: None }, 20.0)); +} + +#[test] +fn a_right_to_left_title_bar_puts_its_buttons_left_and_cuts_a_galley_title() { + let ctx = Context::default(); + let mut shapes = Vec::new(); + let mut rects = (Rect::NOTHING, Rect::NOTHING, Rect::NOTHING); + let bar = Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 18.0)); + for _ in 0..2 { + let mut full = ctx.run_ui(RawInput::default(), |ui| { + let mut style = DockStyle::from_ui(ui); + style.right_to_left = true; + let chrome = chrome::ChromeStyle::from_dock(&style); + let title = egui::WidgetText::Galley(ui.painter().layout_no_wrap("Paragraph Styles and More".into(), style.font.clone(), style.text)); + let shown = chrome::TitleBar::new(Id::unique("title"), "Move").title(title).menu("Menu").close("Close").show(ui, bar, &chrome); + rects = (shown.bar.rect, shown.menu.unwrap().rect, shown.close.unwrap().rect); + }); + full.textures_delta.clear(); + shapes = full.shapes; + } + let (grip, menu, close) = rects; + assert!(close.center().x < menu.center().x && menu.right() <= grip.left() + 0.5, "{close:?} {menu:?} {grip:?}"); + assert!((grip.right() - bar.right()).abs() < 0.5); + let title = flat(&shapes) + .into_iter() + .find_map(|(_, shape)| match shape { + egui::Shape::Text(text) if text.galley.text().starts_with("Para") => Some(text), + _ => None, + }) + .unwrap(); + assert_eq!(last_char(&title.galley), Some('…')); + assert!(title.pos.x + title.galley.size().x <= grip.right() + 0.5 && title.pos.x >= grip.left()); +} + +#[test] +fn hidden_close_controls_leave_closing_to_the_host() { + let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: vec![Floating::single(vec![5, 6], 0, [80.0, 70.0, 300.0, 220.0])] }; + let close = Id::unique(AREA).with(("move-group", Some(&5u32))).with("close"); + let mut d = Driver::new(layout); + assert!(d.ctx.read_response(close).is_some()); + d.tweak = |style| { + style.close_button = false; + style.close_menu_items = false; + }; + d.step(vec![]); + d.step(vec![]); + assert!(d.ctx.read_response(close).is_none()); + let at = d.tab(1).center(); + let secondary = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }; + d.step(vec![Event::PointerMoved(at), secondary(true)]); + d.step(vec![secondary(false)]); + d.step(vec![]); + let painted = |d: &Driver, label: &str| texts(d).iter().any(|(_, t)| t.galley.text() == label); + assert!(painted(&d, "Float Panel"), "the tab menu is open"); + assert!(!painted(&d, "Close Panel") && !painted(&d, "Close Tab Group")); + // The panels stay closable: the host's own Close applies and reports what it closed. + assert_eq!(d.layout.apply_with_permissions(Action::CloseWindow { panel: 5 }, |p| d.content.permissions(p)), Ok(vec![5, 6])); +} + +#[test] +fn apply_all_returns_every_panel_its_actions_closed() { + let mut layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: vec![Floating::single(vec![5, 6], 0, [80.0, 70.0, 300.0, 220.0])] }; + let closed = layout + .apply_all([Action::Activate { panel: 2 }, Action::CloseOthers { panel: 2 }, Action::CloseWindow { panel: 6 }], |_| Permissions::default()); + assert_eq!(closed, Ok(vec![1, 3, 5, 6])); + assert_eq!(layout.apply(Action::Close { panel: 2 }), Ok(vec![2])); +} + +#[test] +fn the_column_button_and_the_column_menu_item_have_distinct_names() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, content: &mut TestContent| { + DockArea::new(Id::unique(AREA)).show_content(ui, &docked(), &DockStyle::from_ui(ui), content); + }, + TestContent { protected: vec![0], ..Default::default() }, + ); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Collapse column to icons").count(), 1); + assert_eq!(h.query_all_by_label("Collapse to Icons").count(), 0); + let at = h.ctx.read_response(Id::unique(AREA).with(("column-bar", Some(&2u32)))).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); + assert_eq!(h.query_all_by_label("Collapse to Icons").count(), 1); + assert_eq!(h.query_all_by_label("Collapse column to icons").count(), 1); +} + +#[test] +fn for_zone_maps_side_zones_onto_columns_groups_and_splits() { + let layout = Layout { root: docked().root, floating: vec![Floating::single(vec![5], 0, [300.0, 80.0, 200.0, 200.0])] }; + let zone = |anchor, zone, rtl| Placement::for_zone(&layout, &anchor, zone, rtl); + assert_eq!(zone(2, Zone::Right, false), Ok(Placement::Column { after: true })); + assert_eq!(zone(2, Zone::Right, true), Ok(Placement::Column { after: false })); + assert_eq!(zone(4, Zone::Bottom, false), Ok(Placement::Group { below: true, height: None })); + assert_eq!(zone(2, Zone::Center, false), Ok(Placement::Split(Zone::Center))); + assert_eq!(zone(5, Zone::Top, false), Ok(Placement::Split(Zone::Top))); + assert_eq!(zone(5, Zone::Left, false), Ok(Placement::Split(Zone::Left))); + assert_eq!(zone(0, Zone::Left, true), Ok(Placement::Split(Zone::Left))); + assert_eq!(zone(9, Zone::Left, false), Err(DockError::MissingTarget)); + + // An end dock mirrors a start dock. + let mut end = docked(); + let Some(Node::Split { first, second, size, .. }) = &mut end.root else { panic!() }; + std::mem::swap(first, second); + *size = SplitSize::FixedSecond(240.0); + let Node::Dock { side, .. } = &mut **second else { panic!() }; + *side = Side::End; + assert_eq!(Placement::for_zone(&end, &2, Zone::Left, false), Ok(Placement::Column { after: true })); + assert_eq!(Placement::for_zone(&end, &2, Zone::Left, true), Ok(Placement::Column { after: false })); +} + +#[test] +fn a_group_placement_gives_the_inserted_group_its_height() { + let mut layout = docked(); + let below = |height| Placement::Group { below: true, height }; + layout.apply(Action::OpenAt { panel: 7, anchor: 4, placement: below(Some(180.0)) }).unwrap(); + layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 7, placement: below(Some(90.0)) } }).unwrap(); + layout.apply(Action::OpenAt { panel: 8, anchor: 4, placement: below(None) }).unwrap(); + let Some(Node::Split { first, .. }) = &layout.root else { panic!() }; + let Node::Dock { columns, .. } = &**first else { panic!() }; + let heights: Vec<_> = columns[0].groups.iter().map(|g| (g.panels.clone(), g.height)).collect(); + assert_eq!(heights, vec![(vec![4], None), (vec![8], None), (vec![7], Some(180.0)), (vec![2, 3], Some(90.0))]); + assert_eq!(layout.apply(Action::OpenAt { panel: 9, anchor: 4, placement: below(Some(f32::NAN)) }), Err(DockError::InvalidGeometry)); +} + +struct Icons; + +impl DockContent for Icons { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} +} + +#[test] +fn every_flyout_change_reports_one_event_whatever_caused_it() { + use crate::docking::testing::DockDriver; + let mut column = Column::new(vec![Group::new(vec![1, 2], 0), Group::new(vec![3], 0)], 240.0); + column.iconic = true; + let dock = Node::Dock { side: Side::Start, columns: vec![column] }; + let canvas = Node::Tabs { panels: vec![0], 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::new(), + }; + let mut d = DockDriver::new(layout, Icons); + let flyouts = |outputs: Vec>| -> Vec> { + outputs + .into_iter() + .flat_map(|out| out.events) + .filter_map(|event| match event { + DockEvent::FlyoutChanged { panel } => Some(panel), + _ => None, + }) + .collect() + }; + let icon = |d: &DockDriver, panel| d.hit(&Hit::Icon(panel)).unwrap().center(); + let quiet = |d: &mut DockDriver| (0..3).map(|_| d.step(Vec::new())).collect::>(); + + let (one, two) = (icon(&d, 1), icon(&d, 2)); + assert_eq!(flyouts(vec![d.press(one), d.release(one)]), [Some(1)], "an icon opens it"); + assert_eq!(flyouts(quiet(&mut d)), [], "an open flyout reports nothing more"); + assert_eq!(flyouts(vec![d.press(two), d.release(two)]), [Some(2)], "another icon switches it"); + assert_eq!(flyouts(vec![d.key(egui::Key::Escape)]), [None], "Escape closes it"); + open_flyout(&d.ctx, d.id, 3u32); + assert_eq!(flyouts(vec![d.step(Vec::new())]), [Some(3)], "open_flyout"); + close_flyout::(&d.ctx, d.id); + assert_eq!(flyouts(vec![d.step(Vec::new())]), [None], "close_flyout"); + d.raise = Some(1); + assert_eq!(flyouts(vec![d.step(Vec::new())]), [Some(1)], "raising an iconic panel opens it"); + let outside = pos2(600.0, 400.0); + assert_eq!(flyouts(vec![d.press(outside), d.release(outside)]), [None], "a press outside closes it"); + assert_eq!(flyouts(quiet(&mut d)), []); +} diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs new file mode 100644 index 00000000..183af38d --- /dev/null +++ b/crates/ui/src/docking/workspace.rs @@ -0,0 +1,318 @@ +//! A saved arrangement: the current layout, where hidden panels go back, the user's saved +//! arrangement for Reset, and Lock Workspace. +//! +//! Storage, file names and size caps stay with the application. A file that does not parse or +//! validate, or comes from a newer version, should be kept on disk and not overwritten. + +use std::{collections::HashSet, hash::Hash}; + +use super::model::{Action, DockError, Layout, Location, MAX_PANELS, Permissions, Placement, Zone}; + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default, bound(deserialize = "P: serde::Deserialize<'de>")))] +pub struct Workspace

{ + /// Format version; files without one are v1 (`{layout, hidden}`). Always written as + /// [`Workspace::VERSION`]. + #[cfg_attr(feature = "serde", serde(default = "first_version", serialize_with = "write_version"))] + pub version: u32, + /// The current arrangement, changes included. + pub layout: Layout

, + /// Closed panels, and panels the application does not register now, with where each one + /// goes back. + pub hidden: Vec<(P, Location

)>, + /// A user workspace's saved arrangement; Reset returns to it. Built-in workspaces use the + /// application's default instead. + pub saved: Option>, + /// Lock Workspace, saved with this workspace: [`Self::apply`] uses + /// [`Permissions::locked`], so moving, floating and closing are rejected while activating, + /// opening, minimizing, resizing and recording native windows still work. Draw it with + /// [`super::DockArea::locked`]. An application with a global lock preference instead + /// passes locked permissions itself. + pub locked: bool, +} + +#[cfg(feature = "serde")] +fn first_version() -> u32 { + 1 +} + +#[cfg(feature = "serde")] +fn write_version(_: &u32, serializer: S) -> Result { + serializer.serialize_u32(Workspace::<()>::VERSION) +} + +impl

Default for Workspace

{ + fn default() -> Self { + Self::new(Layout::default()) + } +} + +impl

Workspace

{ + pub const VERSION: u32 = 2; + + pub fn new(layout: Layout

) -> Self { + Self { version: Self::VERSION, layout, hidden: Vec::new(), saved: None, locked: false } + } +} + +impl Workspace

{ + /// Check the version, both layouts and that every hidden panel is hidden once and absent + /// from the current layout. + pub fn validate(&self) -> Result<(), DockError> { + if self.version > Self::VERSION { + return Err(DockError::UnsupportedVersion); + } + self.layout.validate()?; + if let Some(saved) = &self.saved { + saved.validate()?; + } + if self.hidden.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + let visible: HashSet<&P> = self.layout.panels().into_iter().collect(); + let mut seen = HashSet::new(); + for (panel, _) in &self.hidden { + if visible.contains(panel) || !seen.insert(panel) { + return Err(DockError::DuplicatePanel); + } + } + Ok(()) + } + + /// Apply one action. Closed panels record where they go back, so each one can be reopened + /// with [`Self::set_visible`]. Honors [`Self::locked`]. Returns the panels the action + /// closed, in group order, so the application can release their state. + pub fn apply(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { + self.apply_all([action], permissions) + } + + /// Apply every action in order, or none. Returns the panels the actions closed, in order. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { + self.validate()?; + let locked = self.locked; + let policy = |panel: &P| if locked { permissions(panel).locked() } else { permissions(panel) }; + let mut proposed = self.clone(); + let mut all_closed = Vec::new(); + for action in actions { + let closed = proposed.layout.closed_by(&action)?; + let locations = close_locations(&proposed.layout, &closed); + // SetNative records where the window system has a floating window, so a lock + // takes it too. + if matches!(action, Action::SetNative { .. }) { + proposed.layout.apply_with_permissions(action, &permissions)?; + } else { + proposed.layout.apply_with_permissions(action, policy)?; + } + for (panel, location) in locations { + proposed.hidden.retain(|(p, _)| p != &panel); + proposed.hidden.push((panel, location)); + } + all_closed.extend(closed); + } + let visible: HashSet

= proposed.layout.panels().into_iter().cloned().collect(); + proposed.hidden.retain(|(p, _)| !visible.contains(p)); + let mut seen = HashSet::new(); + all_closed.retain(|p| !visible.contains(p) && seen.insert(p.clone())); + proposed.validate()?; + *self = proposed; + Ok(all_closed) + } + + /// Show or hide one panel. Showing restores its recorded location, following the chain of + /// hidden anchors when its own anchor is hidden too; otherwise it opens as + /// [`Action::Open`] with `fallback` as the anchor, following `permissions`. The hidden + /// panels on that chain reopen beside it later, so a group or window closed one panel at a + /// time comes back as one. + /// A shown panel becomes the active tab of its group. Hiding follows `permissions` and + /// [`Self::locked`], as [`Self::apply`] does, so a protected canvas stays shown. + pub fn set_visible(&mut self, panel: P, visible: bool, fallback: Option

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + self.validate()?; + if !visible { + if !self.layout.contains(&panel) { + return Ok(()); + } + return self.apply(Action::Close { panel }, permissions).map(drop); + } + if self.layout.contains(&panel) { + return self.apply(Action::Activate { panel }, |_| Permissions::default()).map(drop); + } + let mut layout = self.layout.clone(); + if self.restore_from_hidden(&mut layout, &panel).is_err() { + layout = self.layout.clone(); + layout.apply_with_permissions(Action::Open { panel: panel.clone(), anchor: fallback }, &permissions)?; + } + layout.apply(Action::Activate { panel: panel.clone() })?; + self.layout = layout; + self.hidden.retain(|(p, _)| p != &panel); + Ok(()) + } + + /// Restore `panel` where the chain of hidden anchors from its location ends, else at the + /// nearest location before that end that restores. The hidden panels it passed then anchor + /// on the panel before them in the chain, on the recorded side, so reopening them joins it + /// instead of rebuilding their group or window a second time. + fn restore_from_hidden(&mut self, layout: &mut Layout

, panel: &P) -> Result<(), DockError> { + let find = |p: &P| self.hidden.iter().find(|(q, _)| q == p).map(|(_, location)| location); + let mut chain = vec![(panel.clone(), find(panel).ok_or(DockError::MissingTarget)?.clone())]; + while let Some(anchor) = chain.last().and_then(|(_, location)| location.anchor.as_ref()) + && !chain.iter().any(|(p, _)| p == anchor) + && let Some(location) = find(anchor) + { + chain.push((anchor.clone(), location.clone())); + } + // A floating location whose anchor is hidden would open a window of its own, even when + // the chain leads to an open one. A neighbour's location brings the panel's own group + // and column state (height, width, icon-only), not the neighbour's. + let own = chain.first().and_then(|(_, location)| location.group.clone()); + let used = (0..chain.len()) + .rev() + .find_map(|i| { + let location = Location { group: own.clone().or_else(|| chain[i].1.group.clone()), ..chain[i].1.clone() }; + match layout.restore(panel.clone(), &location) { + Err(DockError::MissingTarget) => None, + result => Some(result.map(|()| i)), + } + }) + .unwrap_or(Err(DockError::MissingTarget))?; + // Past an outer-edge frame the relation can't be reversed: the rest of the chain keeps + // its forward anchors, so no two hidden panels anchor on each other. + for i in 1..=used { + let (before, location) = &chain[i - 1]; + let Some((_, own)) = self.hidden.iter_mut().find(|(p, _)| p == &chain[i].0) else { continue }; + let Some(location) = reversed(own, location, before) else { break }; + *own = location; + } + Ok(()) + } + + /// Fit a loaded workspace to the panels the application registers now. An invalid + /// workspace is replaced by `default`. Panels not registered now (plug-ins, on-demand or + /// numbered panels) move to `hidden` with their location, so they come back once registered; + /// the saved arrangement drops them. Registered panels in `default` that are neither shown + /// nor hidden go to their default place, or to `hidden` when that place cannot be rebuilt. + /// A registered panel that `permissions` says cannot close, such as a protected canvas, is + /// always shown: at its recorded or default place, else at the outer edge of the docked + /// area. Pass the panels' own permissions, not [`Permissions::locked`] ones. + /// `hidden` keeps at most [`MAX_PANELS`] entries, dropping the oldest unregistered ones first. + pub fn reconcile(&mut self, registered: impl Fn(&P) -> bool, permissions: impl Fn(&P) -> Permissions, default: &Workspace

) { + if self.validate().is_err() { + *self = Workspace { locked: self.locked, ..default.clone() }; + } + let unknown: Vec

= self.layout.panels().into_iter().filter(|p| !registered(p)).cloned().collect(); + let locations = close_locations(&self.layout, &unknown); + for panel in &unknown { + self.layout.take(panel); + } + self.hidden.extend(locations); + if let Some(saved) = self.saved.as_mut() { + let unknown: Vec

= saved.panels().into_iter().filter(|p| !registered(p)).cloned().collect(); + for panel in unknown { + saved.take(&panel); + } + if saved.validate().is_err() { + self.saved = None; + } + } + let known: HashSet

= self.layout.panels().into_iter().chain(self.hidden.iter().map(|(p, _)| p)).cloned().collect(); + let mut missing: Vec<(P, Location

)> = Vec::new(); + for panel in default.layout.panels() { + if registered(panel) + && !known.contains(panel) + && let Ok(location) = default.layout.location(panel) + { + missing.push((panel.clone(), location)); + } + } + // Anchors may themselves be missing: place what fits, then retry the rest. + while !missing.is_empty() { + let before = missing.len(); + missing.retain(|(panel, location)| self.layout.restore(panel.clone(), location).is_err()); + if missing.len() == before { + break; + } + } + self.hidden.extend(missing); + for (panel, location) in &default.hidden { + if registered(panel) && !known.contains(panel) && !self.layout.contains(panel) && !self.hidden.iter().any(|(p, _)| p == panel) { + self.hidden.push((panel.clone(), location.clone())); + } + } + let stuck: Vec

= self.hidden.iter().map(|(p, _)| p).filter(|p| registered(p) && !permissions(p).closable).cloned().collect(); + for panel in stuck { + let mut layout = self.layout.clone(); + let shown = self.restore_from_hidden(&mut layout, &panel).is_ok() + || default.layout.location(&panel).is_ok_and(|location| layout.restore(panel.clone(), &location).is_ok()) + || layout.open_at_edge(panel.clone()).is_ok(); + if shown { + self.layout = layout; + self.hidden.retain(|(p, _)| p != &panel); + } + } + let mut excess = self.hidden.len().saturating_sub(MAX_PANELS); + self.hidden.retain(|(p, _)| { + let drop = excess > 0 && !registered(p); + excess -= usize::from(drop); + !drop + }); + self.hidden.truncate(MAX_PANELS); + } + + /// Reset: return to the saved arrangement, or `default` when there is none. Panels the + /// reset layout does not show stay hidden with their last location. + pub fn reset(&mut self, default: &Layout

) { + let layout = self.saved.clone().unwrap_or_else(|| default.clone()); + let shown: HashSet

= layout.panels().into_iter().cloned().collect(); + for panel in self.layout.panels() { + if !shown.contains(panel) + && !self.hidden.iter().any(|(p, _)| p == panel) + && let Ok(location) = self.layout.location(panel) + { + self.hidden.push((panel.clone(), location)); + } + } + self.hidden.retain(|(p, _)| !shown.contains(p)); + self.layout = layout; + } +} + +/// Where each of `closed` goes back, captured as if they closed last to first. Reopening them +/// first to last rebuilds the group: the first restores the group itself, the rest join it. +fn close_locations(layout: &Layout

, closed: &[P]) -> Vec<(P, Location

)> { + if let [panel] = closed { + return layout.location(panel).map(|location| vec![(panel.clone(), location)]).unwrap_or_default(); + } + let mut scratch = layout.clone(); + let mut locations = Vec::new(); + for panel in closed.iter().rev() { + if let Ok(location) = scratch.location(panel) { + locations.push((panel.clone(), location)); + } + scratch.take(panel); + } + locations.reverse(); + locations +} + +/// The location of a hidden panel beside `panel`, whose `location` anchors on it: the same +/// relation from the other side. `None` for an outer-edge frame, which only sat beside it. +fn reversed(hidden: &Location

, location: &Location

, panel: &P) -> Option> { + let next = match &hidden.placement { + Placement::Tab { before } => before.clone(), + _ => None, + }; + let (placement, size) = match &location.placement { + // `before` names the anchor only when `panel` came first and the anchor second; the + // anchor's own `before` then still names the tab after it. + Placement::Tab { before } if before == &location.anchor => (Placement::Tab { before: next }, None), + Placement::Tab { .. } | Placement::Split(Zone::Center) => (Placement::Tab { before: Some(panel.clone()) }, None), + Placement::Split(Zone::Left) => (Placement::Split(Zone::Right), location.size), + Placement::Split(Zone::Right) => (Placement::Split(Zone::Left), location.size), + Placement::Split(Zone::Top) => (Placement::Split(Zone::Bottom), location.size), + Placement::Split(Zone::Bottom) => (Placement::Split(Zone::Top), location.size), + Placement::Group { below, .. } => (Placement::Group { below: !below, height: None }, None), + Placement::Column { after } => (Placement::Column { after: !after }, None), + Placement::Edge(_) => return None, + }; + Some(Location { floating: None, anchor: Some(panel.clone()), placement, size, sibling: Vec::new(), ..hidden.clone() }) +} diff --git a/crates/ui/src/focus.rs b/crates/ui/src/focus.rs new file mode 100644 index 00000000..03d5b7ac --- /dev/null +++ b/crates/ui/src/focus.rs @@ -0,0 +1,64 @@ +//! Window focus as pointer gestures read it. +//! +//! A drag or resize ends when its window loses focus, not while the window merely reports no +//! focus. egui-winit starts `RawInput::focused` false and only a winit `Focused` event sets it +//! (on macOS to `has_focus()` at the time the event is read), so a window can take pointer +//! input for a long time while egui reports it unfocused, at first launch until the user +//! switches to another application and back. Gestures in such a window still run. + +use egui::{Context, Id}; + +#[derive(Clone, Copy)] +struct Seen { + pass: u64, + before: bool, + now: bool, +} + +/// Whether `now` is false and was true on the previous pass, as recorded under `key`. With no +/// record from the previous pass there is no transition, so a gesture asks on every pass it +/// runs. Asking again in the same pass gives the same answer. +pub(crate) fn turned_false(ctx: &Context, key: Id, now: bool) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + let seen = match d.get_temp::(key) { + Some(seen) if seen.pass == pass => seen, + Some(seen) if seen.pass + 1 == pass => Seen { pass, before: seen.now, now }, + _ => Seen { pass, before: now, now }, + }; + d.insert_temp(key, seen); + seen.before && !seen.now + }) +} + +/// Whether the window being drawn lost focus this pass: focused on the previous pass, not now. +pub(crate) fn lost(ctx: &Context) -> bool { + let focused = ctx.input(|i| i.focused); + turned_false(ctx, Id::unique("craft-ui-window-focus").with(ctx.viewport_id()), focused) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::RawInput; + + fn pass(ctx: &Context, focused: bool) -> bool { + let mut lost_now = false; + let mut out = ctx.run_ui(RawInput { focused, ..Default::default() }, |ui| { + lost_now = lost(ui.ctx()); + assert_eq!(lost(ui.ctx()), lost_now, "asking twice in a pass"); + }); + out.textures_delta.clear(); + lost_now + } + + #[test] + fn only_a_focused_to_unfocused_pass_is_a_loss() { + let ctx = Context::default(); + assert!(!pass(&ctx, false), "a window that never had focus has not lost it"); + assert!(!pass(&ctx, false)); + assert!(!pass(&ctx, true)); + assert!(pass(&ctx, false)); + assert!(!pass(&ctx, false), "the loss is one pass"); + } +} diff --git a/crates/ui/src/layout/mod.rs b/crates/ui/src/layout/mod.rs index 335dd072..a6ffed84 100644 --- a/crates/ui/src/layout/mod.rs +++ b/crates/ui/src/layout/mod.rs @@ -9,6 +9,7 @@ mod resize; mod toolbar; pub use geometry::{SplitAxis, SplitLayout, SplitSize, split_rect}; +pub(crate) use resize::drag_focus; pub use resize::{ResizeHandle, ResizeHandleStyle, ResizeResponse}; pub use toolbar::{ToolbarFit, fit_toolbar}; diff --git a/crates/ui/src/layout/resize.rs b/crates/ui/src/layout/resize.rs index fc51aa1f..7fd055ab 100644 --- a/crates/ui/src/layout/resize.rs +++ b/crates/ui/src/layout/resize.rs @@ -24,7 +24,8 @@ pub struct ResizeResponse { /// A focusable divider with pointer, arrow-key and accessibility resizing. /// /// This widget never stores or changes a pane size. IDs and labels are separate, so translated -/// labels do not change identity. Pointer drags retain egui's immediate `Sense::drag` behavior. +/// labels do not change identity. Pointer drags retain egui's immediate `Sense::drag` behavior +/// and hold focus only for the gesture (see [`drag_focus`]). /// Only arrows along the split axis are consumed, while this handle has focus. Tab remains /// available for normal focus traversal. pub struct ResizeHandle<'a> { @@ -36,11 +37,12 @@ pub struct ResizeHandle<'a> { hit_margin: f32, step: f32, value: Option<(f32, RangeInclusive)>, + reversed: bool, } impl<'a> ResizeHandle<'a> { pub fn new(id: Id, axis: SplitAxis, rect: Rect, label: &'a str, style: ResizeHandleStyle) -> Self { - Self { id, axis, rect, label, style, hit_margin: 3.0, step: 8.0, value: None } + Self { id, axis, rect, label, style, hit_margin: 3.0, step: 8.0, value: None, reversed: false } } /// Expand the hit rectangle along the movement axis, in logical points. Default: 3. @@ -62,6 +64,14 @@ impl<'a> ResizeHandle<'a> { self } + /// The value grows against the axis, as for a column docked on the right edge that widens + /// as its divider moves left. Accessibility actions then work on the value; the pointer + /// and arrow keys stay physical, and `delta` stays along the axis. + pub fn reversed(mut self, reversed: bool) -> Self { + self.reversed = reversed; + self + } + pub fn show(self, ui: &mut Ui) -> ResizeResponse { let hit = self.rect.expand2(match self.axis { SplitAxis::Horizontal => Vec2::new(self.hit_margin, 0.0), @@ -83,9 +93,7 @@ impl<'a> ResizeHandle<'a> { let enabled = valid && response.enabled(); let mut delta = 0.0; if enabled { - if response.drag_started() { - response.request_focus(); - } + drag_focus(ui, &response); if response.dragged() { let drag = response.drag_delta(); delta = match self.axis { @@ -117,27 +125,30 @@ impl<'a> ResizeHandle<'a> { } }); } + let mut change = 0.0; ui.input_mut(|input| { input.consume_accesskit_action_requests(response.id, |request| match request.action { accesskit::Action::Increment => { - delta += self.step; + change += self.step; true } accesskit::Action::Decrement => { - delta -= self.step; + change -= self.step; true } accesskit::Action::SetValue => { if let (Some((value, range)), Some(accesskit::ActionData::NumericValue(new_value))) = (&self.value, &request.data) && new_value.is_finite() { - delta = (new_value.clamp(f64::from(*range.start()), f64::from(*range.end())) - f64::from(*value)) as f32; + change = (new_value.clamp(f64::from(*range.start()), f64::from(*range.end())) - f64::from(*value)) as f32; + delta = 0.0; } true } _ => false, }); }); + delta += if self.reversed { -change } else { change }; } if !delta.is_finite() { delta = 0.0; @@ -196,3 +207,19 @@ impl<'a> ResizeHandle<'a> { ResizeResponse { response, delta } } } + +/// A pointer drag focuses its handle for the gesture only and gives the focus up on release, +/// so the app's shortcuts and its canvas arrow keys work again (they defer to a focused +/// widget). A handle focused from the keyboard (Tab, F6) before the drag keeps its focus. +pub(crate) fn drag_focus(ui: &Ui, response: &Response) { + let key = response.id.with("pointer-focus"); + if response.drag_started() { + if !response.has_focus() { + ui.ctx().data_mut(|d| d.insert_temp(key, ())); + } + response.request_focus(); + } + if response.drag_stopped() && ui.ctx().data_mut(|d| d.remove_temp::<()>(key)).is_some() { + response.surrender_focus(); + } +} diff --git a/crates/ui/src/layout/tests.rs b/crates/ui/src/layout/tests.rs index cdd44cb5..21342da1 100644 --- a/crates/ui/src/layout/tests.rs +++ b/crates/ui/src/layout/tests.rs @@ -171,9 +171,32 @@ fn resize_handle_preserves_pointer_drag_lifecycle_and_reports_per_frame_delta() let (response, _) = h.frame(vec![pointer(center + vec2(16.0, 0.0), false)]); assert!(response.response.drag_stopped()); assert_eq!(response.delta, 0.0); + assert!(!response.response.has_focus()); + let (response, _) = h.frame(vec![key(Key::ArrowRight)]); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "after a pointer drag no widget has focus, so app shortcuts work"); + assert_eq!(response.delta, 0.0, "arrow keys are left to the app"); assert_eq!(h.value, 100.0, "the component never owns pane state"); } +#[test] +fn tab_focused_handle_keeps_focus_through_a_pointer_drag_and_arrow_resizing() { + let mut h = HandleTest::new(); + let center = h.rect.center(); + h.settle(); + h.frame(vec![key(Key::Tab)]); + let (response, _) = h.frame(vec![]); + assert!(response.response.has_focus(), "Tab reaches the handle"); + h.frame(vec![Event::PointerMoved(center), pointer(center, true)]); + h.frame(vec![Event::PointerMoved(center + vec2(12.0, 0.0))]); + h.frame(vec![pointer(center + vec2(12.0, 0.0), false)]); + let (response, _) = h.frame(vec![key(Key::ArrowRight)]); + assert!(response.response.has_focus()); + assert_eq!(response.delta, 8.0); + let (response, _) = h.frame(vec![key(Key::ArrowLeft)]); + assert!(response.response.has_focus()); + assert_eq!(response.delta, -8.0); +} + #[test] fn focused_handle_resizes_with_axis_arrows_and_draws_visible_focus() { for (axis, forward, back) in [(SplitAxis::Horizontal, Key::ArrowRight, Key::ArrowLeft), (SplitAxis::Vertical, Key::ArrowDown, Key::ArrowUp)] { diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 03e667a0..3a8ec302 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -8,8 +8,10 @@ pub mod buttons; pub mod capture; pub mod docking; +mod focus; pub mod icons; pub mod layout; +pub mod panes; pub mod printing; pub mod tabs; pub mod text; diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs new file mode 100644 index 00000000..f0eb6676 --- /dev/null +++ b/crates/ui/src/panes/mod.rs @@ -0,0 +1,741 @@ +//! Task panes for document apps: side and bottom panes around the app's own central document. +//! +//! [`PaneHost`] draws each open pane as an egui side or bottom panel, or as a floating frame, with +//! a title bar (title, ▾ menu, ×) and a resizable inner edge. Call it before the app's +//! `CentralPanel`; the document never becomes a panel. The app owns which panes are open and +//! where they are: the host takes the open list and each pane's [`PaneState`] every frame and +//! returns [`PaneRequest`]s for the app to run through its own commands. Docking chrome +//! ([`crate::docking::chrome`]) draws the floating frames and title bars. + +use std::fmt::Debug; +use std::hash::Hash; +use std::ops::RangeInclusive; + +use egui::{Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Ui, UiBuilder, Vec2, pos2, vec2}; + +use crate::docking::PanelLimits; +use crate::docking::chrome::{self, ChromeStyle, FloatingFrame, MoveStep, TitleBar}; +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// A title dragged this far out of a docked pane floats it; a pane dropped this close to the +/// host's left or right edge docks there. +pub const DOCK_SNAP: f32 = 32.0; +/// Height of a pane's title bar. +pub const TITLE_BAR_HEIGHT: f32 = 24.0; +/// Size of a pane floated for the first time. +pub const FLOAT_DEFAULT: Vec2 = vec2(270.0, 480.0); +const FLOAT_MIN_HEIGHT: f32 = 160.0; +/// Where the title bar is held when a dragged-out pane floats at the pointer. +const FLOAT_GRAB: Vec2 = vec2(40.0, 12.0); + +/// A logical edge of the host area. `Start` is left in left-to-right interfaces and right in +/// right-to-left ones. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Edge { + Start, + End, + Bottom, +} + +/// Where a pane is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Place { + Docked(Edge), + Floating, +} + +/// The persisted layout of one pane. The app stores it where it keeps its preferences. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct PaneState { + pub place: Place, + /// Width of a side pane or height of a bottom pane, in points. `None`: the pane's default. + pub size: Option, + /// `[x, y, width, height]` of the floating pane, also kept while it is docked. + pub float_rect: Option<[f32; 4]>, +} + +impl PaneState { + pub fn docked(edge: Edge, size: f32) -> Self { + Self { place: Place::Docked(edge), size: Some(size), float_rect: None } + } + + fn apply

(&mut self, request: &PaneRequest

) { + match request { + PaneRequest::Place(_, place) => self.place = *place, + PaneRequest::Resize(_, size) => self.size = Some(*size), + PaneRequest::FloatRect(_, rect) => self.float_rect = Some(*rect), + PaneRequest::Close(_) | PaneRequest::Focus(_) => {} + } + } +} + +/// How docked panes share an edge. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SidePolicy { + /// Every pane on an edge is shown, side by side in `open` order, the first outermost. + #[default] + Columns, + /// One pane per edge: the last of the edge's closable panes in `open`. Panes that are not + /// closable (context panes the app shows from its own state) are shown beside the slot. + OneSlot, +} + +/// A change the user asked for. The host applies none of them to the app's state. +#[derive(Clone, Debug, PartialEq)] +pub enum PaneRequest

{ + Close(P), + Place(P, Place), + Resize(P, f32), + FloatRect(P, [f32; 4]), + /// The pane took focus from a click, keyboard focus or F6. + Focus(P), +} + +impl

PaneRequest

{ + pub fn pane(&self) -> &P { + match self { + Self::Close(p) | Self::Place(p, _) | Self::Resize(p, _) | Self::FloatRect(p, _) | Self::Focus(p) => p, + } + } +} + +/// What [`PaneHost::show`] returns. +#[derive(Clone, Debug, PartialEq)] +pub struct PaneOutput

{ + /// In the order they happened; apply them in order. Resize and FloatRect arrive every + /// frame a gesture changes the size or rect. + pub requests: Vec>, + /// The pane holding keyboard focus or last clicked, if it is still shown. + pub focused: Option

, + /// F6 moved focus past the last pane (or Shift+F6 past the first): focus the document. + pub to_document: bool, +} + +/// The app's side of a pane: its contents and defaults. +pub trait PaneContent

{ + /// May follow the selection (Format Shape, Format Picture). + fn title(&self, pane: &P) -> String; + fn body(&mut self, ui: &mut Ui, pane: &P); + /// Used when the state callback returns `None` and the host has none in memory. + fn default_state(&self, pane: &P) -> PaneState; + /// Width (or bottom height) limits of the docked pane. + fn size_range(&self, _pane: &P) -> RangeInclusive { + 200.0..=520.0 + } + /// `false`: no Move or Float items and no title drag. + fn movable(&self, _pane: &P) -> bool { + true + } + /// `false`: no × and no Close item (context panes such as PivotTable Fields). + fn closable(&self, _pane: &P) -> bool { + true + } + /// App items, shown above the Move, Float and Close items of the ▾ menu. + fn menu(&mut self, _ui: &mut Ui, _pane: &P) {} +} + +/// Labels of the chrome; translate them in the app. +#[derive(Clone, Debug)] +pub struct PaneLabels { + pub menu: String, + pub move_start: String, + pub move_end: String, + pub move_bottom: String, + pub float: String, + pub close: String, + /// Accessibility label of the title bar. `{title}` is replaced with the pane's title, so + /// each title bar has its own name. + pub move_pane: String, + /// Accessibility label of the resize edges. + pub resize: String, +} + +impl Default for PaneLabels { + fn default() -> Self { + Self { + menu: "Pane options".into(), + move_start: "Move to Start".into(), + move_end: "Move to End".into(), + move_bottom: "Move to Bottom".into(), + float: "Float".into(), + close: "Close".into(), + move_pane: "Move {title} pane".into(), + resize: "Resize pane".into(), + } + } +} + +/// Draws the open panes. Build it every frame. +pub struct PaneHost { + id: Id, + rtl: bool, + policy: SidePolicy, + max_fraction: f32, + f6: bool, + labels: PaneLabels, + frame: Option, + margin: Margin, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Side { + Left, + Right, + Bottom, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Memory { + active: Option, + last_focus: Option, + /// F6 pressed or [`focus_next`] called: `true` cycles backwards. + cycle: Option, + drag_out: Option, +} + +#[derive(Clone, Copy, Debug)] +struct DragOut { + pane: Id, + canceled: bool, +} + +struct Shown

{ + pane: P, + id: Id, + state: PaneState, +} + +/// Where a pane was drawn, for focus tracking. +struct Region { + pane: Id, + rect: Rect, + floating: Option, +} + +fn memory_id(id: Id) -> Id { + id.with("panes-memory") +} + +/// Move focus to the next pane (the previous one with `reverse`) at the host's next frame. For +/// apps that route F6 through their own commands; see [`PaneHost::f6`]. +pub fn focus_next(ctx: &Context, id: impl Into, reverse: bool) { + let key = memory_id(id.into()); + ctx.data_mut(|data| data.get_temp_mut_or_default::(key).cycle = Some(reverse)); + ctx.request_repaint(); +} + +impl PaneHost { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + rtl: false, + policy: SidePolicy::Columns, + max_fraction: 1.0, + f6: true, + labels: PaneLabels::default(), + frame: None, + margin: Margin::same(8), + } + } + + /// Right-to-left interface: `Start` is the right edge. + pub fn rtl(mut self, rtl: bool) -> Self { + self.rtl = rtl; + self + } + + pub fn policy(mut self, policy: SidePolicy) -> Self { + self.policy = policy; + self + } + + /// Largest share of the host's width (height for bottom panes) one docked pane may take. + pub fn max_fraction(mut self, fraction: f32) -> Self { + self.max_fraction = if fraction.is_finite() { fraction.clamp(0.0, 1.0) } else { 1.0 }; + self + } + + /// Whether the host takes F6 and Shift+F6 to cycle focus through the panes and back to the + /// document. Default on. Apps that bind F6 themselves call [`focus_next`]. + pub fn f6(mut self, f6: bool) -> Self { + self.f6 = f6; + self + } + + pub fn labels(mut self, labels: PaneLabels) -> Self { + self.labels = labels; + self + } + + /// Frame of the docked panels. Default: the panel fill and a hairline border, no margin. + pub fn frame(mut self, frame: Frame) -> Self { + self.frame = Some(frame); + self + } + + /// Margin around each pane's body. Default 8. + pub fn body_margin(mut self, margin: Margin) -> Self { + self.margin = margin; + self + } + + fn pane_id(&self, pane: &P) -> Id { + self.id.with(("pane", pane)) + } + + fn side(&self, edge: Edge) -> Side { + match (edge, self.rtl) { + (Edge::Bottom, _) => Side::Bottom, + (Edge::Start, false) | (Edge::End, true) => Side::Left, + (Edge::Start, true) | (Edge::End, false) => Side::Right, + } + } + + /// The edge a pane dropped at `pointer` docks against, if it is within [`DOCK_SNAP`] of the + /// host's left or right edge. + fn snap(&self, pointer: Pos2, bounds: Rect) -> Option { + let left = if self.rtl { Edge::End } else { Edge::Start }; + let right = if self.rtl { Edge::Start } else { Edge::End }; + if pointer.x <= bounds.left() + DOCK_SNAP { + Some(left) + } else if pointer.x >= bounds.right() - DOCK_SNAP { + Some(right) + } else { + None + } + } + + /// Draw the panes in `open` with the state `states` returns for each (`None`: the state the + /// host kept from earlier requests, else the pane's default). Call between the app's top + /// and bottom bars and its `CentralPanel`. + pub fn show>( + self, + ui: &mut Ui, + open: &[P], + states: impl Fn(&P) -> Option, + content: &mut C, + ) -> PaneOutput

{ + let ctx = ui.ctx().clone(); + let bounds = ui.available_rect_before_wrap(); + let style = ChromeStyle { right_to_left: self.rtl, ..ChromeStyle::from_ui(ui) }; + let key = memory_id(self.id); + let mut memory = ctx.data(|data| data.get_temp::(key)).unwrap_or_default(); + let mut requests = Vec::new(); + let mut to_document = false; + + if self.f6 { + ui.input_mut(|input| { + if input.consume_key(Modifiers::SHIFT, Key::F6) { + memory.cycle = Some(true); + } else if input.consume_key(Modifiers::NONE, Key::F6) { + memory.cycle = Some(false); + } + }); + } + + let mut shown: Vec> = Vec::new(); + for pane in open { + let id = self.pane_id(pane); + if shown.iter().any(|s| s.id == id) { + continue; + } + let state = + states(pane).or_else(|| ctx.data(|data| data.get_temp::(id.with("state")))).unwrap_or_else(|| content.default_state(pane)); + shown.push(Shown { pane: pane.clone(), id, state }); + } + if self.policy == SidePolicy::OneSlot { + let mut taken = Vec::new(); + for index in (0..shown.len()).rev() { + if let Place::Docked(edge) = shown[index].state.place + && content.closable(&shown[index].pane) + { + if taken.contains(&edge) { + shown.remove(index); + } else { + taken.push(edge); + } + } + } + } + + let previous = memory.active.filter(|active| shown.iter().any(|s| s.id == *active)); + memory.active = previous; + let mut focus_target = None; + if let Some(reverse) = memory.cycle.take() { + let current = memory.active.and_then(|active| shown.iter().position(|s| s.id == active)); + let next = match (current, reverse) { + (None, false) => shown.first(), + (None, true) => shown.last(), + (Some(i), false) => shown.get(i + 1), + (Some(i), true) => i.checked_sub(1).and_then(|i| shown.get(i)), + }; + focus_target = next.map(|s| s.id); + memory.active = focus_target; + if focus_target.is_none() { + to_document = true; + ctx.memory_mut(|m| { + if let Some(focused) = m.focused() { + m.surrender_focus(focused); + } + }); + } + } + + let preview = ctx.layer_painter(LayerId::new(Order::Tooltip, self.id.with("drop-preview"))); + let mut regions = Vec::new(); + for edge in [Edge::Start, Edge::End, Edge::Bottom] { + for s in shown.iter().filter(|s| s.state.place == Place::Docked(edge)) { + let rect = self.docked(ui, s, edge, content, &style, focus_target == Some(s.id), bounds, &mut memory, &preview, &mut requests); + regions.push(Region { pane: s.id, rect, floating: None }); + } + } + for s in shown.iter().filter(|s| s.state.place == Place::Floating) { + let (rect, layer) = self.floating(&ctx, s, content, &style, focus_target == Some(s.id), bounds, &mut requests); + regions.push(Region { pane: s.id, rect, floating: Some(layer) }); + } + + if focus_target.is_none() && !to_document { + self.track_focus(&ctx, ui.layer_id(), ®ions, &mut memory); + } + memory.last_focus = ctx.memory(|m| m.focused()); + if memory.active != previous + && let Some(s) = memory.active.and_then(|active| shown.iter().find(|s| s.id == active)) + { + requests.push(PaneRequest::Focus(s.pane.clone())); + } + + for s in &shown { + let mut state = s.state; + let mut changed = false; + for request in requests.iter().filter(|r| self.pane_id(r.pane()) == s.id) { + state.apply(request); + changed = true; + } + if changed { + ctx.data_mut(|data| data.insert_temp(s.id.with("state"), state)); + } + } + let focused = memory.active.and_then(|active| shown.iter().find(|s| s.id == active)).map(|s| s.pane.clone()); + ctx.data_mut(|data| data.insert_temp(key, memory)); + PaneOutput { requests, focused, to_document } + } + + /// The pane under a primary press, or the pane holding a newly focused widget, becomes active. + /// Presses and focus outside every pane (the document) clear it; presses on other layers + /// (menus, the app's own windows) leave it. + fn track_focus(&self, ctx: &Context, layer: LayerId, regions: &[Region], memory: &mut Memory) { + let find = |at_layer: Option, pos: Pos2| -> Option> { + if let Some(region) = regions.iter().find(|r| r.floating.is_some() && r.floating == at_layer) { + return Some(Some(region.pane)); + } + if at_layer.is_none() || at_layer == Some(layer) { + return Some(regions.iter().find(|r| r.floating.is_none() && r.rect.contains(pos)).map(|r| r.pane)); + } + None + }; + if ctx.input(|input| input.pointer.primary_pressed()) + && let Some(pos) = ctx.input(|input| input.pointer.press_origin()) + && let Some(active) = find(ctx.layer_id_at(pos), pos) + { + memory.active = active; + } + let focused = ctx.memory(|m| m.focused()); + if focused != memory.last_focus + && let Some(response) = focused.and_then(|id| ctx.read_response(id)) + && let Some(active) = find(Some(response.layer_id), response.rect.center()) + { + memory.active = active; + } + } + + fn limits>(&self, content: &C, pane: &P, extent: f32) -> (f32, f32) { + let range = content.size_range(pane); + let min = if range.start().is_finite() { range.start().max(0.0) } else { 0.0 }; + let max = if range.end().is_finite() { range.end().max(min) } else { min.max(extent) }; + (min, max.min(self.max_fraction * extent).max(min)) + } + + #[allow(clippy::too_many_arguments)] + fn docked>( + &self, + ui: &mut Ui, + s: &Shown

, + edge: Edge, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + memory: &mut Memory, + preview: &egui::Painter, + requests: &mut Vec>, + ) -> Rect { + let side = self.side(edge); + let extent = if side == Side::Bottom { bounds.height() } else { bounds.width() }; + let (min, max) = self.limits(content, &s.pane, extent); + let default = content.default_state(&s.pane).size; + let size = s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(min).clamp(min, max); + let panel_id = s.id.with("panel"); + let panel = match side { + Side::Left => egui::Panel::left(panel_id), + Side::Right => egui::Panel::right(panel_id), + Side::Bottom => egui::Panel::bottom(panel_id), + }; + let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).stroke(ui.visuals().widgets.noninteractive.bg_stroke)); + let shown = panel.exact_size(size).resizable(false).frame(frame).show(ui, |ui| { + let top = ui.max_rect(); + let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT)); + ui.allocate_rect(title, Sense::hover()); + let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests); + // The pane reserves exactly its size and clips its body. egui cuts a panel wider + // than its exact size at its outer edge, so a wide body would shift a right or + // bottom pane's reserved rect inwards and the document would draw over the pane. + let body = Rect::from_min_max(pos2(top.left(), title.bottom()), top.max); + let mut child = ui.new_child(UiBuilder::new().max_rect(body).layout(*ui.layout())); + child.set_clip_rect(body.intersect(ui.clip_rect())); + Frame::NONE.inner_margin(self.margin).show(&mut child, |ui| { + ui.set_min_width(ui.available_width()); + content.body(ui, &s.pane); + }); + ui.allocate_rect(body, Sense::hover()); + bar + }); + let rect = shown.response.rect; + let bar = shown.inner; + + let (axis, strip) = match side { + Side::Left => (SplitAxis::Horizontal, Rect::from_x_y_ranges(rect.right() - 2.0..=rect.right() + 2.0, rect.y_range())), + Side::Right => (SplitAxis::Horizontal, Rect::from_x_y_ranges(rect.left() - 2.0..=rect.left() + 2.0, rect.y_range())), + Side::Bottom => (SplitAxis::Vertical, Rect::from_x_y_ranges(rect.x_range(), rect.top() - 2.0..=rect.top() + 2.0)), + }; + let handle_style = ResizeHandleStyle { hovered: style.accent.gamma_multiply(0.5), dragged: style.accent, focused: style.accent }; + let resize = ResizeHandle::new(s.id.with("resize"), axis, strip, &self.labels.resize, handle_style).show(ui); + let next = if resize.response.dragged() { + resize.response.interact_pointer_pos().map(|p| match side { + Side::Left => p.x - rect.left(), + Side::Right => rect.right() - p.x, + Side::Bottom => rect.bottom() - p.y, + }) + } else if resize.delta != 0.0 { + // Arrow keys move the edge: right grows a left pane, left grows a right pane. + Some(if side == Side::Left { size + resize.delta } else { size - resize.delta }) + } else { + None + }; + if let Some(next) = next.filter(|v| v.is_finite()).map(|v| v.clamp(min, max).round()) + && next != size + { + requests.push(PaneRequest::Resize(s.pane.clone(), next)); + } + + if content.movable(&s.pane) { + // Docked at the host's left or right edge, the pane gets the width the side limits + // give its size: a bottom pane's height, not its width. + let (side_min, side_max) = self.limits(content, &s.pane, bounds.width()); + let side_width = + s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(side_min).clamp(side_min, side_max); + self.drag_out(ui.ctx(), &bar, s, edge, rect, side_width, bounds, memory, preview, style, requests); + } + rect + } + + /// A docked pane's title dragged beyond [`DOCK_SNAP`] of the pane: dropped near the host's + /// left or right edge it docks there `side_width` wide, elsewhere it floats under the + /// pointer. + #[allow(clippy::too_many_arguments)] + fn drag_out( + &self, + ctx: &Context, + bar: &Response, + s: &Shown

, + edge: Edge, + rect: Rect, + side_width: f32, + bounds: Rect, + memory: &mut Memory, + preview: &egui::Painter, + style: &ChromeStyle, + requests: &mut Vec>, + ) { + if bar.drag_started() { + memory.drag_out = Some(DragOut { pane: s.id, canceled: false }); + } + let Some(mut drag) = memory.drag_out.filter(|d| d.pane == s.id) else { return }; + if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) { + drag.canceled = true; + } + let near = rect.expand(DOCK_SNAP); + let Some(pointer) = ctx.pointer_latest_pos() else { return }; + let outside = if edge == Edge::Bottom { !near.y_range().contains(pointer.y) } else { !near.x_range().contains(pointer.x) }; + let size = s.state.float_rect.map_or(FLOAT_DEFAULT, |r| vec2(r[2], r[3])); + let float = float_rect(Some(rect_array(Rect::from_min_size(pointer - FLOAT_GRAB, size))), bounds, vec2(0.0, FLOAT_MIN_HEIGHT)); + let target = self.snap(pointer, bounds); + if bar.drag_stopped() { + memory.drag_out = None; + if drag.canceled || !outside { + return; + } + match target { + Some(target) if target != edge => requests.push(PaneRequest::Place(s.pane.clone(), Place::Docked(target))), + Some(_) => {} + None => { + requests.push(PaneRequest::Place(s.pane.clone(), Place::Floating)); + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(float))); + } + } + return; + } + if !bar.dragged() { + memory.drag_out = None; + return; + } + memory.drag_out = Some(drag); + if drag.canceled || !outside { + return; + } + let shape = match target.map(|t| self.side(t)) { + Some(Side::Left) => Rect::from_x_y_ranges(bounds.left()..=bounds.left() + side_width, bounds.y_range()), + Some(_) => Rect::from_x_y_ranges(bounds.right() - side_width..=bounds.right(), bounds.y_range()), + None => float, + }; + chrome::drop_preview(preview, shape.shrink(1.0), style.accent, true); + } + + #[allow(clippy::too_many_arguments)] + fn floating>( + &self, + ctx: &Context, + s: &Shown

, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + requests: &mut Vec>, + ) -> (Rect, LayerId) { + let (min_width, _) = self.limits(content, &s.pane, bounds.width()); + let min = vec2(min_width, FLOAT_MIN_HEIGHT); + let rect = float_rect(s.state.float_rect, bounds, min); + let limits = PanelLimits { min, max: bounds.size().max(min) }; + let shown = FloatingFrame::new(s.id.with("float"), rect).bounds(bounds).limits(limits).resize_label(&self.labels.resize).show( + ctx, + style, + |ui, rect| { + let title = Rect::from_min_size(rect.min, vec2(rect.width(), TITLE_BAR_HEIGHT)); + let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests); + let body = Rect::from_min_max(pos2(rect.left(), title.bottom()), rect.max) - self.margin; + if body.is_positive() { + ui.scope_builder(UiBuilder::new().max_rect(body), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + content.body(ui, &s.pane); + }); + } + bar + }, + ); + if let Some(resized) = shown.resized { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(resized))); + } + if content.movable(&s.pane) { + match chrome::live_move(ctx, &shown.inner, shown.rect, bounds) { + Some(MoveStep::Moving(next) | MoveStep::Canceled(next)) if next != shown.rect => { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(next))); + } + Some(MoveStep::Dropped { rect, pointer }) => { + if let Some(edge) = self.snap(pointer, bounds) { + requests.push(PaneRequest::Place(s.pane.clone(), Place::Docked(edge))); + } else if rect != shown.rect { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(rect))); + } + } + _ => {} + } + } + (shown.rect, shown.layer) + } + + /// Title, ▾ menu and ×. Returns the bar, which senses the title drag. + #[allow(clippy::too_many_arguments)] + fn chrome>( + &self, + ui: &mut Ui, + rect: Rect, + s: &Shown

, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + requests: &mut Vec>, + ) -> Response { + let movable = content.movable(&s.pane); + let closable = content.closable(&s.pane); + let title = content.title(&s.pane); + let move_label = self.labels.move_pane.replace("{title}", &title); + let mut bar = TitleBar::new(s.id.with("title"), &move_label).title(title).menu(&self.labels.menu).movable(movable); + if closable { + bar = bar.close(&self.labels.close); + } + let title = bar.show(ui, rect, style); + if focus { + title.bar.request_focus(); + } + if title.close.as_ref().is_some_and(Response::clicked) { + requests.push(PaneRequest::Close(s.pane.clone())); + } + if let Some(menu) = &title.menu { + egui::Popup::menu(menu).show(|ui| { + let top = ui.cursor().top(); + content.menu(ui, &s.pane); + if ui.cursor().top() > top && (movable || closable) { + ui.separator(); + } + if movable { + let mut places = vec![(Place::Docked(Edge::Start), &self.labels.move_start), (Place::Docked(Edge::End), &self.labels.move_end)]; + if content.default_state(&s.pane).place == Place::Docked(Edge::Bottom) { + places.push((Place::Docked(Edge::Bottom), &self.labels.move_bottom)); + } + places.push((Place::Floating, &self.labels.float)); + for (place, label) in places { + if ui.selectable_label(s.state.place == place, label.as_str()).clicked() && s.state.place != place { + requests.push(PaneRequest::Place(s.pane.clone(), place)); + if place == Place::Floating { + let rect = float_rect(s.state.float_rect, bounds, vec2(0.0, FLOAT_MIN_HEIGHT)); + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(rect))); + } + } + } + } + if closable && ui.button(self.labels.close.as_str()).clicked() { + requests.push(PaneRequest::Close(s.pane.clone())); + } + }); + } + title.bar + } +} + +fn rect_array(rect: Rect) -> [f32; 4] { + [rect.min.x, rect.min.y, rect.width(), rect.height()] +} + +/// The floating rect for `saved` (`[x, y, width, height]`), at least `min` and at most the +/// bounds in size, inside `bounds`. Without a saved rect: [`FLOAT_DEFAULT`] right of the middle. +/// Non-finite or negative values fall back to the defaults, so the result is always finite. +pub fn float_rect(saved: Option<[f32; 4]>, bounds: Rect, min: Vec2) -> Rect { + let bounds = if bounds.is_finite() && bounds.is_positive() { bounds } else { Rect::from_min_size(Pos2::ZERO, FLOAT_DEFAULT) }; + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let size = saved.map_or(FLOAT_DEFAULT, |r| vec2(finite(r[2], FLOAT_DEFAULT.x), finite(r[3], FLOAT_DEFAULT.y))); + let size = size.max(min.max(Vec2::splat(1.0))).min(bounds.size()); + let free = (bounds.size() - size).max(Vec2::ZERO); + let default = (bounds.min + vec2(free.x * 0.75, free.y * 0.3)).round(); + let pos = saved.map_or(default, |r| pos2(finite(r[0], default.x), finite(r[1], default.y))); + let pos = pos.max(bounds.min).min((bounds.max - size).max(bounds.min)); + Rect::from_min_size(pos, size) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs new file mode 100644 index 00000000..fffd99a1 --- /dev/null +++ b/crates/ui/src/panes/tests.rs @@ -0,0 +1,361 @@ +//! Behavior tests for the pane host, driven by egui pointer and key events in an 800 × 600 +//! window. The harness's root ui has an 8 pt margin, so the host spans x 8..792, y 8..592. + +use std::collections::HashMap; + +use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2, Rect, Ui, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +use super::*; + +const L: f32 = 8.0; +const R: f32 = 792.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Pane { + Nav, + Styles, + Comments, + /// A context pane, like PivotTable Fields: not closable, not movable. + Pivot, +} + +struct Content; + +impl PaneContent for Content { + fn title(&self, pane: &Pane) -> String { + format!("{pane:?}") + } + fn body(&mut self, ui: &mut Ui, pane: &Pane) { + ui.label(format!("{pane:?} body")); + } + fn default_state(&self, pane: &Pane) -> PaneState { + let edge = if matches!(pane, Pane::Comments | Pane::Pivot) { Edge::End } else { Edge::Start }; + PaneState::docked(edge, 250.0) + } + fn movable(&self, pane: &Pane) -> bool { + *pane != Pane::Pivot + } + fn closable(&self, pane: &Pane) -> bool { + *pane != Pane::Pivot + } +} + +/// The app side: open list, persisted states and every request it received. +struct App { + open: Vec, + states: HashMap, + log: Vec>, + focused: Option, + to_document: usize, + rtl: bool, + policy: SidePolicy, +} + +impl App { + fn new(open: &[Pane]) -> Self { + Self { open: open.to_vec(), states: HashMap::new(), log: Vec::new(), focused: None, to_document: 0, rtl: false, policy: SidePolicy::Columns } + } + + fn frame(&mut self, ui: &mut Ui) { + let states = self.states.clone(); + let out = PaneHost::new(Id::unique("panes")).rtl(self.rtl).policy(self.policy).show(ui, &self.open, |p| states.get(p).copied(), &mut Content); + egui::CentralPanel::default().show(ui, |ui| ui.label("Document")); + for request in &out.requests { + match request { + PaneRequest::Close(p) => self.open.retain(|o| o != p), + PaneRequest::Focus(_) => {} + other => { + let pane = *other.pane(); + let state = self.states.entry(pane).or_insert_with(|| Content.default_state(&pane)); + state.apply(other); + } + } + } + self.log.extend(out.requests); + self.focused = out.focused; + self.to_document += usize::from(out.to_document); + } + + fn place(&self, pane: Pane) -> Place { + self.states.get(&pane).map_or_else(|| Content.default_state(&pane).place, |s| s.place) + } +} + +fn harness(app: App) -> Harness<'static, App> { + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(|ui, app: &mut App| app.frame(ui), app); + h.input_mut().focused = true; + h.run_steps(2); + h +} + +fn pane_id(pane: Pane) -> Id { + Id::unique("panes").with(("pane", &pane)) +} + +fn rect(h: &Harness<'_, App>, id: Id) -> Option { + h.ctx.read_response(id).map(|r| r.rect) +} + +fn title(h: &Harness<'_, App>, pane: Pane) -> Rect { + rect(h, pane_id(pane).with("title")).unwrap() +} + +/// The x of a side pane's inner edge. +fn edge(h: &Harness<'_, App>, pane: Pane) -> f32 { + rect(h, pane_id(pane).with("resize")).unwrap().center().x +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn send(h: &mut Harness<'_, App>, events: Vec) { + h.input_mut().events.extend(events); + h.run_steps(1); +} + +fn click(h: &mut Harness<'_, App>, at: Pos2) { + send(h, vec![Event::PointerMoved(at), button(at, true)]); + send(h, vec![button(at, false)]); + h.run_steps(1); +} + +fn hold(h: &mut Harness<'_, App>, from: Pos2, to: Pos2) { + send(h, vec![Event::PointerMoved(from), button(from, true)]); + send(h, vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + send(h, vec![Event::PointerMoved(to)]); +} + +fn drag(h: &mut Harness<'_, App>, from: Pos2, to: Pos2) { + hold(h, from, to); + send(h, vec![button(to, false)]); + h.run_steps(2); +} + +fn key(h: &mut Harness<'_, App>, key: Key, modifiers: Modifiers) { + send(h, vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]); + h.run_steps(1); +} + +/// The requests that change a pane's state or open list. +fn changes(app: &App) -> Vec> { + app.log.iter().filter(|r| !matches!(r, PaneRequest::Focus(_))).cloned().collect() +} + +fn last_resize(app: &App, pane: Pane) -> Option { + app.log.iter().rev().find_map(|r| match r { + PaneRequest::Resize(p, size) if *p == pane => Some(*size), + _ => None, + }) +} + +#[test] +fn columns_sit_side_by_side_resize_and_move_to_end() { + let mut h = harness(App::new(&[Pane::Nav, Pane::Styles])); + assert_eq!(edge(&h, Pane::Nav), L + 250.0); + assert_eq!(edge(&h, Pane::Styles), L + 500.0); + assert!(title(&h, Pane::Nav).left() < L + 2.0); + + drag(&mut h, pos2(L + 250.0, 300.0), pos2(L + 300.0, 300.0)); + assert_eq!(last_resize(h.state(), Pane::Nav), Some(300.0)); + assert_eq!(edge(&h, Pane::Nav), L + 300.0); + assert_eq!(edge(&h, Pane::Styles), L + 550.0, "the second column follows"); + + let at = rect(&h, pane_id(Pane::Nav).with("title").with("menu")).unwrap().center(); + click(&mut h, at); + for label in ["Move to Start", "Move to End", "Float", "Close"] { + assert!(h.query_all_by_label(label).count() >= 1, "{label}"); + } + h.get_by_label("Move to End").click(); + h.run_steps(3); + assert!(h.state().log.contains(&PaneRequest::Place(Pane::Nav, Place::Docked(Edge::End)))); + assert_eq!(edge(&h, Pane::Nav), R - 300.0, "a 300 pt pane at the right edge"); + assert_eq!(edge(&h, Pane::Styles), L + 250.0); +} + +#[test] +fn a_bottom_pane_dragged_to_a_side_previews_the_width_it_docks_at() { + let mut app = App::new(&[Pane::Comments]); + app.states.insert(Pane::Comments, PaneState::docked(Edge::Bottom, 300.0)); + let mut h = harness(app); + let from = title(&h, Pane::Comments).center(); + let to = pos2(L + 10.0, 200.0); + hold(&mut h, from, to); + let previewed = h.output().shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect.left() == L + 1.0 && shape.rect.width() == 298.0, + _ => false, + }); + assert!(previewed, "a 300 pt side pane"); + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + assert_eq!(h.state().place(Pane::Comments), Place::Docked(Edge::Start)); + assert_eq!(edge(&h, Pane::Comments), L + 300.0); +} + +#[test] +fn title_dragged_out_floats_and_a_drop_near_an_edge_docks() { + // Drag-out-to-float and the 32 pt edge snap follow wordcraft#2584. + let mut h = harness(App::new(&[Pane::Styles])); + let from = title(&h, Pane::Styles).center(); + // Within DOCK_SNAP of the pane: nothing happens. + drag(&mut h, from, pos2(L + 270.0, 300.0)); + assert!(changes(h.state()).is_empty(), "{:?}", h.state().log); + + drag(&mut h, from, pos2(400.0, 300.0)); + assert_eq!( + changes(h.state()), + vec![PaneRequest::Place(Pane::Styles, Place::Floating), PaneRequest::FloatRect(Pane::Styles, [360.0, 112.0, 270.0, 480.0])], + "floats under the pointer, clamped to the window" + ); + assert_eq!(title(&h, Pane::Styles).min, pos2(360.0, 116.0)); + + // Dropped within 32 pt of the right edge: docked at End. + h.state_mut().log.clear(); + let from = title(&h, Pane::Styles).center(); + drag(&mut h, from, pos2(780.0, 300.0)); + assert_eq!(h.state().log.last(), Some(&PaneRequest::Place(Pane::Styles, Place::Docked(Edge::End)))); + assert_eq!(edge(&h, Pane::Styles), R - 250.0); + // The floating rect's size is kept for the next float. + assert_eq!(h.state().states[&Pane::Styles].float_rect.map(|r| [r[2], r[3]]), Some([270.0, 480.0])); +} + +#[test] +fn escape_and_focus_loss_restore_a_floating_pane_during_a_move() { + let mut app = App::new(&[Pane::Styles]); + let start = [300.0, 100.0, 270.0, 400.0]; + app.states.insert(Pane::Styles, PaneState { place: Place::Floating, size: None, float_rect: Some(start) }); + let mut h = harness(app); + let from = title(&h, Pane::Styles).center(); + let float_rect = |h: &Harness<'_, App>| h.state().states[&Pane::Styles].float_rect; + + hold(&mut h, from, from + vec2(40.0, 30.0)); + assert_eq!(float_rect(&h), Some([340.0, 130.0, 270.0, 400.0]), "follows the pointer"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(float_rect(&h), Some(start)); + send(&mut h, vec![Event::PointerMoved(pos2(790.0, 300.0))]); + send(&mut h, vec![button(pos2(790.0, 300.0), false)]); + h.run_steps(1); + assert_eq!(h.state().place(Pane::Styles), Place::Floating, "the rest of the drag is ignored"); + assert_eq!(float_rect(&h), Some(start)); + + hold(&mut h, from, from + vec2(-60.0, 20.0)); + assert_eq!(float_rect(&h), Some([240.0, 120.0, 270.0, 400.0])); + h.input_mut().focused = false; + h.run_steps(1); + h.input_mut().focused = true; + send(&mut h, vec![button(from + vec2(-60.0, 20.0), false)]); + assert_eq!(float_rect(&h), Some(start)); +} + +#[test] +fn f6_cycles_through_the_panes_and_back_to_the_document() { + let mut h = harness(App::new(&[Pane::Nav, Pane::Comments])); + let focused_widget = |h: &Harness<'_, App>| h.ctx.memory(|m| m.focused()); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!(h.state().focused, Some(Pane::Nav)); + assert_eq!(focused_widget(&h), Some(pane_id(Pane::Nav).with("title"))); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!(h.state().focused, Some(Pane::Comments)); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!((h.state().focused, h.state().to_document), (None, 1)); + assert_eq!(focused_widget(&h), None); + key(&mut h, Key::F6, Modifiers::SHIFT); + assert_eq!(h.state().focused, Some(Pane::Comments), "Shift+F6 from the document goes to the last pane"); + let focus: Vec<_> = h.state().log.iter().filter(|r| matches!(r, PaneRequest::Focus(_))).cloned().collect(); + assert_eq!(focus, vec![PaneRequest::Focus(Pane::Nav), PaneRequest::Focus(Pane::Comments), PaneRequest::Focus(Pane::Comments)]); + + // A click in the document clears it; a click in a pane sets it. + click(&mut h, pos2(400.0, 300.0)); + assert_eq!(h.state().focused, None); + click(&mut h, pos2(100.0, 300.0)); + assert_eq!(h.state().focused, Some(Pane::Nav)); +} + +#[test] +fn one_slot_shows_the_last_pane_per_edge_beside_context_panes() { + let mut app = App::new(&[Pane::Pivot, Pane::Comments, Pane::Styles]); + app.policy = SidePolicy::OneSlot; + app.states.insert(Pane::Styles, PaneState::docked(Edge::End, 300.0)); + let mut h = harness(app); + assert!(rect(&h, pane_id(Pane::Comments).with("title")).is_none()); + assert_eq!(edge(&h, Pane::Pivot), R - 250.0, "the context pane stays, outermost"); + assert_eq!(edge(&h, Pane::Styles), R - 550.0); + assert!(rect(&h, pane_id(Pane::Pivot).with("title").with("close")).is_none()); + + let at = rect(&h, pane_id(Pane::Styles).with("title").with("close")).unwrap().center(); + click(&mut h, at); + h.run_steps(1); + assert_eq!(h.state().open, vec![Pane::Pivot, Pane::Comments]); + assert_eq!(edge(&h, Pane::Comments), R - 500.0, "the slot shows the remaining pane"); +} + +#[test] +fn rtl_mirrors_sides_and_edge_snaps() { + let mut app = App::new(&[Pane::Nav, Pane::Comments]); + app.rtl = true; + let mut h = harness(app); + assert_eq!(edge(&h, Pane::Nav), R - 250.0, "Start is the right edge"); + assert_eq!(edge(&h, Pane::Comments), L + 250.0); + + let from = title(&h, Pane::Nav).center(); + drag(&mut h, from, pos2(10.0, 300.0)); + assert_eq!(changes(h.state()), vec![PaneRequest::Place(Pane::Nav, Place::Docked(Edge::End))], "the left edge is End"); + // Both on End, outermost first in open order. + assert_eq!(edge(&h, Pane::Nav), L + 250.0); + assert_eq!(edge(&h, Pane::Comments), L + 500.0); +} + +#[test] +fn float_rect_is_finite_and_inside_the_bounds() { + let bounds = Rect::from_min_size(pos2(0.0, 100.0), vec2(800.0, 500.0)); + let min = vec2(200.0, 160.0); + assert_eq!(float_rect(Some([300.0, 150.0, 280.0, 300.0]), bounds, min), Rect::from_min_size(pos2(300.0, 150.0), vec2(280.0, 300.0))); + for junk in [[f32::NAN, 0.0, 1.0, -3.0], [5000.0, -5000.0, 1.0e9, 1.0e9], [f32::NEG_INFINITY, f32::INFINITY, f32::NAN, 0.0]] { + let r = float_rect(Some(junk), bounds, min); + assert!(r.is_finite() && bounds.contains_rect(r) && r.width() >= 200.0, "{junk:?} -> {r:?}"); + } + assert_eq!(float_rect(None, bounds, min).size(), FLOAT_DEFAULT.min(bounds.size())); +} + +#[cfg(feature = "serde")] +#[test] +fn pane_state_round_trips_through_json() { + let state = PaneState { place: Place::Docked(Edge::End), size: Some(280.0), float_rect: Some([1.0, 2.0, 270.0, 480.0]) }; + let json = serde_json::to_string(&state).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), state); +} + +#[test] +fn a_body_wider_than_its_pane_is_clipped_and_the_document_starts_at_the_pane_edge() { + struct Wide; + impl PaneContent for Wide { + fn title(&self, pane: &Pane) -> String { + format!("{pane:?}") + } + fn body(&mut self, ui: &mut Ui, _: &Pane) { + ui.allocate_exact_size(vec2(400.0, 40.0), Sense::hover()); + } + fn default_state(&self, pane: &Pane) -> PaneState { + PaneState::docked(if *pane == Pane::Comments { Edge::End } else { Edge::Start }, 250.0) + } + } + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, document: &mut Rect| { + PaneHost::new(Id::unique("panes")).show(ui, &[Pane::Nav, Pane::Comments], |_| None, &mut Wide); + *document = ui.available_rect_before_wrap(); + }, + Rect::NOTHING, + ); + h.run_steps(2); + let document = *h.state(); + assert_eq!(document.left(), L + 250.0, "{document:?}"); + assert_eq!(document.right(), R - 250.0, "{document:?}"); +} + +#[test] +fn each_title_bar_is_named_after_its_pane() { + let h = harness(App::new(&[Pane::Nav, Pane::Comments])); + assert_eq!(h.query_all_by_label("Move Nav pane").count(), 1); + assert_eq!(h.query_all_by_label("Move Comments pane").count(), 1); +} diff --git a/crates/ui/src/tabs/mod.rs b/crates/ui/src/tabs/mod.rs index 9dc33963..ab6d16ae 100644 --- a/crates/ui/src/tabs/mod.rs +++ b/crates/ui/src/tabs/mod.rs @@ -1,8 +1,9 @@ //! Tab fitting and accessible tab interactions without owning a dock or document model. //! //! The caller owns identity, localization, selection, painting, overflow menus and drag actions. -//! [`fit`] only assigns widths; [`Tab`] returns the complete egui response, including double-click -//! and drag phases, so a panel strip can collapse, reorder or tear off according to its app. +//! [`fit`] (tabs shrink before they overflow) and [`fit_whole`] (tabs keep their full width) only +//! assign widths; [`Tab`] returns the complete egui response, including double-click and drag +//! phases, so a panel strip can collapse, reorder or tear off according to its app. use egui::{Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2}; @@ -70,6 +71,48 @@ pub fn fit(natural: &[f32], selected: usize, available: f32, minimum_width: f32, TabFit { shown, overflow, overflow_width } } +/// Fit tabs at their natural widths; the tabs that do not fit go to an overflow menu. +/// +/// Tabs are shown in input order up to the first one that does not fit beside the selected tab +/// and an overflow button of `overflow_width`. The selected tab (or the final tab if `selected` +/// is out of bounds) is always shown, in its logical place: when it lies past that run, it takes +/// the place of the last tabs that fit. Only a selected tab wider than the strip minus the button +/// gets less than its natural width; its painter elides the label. A lone tab never reserves a +/// button. +/// +/// Dimensions are sanitized as in [`fit`], and the shown widths plus `overflow_width` never +/// exceed the sanitized available width. Work and storage are linear in the input count. +pub fn fit_whole(natural: &[f32], selected: usize, available: f32, overflow_width: f32) -> TabFit { + let available = extent(available); + let natural: Vec = natural.iter().map(|&w| extent(w)).collect(); + if natural.iter().map(|&w| f64::from(w)).sum::() <= f64::from(available) { + return TabFit { shown: natural.into_iter().enumerate().collect(), ..Default::default() }; + } + if let [width] = natural[..] { + return TabFit { shown: vec![(0, width.min(available))], ..Default::default() }; + } + let overflow_width = extent(overflow_width).min(available); + let budget = available - overflow_width; + let selected = selected.min(natural.len().saturating_sub(1)); + let active = natural.get(selected).copied().unwrap_or(0.0).min(budget); + let mut room = f64::from(budget - active); + let mut shown = Vec::new(); + let mut overflow = Vec::new(); + let mut full = false; + for (i, &width) in natural.iter().enumerate() { + if i == selected { + shown.push((i, active)); + } else if !full && f64::from(width) <= room { + room -= f64::from(width); + shown.push((i, width)); + } else { + full = true; + overflow.push(i); + } + } + TabFit { shown, overflow, overflow_width } +} + fn squeeze(natural: &[f32], indices: &[usize], total: f32, minimum: f32) -> Vec<(usize, f32)> { let width = |i: usize| natural.get(i).copied().unwrap_or(0.0); let sum_at = |cap: f32| indices.iter().map(|&i| f64::from(width(i).min(cap.max(width(i).min(minimum))))).sum::(); diff --git a/crates/ui/src/tabs/tests.rs b/crates/ui/src/tabs/tests.rs index 854cb392..7038f5e7 100644 --- a/crates/ui/src/tabs/tests.rs +++ b/crates/ui/src/tabs/tests.rs @@ -72,6 +72,48 @@ fn many_tabs_keep_the_last_selected_without_quadratic_membership_scans() { assert!(total(&f) <= 240.001); } +#[test] +fn whole_tabs_keep_their_width_and_the_selected_tab_replaces_the_last_that_fits() { + let natural = [60.0, 70.0, 80.0, 50.0, 90.0, 40.0]; + // 200 points: 22 for the button leaves 178. + let f = fit_whole(&natural, 0, 200.0, 22.0); + assert_eq!(f.shown, [(0, 60.0), (1, 70.0)]); + assert_eq!(f.overflow, [2, 3, 4, 5]); + assert_eq!(f.overflow_width, 22.0); + // The last tab, selected, takes the place of tab 1 and keeps its order. + let f = fit_whole(&natural, 5, 200.0, 22.0); + assert_eq!(f.shown, [(0, 60.0), (1, 70.0), (5, 40.0)]); + assert_eq!(f.overflow, [2, 3, 4]); + let f = fit_whole(&natural, 4, 200.0, 22.0); + assert_eq!(f.shown, [(0, 60.0), (4, 90.0)]); + assert_eq!(f.overflow, [1, 2, 3, 5]); + // Everything fits: no button. + let f = fit_whole(&natural, 4, 390.0, 22.0); + assert_eq!(f.shown.len(), 6); + assert!(f.overflow.is_empty() && f.overflow_width == 0.0); +} + +#[test] +fn only_a_selected_tab_wider_than_the_strip_is_narrowed() { + let f = fit_whole(&[60.0, 300.0, 70.0], 1, 200.0, 22.0); + assert_eq!(f.shown, [(1, 178.0)]); + assert_eq!(f.overflow, [0, 2]); + for selected in [0, 1, 2, usize::MAX] { + for available in [0.0, 1.0, 21.0, 22.0, 100.0, 200.0, 500.0] { + let natural = [60.0, 300.0, 70.0]; + let f = fit_whole(&natural, selected, available, 22.0); + let selected = selected.min(2); + assert!(f.shown.iter().all(|&(i, w)| i == selected || w == natural[i]), "{selected} at {available}: {f:?}"); + assert!(f.shown.iter().any(|&(i, _)| i == selected)); + assert_eq!(f.shown.len() + f.overflow.len(), 3); + assert!(total(&f) <= available + 0.001, "{available}: {f:?}"); + } + } + assert_eq!(fit_whole(&[80.0], 0, 10.0, 22.0).shown, [(0, 10.0)]); + let f = fit_whole(&[f32::NAN, f32::INFINITY, -5.0, 30.0], usize::MAX, f32::NAN, f32::MAX); + assert!(f.shown.iter().all(|&(_, w)| w.is_finite() && w >= 0.0) && total(&f) == 0.0); +} + struct TabTest { ctx: Context, labels: [String; 2], diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 24feb6f1..d513cf15 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -55,7 +55,7 @@ impl Harness { first: Box::new(Node::Tabs { panels: vec![CANVAS], active: 0 }), second: Box::new(Node::Tabs { panels: vec![LAYERS, PROPERTIES], active: 0 }), }), - floating: vec![Floating { panels: vec![BRUSHES], active: 0, rect: [38.0, 76.0, 210.0, 250.0] }], + floating: vec![Floating::single(vec![BRUSHES], 0, [38.0, 76.0, 210.0, 250.0])], }; let mut harness = Self { ctx, @@ -235,7 +235,7 @@ fn floating_brushes_merge_detach_and_redock_through_real_pointer_input() { assert_eq!(h.body_ids[&BRUSHES], brush_id); h.capture(&directory, "02-merged-main-tabs"); h.drag(h.tab(BRUSHES).center(), pos2((width - 240.0) * 0.5, 270.0)); - assert!(h.layout.floating.iter().any(|g| g.panels.contains(&BRUSHES)), "{name}: {:?}", h.applied); + assert!(h.layout.floating.iter().any(|g| g.contains(&BRUSHES)), "{name}: {:?}", h.applied); h.capture(&directory, "03-detached-again"); h.drag(h.tab(BRUSHES).center(), h.tab(LAYERS).left_center() + vec2(4.0, 0.0)); assert!(h.layout.floating.is_empty(), "{name}"); @@ -250,3 +250,188 @@ fn floating_brushes_merge_detach_and_redock_through_real_pointer_input() { } } } + +/// Each app's saved v1 layout goes through a docking session driven by real pointer input and a +/// command, then writes and reads back unchanged. +#[cfg(all(feature = "serde", feature = "testing"))] +mod v1_fixtures { + use craft_ui::docking::{DockCommand, DockContent, Hit, Layout, Node, Permissions, ResizeEdge, Side, testing::DockDriver}; + use egui::{Pos2, Rect, pos2}; + use serde_json::{Value, json}; + + struct Content; + + impl DockContent for Content { + fn body(&mut self, ui: &mut egui::Ui, panel: &String) { + ui.label(panel.as_str()); + } + fn logical_label(&self, panel: &String) -> String { + panel.clone() + } + fn permissions(&self, panel: &String) -> Permissions { + if panel == "canvas" { Permissions::PROTECTED } else { Permissions::default() } + } + } + + type Driver = DockDriver; + type Columns = Vec>>; + + /// App, file contents and the JSON pointer to a saved layout. + const FIXTURES: [(&str, &str, &str); 10] = [ + ("designcraft", include_str!("fixtures/v1/designcraft.json"), "/docking"), + ("designcraft", include_str!("fixtures/v1/designcraft.json"), "/customWorkspaces/0/docking"), + ("vectorcraft", include_str!("fixtures/v1/vectorcraft.json"), "/docking"), + ("lightcraft", include_str!("fixtures/v1/lightcraft.json"), "/docking/layout"), + ("pdfcraft", include_str!("fixtures/v1/pdfcraft.json"), "/docking/layout"), + ("cadcraft", include_str!("fixtures/v1/cadcraft.json"), "/workspace/docking/layout"), + ("soundcraft", include_str!("fixtures/v1/soundcraft.json"), "/docking/layout"), + ("soundcraft", include_str!("fixtures/v1/soundcraft.json"), "/mix_docking/layout"), + ("photocraft", include_str!("fixtures/v1/photocraft.json"), "/docking"), + ("photocraft", include_str!("fixtures/v1/photocraft.json"), "/panel_layout/docking"), + ]; + + /// The start dock's columns, each a list of groups. + fn start_columns(layout: &Layout) -> Columns { + fn find(node: &Node) -> Option { + match node { + Node::Dock { side: Side::Start, columns } => { + Some(columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect()) + } + Node::Split { first, second, .. } => find(first).or_else(|| find(second)), + _ => None, + } + } + layout.root.as_ref().and_then(find).unwrap_or_default() + } + + fn iconic(layout: &Layout) -> bool { + fn find(node: &Node) -> Option { + match node { + Node::Dock { side: Side::Start, columns } => columns.first().map(|column| column.iconic), + Node::Split { first, second, .. } => find(first).or_else(|| find(second)), + _ => None, + } + } + layout.root.as_ref().and_then(find).unwrap_or(false) + } + + fn hit(d: &Driver, hit: Hit) -> Rect { + d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits())) + } + + /// A point of `from` that no floating window above it covers, as drawn last frame. + fn grab(d: &Driver, from: &Hit, panel: &String) -> Option { + let rect = d.hit(from)?; + let own = d.layout.floating.iter().position(|w| w.contains(panel)); + let windows: Vec = d + .layout + .floating + .iter() + .filter_map(|w| { + let first = w.panels().next()?.clone(); + Some(Rect::from_min_max(d.hit(&Hit::Title(first.clone()))?.min, d.hit(&Hit::Resize(first, ResizeEdge::BottomRight))?.max)) + }) + .collect(); + let covered = |p: Pos2| windows.iter().enumerate().any(|(i, w)| own.is_none_or(|own| i > own) && w.contains(p)); + let mut x = rect.left() + 4.0; + while x < rect.right() { + let p = pos2(x, rect.center().y); + if !covered(p) { + return Some(p); + } + x += 4.0; + } + None + } + + /// Drag from an uncovered point of `from` to `to`; something must change. + fn drag(d: &mut Driver, from: Hit, to: Pos2, what: &str) { + let panel = match &from { + Hit::DragBar(p) | Hit::Title(p) => p.clone(), + other => panic!("{other:?}"), + }; + let at = grab(d, &from, &panel).unwrap_or_else(|| panic!("{what}: {from:?} is covered")); + let out = d.drag(at, to); + assert!(out.error.is_none() && !out.actions.is_empty(), "{what}: {out:?}\n{:?}\n{:?}", d.layout, d.hits()); + d.settle(); + } + + fn sorted(layout: &Layout) -> Vec { + let mut panels: Vec = layout.panels().into_iter().cloned().collect(); + panels.sort(); + panels + } + + #[test] + fn v1_layouts_dock_collapse_float_redock_and_reload() { + let mut seams = Vec::new(); + for (app, text, pointer) in FIXTURES { + let at = format!("{app}{pointer}"); + let file: Value = serde_json::from_str(text).unwrap(); + let layout: Layout = serde_json::from_value(file.pointer(pointer).unwrap().clone()).unwrap_or_else(|e| panic!("{at}: {e}")); + let panels = sorted(&layout); + let mut d = Driver::new(layout, Content); + let mut bars: Vec = Vec::new(); + for (h, _) in d.hits() { + if let Hit::DragBar(panel) = h + && !bars.contains(&panel) + { + bars.push(panel); + } + } + assert!(bars.len() >= 2, "{at}: two movable groups in {bars:?}"); + + // The first group's bar dropped on the window's start edge makes a dock. + drag(&mut d, Hit::DragBar(bars[0].clone()), pos2(2.0, 300.0), &format!("{at}: new dock")); + let columns = start_columns(&d.layout); + assert!(columns.len() == 1 && columns[0].len() == 1 && columns[0][0][0] == bars[0], "{at}: {columns:?}"); + + // The second group stacks below it, and a third goes into the seam between them. + let first = hit(&d, Hit::Tab(bars[0].clone())); + let bottom = d.area.bottom(); + drag(&mut d, Hit::DragBar(bars[1].clone()), pos2(first.center().x, bottom - 10.0), &format!("{at}: stack below")); + let third = bars[2..].iter().find(|p| grab(&d, &Hit::DragBar((*p).clone()), p).is_some()).cloned(); + if let Some(third) = &third { + seams.push(at.clone()); + let second = hit(&d, Hit::Tab(bars[1].clone())); + drag(&mut d, Hit::DragBar(third.clone()), pos2(second.center().x, second.top() + 2.0), &format!("{at}: seam")); + } + let columns = start_columns(&d.layout); + let heads: Vec<&String> = columns[0].iter().map(|group| &group[0]).collect(); + let expected: Vec<&String> = match &third { + Some(third) => vec![&bars[0], third, &bars[1]], + None => vec![&bars[0], &bars[1]], + }; + assert_eq!((columns.len(), heads), (1, expected), "{at}"); + + // Collapse the column to icons and expand it again. + d.click(hit(&d, Hit::Collapse(bars[0].clone())).center()); + d.settle(); + assert!(iconic(&d.layout), "{at}: collapsed"); + assert!(d.hit(&Hit::Icon(bars[0].clone())).is_some(), "{at}: icons"); + d.click(hit(&d, Hit::Collapse(bars[0].clone())).center()); + d.settle(); + assert!(!iconic(&d.layout), "{at}: expanded"); + + // Float the column by command, then drag its window back to the start edge. + let float: DockCommand = + serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap(); + let space = craft_ui::docking::float_space(&d.ctx, d.id).unwrap(); + let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space).unwrap(); + d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}")); + d.settle(); + assert!(start_columns(&d.layout).is_empty(), "{at}: floated"); + drag(&mut d, Hit::Title(bars[0].clone()), pos2(2.0, 300.0), &format!("{at}: dock back")); + assert_eq!(start_columns(&d.layout), columns, "{at}: docked back"); + + // Nothing was lost, and the layout reads back as written. + assert_eq!(sorted(&d.layout), panels, "{at}"); + let written = serde_json::to_string(&d.layout).unwrap(); + let read: Layout = serde_json::from_str(&written).unwrap(); + read.validate().unwrap_or_else(|e| panic!("{at}: {e}")); + assert_eq!(read, d.layout, "{at}"); + } + // The fixtures with a third movable group, uncovered, try the seam. + assert_eq!(seams.len(), 6, "{seams:?}"); + } +} diff --git a/crates/ui/tests/fixtures/v1/cadcraft.json b/crates/ui/tests/fixtures/v1/cadcraft.json new file mode 100644 index 00000000..996bb02e --- /dev/null +++ b/crates/ui/tests/fixtures/v1/cadcraft.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "workspace": { + "docking": { + "layout": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 220.0 + }, + "first": { + "Tabs": { + "panels": [ + "toolSets" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 300.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Stack": { + "entries": [ + { + "panel": "layers", + "open": true, + "height": 280.0 + }, + { + "panel": "properties", + "open": false, + "height": null + } + ] + } + } + } + } + } + }, + "floating": [ + { + "panels": [ + "quickCalc" + ], + "active": 0, + "rect": [ + 320.0, + 200.0, + 240.0, + 300.0 + ] + } + ] + }, + "hidden": [ + [ + "externalReferences", + { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": "properties" + } + }, + "size": null, + "stack": [ + true, + null + ], + "sibling": [] + } + ] + ] + } + } +} diff --git a/crates/ui/tests/fixtures/v1/designcraft.json b/crates/ui/tests/fixtures/v1/designcraft.json new file mode 100644 index 00000000..ea99c2c1 --- /dev/null +++ b/crates/ui/tests/fixtures/v1/designcraft.json @@ -0,0 +1,123 @@ +{ + "uiScale": 1.0, + "theme": "dark", + "docking": { + "root": { + "Tabs": { + "panels": [ + "properties", + "pages", + "layers", + "stroke" + ], + "active": 2 + } + }, + "floating": [ + { + "panels": [ + "swatches", + "color" + ], + "active": 1, + "rect": [ + 640.0, + 120.0, + 280.0, + 360.0 + ] + } + ] + }, + "dockingHidden": { + "gradient": { + "floating": [ + 640.0, + 120.0, + 280.0, + 360.0 + ], + "anchor": "swatches", + "placement": { + "Tab": { + "before": null + } + }, + "size": null, + "stack": null, + "sibling": [] + }, + "effects": { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": "stroke" + } + }, + "size": null, + "stack": null, + "sibling": [] + } + }, + "customWorkspaces": [ + { + "name": "Print", + "docking": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 268.0 + }, + "first": { + "Tabs": { + "panels": [ + "layers" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "properties", + "pages" + ], + "active": 0 + } + } + } + }, + "floating": [ + { + "panels": [ + "swatches" + ], + "active": 0, + "rect": [ + 120.0, + 160.0, + 280.0, + 360.0 + ] + } + ] + }, + "dockingHidden": { + "stroke": { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": null + } + }, + "size": null, + "stack": null, + "sibling": [] + } + } + } + ] +} diff --git a/crates/ui/tests/fixtures/v1/lightcraft.json b/crates/ui/tests/fixtures/v1/lightcraft.json new file mode 100644 index 00000000..5b558be6 --- /dev/null +++ b/crates/ui/tests/fixtures/v1/lightcraft.json @@ -0,0 +1,67 @@ +{ + "uiScale": 1.0, + "docking": { + "layout": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 270.0 + }, + "first": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 268.0 + }, + "first": { + "Tabs": { + "panels": [ + "library" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + } + } + }, + "second": { + "Tabs": { + "panels": [ + "tools" + ], + "active": 0 + } + } + } + }, + "floating": [] + }, + "hidden": [ + [ + "presets", + { + "floating": null, + "anchor": "canvas", + "placement": { + "Split": "Right" + }, + "size": { + "FixedSecond": 300.0 + }, + "stack": null, + "sibling": [ + "canvas" + ] + } + ] + ] + } +} diff --git a/crates/ui/tests/fixtures/v1/pdfcraft.json b/crates/ui/tests/fixtures/v1/pdfcraft.json new file mode 100644 index 00000000..2951f997 --- /dev/null +++ b/crates/ui/tests/fixtures/v1/pdfcraft.json @@ -0,0 +1,51 @@ +{ + "docking": { + "layout": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 272.0 + }, + "first": { + "Tabs": { + "panels": [ + "tools" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 330.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "inspector" + ], + "active": 0 + } + } + } + } + } + }, + "floating": [] + }, + "hidden": [] + }, + "docking_left_open": true, + "docking_right": null, + "recent": [] +} diff --git a/crates/ui/tests/fixtures/v1/photocraft.json b/crates/ui/tests/fixtures/v1/photocraft.json new file mode 100644 index 00000000..34aed08b --- /dev/null +++ b/crates/ui/tests/fixtures/v1/photocraft.json @@ -0,0 +1,151 @@ +{ + "docking": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 300.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Vertical", + "size": { + "Ratio": 0.5 + }, + "first": { + "Tabs": { + "panels": [ + "layers", + "channels", + "paths" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "adjustments", + "history" + ], + "active": 1 + } + } + } + } + } + }, + "floating": [ + { + "panels": [ + "properties" + ], + "active": 0, + "rect": [ + 120.0, + 160.0, + 320.0, + 440.0 + ] + } + ] + }, + "docking_hidden": { + "navigator": { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": "channels" + } + }, + "size": null, + "stack": null, + "sibling": [] + } + }, + "panel_layout": { + "docking": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 300.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Vertical", + "size": { + "Ratio": 0.5 + }, + "first": { + "Tabs": { + "panels": [ + "layers", + "channels", + "paths" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "adjustments", + "history" + ], + "active": 1 + } + } + } + } + } + }, + "floating": [ + { + "panels": [ + "properties" + ], + "active": 0, + "rect": [ + 120.0, + 160.0, + 320.0, + 440.0 + ] + } + ] + }, + "dockingHidden": { + "navigator": { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": "channels" + } + }, + "size": null, + "stack": null, + "sibling": [] + } + }, + "dockingCollapsed": [] + } +} diff --git a/crates/ui/tests/fixtures/v1/soundcraft.json b/crates/ui/tests/fixtures/v1/soundcraft.json new file mode 100644 index 00000000..51bd013d --- /dev/null +++ b/crates/ui/tests/fixtures/v1/soundcraft.json @@ -0,0 +1,180 @@ +{ + "docking": { + "layout": { + "root": { + "Split": { + "axis": "Vertical", + "size": { + "FixedSecond": 348.0 + }, + "first": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 168.0 + }, + "first": { + "Tabs": { + "panels": [ + "tracks" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 230.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "clips" + ], + "active": 0 + } + } + } + } + } + }, + "second": { + "Tabs": { + "panels": [ + "midi" + ], + "active": 0 + } + } + } + }, + "floating": [ + { + "panels": [ + "memory" + ], + "active": 0, + "rect": [ + 160.0, + 120.0, + 480.0, + 280.0 + ] + }, + { + "panels": [ + "history" + ], + "active": 0, + "rect": [ + 680.0, + 120.0, + 260.0, + 300.0 + ] + } + ] + }, + "hidden": [] + }, + "mix_docking": { + "layout": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 168.0 + }, + "first": { + "Tabs": { + "panels": [ + "tracks" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + } + } + }, + "floating": [ + { + "panels": [ + "memory" + ], + "active": 0, + "rect": [ + 160.0, + 120.0, + 480.0, + 280.0 + ] + }, + { + "panels": [ + "history" + ], + "active": 0, + "rect": [ + 680.0, + 120.0, + 260.0, + 300.0 + ] + } + ] + }, + "hidden": [ + [ + "clips", + { + "floating": null, + "anchor": "canvas", + "placement": { + "Split": "Right" + }, + "size": { + "FixedSecond": 230.0 + }, + "stack": null, + "sibling": [ + "canvas" + ] + } + ], + [ + "midi", + { + "floating": null, + "anchor": "tracks", + "placement": { + "Split": "Bottom" + }, + "size": { + "FixedSecond": 348.0 + }, + "stack": null, + "sibling": [ + "tracks", + "canvas" + ] + } + ] + ] + } +} diff --git a/crates/ui/tests/fixtures/v1/vectorcraft.json b/crates/ui/tests/fixtures/v1/vectorcraft.json new file mode 100644 index 00000000..d9c25542 --- /dev/null +++ b/crates/ui/tests/fixtures/v1/vectorcraft.json @@ -0,0 +1,91 @@ +{ + "dock_tab": "layers", + "docking": { + "root": { + "Split": { + "axis": "Vertical", + "size": { + "Ratio": 0.625 + }, + "first": { + "Tabs": { + "panels": [ + "properties", + "layers", + "libraries" + ], + "active": 1 + } + }, + "second": { + "Stack": { + "entries": [ + { + "panel": "color", + "open": true, + "height": 180.0 + }, + { + "panel": "swatches", + "open": false, + "height": null + }, + { + "panel": "stroke", + "open": true, + "height": null + } + ] + } + } + } + }, + "floating": [ + { + "panels": [ + "brushes", + "symbols" + ], + "active": 0, + "rect": [ + 200.0, + 140.0, + 300.0, + 400.0 + ] + }, + { + "panels": [ + "gradient" + ], + "active": 0, + "rect": [ + 540.0, + 180.0, + 260.0, + 320.0 + ] + } + ] + }, + "docking_hidden": { + "patternOptions": { + "floating": null, + "anchor": "color", + "placement": { + "Tab": { + "before": "swatches" + } + }, + "size": null, + "stack": [ + true, + 240.0 + ], + "sibling": [] + } + }, + "docking_icons": [ + "stroke" + ] +} diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 7ed8278e..30f6cd86 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -4,7 +4,9 @@ Status: final landing integration, 2026-10-25. The reviewed shared UI includes s The integration of migration ancestor `25da1314d443171b4adeaad2cd9454b273f35c6f` with main `2ab6049d9031bc8fae7f7df669b5fccaf5b9966f` passed formatting, strict workspace all-target clippy, workspace tests and the PDF all-feature suite in managed job `egui037-craft-libs-current-main-gates-20261024-2021`. Source and lock hashes were checked against that job before the local integration checkpoint. -New main `6e7f28518dd1cb57e1624bdc269c3c14316b13ae` adds docking, native floating windows and other shared-library changes. Its integration is separately planned and reviewed; its compiled, runtime and performance checks remain pending. Earlier test counts below are historical snapshots, not acceptance for that newer tree. App-pinned migration ancestors remain reachable. The prepared benchmark protocol has no accepted timing result; the completed egui text benchmark remains separately inconclusive. +New main `6e7f28518dd1cb57e1624bdc269c3c14316b13ae` adds docking, native floating windows and other shared-library changes. Its integration passed fresh review. That review identified an IME reset acknowledgement lost when a native floating window was blocked; the fix preserves ordered control messages in both raw and processed panel input, with a native-driver regression. A second review accepted the fix. + +Managed job `egui037-craft-libs-native-fix-gates-20261025-0328` passed formatting, strict workspace lint, workspace tests, UI all-feature lint/tests, the native docking example build, UI WASM check, and prepress/plate-test all-feature lint/tests with hash-verified licensed PDF test fonts. Its 121 test summaries total 1,723 passed, 0 failed and 25 ignored; overlapping feature suites are included in those totals. PDF all-feature tests also passed in job `egui037-craft-libs-new-main-ci-20261025-0242`. Further shared-crate feature/platform matrices, visible native-window workflows and final performance acceptance remain open. App-pinned migration ancestors remain reachable. The prepared benchmark protocol has no accepted timing result; the completed egui text benchmark remains separately inconclusive. Earlier test counts below are historical snapshots. Every push or new PR requires approval of its exact commit range and validation. Human review and merge are required. The baseline tables below preserve the original planning record. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 82324509..eef8db51 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -1,151 +1,635 @@ # Panel docking -`craft_ui::docking` lets an application move the same panel between docked tabs, side splits and -floating groups. The shared layer owns placement and interaction; the application owns each -panel's contents, commands, document state and persistence policy. +`craft_ui::docking` lets the user arrange an app's panels: tab groups, docks with columns, +splits, floating windows and iconic columns. craft-ui owns the layout model, the renderer and the +gestures. The app owns panel contents, commands, document state and where layouts are saved. -## Model and transactions +Office apps that draw their own document with a few task panes beside it use +[`craft_ui::panes`](ui-panes.md) instead. -`Layout

` contains an optional root `Node

` and floating tab groups. A node can be: +## Arrangements -- `Tabs`: ordered panel identities with one active index. -- `Split`: horizontal or vertical children, with a ratio or fixed first/second size. -- `Stack`: accordion entries with individual open state and optional content height. +One model serves every app. What differs between apps is the default layout and a few +`DockStyle` settings. An app builds its style with `DockStyle::from_ui`, `from_visuals` or +`default` and then sets fields; new fields can be added, so the struct has no literal form +outside the crate. -Choose a stable, nonlocalized panel ID type implementing `Clone + Eq + Hash`. Rendering also -requires `Debug + Send + Sync + 'static`. A panel identity occurs at most once in the layout. -Keep transient document/sequence tabs separate when they represent documents inside one panel. +| Arrangement | Layout | Style settings | Apps | +|---|---|---|---| +| Docks beside a canvas (the defaults) | `Node::Dock`s on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | `new_dock_zones` on | Creative apps with a canvas and panel docks | +| A split tree of tab groups | `Split`s and `Tabs` only. | `side_edge_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, `wheel_selects_tabs`, `middle_click_closes_tabs` on, `new_dock_zones`, `float_on_empty_drop` and `title_bar_docks` off; menu labels such as "Undock Panel" set on `float_label`, `float_group_label` and `close_group_label` | Apps whose whole window is frames | + +Sides are logical: `Side::Start` is the left edge, or the right edge when +`DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its +locale. Right to left, tab strips also mirror: the first tab is rightmost, the group header and +drag bar are at the left, and drop insertion slots follow. Accordion headers and floating title +bars (`chrome::TitleBar`, through `ChromeStyle::right_to_left`) mirror too. Frame splits keep +their sides. + +## Model + +`Layout

` holds an optional root `Node

` and a list of `Floating` windows. + +| Node | Holds | +|---|---| +| `Tabs` | A tab group: panels and the active index. | +| `Split` | Two children, horizontal or vertical, and a size: a ratio, a fixed first size or a fixed second size. A moved divider keeps the mode: a fixed side stores its new size in points, a ratio its new ratio. | +| `Stack` | Accordion entries, each open or closed, with an optional height. | +| `Dock` | A side and its columns. A `Column` has `Group`s, a width, and `iconic`. | + +A `Floating` window holds a tree like the docked layout's (`root`: splits, tab groups and +accordions, no docks), a rect, an optional native-window rect with the monitor it is on, and +`minimized`. Its `id` names the window across panel moves (see [Native windows](#native-windows)). +`Floating::single` makes a window with one group, `Floating::new` one with a tree, and +`Floating::stack` one with groups stacked top to bottom (a vertical split sized by the groups' +heights, as an app's own stacked layouts migrate); `panels()` and `contains()` read it. Each of +these calls makes a new window with a fresh `id`. Build a layout once and keep it, or give its +windows ids with `with_id`: a layout built anew each frame, as an unsaved default layout might +be, otherwise opens new OS windows each frame and loses their drags and places. Debug builds log +a warning when native windows are replaced by new ones with the same panels three frames running. Saved layouts keep loading: a window stored as one group (v1) or as a +stack of groups loads as that group or as a vertical split of the stacked groups, sized by their +heights. + +`P` is the app's panel id: stable, not localized, `Clone + Eq + Hash`. The renderer also needs +`Debug + Send + Sync + 'static`. A panel occurs at most once in a layout. + +Change a layout only through `Action`s: ```rust use craft_ui::docking::{Action, Floating, Layout, Node, Placement}; let mut layout = Layout { root: Some(Node::Tabs { panels: vec!["layers", "properties"], active: 0 }), - floating: vec![Floating { - panels: vec!["brushes"], active: 0, rect: [40.0, 80.0, 240.0, 300.0], - }], + floating: vec![Floating::single(vec!["brushes"], 0, [40.0, 80.0, 240.0, 300.0]).with_id(1)], }; layout.apply(Action::Move { - panel: "brushes", anchor: "layers", + panel: "brushes", + anchor: "layers", placement: Placement::Tab { before: Some("properties") }, })?; # Ok::<(), craft_ui::docking::DockError>(()) ``` -Every transaction validates the original layout before cloning or recursive mutation. It checks -the resulting layout before replacing the original. Missing or self targets, duplicate IDs, -invalid active indices, excessive depth/count and invalid geometry return `DockError`; they do -not remove the source panel. Tab removals retain the selected panel by identity when possible. -Empty source groups collapse. Moving within an accordion preserves its entry state and height; -unaffected split sizing and accordion entries remain unchanged. +- `Move`, `MovePanel`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and + `MoveWindow` move a group, a dock column or a floating window, named by any panel in it. +- `Open` reveals an absent panel as the last tab of its `anchor`'s group. Without an anchor it + becomes the root of an empty layout, else joins the first dock group that accepts tabs and is + shown, not minimized or in an iconic column (the end-side dock's first), else gets a new frame a quarter wide at the right edge of the docked + area. `OpenAt` takes any placement. +- A `Target` is `At { anchor, placement }`, `Float { rect }` or `NewDock { side }`. +- A `Placement` is a tab position, a split zone, a new group above or below (with an optional + preferred `height`), a new column, or a new frame on an outer edge. +- Containers take different placements: a dock column takes groups and columns, not splits. + `Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or + center zone onto what the anchor's container takes: a split in the frame tree, a new column on + that side or a new group above or below in a dock. A floating window takes splits as the + docked layout does; `Placement::Group` on a floating anchor splits its group above or below. +- A side split zone puts what moves in a new half-size split of the anchor's frame, and no + other frame changes size. The new split has its own divider, and the drop preview shows the + half the new frame takes. +- Other actions select, minimize, collapse to icons, resize splits (`ResizeSplit` in the docked + layout, `ResizeWindowSplit` in the floating window holding a panel), columns and groups, and + move a floating window to its own OS window (`SetNative`, and `SetMonitor` for the monitor it + is on, feature `native-windows`). +- A move between windows (docked to floating, floating to floating) is one action, checked as a + whole like any other. -`OpenAt` adds a currently absent registered panel at an explicit tab or side destination. `Open` -is the simpler group-reveal operation. Registry checks remain app-owned: the shared model cannot -know whether an ID names an actual panel or whether its content is supported by the application. +Every action checks the whole result before it commits. A bad target, a duplicate id, a stale +split path or invalid geometry returns a `DockError` and leaves the layout unchanged. The source +panel is never lost. `apply`, `apply_with_permissions` and `apply_all` return the panels the +actions closed, so the app can release their state; `closed_by` lists them before applying. -Use `apply_with_permissions` to prevent moving/closing a document canvas or merging a panel into -it. `Permissions::PROTECTED` disallows these operations while allowing side splits around it. -An app that requires a permanently docked panel also checks that policy before accepting a -transaction; an empty shared root is valid when all panels float. +`Permissions` limit what may happen to a panel: `movable`, `floatable`, `closable`, +`accepts_tabs` and `accepts_splits`. `apply_with_permissions` enforces them for every action, +whoever issues it. `Permissions::PROTECTED` keeps a canvas docked and open and takes neither tabs +nor side splits; a group dropped on it floats there. An app that opens panels beside its canvas +uses `Permissions { accepts_splits: true, ..Permissions::PROTECTED }`. -## Rendering and interaction +## Workspace -Existing renderers can convert their layout into the shared model for transactions and retain -their own panel chrome. `DropTarget` describes visible body/tab rectangles and whether a group -accepts tabs or splits. Supply targets back-to-front, including floating-window layer order. -`resolve_drop` returns a tab insertion or side/center placement and its preview rectangle. -The frontmost containing group blocks targets underneath even when it rejects a drop. +`Workspace

` is what an app saves: the layout, the hidden panels with where each one goes back, +an optional saved arrangement for Reset, and `locked`. -The optional `DockArea` renderer handles tab fitting and overflow, drag/drop previews, -Escape/focus-loss cancellation, split/accordion dividers and floating-group resize grips. It renders a -snapshot and returns `DockOutput.actions`; apply them through the application's command path. -With `serde` enabled, `Action` also serializes so pointer, keyboard and control-channel requests -can share that command path, including splitter changes, tab reorder and floating bounds. -Panel body callbacks receive stable global egui IDs, so moving a panel does not reset its widget -memory. The application should still keep durable panel/document data outside transient egui -widget memory. +| Method | Does | +|---|---| +| `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused; `SetNative`, which records the OS windows, still applies. | +| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else as `Open` beside `fallback`, or where `Open` without an anchor puts it. A group or window closed one panel at a time comes back as one group as its panels are shown again. | +| `reconcile(registered, permissions, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. A registered panel that cannot close, such as the canvas, is always shown. | +| `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. | +| `validate` | Check a loaded workspace before use. | -`DockStyle` supplies colors, font, spacing and localized action labels. `show_with_limits` also -accepts per-panel minimum/maximum sizes. Visible geometry clamps to the available area; a narrow -viewport relaxes minima proportionally. Persisted preferred sizes are unchanged by this drawing -clamp. A divider gesture produces an explicit resize action. +With the `serde` feature, `Workspace`, `Layout` and `Action` serialize. Files without a version +are v1 and load unchanged. craft-ui does not choose where files go; the app does. -Use `begin_drag` with the same area ID to carry an existing tab/header gesture into `DockArea` -when an app switches from its default arrangement to a customizable workspace. +## Rendering -Floating groups are egui areas in one viewport. Native child windows, cross-viewport drag -transport, OS window ownership and multi-monitor coordinate conversion remain the host's -responsibility. The model does not instantiate or reparent native windows. +`DockArea::new(id).show_content(ui, &layout, &style, &mut content)` draws a layout snapshot and +returns a `DockOutput`. It does not change the layout. The app applies `output.actions` through +its own command path, so pointer, keyboard and control commands share one path. -## Persistence and visibility +`DockContent

` is the app's adapter. It draws each panel body and gives its label, icon, +permissions, size limits, dock column heights, preferred floating size, panel menu and group header. The group header +sits at the end of the tab strip; `group_header_width` gets the strip width, so a header that +draws its own tabs can size them to the room left. An icon is an image (`icon`), or drawn in code by `paint_icon`, which +gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory +survives. -Enable `craft-ui`'s optional `serde` feature to serialize the shared model, or retain an existing -schema through an adapter. The serialization feature does not choose a filesystem location or -write preferences. Applications validate panel IDs, payload size and schema version before -committing loaded state. Bound recursive deserialization/conversion in the app as well as using -`Layout::validate`; the validator cannot retroactively protect an unbounded parser. +`DockOutput` also has: -To hide a panel without forgetting its placement, capture `Layout::location`, then close it. -Keep the resulting `Location` with the app's visibility state. `restore` uses stable neighbor -IDs, restores captured fixed sizing/floating bounds and recreates a previously closed accordion -group. If a neighbor was removed, it returns an unchanged layout and `MissingTarget`; choose an -explicit fallback such as a default side or remembered floating rectangle. Restoring multiple -panels closed from the same group in reverse close order preserves neighbor relationships. +- `focused`: the panel the user focused this frame: a clicked tab, a drop, or a press of any + pointer button in a panel body (protected panels included) or in the group header, + which focuses the group's shown panel. Commands, `raise_panel` and + workspace resets never set it; a host that tracks the last focused panel clears it when a reset + or command hides, closes or moves that panel. +- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (`maximize_item`) and + `DockEvent::ToggleAutoCollapse` and `DockEvent::FlyoutChanged { panel }` (docks): the + flyout opened, switched or closed (`None`). It comes once per change, whatever made it: an + icon, Escape, a press outside, a raised panel, `open_flyout` or `close_flyout`. +- `error`: a rejected gesture. -## Acceptance +Builder options on `DockArea`: -The shared tests cover lossless moves, tab reorder, stale targets, atomic errors, protected -canvases, accordion state, fixed sizing, hidden-panel restoration, bounded malformed layouts, -and a deterministic sequence of 800 panel moves. Pointer tests exercise dragging a floating -panel into the main tab strip and canceling a drag. +| Option | Use | +|---|---| +| `maximized(panel)` | Maximize Frame: that frame fills the area. | +| `hide_panels(HidePanels)` | Hide panels for Tab and Shift+Tab. Protected panels stay. | +| `auto_collapse_iconic(on)` | Close a flyout on Escape or a click outside (default on). | +| `raise_panel(panel)` | Raise a panel's floating window, or open its flyout, this frame. It moves no keyboard focus and does not set `DockOutput::focused`. | +| `locked(on)` | Draw `Workspace::locked`: every panel gets `Permissions::locked`, so nothing starts a move, float or close the workspace would refuse. | +| `blocked_rects(rects)` | App overlays that docking gestures must ignore. | +| `float_bounds(rect)` | Where floating windows, flyouts and drag ghosts may go (default: the whole viewport). Pass the viewport below a title bar drawn under a transparent OS title bar, so no floating window sits where a press moves the app window. | -`tests/docking_workflows.rs` renders a document and populated Layers/Properties/Brushes panels, -then uses actual pointer events to merge, detach and redock Brushes. It checks retained values -and widget identity at two widths, two display scales and both egui themes. To retain captures: +`DockStyle::close_menu_items` and `DockStyle::close_button` hide the built-in Close menu items +and the floating window ×. They hide controls only: a panel that stays `closable` still closes +by the app's own Close items and commands. + +`DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome (any control a +`Hit` names) rather than on a panel body. Focus follows only the user's input: a tab keeps keyboard focus until a command or a +workspace reset hides, closes or moves its panel, and then gives it up. A divider or a floating +window's resize grip holds focus only while the pointer drags it, so the app's shortcuts and +canvas arrow keys work right after; one reached with Tab or F6 keeps its focus, and arrow keys +resize it. Hovering focuses nothing. `begin_drag` hands a drag that started in the app's own chrome to the area. +`docking::chrome` has the floating frame, the title bar and `drop_preview` (the drop preview +docking and `craft_ui::panes` both draw) for apps that draw their own windows. + +## Tab strips + +Tabs start at their natural width, wide enough for the panel's name. When the strip is too +narrow, the widest tabs shrink first, to a common width; tabs whose names fit that width keep +their natural width. A tab narrower than its name cuts the name with "…" and shows it in full as a +tooltip. Shrunk tabs keep their hit areas, drag and keyboard behaviour, and accessibility labels +with the full names. + +No tab shrinks below `DockStyle::min_tab_width`: by default about three characters and the "…" +plus the tab's padding, 54 pt at the 12 pt font. Only when the tabs at that width do not all fit +do the rest go to the "…" button at the end of the strip, whose menu lists the hidden panels; +choosing one makes it active. The active tab is always shown, in its logical place, and the +others take the remaining room in their order. An active tab with less room than its minimum +width, or a lone tab wider than the strip, is cut the same way. The drag bar always keeps its +reserved width: tabs shrink, then overflow, before it narrows, and only a strip narrower than the +group header plus the bar shrinks it. A tab dropped on the "…" button goes to the end of the group. + +## Floating size + +A panel or group floated by a drag, a menu or a command without a rect takes its content height: +the title bar, the tab strip and the height the active panel's content used when it was last +drawn (the content ui's `min_rect`), at least 120 points. The most it takes is the height of +`DockContent::floating_size`, or 320 points for a panel leaving a dock or frame and a group +leaving a dock. A short panel therefore floats as a short window. Content that reaches the bottom +of its body (a panel that allocates its whole rect) has no height of its own: it floats at the +`floating_size` height, else at 320 points. A panel's width is its `floating_size` width, else +its docked width; a group keeps its docked width. +A flyout fits its content the same way, without the title bar. + +- A panel the area has not drawn floats at the `floating_size` height, else the docked height + capped at 320, or at a `FloatSpace::with_panel_size` size for commands. +- A group from a frame without a `floating_size` keeps its frame size. A dock column of several + groups floats at the tallest `floating_size` height, else its column height capped at 320. +- A rect from a command and a window the user resized keep their size; floating windows are not + refitted while open. +- Docked layout does not change: a column's last group still fills the column. + +## Gestures + +- A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags + the whole group. A column bar drags a column. A floating window's title bar drags the window; + with `title_bar_docks` a release over a target docks the whole window there, else it only + moves. +- Inside a floating window, tabs and drag bars drag and dock as they do in the docked layout, + into any window. Its groups split, join and resize like frames, with dividers and four-way + handles (`Divider::WindowSplit` and `Divider::WindowIntersection`, named by the window's first + panel). Double-clicking the title bar minimizes the window to its title bar, or to the tab strip + of its only group; so does double-clicking that group's strip. The group menu's float item + floats one group of a window that has several. Closing the window closes its panels; each + reopens where it was while the window or its neighbour in it is open, else in a window at the + window's last rect. +- A movable floating window resizes from every edge and corner. The top edge and corners are a + 4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on + the title bar's buttons. A resize keeps the opposite edges in place and stays within the size + limits and the screen. A focused resize handle moves its edge 8 pt per arrow key. +- Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. The + side zones are trapezoids along the four edges of the body below the tab strip, like a picture + frame, all four the same depth: `DockStyle::zone_depth` (40 pt), or a quarter of the body's + smaller side when that is less, so every frame keeps a center. A frame too narrow or short for two + halves of `DockStyle::min_pane` still splits when the frames beyond it can give the room, as they + do for a dragged divider; when they cannot, the drop joins the frame's tabs instead. The gap between two frames is no target. +- The outer edge band (`DockStyle::edge_band`) takes a drop as a new frame across the whole + window side: top and bottom, and left and right with `side_edge_zones`. It takes a quarter of + the window and is offered only when every frame keeps its minimum. Past the band, a frame's own + side zones apply. +- A dragged divider grows the frames that touch it on one side, up to their maximums. On the + other side the frame touching it shrinks to its minimum, then the next frame along the same + axis, and so on: the divider stops when every frame on that side is at its minimum. Frames + side by side across the axis shrink together, and the next frame gives way once one of them + is at its minimum. The line of frames ends at a split across the axis or at a dock; every + other frame keeps its size. Dividers between stacked panels and between the groups of a dock + column work the same way. Where a divider ends on another divider, a four-way handle + (`Divider::Intersection`) moves both. +- Every frame has the same minimum, `DockStyle::min_pane` (plus its tab strip), unless its panels + ask for more through `PanelLimits::min`. Frames have no maximum unless every panel in them asks + for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it + joins. +- A dock column stacks its groups top to bottom. A minimized group shows only its tab strip, and a + group the user resized keeps that `Group::height`. A group the user never resized (`height` is + `None`) takes the default height its panels ask for through `DockContent::dock_heights`. Groups + whose panels ask for a `fill` height share the rest of the column, and groups whose panels ask + for no heights take their minimum. When no group asks for a fill, the groups without heights + share the rest, and when every open group has a height, the last one fills the column. In a short column the groups at their default give way first, down + to their `compact` heights, so the filling group gets its `fill` height. When the column is still + too short, every group gives way down to its minimum, and past that the column scrolls. Defaults + give way nearest the filling group first: one is at its compact height before the next gives + way. Towards the minimums, groups give way in proportion to their room. A group takes the + largest default, compact and fill height of its panels. All three are body heights, without + the tab strip, like `PanelLimits`. +- Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, + a filled half for a split. With `side_edge_zones`, a drag over a group instead outlines each of + its zones that a drop would change something in (the side trapezoids, the center rectangle and + the tab strip; `zone_shape` gives the outlines) and fills the zone under the pointer. Sides the + group does not take, by its permissions or because a split leaves no room, are not drawn; nor is + the center of the group a tab comes from. Escape or losing window focus cancels + a drag. +- A drag in progress (a tab, group, column, window bar, native window bar or divider, in any of + the app's windows) also ends when the layout passed to the area is not the one it drew last + frame, with or without its returned actions applied: the app switched workspaces, ran Reset + or changed the layout by command. Sizes and rectangles are not compared, so an app may clamp + a moved window or adjust a split during the drag. Nothing is restored from before the drag; + windows go where the new layout has them. `cancel_drag` ends the area's drags at any other + time. A native window moved by the compositor (`ViewportCommand::StartDrag`, as on Wayland) + is not the area's drag: it ends when the user releases the button. +- With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such + as a panel dropped on a protected canvas. Without it, that release cancels the drag and + nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release + outside the app window floats in both cases. +- Ctrl (⌘ on macOS) held at the drop floats it wherever it lands. With `command_press_floats`, + the key held when a tab drag of a frame starts is enough. With `float_hint`, a hint under the + pointer names the modifier while a drag offers a docking target: `float_hint_label`, where + `{key}` becomes ⌘ or Ctrl. +- Double-click a tab or drag bar to minimize a group to its strip. +- Right-click a tab for its menu, or a drag bar for the group menu; Enter or Space on a focused + drag bar opens it too. A left click on the drag bar only focuses it. +- With `DockStyle::wheel_selects_tabs`, the mouse wheel over a tab strip selects the next or + previous tab. With `middle_click_closes_tabs`, a middle click on a closable panel's tab closes + it. +- A column's »/« button collapses it to icons, one icon per panel with a separator between groups. + Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the + canvas side. Drag an icon to move its panel, or an icon group's top edge (the separator line above it) to move the group. + +## Commands + +With the `serde` feature, `DockCommand

` is the shared control vocabulary. The app puts the +command id under `"command"` in the params and deserializes them. craft-ui has no `serde_json` +dependency. + +```rust,ignore +let mut params = params.clone(); +params["command"] = json!(id); +let command: DockCommand = serde_json::from_value(params)?; +if command.is_app_owned() { + return app.workspace_command(command); +} +let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())); +let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |panel| app.permissions(panel))?; +``` + +| Id | Params (`?` is optional) | +|---|---| +| `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?}` | +| `window.panel.iconic` | `{panel, iconic?}` | +| `window.panel.window` | `{panel, native?}` | +| `window.workspace`, `.new`, `.delete` | `{name}` | +| `window.workspace.reset`, `ui.dock.reset` | `{}` | +| `window.workspace.lock` | `{locked?}` | +| `ui.dock` | `{action}`, or `{operation, …}` | + +- `zone` is `tab`, `center`, `left`, `right`, `top`, `bottom`, `above` or `below`. `before` names + the tab to insert before; `null` appends. `left`, `right`, `top` and `bottom` follow + `Placement::for_zone`, so beside a docked panel they make a new column or group. `apply` and + `to_actions` take the layout's `right_to_left` for that. +- `group` moves the panel's whole group; `column` moves its dock column, or its whole window when + it floats. `side` is `Start` or `End`: what moves becomes a new last column of that dock, or + a new dock; when it already is that dock's last column, the command does nothing. +- `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a + new frame on that outer edge (`Placement::Edge`). A hidden panel is shown first. +- Showing a panel (`activate`, `dock` without a destination, `ui.dock` `open`) restores a hidden + panel's place; a panel never laid out opens as `Open` without an anchor does. +- `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see + Floating size). It goes + inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar. + The area records that geometry in a `FloatSpace` each frame; `float_space(ctx, id)` reads it + and `apply` and `to_actions` take it. Before the area has drawn, `FloatSpace::new(area)` floats + a panel at 260x320 and a group or column at its column width by 320. + `FloatSpace::with_panel_size(panel, size)` gives a panel the area has not drawn (a hidden + panel, a headless run) the host's own size; a size the area recorded wins. `x`, `y`, `width` and + `height` replace the matching values, and a group or column that is already a whole floating + window keeps its rect. +- Without a value, `minimized`, `iconic` and `locked` toggle. +- `ui.dock` operations are those of LightCraft, PDFCraft, CADCraft and SoundCraft: `open`, + `close`, `activate`, `float {rect}`, `moveFloating {rect}`, `move {target, zone?, before?}`, + `setStackOpen {open}`, `resizeStack {height}` and `resizeSplit {path, size}`. `rect` is + `[x, y, width, height]`. + +`to_actions` returns the actions a command makes without applying them. `apply` applies them all +or none and returns the panels it closed, so the app can release their state. Showing a hidden +panel and Lock change the workspace directly. Named workspaces and Reset belong to the app: +`is_app_owned` is true for them and `apply` refuses them. `DockCommand::IDS` lists every id for +the app's command registry. App-specific aliases stay in the app. + +## Native windows + +With feature `native-windows`, on a backend with egui viewports (`!ctx.embed_viewports()`, +eframe on desktop), every floating window opens in its own OS window: a Ctrl/Cmd-drag, Undock +Panel and Undock Panel Group, every other drop that floats, and the floating windows of a +restored layout. A window still in-window opens over its place in the app window (the main +viewport's `inner_rect.min` plus its rect). A window with a panel whose +`DockContent::allow_native` returns false (it returns true unless the app overrides it), or +that is not floatable, stays in-window. Floating windows get no taskbar or Alt-Tab entry +(`with_taskbar(false)`). + +On Wayland floating windows stay in-window: the compositor reports no window positions, so a +native window could not dock again by drag or reopen where it was. Their `native` rects are +kept, and the same layout opens native windows on X11, macOS and Windows. The dock area takes +Wayland to be the backend when `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (winit's rule) and +the main window has never reported its `outer_rect`; an app that forces X11 in code gets native +windows from the first frame. `DockArea::native_windows(bool)` overrides the choice either way. + +- **Identity.** `Floating::id` names the OS window, so a window keeps it when its panels change, + including when its first panel moves out. `Floating::new` and loading draw fresh ids, and + `Floating::with_id` sets one; a layout + loads with unique ids; a window that merges into another takes the receiver's id, and a whole + window that moves (floated again, or its only group dragged) keeps its own. +- **Kind and level.** `DockContent::window_kind` sorts panels into palettes + (`WindowKind::Palette`, the default) and viewers (`WindowKind::Normal`), such as a video + monitor on a second display. A window holding any `Normal` panel is a normal window, whichever + of its tabs shows; the rest are palettes. + - Palettes stay above other windows (`WindowLevel::AlwaysOnTop`), so clicking the document + does not bury them. While none of the app's windows has focus they hide + (`ViewportCommand::Visible(false)`), and they come back when one has it again. The hide + waits 250 ms, so focus moving between the app's own windows does not flicker. + - Normal windows have `WindowLevel::Normal` and stay shown while the app is in the + background. They stack with the main window by focus, below every palette; a drag over a + normal window that lies behind the main window targets the main window where the two + overlap, and the normal window outside the main window. + - Like palettes, normal windows have no taskbar entry; they come forward with the app + instead. When one of the app's windows gains focus after none had it for the hide delay, + the main window and each normal window get `WindowLevel::AlwaysOnTop` and then + `WindowLevel::Normal`, one window per frame, in their focus order from back to front. + They keep their order, nothing takes focus, and the window the user activated is raised + last, so it stays in front. The focus order counts a press in the main window as focusing + it, since the window system raises it then. The main window is left at the normal level, + so an app that keeps its main window always on top should not use normal windows. On + Windows the toggle puts the window in front of every window that is not topmost, without + activating it; on X11 the window manager raises a window as it takes + `_NET_WM_STATE_ABOVE`. egui 0.36 cannot make the main window a native window's owner, + which on Windows would bring the window forward with the main window, and + `ViewportCommand::Focus` would take focus. On + macOS nothing is raised: activation from the Dock or the application switcher brings all + of the app's windows forward, and a click brings the clicked window alone, as with the + app's other windows. +- **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style: + the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On + Wayland, where a window cannot be placed, the bar hands the move to the compositor. On + Windows and Linux the OS frame is off. On macOS the frame stays, with its title bar and + buttons hidden, for its resizing, rounded corners and standard window shadow. The dock area + paints no shadow of its own around a native window. +- **The ×.** Its place is set in `render/native.rs` (`MACOS_CLOSE`, `OTHER_CLOSE`): the + glyph's inset from the top and trailing edges, its size, and how far the hit area reaches + past the glyph on each side. On macOS the 9 pt glyph sits where macOS puts the close button + of its own windows: centered in the bar, its trailing side 8 pt in. AppKit's corner resize + area covers the glyph's trailing half and takes presses there, so the hit area reaches 10 pt + further toward the leading edge and down to the bar's bottom. Elsewhere the × is an 18 pt + square, its trailing side 7 pt in. With `CRAFT_DOCK_DEBUG_CLOSE=1` in the environment, + native windows outline the ×'s hit area in red and, on macOS, AppKit's resize areas in + orange. +- **Resize.** A movable window resizes from its edges and corners. On macOS AppKit does it, + with its own areas and cursors. Its corner area reaches about 15 pt into the window along + each edge (a 19 pt square, measured on macOS 26) and takes presses before the content sees + them. Over AppKit's areas the window sets no cursor of its own, which would flicker with + AppKit's. Elsewhere the window resizes from bands 6 pt deep along its edges, and each corner + is an L along both edges, 16 pt long and as deep as the edges. The bands leave out the bar's + buttons, so the corner's grab runs down beside the ×; elsewhere the top band lies over the + bar's drag area. The OS runs the resize from a band (`ViewportCommand::BeginResize`). +- **Close.** The bar's × and the OS close request close the window's panels, as the + floating window's × does. They reopen from the app's Window menu. A window with a panel that + may not close, or in a locked area, has no × and answers the OS close request with + `ViewportCommand::CancelClose`. +- **Place.** `Floating::native` is the desktop rect of the window's content (egui's + `inner_rect`), and the in-window `rect` follows it for builds that draw the window in-window. + The content is also the window's origin for drags; only `ViewportCommand::OuterPosition` + takes the outer position, which the renderer works out from the reported outer and inner + rects. Where the window system puts a new window's frame, not its content, at the place asked + for, the window is moved once so its content is there. When the app changes `native` (undo, + a workspace reset, a reloaded layout whose ids match), the OS window moves there; a report + that differs from `native` while a commanded move has not landed (up to 0.5 s) is ignored. `Floating::monitor` is the monitor + holding the window's center, from the list the app passes to `DockArea::monitors` (each a + `Monitor { name, rect, scale }` in desktop logical points). egui 0.36 reports no monitors, so + an app fills the list from its backend when it can; without one, `Floating::monitor_size` + keeps the monitor size egui reports. +- **Restore.** Only the windows of a loaded layout (a `native` the session has not shown yet) + are restored. A window made this session (a drag out, Undock, a Ctrl/Cmd drop) opens at the + rect it was given, only made safe, wherever that is on the desktop. A restored window opens on + its saved monitor when one with that name (or else that rect) is connected, moved in + proportion if the monitor's rect changed, and moved onto it when too little of its bar lies on + a monitor to grab (40 by 9 points). Otherwise it opens on the main window's monitor at the + same offset from that monitor's origin, wholly on screen. Without a monitor list, it is + clamped onto the main window's monitor, taken from `monitor_size` and the main window's + position. Hostile rects are made safe first. With a monitor list, a window whose `native` the + app changes, or whose bar drag ends with too little of the bar on a monitor, goes onto the + nearest monitor too. +- **Mixed scale factors.** Rects are stored in egui points as the window reports them, and + `Monitor::scale` is kept but not used. On macOS every window shares one point space, so this + is exact. On Windows egui-winit divides each window's physical position by that window's own + scale factor, so with monitors at different scales (say 100 % and 150 %) the points of windows + on different monitors are in different units. Across such monitors, a restored or + app-changed place can land off by the ratio of the scales, and a drop across windows on + different monitors can resolve against the wrong point. Same-scale setups are unaffected. +- **Shortcuts.** egui gives each OS window only its own input. Key, text, copy, cut and paste + events that reach a native window and that none of its widgets used come back in + `DockOutput::forwarded_events`, with the modifiers held in that window in + `DockOutput::forwarded_modifiers` (egui-winit sends Cmd/Ctrl+C, X and V as `Copy`, `Cut` and + `Paste` without the key). The app feeds them to the shortcut handling it runs on the main + window. A window where any widget has keyboard focus forwards nothing, and an Escape that + cancels a drag is not forwarded. +- **Modal dialogs.** egui's `Modal` blocks input only in the viewport it is shown in. While the + app shows one, it passes `DockArea::input_blocked(true)`. Each native window is then drawn + under `DockStyle::blocked_dim` (by default the panel fill at 60 %), and its bar, tabs, body + and resize edges take no input: its widgets are disabled, the pointer, key, text and scroll + events are dropped at the start of its frame, and a layer over its content takes the pointer, + so a popup left open takes no click either. It forwards only key releases, so a key pressed + before the block does not stay down in the app's shortcuts. It reports no `window_files`, + offers no drop targets, starts no move or resize, is not focused by `raise_panel`, and + answers the OS close request with `ViewportCommand::CancelClose`. On macOS it is not + resizable meanwhile. + - *Stacking.* A palette drops to `WindowLevel::Normal` while blocked, so a click on the main + window brings it in front of a palette that covers the modal. A shown palette stays shown + while the app is in the background, since showing it again on the app's return would raise + it over the modal and, on macOS, give it focus. When the app is activated meanwhile, the + main window is raised last, in front of the normal windows and of a palette the user + clicked. + - *Edges.* Turning blocking on cancels a drag in progress, and a widget of a native window + loses keyboard focus. A zoom gesture still shows in egui's `InputState::zoom_delta`, which + cannot be cleared; a widget that zooms only while hovered ignores it. egui smooths wheel + scrolling, so a scroll area in a native window may still scroll for about 100 ms after + blocking ends, with the remainder of a wheel turn made just before it began. + - Floating windows drawn in the main window need nothing: the modal covers them. +- **Input hook.** eframe runs `App::raw_input_hook` for the main window only. An app that + rewrites input there (say, Ctrl+click to a secondary click on macOS, or blocking input + during automation) passes the same rewrite to `DockArea::native_input_hook` every frame as a + closure. eframe's hook takes `&mut self`, so state it shares with the closure goes in an + `Arc>`. The dock area runs it on each native window's `RawInput` (`viewport_id` names the window; see + `DockArea::window_viewport`). It runs as an egui `Plugin::input_hook`, before egui reads the + input, so widgets see rewritten pointer and key events as if the window system had sent them. + It runs once per pass, so a frame that egui runs twice calls it a second time with the + events already taken. The hook must be `Send + 'static`, as egui plugins are. +- **Dropped files.** Files dragged over or dropped on a native window come back in + `DockOutput::window_files`, one `WindowFiles` per window that had any this frame: the + window's id and viewport, the panel whose body is under the pointer (else the window's + shown panel, since the window system may send no pointer moves during a file drag), the + pointer's last position in the window, and the window's `hovered_files` and + `dropped_files`. Files on the main window stay in its own `RawInput`. +- **Drags.** Tabs, groups and whole windows drag between the app's OS windows. + - *Ownership.* The window where the button went down owns the drag: the window system sends it + the pointer's moves and the release while the button is held, inside the window or not. So + the pointer leaving a window (`PointerGone`) is not a release. A drag ends on release, + Escape (pressed in any of the app's windows, since keys go to the one with keyboard focus), + when none of the app's windows has focus, or when its window closes or its panel goes. + - *Hit-testing.* The owner adds its desktop origin to the pointer: a native window's reported + `inner_rect.min`, or while it moves by its bar, the place it was last put less the pointer's + offset from the pressed point. The window under that + desktop point is a floating native window in front of the main window first (palettes, then + normal windows, most recently focused first), then the main window, then a normal window + behind it. A window moving by its bar is never its own target. A point in no window is on the + desktop. + - *Targets and previews.* Each window draws its drop targets in its own coordinates, and the + window under the pointer draws the drop preview (zone, dock line, ghost) in its own frame. + The source window shows only the "Hold {key} to undock" hint. The release is resolved at the + end of the main window's frame, after every native window has drawn. + - *Drops.* Over a window, a tab or group docks or joins there with the usual targets: dock lines + and frame zones in the main window, frame zones and tab rows in floating windows. Over the + desktop, it floats into a new OS window placed so the grabbed tab stays under the pointer + (the window's top-left is the pointer minus the grab offset, less the bar's height). A + Ctrl/Cmd-drag floats there wherever it is released. + - *Moving a window.* Dragging the bar, the only tab of a window, or the drag area of its only + group moves the window, so the pressed point stays under the pointer. Each move is worked + out from a pointer move of that frame and the window's reported place, which the pointer's + position is relative to; a move commanded earlier may land late. A frame with no pointer + move leaves the window. X11 sends no move whose position in the window repeats the last + one, so while the pointer keeps exact pace with the window the window can trail it by one + move until the next. Released over a target in another window, the whole window docks or + joins there as its groups (`Action::MoveWindow`). The bar docks only with + `DockStyle::title_bar_docks`; the only tab or group docks either way, since it is the way + back in with that flag off. Targets: in the + main window a dock line, a frame's tab row or edge zone, or the layout's outer edge; in a + floating window a frame zone or tab row. A frame body's center takes a window moved by its + only tab or group, as that tab or group drag would, but not one moved by its bar. Over + the desktop, or with Ctrl/Cmd held, it stays where it was moved, and the layout takes its + place once, at the release. Escape puts it back. An + in-window floating window's title bar still splits only groups of other floating windows. + - *Wayland.* With `DockArea::native_windows(true)` on Wayland, or wherever `outer_rect` is + `None`, desktop positions are unknown, so drops across windows cannot be resolved. A drag + works inside its own window, and a release outside it floats a new window that the + compositor places. Moving a window hands the move to the + compositor, which never docks it. + +On the web and wherever egui embeds viewports, nothing changes: floating windows stay in the +page at their in-window rect, `native` and the monitor fields are kept but unused, the input +hook never runs, and `forwarded_events` and `window_files` stay empty. Without the feature, the fields exist and are never set by the +renderer. + +## Labels + +Every visible and accessibility label is a `*_label` field of `DockStyle`, with an English +default: title case for menu items, sentence case for names only screen readers announce. `DockStyle::localized` sets them all from one keyed lookup: + +```rust,ignore +let style = DockStyle::from_ui(ui).localized(|key| app.strings.get(key).cloned()); +``` + +Keys are `dock.close`, `dock.float`, `dock.collapseToIcons` and so on; the rustdoc of `localized` +lists them all. `DockContent::label` and `action_label` can shape panel and action text further; +the logical label stays the accessibility name. + +## Testing + +`DockArea::hit_rects(ctx, id)` lists every gesture source drawn last frame as a `Hit` with its +rect: tabs, drag bars, borders, column bars, »/« buttons, title bars, close buttons, icons, icon +grips, resize edges and dividers. It is always compiled, so an app can return it from a +`ui.docking` control method next to the layout and the focused panel. + +`DockArea::hit_id(id, &hit)` is the egui widget id of a control, for tests and automation that +focus it or read its response. `DockArea::floating_layer(id, &window)` is a floating window's +egui layer: compare its place in `memory.layer_ids()` with another layer to tell whether the +window covers it, and read its rect with `memory.area_rect(layer.id)`. A window the area does not +draw (its panels hidden, or a frame maximized) keeps its last rect there, so check +`memory.areas().is_visible(&layer)` first. + +The `testing` feature adds `docking::testing::DockDriver`. It runs a `DockArea` in a headless +egui context with real pointer and key events, and applies the actions each frame emits. + +```rust,ignore +let mut driver = DockDriver::new(app_layout(), Panels::default()); +let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap(); +driver.drag(bar.center(), egui::pos2(2.0, 300.0)); +driver.settle(); +assert!(driver.hit(&Hit::ColumnBar(PanelId::Layers)).is_some()); +``` + +It also has `press`, `release`, `click`, `hold`, `key`, `focus`, `hits`, and checks for painted +previews (`filled`, `outlined`). With `native-windows`, `with_native_host(origin)` stands in for +OS windows: the main window's content sits at `origin` on the desktop, and each native window +runs its own frame with its own input and desktop rect (`host()`, `window_step`, +`window_click`). `NativeHost` can report a monitor size (`monitor_size`), give windows a frame +between their outer position and content (`frame`), land `OuterPosition` a frame late (`lag`), +and report no positions (`no_position`, as on Wayland). A `HostWindow` takes files dragged +over it (`hovered_files`) or dropped on it (`dropped_files`), and `with_native_input_hook` +passes a hook to `DockArea::native_input_hook` every frame. + +`crates/ui/examples/docking_native.rs` (`cargo run -p craft-ui --example docking_native +--features native-windows`) shows a dock with two native floating windows and prints every +action and window place, for checking moves on a real window system. + +craft-ui's own tests in `crates/ui/tests/docking_workflows.rs`: + +- Load every app's v1 layout fixture, dock groups into a new dock, a bottom band and a seam, + collapse and expand the column, float it by command and drag it back, then check the layout + reads back as written. +- Render Layers, Properties and Brushes panels with real content, and merge, detach and redock + Brushes by pointer at two widths, two scales and both themes. Set `CRAFT_UI_DOCK_VISUAL_DIR` + to keep the captures: ```sh CRAFT_UI_DOCK_VISUAL_DIR=/absolute/output/path cargo test -p craft-ui --test docking_workflows --all-features ``` -These are offscreen egui checks. An adopting application still needs command, content, workspace -reload, theme and input tests using its own renderer and persistence adapter. +An adopting app still tests its own commands, contents, saved workspaces and themes. -## Application chrome and activation +## What stays in the app -`DockArea::show_customized` accepts one mutable `DockContent

` adapter, with body, -panel-menu, group-header and label methods. Apps queue commands in these callbacks and -apply them after rendering the readonly snapshot. `group_header_width` reserves a bounded -trailing portion of the existing tab strip; its child UI is clipped to that region. -Collapsed accordion entries still have panel menus; collapsing tab groups is host policy. - -`label` returns `WidgetText`, including an application-shaped galley, used for tab width, -painting and menu labels. The separate logical label callback supplies tab accessibility -text. `action_label` independently shapes the localized stock Float/Move/Close menu -text without receiving a panel identity. Its default returns unchanged logical `WidgetText`; -custom visual text never replaces the action's logical accessibility label. Panel targets -and overflow entries continue to use `label`. Original `show` and `show_with_limits` call -sites remain supported. - -Apply `Action::Activate` in the host, then use `DockArea::raise_panel(panel)` on the next -frame to raise the owning in-viewport floating layer and focus the panel tab. This request -does not mutate the serialized layout or persist z-order. Floating area identity still -derives from the first panel; native-window identity remains host-owned. - -Floating groups have a distinct 12-point grip in the existing tab strip. Moving it emits -`MoveFloating` for the whole group; dragging an individual tab detaches just that panel. -`DockOutput::group_drag` and `group_drop` expose the group identity and release position -for app-owned atomic group docking policies. Escape/lost focus restores the group's -starting rectangle. No generic group docking or app command registry is inferred. - -Hosts supply overlay rectangles via `blocked_rects` before showing the surface. These -block drop previews, release actions and float fallback at the pointer, including overlays -appearing mid-drag. Hosts must provide current overlay geometry or cancel docking when -modal state prevents interaction; later-drawn overlays cannot be discovered retroactively. - -`DockArea::chrome_has_focus(ctx, layout)` classifies focus on rendered dock tabs, -collapse buttons, overflow, split/stack dividers, floating resize corners and group -grips. It excludes panel content and custom header/menu widgets and rejects invalid -layouts. Hosts can permit global commands from chrome while reserving Enter, Space, -Tab, Escape and arrow keys for activation, traversal, cancellation and resizing. -Content widgets retain their usual keyboard ownership. The renderer owns the IDs; -hosts do not need to reconstruct them. +- The panel registry, panel contents and document state. +- Named workspaces, Reset, and when and where layouts are saved. +- Menus and key bindings outside the docking chrome. +- Native window ownership on platforms without egui viewports. diff --git a/docs/ui-panes.md b/docs/ui-panes.md new file mode 100644 index 00000000..a060411d --- /dev/null +++ b/docs/ui-panes.md @@ -0,0 +1,113 @@ +# Task panes + +`craft_ui::panes` draws task panes around an app's own document: Navigation, Styles, Comments, +Format Shape and the like. Each pane is an egui side or bottom panel, or a floating frame. The +document stays the app's `CentralPanel`. + +## Panes or docking + +| Use | When | +|---|---| +| `panes` | Office-style apps (WordCraft, DeckCraft, GridCraft). A few panes at the sides of a document the app draws itself; the app's commands own which panes are open. | +| `docking` | Creative apps whose whole window is panels: tab groups, splits, stacked groups, workspaces, a protected canvas panel. See [ui-docking.md](ui-docking.md). | +| plain egui panels | Fixed view parts: slide thumbnails, notes, formula bar, sheet tabs, status bar. | +| `egui::Window` | Free tool windows that never dock (Read Aloud, Power Query). | + +`panes` needs no docking types; it uses only the docking chrome (`FloatingFrame`, `TitleBar`, +`live_move`). + +## API + +```rust +use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneRequest, PaneState, Place, SidePolicy}; + +impl PaneContent for MyPanes<'_> { + fn title(&self, pane: &Pane) -> String { tr(pane.title()) } + fn body(&mut self, ui: &mut egui::Ui, pane: &Pane) { /* the pane's contents */ } + fn default_state(&self, pane: &Pane) -> PaneState { PaneState::docked(Edge::End, 250.0) } + // Optional: size_range (200..=520), movable, closable, menu (app items in the ▾ menu). +} + +// Every frame, after the top and bottom bars and before the CentralPanel: +let out = PaneHost::new(egui::Id::unique("task-panes")) + .rtl(ui_is_rtl) + .policy(SidePolicy::Columns) + .show(ui, &open_panes, |pane| prefs.pane_state(pane), &mut MyPanes { app }); +for request in out.requests { + match request { + PaneRequest::Close(pane) => app.run(pane.close_command()), + PaneRequest::Place(pane, place) => prefs.set_place(pane, place), + PaneRequest::Resize(pane, size) => prefs.set_size(pane, size), + PaneRequest::FloatRect(pane, rect) => prefs.set_float_rect(pane, rect), + PaneRequest::Focus(_) => {} + } +} +egui::CentralPanel::default().show(ui, |ui| { + let response = document(ui); + if out.to_document { + // F6 or Shift+F6 left the panes: the document takes keyboard focus. + response.request_focus(); + } +}); +``` + +| Piece | Behavior | +|---|---| +| `Edge::{Start, End, Bottom}` | Logical edges. `Start` is left, or right with `.rtl(true)`. | +| `SidePolicy::Columns` | Every docked pane shows; panes on one edge sit side by side in `open` order, the first outermost. | +| `SidePolicy::OneSlot` | One closable pane per edge: the last one in `open`. Non-closable context panes (PivotTable Fields) show beside the slot. | +| `.max_fraction(f)` | One docked pane takes at most `f` of the host's width (height for bottom panes). | +| Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. The title bar mirrors with `.rtl(true)`. Its accessible name is `PaneLabels::move_pane` with `{title}` replaced by the pane's title ("Move Styles pane"). | +| Size | A docked pane reserves exactly its size and clips its body, so a body wider than the pane never pushes into the document. | +| Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped; dropped within 32 pt of the host's left or right edge it docks there. | +| Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop within 32 pt of the left or right edge docks it. Rects are clamped to the host area. | +| Focus | `out.focused` is the pane holding keyboard focus or last clicked; a click or focus in the document clears it. F6 and Shift+F6 cycle through the shown panes and then report `out.to_document`: give focus to the document. `.f6(false)` leaves F6 to the app, which calls `panes::focus_next(ctx, id, reverse)` instead. | + +State: + +- The host never stores which panes are open and never writes files. +- `states` returns the app's saved `PaneState` (place, size, float rect; serde with the `serde` + feature). Return `None` to let the host keep the state in egui memory for the session, as + egui panels keep widths today. +- Apply `Resize` and `FloatRect` as they come: they arrive every frame of a gesture, and an + Escape sends the start rect back. +- `panes::float_rect` brings a saved rect back inside the window, whatever the file says. + +## Adoption + +### WordCraft (`Columns`) + +- Add a `Pane` enum for the nine panes and implement `PaneContent` by calling the existing + bodies; drop their `header()` calls, since the host draws the title and ×. +- Build `open` each frame from the `ViewState` flags, in today's order: SmartArt Text Pane (only + while SmartArt is selected), Navigation and Clipboard at `Start`; Styles, Style Inspector, + Accessibility, Alt Text, Restrict Editing and Comments at `End`. Default widths as today. +- `Close(p)` runs the pane's existing command (`view.navigationPane {value: false}`, …). Command + ids and `nav_pane` in `ui.json` stay as they are. +- Styles takes #2584's behavior through the host: map `StylesPaneDock::{Left, Right, Floating}` + to `Place` (`Left` is `Docked(Start)` in left-to-right interfaces), run `view.stylesPaneDock` + on `Place`, keep the float rect in UI prefs. #2584's own drag, snap and `float_rect` code goes. +- Other panes return `None` from `states`; their widths stay session-only, as now. +- Pane title bars are focusable. Check that Tab in the canvas still stays in the document, so + panes stay off its keyboard focus. Use `.f6(false)` if F6 is bound elsewhere. + +### DeckCraft (`OneSlot`) + +- `open` is `ui.pane` (one entry) at `End`; `states` returns `PaneState::docked(Edge::End, + pane_width)`; `Resize` writes `pane_width`; `Close` clears `ui.pane` as the × does today. + `size_range` is 240..=520. The `ui.json` fields stay. +- Format, Animation and Selection return `movable = true`; the rest `false`. Saving their place + needs new optional `ui.json` fields (`#[serde(default)]`). +- Thumbnails, outline, notes and the #835 canvas shell stay plain panels. +- F6 from the host closes the ui-parity F6 gap. + +### GridCraft (`OneSlot`, `.max_fraction(0.5)`) + +- `open` is the task slot pane (`grid.pane`, or "comments" while `session.comments_pane`) plus + PivotTable Fields while the active cell is in a pivot. PivotTable Fields returns + `closable = false` and `movable = false`, so it stays beside the slot. +- `Close("comments")` runs `review.showComments {on: false}`; agents still drive it through the + engine. +- No pane prefs today: return `None` from `states`. +- Code Editor (#1193) and Queries & Connections (#1203) become slot panes. The formula bar, + sheet tabs and status bar stay plain panels. diff --git a/scripts/plate-check.sh b/scripts/plate-check.sh index 1050e867..d10a4a90 100755 --- a/scripts/plate-check.sh +++ b/scripts/plate-check.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Separate every plate-test scene with Ghostscript (local only, never CI) and judge the plates. +# With --flatten, also flatten each scene with craft-prepress's flattener and judge that page. # Ghostscript is `gs` on PATH, or CRAFT_PLATE_TEST_GS. set -euo pipefail root=$(cd "$(dirname "$0")/.." && pwd) @@ -8,5 +9,17 @@ if [[ -z "${CRAFT_PDF_TEST_FONT_DIR:-}" && -f "$fonts/Inter-Regular.ttf" ]]; the export CRAFT_PDF_TEST_FONT_DIR="$fonts" fi cd "$root" -cargo test -p craft-plate-test --test ghostscript -- --ignored --nocapture -echo "report: ${CARGO_TARGET_DIR:-target}/plate-test/report.md" +case "${1:-}" in + "") + cargo test -p craft-plate-test --test ghostscript -- --ignored --nocapture + echo "report: ${CARGO_TARGET_DIR:-target}/plate-test/report.md" + ;; + --flatten) + cargo test -p craft-prepress --features flatten --test flatten_plates -- --ignored --nocapture + echo "report: ${CARGO_TARGET_DIR:-target}/plate-test/flattened/report.md" + ;; + *) + echo "usage: $0 [--flatten]" >&2 + exit 2 + ;; +esac diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 2c0093cb..1627665f 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -326,3 +326,21 @@ The snapshot also preserves PdfCraft #1477 (WinAnsi Unicode fallback for TrueTyp fonts without Encoding) and #1499 (byte-aligned packed TIFF predictor rows). Synthetic tests cover 1/2/4/8-bit RGB rows and rendering, plus embedded TrueType text with absent or partial ToUnicode maps and explicit encoding precedence. + +## Craft patches for the flattener + +Local changes for craft-prepress's PDF-in adapter (`craft_prepress::flatten::pdf`), which reads +pages through a `Device` that keeps colors as inks and embeds the source fonts again. Code +comments label them `Craft patch (flatten)`. + +| Files | Change | +|---|---| +| hayro-interpret `font/outline.rs`, `font/cid.rs` | `OutlineFontData::embedded`: true when the bytes come from the PDF's `FontFile*`, false for a non-embedded CID font's substitute from the font resolver | +| hayro-interpret `font/outline.rs`, `font/cid.rs`, `font/blob.rs` | `OutlineFontData::index`: the face index of the bytes in a font collection, as the resolver returned it | +| hayro-interpret `font/outline.rs`, `font/type1.rs`, `font/mod.rs` | `OutlineGlyph::font_data` returns the bare CFF bytes of a simple `FontFile3/Type1C` font, with `embedded: true`; its glyph ids index the CFF. The descriptive fields (`postscript_name`, `weight`, flags) are left empty | +| hayro-interpret `device.rs` | `Device::set_overprint(fill, stroke, mode)`: the `op`, `OP` and `OPM` for the paints that follow. `Device::set_isolated`: the group just pushed is isolated (`/I true`). Both default to no-ops | +| hayro-interpret `interpret/path.rs`, `interpret/text.rs`, `x_object.rs` | `set_overprint` is called where `set_blend_mode` is for fills, strokes (the `sh` operator included), glyphs and images; `set_isolated` after an isolated group is pushed | +| hayro-interpret `color.rs` | `Color::ink_paint`: the inks a color paints, as plate mode separates it (`color::InkPaint`). `Color::spot_alternate`: a spot ink at 100% (`color::SpotAlternate`), CMYK when the alternate space is DeviceCMYK. `Color::opacity` and `Color::is_device_cmyk` | +| hayro-interpret `color.rs` | `color::InkSpace`, `ColorSpace::ink_space` and `ColorSpace::ink_values`: how a space's colors are kept as inks (gray, CMYK, its colorants, `All`, `None`) and colors converted in batches, process color as `Color::ink_paint` converts it. `ColorSpace::components`; `ColorSpace::is_device_cmyk` and `ColorSpace::spot_alternate` public | +| hayro-interpret `types.rs`, `x_object.rs` | `RasterImage::with_inks`: an image's samples in its own space's inks (`InkImage`), `/Decode` and `/Matte` applied, transfer functions left out, with its alpha at the alpha's own size | +| hayro-interpret `interpret/mod.rs` | The `sh` operator drops the shading's `/Background`, which only a shading pattern paints (ISO 32000-2 §8.7.4.5.1) | diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index b1a9faac..509fd7da 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -182,7 +182,7 @@ "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "85391b386b0c34b6bb1d6757c3d083ca07764062d085e0324456a377d6d520fc" + "sha256": "025186ea5726339068f66d39bc0f5eafb05f6223e4a9a74b9944c20d0fbe7886" }, { "path": "src/context.rs", @@ -200,7 +200,7 @@ "path": "src/device.rs", "source_sha256": "6f0c1203d1e60724cdd11e9a230249247a70f578c4a7ccb9bf39a23411b32db6", "source_git_blob": "36c64a23fd23f6c4abad941077cf93296a67841f", - "sha256": "abe1a0a835deb409b4d466e1d0f041a0e53ef8981be0929070afc77258cd8434" + "sha256": "4465153ace47c6893958f187a37579dd87139e04505749483aea139f5f2bac99" }, { "path": "src/encode.rs", @@ -212,13 +212,13 @@ "path": "src/font/blob.rs", "source_sha256": "30a9dadf498829ee03eb1ddc6deaeb09a06ee42415f288d46f4a67640582c575", "source_git_blob": "6eabc26b9bc30bdc51f0b5fdd5e4b556d7583ec5", - "sha256": "109170b39c6e2c8c95a973c73a174e749b0d25e78b4c1e40c8a3ae4dfb220db7" + "sha256": "8c39f3ef95c48054d4aacaeb17189193c075df42fc767eee596bc4dbf2453d5f" }, { "path": "src/font/cid.rs", "source_sha256": "c1dbe7fe5b7f7092d97b84063008df54aebf87db4ba1fdb3a179fab9fe6674b8", "source_git_blob": "b99dcbc116ca1169df3a229eafd05ee5b9204626", - "sha256": "a0f7e140ddea72e08e78ebc996d4136947fd9bb28911b8745bf05d4941fcf320" + "sha256": "6c9927c47893edacab4a3258d4ec72b4d3cc2dd25c518e308884321110847ab6" }, { "path": "src/font/generated/glyph_names.rs", @@ -290,13 +290,13 @@ "path": "src/font/mod.rs", "source_sha256": "7e6411927d91205ca76c6b59f9d737cd1104d066f8cfcc0a87f481016ef30313", "source_git_blob": "7c8c8d086d59139df791cdb6952506473ec4b5a7", - "sha256": "0de994e24c9413e2be1b6116ffe5ea44659df05bf61afdb60e14107630854492" + "sha256": "c0c903cb8a33ab863412c407ad10181adec0d22b81c899fc5225de14d8878091" }, { "path": "src/font/outline.rs", "source_sha256": "2462d9ae4008c620afb47103bfa55362e75d84a28cb418240439b147de4ada8c", "source_git_blob": "558d886cad5b9f79c6c1422f604c517a5f98ce0d", - "sha256": "2462d9ae4008c620afb47103bfa55362e75d84a28cb418240439b147de4ada8c" + "sha256": "a15235fb600d8bf6496405c125ea03f9196e7428e439ef7e01303eef14efa000" }, { "path": "src/font/standard_font.rs", @@ -314,7 +314,7 @@ "path": "src/font/type1.rs", "source_sha256": "431b1fe86ca51bd993e81084a64c3e8ed8b90a67385a30eb4ffedfdd98c6f96b", "source_git_blob": "02f76bfb8095041e3d54df014190047de46be4c5", - "sha256": "a8eabb741ce3991bca11a4f72f42658b33802b8688dda4cc5904e3e4cdae9955" + "sha256": "af73de5038ee590bb062ee1e610a60e5e4f51d109ae765922ce9669b4e36ce95" }, { "path": "src/font/type3.rs", @@ -362,13 +362,13 @@ "path": "src/interpret/mod.rs", "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", - "sha256": "c83663220d27ecf7f80419aab07d44a7ab1894d4932b71b9f5dee83e7e2ba221" + "sha256": "3f8958e8d234f00b24478acb8081aa1aaffdca6b8a12b24c4abc6f534ce09a05" }, { "path": "src/interpret/path.rs", "source_sha256": "a5defbc4b883b262662f7e7474a00ec1f97dbef8c03b8669859f624f5d02344b", "source_git_blob": "838ea099f11d918d6e333e32503368be864689f8", - "sha256": "2731da799d456cbdddc18518f468ba202356b5f255a116fc7801f65a4aedb9b0" + "sha256": "0e4d2dc0a2d5a2c5cdc0d1d9358fcf3b0854e8d24d002ce1830a1b4def786bc8" }, { "path": "src/interpret/state.rs", @@ -380,7 +380,7 @@ "path": "src/interpret/text.rs", "source_sha256": "35eafa80147aea635bdef127c33ace20e1695e11e87c7bfe1e035c886806ff30", "source_git_blob": "ddec0400ea7a639c14769ac9ee7e137d000b3efd", - "sha256": "34911b1b423e24638863c1c9b173a4d1ccddd3bf08789dab226765adc072c10f" + "sha256": "b34117d28ca7987833626f2e938abe4dd5d02ea869db7b0bdc1b8a264d775f9c" }, { "path": "src/lib.rs", @@ -422,7 +422,7 @@ "path": "src/types.rs", "source_sha256": "0e66569b8a7dc243b0a384cf1d3615ca62bf660b5324236f2594609bdfddfd46", "source_git_blob": "64d2b849bdd3fdcd4ebfd76845edcef77b625fbd", - "sha256": "9b5cc9485c0e4104aee99be773706490e6a33f3c03e796f27940571041864125" + "sha256": "669fb4a9427193a962b7cda70656b688795ea691f67ae52333124807ba88006c" }, { "path": "src/util.rs", @@ -434,7 +434,7 @@ "path": "src/x_object.rs", "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", - "sha256": "6adbf88e6bcdde2321c517525c1291ce7ead55a7989c930757ed384c309589d4" + "sha256": "4d2d5a2b8ff6d75308cb4fbb606bb2135073d35e859f1b32a5be5b3276ca2c20" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index e85fd17e..3965581e 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -2073,8 +2073,8 @@ impl ColorSpace { } /// PdfCraft patch (35): DeviceCMYK itself (`CalCMYK` reads as it too), where the nonzero - /// overprint mode applies. - pub(crate) fn is_device_cmyk(&self) -> bool { + /// overprint mode applies. Craft patch (flatten): public. + pub fn is_device_cmyk(&self) -> bool { matches!(self.0.as_ref(), ColorSpaceType::DeviceCmyk) } @@ -2540,6 +2540,299 @@ pub(crate) trait ToRgb { } } +/// Craft patch (flatten): the inks a colour paints, for devices that keep colours as inks +/// instead of RGB (see [`Color::ink_paint`]). +#[derive(Clone, Debug, PartialEq)] +pub enum InkPaint { + /// Tints `0.0..=1.0` of the listed inks; other inks are unspecified. Process colour lists + /// all four process inks. + Tints(Vec<(Ink, f32)>), + /// The same tint on every plate (the `All` colorant). + All(f32), + /// No ink (the `None` colorant). + None, +} + +/// Craft patch (flatten): how a spot ink looks at 100%, from its colour space's alternate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SpotAlternate { + /// The alternate space is DeviceCMYK: the tint transform's CMYK. + Cmyk([f32; 4]), + /// Any other alternate space: the sRGB appearance. + Srgb([u8; 3]), +} + +impl Color { + /// Craft patch (flatten): the opacity (`ca` or `CA`) the colour paints with. + pub fn opacity(&self) -> f32 { + self.opacity + } + + /// Craft patch (flatten): whether the colour was set in DeviceCMYK itself, where the + /// nonzero overprint mode applies (ISO 32000-2 §8.6.7). + pub fn is_device_cmyk(&self) -> bool { + self.color_space.is_device_cmyk() + } + + /// Craft patch (flatten): the inks the colour paints, as plate mode separates it: + /// `Separation` and `DeviceN` colorants as they are, and process colour as CMYK, converted + /// through `simulation` where plate mode converts it. `None` for a pattern space. + pub fn ink_paint(&self, simulation: Option<&CmykSimulation>) -> Option { + self.color_space + .ink_paint(&self.components, simulation, false) + } + + /// Craft patch (flatten): how spot `ink` of this colour's space looks at 100%; `None` when + /// the space doesn't name it. + pub fn spot_alternate(&self, ink: &Ink) -> Option { + self.color_space.spot_alternate(ink) + } +} + +impl ColorSpace { + /// Craft patch (flatten): see [`Color::ink_paint`]. + fn ink_paint( + &self, + components: &[f32], + simulation: Option<&CmykSimulation>, + manual_scale: bool, + ) -> Option { + let get = |k: usize| unit(components.get(k).copied().unwrap_or(0.0)); + Some(match self.0.as_ref() { + ColorSpaceType::Separation(s) => match &s.colorant { + Colorant::None => InkPaint::None, + Colorant::All => InkPaint::All(get(0)), + Colorant::Ink(ink) => InkPaint::Tints(vec![(ink.clone(), get(0))]), + }, + ColorSpaceType::DeviceN(d) => { + let tints: Vec<_> = d + .colorants + .iter() + .enumerate() + .filter_map(|(k, c)| match c { + Colorant::Ink(ink) => Some((ink.clone(), get(k))), + _ => None, + }) + .collect(); + if d.is_none || tints.is_empty() { + InkPaint::None + } else { + InkPaint::Tints(tints) + } + } + ColorSpaceType::Indexed(i) => { + let index = components.first().copied().unwrap_or(0.0); + let index = (clamp_component(index, 0.0, f32::from(i.hival)) + 0.5) as usize; + return i.base.ink_paint(i.values.get(index)?, simulation, true); + } + ColorSpaceType::Pattern(_) => return None, + ColorSpaceType::Plate(p) => { + return p.inner.ink_paint(components, simulation, manual_scale); + } + _ => { + let cmyk = self.process_cmyk(components, simulation, manual_scale)?; + InkPaint::Tints(Ink::PROCESS.into_iter().zip(cmyk).collect()) + } + }) + } + + /// Craft patch (flatten): see [`Color::spot_alternate`]. + pub fn spot_alternate(&self, ink: &Ink) -> Option { + let (alternate, transform, components) = match self.0.as_ref() { + ColorSpaceType::Separation(s) => { + if s.colorant != Colorant::Ink(ink.clone()) { + return None; + } + (&s.alternate_space, &s.tint_transform, smallvec![1.0]) + } + ColorSpaceType::DeviceN(d) => { + let k = d + .colorants + .iter() + .position(|c| matches!(c, Colorant::Ink(i) if i == ink))?; + let mut components: ColorComponents = smallvec![0.0; d.colorants.len()]; + *components.get_mut(k)? = 1.0; + (&d.alternate_space, &d.tint_transform, components) + } + ColorSpaceType::Indexed(i) => return i.base.spot_alternate(ink), + ColorSpaceType::Pattern(cs) => return cs.spot_alternate(ink), + ColorSpaceType::Plate(p) => return p.inner.spot_alternate(ink), + _ => return None, + }; + if alternate.is_device_cmyk() + && let Some(cmyk) = transform.eval(components.clone()) + && let [c, m, y, k] = cmyk[..] + { + return Some(SpotAlternate::Cmyk([c, m, y, k].map(unit))); + } + Some(SpotAlternate::Srgb(self.appearance(&components))) + } +} + +/// Craft patch (flatten): the components a device that keeps colours as inks stores a space's +/// colours in, as plate mode separates them (see [`ColorSpace::ink_values`]). +#[derive(Clone, Debug, PartialEq)] +pub enum InkSpace { + /// DeviceGray: one gray level, 1 = white. + Gray, + /// The four process tints. `converted` when the colours are converted into CMYK (RGB, Lab, + /// CIE-based and ICC spaces other than a CMYK profile the simulation leaves as it is). + Cmyk { + /// Whether the colours are converted into CMYK. + converted: bool, + }, + /// Tints of these inks: `Separation` and `DeviceN` colorants, `None` colorants left out. + Inks(Vec), + /// One tint for every ink (the `All` colorant). + All, + /// No ink (the `None` colorant, or a pattern space). + None, +} + +impl InkSpace { + /// Values per colour. + pub fn len(&self) -> usize { + match self { + Self::Gray | Self::All => 1, + Self::Cmyk { .. } => 4, + Self::Inks(inks) => inks.len(), + Self::None => 0, + } + } + + /// Whether colours have no values (the space paints nothing). + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl ColorSpace { + /// Craft patch (flatten): the components per colour. + pub fn components(&self) -> usize { + usize::from(self.num_components()) + } + + /// Craft patch (flatten): how [`Self::ink_values`] stores this space's colours. An Indexed + /// space stores its base space's. + pub fn ink_space(&self, simulation: Option<&CmykSimulation>) -> InkSpace { + match self.0.as_ref() { + ColorSpaceType::DeviceGray => InkSpace::Gray, + ColorSpaceType::DeviceCmyk => InkSpace::Cmyk { converted: false }, + ColorSpaceType::ICCBased(icc) if icc.0.number_components == 4 => InkSpace::Cmyk { + converted: simulation.is_some_and(|s| self.simulated_by(s)), + }, + ColorSpaceType::Indexed(i) => i.base.ink_space(simulation), + ColorSpaceType::Separation(s) => match &s.colorant { + Colorant::Ink(ink) => InkSpace::Inks(vec![ink.clone()]), + Colorant::All => InkSpace::All, + Colorant::None => InkSpace::None, + }, + ColorSpaceType::DeviceN(d) => { + let inks: Vec = d + .colorants + .iter() + .filter_map(|c| match c { + Colorant::Ink(ink) => Some(ink.clone()), + _ => None, + }) + .collect(); + if d.is_none || inks.is_empty() { + InkSpace::None + } else { + InkSpace::Inks(inks) + } + } + ColorSpaceType::Pattern(_) => InkSpace::None, + ColorSpaceType::Plate(p) => p.inner.ink_space(simulation), + _ => InkSpace::Cmyk { converted: true }, + } + } + + /// Craft patch (flatten): the colours `colors` (whole colours of [`Self::components`] + /// values each) appended to `out` as [`Self::ink_space`] values, `0.0..=1.0`. Process + /// colour converts as [`Color::ink_paint`] does, in batches. + pub fn ink_values(&self, colors: &[f32], simulation: Option<&CmykSimulation>, out: &mut Vec) { + self.ink_values_scaled(colors, simulation, false, out); + } + + fn ink_values_scaled( + &self, + colors: &[f32], + simulation: Option<&CmykSimulation>, + manual_scale: bool, + out: &mut Vec, + ) { + let n = usize::from(self.num_components()).max(1); + let colors = &colors[..colors.len() / n * n]; + match self.0.as_ref() { + ColorSpaceType::Indexed(i) => { + let width = i.base.ink_space(simulation).len(); + let mut table = Vec::with_capacity((usize::from(i.hival) + 1) * width); + for values in &i.values { + let start = table.len(); + i.base.ink_values_scaled(values, simulation, true, &mut table); + table.resize(start + width, 0.0); + } + for index in colors { + let index = (clamp_component(*index, 0.0, f32::from(i.hival)) + 0.5) as usize; + match table.get(index * width..(index + 1) * width) { + Some(values) => out.extend_from_slice(values), + None => out.extend(std::iter::repeat_n(0.0, width)), + } + } + } + ColorSpaceType::Plate(p) => p.inner.ink_values_scaled(colors, simulation, manual_scale, out), + ColorSpaceType::Pattern(_) => {} + ColorSpaceType::DeviceGray => out.extend(colors.iter().map(|v| unit(*v))), + ColorSpaceType::Separation(s) => { + if s.colorant != Colorant::None { + out.extend(colors.iter().map(|v| unit(*v))); + } + } + ColorSpaceType::DeviceN(d) => { + if d.is_none { + return; + } + for colour in colors.chunks_exact(n) { + for (c, v) in d.colorants.iter().zip(colour) { + if matches!(c, Colorant::Ink(_)) { + out.push(unit(*v)); + } + } + } + } + _ => { + let start = out.len(); + out.resize(start + colors.len() / n * 4, 0.0); + if let Some(simulation) = simulation.filter(|s| self.simulated_by(s)) + && self + .simulate_cmyk(simulation, colors, manual_scale, &mut out[start..]) + .is_some() + { + for v in &mut out[start..] { + *v = unit(*v); + } + return; + } + if self.separates_through_srgb() { + let mut rgb = vec![0_u8; colors.len() / n * 3]; + if self.convert_f32(colors, &mut rgb, manual_scale).is_some() { + for (cmyk, rgb) in out[start..].chunks_exact_mut(4).zip(rgb.chunks_exact(3)) { + cmyk.copy_from_slice(&rgb_to_cmyk([rgb[0], rgb[1], rgb[2]].map(u8_to_f32))); + } + return; + } + } + for (cmyk, colour) in out[start..].chunks_exact_mut(4).zip(colors.chunks_exact(n)) { + if let Some(c) = self.process_cmyk(colour, None, manual_scale) { + cmyk.copy_from_slice(&c); + } + } + } + } + } +} + #[cfg(test)] mod device_color_compatibility_tests { use super::ColorSpace; diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 57d11be2..6f666291 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -107,6 +107,14 @@ pub trait Device<'a> { fn begin_knockout_erase(&mut self) {} /// Knockout patch (pdfcraft #2974): see `knockout_erase_wanted`. fn end_knockout_erase(&mut self) {} + /// Craft patch (flatten): the overprint parameters for the paints that follow, until the + /// next call: `op` (`fill`, for fills, glyph fills and images), `OP` (`stroke`) and `OPM` + /// (`mode`, 0 or 1; ISO 32000-2 §8.6.7). Called where `set_blend_mode` is called for a + /// paint. + fn set_overprint(&mut self, _fill: bool, _stroke: bool, _mode: u8) {} + /// Craft patch (flatten): the transparency group just pushed is isolated (`/I true`), until + /// the matching `pop_transparency_group`. + fn set_isolated(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 8df3611a..53501e5a 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -180,6 +180,8 @@ impl CffFontBlob { pub(crate) struct OpenTypeFontBlob { yoke: Arc, cff_blob: Option, + // Craft patch (flatten): the face index in a collection, for `OutlineFontData::index`. + index: u32, } impl Debug for OpenTypeFontBlob { @@ -248,6 +250,7 @@ impl OpenTypeFontBlob { Some(Self { yoke: Arc::new(font_ref_yoke), cff_blob, + index, }) } @@ -255,6 +258,11 @@ impl OpenTypeFontBlob { self.yoke.backing_cart().clone() } + /// Craft patch (flatten): the face index the blob was read with. + pub(crate) fn index(&self) -> u32 { + self.index + } + pub(crate) fn font_ref(&self) -> &FontRef<'_> { &self.yoke.as_ref().get().font_ref } diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index ed32cfe4..db40d5a7 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -343,6 +343,20 @@ impl Type0Font { } } + /// Craft patch (flatten): the face index of `font_data`. + pub(crate) fn font_index(&self) -> u32 { + match &self.font_type { + FontType::OpenType(t) => t.index(), + FontType::Cff(_) | FontType::Type1(_) => 0, + } + } + + /// Craft patch (flatten): whether `font_data` comes from the file rather than the font + /// resolver. + pub(crate) fn is_embedded(&self) -> bool { + !self.fallback + } + /// Get the PostScript name. pub(crate) fn postscript_name(&self) -> Option<&str> { self.postscript_name.as_deref() diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 103ee396..37f51f8c 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -155,7 +155,8 @@ impl OutlineGlyph { /// Get raw font bytes and metadata for downstream use. /// - /// Returns `None` for Type1 fonts. + /// Returns `None` for Type1 fonts. Craft patch (flatten): Type1C (`FontFile3`) fonts return + /// their CFF bytes. pub fn font_data(&self) -> Option { self.font.font_data() } diff --git a/vendor/hayro-interpret/src/font/outline.rs b/vendor/hayro-interpret/src/font/outline.rs index 558d886c..f46005b9 100644 --- a/vendor/hayro-interpret/src/font/outline.rs +++ b/vendor/hayro-interpret/src/font/outline.rs @@ -15,6 +15,11 @@ pub struct OutlineFontData { pub data: crate::font::FontData, /// Cache key for font deduplication. pub cache_key: u128, + /// Craft patch (flatten): the face index of `data` in a font collection. + pub index: u32, + /// Craft patch (flatten): true when `data` comes from the PDF (`FontFile*`); false when it + /// is the font resolver's substitute for a font the PDF doesn't embed. + pub embedded: bool, /// PostScript name (e.g., "TimesNewRomanPS-BoldMT"). pub postscript_name: Option, /// Font weight (100-900, 400=normal, 700=bold). @@ -119,13 +124,28 @@ impl OutlineFont { /// Get raw font bytes and metadata. /// - /// Returns None for Type1 fonts and non-embedded TrueType fonts. + /// Returns None for Type1 (`FontFile`), standard and non-embedded simple fonts. pub(crate) fn font_data(&self) -> Option { match self { - Self::Type1(_) => None, + // Craft patch (flatten): Type1C (`FontFile3`) fonts hand out their bare CFF bytes. + // The font dictionary's descriptive fields are not read. + Self::Type1(t) => Some(OutlineFontData { + data: t.font_data()?, + cache_key: t.cache_key(), + index: 0, + embedded: true, + postscript_name: None, + weight: None, + is_italic: false, + is_serif: false, + is_monospace: false, + }), Self::TrueType(t) => Some(OutlineFontData { data: t.font_data()?, cache_key: t.cache_key(), + // Craft patch (flatten): only an embedded `FontFile2`, read as face 0, has bytes. + index: 0, + embedded: true, postscript_name: t.postscript_name().map(|s| s.to_string()), weight: t.weight(), is_italic: t.is_italic(), @@ -135,6 +155,9 @@ impl OutlineFont { Self::Type0(t) => Some(OutlineFontData { data: t.font_data()?, cache_key: t.cache_key(), + // Craft patch (flatten): a substitute from the resolver is not embedded. + index: t.font_index(), + embedded: t.is_embedded(), postscript_name: t.postscript_name().map(|s| s.to_string()), weight: t.weight(), is_italic: t.is_italic(), diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 4ef5f953..3f9bac40 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -105,6 +105,15 @@ impl Type1Font { } } + /// Craft patch (flatten): the bare CFF bytes of a `FontFile3/Type1C` font, which the glyph + /// ids index. Type 1 and standard fonts have none. + pub(crate) fn font_data(&self) -> Option { + match &self.1 { + Kind::Cff(c) => Some(c.font.font_data()), + Kind::Standard(_) | Kind::Type1(_) => None, + } + } + pub(crate) fn char_code_to_unicode(&self, char_code: u32) -> Option { if let Some(to_unicode) = &self.2 && let Some(c) = to_unicode.lookup_bf_string(char_code) diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 44cbdc0c..1e9c0b80 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -874,7 +874,10 @@ fn interpret_window<'a>( &defaults, ) }) - .map(|s| { + .map(|mut s| { + // Craft patch (flatten): `sh` ignores `/Background` (ISO 32000-2 + // §8.7.4.5.1); only a shading pattern paints it. + s.background = None; Pattern::Shading(ShadingPattern { shading: Arc::new(s), matrix: Affine::IDENTITY, diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index 3fbd1129..07d37b2d 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -76,6 +76,8 @@ pub(crate) fn fill_path_impl<'a>( let paint = get_paint(context, false); device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); device.set_blend_mode(context.get().graphics_state.blend_mode); + // Craft patch (flatten): tell the device the overprint parameters. + set_overprint(context, device); let mut draw = |path: &BezPath| { // pdf.js issue 4260: Replace zero-sized paths with a small stroke instead. @@ -136,6 +138,8 @@ pub(crate) fn stroke_path_impl<'a>( let stroke_props = context.stroke_props(); device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); device.set_blend_mode(context.get().graphics_state.blend_mode); + // Craft patch (flatten): tell the device the overprint parameters. + set_overprint(context, device); let paint = get_paint(context, true); let path = path.unwrap_or(context.path()); @@ -148,6 +152,16 @@ pub(crate) fn stroke_path_impl<'a>( } } +/// Craft patch (flatten): passes the graphics state's `op`, `OP` and `OPM` to the device. +pub(crate) fn set_overprint<'a>(context: &Context<'a>, device: &mut impl Device<'a>) { + let state = &context.get().graphics_state; + device.set_overprint( + state.overprint_fill, + state.overprint_stroke, + state.overprint_mode, + ); +} + pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> { let data = if is_stroke { context.get().stroke_data() diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index 0bf5a996..f9f68b1e 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -127,6 +127,8 @@ fn show_glyph<'a>( device.set_soft_mask(ctx.get().graphics_state.soft_mask.clone()); device.set_blend_mode(ctx.get().graphics_state.blend_mode); + // Craft patch (flatten): tell the device the overprint parameters. + crate::interpret::path::set_overprint(ctx, device); let stroke_props = ctx.stroke_props(); // PdfCraft patch: only mode 3 has any effect on text in a Type 3 font (ISO 32000-1 diff --git a/vendor/hayro-interpret/src/types.rs b/vendor/hayro-interpret/src/types.rs index af540247..7c46cbc8 100644 --- a/vendor/hayro-interpret/src/types.rs +++ b/vendor/hayro-interpret/src/types.rs @@ -106,6 +106,39 @@ impl RasterImage<'_> { } } +impl RasterImage<'_> { + /// Craft patch (flatten): the image's samples as inks, in its own color space (see + /// [`InkImage`]); process colour converts through `simulation` where plate mode converts it. + pub fn with_inks( + &self, + simulation: Option<&crate::color::CmykSimulation>, + func: impl FnOnce(InkImage), + ) { + if let Some(image) = crate::x_object::decode_inks(&self.0, simulation) { + func(image); + } + } +} + +/// Craft patch (flatten): a raster image decoded into inks rather than RGB. +#[derive(Clone)] +pub struct InkImage { + /// The width in samples. + pub width: u32, + /// The height in samples. + pub height: u32, + /// What each sample's values are. + pub space: crate::color::InkSpace, + /// The image's color space (an Indexed space itself, not its base). + pub color_space: crate::color::ColorSpace, + /// `space.len()` bytes per sample, rows top to bottom, 255 = 1.0. + pub samples: Vec, + /// The `/SMask`, `/Mask` or `/SMaskInData` alpha, at its own size. + pub alpha: Option, + /// As [`RgbData::scale_factors`]. + pub scale_factors: (f32, f32), +} + impl CacheKey for RasterImage<'_> { fn cache_key(&self) -> u128 { self.0.cache_key() diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 60af0748..d8f01a30 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,15 +1,16 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; use crate::color::{ - ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, InkUse, Plate, ToRgb, + CmykSimulation, ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, InkSpace, + InkUse, Plate, ToRgb, }; use crate::context::Context; use crate::device::Device; use crate::function::{Function, interpolate}; -use crate::interpret::path::get_paint; +use crate::interpret::path::{get_paint, set_overprint}; use crate::interpret::state::ActiveTransferFunction; use crate::{BlendMode, CacheKey, ClipPath, Image, Paint, RasterImage, StencilImage}; use crate::{FillRule, InterpreterWarning, WarningSinkFn, interpret}; -use crate::{ImageData, LumaData, RgbData}; +use crate::{ImageData, InkImage, LumaData, RgbData}; use hayro_syntax::bit_reader::BitReader; use hayro_syntax::content::TypedIter; use hayro_syntax::object::Array; @@ -209,6 +210,10 @@ pub(crate) fn draw_form_xobject<'a, 'b>( if x_object.is_knockout { device.set_knockout(); } + // Craft patch (flatten): tell the device the group is isolated. + if x_object.is_isolated { + device.set_isolated(); + } } device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); @@ -340,6 +345,8 @@ pub(crate) fn draw_image_xobject<'a, 'b>( device.set_soft_mask(None); device.set_blend_mode(BlendMode::default()); + // Craft patch (flatten): tell the device the overprint parameters. + set_overprint(context, device); // PdfCraft patch (46): the group just pushed applies the fill opacity, so a stencil mask's // paint (the fill color) must not carry it again: at `ca 0.5` a mask was drawn at a // quarter of its opacity (#2975). The alpha is put back below: `restore_state` doesn't @@ -950,6 +957,111 @@ fn decode_raster( Some(DecodedRaster { image, alpha }) } +/// Craft patch (flatten): the image's samples in its own space's inks (see +/// [`RasterImage::with_inks`](crate::RasterImage::with_inks)): `/Decode` and `/Matte` applied, +/// process colour converted as [`ColorSpace::ink_values`] does, transfer functions left out +/// (as on a plate). +pub(crate) fn decode_inks( + obj: &ImageXObject<'_>, + simulation: Option<&CmykSimulation>, +) -> Option { + if obj.is_mask { + return None; + } + let mut ctx = decode_context(obj, None)?; + let cs = ctx.color_space.clone(); + let n = cs.components(); + if n == 0 { + return None; + } + let space = cs.ink_space(simulation); + let bpc = ctx.bits_per_component; + let mut height = ctx.height; + let is_default = ctx.decode_arr == cs.default_decode_arr(bpc as f32); + let is_inverted = ctx.decode_arr == cs.inverted_default_decode_arr(bpc as f32); + let identity = !cs.is_indexed() + && space.len() == n + && !matches!(space, InkSpace::Cmyk { converted: true }); + let matte = obj + .stream + .dict() + .get::>(SMASK) + .and_then(|s| s.dict().get::(MATTE)) + .filter(|m| m.len() == n && !cs.is_indexed()); + + let mut alpha_height = height; + let alpha = resolve_alpha( + obj, + &mut ctx.decoded, + None, + &cs, + bpc, + ctx.width, + &mut alpha_height, + ctx.scale_factors, + None, + ) + .flatten(); + let matte_alpha = alpha + .as_ref() + .filter(|a| a.width == ctx.width && a.height == height && matte.is_some()); + + let samples = if bpc == 8 && identity && (is_default || is_inverted) && matte_alpha.is_none() { + let mut data = ctx.decoded.data.into_owned(); + fix_image_length(&mut data, ctx.width, &mut height, 0, &cs)?; + if is_inverted { + for b in &mut data { + *b = 255 - *b; + } + } + data + } else { + let mut components = get_components(&ctx.decoded.data, ctx.width, height, &cs, bpc)?; + fix_image_length(&mut components, ctx.width, &mut height, 0, &cs)?; + let width = space.len(); + let mut samples = Vec::with_capacity(components.len() / n * width); + let mut colors = Vec::new(); + let mut values = Vec::new(); + const CHUNK: usize = 1 << 14; + for (c, chunk) in components.chunks(CHUNK * n).enumerate() { + colors.clear(); + for pixel in chunk.chunks_exact(n) { + let start = colors.len(); + for (sample, range) in pixel.iter().zip(&ctx.decode_arr) { + colors.push(decode_sample(*sample, bpc, *range)); + } + colors.resize(start + n, 0.0); + } + if let (Some(matte), Some(alpha)) = (&matte, matte_alpha) { + for (k, pixel) in colors.chunks_exact_mut(n).enumerate() { + let a = alpha.data.get(c * CHUNK + k).copied().unwrap_or(255); + if a == 0 { + continue; + } + let a = f32::from(a) / 255.0; + for (v, m) in pixel.iter_mut().zip(matte.iter()) { + *v = m + (*v - m) / a; + } + } + } + values.clear(); + cs.ink_values(&colors, simulation, &mut values); + samples.extend(values.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)); + } + samples + }; + + Some(InkImage { + width: ctx.width, + height, + space, + color_space: cs, + samples, + alpha, + scale_factors: ctx.scale_factors, + }) +} + fn decode_mask_bytes( mut decoded_data: Cow<'_, [u8]>, width: u32, diff --git a/vendor/krilla/README.craft.md b/vendor/krilla/README.craft.md index c47779c7..6171ba88 100644 --- a/vendor/krilla/README.craft.md +++ b/vendor/krilla/README.craft.md @@ -23,6 +23,9 @@ itself omits those texts. Its examples and four CC0 ICC profiles are retained wi | `patches/11-png-decode-cap.patch` | PNG decode cap | `graphics/image.rs` | | `patches/12-optional-content.patch` | Optional content | `chunk_container.rs`, `configure/{PDF_A1,PDF_A2,PDF_A4,PDF_UA1}.md`, `configure/validate.rs`, `content.rs`, `error.rs`, `graphics/{mod,optional_content}.rs`, `lib.rs`, `resource.rs`, `serialize.rs`, `surface.rs` | | `patches/13-annotation-bounds.patch` | Annotation and destination bounds | `error.rs`, `interactive/{annotation,destination}.rs`, `page.rs`, `serialize.rs` | +| `patches/14-devicen-pdf-version.patch` | DeviceN attributes by PDF version | `graphics/device_n.rs` | +| `patches/15-pdfx-conformance.patch` | PDF/X conformance key | `serialize.rs` | +| `patches/16-modification-date.patch` | Modification date | `interchange/metadata.rs` | Each patch applies with `patch -p1` from this directory, in numeric order, to the pristine crate. `SOURCE.json` records, for every retained file, the upstream hash (`upstream_sha256`) @@ -181,7 +184,7 @@ written as CMYK, which knocks out or replaces the other plates. - From PDF 1.6, a space with spots gets an attributes dictionary whose `/Colorants` maps each spot to its Separation space. When spots and process inks mix, it is `/Subtype /NChannel` with `/Process << /ColorSpace alternate /Components [/Cyan /Magenta - /Yellow /Black] >>`. + /Yellow /Black] >>`. Patch 14 changes which of these each PDF version gets. - Validation: a spot's CMYK alternate counts as its Separation fallback, so one colorant name keeps one alternate across Separation and DeviceN spaces (`ValidationError::InconsistentSeparationFallback`). `/All` and `/None` are Separation-only @@ -356,3 +359,54 @@ runs at serialization, before the annotation object starts. Refusing a single an instead of skipping it keeps the indices of tagged annotations in the structure tree valid. Tests: `crates/pdf/tests/link_annotations.rs`. + +## 14: DeviceN attributes by PDF version + +Patch 06 writes the DeviceN attributes dictionary only from PDF 1.6. A PDF/X-1a file is PDF +1.3 or 1.4, so its spots then had no `/Colorants`, and a file written at 1.6 or later and +relabeled 1.4 kept `/Subtype /NChannel` and `/Process`, which preflight tools flag in a 1.4 +file. + +ISO 32000-1, 8.6.6.5 (Table 71) marks the attributes dictionary and its `/Colorants` entry +PDF 1.3, and `/Subtype`, `/Process` and `/MixingHints` PDF 1.6; the PDF Reference for 1.3 and +1.4 lists `/Colorants` as the dictionary's only entry. The spec was checked from memory, not +against the text. + +- Every version krilla writes (1.4 and later): a space with spots gets an attributes + dictionary with `/Colorants`, each spot mapped to its Separation space. +- From PDF 1.6, a space that mixes spots and process inks also gets `/Subtype /NChannel` and + `/Process`, as patch 06 writes them. +- A space of process inks only gets no attributes, as before. + +Tests: `crates/pdf/tests/print_color.rs` (`device_n_attributes_follow_the_pdf_version`) and +`crates/prepress/tests/flatten_document.rs` (`pdf_x1a_output_is_pdf_14_without_nchannel`). + +## 15: PDF/X conformance key + +Patch 08 writes only `GTS_PDFXVersion`. PDF/X-1a and X-3 also need `GTS_PDFXConformance` in +the document information dictionary: a PDF/X-1a:2003 file carries `GTS_PDFXVersion +(PDF/X-1:2003)` and `GTS_PDFXConformance (PDF/X-1a:2003)` (ISO 15930-4). Later parts (X-4 +and on) have no conformance key. + +- `SerializeSettings::pdfx_conformance: Option` writes `/GTS_PDFXConformance` to the + dictionary, under the same PDF 2.0 rule as patch 08's entries. It defaults to `None`. +- There is no XMP property for it: the `pdfxid` schema (ISO 15930-7) defines only + `GTS_PDFXVersion`. A PDF 2.0 file therefore omits the key. + +Tests: `crates/pdf/tests/print_color.rs` (`trapped_and_pdfx_version_are_written_to_info_and_xmp`) +and `crates/prepress/tests/flatten_document.rs` (`pdf_x1a_output_is_pdf_14_without_nchannel`). + +## 16: modification date + +Upstream has only a creation date and writes it as the modification date too (`/ModDate` and +`xmp:ModifyDate`), so a document rewritten from another, such as a flattened copy, can't keep +its source's modification date. + +- `Metadata::modification_date(DateTime)` sets the date written as `/ModDate` and + `xmp:ModifyDate`. Without it, the creation date is written there, as upstream does. +- The XMP history events keep the creation date. + +Tests: `crates/pdf/tests/writing.rs` (`writes_pages_metadata_and_selected_version_without_a_renderer`, +`the_creation_date_is_the_modification_date_when_none_is_given`) and +`crates/prepress/tests/flatten_document.rs` +(`a_document_flattens_page_by_page_and_keeps_boxes_metadata_and_shared_fonts`). diff --git a/vendor/krilla/SOURCE.json b/vendor/krilla/SOURCE.json index be542dd6..c802335c 100644 --- a/vendor/krilla/SOURCE.json +++ b/vendor/krilla/SOURCE.json @@ -157,7 +157,7 @@ { "path": "src/graphics/device_n.rs", "upstream_sha256": null, - "sha256": "29b01e54699f47f02c15288a74eeae265f02f01c3d97a2612c23784b87eaa542" + "sha256": "02f4d1ad8d5e72ddcf633c8d9255632386bd73f19aacf84941746409e0823bed" }, { "path": "src/graphics/graphic.rs", @@ -252,7 +252,7 @@ { "path": "src/interchange/metadata.rs", "upstream_sha256": "14fab986f2436a4275e7cca13e065763b14b938ae180632294455ad0199380ec", - "sha256": "95d5441904b7aef4b0e8812ea536c56e22744886d1c485d9bcc3b08c7ae2b3a7" + "sha256": "7c8a66d89eba13a150295837ef5042d1909cc2901be1433b776597c557345d1f" }, { "path": "src/interchange/mod.rs", @@ -317,7 +317,7 @@ { "path": "src/serialize.rs", "upstream_sha256": "4f2c439dbd20006eea112e056b2f1a5ba98a1ede9227a45f66a6df46fb1a07e8", - "sha256": "f5ccb6b92f546e1068e51e0791dbac8b802f07f29df0c2a6ccd1dcf8a1bdc938" + "sha256": "031eb4ba074367da1da63ec924ae4e5b67fbbbeb66c8ceef7fafb373bc769af3" }, { "path": "src/stream.rs", diff --git a/vendor/krilla/patches/14-devicen-pdf-version.patch b/vendor/krilla/patches/14-devicen-pdf-version.patch new file mode 100644 index 00000000..3a63160a --- /dev/null +++ b/vendor/krilla/patches/14-devicen-pdf-version.patch @@ -0,0 +1,51 @@ +diff --git a/src/graphics/device_n.rs b/src/graphics/device_n.rs +index 8ee7064..f2873ac 100644 +--- a/src/graphics/device_n.rs ++++ b/src/graphics/device_n.rs +@@ -17,9 +17,10 @@ use crate::stream::FilterStreamBuilder; + /// `[/DeviceN [names] alternate tintTransform attributes?]`. + /// + /// The alternate is DeviceCMYK, or the `cmyk_profile` ICC space with `no_device_cs`. The +-/// attributes dictionary (PDF 1.6+) lists each spot's Separation color space under +-/// `/Colorants`; when spots and process colorants mix, it is an `NChannel` space whose +-/// `/Process` names the CMYK space and components. ++/// attributes dictionary lists each spot's Separation color space under `/Colorants` ++/// (PDF 1.3). From PDF 1.6, when spots and process colorants mix, it is an `NChannel` space ++/// whose `/Process` names the CMYK space and components; `/Subtype` and `/Process` don't ++/// exist before 1.6, and PDF/X-1a preflights reject them. + #[derive(Debug, Eq, PartialEq, Hash, Clone)] + pub(crate) struct DeviceNColorSpace { + space: DeviceNSpace, +@@ -54,21 +55,20 @@ impl Cacheable for DeviceNColorSpace { + + let tint_ref = tint_transform(&space, sc, chunk_container); + +- let with_attrs = sc.serialize_settings().pdf_version() >= PdfVersion::Pdf16; + let mut separations = Vec::new(); +- if with_attrs { +- for (name, cmyk) in space.spots() { +- let separation = +- SeparationSpace::new(SeparationColorant::Custom(name.to_owned()), cmyk.into()); +- let cs = ColorSpace::Special(SpecialColorSpace::Separation(separation)); +- if let MaybeDeviceColorSpace::ColorSpace(cs) = +- sc.register_colorspace(chunk_container, cs) +- { +- separations.push((name.to_owned(), cs.get_ref())); +- } ++ for (name, cmyk) in space.spots() { ++ let separation = ++ SeparationSpace::new(SeparationColorant::Custom(name.to_owned()), cmyk.into()); ++ let cs = ColorSpace::Special(SpecialColorSpace::Separation(separation)); ++ if let MaybeDeviceColorSpace::ColorSpace(cs) = ++ sc.register_colorspace(chunk_container, cs) ++ { ++ separations.push((name.to_owned(), cs.get_ref())); + } + } +- let nchannel = with_attrs && space.has_process() && !separations.is_empty(); ++ let nchannel = sc.serialize_settings().pdf_version() >= PdfVersion::Pdf16 ++ && space.has_process() ++ && !separations.is_empty(); + + let chunk = &mut chunk_container.non_stream.color_spaces; + let mut array = chunk.indirect(root_ref).array(); diff --git a/vendor/krilla/patches/15-pdfx-conformance.patch b/vendor/krilla/patches/15-pdfx-conformance.patch new file mode 100644 index 00000000..15c3af63 --- /dev/null +++ b/vendor/krilla/patches/15-pdfx-conformance.patch @@ -0,0 +1,46 @@ +diff --git a/src/serialize.rs b/src/serialize.rs +index 4f4af4c..7f6ece3 100644 +--- a/src/serialize.rs ++++ b/src/serialize.rs +@@ -136,6 +136,11 @@ pub struct SerializeSettings { + /// extension schema for the XMP property, which krilla does not write, so export under + /// them fails with [`ValidationError::PdfxVersionWithoutExtensionSchema`]. + pub pdfx_version: Option, ++ /// The PDF/X conformance level the caller claims, such as `PDF/X-1a:2003`: ++ /// `/GTS_PDFXConformance` in the document information dictionary. PDF/X-1a and X-3 ++ /// require it next to the version (`PDF/X-1:2003` for PDF/X-1a:2003); later parts ++ /// have no conformance key. It has no XMP property, so PDF 2.0 output omits it. ++ pub pdfx_conformance: Option, + } + + /// A CMYK printing condition, written as an `/OutputIntents` entry with `/S /GTS_PDFX`. +@@ -201,7 +206,10 @@ impl SerializeSettings { + /// Whether the document information dictionary carries print markers. Like the rest of + /// the dictionary, they are deprecated in PDF 2.0 and only written to XMP there. + pub(crate) fn has_print_info(&self, version: PdfVersion) -> bool { +- version < PdfVersion::Pdf20 && (self.trapped.is_some() || self.pdfx_version.is_some()) ++ version < PdfVersion::Pdf20 ++ && (self.trapped.is_some() ++ || self.pdfx_version.is_some() ++ || self.pdfx_conformance.is_some()) + } + + pub(crate) fn write_print_info(&self, info: &mut DocumentInfo<'_>) { +@@ -215,6 +223,9 @@ impl SerializeSettings { + if let Some(version) = &self.pdfx_version { + info.pair(Name(b"GTS_PDFXVersion"), TextStr(version)); + } ++ if let Some(conformance) = &self.pdfx_conformance { ++ info.pair(Name(b"GTS_PDFXConformance"), TextStr(conformance)); ++ } + } + + pub(crate) fn write_print_xmp(&self, xmp: &mut XmpWriter) { +@@ -260,6 +271,7 @@ impl Default for SerializeSettings { + output_intent: None, + trapped: None, + pdfx_version: None, ++ pdfx_conformance: None, + } + } + } diff --git a/vendor/krilla/patches/16-modification-date.patch b/vendor/krilla/patches/16-modification-date.patch new file mode 100644 index 00000000..45940b53 --- /dev/null +++ b/vendor/krilla/patches/16-modification-date.patch @@ -0,0 +1,65 @@ +diff --git a/src/interchange/metadata.rs b/src/interchange/metadata.rs +index 4cfc8e5..6c74b36 100644 +--- a/src/interchange/metadata.rs ++++ b/src/interchange/metadata.rs +@@ -24,6 +24,7 @@ pub struct Metadata { + pub(crate) document_id: Option, + pub(crate) language: Option, + pub(crate) creation_date: Option, ++ pub(crate) modification_date: Option, + pub(crate) text_direction: Option, + pub(crate) page_layout: Option, + } +@@ -101,6 +102,18 @@ impl Metadata { + self + } + ++ /// The date the document was last modified. Without it, the creation date is ++ /// written as the modification date. ++ pub fn modification_date(mut self, modification_date: DateTime) -> Self { ++ self.modification_date = Some(modification_date); ++ self ++ } ++ ++ /// The date written as the modification date: the one given, else the creation date. ++ fn modified(&self) -> Option { ++ self.modification_date.or(self.creation_date) ++ } ++ + /// A document ID. + /// + /// This attribute will be used as an identifier for identifying +@@ -129,6 +142,7 @@ impl Metadata { + || self.authors.is_some() + || self.creator.is_some() + || self.creation_date.is_some() ++ || self.modification_date.is_some() + || self.description.is_some() + } + +@@ -195,8 +209,11 @@ impl Metadata { + xmp.language([LangId(lang)]); + } + +- if let Some(date) = self.creation_date.map(xmp_date) { ++ if let Some(date) = self.modified().map(xmp_date) { + xmp.modify_date(date); ++ } ++ ++ if let Some(date) = self.creation_date.map(xmp_date) { + xmp.create_date(date); + + if sc +@@ -289,8 +306,11 @@ impl Metadata { + } + } + +- if let Some(date_time) = self.creation_date { ++ if let Some(date_time) = self.modified() { + document_info.modified_date(pdf_date(date_time)); ++ } ++ ++ if let Some(date_time) = self.creation_date { + document_info.creation_date(pdf_date(date_time)); + } + diff --git a/vendor/krilla/src/graphics/device_n.rs b/vendor/krilla/src/graphics/device_n.rs index 8ee7064f..f2873ac1 100644 --- a/vendor/krilla/src/graphics/device_n.rs +++ b/vendor/krilla/src/graphics/device_n.rs @@ -17,9 +17,10 @@ use crate::stream::FilterStreamBuilder; /// `[/DeviceN [names] alternate tintTransform attributes?]`. /// /// The alternate is DeviceCMYK, or the `cmyk_profile` ICC space with `no_device_cs`. The -/// attributes dictionary (PDF 1.6+) lists each spot's Separation color space under -/// `/Colorants`; when spots and process colorants mix, it is an `NChannel` space whose -/// `/Process` names the CMYK space and components. +/// attributes dictionary lists each spot's Separation color space under `/Colorants` +/// (PDF 1.3). From PDF 1.6, when spots and process colorants mix, it is an `NChannel` space +/// whose `/Process` names the CMYK space and components; `/Subtype` and `/Process` don't +/// exist before 1.6, and PDF/X-1a preflights reject them. #[derive(Debug, Eq, PartialEq, Hash, Clone)] pub(crate) struct DeviceNColorSpace { space: DeviceNSpace, @@ -54,21 +55,20 @@ impl Cacheable for DeviceNColorSpace { let tint_ref = tint_transform(&space, sc, chunk_container); - let with_attrs = sc.serialize_settings().pdf_version() >= PdfVersion::Pdf16; let mut separations = Vec::new(); - if with_attrs { - for (name, cmyk) in space.spots() { - let separation = - SeparationSpace::new(SeparationColorant::Custom(name.to_owned()), cmyk.into()); - let cs = ColorSpace::Special(SpecialColorSpace::Separation(separation)); - if let MaybeDeviceColorSpace::ColorSpace(cs) = - sc.register_colorspace(chunk_container, cs) - { - separations.push((name.to_owned(), cs.get_ref())); - } + for (name, cmyk) in space.spots() { + let separation = + SeparationSpace::new(SeparationColorant::Custom(name.to_owned()), cmyk.into()); + let cs = ColorSpace::Special(SpecialColorSpace::Separation(separation)); + if let MaybeDeviceColorSpace::ColorSpace(cs) = + sc.register_colorspace(chunk_container, cs) + { + separations.push((name.to_owned(), cs.get_ref())); } } - let nchannel = with_attrs && space.has_process() && !separations.is_empty(); + let nchannel = sc.serialize_settings().pdf_version() >= PdfVersion::Pdf16 + && space.has_process() + && !separations.is_empty(); let chunk = &mut chunk_container.non_stream.color_spaces; let mut array = chunk.indirect(root_ref).array(); diff --git a/vendor/krilla/src/interchange/metadata.rs b/vendor/krilla/src/interchange/metadata.rs index 4cfc8e59..6c74b361 100644 --- a/vendor/krilla/src/interchange/metadata.rs +++ b/vendor/krilla/src/interchange/metadata.rs @@ -24,6 +24,7 @@ pub struct Metadata { pub(crate) document_id: Option, pub(crate) language: Option, pub(crate) creation_date: Option, + pub(crate) modification_date: Option, pub(crate) text_direction: Option, pub(crate) page_layout: Option, } @@ -101,6 +102,18 @@ impl Metadata { self } + /// The date the document was last modified. Without it, the creation date is + /// written as the modification date. + pub fn modification_date(mut self, modification_date: DateTime) -> Self { + self.modification_date = Some(modification_date); + self + } + + /// The date written as the modification date: the one given, else the creation date. + fn modified(&self) -> Option { + self.modification_date.or(self.creation_date) + } + /// A document ID. /// /// This attribute will be used as an identifier for identifying @@ -129,6 +142,7 @@ impl Metadata { || self.authors.is_some() || self.creator.is_some() || self.creation_date.is_some() + || self.modification_date.is_some() || self.description.is_some() } @@ -195,8 +209,11 @@ impl Metadata { xmp.language([LangId(lang)]); } - if let Some(date) = self.creation_date.map(xmp_date) { + if let Some(date) = self.modified().map(xmp_date) { xmp.modify_date(date); + } + + if let Some(date) = self.creation_date.map(xmp_date) { xmp.create_date(date); if sc @@ -289,8 +306,11 @@ impl Metadata { } } - if let Some(date_time) = self.creation_date { + if let Some(date_time) = self.modified() { document_info.modified_date(pdf_date(date_time)); + } + + if let Some(date_time) = self.creation_date { document_info.creation_date(pdf_date(date_time)); } diff --git a/vendor/krilla/src/serialize.rs b/vendor/krilla/src/serialize.rs index f2986fc8..4a415337 100644 --- a/vendor/krilla/src/serialize.rs +++ b/vendor/krilla/src/serialize.rs @@ -136,6 +136,11 @@ pub struct SerializeSettings { /// extension schema for the XMP property, which krilla does not write, so export under /// them fails with [`ValidationError::PdfxVersionWithoutExtensionSchema`]. pub pdfx_version: Option, + /// The PDF/X conformance level the caller claims, such as `PDF/X-1a:2003`: + /// `/GTS_PDFXConformance` in the document information dictionary. PDF/X-1a and X-3 + /// require it next to the version (`PDF/X-1:2003` for PDF/X-1a:2003); later parts + /// have no conformance key. It has no XMP property, so PDF 2.0 output omits it. + pub pdfx_conformance: Option, } /// A CMYK printing condition, written as an `/OutputIntents` entry with `/S /GTS_PDFX`. @@ -201,7 +206,10 @@ impl SerializeSettings { /// Whether the document information dictionary carries print markers. Like the rest of /// the dictionary, they are deprecated in PDF 2.0 and only written to XMP there. pub(crate) fn has_print_info(&self, version: PdfVersion) -> bool { - version < PdfVersion::Pdf20 && (self.trapped.is_some() || self.pdfx_version.is_some()) + version < PdfVersion::Pdf20 + && (self.trapped.is_some() + || self.pdfx_version.is_some() + || self.pdfx_conformance.is_some()) } pub(crate) fn write_print_info(&self, info: &mut DocumentInfo<'_>) { @@ -215,6 +223,9 @@ impl SerializeSettings { if let Some(version) = &self.pdfx_version { info.pair(Name(b"GTS_PDFXVersion"), TextStr(version)); } + if let Some(conformance) = &self.pdfx_conformance { + info.pair(Name(b"GTS_PDFXConformance"), TextStr(conformance)); + } } pub(crate) fn write_print_xmp(&self, xmp: &mut XmpWriter) { @@ -260,6 +271,7 @@ impl Default for SerializeSettings { output_intent: None, trapped: None, pdfx_version: None, + pdfx_conformance: None, } } }