diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..5d79390a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Copies of the vendored krilla source diff, one per patch (see vendor/krilla/README.craft.md). +vendor/krilla/patches/*.patch linguist-generated=true diff --git a/.github/workflows/ai-core.yml b/.github/workflows/ai-core.yml index 5f2204f7..2dd03168 100644 --- a/.github/workflows/ai-core.yml +++ b/.github/workflows/ai-core.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/atomic.yml b/.github/workflows/atomic.yml index d2112768..9cda9f28 100644 --- a/.github/workflows/atomic.yml +++ b/.github/workflows/atomic.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/docpath.yml b/.github/workflows/docpath.yml index 3aaaa97e..3cae8fb5 100644 --- a/.github/workflows/docpath.yml +++ b/.github/workflows/docpath.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index a973e37f..7a887649 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -20,10 +20,10 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - run: cargo fmt --check - - run: cargo clippy -p craft-i18n --all-targets --locked -- -D warnings - - run: cargo test -p craft-i18n --locked -- --nocapture + - run: cargo clippy -p craft-i18n --all-targets --features collate --locked -- -D warnings + - run: cargo test -p craft-i18n --features collate --locked -- --nocapture 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/keymap.yml b/.github/workflows/keymap.yml new file mode 100644 index 00000000..51ff2ba0 --- /dev/null +++ b/.github/workflows/keymap.yml @@ -0,0 +1,92 @@ +name: Keymap + +on: + push: + branches: [main] + paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + pull_request: + paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: keymap-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + timeout-minutes: 15 + name: Keymap (Linux) + runs-on: ubuntu-latest + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/.test-inputs/craft-fonts + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Checkout test fonts + uses: actions/checkout@v4 + with: + repository: storytold/craft-fonts + ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + path: .test-inputs/craft-fonts + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-keymap-linux + - run: cargo fmt -p craft-keymap --check + - run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings + # `snapshot` covers `ui` and `egui` and compiles the dialog screenshots, which need a GPU + # to run, so the test steps leave them out. + - run: cargo clippy -p craft-keymap --all-targets --features snapshot --locked -- -D warnings + # Doctests run once, in the `ui` step. + - name: Test core + run: cargo test -p craft-keymap --no-default-features --lib --tests --locked + - name: Test egui dispatch and the shortcuts dialog + run: cargo test -p craft-keymap --features ui --locked + native: + timeout-minutes: 15 + name: Keymap (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/.test-inputs/craft-fonts + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Checkout test fonts + uses: actions/checkout@v4 + with: + repository: storytold/craft-fonts + ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + path: .test-inputs/craft-fonts + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-keymap-${{ matrix.os }} + - run: cargo test -p craft-keymap --features ui --locked + wasm: + timeout-minutes: 10 + name: Keymap WASM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-keymap-wasm + - run: cargo check -p craft-keymap --features ui --locked --target wasm32-unknown-unknown diff --git a/.github/workflows/media.yml b/.github/workflows/media.yml index a9fa3213..5d519b0a 100644 --- a/.github/workflows/media.yml +++ b/.github/workflows/media.yml @@ -21,7 +21,7 @@ jobs: name: Media lint, feature sets, WASM runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable @@ -43,7 +43,7 @@ jobs: name: Media tests with ffmpeg oracles runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index c2f8c3f6..2ff73a7a 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -3,9 +3,9 @@ name: Shared PDF on: push: branches: [main] - paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"] + paths: ["crates/pdf/**", "crates/plate-test/**", "crates/prepress/**", "crates/cms/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"] pull_request: - paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"] + paths: ["crates/pdf/**", "crates/plate-test/**", "crates/prepress/**", "crates/cms/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"] workflow_dispatch: permissions: @@ -26,14 +26,14 @@ jobs: env: RUSTFLAGS: --cfg craft_pdf_unicode_fixtures steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-pdf - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Provision licensed PDF test fonts @@ -50,7 +50,7 @@ jobs: name: PDF vendored contracts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: @@ -63,14 +63,14 @@ jobs: RUSTFLAGS: --cfg craft_pdf_unicode_fixtures CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-pdf-wasm - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Provision licensed PDF test fonts @@ -86,3 +86,25 @@ jobs: cargo install wasm-bindgen-cli --version "$runner_version" --locked - run: cargo test -p craft-pdf --all-features --locked --target wasm32-unknown-unknown - run: cargo test --manifest-path crates/pdf/read-only-contract/Cargo.toml --locked --target wasm32-unknown-unknown + plate-test: + name: Plate tests and craft-prepress + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: clippy + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-plate-test + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Provision licensed PDF test fonts + shell: bash + run: | + 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 --all-features --locked -- -D warnings + - run: cargo test -p craft-plate-test -p craft-prepress --all-features --locked diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index 270ac75e..9e144a04 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -17,7 +17,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e78fe1ea..1f88bd5d 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -16,6 +16,8 @@ concurrency: jobs: sonarcloud: - # Tracked at @main so org-wide CI improvements propagate without PR churn. + # Track shared CI improvements on main. uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + with: + pdf-test-fonts: true secrets: inherit diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index 3db33478..e9299f37 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -3,9 +3,9 @@ name: Shared UI on: push: branches: [main] - paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] + paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] pull_request: - paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] + paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] workflow_dispatch: permissions: @@ -25,7 +25,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install software Vulkan renderer if: runner.os == 'Linux' run: | @@ -48,7 +48,7 @@ jobs: name: UI WASM runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml new file mode 100644 index 00000000..be10934e --- /dev/null +++ b/.github/workflows/update.yml @@ -0,0 +1,36 @@ +name: App updates + +on: + workflow_dispatch: + push: + branches: [main] + paths: ['crates/update/**', 'Cargo.toml', 'Cargo.lock', 'rustfmt.toml', 'clippy.toml', '.github/workflows/update.yml'] + pull_request: + paths: ['crates/update/**', 'Cargo.toml', 'Cargo.lock', 'rustfmt.toml', 'clippy.toml', '.github/workflows/update.yml'] + +permissions: + contents: read + +jobs: + update: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + # The token isn't needed after checkout; cargo runs build scripts and proc macros from + # dependencies, so don't leave it in .git/config for that third-party code to read. + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + - run: cargo fmt --check + # Default (no egui) and the egui feature both build and lint cleanly. + - run: cargo clippy -p craft-update --all-targets --locked -- -D warnings + - run: cargo clippy -p craft-update --all-targets --locked --features egui -- -D warnings + - run: cargo test -p craft-update --locked -- --nocapture + - run: cargo test -p craft-update --locked --features egui -- --nocapture diff --git a/.github/workflows/window.yml b/.github/workflows/window.yml index 80aa86b6..b384d61b 100644 --- a/.github/workflows/window.yml +++ b/.github/workflows/window.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # The token isn't needed after checkout; cargo runs build scripts and proc macros from # dependencies, so don't leave it in .git/config for that third-party code to read. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 34cd2ee2..c6345e85 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,7 +8,20 @@ author, source and license. Library code and original assets are under the repos |---|---|---|---| | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 | | `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 | +| `crates/font-store/index/sources.json` (unmodified) | craft-font-index contributors | [aiongg/craft-font-index@5a9a7f8](https://github.com/aiongg/craft-font-index/tree/5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f) | Apache-2.0 | +| `crates/font-store/index/indexes/google-fonts.json.gz` (unmodified) | craft-font-index contributors; generated from the METADATA.pb files of [google/fonts](https://github.com/google/fonts) at `45e3d5269e4a17d823f6d84470bc084dc5ae46f6` | [aiongg/craft-font-index@5a9a7f8](https://github.com/aiongg/craft-font-index/tree/5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f) | Apache-2.0 | +| `crates/font-store/index/indexes/noto.json.gz` (unmodified) | craft-font-index contributors; generated from [notofonts/notofonts.github.io](https://github.com/notofonts/notofonts.github.io) at `93fac8abebe6c7fed0c5b7d2456d8fa484fa146c` and [noto-cjk](https://github.com/notofonts/noto-cjk) releases | [aiongg/craft-font-index@5a9a7f8](https://github.com/aiongg/craft-font-index/tree/5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f) | Apache-2.0 | | `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) | +| `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 | +| `crates/text/assets/hyphen/en-us.dic` | Grady Ward (Moby Hyphenator II), converted by DesignCraft | [Project Gutenberg eBook #3204](https://www.gutenberg.org/ebooks/3204) | Public domain | +| `crates/text/assets/hyphen/en-us.pat` | DesignCraft contributors, trained from the Moby Hyphenator II list | `crates/text/examples/hyphgen.rs` | CC0-1.0 | +| Patterns embedded through `hypher` (optional `craft-text` language features) | hyph-utf8 authors, per language | [tex-hyphen](https://github.com/hyphenation/tex-hyphen), as packaged in [hypher](https://github.com/typst/hypher/tree/main/patterns) | Per file header: LPPL, MIT, BSD-3-Clause, Unlicense or a permit-any-use notice (or a choice including one); Slovak (`hyph-sk.tex`, embedded whenever `hypher/slovak` is on: feature `slovak-gpl`, or another dependency through feature unification) is GPL-2.0-or-later; Spanish (`hyph-es.tex` 5.0, Javier Bezos, CervanTeX) is MIT | +| Word-break dictionaries and LSTM models embedded through `icu_segmenter_data` (optional `craft-text` features `thai`, `lao`, `khmer`, `burmese`) | Unicode, Inc. and ICU4X contributors | [ICU4X](https://github.com/unicode-org/icu4x) | Unicode-3.0 | +| `crates/ui/tests/fonts/NotoSansArabic-test.ttf` (subset to the test strings, default weight set to 100, width axis dropped) | The Noto Project Authors | [google/fonts@7085eb8 `ofl/notosansarabic`](https://github.com/google/fonts/tree/7085eb89a950e85db5b166b7a58d414544b4140c/ofl/notosansarabic), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansArabic.txt`) | +| `crates/ui/tests/fonts/NotoSansCJKsc-test.otf` (subset to the test strings) | The Noto Project Authors (Adobe, Google) | [notofonts/noto-cjk@f8d1575 `Sans/OTF/SimplifiedChinese`](https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansCJK.txt`) | +| `crates/ui/tests/fonts/NotoSansArabic-screens.ttf` (instance at weight 400 and width 100, subset to the screen test strings) | The Noto Project Authors | [google/fonts@7085eb8 `ofl/notosansarabic`](https://github.com/google/fonts/tree/7085eb89a950e85db5b166b7a58d414544b4140c/ofl/notosansarabic), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansArabic.txt`) | +| `crates/ui/tests/fonts/NotoSansCJKsc-screens.otf` (subset to printable ASCII and the screen test strings) | The Noto Project Authors (Adobe, Google) | [notofonts/noto-cjk@f8d1575 `Sans/OTF/SimplifiedChinese`](https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansCJK.txt`) | +| `crates/ui/tests/fonts/NotoSansThai-screens.ttf` (instance at weight 400 and width 100, subset to the screen test strings) | The Noto Project Authors | [google/fonts@8b0a1d0 `ofl/notosansthai`](https://github.com/google/fonts/tree/8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5/ofl/notosansthai), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansThai.txt`) | Original assets are covered by the repository licence. @@ -28,14 +41,15 @@ The profile licence is retained in `licenses/pdf-writer/LICENSE-ICC.txt`. | `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF writer colour profiles | The four Krilla ICC entries above are also stored unchanged in `vendor/krilla/icc`. -The vendored source patch and per-file hashes are recorded in `vendor/krilla/README.craft.md` and `vendor/krilla/SOURCE.json`. +The vendored source patches are in `vendor/krilla/patches`, described in `vendor/krilla/README.craft.md`; per-file hashes are in `vendor/krilla/SOURCE.json`. ## Assets retained with the optional PDF reader -The shared reader centralizes the four existing patched Hayro source trees from PdfCraft. +The shared reader centralizes the five patched Hayro source trees from PdfCraft and the +patched crates.io `hayro-postscript` 0.1.0 scanner, which bundles no assets. The standard-14 fonts, CMYK profile and generated metrics listed below remain byte-identical -in `vendor/hayro-interpret`; no application document fonts are added. CMaps remain a pinned -registry dependency. The removed upstream LAB.icc profile is not restored. Source hashes +in `vendor/hayro-interpret`, and the CMaps in `vendor/hayro-cmap/assets`; no application +document fonts are added. The removed upstream LAB.icc profile is not restored. Source hashes and the exact transfer revision are recorded in each vendored crate's SOURCE.json. | Crate / file | Author | Source | Licence | SHA-256 | Use | @@ -55,5 +69,5 @@ and the exact transfer revision are recorded in each vendored crate's SOURCE.jso | `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | | `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | | `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) | -| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/pdfcraft/blob/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | | `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | diff --git a/Cargo.lock b/Cargo.lock index 2b2ebaeb..e2f00610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,8 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ + "enumn", + "serde", "uuid", ] @@ -35,6 +37,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -94,6 +97,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arrayref" version = "0.3.9" @@ -105,6 +114,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" @@ -513,6 +531,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" @@ -571,6 +598,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -597,6 +633,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.8" @@ -733,15 +779,112 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-font-catalog" +version = "0.1.0" +dependencies = [ + "fontique", + "skrifa 0.47.0", + "thiserror 2.0.21", + "windows-registry", + "write-fonts 0.54.1", +] + +[[package]] +name = "craft-font-manager" +version = "0.1.0" +dependencies = [ + "craft-font-catalog", + "craft-font-store", + "craft-i18n", + "craft-ui", + "egui", + "egui_kittest", + "egui_system_font_provider", + "epaint_default_fonts", + "image", + "log", + "serde", +] + +[[package]] +name = "craft-font-store" +version = "0.1.0" +dependencies = [ + "craft-atomic", + "craft-font-catalog", + "flate2", + "serde", + "serde_json", + "sha2", + "skrifa 0.47.0", + "thiserror 2.0.21", + "ureq", + "write-fonts 0.54.1", +] + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +dependencies = [ + "craft-fonts-build-consumer", + "skera", + "skrifa 0.48.0", +] + +[[package]] +name = "craft-fonts-build-consumer" +version = "0.1.0" +dependencies = [ + "craft-fonts-build", +] + [[package]] name = "craft-i18n" version = "0.1.0" dependencies = [ - "intl_pluralrules", + "fixed_decimal", + "icu_collator", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", "log", + "objc2-foundation 0.3.2", "sys-locale", - "unic-langid", "unicode-bidi", + "windows-registry", + "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" +dependencies = [ + "craft-i18n", + "craft-settings", + "craft-ui", + "egui", + "egui_kittest", + "image", + "serde", + "serde_json", + "sha2", + "tempfile", ] [[package]] @@ -753,6 +896,18 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-menu" +version = "0.1.0" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -769,6 +924,18 @@ dependencies = [ "wasm-bindgen-test", ] +[[package]] +name = "craft-plate-test" +version = "0.1.0" +dependencies = [ + "craft-cms", + "craft-pdf", + "hayro-syntax", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-platform" version = "0.1.0" @@ -786,13 +953,26 @@ dependencies = [ "zbus", ] +[[package]] +name = "craft-prepress" +version = "0.1.0" +dependencies = [ + "craft-cms", + "craft-pdf", + "craft-plate-test", + "kurbo 0.13.1", + "linesweeper", + "thiserror 2.0.21", + "ttf-parser", +] + [[package]] name = "craft-print" version = "0.1.0" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -820,16 +1000,70 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "craft-text" +version = "0.1.0" +dependencies = [ + "hypher", + "icu_segmenter", + "miniz_oxide 0.8.9", + "unicode-bidi", + "unicode-bidi-mirroring", + "unicode-general-category", + "unicode-linebreak", + "unicode-segmentation", +] + [[package]] name = "craft-ui" version = "0.1.0" dependencies = [ + "craft-fonts-build", + "craft-i18n", + "eframe", "egui", "egui_extras", "egui_kittest", + "epaint", + "fontique", "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", "serde_json", + "skrifa 0.44.0", + "unicode-script", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", +] + +[[package]] +name = "craft-update" +version = "0.1.0" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", ] [[package]] @@ -837,6 +1071,8 @@ name = "craft-window" version = "0.1.0" dependencies = [ "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "wayland-client", ] @@ -955,6 +1191,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "digest" version = "0.10.7" @@ -1052,18 +1307,17 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ecolor" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] name = "eframe" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1071,6 +1325,9 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", "image", "js-sys", "log", @@ -1095,8 +1352,7 @@ dependencies = [ [[package]] name = "egui" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1107,6 +1363,7 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "serde", "smallvec", "unicode-segmentation", "web-sys", @@ -1115,8 +1372,7 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1135,10 +1391,10 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "block2 0.6.2", + "bytemuck", "egui", "log", "objc2 0.6.5", @@ -1155,8 +1411,7 @@ dependencies = [ [[package]] name = "egui_extras" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def286cf47194f3fe45b8e37c503ada7a20a64b59a903b4bc704baec943b1827" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1167,24 +1422,53 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ + "dify", "eframe", "egui", "egui-wgpu", "image", "kittest", "log", + "open", "pollster", "serde", + "tempfile", "toml", "wgpu", ] +[[package]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +dependencies = [ + "epaint", + "fontique", + "log", + "poll-promise", + "skrifa 0.44.0", + "sys-locale", + "unicode-script", +] + [[package]] name = "either" version = "1.19.0" @@ -1194,12 +1478,24 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", + "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1261,26 +1557,38 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.6", + "font-types 0.12.7", "harfrust", "log", "nohash-hasher", "parking_lot", "profiling", "self_cell", + "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", "unicode-properties", "unicode-segmentation", @@ -1290,8 +1598,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1375,6 +1682,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "flagset" version = "0.4.7" @@ -1446,11 +1764,34 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", + "serde", +] + +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.41.0", + "roxmltree 0.21.2", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", ] [[package]] @@ -1567,6 +1908,25 @@ 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 = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1580,6 +1940,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -1589,7 +1961,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -1643,6 +2015,7 @@ dependencies = [ "hashbrown 0.17.1", "log", "peniko", + "png 0.18.1", "skrifa 0.44.0", "smallvec", "vello_common 0.2.0", @@ -1660,6 +2033,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" @@ -1777,8 +2206,6 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" dependencies = [ "brotli", "hayro-postscript", @@ -1821,8 +2248,6 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" @@ -1969,6 +2394,12 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "hypher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f2b4f6b5da34d400d4c112eadcc14512cc5d04307a8eee182586d9bebd7ce6" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1993,6 +2424,24 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "serde", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -2001,12 +2450,30 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2015,11 +2482,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2030,7 +2518,11 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", + "serde", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2040,6 +2532,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2051,6 +2569,7 @@ dependencies = [ "icu_locale_core", "icu_properties_data", "icu_provider", + "serde", "zerotrie", "zerovec", ] @@ -2069,6 +2588,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2076,6 +2598,53 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "core_maths", + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + [[package]] name = "idna" version = "1.1.0" @@ -2142,21 +2711,31 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - [[package]] name = "ipnet" version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.15.0" @@ -2240,6 +2819,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" @@ -2335,9 +2920,16 @@ dependencies = [ "arrayvec", "euclid", "polycool", + "serde", "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" @@ -2378,6 +2970,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" @@ -2395,6 +3000,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -2429,6 +3037,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2587,6 +3204,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2602,6 +3231,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -2771,6 +3406,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2955,6 +3600,16 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3025,6 +3680,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + [[package]] name = "pdf-writer" version = "0.15.0" @@ -3182,6 +3843,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -3208,6 +3882,16 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "poll-promise" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6a58fecbf9da8965bcdb20ce4fd29788d1acee68ddbb64f0ba1b81bccdb7df" +dependencies = [ + "document-features", + "static_assertions", +] + [[package]] name = "polling" version = "3.11.0" @@ -3252,15 +3936,35 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3362,6 +4066,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -3428,6 +4141,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -3536,7 +4255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3547,10 +4266,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] +[[package]] +name = "read-fonts" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" +dependencies = [ + "bytemuck", + "foldhash", + "font-types 0.12.7", + "once_cell", + "yoke", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -3686,6 +4418,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3735,12 +4476,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3914,6 +4657,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -3953,9 +4700,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4251,6 +4998,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531045338e36ad05b9652912a47ba38a935d0eaa0293c3e33d8112cfa414c14b" +dependencies = [ + "hashbrown 0.17.1", + "skrifa 0.48.0", + "thiserror 2.0.21", + "write-fonts 0.54.1", +] + [[package]] name = "skrifa" version = "0.42.1" @@ -4281,6 +5040,16 @@ dependencies = [ "read-fonts 0.44.0", ] +[[package]] +name = "skrifa" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034" +dependencies = [ + "bytemuck", + "read-fonts 0.45.1", +] + [[package]] name = "slab" version = "0.4.12" @@ -4301,6 +5070,9 @@ name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] [[package]] name = "smol_str" @@ -4402,7 +5174,7 @@ dependencies = [ "kurbo 0.13.1", "rustc-hash 2.1.3", "skrifa 0.42.1", - "write-fonts", + "write-fonts 0.48.1", ] [[package]] @@ -4579,6 +5351,36 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4844,6 +5646,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -4867,24 +5675,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4915,6 +5705,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -4960,6 +5756,36 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4985,7 +5811,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -4994,6 +5820,18 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5056,6 +5894,7 @@ dependencies = [ "guillotiere", "log", "peniko", + "png 0.18.1", "smallvec", "thiserror 2.0.21", ] @@ -5082,9 +5921,20 @@ dependencies = [ "bytemuck", "glifo 0.3.0", "hashbrown 0.17.1", + "png 0.18.1", "vello_common 0.2.0", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5125,6 +5975,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -5252,7 +6111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -5297,6 +6156,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5710,13 +6578,13 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "android-activity", "atomic-waker", "bitflags 2.13.2", "block2 0.5.1", + "bytemuck", "calloop", "cfg_aliases", "concurrent-queue", @@ -5732,6 +6600,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", @@ -5744,6 +6613,8 @@ dependencies = [ "web-sys", "web-time", "windows-sys 0.52.0", + "x11-dl", + "x11rb", "xkbcommon-dl", ] @@ -5777,12 +6648,28 @@ dependencies = [ "xml", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winsafe" version = "0.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "write-fonts" version = "0.48.1" @@ -5796,11 +6683,65 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write-fonts" +version = "0.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55716cfa7e8bcdb90289e07dfac478ccb2e3ec0f9a604fe682c7a03a7984d705" +dependencies = [ + "font-types 0.12.7", + "indexmap", + "kurbo 0.13.1", + "log", + "read-fonts 0.45.1", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +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" @@ -5880,6 +6821,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.3" @@ -6041,8 +6993,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6068,6 +7023,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6080,6 +7052,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -6135,3 +7119,13 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 45ff1948..daa783f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" members = ["crates/*"] -exclude = ["vendor/krilla", "vendor/hayro", "vendor/hayro-interpret", "vendor/hayro-syntax", "vendor/hayro-jbig2"] +exclude = ["vendor/krilla", "vendor/hayro", "vendor/hayro-interpret", "vendor/hayro-syntax", "vendor/hayro-jbig2", "vendor/hayro-cmap", "vendor/hayro-postscript"] [workspace.package] version = "0.1.0" @@ -15,21 +15,33 @@ repository = "https://github.com/storytold/craft-libs" craft-platform = { path = "crates/platform" } craft-ai-core = { path = "crates/ai-core" } craft-pdf = { path = "crates/pdf", version = "=0.1.0" } +craft-plate-test = { path = "crates/plate-test" } krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] } 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-font-catalog = { path = "crates/font-catalog" } +craft-font-manager = { path = "crates/font-manager" } +craft-font-store = { path = "crates/font-store" } +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-menu = { path = "crates/menu" } +craft-prepress = { path = "crates/prepress" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } +craft-text = { path = "crates/text" } craft-ui = { path = "crates/ui" } +craft-update = { path = "crates/update" } craft-window = { path = "crates/window" } egui = { version = "0.37", default-features = false } rayon = "1" @@ -44,6 +56,9 @@ unsafe_code = "forbid" unexpected_cfgs = { level = "warn", check-cfg = ["cfg(craft_pdf_unicode_fixtures)"] } [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" @@ -54,3 +69,20 @@ hayro = { path = "vendor/hayro" } hayro-interpret = { path = "vendor/hayro-interpret" } hayro-syntax = { path = "vendor/hayro-syntax" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } +hayro-cmap = { path = "vendor/hayro-cmap" } +hayro-postscript = { path = "vendor/hayro-postscript" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 38a65fcf..a46d3754 100644 --- a/NOTICE +++ b/NOTICE @@ -19,6 +19,40 @@ crates/cms/tests/data/FOGRA39.txt: FOGRA39 characterisation data, distributed un the data are distributed unmodified and Fogra is identified as the source. The designation FOGRAxx may be used solely to identify the respective reference data. Such use does not imply certification, approval or endorsement by Fogra." +crates/text/assets/hyphen/en-us.dic: public domain, derived from the Moby Hyphenator II list by + Grady Ward (Project Gutenberg eBook #3204). +crates/text/assets/hyphen/en-us.pat: CC0-1.0, trained from en-us.dic by DesignCraft's trainer. +Hyphenation patterns for other languages, embedded through the hypher crate only when the + matching craft-text feature is enabled (none are by default). They come from hyph-utf8, + https://github.com/hyphenation/tex-hyphen, as packaged in https://github.com/typst/hypher + (patterns/). Each file is under the license stated in its header: LPPL, MIT, BSD-3-Clause, the + Unlicense, a notice permitting any use, or a choice of licenses that includes one of these. An + application that enables a language ships that file's copyright and license notice. The + exception is Slovak (hyph-sk.tex, Copyright (C) 1992 Jana Chlebíková), under the GNU General + Public License, version 2 or later (SPDX: GPL-2.0-or-later). craft-text asks for it through the + feature "slovak-gpl", which "all-languages" does not include; an application that enables it + ships GPL-licensed data. Cargo unifies features, so another dependency that enables hypher's + "slovak" feature (hypher's default features do) embeds the same data; a build that must exclude + it checks its dependency graph. The Spanish patterns (feature "spanish") are hyph-es.tex 5.0. + Copyright (C) 1993, 1997 Javier Bezos, 2001-2019 Javier Bezos, CervanTeX. MIT License: + Permission is hereby granted, free of charge, to any person obtaining a copy of this software + and associated documentation files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: The above copyright notice + and this permission notice shall be included in all copies or substantial portions of the + Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE + AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- Word-break dictionaries and LSTM models for Thai, Lao, Khmer and Burmese, embedded through the + icu_segmenter_data crate only when craft-text's "thai", "lao", "khmer" or "burmese" feature is + enabled (none are by default). From ICU4X, https://github.com/unicode-org/icu4x. + Copyright (c) 2020-2024 Unicode, Inc. Unicode License v3 (SPDX: Unicode-3.0); portions adapted + from ICU4C, (c) 1995-2016 International Business Machines Corporation and others. An + application that enables one of these features ships the Unicode License v3 copyright and + permission notice, https://www.unicode.org/license.txt. The optional PDF writer bundles four CC0 ICC profiles by Clinton Ingram. See ATTRIBUTION.md for per-file sources and hashes. @@ -53,6 +87,13 @@ The transactional panel model and drop-zone semantics in crates/ui/src/docking d FilmCraft/EffectCraft split, tab and accordion layouts and VectorCraft floating groups listed above. The generic transaction validation, optional renderer and workspace-location helpers are implemented here without dependencies on application engines or native window helpers. +The hyphenation code, data and generator in crates/text (src/hyphen/, assets/hyphen/ and +examples/hyphgen.rs) are adapted from: +- DesignCraft, crates/compose/src/hyphen/, crates/compose/examples/hyphgen.rs and + assets/hyphenation/, revision e161878e4c7744fa06f9a2eede8103cb4c296355 (above). +- WordCraft, crates/proof/src/hyphen.rs, revision e4b7d155f15a7784197ba34eeea0b54490d70eb2. + https://github.com/storytold/wordcraft + Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. All sources listed above are licensed MIT OR Apache-2.0. No icon artwork is copied here. The PDF spooler in crates/print is extracted from: @@ -75,6 +116,31 @@ PrintTicket and its driver-paper preference): Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors. Licensed MIT OR Apache-2.0. +The menu layout and native-menu driver in crates/menu are extracted from: +- PhotoCraft, crates/ui-egui/src/native_menu.rs, revision 6f85054288a564f9fbbccb99268ffdb34e581584. + https://github.com/storytold/photocraft + Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors. + Licensed MIT OR Apache-2.0. PhotoCraft's menus, translations and command handling are not copied. +The macOS backend in crates/menu/src/mac.rs is adapted from: +- PhotoCraft, apps/photocraft/src/mac_menu.rs, revision 6f85054288a564f9fbbccb99268ffdb34e581584. + https://github.com/storytold/photocraft + Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors. +- VectorCraft, apps/vectorcraft/src/mac_menu.rs, revision 0d0ea352df99044589a7ce0efc7004f7a5b6b7c0 + (closing an open menu on a click or Escape, and its tests, in crates/menu/src/driver.rs). + https://github.com/storytold/vectorcraft + Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. + Both licensed MIT OR Apache-2.0. +The egui menu bar in crates/menu/src/bar (its menu navigation, scrolling, submenu hover +intent, keyboard access and mnemonics, and their tests) is ported from: +- PhotoCraft, crates/ui-egui/src/menu_nav.rs and menu_nav_tests.rs, menu_keys.rs and + menu_keys_tests.rs, and the menu bar in crates/ui-egui/src/menus.rs, revision + c36e46c3873ee39f299cc50a3dcd28494c78366d. + https://github.com/storytold/photocraft + Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors, among them aiongg, artman67, + BFlat, Brandon Thomas, Brennan Stehling, Daniel Lošák, David Mann, Fabio Lissi, FredApps, + Higgs32584, itsF4LCON, jason4short, Korg1ch, MarekSpodBiedry and Nigel Ihza Firdausy. + Licensed MIT OR Apache-2.0. PhotoCraft's menus, theme and Help search are not copied. + The colour management module in crates/cms is extracted from: - PhotoCraft, crates/cms, revision b3222ee8e7e6bcb4799c37f904c595f20f9baf5e. https://github.com/storytold/photocraft @@ -91,20 +157,31 @@ The optional writer embeds Compact ICC Profiles by Clinton Ingram See licenses/pdf-writer/LICENSE-ICC.txt and ATTRIBUTION.md for sources and hashes. The writer adds no font files. Its four existing ICC profiles are retained under vendor/krilla/icc. -Krilla is vendored with a native unwind-cleanup patch; see vendor/krilla/README.craft.md +Krilla is vendored with the local patches in vendor/krilla/patches; see vendor/krilla/README.craft.md and vendor/krilla/SOURCE.json. Its upstream MIT/Apache-2.0 licences and CC0 profiles are unchanged. Optional PDF reader -Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 -(https://github.com/LaurenzV/hayro), MIT OR Apache-2.0, retain their upstream licence files. -These patched sources are transferred from PdfCraft revision -7437bde378f43856eada40af25b983812e615e8a (https://github.com/storytold/pdfcraft). +Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0 +and hayro-postscript 0.1.0 (https://github.com/LaurenzV/hayro), MIT OR Apache-2.0, retain their +upstream licence files. The first five are patched sources transferred from PdfCraft +(https://github.com/storytold/pdfcraft): hayro-cmap from revision +a2ec27b4775f53c40cad3456936d9406611e3a8d, the others from revision +ec0a965074f376a5bf610435948e4e5184bf3299. hayro-postscript is the crates.io 0.1.0 package +(upstream revision a2354aebaf86f5ab13e5fd6b6b0521306488bf9d) with one local patch. See vendor/README.craft-reader.md and each crate's SOURCE.json for provenance and local changes. +The hayro-interpret plate mode (separations, overprint and output-intent simulation) is ported +from PdfCraft pull request #2770 by Guillem Hermida, commit +6fcf2a4aca29e943e28cb69017ff8f87d00656c6 (https://github.com/storytold/pdfcraft/pull/2770), +MIT OR Apache-2.0; crates/pdf/tests/plates.rs and tests/support/synthetic_icc.rs adapt its tests. +Knockout transparency groups in hayro-interpret and hayro are ported from PdfCraft pull request +#2974 by mtlcl410, commit bae69819594a6385a027fec1f67e6b3c36c259b4 +(https://github.com/storytold/pdfcraft/pull/2974), MIT OR Apache-2.0; +crates/pdf/tests/knockout.rs adapts its test. The reader retains the existing Foxit/PDFium standard-14 font assets (BSD-3-Clause), Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Clause). Their licences and per-file hashes are retained in vendor/hayro-interpret/assets, -licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets; +vendor/hayro-cmap/assets, licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets; applications retain their own document-font selection and licensing policy. Platform lifecycle source provenance diff --git a/README.md b/README.md index 2dc92f8a..421c5aea 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,40 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = { git = "https://github.com/storytold/craft-libs", rev = "` and `` entries of the fontconfig configuration with its includes and the user's own. Inside a Flatpak sandbox, also the host's folders from `/run/host/font-dirs.xml` and from the host's configuration under `/run/host/etc/fonts`. +- Haiku: the system's and the user's `data/fonts` folders. + +The font service covers fonts outside those folders: + +- macOS: CoreText's available fonts, which include fonts that Adobe Fonts, RightFont and other font managers activate from their own folders. +- Windows: DirectWrite's system font collection, which includes per-user fonts and the fonts that font services such as Adobe Fonts load at run time. The registry adds fonts that programs register by full path. +- Linux and FreeBSD: fontconfig, loaded at run time (`fontconfig-dlopen`), so a system without it still runs. + +`FontEnv` holds the environment all of this depends on (`HOME`, the XDG variables, `FONTCONFIG_FILE`, `WINDIR`, `LOCALAPPDATA`, `CRAFT_FONT_STORE`, a file system root), so tests can describe any system without touching the process environment. + +## The font store + +`font_store_dir` is the one per-user folder that the suite's font store installs fonts into and every app's catalog reads. No other program reads it. + +| Platform | Folder | +|---|---| +| macOS | `~/Library/Application Support/Craft Fonts` | +| Windows | `%LOCALAPPDATA%\Craft Fonts` | +| Linux, BSDs, Haiku | `$XDG_DATA_HOME/craft-fonts` (`~/.local/share/craft-fonts`) | + +`CRAFT_FONT_STORE` replaces it, for portable installs and tests. + +## Scanning + +The scan reads each file's table directory and its `name`, `OS/2` and `fvar` tables, at most 1 MiB per table, never the whole file. Faces without outline tables (`glyf`, `CFF `, `CFF2`, `VARC`) are left out. Files in folders count when they have a font extension (`.ttf`, `.otf`, `.ttc`, `.otc`); files a font service or lister names count whatever their name. Mac suitcase fonts are read from their resource fork. Damaged files give no faces, never a panic. + +Each face (`FaceInfo`) has its family and style, legacy family and style, family names in other languages, full name, PostScript name, weight, width, italic, a named instance's axis settings, `fsType`, the OS/2 code page bits, its file and index, and its source. Each named instance of a variable font is a face of its own. + +## Lookup + +- `families()` and `styles(family)`. +- `family(name)` and `resolve(name, style)` accept any name of a family: its own name ignoring ASCII case, its names in other languages and its legacy names ignoring case and anything but letters and digits. `resolve` also accepts a legacy family with its legacy style (`Roboto Light` / `Regular`), a full name, and a PostScript name. +- Within a family, `resolve` matches the style by name (any of its names). Otherwise it picks the nearest weight, with italic differing counting as 1000, then the nearest width. It returns `Match::Exact` or `Match::NearestStyle`, so the app decides about substitutes. +- `by_postscript_name(name)`. + +## Loading and threads + +- The first scan runs on first use, or on a background thread after `scan_in_background`. Lookups wait for it. +- Lookups read an immutable snapshot, so a rescan never blocks them. +- `load_family` reads a family's files once. Other threads asking for the same family wait and share the result; other families load at the same time. Faces of one file share its bytes (`Arc<[u8]>`). +- `add_bytes` and `add_file` add fonts from documents, downloads or the app's bundle, within `Limits`. Added faces win over installed faces of the same family and style. `remove_added` removes a source's faces, for example when a document closes. + +## Changes + +- `rescan` scans again, reading only new and changed files. +- `installed_fonts_changed` compares the modification times of every folder the scan read (and the folders of files a font service named) and, on Windows, the registry's font list. It costs a few hundred `stat` calls, not a scan. +- `rescan_if_changed` does both; apps call it when their window is activated. +- `watch(interval, on_change)` polls on a background thread. +- `generation` counts the catalog's changes. + +## One enumeration for the UI and documents + +With the `system` feature, `system::collection()` returns the process's fontique `Collection`: the system's fonts and the font store's. The catalog lists its files, and craft-ui's font fallback can query the same collection instead of building its own, so the system's fonts are enumerated once per process. A rescan builds a new collection; `system::generation()` tells holders of an older clone to take a new one. + +## Try it + +```sh +cargo run -p craft-font-catalog --example fonts # counts, folders, faces outside them +cargo run -p craft-font-catalog --example fonts -- Arial # a family's faces +cargo run -p craft-font-catalog --example fonts -- --watch # report fonts installed or removed +``` diff --git a/crates/font-catalog/examples/fonts.rs b/crates/font-catalog/examples/fonts.rs new file mode 100644 index 00000000..772ad515 --- /dev/null +++ b/crates/font-catalog/examples/fonts.rs @@ -0,0 +1,51 @@ +//! Lists what the catalog finds on this computer, to check discovery by hand. +//! +//! ```text +//! cargo run -p craft-font-catalog --example fonts # counts, folders, font store +//! cargo run -p craft-font-catalog --example fonts -- Arial # a family's faces +//! cargo run -p craft-font-catalog --example fonts -- --watch # report fonts installed or removed +//! ``` + +use std::time::{Duration, Instant}; + +use craft_font_catalog::{Catalog, font_dirs, font_store_dir}; + +fn main() { + let catalog = Catalog::global(); + let args: Vec = std::env::args().skip(1).collect(); + let start = Instant::now(); + let families = catalog.families(); + let faces = catalog.faces(); + println!("{} families, {} faces, first scan {:?}", families.len(), faces.len(), start.elapsed()); + println!("font store: {:?}", font_store_dir(catalog.env())); + for dir in font_dirs(catalog.env()).iter().filter(|d| d.is_dir()) { + println!("folder: {}", dir.display()); + } + let outside: Vec<_> = + faces.iter().filter(|f| f.path.as_ref().is_some_and(|p| !font_dirs(catalog.env()).iter().any(|d| p.starts_with(d)))).collect(); + println!("{} faces outside the font folders (font service, store, registry):", outside.len()); + for f in outside.iter().take(40) { + println!(" {} {} ({:?}) {}", f.family, f.style, f.source, f.path.as_deref().map(|p| p.display().to_string()).unwrap_or_default()); + } + match args.first().map(String::as_str) { + Some("--watch") => { + let _watch = catalog.watch(Duration::from_secs(2), |c| println!("changed: {} families", c.families().len())); + println!("watching; install or remove a font, Ctrl+C to stop"); + loop { + std::thread::sleep(Duration::from_secs(3600)); + } + } + Some(name) => { + for f in catalog.styles(name) { + println!( + "{} / {}: weight {} width {} italic {} ps {:?} {:?} #{} {:?}", + f.family, f.style, f.weight, f.width, f.italic, f.postscript_name, f.path, f.index, f.source + ); + } + if let Some((f, m)) = catalog.resolve(name, args.get(1).map_or("Regular", String::as_str)) { + println!("resolve: {} {} ({m:?})", f.family, f.style); + } + } + None => {} + } +} diff --git a/crates/font-catalog/src/catalog.rs b/crates/font-catalog/src/catalog.rs new file mode 100644 index 00000000..c20753fb --- /dev/null +++ b/crates/font-catalog/src/catalog.rs @@ -0,0 +1,778 @@ +//! The catalog: every installed face, found by any of its names, loaded once. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock, Weak}; +use std::time::{Duration, SystemTime}; + +use crate::scan::{self, Face, FaceInfo, Source, norm, style_italic, style_weight, style_width}; +use crate::{FontEnv, Os, font_dirs, font_store_dir}; + +/// Lists font files that the platform's font service knows outside the font folders. Called by +/// every scan. +pub type Lister = Arc Vec + Send + Sync>; + +/// How deep the scan goes below a font folder. +const MAX_DEPTH: usize = 12; +/// The most folder entries one scan looks at. +const MAX_ENTRIES: usize = 200_000; +/// The most bytes the scan reads from one file (its tables, or a suitcase's resource fork). +const MAX_SCAN_READ: u64 = 256 << 20; +/// The largest font file loaded. +const MAX_LOAD: u64 = 512 << 20; + +/// Why a font file or font data wasn't added or loaded. +#[derive(Debug, thiserror::Error)] +pub enum FontFileError { + /// Reading the file failed. + #[error("the font file can't be read: {0}")] + Io(#[from] std::io::Error), + /// The file is larger than the limit. + #[error("the font file is {size} bytes, more than the {max} allowed")] + TooLarge { + /// The file's size. + size: u64, + /// The limit. + max: u64, + }, + /// No face in it could be read. + #[error("not a font file, or a damaged one")] + NotAFont, + /// Adding it would pass the limit on added faces. + #[error("more than {max} added font faces")] + TooManyFaces { + /// The limit. + max: usize, + }, + /// The face is no longer in the catalog (its data was removed). + #[error("the font is no longer available")] + Gone, +} + +/// Limits on fonts added from files or bytes ([`Catalog::add_bytes`], [`Catalog::add_file`]), +/// which may come from documents or downloads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// The largest file accepted. + pub max_file_bytes: u64, + /// The most faces read from one file (a collection). + pub max_faces_per_file: u32, + /// The most added faces the catalog holds in all. + pub max_added_faces: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_file_bytes: 256 << 20, max_faces_per_file: 256, max_added_faces: 1000 } + } +} + +/// How [`Catalog::resolve`] matched. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Match { + /// The family and style asked for, by any of their names. + Exact, + /// The family, in the style nearest to the one asked for. + NearestStyle, +} + +/// A face's data: the whole file (or a suitcase font's resource) and the face's index in it. +#[derive(Clone, Debug)] +pub struct FontData { + /// The file's bytes, shared by every face loaded from it. + pub bytes: Arc<[u8]>, + /// The index to pass to a font parser. + pub index: u32, +} + +/// A loaded face ([`Catalog::load_family`]). +#[derive(Clone, Debug)] +pub struct LoadedFace { + /// The face. + pub info: FaceInfo, + /// Its data. + pub data: FontData, +} + +/// A face the scan can't find again, as an alias records it. +#[derive(Clone, Debug)] +enum Alias { + /// The family of this face. + Family(usize), + /// A legacy family name: this face, under this legacy style. + Legacy(usize, String), + /// A full or PostScript name: this face. + Face(usize), +} + +/// One state of the catalog: what lookups read while a scan builds the next one. +#[derive(Default)] +struct Index { + faces: Vec, + infos: Arc<[FaceInfo]>, + families: Arc<[String]>, + by_family: HashMap>, + aliases: HashMap>, + postscript: HashMap, +} + +impl Index { + /// Added faces first: a document's or the app's own face wins over an installed face of the + /// same family and style. + fn build(added: &[Face], scanned: &[Face]) -> Self { + let mut faces: Vec = Vec::new(); + let mut seen = HashSet::new(); + for f in added.iter().chain(scanned) { + let variations: Vec<(u32, u32)> = f.info.variations.iter().map(|(t, v)| (u32::from_be_bytes(*t), v.to_bits())).collect(); + let key = (f.info.family.to_ascii_lowercase(), norm(&f.info.style), f.keys.postscript.first().cloned(), variations); + if seen.insert(key) { + faces.push(f.clone()); + } + } + let mut by_family: HashMap> = HashMap::new(); + let mut aliases: HashMap> = HashMap::new(); + let mut postscript: HashMap = HashMap::new(); + let mut families: Vec = Vec::new(); + for (i, f) in faces.iter().enumerate() { + let entry = by_family.entry(f.info.family.to_ascii_lowercase()).or_default(); + if entry.is_empty() { + families.push(f.info.family.clone()); + } + entry.push(i); + for k in &f.keys.families { + let list = aliases.entry(k.clone()).or_default(); + if !list + .iter() + .any(|a| matches!(a, Alias::Family(j) if faces.get(*j).is_some_and(|g| g.info.family.eq_ignore_ascii_case(&f.info.family)))) + { + list.push(Alias::Family(i)); + } + } + for (family, style) in &f.keys.legacy { + aliases.entry(family.clone()).or_default().push(Alias::Legacy(i, style.clone())); + } + for k in f.keys.full_names.iter().chain(&f.keys.postscript) { + aliases.entry(k.clone()).or_default().push(Alias::Face(i)); + } + for k in &f.keys.postscript { + postscript.entry(k.clone()).or_insert(i); + } + } + // Narrow to wide, light to heavy, upright first. The sort is stable, so of two faces alike + // the added one stays first and wins a tie in `pick_style`. + for list in by_family.values_mut() { + list.sort_by(|a, b| { + let (Some(a), Some(b)) = (faces.get(*a), faces.get(*b)) else { return std::cmp::Ordering::Equal }; + (a.info.width.total_cmp(&b.info.width)).then(a.info.weight.total_cmp(&b.info.weight)).then(a.info.italic.cmp(&b.info.italic)) + }); + } + families.sort_by_cached_key(|f| (f.to_lowercase(), f.clone())); + let infos: Arc<[FaceInfo]> = faces.iter().map(|f| f.info.clone()).collect(); + Self { faces, infos, families: families.into(), by_family, aliases, postscript } + } + + fn face(&self, i: usize) -> Option<&Face> { + self.faces.get(i) + } + + /// The family `name` names: a family name ignoring ASCII case, else any family name of a face + /// (another language, legacy) ignoring case and everything but letters and digits. + fn family_of(&self, name: &str) -> Option<&[usize]> { + if let Some(faces) = self.by_family.get(&name.to_ascii_lowercase()) { + return Some(faces); + } + let aliases = self.aliases.get(&norm(name))?; + let i = aliases.iter().find_map(|a| match a { + Alias::Family(i) => Some(*i), + _ => None, + })?; + self.by_family.get(&self.face(i)?.info.family.to_ascii_lowercase()).map(Vec::as_slice) + } + + fn resolve(&self, name: &str, style: &str) -> Option<(usize, Match)> { + if let Some(faces) = self.family_of(name) { + return self.pick_style(faces, style); + } + let aliases = self.aliases.get(&norm(name))?; + let legacy: Vec<(usize, &str)> = aliases + .iter() + .filter_map(|a| match a { + Alias::Legacy(i, s) => Some((*i, s.as_str())), + _ => None, + }) + .collect(); + if !legacy.is_empty() { + let ns = style_key(style); + if let Some((i, _)) = legacy.iter().find(|(_, s)| *s == ns) { + return Some((*i, Match::Exact)); + } + let faces: Vec = legacy.iter().map(|(i, _)| *i).collect(); + return self.pick_style(&faces, style).map(|(i, _)| (i, Match::NearestStyle)); + } + aliases.iter().find_map(|a| match a { + Alias::Face(i) => Some((*i, Match::Exact)), + _ => None, + }) + } + + /// The face of `faces` (one family) in `style`: by its style name, then by any other name of + /// its style, else the nearest weight with italic differing costing 1000, then the nearest + /// width; the first face wins a tie. + fn pick_style(&self, faces: &[usize], style: &str) -> Option<(usize, Match)> { + let ns = style_key(style); + let cands: Vec<(usize, &Face)> = faces.iter().filter_map(|i| Some((*i, self.face(*i)?))).collect(); + if let Some((i, _)) = cands.iter().find(|(_, f)| norm(&f.info.style) == ns) { + return Some((*i, Match::Exact)); + } + if let Some((i, _)) = cands.iter().find(|(_, f)| f.keys.styles.contains(&ns)) { + return Some((*i, Match::Exact)); + } + let (tw, ti, twd) = (style_weight(style), style_italic(style), style_width(style)); + let score = |f: &Face| ((f.info.weight - tw).abs() + if f.info.italic != ti { 1000.0 } else { 0.0 }, (f.info.width - twd).abs()); + cands + .iter() + .min_by(|(_, a), (_, b)| { + let (sa, sb) = (score(a), score(b)); + sa.0.total_cmp(&sb.0).then(sa.1.total_cmp(&sb.1)) + }) + .map(|(i, _)| (*i, Match::NearestStyle)) + } +} + +/// A style name as compared: normalized, with no style meaning `Regular`. +fn style_key(style: &str) -> String { + let ns = norm(style); + if ns.is_empty() { "regular".into() } else { ns } +} + +/// What the catalog reads besides the platform's font folders. +struct Config { + app_dirs: Vec, + listers: Vec, + font_service: bool, +} + +/// A file found by a scan, remembered so a rescan reads only files that changed. +struct Cached { + len: u64, + modified: Option, + faces: Vec, +} + +#[derive(Default)] +struct ScanState { + files: HashMap, +} + +/// What a change check compares with. +#[derive(Default)] +struct Stamp { + app_dirs: Vec, + /// Every folder the scan listed, and the folders holding the files listers named, with their + /// modification times (`None`: missing). + dirs: Vec<(PathBuf, Option)>, + /// The fonts registered with Windows. + registered: Vec, +} + +#[derive(Default)] +struct Added { + faces: Vec, + blobs: HashMap>, + next_blob: u64, +} + +/// A family's faces, loaded once ([`Catalog::load_family`]). +type FamilyLoad = Arc>>; + +/// The installed fonts: the platform's font folders, the files its font service knows, the font +/// store and the app's own folders, plus faces added from files or bytes. +/// +/// The first scan runs on first use or on a background thread ([`Catalog::scan_in_background`]); +/// lookups wait for it. Lookups read an immutable snapshot, so they never wait for a rescan. +/// Each family's files load once ([`Catalog::load_family`]): other threads asking for the same +/// family wait for that load, other families load at the same time. +pub struct Catalog { + env: FontEnv, + config: RwLock, + index: RwLock>, + ready: OnceLock<()>, + scan: Mutex, + stamp: Mutex, + generation: AtomicU64, + added: Mutex, + loaded: Mutex>, + files: Mutex>>, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn modified(path: &Path) -> Option { + std::fs::metadata(path).and_then(|m| m.modified()).ok() +} + +impl Catalog { + /// A catalog of the fonts installed on the system `env` describes, the font store, and the + /// files the platform's font service lists (with the `system` feature). + pub fn new(env: FontEnv) -> Self { + Self { + env, + config: RwLock::new(Config { app_dirs: Vec::new(), listers: Vec::new(), font_service: cfg!(feature = "system") }), + index: RwLock::new(Arc::new(Index::default())), + ready: OnceLock::new(), + scan: Mutex::new(ScanState::default()), + stamp: Mutex::new(Stamp::default()), + generation: AtomicU64::new(0), + added: Mutex::new(Added::default()), + loaded: Mutex::new(HashMap::new()), + files: Mutex::new(HashMap::new()), + } + } + + /// The process's catalog, for [`FontEnv::from_process`]. + pub fn global() -> &'static Arc { + static GLOBAL: OnceLock> = OnceLock::new(); + GLOBAL.get_or_init(|| Arc::new(Catalog::new(FontEnv::from_process()))) + } + + /// Whether scans ask the platform's font service (CoreText, DirectWrite, fontconfig) for the + /// font files it knows: on by default with the `system` feature, which provides it. + #[must_use] + pub fn with_font_service(self, on: bool) -> Self { + self.write_config().font_service = on && cfg!(feature = "system"); + self + } + + /// Have every scan also read the files `lister` names (any file name; a folder is read with + /// its subfolders), as fonts of the system. + #[must_use] + pub fn with_lister(self, lister: Lister) -> Self { + self.write_config().listers.push(lister); + self + } + + /// The app's own font folders (its Fonts folder, Office's, folders the user picked), read + /// with their subfolders from the next scan on. A change shows in + /// [`Catalog::installed_fonts_changed`]. + pub fn set_app_dirs(&self, dirs: Vec) { + self.write_config().app_dirs = dirs; + } + + fn write_config(&self) -> std::sync::RwLockWriteGuard<'_, Config> { + self.config.write().unwrap_or_else(PoisonError::into_inner) + } + + fn read_config(&self) -> std::sync::RwLockReadGuard<'_, Config> { + self.config.read().unwrap_or_else(PoisonError::into_inner) + } + + /// The environment the catalog was made for. + pub fn env(&self) -> &FontEnv { + &self.env + } + + /// Start the first scan on a background thread, so the first lookup doesn't wait for all of + /// it. Does nothing once it has run, or when no thread can be started (the first lookup + /// then scans). + pub fn scan_in_background(self: &Arc) { + if self.ready.get().is_none() { + let me = Arc::clone(self); + let _ = std::thread::Builder::new().name("font-catalog-scan".into()).spawn(move || me.ensure()); + } + } + + fn ensure(&self) { + self.ready.get_or_init(|| { + self.scan_now(false); + }); + } + + /// Whether the first scan has finished: lookups won't wait. + pub fn is_ready(&self) -> bool { + self.ready.get().is_some() + } + + /// Scan again, for fonts installed or removed since; only new and changed files are read. + /// Families loaded before load again on their next [`Catalog::load_family`]. Returns the + /// number of faces cataloged. + pub fn rescan(&self) -> usize { + let mut first = false; + self.ready.get_or_init(|| { + first = true; + self.scan_now(false); + }); + if !first { + self.scan_now(true); + } + self.snapshot().faces.len() + } + + /// [`Catalog::rescan`] when [`Catalog::installed_fonts_changed`] says so, for an app to call + /// when its window is activated. Returns whether it rescanned. + pub fn rescan_if_changed(&self) -> bool { + let changed = self.installed_fonts_changed(); + if changed { + self.rescan(); + } + changed + } + + /// Whether fonts were installed or removed since the last scan: a folder the scan read (or + /// the folder of a file the font service named) changed, a missing one appeared, the app's + /// folders changed, or on Windows the registry's font list changed. It looks at folders, + /// not files, so it costs far less than a scan. `false` before the first scan. + /// + /// Fonts a font service registers from a folder no scan has seen are found by the next + /// [`Catalog::rescan`] only. + pub fn installed_fonts_changed(&self) -> bool { + if !self.is_ready() { + return false; + } + let stamp = lock(&self.stamp); + if stamp.app_dirs != self.read_config().app_dirs { + return true; + } + if stamp.dirs.iter().any(|(p, t)| modified(p) != *t) { + return true; + } + self.read_config().font_service && self.env.os == Os::Windows && stamp.registered != crate::registry::registered_font_files() + } + + /// Poll for installed and removed fonts every `interval` on a background thread: when + /// [`Catalog::installed_fonts_changed`], rescan and call `on_change`. Stops when the returned + /// [`Watch`] or the catalog is dropped. + pub fn watch(self: &Arc, interval: Duration, on_change: impl Fn(&Catalog) + Send + 'static) -> Watch { + let stop = Arc::new(AtomicBool::new(false)); + let (me, flag) = (Arc::downgrade(self), Arc::clone(&stop)); + let _ = std::thread::Builder::new().name("font-catalog-watch".into()).spawn(move || { + loop { + std::thread::sleep(interval); + if flag.load(Ordering::Relaxed) { + return; + } + let Some(catalog) = me.upgrade() else { return }; + if catalog.rescan_if_changed() { + on_change(&catalog); + } + } + }); + Watch { stop } + } + + /// Counts the catalog's changes: each scan, and each face added or removed. + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + fn snapshot(&self) -> Arc { + self.ensure(); + Arc::clone(&self.index.read().unwrap_or_else(PoisonError::into_inner)) + } + + fn publish(&self, index: Index) { + *self.index.write().unwrap_or_else(PoisonError::into_inner) = Arc::new(index); + lock(&self.loaded).clear(); + self.generation.fetch_add(1, Ordering::AcqRel); + } + + /// The family names, sorted ignoring case. + pub fn families(&self) -> Arc<[String]> { + Arc::clone(&self.snapshot().families) + } + + /// Every face, added faces first. + pub fn faces(&self) -> Arc<[FaceInfo]> { + Arc::clone(&self.snapshot().infos) + } + + /// The family `name` names: its own name ignoring ASCII case, or any other of its names + /// (another language, a legacy name) ignoring case and everything but letters and digits. + pub fn family(&self, name: &str) -> Option { + let index = self.snapshot(); + let i = *index.family_of(name)?.first()?; + index.face(i).map(|f| f.info.family.clone()) + } + + /// The faces of the family `name` names ([`Catalog::family`]). + pub fn styles(&self, family: &str) -> Vec { + let index = self.snapshot(); + index.family_of(family).unwrap_or_default().iter().filter_map(|i| index.face(*i)).map(|f| f.info.clone()).collect() + } + + /// The face for `name` in `style`, and how it matched. `name` is a family name (any of its + /// names, as [`Catalog::family`]), a legacy family name (`Roboto Light`, matched with its + /// legacy style), a full name or a PostScript name (which name one face; `style` is then + /// ignored). Within the family, `style` matches by name; otherwise the nearest weight wins, + /// italic differing counting as 1000, then the nearest width. An empty style is `Regular`. + /// `None` when nothing has that name. + pub fn resolve(&self, name: &str, style: &str) -> Option<(FaceInfo, Match)> { + let index = self.snapshot(); + let (i, matched) = index.resolve(name, style)?; + Some((index.face(i)?.info.clone(), matched)) + } + + /// The face with this PostScript name (a named instance's too), ignoring case and everything + /// but letters and digits. + pub fn by_postscript_name(&self, name: &str) -> Option { + let index = self.snapshot(); + let i = *index.postscript.get(&norm(name))?; + index.face(i).map(|f| f.info.clone()) + } + + /// The data of `face`, read from its file (shared with other faces of the file still loaded). + pub fn load(&self, face: &FaceInfo) -> Result { + if let Some(blob) = face.blob { + let bytes = lock(&self.added).blobs.get(&blob).cloned().ok_or(FontFileError::Gone)?; + return Ok(FontData { bytes, index: face.index }); + } + let path = face.path.as_deref().ok_or(FontFileError::Gone)?; + if crate::suitcase::is_suitcase(path) { + let font = crate::suitcase::read_fonts(path).into_iter().nth(face.index as usize).ok_or(FontFileError::NotAFont)?; + return Ok(FontData { bytes: font.into(), index: 0 }); + } + Ok(FontData { bytes: self.file_bytes(path)?, index: face.index }) + } + + fn file_bytes(&self, path: &Path) -> Result, FontFileError> { + if let Some(bytes) = lock(&self.files).get(path).and_then(Weak::upgrade) { + return Ok(bytes); + } + let size = std::fs::metadata(path)?.len(); + if size > MAX_LOAD { + return Err(FontFileError::TooLarge { size, max: MAX_LOAD }); + } + let bytes: Arc<[u8]> = std::fs::read(path)?.into(); + let mut files = lock(&self.files); + files.retain(|_, w| w.strong_count() > 0); + files.insert(path.to_path_buf(), Arc::downgrade(&bytes)); + Ok(bytes) + } + + /// Every face of the family `family` names ([`Catalog::family`]) with its data. The first + /// call reads the family's files; calls for the same family on other threads wait for it and + /// share the result, while other families load at the same time. Faces whose files can't be + /// read are left out. After a rescan, the next call reads the files again. + pub fn load_family(&self, family: &str) -> Arc<[LoadedFace]> { + let Some(name) = self.family(family) else { return Arc::from([]) }; + let cell = Arc::clone(lock(&self.loaded).entry(name.to_ascii_lowercase()).or_default()); + Arc::clone( + cell.get_or_init(|| self.styles(&name).into_iter().filter_map(|info| Some(LoadedFace { data: self.load(&info).ok()?, info })).collect()), + ) + } + + /// Add the faces of a font file's `bytes` (a font or a collection) for this session, as + /// `source`. Returns the faces added. + pub fn add_bytes(&self, bytes: Vec, source: Source, limits: &Limits) -> Result, FontFileError> { + let size = bytes.len() as u64; + if size > limits.max_file_bytes { + return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes }); + } + let mut faces = scan::data_faces(&bytes, limits.max_faces_per_file, None, source); + if faces.is_empty() { + return Err(FontFileError::NotAFont); + } + self.ensure(); + let mut added = lock(&self.added); + if added.faces.len() + faces.len() > limits.max_added_faces { + return Err(FontFileError::TooManyFaces { max: limits.max_added_faces }); + } + let blob = added.next_blob; + added.next_blob += 1; + added.blobs.insert(blob, bytes.into()); + for f in &mut faces { + f.info.blob = Some(blob); + } + Ok(self.push_added(added, faces)) + } + + /// Add the faces of the font file at `path` for this session, as `source`, read in place. + /// Returns the faces added. + pub fn add_file(&self, path: &Path, source: Source, limits: &Limits) -> Result, FontFileError> { + let size = std::fs::metadata(path)?.len(); + if size > limits.max_file_bytes { + return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes }); + } + let mut faces = scan::file_faces(path, limits.max_file_bytes, source); + let mut fonts: Vec = faces.iter().map(|f| f.info.index).collect(); + fonts.dedup(); + if let Some(&keep) = fonts.get(limits.max_faces_per_file as usize) { + faces.retain(|f| f.info.index < keep); + } + if faces.is_empty() { + return Err(FontFileError::NotAFont); + } + self.ensure(); + let added = lock(&self.added); + if added.faces.len() + faces.len() > limits.max_added_faces { + return Err(FontFileError::TooManyFaces { max: limits.max_added_faces }); + } + Ok(self.push_added(added, faces)) + } + + fn push_added(&self, mut added: MutexGuard<'_, Added>, faces: Vec) -> Vec { + let infos = faces.iter().map(|f| f.info.clone()).collect(); + added.faces.extend(faces); + let scanned = self.scanned(); + self.publish(Index::build(&added.faces, &scanned)); + infos + } + + /// Remove every added face of `source` (a closed document's fonts). Returns how many. + pub fn remove_added(&self, source: Source) -> usize { + let mut added = lock(&self.added); + let before = added.faces.len(); + added.faces.retain(|f| f.info.source != source); + let blobs: HashSet = added.faces.iter().filter_map(|f| f.info.blob).collect(); + added.blobs.retain(|b, _| blobs.contains(b)); + let removed = before - added.faces.len(); + if removed > 0 { + let scanned = self.scanned(); + self.publish(Index::build(&added.faces, &scanned)); + } + removed + } + + /// The faces the last scan found. + fn scanned(&self) -> Vec { + let scan = lock(&self.scan); + let mut faces: Vec<(&PathBuf, &Cached)> = scan.files.iter().collect(); + faces.sort_by(|a, b| a.0.cmp(b.0)); + faces.into_iter().flat_map(|(_, c)| c.faces.iter().cloned()).collect() + } + + fn scan_now(&self, refresh: bool) { + let mut state = lock(&self.scan); + let (app_dirs, listers, service) = { + let c = self.read_config(); + (c.app_dirs.clone(), c.listers.clone(), c.font_service) + }; + let ignore_case = self.env.os == Os::Windows; + let mut roots: Vec<(PathBuf, Source)> = font_dirs(&self.env).into_iter().map(|d| (d, Source::System)).collect(); + roots.extend(font_store_dir(&self.env).map(|d| (d, Source::Store))); + roots.extend(app_dirs.iter().map(|d| (d.clone(), Source::App))); + + let mut named: Vec = Vec::new(); + let mut registered = Vec::new(); + if service { + named.extend(crate::system::font_files(refresh)); + if self.env.os == Os::Windows { + registered = crate::registry::registered_font_files(); + named.extend(registered.iter().cloned()); + } + } + for lister in &listers { + named.extend(lister()); + } + + let mut walk = Walk { ignore_case, seen_dirs: HashSet::new(), seen_files: HashSet::new(), entries: 0, dirs: Vec::new(), files: Vec::new() }; + for (root, source) in &roots { + walk.dir(root, *source, 0); + } + let mut parents = HashSet::new(); + for file in named { + if file.is_dir() { + walk.dir(&file, Source::System, 0); + continue; + } + if let Some(parent) = file.parent() + && parents.insert(parent.to_path_buf()) + { + walk.dirs.push((parent.to_path_buf(), modified(parent))); + } + walk.file(file, Source::System, true); + } + + let mut files = HashMap::new(); + for (path, source, by_name) in walk.files { + let Ok(meta) = std::fs::metadata(&path) else { continue }; + let (len, mtime) = (meta.len(), meta.modified().ok()); + let cached = state.files.remove(&path).filter(|c| c.len == len && c.modified == mtime); + let mut faces = match cached { + Some(c) => c.faces, + None if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => scan::file_faces(&path, MAX_SCAN_READ, source), + None => continue, + }; + for f in &mut faces { + f.info.source = source; + } + files.insert(path, Cached { len, modified: mtime, faces }); + } + state.files = files; + drop(state); + + *lock(&self.stamp) = Stamp { app_dirs, dirs: walk.dirs, registered }; + let added = lock(&self.added); + let scanned = self.scanned(); + self.publish(Index::build(&added.faces, &scanned)); + } +} + +/// Stops a [`Catalog::watch`] when dropped. +#[must_use = "dropping a Watch stops it"] +pub struct Watch { + stop: Arc, +} + +impl Drop for Watch { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +/// The files of one scan: each folder and file once, however links lead back to it. +struct Walk { + ignore_case: bool, + seen_dirs: HashSet, + seen_files: HashSet, + entries: usize, + dirs: Vec<(PathBuf, Option)>, + /// Each file with where it was found and whether a lister named it. + files: Vec<(PathBuf, Source, bool)>, +} + +impl Walk { + fn key(&self, path: &Path) -> PathBuf { + let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if self.ignore_case { PathBuf::from(real.to_string_lossy().to_lowercase()) } else { real } + } + + fn dir(&mut self, dir: &Path, source: Source, depth: usize) { + let key = self.key(dir); + if !self.seen_dirs.insert(key) { + return; + } + // Stamped before it is listed: a font installed meanwhile changes it after this. + self.dirs.push((dir.to_path_buf(), modified(dir))); + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut paths: Vec<(PathBuf, bool)> = Vec::new(); + for e in entries.flatten() { + self.entries += 1; + if self.entries > MAX_ENTRIES { + break; + } + let path = e.path(); + let is_dir = e.file_type().is_ok_and(|t| t.is_dir() || (t.is_symlink() && path.is_dir())); + paths.push((path, is_dir)); + } + paths.sort(); + for (path, is_dir) in paths { + if is_dir { + if depth < MAX_DEPTH { + self.dir(&path, source, depth + 1); + } + } else { + self.file(path, source, false); + } + } + } + + fn file(&mut self, path: PathBuf, source: Source, by_name: bool) { + let key = self.key(&path); + if self.seen_files.insert(key) { + self.files.push((path, source, by_name)); + } + } +} diff --git a/crates/font-catalog/src/env.rs b/crates/font-catalog/src/env.rs new file mode 100644 index 00000000..08a84bdd --- /dev/null +++ b/crates/font-catalog/src/env.rs @@ -0,0 +1,235 @@ +//! Where fonts are installed: the platform's font folders and the suite's font store, worked out +//! from a [`FontEnv`] value rather than the process environment, so tests can describe any system. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// The platform family whose font folders apply. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Os { + /// macOS. + MacOs, + /// Windows. + Windows, + /// Linux and the BSDs: XDG folders, fontconfig and Flatpak. + #[default] + Unix, + /// Haiku. + Haiku, + /// The web, Android, iOS and anything else without font folders to scan. + None, +} + +impl Os { + /// The platform this build runs on. + pub fn current() -> Self { + if cfg!(target_arch = "wasm32") || cfg!(target_os = "android") || cfg!(target_os = "ios") { + Self::None + } else if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(windows) { + Self::Windows + } else if cfg!(target_os = "haiku") { + Self::Haiku + } else { + Self::Unix + } + } +} + +/// The environment the font folders depend on. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FontEnv { + /// Which platform's folders to list. + pub os: Os, + /// The file system root that fixed system folders (`/usr/share/fonts`, `/run/host/fonts`, + /// `/System/Library/Fonts`, `/etc/fonts/fonts.conf`) and absolute fontconfig `` entries + /// are under: `/` on a real system, a temporary folder in tests. + pub root: PathBuf, + /// `HOME` (`USERPROFILE` on Windows). + pub home: Option, + /// `XDG_DATA_HOME`. + pub xdg_data_home: Option, + /// `XDG_DATA_DIRS`. + pub xdg_data_dirs: Option, + /// `XDG_CONFIG_HOME`. + pub xdg_config_home: Option, + /// `FONTCONFIG_FILE`: the main fontconfig file instead of `/etc/fonts/fonts.conf`. + pub fontconfig_file: Option, + /// Inside a Flatpak sandbox (`/.flatpak-info` exists): the host's fontconfig folders are read + /// too, under `/run/host`. + pub flatpak: bool, + /// `WINDIR`. + pub windir: Option, + /// `LOCALAPPDATA`. + pub local_app_data: Option, + /// `CRAFT_FONT_STORE`: the font store folder instead of the platform's ([`font_store_dir`]). + pub font_store: Option, +} + +impl FontEnv { + /// The environment of this process. Empty variables count as unset. + pub fn from_process() -> Self { + let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()); + let path = |k: &str| var(k).map(PathBuf::from); + let os = Os::current(); + let home = if os == Os::Windows { path("USERPROFILE").or_else(|| path("HOME")) } else { path("HOME") }; + Self { + os, + root: PathBuf::from("/"), + home, + xdg_data_home: path("XDG_DATA_HOME"), + xdg_data_dirs: var("XDG_DATA_DIRS"), + xdg_config_home: path("XDG_CONFIG_HOME"), + fontconfig_file: path("FONTCONFIG_FILE"), + flatpak: os == Os::Unix && Path::new("/.flatpak-info").exists(), + windir: path("WINDIR"), + local_app_data: path("LOCALAPPDATA"), + font_store: path("CRAFT_FONT_STORE"), + } + } + + /// `path` (absolute, as on the real system) under [`FontEnv::root`]. + pub(crate) fn rooted(&self, path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + let relative: PathBuf = path.components().filter(|c| matches!(c, std::path::Component::Normal(_))).collect(); + if self.root.as_os_str().is_empty() { Path::new("/").join(relative) } else { self.root.join(relative) } + } + + /// `$XDG_DATA_HOME`, else `~/.local/share` (a relative `XDG_DATA_HOME` is ignored, as the XDG + /// specification says). + pub(crate) fn data_home(&self) -> Option { + self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share"))) + } + + /// `$XDG_CONFIG_HOME`, else `~/.config`. + pub(crate) fn config_home(&self) -> Option { + self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config"))) + } + + fn windows_fonts(&self) -> PathBuf { + self.windir.clone().unwrap_or_else(|| PathBuf::from("C:\\Windows")).join("Fonts") + } +} + +/// The folder name of the font store under the platform's per-user data folder, spelled as the +/// suite's other per-user folders are (`ArtCraft Launcher`, `artcraft-launcher`). +fn store_name(os: Os) -> &'static str { + if matches!(os, Os::MacOs | Os::Windows) { "Craft Fonts" } else { "craft-fonts" } +} + +/// The suite's font store: one per-user folder of fonts installed by Craft Font Manager or an +/// app's "install missing font", read by every app's catalog and by no other program. +/// +/// - macOS: `~/Library/Application Support/Craft Fonts` +/// - Windows: `%LOCALAPPDATA%\Craft Fonts` +/// - Linux, the BSDs and Haiku: `$XDG_DATA_HOME/craft-fonts` (`~/.local/share/craft-fonts`) +/// +/// `CRAFT_FONT_STORE` ([`FontEnv::font_store`]) replaces it. `None` where there is no per-user +/// folder (the web), or when the variables it is built from are unset. +pub fn font_store_dir(env: &FontEnv) -> Option { + if let Some(dir) = &env.font_store { + return Some(dir.clone()); + } + let base = match env.os { + Os::MacOs => env.home.as_ref()?.join("Library/Application Support"), + Os::Windows => env.local_app_data.clone()?, + Os::Unix | Os::Haiku => env.data_home()?, + Os::None => return None, + }; + Some(base.join(store_name(env.os))) +} + +/// The platform's font folders, the system's and the user's, without duplicates. Folders that +/// don't exist are kept: a scan skips them, and a change check notices when one appears. +/// +/// - macOS: `/System/Library/Fonts`, `/Library/Fonts`, `/Network/Library/Fonts`, +/// `~/Library/Fonts`, `/System/Library/Assets`, and the font assets macOS downloads on demand +/// (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*`, also under +/// `PreinstalledAssetsV2/InstallWithOs`). +/// - Windows: `%WINDIR%\Fonts` and the current user's `%LOCALAPPDATA%\Microsoft\Windows\Fonts`. +/// The registry's font list is read by the catalog itself. +/// - Linux and the BSDs: `/usr/share/fonts`, `/usr/local/share/fonts`, the Flatpak host folders +/// (`/run/host/fonts`, `/run/host/local-fonts`, `/run/host/user-fonts`), `~/.local/share/fonts`, +/// `~/.fonts`, `$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`, and the `` and +/// `` entries of the fontconfig configuration ([`crate::fontconfig_dirs`]). +/// - Haiku: the system's and the user's `data/fonts` folders, packaged and non-packaged. +pub fn font_dirs(env: &FontEnv) -> Vec { + let mut dirs: Vec = Vec::new(); + match env.os { + Os::MacOs => { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts", "/Network/Library/Fonts"].map(|d| env.rooted(d))); + dirs.extend(env.home.as_ref().map(|h| h.join("Library/Fonts"))); + dirs.push(env.rooted("/System/Library/Assets")); + for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { + let mut assets: Vec = std::fs::read_dir(env.rooted(parent)) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font")) + .map(|e| e.path()) + .collect(); + assets.sort(); + dirs.extend(assets); + } + } + Os::Windows => { + dirs.push(env.windows_fonts()); + dirs.extend(env.local_app_data.as_ref().map(|l| l.join("Microsoft\\Windows\\Fonts"))); + } + Os::Unix => { + dirs.extend( + ["/usr/share/fonts", "/usr/local/share/fonts", "/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"] + .map(|d| env.rooted(d)), + ); + if let Some(h) = &env.home { + dirs.push(h.join(".local/share/fonts")); + dirs.push(h.join(".fonts")); + } + dirs.extend(env.data_home().map(|d| d.join("fonts"))); + let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| env.rooted(p).join("fonts"))); + dirs.extend(crate::fontconfig::fontconfig_dirs(env)); + } + Os::Haiku => { + dirs.extend(["/boot/system/data/fonts", "/boot/system/non-packaged/data/fonts"].map(|d| env.rooted(d))); + if let Some(h) = &env.home { + dirs.push(h.join("config/data/fonts")); + dirs.push(h.join("config/non-packaged/data/fonts")); + } + } + Os::None => {} + } + dedup_dirs(dirs, env.os == Os::Windows) +} + +/// The folders Microsoft Office keeps its own fonts in (Calibri, Cambria, Aptos, and the cloud +/// fonts it downloads), which other apps don't see. Apps that open Office documents add them to +/// their catalog ([`crate::Catalog::set_app_dirs`]). +pub fn office_font_dirs(env: &FontEnv) -> Vec { + match env.os { + Os::MacOs => { + let mut dirs: Vec = + env.home.iter().map(|h| h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts")).collect(); + dirs.extend( + ["Microsoft Word", "Microsoft Excel", "Microsoft PowerPoint", "Microsoft Outlook"] + .map(|app| env.rooted(format!("/Applications/{app}.app/Contents/Resources/DFonts"))), + ); + dirs + } + Os::Windows => env.local_app_data.iter().map(|l| l.join("Microsoft\\FontCache\\4\\CloudFonts")).collect(), + _ => Vec::new(), + } +} + +/// `dirs` without repeats (also one folder reached through a link), keeping the first. Windows +/// paths compare ignoring case. +pub(crate) fn dedup_dirs(dirs: Vec, ignore_case: bool) -> Vec { + let mut seen = std::collections::HashSet::new(); + dirs.into_iter() + .filter(|d| { + let key = std::fs::canonicalize(d).unwrap_or_else(|_| d.clone()); + seen.insert(if ignore_case { PathBuf::from(key.to_string_lossy().to_lowercase()) } else { key }) + }) + .collect() +} diff --git a/crates/font-catalog/src/fontconfig.rs b/crates/font-catalog/src/fontconfig.rs new file mode 100644 index 00000000..401e5f31 --- /dev/null +++ b/crates/font-catalog/src/fontconfig.rs @@ -0,0 +1,193 @@ +//! The font folders named by the fontconfig configuration, read with a small tag scanner (no +//! fontconfig library, no XML crate): only ``, `` and `` matter here. +//! The work is bounded: include depth, number and size of the files read, and every file is read +//! at most once. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::FontEnv; + +const MAX_INCLUDE_DEPTH: u32 = 8; +const MAX_FILES: usize = 512; +const MAX_FILE_BYTES: u64 = 1 << 20; + +/// The folders the fontconfig configuration for `env` names, in the order it names them. +/// +/// The main file is `FONTCONFIG_FILE`, else the first of `/etc/fonts/fonts.conf` and +/// `/usr/local/etc/fonts/fonts.conf` (FreeBSD) that exists. The user's own configuration +/// (`$XDG_CONFIG_HOME/fontconfig/fonts.conf` and `conf.d`) is read too, in case the main file +/// doesn't include it. In a Flatpak sandbox ([`FontEnv::flatpak`]) the host's folders are added: +/// those `/run/host/font-dirs.xml` maps, and those of the host's own configuration when the +/// sandbox can see it (`/run/host/etc/fonts/fonts.conf`), under `/run/host`. +pub fn fontconfig_dirs(env: &FontEnv) -> Vec { + let mut reader = Reader { env, out: Vec::new(), seen: HashSet::new(), host: None }; + let root = match &env.fontconfig_file { + Some(f) => Some(f.clone()), + None => ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(|p| env.rooted(p)).into_iter().find(|p| p.is_file()), + }; + if let Some(root) = root { + reader.file(&root, 0); + } + if let Some(c) = env.config_home() { + reader.include(&c.join("fontconfig/fonts.conf"), 0); + reader.include(&c.join("fontconfig/conf.d"), 0); + } + if env.flatpak { + reader.file(&env.rooted("/run/host/font-dirs.xml"), 0); + reader.host = Some(env.rooted("/run/host")); + reader.file(&env.rooted("/run/host/etc/fonts/fonts.conf"), 0); + } + reader.out +} + +struct Reader<'a> { + env: &'a FontEnv, + out: Vec, + seen: HashSet, + /// While reading the host's configuration from a sandbox: where the host's root is. + host: Option, +} + +impl Reader<'_> { + /// Read a configuration file or, for a folder, its `NN-name.conf` files in name order. + fn include(&mut self, path: &Path, depth: u32) { + if path.is_dir() { + let Ok(rd) = std::fs::read_dir(path) else { return }; + let mut files: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit())) + }) + .collect(); + files.sort(); + for f in files { + self.file(&f, depth); + } + } else { + self.file(path, depth); + } + } + + fn file(&mut self, path: &Path, depth: u32) { + if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() { + return; + } + let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if !self.seen.insert(key) { + return; + } + if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + let base = path.parent().map(Path::to_path_buf).unwrap_or_default(); + for el in elements(&text) { + match el.name { + "dir" | "remap-dir" => { + if let Some(p) = self.resolve(&el, &base, false) { + self.out.push(p); + } + } + "include" => { + if let Some(p) = self.resolve(&el, &base, true) { + self.include(&p, depth.saturating_add(1)); + } + } + _ => {} + } + } + } + + /// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders) + /// or config (includes) home, `prefix="relative"` and plain relative paths the including + /// file's folder, `prefix="default"`/`"cwd"` the working folder. Absolute paths are under the + /// root, or under the host's root while reading the host's configuration. + fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option { + let v = el.text.trim(); + if v.is_empty() { + return None; + } + if let Some(rest) = v.strip_prefix('~') { + return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/'))); + } + let p = Path::new(v); + if p.is_absolute() { + return Some(match &self.host { + Some(host) => host.join(p.strip_prefix("/").unwrap_or(p)), + None => self.env.rooted(p), + }); + } + match el.attr("prefix") { + Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)), + Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)), + _ => Some(base.join(p)), + } + } +} + +struct Element<'a> { + name: &'a str, + attrs: &'a str, + text: String, +} + +impl Element<'_> { + fn attr(&self, key: &str) -> Option<&str> { + let mut rest = self.attrs; + while let Some(eq) = rest.find('=') { + let k = rest.get(..eq)?.trim(); + let after = rest.get(eq + 1..)?.trim_start(); + let q = after.chars().next()?; + if q != '"' && q != '\'' { + return None; + } + let body = after.get(1..)?; + let end = body.find(q)?; + if k == key { + return body.get(..end); + } + rest = body.get(end + 1..)?; + } + None + } +} + +/// The ``, `` and `` elements of a fontconfig file, comments skipped. +fn elements(src: &str) -> Vec> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(off) = src.get(i..).and_then(|s| s.find('<')) { + let start = i + off; + let Some(tail) = src.get(start..) else { break }; + if tail.starts_with("") { + Some(e) => { + i = start + e + 3; + continue; + } + None => break, + } + } + let Some(close) = tail.find('>') else { break }; + let inner = tail.get(1..close).unwrap_or_default(); + i = start + close + 1; + let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len()); + let name = inner.get(..name_len).unwrap_or_default(); + if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') { + continue; + } + let attrs = inner.get(name_len..).unwrap_or_default(); + let end_tag = format!(""); + let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break }; + let body = src.get(i..i + body_len).unwrap_or_default(); + i += body_len + end_tag.len(); + out.push(Element { name, attrs, text: decode_entities(body) }); + } + out +} + +fn decode_entities(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} diff --git a/crates/font-catalog/src/lib.rs b/crates/font-catalog/src/lib.rs new file mode 100644 index 00000000..c787d3ec --- /dev/null +++ b/crates/font-catalog/src/lib.rs @@ -0,0 +1,37 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! The installed fonts, for every Craft app: discovery on every platform, lookup by family and +//! style or by any other name, and loading shared between threads. +//! +//! A [`Catalog`] scans the platform's font folders ([`font_dirs`]), the suite's font store +//! ([`font_store_dir`]), the app's own folders ([`Catalog::set_app_dirs`]) and, with the `system` +//! feature, the files the platform's font service knows ([`system`]): CoreText on macOS, +//! DirectWrite on Windows, fontconfig on Linux and FreeBSD. On Windows it also reads the +//! registry's font list. The scan reads each file's table directory and its `name`, `OS/2` and +//! `fvar` tables, never whole files, and a rescan reads only files that changed. +//! +//! ```no_run +//! use craft_font_catalog::{Catalog, Match}; +//! +//! let catalog = Catalog::global(); +//! catalog.scan_in_background(); +//! if let Some((face, Match::Exact)) = catalog.resolve("Source Serif 4", "Bold Italic") { +//! let data = catalog.load(&face); +//! } +//! ``` + +mod catalog; +mod env; +mod fontconfig; +mod registry; +mod scan; +mod suitcase; +pub mod system; + +pub use catalog::{Catalog, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Watch}; +pub use env::{FontEnv, Os, font_dirs, font_store_dir, office_font_dirs}; +pub use fontconfig::fontconfig_dirs; +pub use scan::{FaceInfo, LocalizedName, Source, is_font_file, style_italic, style_weight, style_width}; + +#[cfg(test)] +mod tests; diff --git a/crates/font-catalog/src/registry.rs b/crates/font-catalog/src/registry.rs new file mode 100644 index 00000000..11154234 --- /dev/null +++ b/crates/font-catalog/src/registry.rs @@ -0,0 +1,30 @@ +//! The fonts registered with Windows in the registry: one value per face, holding a file name in +//! the Windows font folder or a full path. Programs that install fonts into their own folders +//! register them with full paths, which only the registry lists. + +use std::path::PathBuf; + +/// The full paths the registry lists for the machine's and the current user's fonts, sorted and +/// without repeats. Bare file names (files in the Windows font folder) are left out: the scan +/// reads that folder. Empty elsewhere than on Windows. +pub(crate) fn registered_font_files() -> Vec { + #[cfg(windows)] + { + const MAX_REGISTERED: usize = 1 << 16; + const KEY: &str = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts"; + use windows_registry::{CURRENT_USER, LOCAL_MACHINE}; + let mut files: Vec = [LOCAL_MACHINE, CURRENT_USER] + .into_iter() + .filter_map(|root| root.open(KEY).ok()) + .flat_map(|key| key.values().map(|vs| vs.filter_map(|(_, v)| String::try_from(v).ok()).collect::>()).unwrap_or_default()) + .take(MAX_REGISTERED) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .collect(); + files.sort(); + files.dedup(); + files + } + #[cfg(not(windows))] + Vec::new() +} diff --git a/crates/font-catalog/src/scan.rs b/crates/font-catalog/src/scan.rs new file mode 100644 index 00000000..ae321dac --- /dev/null +++ b/crates/font-catalog/src/scan.rs @@ -0,0 +1,461 @@ +//! Reading face records from font files: the table directory and the `name`, `OS/2` and `fvar` +//! tables, never the whole file. + +use std::path::{Path, PathBuf}; + +use skrifa::raw::{FileRef, TableProvider}; +use skrifa::{MetadataProvider, string::StringId}; + +/// Where a face comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Source { + /// Installed on the system: a platform font folder or the platform's font service. + System, + /// The suite's font store ([`crate::font_store_dir`]). + Store, + /// A folder the app added ([`crate::Catalog::set_app_dirs`]). + App, + /// Bundled with the app and added as bytes. + Bundled, + /// Added by the user to this app. + User, + /// Embedded in, or shipped beside, an open document. + Document, +} + +/// A name in one language, from the font's `name` table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalizedName { + /// The record's BCP 47 language tag (`ja`, `zh-Hant`), when the font says. + pub language: Option, + /// The name. + pub name: String, +} + +/// One face of an installed or added font: one face of a file, or one named instance of a +/// variable font. +#[derive(Clone, Debug, PartialEq)] +pub struct FaceInfo { + /// The family: the typographic family name (name ID 16), else the family name (ID 1), in + /// English when the font has an English record. + pub family: String, + /// The style: the typographic subfamily (ID 17), else the subfamily (ID 2), else `Regular`. + /// A named instance's subfamily name. + pub style: String, + /// The legacy family name (ID 1) when it differs from [`FaceInfo::family`] (`Roboto Light` + /// in the family `Roboto`). + pub legacy_family: Option, + /// The legacy style (ID 2) that goes with [`FaceInfo::legacy_family`]. + pub legacy_style: Option, + /// The family's names in other languages (`ヒラギノ角ゴシック`). + pub localized_families: Vec, + /// The full name (ID 4) of the face itself; `None` for a named instance. + pub full_name: Option, + /// The PostScript name (ID 6, or a named instance's own), as PDF and other documents name fonts. + pub postscript_name: Option, + /// The weight, 1 to 1000 (400 regular, 700 bold): `usWeightClass`, a named instance's `wght`, + /// else the weight the style name implies. + pub weight: f32, + /// The width as a percentage of normal (100; 75 condensed, 125 expanded): `usWidthClass`, a + /// named instance's `wdth`, else the width the style name implies. + pub width: f32, + /// Italic or oblique. + pub italic: bool, + /// A named instance's axis settings (tag, user units); empty for the face itself. + pub variations: Vec<([u8; 4], f32)>, + /// The face has variation axes. + pub variable: bool, + /// The OS/2 `fsType` embedding permissions (0 without an OS/2 table). + pub fs_type: u16, + /// The OS/2 code page bits (`ulCodePageRange1`, `ulCodePageRange2`). + pub code_pages: [u32; 2], + /// The file the face is in; `None` for a face added as bytes. + pub path: Option, + /// The face's number in its file: its index in a collection (0 in a single font), or for a + /// Mac suitcase font the number of its `sfnt` resource. + pub index: u32, + /// Where it comes from. + pub source: Source, + /// For a face added as bytes: which of the catalog's added blobs holds it. + pub(crate) blob: Option, +} + +/// A face record with the normalized names it answers to. +#[derive(Clone, Debug)] +pub(crate) struct Face { + pub info: FaceInfo, + pub keys: Keys, +} + +/// Every name a face answers to besides its family and style, normalized ([`norm`]). +#[derive(Clone, Debug, Default)] +pub(crate) struct Keys { + /// Its family in every language, typographic else legacy. + pub families: Vec, + /// Its style in every language, and a variable font's default instance's name. + pub styles: Vec, + /// Legacy family and style pairs (`Hiragino Sans W3` / `Regular`), per language record. + pub legacy: Vec<(String, String)>, + pub full_names: Vec, + pub postscript: Vec, +} + +const MAX_AXES: usize = 64; +const MAX_INSTANCES: usize = 512; +/// The most faces read from a (possibly damaged) collection. +pub(crate) const MAX_FACES: u32 = 256; +/// The largest table the scan reads. +const MAX_TABLE: u32 = 1 << 20; +/// Tables holding outlines a renderer draws. A face with none of them (outlines in Apple's `hvgl` +/// table, bitmaps only) draws nothing and is left out. +const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; + +pub(crate) fn norm(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() +} + +/// The weight a style name implies (400 when it names none). +pub fn style_weight(style: &str) -> f32 { + const TABLE: &[(&str, f32)] = &[ + ("extralight", 200.0), + ("ultralight", 200.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("hairline", 100.0), + ("thin", 100.0), + ("light", 300.0), + ("medium", 500.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + let s = norm(style); + TABLE.iter().find(|(k, _)| s.contains(k)).map_or(400.0, |(_, w)| *w) +} + +/// Whether a style name says italic or oblique. +pub fn style_italic(style: &str) -> bool { + let s = norm(style); + s.contains("italic") || s.contains("oblique") || s == "it" +} + +/// The width a style name implies, in percent of normal (100 when it names none). +pub fn style_width(style: &str) -> f32 { + const TABLE: &[(&str, f32)] = &[ + ("ultracondensed", 50.0), + ("extracondensed", 62.5), + ("semicondensed", 87.5), + ("condensed", 75.0), + ("narrow", 75.0), + ("ultraexpanded", 200.0), + ("extraexpanded", 150.0), + ("semiexpanded", 112.5), + ("expanded", 125.0), + ("extended", 125.0), + ("wide", 125.0), + ]; + let s = norm(style); + TABLE.iter().find(|(k, _)| s.contains(k)).map_or(100.0, |(_, w)| *w) +} + +/// `usWidthClass` (1–9) as a percentage of normal. +fn width_class_percent(class: u16) -> Option { + const PERCENT: [f32; 9] = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0]; + PERCENT.get(usize::from(class).checked_sub(1)?).copied() +} + +fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { + ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} + +/// Every string of `id`, with its language. +fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option, String)> { + font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect() +} + +fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec { + localized(font, id).into_iter().map(|(_, s)| s).collect() +} + +fn sort_dedup(v: &mut Vec) { + v.retain(|k| !k.is_empty()); + v.sort(); + v.dedup(); +} + +/// The faces `font` (face `index` of its file) offers: the face itself, then the named instances +/// of a variable font. Empty when it has no family name. +pub(crate) fn font_faces(font: &skrifa::FontRef<'_>, index: u32, path: Option<&Path>, source: Source) -> Vec { + let Some(family) = name(font, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]) else { return vec![] }; + let style = name(font, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + let typo_family = localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME); + let typo_style = all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME); + let legacy_family = localized(font, StringId::FAMILY_NAME); + let legacy_style = localized(font, StringId::SUBFAMILY_NAME); + + let family_records = if typo_family.is_empty() { &legacy_family } else { &typo_family }; + let mut localized_families: Vec = Vec::new(); + for (language, n) in family_records { + if *n != family && !localized_families.iter().any(|l| l.name == *n) { + localized_families.push(LocalizedName { language: language.clone(), name: n.clone() }); + } + } + let mut families: Vec = std::iter::once(family.as_str()).chain(family_records.iter().map(|(_, f)| f.as_str())).map(norm).collect(); + // A legacy style names the face beside its legacy family only when the font has a + // typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family. + let style_names: Vec = if typo_style.is_empty() { legacy_style.iter().map(|(_, s)| s.clone()).collect() } else { typo_style }; + let mut styles: Vec = std::iter::once(style.as_str()).chain(style_names.iter().map(String::as_str)).map(norm).collect(); + let mut legacy: Vec<(String, String)> = if typo_family.is_empty() { + vec![] + } else { + legacy_family + .iter() + .flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s)))) + .filter(|(f, s)| !f.is_empty() && !s.is_empty()) + .collect() + }; + legacy.sort(); + legacy.dedup(); + let mut postscript: Vec = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect(); + let mut full_names: Vec = all_names(font, StringId::FULL_NAME).iter().map(|n| norm(n)).collect(); + for v in [&mut families, &mut styles, &mut full_names, &mut postscript] { + sort_dedup(v); + } + + let os2 = font.os2().ok(); + let weight = os2 + .as_ref() + .map(|t| t.us_weight_class()) + .map(|w| if (1..10).contains(&w) { w * 100 } else { w }) + .filter(|w| (1..=1000).contains(w)) + .map_or_else(|| style_weight(&style), f32::from); + let width = os2.as_ref().and_then(|t| width_class_percent(t.us_width_class())).unwrap_or_else(|| style_width(&style)); + let selection_italic = os2.as_ref().is_some_and(|t| { + let f = t.fs_selection(); + f.contains(skrifa::raw::tables::os2::SelectionFlags::ITALIC) || f.contains(skrifa::raw::tables::os2::SelectionFlags::OBLIQUE) + }); + let fs_type = os2.as_ref().map_or(0, |t| t.fs_type()); + let code_pages = os2.as_ref().map_or([0, 0], |t| [t.ul_code_page_range_1().unwrap_or(0), t.ul_code_page_range_2().unwrap_or(0)]); + let axes = font.axes(); + let legacy_pair = (!typo_family.is_empty()) + .then(|| name(font, &[StringId::FAMILY_NAME])) + .flatten() + .filter(|f| *f != family) + .map(|f| (f, name(font, &[StringId::SUBFAMILY_NAME]))); + + let info = FaceInfo { + family: family.clone(), + style: style.clone(), + legacy_family: legacy_pair.as_ref().map(|(f, _)| f.clone()), + legacy_style: legacy_pair.and_then(|(_, s)| s), + localized_families, + full_name: name(font, &[StringId::FULL_NAME]), + postscript_name: name(font, &[StringId::POSTSCRIPT_NAME]), + weight, + width, + italic: selection_italic || style_italic(&style), + variations: vec![], + variable: !axes.is_empty(), + fs_type, + code_pages, + path: path.map(Path::to_path_buf), + index, + source, + blob: None, + }; + let mut keys = Keys { families, styles, legacy, full_names, postscript }; + let instances = named_instances(font, &info, &mut keys); + std::iter::once(Face { info, keys }).chain(instances).collect() +} + +/// The named instances of a variable face (its `fvar` table) as faces of its family, without the +/// default instance (the face itself) and without names already taken. The default instance's +/// name, when it isn't the face's style (a face `Regular` whose default instance is `Book`), names +/// the face too. Damaged tables give fewer instances or none. +fn named_instances(font: &skrifa::FontRef<'_>, face: &FaceInfo, keys: &mut Keys) -> Vec { + let axes = font.axes(); + if axes.is_empty() || axes.len() > MAX_AXES { + return vec![]; + } + let tags: Vec<[u8; 4]> = axes.iter().map(|a| a.tag().to_be_bytes()).collect(); + let defaults: Vec = axes.iter().map(|a| a.default_value()).collect(); + // Instances without a PostScript name of their own are named `-\n\n\n

{title}

\n" + ); + for g in groups(km, text) { + let _ = writeln!(s, "

{}

\n", escape_html(&g.name)); + for (label, keys) in &g.rows { + let keys: String = keys.iter().map(|k| format!("{}", escape_html(k))).collect(); + let _ = writeln!(s, "", escape_html(label)); + } + s.push_str("
{}{keys}
\n"); + } + s.push_str("\n\n"); + s +} + +/// Plain text titled `title`: a line per group, then ` labelshortcuts` per command. +pub fn text(km: &Keymap, text: &dyn Text, title: &str) -> String { + let mut s = one_line(title); + s.push('\n'); + for g in groups(km, text) { + let _ = write!(s, "\n{}\n", one_line(&g.name)); + for (label, keys) in &g.rows { + let _ = writeln!(s, " {}\t{}", one_line(label), one_line(&keys.join(", "))); + } + } + s +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs new file mode 100644 index 00000000..12dd0941 --- /dev/null +++ b/crates/keymap/src/ui/text.rs @@ -0,0 +1,388 @@ +//! The dialog's words: its own strings, command labels, menu path parts, key names and text +//! direction, all supplied by the app through [`Text`]. + +use std::borrow::Cow; + +use crate::chord::{Key, Platform, Trigger}; +use crate::keymap::Bindable; + +/// Where the dialog gets its words. Apps on craft-i18n pass [`I18nText`]; others implement the two +/// required methods with their own lookup. +pub trait Text { + /// The dialog's own strings, by English source text (listed in [`STRINGS`]). Placeholders + /// (`{chord}`, `{label}`, `{set}`, `{n}`) are filled after the lookup. + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str>; + /// A count's wording with `{n}` filled in. `one` and `other` are the English forms. + fn plural(&self, n: u64, one: &str, other: &str) -> String; + /// A command's label. + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + b.label.as_str().into() + } + /// A menu path part or a scope name. + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + s.into() + } + /// Extra lines for a command's tooltip. + fn hint(&self, _b: &Bindable) -> Option { + None + } + /// A key cap's name on `platform` (`Strg`, `Umschalt`); `None` keeps the crate's name. + fn key(&self, _k: Key, _p: Platform) -> Option { + None + } + /// Whether the interface language is written right to left. + fn rtl(&self) -> bool { + false + } + /// A label or message put into display order (the app's bidi reordering). Chord text is never + /// passed here. + fn visual(&self, s: &str) -> String { + s.to_string() + } +} + +/// [`Text`] over a craft-i18n catalog. +pub struct I18nText<'a> { + pub i18n: &'a craft_i18n::I18n, + pub lang: craft_i18n::Lang, +} + +impl Text for I18nText<'_> { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + self.i18n.t(self.lang, source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + self.i18n.trn(self.lang, n, one, other) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + self.i18n.tid(self.lang, &b.command.id, &b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + self.i18n.t(self.lang, s).into() + } + + fn key(&self, k: Key, p: Platform) -> Option { + // Glyphs (⌫, ↑) need no translation. + if p.is_mac() && k.mac_symbol() != k.text_name() { + return None; + } + self.i18n.get(self.lang, "key", k.text_name()).map(str::to_string) + } + + fn rtl(&self) -> bool { + self.i18n.rtl(self.lang) + } +} + +/// English [`Text`]: every string as written. +pub struct English; + +impl Text for English { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + craft_i18n::fmt(if n == 1 { one } else { other }, &[("n", &n.to_string())]) + } +} + +/// A trigger as `platform` shows it, with key names from [`Text::key`]. Modifiers come in the +/// order of [`Chord::display`](crate::Chord::display). +pub fn trigger_text(text: &dyn Text, trigger: &Trigger, platform: Platform) -> String { + let key = trigger.chord.key; + let Some(name) = text.key(key, platform) else { return trigger.display(platform) }; + let base = if platform.is_mac() { key.mac_symbol() } else { key.text_name() }; + let one = trigger.chord.display(platform); + let one = match one.strip_suffix(base) { + Some(mods) => format!("{mods}{name}"), + None => one, + }; + if trigger.double { format!("{one} {one}") } else { one } +} + +/// `s` in display order with `chord`, text inside it, left as it is: chord text never goes +/// through [`Text::visual`]. Right to left, the parts before and after the chord change sides. +pub(crate) fn visual_around(text: &dyn Text, s: &str, chord: &str) -> String { + let Some(i) = s.find(chord).filter(|_| !chord.is_empty()) else { return text.visual(s) }; + let (before, rest) = s.split_at(i); + let (before, after) = (text.visual(before), text.visual(&rest[chord.len()..])); + if text.rtl() { format!("{after}{chord}{before}") } else { format!("{before}{chord}{after}") } +} + +/// Plural forms the dialog looks up, as `one|other`, the key of craft-i18n's `@plural` rows. +pub const PLURALS: &[(&str, &str)] = &[ + ("{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"), + ("{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"), + ("{n} command will change", "{n} commands will change"), + ("{n} item without a shortcut", "{n} items without a shortcut"), + ("{n} item not matched", "{n} items not matched"), + ("{n} item could not be read", "{n} items could not be read"), + ("{n} key read approximately", "{n} keys read approximately"), + ("{n} entry cut at a limit", "{n} entries cut at a limit"), + ("{n} command changed", "{n} commands changed"), + ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), + ("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"), +]; + +/// Every source string the dialog looks up with [`Text::ui`], for the shared catalog. +pub const STRINGS: &[&str] = &[ + // Window and footer. + "Keyboard Shortcuts", + "Custom", + "OK", + "Cancel", + "Close", + "Undo", + "Redo", + "Reset All…", + "Restore every default shortcut in this set?", + "Reset All", + // Header. + "Show:", + "Filter:", + "Search:", + "Search by key", + "All Commands", + "{menu} Menu", + "Tools", + "{panel} Panel", + "Other", + "Fixed Keys", + "All", + "With Shortcut", + "Changed", + "Conflicts", + "No matching commands", + "(cycles)", + "{label} (cycles)", + "{label} (Changed)", + // Table and detail pane. + "Command", + "Commands", + "Detail pane size", + "Select a command to see and change its shortcuts", + "Shortcut", + "Default", + "{chord} (Default)", + "{chord} ({scope})", + "{label} ({scope})", + "Application", + "Current shortcuts:", + "New shortcut:", + "New shortcut: {chord}", + "press keys", + "{chord} (press again for a double press)", + "Type…", + "Context:", + "Assign", + "Use Default", + "Clear", + "Default: {chord}", + "None", + "Currently assigned to:", + "Not assigned", + "{label} ({scope}, both work)", + "Add a shortcut to {label}", + "Remove {chord} from {label}", + "Restore the default for {label}", + "{label}: {chord}", + "{label}: press keys", + "Now used by {label}", + "{label}: {chord} (Now used by {holder})", + // Typed shortcuts and saved texts that do not parse. + "the shortcut has no key", + "unknown modifier {key}", + "modifier {key} given twice", + "key sequences are not supported", + "the scope name is not valid", + // On-screen keyboard. + "Keyboard", + "Keyboard: US", + "Keyboard: US (macOS)", + "Control", + "Option", + "Shift", + "Ctrl", + "Alt", + "Caps Lock", + "Win", + "Super", + "Panel or context", + "Both", + "Modifier on", + "{chord}: {label}", + "Click a key to see its shortcuts", + "Key: {key}", + "Assign to Selected", + "Assign {chord} to {label}", + "Select a command first", + "Nothing uses this key", + // Messages and conflicts. + "⚠ {chord} is used by {label}", + "{chord} was removed from {label}", + "Also used by {label}", + "{chord} is reserved by the system", + "A command can have at most {n} shortcuts", + "The scope {scope} is unknown", + "Too many shortcuts are assigned", + "This command is unknown", + "Take It", + "Take It and Go to Conflict", + "Keep Both", + "Go to Conflict", + "Cancel the new shortcut", + "Dismiss", + // Saved shortcuts that could not be used. + "{key}: no command has this name", + "{key}: “{text}” is not a shortcut ({error})", + "{key}: {text} is reserved by the system", + "{key}: the scope in “{text}” is unknown", + "{key}: listed more than once", + "{key}: part of the entry was dropped", + // Sets. + "Set:", + "(modified)", + "New Set…", + "Rename…", + "Delete", + "Save", + "Revert Set", + "Import…", + "Export", + "Export…", + "Summarize…", + "{title}: {set}", + "Create", + "Rename", + "More", + "Name:", + "Based on:", + "Current shortcuts", + "Save changes to “{set}”?", + "Don't Save", + "Delete the set “{set}”?", + "Keyboard Shortcuts (JSON)", + "The name is empty, too long or has control characters", + "A set with this name exists", + "The set was not found", + "Built-in sets cannot be changed", + "There are too many sets", + "This is not a shortcut set file", + "This is not a craft-keymap file", + "This file is from a newer version (format {n})", + "The file is too large", + // Import review. + "Import “{set}”", + "Set name:", + "Import", + "Cancel Import", + "line {n}", + "Will change", + "Not matched", + "Unreadable", + "Approximated", + "{title} ({n})", + "Suggestions ({n})", + "numeric keypad key read as main key", + "Matched without change", + "Command for {item}", + "Choose a command…", + "no command has this name", + "more than one command fits", + "in a context that is not imported", + "only a command of the other kind fits (tool or menu command)", + "empty shortcut", + "the shortcut is too long", + "unknown key {key}", + "unsupported modifier {key}", + "{label} keeps its shortcuts (matched by a panel menu name)", + "Import as New Set", + "Imported", + "Imported as “{set}”", + "Skipped", + "Taken from other commands", + "Not changed", + "taken by {label}", + "taken by a later entry", + "reserved by the system", + "the scope is unknown", + "the set's shortcuts could not be added", + "the shortcuts could not be removed", + "Copy Report", + "Done", +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::chord::Chord; + + struct German; + impl Text for German { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, _one: &str, other: &str) -> String { + other.replace("{n}", &n.to_string()) + } + fn key(&self, k: Key, _p: Platform) -> Option { + (k == Key::Delete).then(|| "Entf".to_string()) + } + } + + #[test] + fn trigger_text_uses_localized_key_names() -> Result<(), crate::ParseError> { + let t = Trigger::parse("Cmd+Shift+Delete")?; + assert_eq!(trigger_text(&German, &t, Platform::Windows), "Ctrl+Shift+Entf"); + assert_eq!(trigger_text(&German, &Trigger::parse("F7 F7")?, Platform::Windows), "F7 F7"); + assert_eq!(trigger_text(&English, &Trigger::single(Chord::parse("⇧⌘Z")?), Platform::Mac), "⇧⌘Z"); + Ok(()) + } + + /// Every `ui("…")` and `plural(n, "…", "…")` literal in the dialog's sources is listed. + #[test] + fn every_looked_up_string_is_listed() { + let sources = [ + include_str!("mod.rs"), + include_str!("state.rs"), + include_str!("rows.rs"), + include_str!("capture.rs"), + include_str!("view.rs"), + include_str!("keyboard.rs"), + include_str!("style.rs"), + include_str!("sets.rs"), + include_str!("review.rs"), + include_str!("summary.rs"), + ]; + let mut missing = Vec::new(); + for src in sources { + let src = src.split("#[cfg(test)]").next().unwrap_or(src); + for (i, open) in src.match_indices(".ui(\"") { + let rest = &src[i + open.len()..]; + if let Some(end) = rest.find('"') { + let s = &rest[..end]; + if !STRINGS.contains(&s) { + missing.push(s.to_string()); + } + } + } + for (i, _) in src.match_indices(".plural(") { + let quoted: Vec<&str> = src[i..].split('"').skip(1).step_by(2).take(2).collect(); + if let [one, other] = quoted[..] + && !PLURALS.contains(&(one, other)) + { + missing.push(format!("{one}|{other}")); + } + } + } + assert!(missing.is_empty(), "not in STRINGS or PLURALS: {missing:?}"); + let mut sorted = STRINGS.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), STRINGS.len(), "STRINGS has duplicates"); + } +} diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs new file mode 100644 index 00000000..56b65c6d --- /dev/null +++ b/crates/keymap/src/ui/view.rs @@ -0,0 +1,1517 @@ +//! Drawing the dialog: header, table, detail pane, conflict bar, messages and footer. +//! +//! Widgets are created in focus order (§2.10 of the dialog plan): set row, Show, Filter, Search, +//! ⌨, table, detail pane, conflict bar, footer. egui's Tab order is creation order, so the bars +//! under the table are drawn last even though their height is needed first: the view uses last +//! frame's height and asks egui to discard a frame whose height changed. + +use std::ops::Range; +use std::sync::Arc; + +use craft_ui::buttons::{IconButton, IconButtonStyle}; +use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; +use craft_ui::tabs::{self, Tab}; +use egui::Galley; +use egui::accesskit::Live; +use egui::text::{LayoutJob, TextFormat, TextWrapping}; +use egui::{ + Align, Button, Color32, ComboBox, EventFilter, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, Role, ScrollArea, + Sense, Stroke, StrokeKind, TextEdit, TextStyle, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, pos2, vec2, +}; + +use crate::chord::Trigger; +use crate::command::CommandRef; +use crate::keymap::{Binding, MAX_BINDINGS, Problem}; +use crate::scope::Scope; +use crate::ui::capture; +use crate::ui::rows::{AreaId, Filter, FixedLine, Line, Row}; +use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target, binding_error}; +use crate::ui::style::{self, DialogStyle}; +use crate::ui::text::{trigger_text, visual_around}; + +#[path = "keyboard.rs"] +pub(crate) mod keyboard; +#[path = "review.rs"] +mod review; +#[path = "sets.rs"] +mod sets; + +/// Below this width the detail pane goes under the table. +const NARROW: f32 = 720.0; +/// Smallest table or detail pane when dragging the divider. +const SPLIT_MIN: f32 = 120.0; +/// Icon buttons in rows and bars. +const ICON: f32 = 20.0; +/// Padding inside a chip. +const CHIP_PAD: f32 = 5.0; +const CHIP_GAP: f32 = 4.0; +/// The detail pane's New Shortcut field. +const FIELD_W: f32 = 160.0; +/// The widest Show, Filter or Set combo box; longer names are cut with "…". +const COMBO_MAX: f32 = 200.0; +/// Search field width. +const SEARCH_MIN: f32 = 120.0; +const SEARCH_MAX: f32 = 240.0; + +/// One frame of the dialog body inside `ui`'s available rectangle. +pub(crate) fn body(ui: &mut Ui, env: &mut Env, state: &mut DialogState) { + let style = env.options.style.unwrap_or_else(|| DialogStyle::from_visuals(ui.visuals())); + let icons = style::icon_buttons(ui.visuals()); + let row_h = ui.text_style_height(&TextStyle::Body) + 6.0; + let base = ui.scope_id().with("craft_keymap_shortcuts"); + let mut d = Draw { env, state, style, icons, base, row_h }; + d.keys(ui); + d.layout(ui); +} + +struct Draw<'s, 'e> { + env: &'s mut Env<'e>, + state: &'s mut DialogState, + style: DialogStyle, + icons: IconButtonStyle, + base: Id, + row_h: f32, +} + +fn fill(source: String, args: &[(&str, &str)]) -> String { + craft_i18n::fmt(&source, args) +} + +/// What a click in the table asked for, applied after the rows are drawn. +enum RowAction { + Select(CommandRef), + Record(CommandRef, Option, usize), + Reset(CommandRef), + Toggle(String), +} + +impl Draw<'_, '_> { + // ---- Text ---- + + /// The dialog's string, translated (logical order: for accessibility labels and `fill`). + fn ui(&self, source: &str) -> String { + self.env.text.ui(source).into_owned() + } + + /// Display order for drawn text. + fn vis(&self, s: &str) -> String { + self.env.text.visual(s) + } + + fn rtl(&self) -> bool { + self.env.text.rtl() + } + + /// Parts in logical order, each already in display order, put into display order: reversed in + /// a right-to-left interface, where the path separator ` › ` becomes ` ‹ `. + fn ordered(&self, mut parts: Vec<(String, T)>) -> Vec<(String, T)> { + if self.rtl() { + parts.reverse(); + for (s, _) in &mut parts { + if s == PATH_SEP { + *s = " ‹ ".into(); + } + } + } + parts + } + + /// [`ordered`](Self::ordered) parts as one string. + fn joined(&self, parts: Vec) -> String { + self.ordered(parts.into_iter().map(|s| (s, ())).collect()).into_iter().map(|(s, ())| s).collect() + } + + /// Display-order `parts` on one line within `width`, cut with "…" when they do not fit: at + /// the end, or at the start in a right-to-left interface, where the logical end of the text is + /// on the left. Returns the galley and whether it was cut. + fn one_line(&self, painter: &Painter, parts: &[(String, TextFormat)], width: f32) -> (Arc, bool) { + let width = width.max(0.0); + let job = |skip: usize| { + let mut job = LayoutJob::default(); + if let (true, Some((_, f))) = (skip > 0, parts.first()) { + job.append("…", 0.0, f.clone()); + } + let mut left = skip; + for (s, f) in parts { + let n = s.chars().count(); + if left >= n { + left -= n; + continue; + } + let start = s.char_indices().nth(left).map_or(s.len(), |(i, _)| i); + left = 0; + job.append(&s[start..], 0.0, f.clone()); + } + job + }; + let full = painter.layout_job(job(0)); + if full.size().x <= width + 0.5 { + return (full, false); + } + if !self.rtl() { + let mut cut = job(0); + cut.wrap = TextWrapping::truncate_at_width(width); + return (painter.layout_job(cut), true); + } + // The fewest characters to drop from the start so that "…" and the rest fit. + let total: usize = parts.iter().map(|(s, _)| s.chars().count()).sum(); + let (mut lo, mut hi) = (1, total); + while lo < hi { + let mid = (lo + hi) / 2; + if painter.layout_job(job(mid)).size().x <= width + 0.5 { hi = mid } else { lo = mid + 1 } + } + (painter.layout_job(job(lo)), true) + } + + /// `s` in display order with `chord`, text inside it, left as it is: chord text never goes + /// through [`Text::visual`](crate::ui::Text::visual). + fn vis_chord(&self, s: &str, chord: &str) -> String { + visual_around(self.env.text, s, chord) + } + + /// `text` after a ⚠, in display order. + fn warned(&self, text: String) -> String { + self.joined(vec!["⚠".into(), " ".into(), text]) + } + + /// Horizontal placement inside `rect`, mirrored in a right-to-left interface. + fn mirror(&self, rect: Rect) -> Mirror { + Mirror { on: self.rtl(), sum: rect.min.x + rect.max.x } + } + + fn chord(&self, t: &Trigger) -> String { + trigger_text(self.env.text, t, self.env.keymap.platform()) + } + + fn scope_name(&self, s: &Scope) -> String { + if s.is_app() { self.ui("Application") } else { self.env.text.segment(s.as_str()).into_owned() } + } + + /// A binding as text, with its scope when that is not the command's home scope. + fn binding_text(&self, b: &Binding, home: Option<&Scope>) -> String { + let chord = self.chord(&b.trigger); + match &b.scope { + Some(s) if Some(s) != home => fill(self.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &self.scope_name(s))]), + _ => chord, + } + } + + /// A menu path in display order: "File › Export", "Export ‹ File" right to left. + fn path_text(&self, path: &[String]) -> String { + let mut parts = Vec::new(); + for (i, s) in path.iter().enumerate() { + if i > 0 { + parts.push(PATH_SEP.to_string()); + } + parts.push(self.vis(&self.env.text.segment(s))); + } + self.joined(parts) + } + + fn label_of(&self, command: &CommandRef) -> String { + self.env.keymap.bindable(command).map_or_else(|| command.id.clone(), |b| self.env.text.command(b).into_owned()) + } + + /// "press keys", or the chord waiting for a second press. + fn recording_text(&self) -> String { + match self.state.recording.as_ref().and_then(|r| r.waiting.as_ref()) { + Some((t, _)) => { + let chord = self.chord(t); + self.vis_chord(&fill(self.ui("{chord} (press again for a double press)"), &[("chord", &chord)]), &chord) + } + None => self.ui("press keys"), + } + } + + /// Reports a control to [`Options::on_widget`](crate::ui::Options::on_widget). + fn tag(&self, id: impl FnOnce() -> String, rect: Rect, label: &str) { + if let Some(f) = &self.env.options.on_widget { + f(&id(), rect, label); + } + } + + fn button(&self, ui: &mut Ui, enabled: bool, text: &str, id: &str) -> bool { + let r = ui.add_enabled(enabled, Button::new(self.vis(text))); + self.tag(|| id.to_string(), r.rect, text); + r.clicked() + } + + /// The width a [`Button`] with `text` takes. + fn button_width(&self, ui: &Ui, text: &str) -> f32 { + let pad = ui.spacing().button_padding.x * 2.0; + let g = WidgetText::from(self.vis(text)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); + (g.size().x + pad).max(ui.spacing().interact_size.x) + } + + fn icon_button(&self, ui: &mut Ui, label: &str, selected: bool, paint: fn(&Painter, Rect, Color32)) -> Response { + IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) + } + + /// A row control: clickable and labeled but not a Tab stop (the table is one). + fn row_button(&self, ui: &mut Ui, rect: Rect, id: Id, label: &str, enabled: bool, paint: fn(&Painter, Rect, Color32)) -> Response { + let resp = ui.interact(rect, id, if enabled { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, enabled, label)); + let v = if !enabled { + self.icons.normal + } else if resp.is_pointer_button_down_on() { + self.icons.pressed.unwrap_or(self.icons.hovered) + } else if resp.hovered() { + self.icons.hovered + } else { + self.icons.normal + }; + if v.fill != Color32::TRANSPARENT { + ui.painter().rect_filled(rect, self.icons.corner_radius, v.fill); + } + let color = if enabled { v.icon } else { self.style.dim.gamma_multiply(0.6) }; + paint(ui.painter(), rect, color); + resp + } + + /// Marks a text field of the dialog, so Escape, Enter, Undo and Delete leave it alone. + fn text_field(&mut self, r: &Response) { + if r.has_focus() { + self.state.text_focus = true; + } + } + + fn recording_target(&self) -> Option<&Target> { + self.state.recording.as_ref().map(|r: &Recording| &r.target) + } + + // ---- Keys when nothing records ---- + + /// Escape, Undo/Redo and Enter (§2.5). Keys while recording were taken by `DialogState::input`. + fn keys(&mut self, ui: &Ui) { + if self.state.is_recording() { + return; + } + let text_focus = self.state.text_focus; + let popup = egui::Popup::is_any_open(ui.ctx()); + if !text_focus && !popup && ui.input_mut(|i| i.consume_key(Modifiers::NONE, EguiKey::Escape)) { + if self.state.typing.take().is_some() { + self.state.type_error = None; + } else { + self.state.escape(self.env); + } + return; + } + if text_focus || !self.env.options.editable || self.state.modal() { + return; + } + let mac = self.env.keymap.platform().is_mac(); + let (undo, redo) = ui.input_mut(|i| { + let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::Z) || (!mac && i.consume_key(Modifiers::COMMAND, EguiKey::Y)); + let undo = !redo && i.consume_key(Modifiers::COMMAND, EguiKey::Z); + (undo, redo) + }); + if redo { + self.state.redo(self.env); + } else if undo { + self.state.undo(self.env); + } + if self.env.options.enter_is_ok + && self.state.prompt.is_none() + && ui.memory(|m| m.focused().is_none()) + && ui.input_mut(|i| i.consume_key(Modifiers::NONE, EguiKey::Enter)) + { + self.state.enter(self.env); + } + } + + // ---- Layout ---- + + fn layout(&mut self, ui: &mut Ui) { + let avail = ui.available_rect_before_wrap(); + let size = |v: f32, or: f32| if v.is_finite() { v.clamp(0.0, 4096.0) } else { or }; + let full = Rect::from_min_size(avail.min, vec2(size(avail.width(), 800.0), size(avail.height(), 600.0))); + self.state.text_focus = false; + let gap = ui.spacing().item_spacing.y; + let column = self.column(); + ui.scope_builder(UiBuilder::new().max_rect(full).layout(column), |ui| { + self.sets_row(ui); + // Show, Filter and Search act on the table, which the import review replaces. + ui.add_enabled_ui(self.state.review.is_none(), |ui| self.header(ui)); + let rest = ui.available_rect_before_wrap(); + let bottom_h = self.state.bottom_h.clamp(0.0, rest.height()); + let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); + let mid = Rect::from_min_max(rest.min, pos2(rest.max.x, mid_bottom)); + if !self.review(ui, mid) { + let prompt = self.state.prompt.is_some(); + ui.scope(|ui| { + if prompt { + ui.disable(); + } + self.middle(ui, mid); + }); + } + let top = (mid.max.y + gap).min(rest.max.y); + let bottom = Rect::from_min_max(pos2(rest.min.x, top), rest.max); + let used = ui.scope_builder(UiBuilder::new().max_rect(bottom).layout(column), |ui| self.bottom(ui)).response.rect.height(); + if (used - self.state.bottom_h).abs() > 0.5 { + self.state.bottom_h = used; + ui.ctx().request_discard("shortcuts dialog: height of the bars under the table"); + } + }); + ui.advance_cursor_after_rect(full); + } + + /// Top to bottom, aligned to the reading side. In a right-to-left interface egui's + /// `horizontal` rows inside such a column also run right to left. + fn column(&self) -> Layout { + Layout::top_down(if self.env.text.rtl() { Align::Max } else { Align::Min }) + } + + fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { + let column = self.column(); + ui.scope_builder(UiBuilder::new().max_rect(rect).layout(column), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + f(self, ui); + }); + } + + // ---- Header: Show, Filter, Search ---- + + fn area_key(id: &AreaId) -> String { + match id { + AreaId::All => "all".into(), + AreaId::Menu(m) => format!("menu.{m}"), + AreaId::Tools => "tools".into(), + AreaId::Panel(p) => format!("panel.{p}"), + AreaId::Other => "other".into(), + AreaId::App(a) => (*a).to_string(), + AreaId::Fixed => "fixed".into(), + } + } + + fn header(&mut self, ui: &mut Ui) { + if self.env.options.area_tabs { + self.area_tabs(ui); + } + // Show and Filter, then Search, ⌨ and Keyboard; Search goes to a row of its own when the + // two do not fit side by side. + let gap = ui.spacing().item_spacing.x; + let text_w = |d: &Self, ui: &Ui, s: &str| d.button_width(ui, s) - ui.spacing().button_padding.x * 2.0; + let mut first = text_w(self, ui, &self.ui("Filter:")) + COMBO_MAX + 2.0 * gap; + if !self.env.options.area_tabs { + first += text_w(self, ui, &self.ui("Show:")) + COMBO_MAX + 2.0 * gap; + } + let toggle = if self.env.options.keyboard { self.button_width(ui, &self.ui("Keyboard")) + gap } else { 0.0 }; + let second = text_w(self, ui, &self.ui("Search:")) + SEARCH_MIN + ICON + toggle + 3.0 * gap; + let one_row = first + second <= ui.available_width(); + ui.horizontal(|ui| { + if !self.env.options.area_tabs { + self.area_combo(ui); + } + self.filter_combo(ui); + if one_row { + self.search(ui, toggle); + } + }); + if !one_row { + ui.horizontal(|ui| self.search(ui, toggle)); + } + } + + /// A combo box at most [`COMBO_MAX`] wide, its text cut with "…" and shown in full on hover. + fn combo(&self, ui: &mut Ui, salt: &str, label: &Response, title: &str, current: &str, items: impl FnOnce(&mut Ui)) -> Response { + let full = self.vis(current); + let natural = self.button_width(ui, &full) + ui.spacing().icon_width + ui.spacing().icon_spacing; + let width = natural.clamp(ui.spacing().combo_width, COMBO_MAX); + let r = ui + .allocate_ui(vec2(width, ui.spacing().interact_size.y), |ui| { + ComboBox::from_id_salt(self.base.with(salt)).width(width).truncate().selected_text(full.clone()).show_ui(ui, items).response + }) + .inner + .labelled_by(label.id); + self.tag(|| format!("shortcuts.{salt}"), r.rect, title); + if natural > width { r.on_hover_text(full) } else { r } + } + + fn filter_combo(&mut self, ui: &mut Ui) { + let filter_label = self.ui("Filter:"); + let l = ui.label(self.vis(&filter_label)); + let current = self.state.filter.label(self.env.text); + let mut pick = None; + self.combo(ui, "filter", &l, &filter_label, ¤t, |ui| { + for f in Filter::ALL { + let label = f.label(self.env.text); + if ui.selectable_label(self.state.filter == f, self.vis(&label)).clicked() { + pick = Some(f); + } + } + }); + if let Some(f) = pick { + self.state.set_filter(f); + } + } + + /// Search, search by key and the keyboard pane's toggle (`toggle` wide, 0 without it). + fn search(&mut self, ui: &mut Ui, toggle: f32) { + let search_label = self.ui("Search:"); + let l = ui.label(self.vis(&search_label)); + let mut q = self.state.query.clone(); + let gap = ui.spacing().item_spacing.x; + let width = (ui.available_width() - ICON - toggle - 2.0 * gap).clamp(SEARCH_MIN.min(ui.available_width()), SEARCH_MAX); + let edit = ui.add(TextEdit::singleline(&mut q).id(self.base.with("search")).desired_width(width)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.search".into(), edit.rect, &search_label); + if edit.changed() { + self.state.set_query(q); + } + + let key_label = self.ui("Search by key"); + let searching = self.recording_target() == Some(&Target::Search); + let r = self.icon_button(ui, &key_label, searching, paint_keyboard).on_hover_text(self.vis(&key_label)); + self.tag(|| "shortcuts.searchKey".into(), r.rect, &key_label); + if r.clicked() { + if searching { + self.state.stop_recording(); + } else { + self.state.record(self.env, Target::Search); + r.request_focus(); + } + } + if self.recording_target() == Some(&Target::Search) { + if r.lost_focus() { + self.state.stop_recording(); + } else if r.has_focus() || r.clicked() { + capture::lock_focus(ui, r.id); + } + } else if r.has_focus() { + capture::unlock_focus(ui, r.id); + } + self.keyboard_toggle(ui); + } + + fn area_combo(&mut self, ui: &mut Ui) { + let show = self.ui("Show:"); + let l = ui.label(self.vis(&show)); + let current = self.state.areas.iter().find(|a| a.id == self.state.area).map_or_else(String::new, |a| a.label.clone()); + let mut pick = None; + let areas = self.state.areas.clone(); + self.combo(ui, "area", &l, &show, ¤t, |ui| { + for a in &areas { + let r = ui.selectable_label(a.id == self.state.area, self.vis(&a.label)); + self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), r.rect, &a.label); + if r.clicked() { + pick = Some(a.id.clone()); + } + } + }); + if let Some(a) = pick { + self.state.set_area(a); + } + } + + /// Show as tabs, with the ones that do not fit in a menu. + fn area_tabs(&mut self, ui: &mut Ui) { + let areas = self.state.areas.clone(); + let pad = ui.spacing().button_padding.x * 2.0; + let font = TextStyle::Button.resolve(ui.style()); + let natural: Vec = + areas.iter().map(|a| ui.painter().layout_no_wrap(self.vis(&a.label), font.clone(), Color32::WHITE).size().x + pad).collect(); + let selected = areas.iter().position(|a| a.id == self.state.area).unwrap_or(0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.spacing().interact_size.y + 4.0), Sense::hover()); + let fit = tabs::fit(&natural, selected, rect.width(), 48.0, ICON + 8.0); + let m = self.mirror(rect); + let mut x = rect.min.x; + let mut pick = None; + for &(i, w) in &fit.shown { + let Some(a) = areas.get(i) else { continue }; + let r = m.rect(Rect::from_min_size(pos2(x, rect.min.y), vec2(w, rect.height()))); + x += w; + let on = i == selected; + let id = self.base.with(("area", i)); + let text = self.vis(&a.label); + let accent = self.style.accent; + let resp = Tab::new(id, &a.label, on).show_at(ui, r, |ui, resp| { + let v = ui.visuals(); + let color = if on { v.strong_text_color() } else { v.text_color() }; + if resp.hovered() && !on { + ui.painter().rect_filled(r, 2.0, v.widgets.hovered.weak_bg_fill); + } + if on { + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(2.0, accent)); + } + let (g, _) = self.one_line(ui.painter(), &[(text, TextFormat::simple(font.clone(), color))], r.width() - 4.0); + ui.painter().galley(r.center() - g.size() / 2.0, g, color); + }); + self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), resp.rect, &a.label); + if resp.clicked() { + pick = Some(a.id.clone()); + } + } + if !fit.overflow.is_empty() { + let r = m.rect(Rect::from_min_size(pos2(rect.max.x - fit.overflow_width, rect.min.y), vec2(fit.overflow_width, rect.height()))); + let more = self.ui("More"); + ui.scope_builder(UiBuilder::new().max_rect(r), |ui| { + ui.menu_button(self.vis(&more), |ui| { + for &i in &fit.overflow { + if let Some(a) = areas.get(i) + && ui.button(self.vis(&a.label)).clicked() + { + pick = Some(a.id.clone()); + } + } + }); + }); + } + if let Some(a) = pick { + self.state.set_area(a); + } + } + + // ---- Table and detail pane ---- + + /// The table and detail pane, with the keyboard pane under both when it is on. + fn middle(&mut self, ui: &mut Ui, mid: Rect) { + let Some(kb_h) = self.keyboard_height(mid.height()) else { + self.table_and_detail(ui, mid); + return; + }; + let gap = ui.spacing().item_spacing.y; + let top = Rect::from_min_max(mid.min, pos2(mid.max.x, mid.max.y - kb_h - gap)); + let pane = Rect::from_min_max(pos2(mid.min.x, mid.max.y - kb_h), mid.max); + self.table_and_detail(ui, top); + self.child(ui, pane, |d, ui| d.keyboard_pane(ui)); + } + + fn table_and_detail(&mut self, ui: &mut Ui, mid: Rect) { + let wide = mid.width() >= NARROW; + let axis = if wide { SplitAxis::Horizontal } else { SplitAxis::Vertical }; + // Right to left: the detail pane goes on the left. + let swap = wide && self.env.text.rtl(); + let ratio = if swap { 1.0 - self.state.split } else { self.state.split }; + let extent = if wide { mid.width() } else { mid.height() }; + let Some(split) = split_rect(mid, axis, SplitSize::Ratio(ratio), 8.0, SPLIT_MIN.min(extent / 3.0)) else { return }; + let (table, detail) = if swap { (split.second, split.first) } else { (split.first, split.second) }; + self.child(ui, table, |d, ui| d.table(ui)); + let label = self.ui("Detail pane size"); + let resp = ResizeHandle::new(self.base.with("split"), axis, split.divider, &label, style::divider(ui.visuals())) + .value(split.first_size(), 0.0..=split.available()) + .show(ui); + let line = ui.visuals().widgets.noninteractive.bg_stroke; + let c = split.divider.center(); + let ends = if wide { + [pos2(c.x, split.divider.min.y), pos2(c.x, split.divider.max.y)] + } else { + [pos2(split.divider.min.x, c.y), pos2(split.divider.max.x, c.y)] + }; + ui.painter().line_segment(ends, line); + if resp.delta != 0.0 + && let Some(SplitSize::Ratio(r)) = split.resized(SplitSize::Ratio(ratio), resp.delta, SPLIT_MIN) + { + self.state.split = if swap { 1.0 - r } else { r }; + } + self.child(ui, detail, |d, ui| d.detail(ui)); + } + + /// Width of the shortcut column: the widest row of chips plus "+", within 40 % of the table. + fn shortcut_width(&self, ui: &Ui, width: f32) -> f32 { + let font = TextStyle::Body.resolve(ui.style()); + let chip_w = |text: &str| ui.painter().layout_no_wrap(text.to_string(), font.clone(), Color32::WHITE).size().x + 2.0 * CHIP_PAD + CHIP_GAP; + let chips = self + .state + .lines_cached() + .iter() + .filter_map(|l| if let Line::Row(r) = l { Some(r.chips.iter().map(|c| chip_w(&c.text)).sum::()) } else { None }) + .fold(0.0, f32::max); + (chips + ICON + CHIP_GAP).max(110.0).min(width * 0.4) + } + + /// Draw the command table and own its keyboard focus and row navigation. + /// + /// The table is a focusable non-interactive region. While focused and not recording, arrow, + /// Home/End, Enter and Delete are consumed by `table_keys`; while a shortcut cell is recording, + /// focus uses `FOCUS_LOCK` so the recording receives keys, and losing focus stops recording. + /// Rows are built from the cached lines, reveal scrolling centers the selected row when needed, + /// and the selected rendered row becomes the active accessibility descendant. Dispatched row + /// actions select, record, reset or toggle a group; group toggles keep focus, other actions + /// refocus the table. + fn table(&mut self, ui: &mut Ui) { + let rect = ui.max_rect(); + let table_id = self.base.with("table"); + let focus = ui.interact(rect, table_id, Sense::focusable_noninteractive()); + let commands = self.ui("Commands"); + focus.widget_info(|| WidgetInfo::labeled(Role::Unknown, true, &commands)); + let has_focus = focus.has_focus(); + if has_focus && !self.state.is_recording() { + self.table_keys(ui); + } + let recording_cell = matches!(self.recording_target(), Some(Target::Cell { .. })); + if recording_cell && focus.lost_focus() { + self.state.stop_recording(); + } + if has_focus { + let filter = if recording_cell { + capture::FOCUS_LOCK + } else { + EventFilter { vertical_arrows: true, horizontal_arrows: true, tab: false, escape: false } + }; + ui.memory_mut(|m| m.set_focus_lock_filter(table_id, filter)); + } + + let selected = self.state.selected.clone(); + let built = self.state.lines(self.env); + let (count, matches) = (built.lines.len(), built.matches); + let position = selected.as_ref().and_then(|c| built.position(c)); + let row_h = self.row_h; + let shortcut_w = self.shortcut_width(ui, rect.width()); + + // Column titles. + let (head, _) = ui.allocate_exact_size(vec2(rect.width(), row_h), Sense::hover()); + let font = TextStyle::Body.resolve(ui.style()); + let dim = self.style.dim; + let m = self.mirror(head); + let key_x = head.max.x - shortcut_w - ICON - 4.0; + let titles = [(self.ui("Command"), head.min.x + 6.0, key_x - head.min.x - 12.0), (self.ui("Shortcut"), key_x, shortcut_w)]; + for (title, x, w) in titles { + let (g, _) = self.one_line(ui.painter(), &[(self.vis(&title), TextFormat::simple(font.clone(), dim))], w); + ui.painter().galley(pos2(m.x(x, g.size().x), head.center().y - g.size().y / 2.0), g, dim); + } + ui.painter().line_segment([head.left_bottom(), head.right_bottom()], ui.visuals().widgets.noninteractive.bg_stroke); + + if matches == 0 { + ui.add_space(8.0); + ui.label(RichText::new(self.vis(&self.ui("No matching commands"))).color(dim)); + } + + let mut scroll = ScrollArea::vertical().id_salt(self.base.with("rows")).auto_shrink([false, false]); + if std::mem::take(&mut self.state.reveal) + && let Some(pos) = position + { + let inside = self.state.visible.start < pos && pos + 1 < self.state.visible.end; + if !inside { + let view = ui.available_height(); + scroll = scroll.vertical_scroll_offset((pos as f32 * row_h - (view - row_h) / 2.0).max(0.0)); + } + } + let mut action = None; + let mut shown = 0..0; + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + scroll.show_rows(ui, row_h, count, |ui, range| { + shown = range.clone(); + action = self.rows(ui, range, shortcut_w, has_focus); + }); + }); + // Screen readers follow the selected row while the table has focus. + let row_id = position.filter(|p| shown.contains(p)).and_then(|p| match self.state.lines_cached().get(p) { + Some(Line::Row(r)) => Some(self.base.with(("row", &r.key))), + _ => None, + }); + if let Some(id) = row_id { + ui.ctx().accesskit_node_builder(table_id, |node| node.set_active_descendant(id.accesskit_id())); + } + self.state.visible = shown; + if has_focus { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } + + if let Some(a) = action { + // A group header is a Tab stop of its own and keeps focus. + if !matches!(a, RowAction::Toggle(_)) { + focus.request_focus(); + } + match a { + RowAction::Select(c) => self.state.select_row(&c), + RowAction::Record(c, old, chip) => { + self.state.record(self.env, Target::Cell { command: c, old }); + self.state.chip = chip; + } + RowAction::Reset(c) => { + self.state.select_row(&c); + self.state.use_default(self.env, &c); + } + RowAction::Toggle(name) => self.state.toggle_group(&name), + } + if self.state.is_recording() { + ui.memory_mut(|m| m.set_focus_lock_filter(table_id, capture::FOCUS_LOCK)); + } + } + } + + /// Up/Down, Home/End, Left/Right between chips, Enter records, Delete removes (§2.10). + fn table_keys(&mut self, ui: &Ui) { + let keys = [ + EguiKey::ArrowUp, + EguiKey::ArrowDown, + EguiKey::Home, + EguiKey::End, + EguiKey::ArrowLeft, + EguiKey::ArrowRight, + EguiKey::Enter, + EguiKey::Delete, + ]; + let pressed = ui.input_mut(|i| keys.map(|k| i.consume_key(Modifiers::NONE, k))); + let [up, down, home, end, left, right, enter, delete] = pressed; + if !pressed.contains(&true) { + return; + } + let rows: Vec<(CommandRef, Vec)> = self + .state + .lines(self.env) + .lines + .iter() + .filter_map(|l| if let Line::Row(r) = l { Some((r.command.clone(), r.chips.iter().map(|c| c.binding.clone()).collect())) } else { None }) + .collect(); + let at = self.state.selected.as_ref().and_then(|s| rows.iter().position(|(c, _)| c == s)); + let to = if up { + Some(at.map_or(0, |i| i.saturating_sub(1))) + } else if down { + Some(at.map_or(0, |i| (i + 1).min(rows.len().saturating_sub(1)))) + } else if home { + Some(0) + } else if end { + Some(rows.len().saturating_sub(1)) + } else { + None + }; + if let Some((command, _)) = to.and_then(|i| rows.get(i)) { + self.state.select_row(command); + self.state.reveal = true; + return; + } + let Some((command, bindings)) = at.and_then(|i| rows.get(i)) else { return }; + let chip = self.state.chip.min(bindings.len().saturating_sub(1)); + if left { + self.state.chip = chip.saturating_sub(1); + } else if right { + self.state.chip = (chip + 1).min(bindings.len().saturating_sub(1)); + } else if enter && self.env.options.editable { + self.state.record(self.env, Target::Cell { command: command.clone(), old: bindings.get(chip).cloned() }); + self.state.chip = chip; + } else if delete + && self.env.options.editable + && let Some(b) = bindings.get(chip) + { + self.state.remove(self.env, command, b); + self.state.chip = chip.saturating_sub(1); + } + } + + fn rows(&mut self, ui: &mut Ui, range: Range, shortcut_w: f32, focused: bool) -> Option { + let lines: Vec = self.state.lines(self.env).lines.get(range).map(<[Line]>::to_vec).unwrap_or_default(); + let mut action = None; + for line in lines { + let (_, rect) = ui.allocate_space(vec2(ui.available_width(), self.row_h)); + let a = match &line { + Line::Header { name, collapsed } => self.group_header(ui, rect, name, *collapsed), + Line::Row(r) => self.row(ui, rect, r, shortcut_w, focused), + Line::Fixed(f) => { + self.fixed_row(ui, rect, f, shortcut_w); + None + } + }; + action = action.or(a); + } + action + } + + /// A group's header; a Tab stop, toggled with Enter or Space. + fn group_header(&self, ui: &mut Ui, rect: Rect, name: &str, collapsed: bool) -> Option { + let resp = ui.interact(rect, self.base.with(("group", name)), Sense::click()); + resp.widget_info(|| WidgetInfo::labeled(Role::DisclosureTriangle, true, name)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_expanded(!collapsed)); + if resp.has_focus() { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } + let v = ui.visuals(); + let color = v.strong_text_color(); + let m = self.mirror(rect); + let tri = m.rect(Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), Vec2::splat(8.0))); + let points = match (collapsed, m.on) { + (true, false) => vec![tri.left_top(), tri.right_center(), tri.left_bottom()], + (true, true) => vec![tri.right_top(), tri.right_bottom(), tri.left_center()], + (false, _) => vec![tri.left_top(), tri.right_top(), tri.center_bottom()], + }; + ui.painter().add(egui::Shape::convex_polygon(points, color, Stroke::NONE)); + let font = TextStyle::Body.resolve(ui.style()); + let (g, cut) = self.one_line(ui.painter(), &[(self.vis(name), TextFormat::simple(font, color))], rect.width() - 24.0); + ui.painter().galley(pos2(m.x(rect.min.x + 20.0, g.size().x), rect.center().y - g.size().y / 2.0), g, color); + let clicked = resp.clicked(); + if cut { + resp.on_hover_text(self.vis(name)); + } + clicked.then(|| RowAction::Toggle(name.to_string())) + } + + /// Draw one command row and return at most one action. + /// + /// The row always senses clicks and can be selected even when the dialog is read-only; + /// `editable` additionally enables shortcut-cell hit testing and the add/reset controls. The + /// accessibility label gains "(cycles)" and "(Changed)" suffixes, and the visual row uses + /// selected/focused and RTL-mirrored styling. A recording cell for this command shows the + /// recording text; clicking a chip records that binding, clicking an empty cell records the + /// first binding, and the plus button records an appended binding. The final fallback action is + /// selecting the clicked row. + fn row(&self, ui: &mut Ui, rect: Rect, r: &Row, shortcut_w: f32, focused: bool) -> Option { + let editable = self.env.options.editable; + let selected = self.state.selected.as_ref() == Some(&r.command); + let resp = ui.interact(rect, self.base.with(("row", &r.key)), Sense::CLICK); + let mut a11y = r.label.clone(); + if r.cycles { + a11y = fill(self.ui("{label} (cycles)"), &[("label", &a11y)]); + } + if r.changed { + a11y = fill(self.ui("{label} (Changed)"), &[("label", &a11y)]); + } + resp.widget_info(|| WidgetInfo::selected(Role::Button, true, selected, &a11y)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_selected(selected)); + self.tag(|| format!("shortcuts.row.{}", r.key), rect, &r.label); + let v = ui.visuals(); + if selected { + let fill = if focused { v.selection.bg_fill } else { v.selection.bg_fill.gamma_multiply(0.5) }; + ui.painter().rect_filled(rect, 0.0, fill); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, v.widgets.hovered.weak_bg_fill); + } + let m = self.mirror(rect); + if r.changed { + ui.painter().rect_filled(m.rect(Rect::from_min_size(rect.min + vec2(1.0, 3.0), vec2(3.0, rect.height() - 6.0))), 1.0, self.style.changed); + } + let text_color = if selected { v.strong_text_color() } else { v.text_color() }; + let strong = v.strong_text_color(); + let dim = self.style.dim; + let font = TextStyle::Body.resolve(ui.style()); + + // Label column. + let label_w = (rect.width() - shortcut_w - ICON - 12.0).max(0.0); + let fmt = |c: Color32| TextFormat::simple(font.clone(), c); + let mut parts = Vec::new(); + if r.conflict { + parts.extend([("⚠".to_string(), fmt(self.style.warning)), (" ".into(), fmt(dim))]); + } + for p in &r.prefix { + parts.extend([(self.vis(p), fmt(dim)), (PATH_SEP.into(), fmt(dim))]); + } + parts.push((self.vis(&r.label), fmt(if r.changed { strong } else { text_color }))); + if r.cycles { + parts.extend([(" ".into(), fmt(dim)), (self.vis(&self.ui("(cycles)")), fmt(dim))]); + } + let (g, _) = self.one_line(ui.painter(), &self.ordered(parts), label_w); + ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, text_color); + + // Shortcut column: chips, then "+". + let mut action = None; + let col = Rect::from_min_max(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.min.y), pos2(rect.max.x - ICON - 4.0, rect.max.y)); + let painter = ui.painter_at(m.rect(col)); + let mut x = col.min.x; + let rec = match self.recording_target() { + Some(Target::Cell { command, old }) if *command == r.command => Some(old.clone()), + _ => None, + }; + let chip_h = rect.height() - 4.0; + let mut cells: Vec<(Option, String, bool)> = + r.chips.iter().map(|c| (Some(c.binding.clone()), c.text.clone(), rec.as_ref() == Some(&Some(c.binding.clone())))).collect(); + if rec == Some(None) { + cells.push((None, String::new(), true)); + } + for (n, (binding, text, recording)) in cells.iter().enumerate() { + let shown = if *recording { self.recording_text() } else { text.clone() }; + let g = painter.layout_no_wrap(shown.clone(), font.clone(), text_color); + let w = g.size().x + 2.0 * CHIP_PAD; + let chip = m.rect(Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h))); + x += w + CHIP_GAP; + let changed = r.chips.get(n).is_some_and(|c| c.changed); + let stroke = if *recording { + Stroke::new(1.5, self.style.accent) + } else if changed { + Stroke::new(1.0, self.style.changed) + } else { + v.widgets.inactive.bg_stroke + }; + let current_chip = selected && focused && n == self.state.chip; + painter.rect(chip, 3.0, v.widgets.inactive.weak_bg_fill, if current_chip { v.selection.stroke } else { stroke }, StrokeKind::Inside); + painter.galley(pos2(chip.min.x + CHIP_PAD, chip.center().y - g.size().y / 2.0), g, v.text_color()); + let waiting = self.state.recording.as_ref().is_some_and(|r| r.waiting.is_some()); + let label = if *recording && waiting { + fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &shown)]) + } else if *recording { + fill(self.ui("{label}: press keys"), &[("label", &r.label)]) + } else { + fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", text)]) + }; + let resp = + ui.interact(chip.intersect(m.rect(col)), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.{n}", r.key), chip, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), binding.clone(), n)); + } + } + if cells.is_empty() { + // An empty cell to click for the first shortcut. + let cell = m.rect(Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y))); + let label = fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &self.ui("None"))]); + let resp = ui.interact(cell, self.base.with(("cell", &r.key, 0)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.0", r.key), cell, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, 0)); + } + x = col.min.x; + } + if editable && r.chips.len() < MAX_BINDINGS && rec != Some(None) { + let plus = m.rect(Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON))); + let label = fill(self.ui("Add a shortcut to {label}"), &[("label", &r.label)]); + let resp = self.row_button(ui, plus, self.base.with(("add", &r.key)), &label, true, paint_plus); + self.tag(|| format!("shortcuts.add.{}", r.key), plus, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, r.chips.len())); + } + } + if editable { + let reset = m.rect(Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON))); + let label = fill(self.ui("Restore the default for {label}"), &[("label", &r.label)]); + let resp = self.row_button(ui, reset, self.base.with(("reset", &r.key)), &label, r.changed, paint_reset); + self.tag(|| format!("shortcuts.reset.{}", r.key), reset, &label); + if resp.clicked() { + action = Some(RowAction::Reset(r.command.clone())); + } + } + + let clicked = resp.clicked(); + let mut tip = vec![self.vis(&r.label)]; + if let Some(b) = self.env.keymap.bindable(&r.command) { + if let Some(path) = b.paths.first().filter(|p| !p.is_empty()) { + tip.push(self.path_text(path)); + } + if let Some(h) = self.env.text.hint(b) { + tip.push(self.vis(&h)); + } + } + resp.on_hover_text(tip.join("\n")); + action.or_else(|| clicked.then(|| RowAction::Select(r.command.clone()))) + } + + /// A read-only row from `Options::fixed`, struck through when a command took its chord. + fn fixed_row(&self, ui: &mut Ui, rect: Rect, f: &FixedLine, shortcut_w: f32) { + let v = ui.visuals(); + let font = TextStyle::Body.resolve(ui.style()); + let color = if f.now_used_by.is_some() { self.style.dim } else { v.text_color() }; + let mut fmt = TextFormat::simple(font.clone(), color); + if f.now_used_by.is_some() { + fmt.strikethrough = Stroke::new(1.0, color); + } + let m = self.mirror(rect); + let label_w = rect.width() - shortcut_w - ICON - 12.0; + let (g, cut) = self.one_line(ui.painter(), &[(self.vis(&f.label), fmt.clone())], label_w); + ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, color); + let (keys, _) = self.one_line(ui.painter(), &[(f.keys.clone(), fmt)], shortcut_w); + ui.painter().galley(pos2(m.x(rect.max.x - shortcut_w - ICON - 4.0, keys.size().x), rect.center().y - keys.size().y / 2.0), keys, color); + let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); + let mut tip = Vec::new(); + if cut { + tip.push(self.vis(&f.label)); + } + match &f.now_used_by { + Some(by) => { + let label = fill(self.ui("{label}: {chord} (Now used by {holder})"), &[("label", &f.label), ("chord", &f.keys), ("holder", by)]); + resp.widget_info(|| WidgetInfo::labeled(Role::Label, true, &label)); + tip.push(self.vis(&fill(self.ui("Now used by {label}"), &[("label", by)]))); + } + None => { + let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); + resp.widget_info(|| WidgetInfo::labeled(Role::Label, true, &label)); + } + } + if !tip.is_empty() { + resp.on_hover_text(tip.join("\n")); + } + } + + fn detail(&mut self, ui: &mut Ui) { + ScrollArea::vertical().id_salt(self.base.with("detail")).auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(4.0); + let Some(command) = self.state.selected.clone().filter(|c| self.env.keymap.bindable(c).is_some()) else { + ui.label(RichText::new(self.vis(&self.ui("Select a command to see and change its shortcuts"))).color(self.style.dim)); + return; + }; + self.detail_of(ui, &command); + }); + } + + fn detail_of(&mut self, ui: &mut Ui, command: &CommandRef) { + let Some(b) = self.env.keymap.bindable(command).cloned() else { return }; + let editable = self.env.options.editable; + let label = self.env.text.command(&b).into_owned(); + let home = Some(&b.scope); + let dim = self.style.dim; + ui.add(Label::new(RichText::new(self.vis(&label)).strong().size(ui.text_style_height(&TextStyle::Body) * 1.15)).wrap()); + let path: Vec = b.paths.first().map(|p| p.iter().filter(|s| !s.is_empty()).cloned().collect()).unwrap_or_default(); + let place = + if path.is_empty() { b.command.id.clone() } else { self.joined(vec![self.path_text(&path), " · ".into(), b.command.id.clone()]) }; + ui.add(Label::new(RichText::new(place).color(dim)).wrap()); + if let Some(h) = self.env.text.hint(&b) { + ui.add(Label::new(RichText::new(self.vis(&h)).color(dim)).wrap()); + } + ui.add_space(6.0); + + // Current shortcuts, each with Remove. + ui.label(self.vis(&self.ui("Current shortcuts:"))); + let bindings = self.env.keymap.bindings(command); + let defaults = self.env.keymap.default_bindings(command); + if bindings.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("None"))).color(dim)); + } + let mut remove = None; + for (n, binding) in bindings.iter().enumerate() { + let text = self.binding_text(binding, home); + ui.horizontal(|ui| { + ui.add_space(8.0); + let shown = if defaults.contains(binding) { fill(self.ui("{chord} (Default)"), &[("chord", &text)]) } else { text.clone() }; + ui.label(self.vis_chord(&shown, &self.chord(&binding.trigger))); + if editable { + let a11y = fill(self.ui("Remove {chord} from {label}"), &[("chord", &text), ("label", &label)]); + let r = self.icon_button(ui, &a11y, false, paint_cross).on_hover_text(self.vis(&a11y)); + self.tag(|| format!("shortcuts.remove.{n}"), r.rect, &a11y); + if r.clicked() { + remove = Some(binding.clone()); + } + } + }); + } + if let Some(binding) = remove { + self.state.remove(self.env, command, &binding); + } + if !editable { + self.defaults_line(ui, &defaults, home); + return; + } + ui.add_space(6.0); + self.new_shortcut(ui, command, home); + self.context(ui, command); + self.holders(ui); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if self.button(ui, self.state.field.is_some(), &self.ui("Assign"), "shortcuts.assign") { + self.state.assign_field(self.env); + } + if self.button(ui, DialogState::can_use_default(self.env, command), &self.ui("Use Default"), "shortcuts.useDefault") { + self.state.use_default(self.env, command); + } + if self.button(ui, !bindings.is_empty(), &self.ui("Clear"), "shortcuts.clear") { + self.state.clear(self.env, command); + } + }); + self.defaults_line(ui, &defaults, home); + } + + fn defaults_line(&self, ui: &mut Ui, defaults: &[Binding], home: Option<&Scope>) { + let chords = + if defaults.is_empty() { self.ui("None") } else { defaults.iter().map(|d| self.binding_text(d, home)).collect::>().join(", ") }; + let text = self.vis_chord(&fill(self.ui("Default: {chord}"), &[("chord", &chords)]), &chords); + ui.add(Label::new(RichText::new(text).color(self.style.dim)).wrap()); + } + + /// New Shortcut: a field that records when focused or clicked, or typed entry. + fn new_shortcut(&mut self, ui: &mut Ui, command: &CommandRef, home: Option<&Scope>) { + let title = self.ui("New shortcut:"); + let title_id = ui.label(self.vis(&title)).id; + let field_id = self.base.with("new"); + let recording = matches!(self.recording_target(), Some(Target::Field { command: c }) if c == command); + if let Some(mut typed) = self.state.typing.clone() { + let type_id = self.base.with("type"); + let edit = ui.add(TextEdit::singleline(&mut typed).id(type_id).desired_width(FIELD_W).hint_text("Cmd+Shift+K")); + let edit = edit.labelled_by(title_id); + // Focus once, on the frame after Type… was clicked. + if ui.data_mut(|d| d.remove_temp::(type_id)).is_some() { + edit.request_focus(); + } + self.text_field(&edit); + self.tag(|| "shortcuts.type".into(), edit.rect, &title); + let enter = edit.lost_focus() && ui.input(|i| i.key_pressed(EguiKey::Enter)); + self.state.typing = Some(typed.clone()); + if enter { + match self.state.type_binding(self.env, command, &typed) { + Ok(()) => { + self.state.typing = None; + self.state.type_error = None; + } + Err(e) => self.state.type_error = Some(e), + } + } + if let Some(e) = &self.state.type_error { + ui.add(Label::new(RichText::new(self.warned(self.vis(e))).color(self.style.warning)).wrap()); + } + return; + } + ui.horizontal(|ui| { + let shown = if recording { + self.recording_text() + } else { + self.state.field.as_ref().filter(|f| f.command == *command).map_or_else(String::new, |f| self.binding_text(&f.binding, home)) + }; + let size = vec2(FIELD_W, ui.spacing().interact_size.y); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, field_id, Sense::click()); + let value = if shown.is_empty() { self.ui("None") } else { shown.clone() }; + let a11y = fill(self.ui("New shortcut: {chord}"), &[("chord", &value)]); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &a11y)); + let v = ui.visuals(); + let stroke = if recording || resp.has_focus() { v.selection.stroke } else { v.widgets.inactive.bg_stroke }; + ui.painter().rect(rect, 3.0, v.extreme_bg_color, stroke, StrokeKind::Inside); + let color = if recording { self.style.dim } else { v.text_color() }; + ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, shown, TextStyle::Body.resolve(ui.style()), color); + self.tag(|| "shortcuts.new".into(), rect, &title); + if !recording && (resp.clicked() || resp.gained_focus()) { + self.state.record(self.env, Target::Field { command: command.clone() }); + resp.request_focus(); + capture::lock_focus(ui, field_id); + } else if recording { + if resp.lost_focus() { + self.state.stop_recording(); + } else { + capture::lock_focus(ui, field_id); + } + } else if resp.has_focus() { + capture::unlock_focus(ui, field_id); + } + if ui.link(self.vis(&self.ui("Type…"))).clicked() { + self.state.stop_recording(); + self.state.typing = Some(String::new()); + self.state.type_error = None; + let type_id = self.base.with("type"); + ui.data_mut(|d| d.insert_temp(type_id, true)); + } + }); + } + + fn context(&mut self, ui: &mut Ui, command: &CommandRef) { + let contexts = DialogState::contexts(self.env, command); + let name = |s: &Option| match s { + None => self.ui("Default"), + Some(s) => self.scope_name(s), + }; + let current = name(&self.state.context); + let names: Vec = contexts.iter().map(name).collect(); + let title = self.ui("Context:"); + let mut pick = None; + ui.horizontal(|ui| { + let l = ui.label(self.vis(&title)); + let combo = ComboBox::from_id_salt(self.base.with("context")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + for (s, n) in contexts.iter().zip(&names) { + if ui.selectable_label(*s == self.state.context, self.vis(n)).clicked() { + pick = Some(s.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.context".into(), combo.rect, &title); + }); + if let Some(s) = pick { + self.state.set_context(self.env, s); + } + } + + /// "Currently assigned to:" for the field's chord, with links to the holders. + fn holders(&mut self, ui: &mut Ui) { + let Some(f) = self.state.field.clone() else { return }; + if f.holders.is_empty() && f.shadows.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("Not assigned"))).color(self.style.dim)); + return; + } + ui.label(self.vis(&self.ui("Currently assigned to:"))); + let mut go = None; + for h in &f.holders { + let label = self.label_of(&h.command); + let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()); + let text = match &h.binding.scope { + Some(s) if Some(s) != home.as_ref() => fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &self.scope_name(s))]), + _ => label, + }; + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(self.warned(self.vis(&text))).clicked() { + go = Some(h.command.clone()); + } + }); + } + for h in &f.shadows { + let label = self.label_of(&h.command); + let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()).unwrap_or_else(Scope::app); + let scope = self.scope_name(h.binding.scope_or(&home)); + let text = fill(self.ui("{label} ({scope}, both work)"), &[("label", &label), ("scope", &scope)]); + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(self.vis(&text)).clicked() { + go = Some(h.command.clone()); + } + }); + } + if let Some(c) = go { + self.state.go_to(&c); + } + } + + // ---- Under the table ---- + + fn bottom(&mut self, ui: &mut Ui) { + self.prompt(ui); + self.conflict_bar(ui); + self.message(ui); + self.info(ui); + self.footer(ui); + } + + fn prompt(&mut self, ui: &mut Ui) { + let Some(p) = self.state.prompt.clone() else { return }; + let (text, yes, no) = match &p { + Prompt::ResetAll => (self.ui("Restore every default shortcut in this set?"), self.ui("Reset All"), None), + Prompt::DeleteSet(set) => (fill(self.ui("Delete the set “{set}”?"), &[("set", set)]), self.ui("Delete"), None), + Prompt::SaveChanges { set, .. } => { + (fill(self.ui("Save changes to “{set}”?"), &[("set", set)]), self.ui("Save"), Some(self.ui("Don't Save"))) + } + }; + ui.add(Label::new(RichText::new(self.vis(&text)).strong()).wrap()); + let mut answer = None; + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &yes, "shortcuts.prompt.yes") { + answer = Some(Answer::Yes); + } + if let Some(no) = &no + && self.button(ui, true, no, "shortcuts.prompt.no") + { + answer = Some(Answer::No); + } + if self.button(ui, true, &self.ui("Cancel"), "shortcuts.prompt.cancel") { + answer = Some(Answer::Cancel); + } + }); + if let Some(a) = answer { + self.state.answer(self.env, a); + } + } + + fn conflict_bar(&mut self, ui: &mut Ui) { + let Some(c) = self.state.conflict.clone() else { return }; + let text = self.vis_chord(&c.text, &self.chord(&c.binding.trigger)); + let r = ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); + let mut act = 0; + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { + act = 1; + } + if self.button(ui, true, &self.ui("Take It and Go to Conflict"), "shortcuts.conflict.goto") { + act = 2; + } + if self.env.options.allow_keep && self.button(ui, true, &self.ui("Keep Both"), "shortcuts.conflict.keep") { + act = 3; + } + let label = self.ui("Cancel the new shortcut"); + let r = self.icon_button(ui, &label, false, paint_cross).on_hover_text(self.vis(&label)); + self.tag(|| "shortcuts.conflict.cancel".into(), r.rect, &label); + if r.clicked() { + act = 4; + } + }); + match act { + 1 => self.state.take_it(self.env), + 2 => self.state.take_it_and_go(self.env), + 3 => self.state.keep_both(self.env), + 4 => self.state.dismiss_conflict(), + _ => {} + } + } + + fn message(&mut self, ui: &mut Ui) { + let Some(m) = self.state.message.clone() else { return }; + let mut act = 0; + ui.horizontal_wrapped(|ui| { + let text = self.vis_chord(&m.text, m.chord.as_deref().unwrap_or_default()); + let text = if m.refused { self.warned(text) } else { text }; + let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; + let r = ui.add(Label::new(RichText::new(text).color(color)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); + if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { + act = 1; + } + if m.undo && self.button(ui, self.state.can_undo() && !self.state.modal(), &self.ui("Undo"), "shortcuts.message.undo") { + act = 2; + } + let label = self.ui("Dismiss"); + let r = self.icon_button(ui, &label, false, paint_cross).on_hover_text(self.vis(&label)); + self.tag(|| "shortcuts.message.dismiss".into(), r.rect, &label); + if r.clicked() { + act = 3; + } + }); + match (act, &m.goto) { + (1, Some(c)) => self.state.go_to(c), + (2, _) => self.state.undo(self.env), + (3, _) => self.state.message = None, + _ => {} + } + } + + /// Clashes, shadows and problems in the keymap as a whole. + fn info(&mut self, ui: &mut Ui) { + let built = self.state.lines(self.env); + let (clashes, shadows) = (built.clashes, built.shadows); + let problems = self.env.keymap.problems(); + if clashes == 0 && shadows == 0 && problems.is_empty() { + return; + } + let mut conflicts = false; + ui.horizontal_wrapped(|ui| { + if clashes > 0 { + let n = clashes as u64; + let text = self.env.text.plural(n, "{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"); + conflicts = ui.link(self.warned(self.vis(&text))).clicked(); + } + if shadows > 0 { + let n = shadows as u64; + let text = self.env.text.plural(n, "{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"); + ui.label(RichText::new(self.vis(&text)).color(self.style.dim)); + } + }); + if conflicts { + self.state.set_filter(Filter::Conflicts); + } + if !problems.is_empty() { + let n = problems.len() as u64; + let title = self.env.text.plural(n, "{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"); + egui::CollapsingHeader::new(self.vis(&title)).id_salt(self.base.with("problems")).show(ui, |ui| { + ScrollArea::vertical().max_height(self.row_h * 4.0).show(ui, |ui| { + for p in &problems { + ui.add(Label::new(self.vis(&self.problem(p))).wrap()); + } + }); + }); + } + } + + fn problem(&self, p: &Problem) -> String { + match p { + Problem::UnknownCommand { key } => fill(self.ui("{key}: no command has this name"), &[("key", key)]), + Problem::BadBinding { key, text, error } => { + let error = binding_error(self.env, error); + fill(self.ui("{key}: “{text}” is not a shortcut ({error})"), &[("key", key), ("text", text), ("error", &error)]) + } + Problem::Reserved { key, text } => fill(self.ui("{key}: {text} is reserved by the system"), &[("key", key), ("text", text)]), + Problem::UnknownScope { key, text } => fill(self.ui("{key}: the scope in “{text}” is unknown"), &[("key", key), ("text", text)]), + Problem::DuplicateKey { key } => fill(self.ui("{key}: listed more than once"), &[("key", key)]), + Problem::Dropped { key, .. } => fill(self.ui("{key}: part of the entry was dropped"), &[("key", key)]), + Problem::TooManyCommands { ignored } => { + self.env.text.plural(*ignored as u64, "{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands") + } + } + } + + fn footer(&mut self, ui: &mut Ui) { + let editable = self.env.options.editable; + let right: Vec<(String, &str)> = if editable { + vec![(self.ui("Cancel"), "shortcuts.cancel"), (self.ui("OK"), "shortcuts.ok")] + } else { + vec![(self.ui("Close"), "shortcuts.close")] + }; + let idle = !self.state.modal(); + ui.horizontal(|ui| { + if editable { + if self.button(ui, idle && self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { + self.state.undo(self.env); + } + if self.button(ui, idle && self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { + self.state.redo(self.env); + } + if self.button(ui, idle && !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { + self.state.ask_reset_all(); + } + } + // With sets, Summarize… is in the set row, which a read-only dialog does not show. + if (self.env.sets.is_none() || !editable) && self.env.options.summary { + let label = self.ui("Summarize…"); + if self.button(ui, true, &label, "shortcuts.summarize") { + self.state.summarize(self.env); + } + } + let gap = ui.spacing().item_spacing.x; + let width: f32 = right.iter().map(|(t, _)| self.button_width(ui, t) + gap).sum(); + ui.add_space((ui.available_width() - width).max(0.0)); + for (text, id) in &right { + // OK waits for the open prompt or review; Cancel and Close never do. + if self.button(ui, idle || *id != "shortcuts.ok", text, id) { + if editable && *id == "shortcuts.ok" { + self.state.ok(self.env); + } else { + self.state.cancel(self.env); + } + } + } + }); + } +} + +/// Separator between menu path parts, mirrored by [`Draw::ordered`]. +const PATH_SEP: &str = " › "; + +/// Places rectangles laid out left to right inside a span, mirrored in a right-to-left interface. +#[derive(Clone, Copy)] +struct Mirror { + on: bool, + /// The span's left plus right edge. + sum: f32, +} + +impl Mirror { + fn rect(self, r: Rect) -> Rect { + if self.on { Rect::from_x_y_ranges((self.sum - r.max.x)..=(self.sum - r.min.x), r.y_range()) } else { r } + } + + /// The left edge of something `width` wide whose left-to-right left edge is `x`. + fn x(self, x: f32, width: f32) -> f32 { + if self.on { self.sum - x - width } else { x } + } +} + +// ---- Icons, drawn so they do not depend on the app's fonts ---- + +fn square(r: Rect, f: f32) -> Rect { + Rect::from_center_size(r.center(), Vec2::splat(r.width().min(r.height()) * f)) +} + +fn paint_plus(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.5); + let s = Stroke::new(1.5, c); + p.line_segment([r.center_top(), r.center_bottom()], s); + p.line_segment([r.left_center(), r.right_center()], s); +} + +fn paint_cross(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.45); + let s = Stroke::new(1.5, c); + p.line_segment([r.left_top(), r.right_bottom()], s); + p.line_segment([r.right_top(), r.left_bottom()], s); +} + +fn paint_reset(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.55); + let (center, radius) = (r.center(), r.width() / 2.0); + let s = Stroke::new(1.5, c); + let at = |a: f32| center + radius * Vec2::angled(a); + let start = -std::f32::consts::FRAC_PI_2; + let points: Vec<_> = (0..=12).map(|i| at(start + i as f32 / 12.0 * 1.6 * std::f32::consts::PI)).collect(); + p.add(egui::Shape::line(points, s)); + let tip = at(start); + p.line_segment([tip, tip + vec2(radius * 0.6, -radius * 0.35)], s); + p.line_segment([tip, tip + vec2(radius * 0.5, radius * 0.45)], s); +} + +fn paint_keyboard(p: &Painter, r: Rect, c: Color32) { + let r = Rect::from_center_size(r.center(), vec2(r.width() * 0.7, r.height() * 0.5)); + p.rect_stroke(r, 2.0, Stroke::new(1.2, c), StrokeKind::Inside); + for row in 0..2 { + for col in 0..4 { + let x = r.min.x + r.width() * (col as f32 + 0.75) / 4.5; + let y = r.min.y + r.height() * (row as f32 + 1.0) / 4.0; + p.circle_filled(pos2(x, y), 0.9, c); + } + } + p.line_segment( + [pos2(r.min.x + r.width() * 0.3, r.max.y - r.height() * 0.22), pos2(r.max.x - r.width() * 0.3, r.max.y - r.height() * 0.22)], + Stroke::new(1.2, c), + ); +} diff --git a/crates/keymap/tests/chord.rs b/crates/keymap/tests/chord.rs new file mode 100644 index 00000000..4a8440d7 --- /dev/null +++ b/crates/keymap/tests/chord.rs @@ -0,0 +1,196 @@ +use craft_keymap::chord::foreign::{Flavor, ForeignError, ReadNote, read_foreign}; +use craft_keymap::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn mods(cmd: bool, ctrl: bool, alt: bool, shift: bool) -> Mods { + Mods { cmd, ctrl, alt, shift } +} + +#[test] +fn canonical_text_round_trips_for_every_key_and_modifier_set() { + for &key in Key::ALL { + for bits in 0..16u8 { + let c = Chord::new(mods(bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0), key); + let text = c.to_string(); + assert_eq!(Chord::parse(&text), Ok(c), "{text}"); + assert_eq!(Trigger::parse(&format!("{text} {text}")), Ok(Trigger::double(c)), "{text} double"); + let json = serde_json::to_string(&Trigger::single(c)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), Trigger::single(c)); + // macOS display text reads back as the same chord. + assert_eq!(Chord::parse(&c.display(Platform::Mac)), Ok(c), "{}", c.display(Platform::Mac)); + } + } + assert_eq!(chord("Cmd+Ctrl+Alt+Shift+K").to_string(), "Cmd+Ctrl+Alt+Shift+K"); + assert_eq!(serde_json::to_string(&chord("shift+cmd+k")).unwrap(), "\"Cmd+Shift+K\""); +} + +#[test] +fn plus_and_minus_keys_parse_from_their_glyphs() { + assert_eq!(Key::parse("+"), Some(Key::Plus)); + assert_eq!(Key::parse("-"), Some(Key::Minus)); + assert_eq!(Key::parse("−"), Some(Key::Minus), "U+2212"); +} + +#[test] +fn arrow_glyphs_parse_as_arrow_keys() { + for (glyph, key) in [("←", Key::Left), ("→", Key::Right), ("↑", Key::Up), ("↓", Key::Down)] { + assert_eq!(Key::parse(glyph), Some(key), "{glyph}"); + } + assert_eq!(Chord::parse("⌘←").unwrap().to_string(), "Cmd+Left"); + assert_eq!(Chord::parse("Alt+Shift+→").unwrap().to_string(), "Alt+Shift+Right"); +} + +#[test] +fn aliases_parse_to_canonical_chords() { + let cases = [ + ("cmd+shift+z", "Cmd+Shift+Z"), + ("Shift+Command+Z", "Cmd+Shift+Z"), + ("meta+z", "Cmd+Z"), + ("Super+Z", "Cmd+Z"), + ("mod+z", "Cmd+Z"), + ("⌘⇧Z", "Cmd+Shift+Z"), + ("⌃⌥⇧⌘K", "Cmd+Ctrl+Alt+Shift+K"), + ("⌘+K", "Cmd+K"), + ("⌘Shift+K", "Cmd+Shift+K"), + ("control+opt+left", "Ctrl+Alt+Left"), + ("ctl+option+ArrowUp", "Ctrl+Alt+Up"), + // craft-menu writes Ctrl before Cmd. + ("Ctrl+Cmd+Alt+Shift+M", "Cmd+Ctrl+Alt+Shift+M"), + ("Ctrl+Cmd+F", "Cmd+Ctrl+F"), + ("Cmd++", "Cmd++"), + ("⌘+", "Cmd++"), + ("+", "+"), + ("Cmd+Plus", "Cmd++"), + ("cmd+Equals", "Cmd+="), + ("Cmd+-", "Cmd+-"), + ("Cmd+−", "Cmd+-"), + ("⌘−", "Cmd+-"), + ("Cmd + K", "Cmd+K"), + ("esc", "Escape"), + ("return", "Enter"), + ("del", "Delete"), + ("pgup", "PageUp"), + ("pgdn", "PageDown"), + ("arrowdown", "Down"), + ("spacebar", "Space"), + ("ins", "Insert"), + ("f12", "F12"), + ("Num5", "5"), + ("Semicolon", ";"), + ("Questionmark", "?"), + ("OpenCurlyBracket", "{"), + ("Backtick", "`"), + ("⌘⌫", "Cmd+Backspace"), + ]; + for (text, canonical) in cases { + assert_eq!(chord(text).to_string(), canonical, "{text}"); + } +} + +#[test] +fn hostile_text_is_refused() { + let long = "Cmd+".repeat(MAX_CHORD_TEXT / 4) + "K"; + assert!(long.len() > MAX_CHORD_TEXT); + let cases: &[(&str, ParseError)] = &[ + ("", ParseError::Empty), + (" ", ParseError::Empty), + (&long, ParseError::TooLong), + ("+++", ParseError::UnknownModifier("+".into())), + ("++", ParseError::MissingKey), + ("Cmd+", ParseError::MissingKey), + ("Cmd", ParseError::MissingKey), + ("⌘", ParseError::MissingKey), + ("Shift+Alt", ParseError::MissingKey), + ("Cmd+Command+K", ParseError::DuplicateModifier("Command".into())), + ("⌘⌘K", ParseError::DuplicateModifier("⌘".into())), + ("Cmd+é", ParseError::UnknownKey("é".into())), + ("Ctrl+ж+K", ParseError::UnknownModifier("ж".into())), + ("Hyper+K", ParseError::UnknownModifier("Hyper".into())), + ("A B", ParseError::Sequence), + ("Cmd+K Cmd+J", ParseError::Sequence), + ("A A A", ParseError::UnknownKey("A A A".into())), + ]; + for (text, err) in cases { + assert_eq!(Trigger::parse(text).as_ref(), Err(err), "{text:?}"); + } + let wide = "⌘".repeat(30); + assert_eq!(Chord::parse(&wide), Err(ParseError::TooLong)); + assert!(serde_json::from_str::("\"Cmd+Nope\"").is_err()); +} + +#[test] +fn display_follows_the_platform() { + let c = chord("Cmd+Ctrl+Alt+Shift+K"); + assert_eq!(c.display(Platform::Mac), "⌃⌥⇧⌘K"); + assert_eq!(c.display(Platform::Windows), "Ctrl+Alt+Shift+K"); + assert_eq!(chord("Ctrl+Up").display(Platform::Mac), "⌃↑"); + assert_eq!(chord("Ctrl+Up").display(Platform::Linux), "Ctrl+Up"); + assert_eq!(chord("Cmd+Enter").display(Platform::Mac), "⌘⏎"); + assert_eq!(chord("Shift+Tab").display(Platform::Mac), "⇧⇥"); + assert_eq!(chord("Space").display(Platform::Mac), "Space"); + assert_eq!(chord("Alt+PageDown").display(Platform::Windows), "Alt+Page Down"); + assert_eq!(Trigger::parse("Cmd+K Cmd+K").unwrap().display(Platform::Mac), "⌘K ⌘K"); + assert_eq!(Trigger::parse("A A").unwrap().to_string(), "A A"); +} + +#[test] +fn ctrl_folds_into_cmd_off_macos() { + let ctrl = chord("Ctrl+S"); + let cmd = chord("Cmd+S"); + assert_ne!(ctrl.effective(Platform::Mac), cmd.effective(Platform::Mac)); + for p in [Platform::Windows, Platform::Linux] { + assert_eq!(ctrl.effective(p), cmd); + assert_eq!(chord("Cmd+Ctrl+S").effective(p), cmd); + } + assert!(ctrl.has_command() && cmd.has_command()); + assert!(chord("Shift+B").is_plain() && !chord("Alt+B").is_plain()); + assert!(chord("Shift+F35").is_function_key() && !chord("F").is_function_key()); +} + +#[test] +fn foreign_windows_text() { + let read = |t: &str| read_foreign(t, Flavor::Windows); + let ok = |t: &str| read(t).unwrap_or_else(|e| panic!("{t}: {e}")); + assert_eq!(ok("Shift+Ctrl+S"), (Trigger::single(chord("Cmd+Shift+S")), vec![])); + assert_eq!(ok("Ctrl+,").0, Trigger::single(chord("Cmd+,"))); + assert_eq!(ok(",").0, Trigger::single(chord(","))); + assert_eq!(ok("Ctrl++").0, Trigger::single(chord("Cmd++"))); + assert_eq!(ok("Ctrl+-").0, Trigger::single(chord("Cmd+-"))); + assert_eq!(ok("Alt-F4").0, Trigger::single(chord("Alt+F4"))); + assert_eq!(ok("Ctrl + S").0, Trigger::single(chord("Cmd+S"))); + assert_eq!(ok("Shift + Ctrl + S").0, Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("Ctrl + +").0, Trigger::single(chord("Cmd++"))); + assert_eq!(ok("Alt - F4").0, Trigger::single(chord("Alt+F4"))); + assert_eq!(ok("Ctrl Page Down").0, Trigger::single(chord("Cmd+PageDown"))); + assert_eq!(ok("Shift+Up Arrow").0, Trigger::single(chord("Shift+Up"))); + assert_eq!(ok("Num +"), (Trigger::single(chord("+")), vec![ReadNote::Numpad])); + assert_eq!(ok("Ctrl+Num /"), (Trigger::single(chord("Cmd+/")), vec![ReadNote::Numpad])); + assert_eq!(ok("Numpad 5"), (Trigger::single(chord("5")), vec![ReadNote::Numpad])); + assert_eq!(ok("Num Enter"), (Trigger::single(chord("Enter")), vec![ReadNote::Numpad])); + assert_eq!(read("Win+E"), Err(ForeignError::UnsupportedModifier("Win".into()))); + assert_eq!(read("Meta+E"), Err(ForeignError::UnsupportedModifier("Meta".into()))); + assert_eq!(read(""), Err(ForeignError::Empty)); + assert_eq!(read(&"x".repeat(MAX_CHORD_TEXT + 1)), Err(ForeignError::TooLong)); + assert_eq!(read("Ctrl+Num Lock"), Err(ForeignError::UnknownKey("Num Lock".into()))); + assert_eq!(read("Alt"), Err(ForeignError::UnknownKey("Alt".into()))); +} + +#[test] +fn foreign_mac_text() { + let texts = ["Shift+Cmd+S", "Ctrl+Opt+Left"]; + assert_eq!(Flavor::detect(texts), Flavor::Mac); + assert_eq!(Flavor::detect(["Shift+Ctrl+S", "Alt+F4"]), Flavor::Windows); + assert_eq!(Flavor::detect(["⌥⌘K"]), Flavor::Mac); + let ok = |t: &str| read_foreign(t, Flavor::Mac).unwrap_or_else(|e| panic!("{t}: {e}")).0; + assert_eq!(ok("Shift+Cmd+S"), Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("Ctrl+Opt+Left"), Trigger::single(chord("Ctrl+Alt+Left"))); + assert_eq!(ok("Control-Option-Space"), Trigger::single(chord("Ctrl+Alt+Space"))); + assert_eq!(ok("⇧⌘S"), Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("⌃⌥⇞"), Trigger::single(chord("Ctrl+Alt+PageUp"))); + assert_eq!(ok("Cmd+,"), Trigger::single(chord("Cmd+,"))); + // In a Windows set the same ⌃ is the command modifier. + assert_eq!(read_foreign("⌃S", Flavor::Windows).map(|r| r.0), Ok(Trigger::single(chord("Cmd+S")))); +} diff --git a/crates/keymap/tests/editor.rs b/crates/keymap/tests/editor.rs new file mode 100644 index 00000000..02a44052 --- /dev/null +++ b/crates/keymap/tests/editor.rs @@ -0,0 +1,200 @@ +use craft_keymap::editor::{MAX_UNDO_BYTES, holders, search}; +use craft_keymap::{ + AssignError, Bindable, Binding, Chord, CommandRef, DOUBLE_PRESS, EditSession, Holder, Keymap, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, + MAX_UNDO, Overrides, Platform, Policy, Recorded, Recorder, Scope, Scopes, Trigger, +}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap(platform: Platform) -> Keymap { + let catalog = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_default(b("Cmd+S")).with_path(vec!["File".into(), "Save".into()]), + Bindable::new("edit.copy", "Copy", Scope::app()).with_default(b("Cmd+C")).with_alias("Kopieren"), + Bindable::new("view.zoomIn", "Zoom In", Scope::app()).with_default(b("Cmd+=")), + Bindable::new("tool.brush", "Brush", Scope::app()).with_default(b("B")).with_path(vec!["Tools".into(), "Paint".into()]), + ]; + Keymap::new(catalog, Scopes::new(), platform) +} + +fn keys(km: &Keymap, id: &str) -> Vec { + km.bindings(&CommandRef::new(id)).iter().map(|b| b.to_string()).collect() +} + +#[test] +fn edits_undo_redo_and_cancel() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + + let assigned = session.edit(&mut km, |km| km.assign(&save, b("Cmd+Shift+S"), Policy::Steal)); + assert!(assigned.is_ok()); + session.edit(&mut km, |km| km.clear(&CommandRef::new("edit.copy"))); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + assert!(keys(&km, "edit.copy").is_empty()); + assert!(session.is_modified(&km)); + + // An edit that changes nothing is no undo step. + session.edit(&mut km, |km| km.reset(&CommandRef::new("tool.brush"))); + + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert!(!session.undo(&mut km)); + assert!(!session.is_modified(&km)); + + assert!(session.redo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + // A new edit drops what could be redone. + session.edit(&mut km, |km| km.clear(&CommandRef::new("tool.brush"))); + assert!(!session.redo(&mut km)); + + session.cancel(&mut km); + assert!(!session.is_modified(&km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert_eq!(keys(&km, "tool.brush"), ["B"]); + assert!(!session.can_undo()); +} + +#[test] +fn undo_keeps_the_newest_steps() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let brush = CommandRef::new("tool.brush"); + // Each edit changes the key: N, B, N, … (B is the default). + let edits = MAX_UNDO + 11; + for i in 0..edits { + let key = if i % 2 == 0 { "N" } else { "B" }; + assert!(session.edit(&mut km, |km| km.set_bindings(&brush, vec![b(key)], Policy::Keep)).is_ok()); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert_eq!(steps, MAX_UNDO); + // The 11 oldest steps are gone: undo stops after the 11th edit, which set N. + assert_eq!(keys(&km, "tool.brush"), ["N"]); + assert!(session.is_modified(&km)); +} + +#[test] +fn undo_history_is_capped_by_size() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + // Full-size snapshots of entries for commands the catalog does not have. + let big = |tag: char| { + let mut o = Overrides::new(); + for n in 0..MAX_COMMANDS { + assert!(o.insert(format!("junk.{n}"), vec![tag.to_string().repeat(MAX_BINDING_TEXT); MAX_BINDINGS])); + } + o + }; + let (a, b) = (big('a'), big('b')); + let string = size_of::(); + let per = a.iter().map(|(k, t)| string + 64 + k.len() + t.iter().map(|t| string + 16 + t.len()).sum::()).sum::(); + let edits = 2 * MAX_UNDO_BYTES / per + 4; + for n in 0..edits { + let next = if n % 2 == 0 { a.clone() } else { b.clone() }; + session.edit(&mut km, |km| km.set_overrides(next)); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert!(steps >= 2 && steps <= MAX_UNDO_BYTES / per, "{steps} steps of {per} bytes"); +} + +#[test] +fn recorder_turns_presses_into_triggers() { + let mut r = Recorder::new(); + let a = chord("A"); + assert_eq!(r.press(a, 1.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 1.0 + DOUBLE_PRESS / 2.0), Recorded::Double(Trigger::double(a))); + // The press after a double starts over. + assert_eq!(r.press(a, 1.4), Recorded::Single(Trigger::single(a))); + // Too late for a double. + assert_eq!(r.press(a, 1.4 + DOUBLE_PRESS + 0.1), Recorded::Single(Trigger::single(a))); + // Another chord in between. + assert_eq!(r.press(chord("B"), 3.0), Recorded::Single(Trigger::single(chord("B")))); + assert_eq!(r.press(a, 3.1), Recorded::Single(Trigger::single(a))); + + assert_eq!(r.press(chord("Escape"), 3.2), Recorded::Cancel); + let shift_escape = chord("Shift+Escape"); + assert_eq!(r.press(shift_escape, 3.3), Recorded::Single(Trigger::single(shift_escape))); + // Two presses in one egui frame share its time. + assert_eq!(r.press(a, 4.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 4.0), Recorded::Double(Trigger::double(a))); +} + +#[test] +fn search_reads_labels_aliases_paths_ids_and_keys() { + let ids = |km: &Keymap, q: &str, p: Platform| search(km, q, p).iter().map(|b| b.command.id.clone()).collect::>(); + let win = keymap(Platform::Windows); + let mac = keymap(Platform::Mac); + assert_eq!(ids(&win, "zoom", Platform::Windows), ["view.zoomIn"]); + assert_eq!(ids(&win, "KOPIEREN", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&win, "paint", Platform::Windows), ["tool.brush"]); + assert_eq!(ids(&win, "file.sa", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "ctrl+s", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "cmd+c", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&mac, "⌘s", Platform::Mac), ["file.save"]); + assert_eq!(ids(&mac, "⌘s", Platform::Windows), Vec::::new()); + assert_eq!(ids(&mac, " ", Platform::Mac).len(), 4); +} + +#[test] +fn a_dialog_asks_before_stealing_and_undoes_each_answer() { + let mut km = keymap(Platform::Mac); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + let copy = CommandRef::new("edit.copy"); + let held = vec![Holder { command: copy.clone(), binding: b("Cmd+C") }]; + + // While recording: who else has the key. + assert_eq!(holders(&km, &save, &b("Cmd+C")), held); + assert!(holders(&km, ©, &b("Cmd+C")).is_empty(), "its own key"); + assert_eq!(session.assign(&mut km, &save, b("Cmd+C"), Policy::Refuse), Err(AssignError::Taken(held.clone()))); + assert!(!session.can_undo(), "a refused assignment changes nothing"); + + // Keep: both hold it. + let kept = session.assign(&mut km, &save, b("Cmd+C"), Policy::Keep).unwrap(); + assert_eq!(kept.clashes, held); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + + // Steal: the other command loses it. + let stolen = session.assign(&mut km, &save, b("Cmd+C"), Policy::Steal).unwrap(); + assert_eq!(stolen.taken_from, held); + assert!(keys(&km, "edit.copy").is_empty()); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + + // Recording over one key of the list replaces it in place. + session.replace(&mut km, &save, &b("Cmd+S"), b("Cmd+Shift+S"), Policy::Refuse).unwrap(); + assert_eq!(keys(&km, "file.save"), ["Cmd+Shift+S", "Cmd+C"]); + assert!(session.remove(&mut km, &save, &b("Cmd+C"))); + assert!(session.clear(&mut km, &CommandRef::new("tool.brush"))); + + assert!(session.reset(&mut km, &save)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + session.reset_all(&mut km); + assert!(km.overrides().is_empty()); + assert!(!session.is_modified(&km)); + + // One undo per action, back to the steal. + for _ in 0..5 { + assert!(session.undo(&mut km)); + } + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + assert!(session.undo(&mut km)); + assert!(!session.can_undo()); + + session.redo(&mut km); + session.cancel(&mut km); + assert!(km.overrides().is_empty(), "cancel restores the state at open"); +} diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs new file mode 100644 index 00000000..e5364518 --- /dev/null +++ b/crates/keymap/tests/egui.rs @@ -0,0 +1,573 @@ +#![cfg(feature = "egui")] + +use craft_keymap::egui::{Dispatcher, Hit, matches, platform}; +use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, KeyInput, Keymap, Kind, Layout, Mods, Platform, Scope, Scopes, Trigger}; +use egui::{Event, InputState, Key as EguiKey, Modifiers}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +#[test] +fn a_browser_on_a_mac_gets_the_mac_keymap() { + let ctx = egui::Context::default(); + ctx.set_os(egui::os::OperatingSystem::from_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15")); + assert_eq!(platform(&ctx), Platform::Mac); + ctx.set_os(egui::os::OperatingSystem::from_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")); + assert_eq!(platform(&ctx), Platform::Windows); +} + +#[test] +fn every_key_converts_both_ways() { + for &key in Key::ALL { + assert_eq!(Key::from_egui(key.to_egui()), Some(key), "{key}"); + } + for k in [EguiKey::Copy, EguiKey::Cut, EguiKey::Paste, EguiKey::BrowserBack, EguiKey::ShiftLeft, EguiKey::IntlBackslash] { + assert_eq!(Key::from_egui(k), None); + } +} + +#[test] +fn modifiers_and_shortcuts_follow_the_platform() { + let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + assert_eq!(Mods::from_egui(&ctrl, Platform::Windows), Mods::CMD); + assert_eq!(Mods::from_egui(&Modifiers::CTRL, Platform::Mac), Mods::CTRL); + assert_eq!(Mods::from_egui(&Modifiers::MAC_CMD, Platform::Mac), Mods::CMD); + + assert!(matches(&chord("Ctrl+S"), EguiKey::S, &ctrl, Platform::Windows)); + assert!(matches(&chord("Cmd+S"), EguiKey::S, &ctrl, Platform::Windows)); + assert!(!matches(&chord("Cmd+S"), EguiKey::S, &Modifiers::CTRL, Platform::Mac)); + + let shortcut = chord("Cmd+Shift+Z").to_egui_shortcut(); + assert_eq!(shortcut.logical_key, EguiKey::Z); + assert!(Modifiers { mac_cmd: true, command: true, shift: true, ..Modifiers::NONE }.matches_exact(shortcut.modifiers)); +} + +#[test] +fn shifted_symbols_match_their_bindings() { + let p = Platform::Windows; + let shift = Modifiers::SHIFT; + let none = Modifiers::NONE; + let cmd = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + let cmd_shift = Modifiers { shift: true, ..cmd }; + + // Shift+base matches the shifted key egui reports, but the base key alone does not. + assert!(matches(&chord("Shift+;"), EguiKey::Colon, &shift, p)); + assert!(!matches(&chord("Shift+;"), EguiKey::Semicolon, &none, p)); + assert!(!matches(&chord(";"), EguiKey::Colon, &shift, p)); + assert!(matches(&chord("Shift+/"), EguiKey::Questionmark, &shift, p)); + assert!(matches(&chord("Cmd+Shift+]"), EguiKey::CloseCurlyBracket, &cmd_shift, p)); + assert!(matches(&chord("Shift+1"), EguiKey::Exclamationmark, &shift, p)); + // A shifted symbol bound directly matches while Shift is held to type it. + assert!(matches(&chord("?"), EguiKey::Questionmark, &shift, p)); + assert!(matches(&chord(":"), EguiKey::Colon, &none, p)); + + // Zoom keys: `=` also matches `+`, with or without Shift; `+` matches Shift+`=`. + assert!(matches(&chord("Cmd+="), EguiKey::Plus, &cmd, p)); + assert!(matches(&chord("Cmd+="), EguiKey::Plus, &cmd_shift, p)); + assert!(matches(&chord("Cmd+="), EguiKey::Equals, &cmd, p)); + assert!(matches(&chord("Cmd++"), EguiKey::Equals, &cmd_shift, p)); + assert!(matches(&chord("Cmd++"), EguiKey::Plus, &cmd_shift, p)); + assert!(!matches(&chord("Cmd++"), EguiKey::Equals, &cmd, p)); + assert!(!matches(&chord("Cmd+="), EguiKey::Plus, &none, p)); + assert!(!matches(&chord("Cmd+="), EguiKey::Minus, &cmd, p)); +} + +const CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + +fn dispatch_keymap() -> Keymap { + let app = |id: &str, keys: &[&str]| keys.iter().fold(Bindable::new(id, id, Scope::app()), |e, k| e.with_default(Binding::parse(k).unwrap())); + let tool = |id: &str| app(id, &["M"]).with_kind(Kind::Tool { group: Some("select".into()) }); + let catalog = vec![ + app("edit.copy", &["Cmd+C", "Cmd+Insert"]), + app("edit.paste", &["Cmd+V", "Shift+Insert"]), + app("edit.pasteInPlace", &["Cmd+Shift+V"]), + app("edit.copyMerged", &["Cmd+Shift+C"]), + app("app.quit", &["Cmd+Alt+Q"]), + app("type.pageNumber", &["Cmd+Alt+Shift+N"]), + app("view.refresh", &["Cmd+Alt+F5"]), + app("file.save", &["Cmd+S"]), + app("view.zoomIn", &["="]), + app("help.find", &["Shift+;"]), + app("tool.brush", &["B"]), + app("app.loop", &["L"]), + Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(Binding::parse("L").unwrap()), + tool("tool.marquee"), + tool("tool.ellipse"), + app("reveal.one", &["U"]), + app("reveal.all", &["U U"]), + ]; + Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Windows) +} + +fn press(key: EguiKey, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers } +} + +fn frame(time: f64, modifiers: Modifiers, events: Vec) -> InputState { + let mut input = InputState::default(); + input.time = time; + input.modifiers = modifiers; + input.events = events; + input +} + +/// Dispatches one frame and returns the ids that ran and the events left. +fn run(d: &mut Dispatcher, km: &Keymap, active: &Active, mut input: InputState) -> (Vec, Vec) { + let hits = d.dispatch(&mut input, km, active); + (hits.into_iter().map(|h| h.command.id).collect(), input.events) +} + +#[test] +fn a_bound_press_runs_once_and_is_removed_with_its_text() { + let km = dispatch_keymap(); + let events = vec![ + press(EguiKey::B, Modifiers::NONE), + Event::Text("b".into()), + Event::Key { key: EguiKey::B, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + press(EguiKey::Q, Modifiers::NONE), + Event::Text("q".into()), + ]; + let (ran, left) = run(&mut Dispatcher::new(), &km, &Active::NONE, frame(1.0, Modifiers::NONE, events)); + assert_eq!(ran, ["tool.brush"]); + // The release and the unbound Q with its text stay. + assert_eq!(left.len(), 3); + assert!(matches!(left[0], Event::Key { key: EguiKey::B, pressed: false, .. })); + assert!(matches!(left[1], Event::Key { key: EguiKey::Q, pressed: true, .. })); + assert_eq!(left[2], Event::Text("q".into())); +} + +#[test] +fn shifted_symbols_and_zoom_keys_reach_their_bindings() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let shift = Modifiers::SHIFT; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, shift, vec![press(EguiKey::Colon, shift), Event::Text(":".into())])); + assert_eq!(ran, ["help.find"]); + assert!(left.is_empty()); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, Modifiers::NONE, vec![press(EguiKey::Plus, Modifiers::NONE)])); + assert_eq!(ran, ["view.zoomIn"]); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, shift, vec![press(EguiKey::Plus, shift)])); + assert_eq!(ran, ["view.zoomIn"]); +} + +#[test] +fn clipboard_events_stay_with_the_app_by_default() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + // egui-winit sends these in place of the Ctrl+C and Ctrl+V presses. + let events = vec![Event::Copy, Event::Paste("text".into())]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + // The web build sends the press and then the event: both stay. + let events = vec![press(EguiKey::C, CTRL), Event::Copy, press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, events.clone())); + assert_eq!(ran, ["file.save"]); + assert_eq!(left, events[..2]); + // A clipboard press with no clipboard event after it is an ordinary press. + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, CTRL, vec![press(EguiKey::Insert, CTRL)])); + assert_eq!(ran, ["edit.copy"]); +} + +#[test] +fn clipboard_chords_with_more_modifiers_run_their_bindings() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + let ctrl_shift = Modifiers { shift: true, ..CTRL }; + // The desktop: egui-winit sends the Paste event in place of Ctrl+Shift+V. + let mut input = frame(1.0, ctrl_shift, vec![Event::Paste("text".into())]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.pasteInPlace", Some("text"))]); + assert!(input.events.is_empty()); + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, ctrl_shift, vec![Event::Copy])); + assert_eq!(ran, ["edit.copyMerged"]); + assert!(left.is_empty()); + // Ctrl+Shift+X has no binding: the event stays. + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(3.0, ctrl_shift, vec![Event::Cut])); + assert!(ran.is_empty()); + assert_eq!(left, [Event::Cut]); + + // The web: the press decides for itself and its event, also while typing. + for (time, active) in [(4.0, &Active::NONE), (5.0, &typing)] { + let events = vec![press(EguiKey::C, ctrl_shift), Event::Copy]; + let (ran, left) = run(&mut d, &km, active, frame(time, ctrl_shift, events)); + assert_eq!(ran, ["edit.copyMerged"]); + assert!(left.is_empty(), "{left:?}"); + } + let events = vec![press(EguiKey::X, ctrl_shift), Event::Cut]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(6.0, ctrl_shift, events.clone())); + assert!(ran.is_empty()); + assert_eq!(left, events); +} + +#[test] +fn clipboard_events_run_their_chords_outside_text_fields_when_turned_on() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + let mut input = frame(1.0, CTRL, vec![Event::Copy, Event::Paste("text".into())]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.copy", None), ("edit.paste", Some("text"))]); + assert!(input.events.is_empty()); + + let typing = Active { typing: true, ..Active::NONE }; + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, CTRL, vec![Event::Copy])); + assert!(ran.is_empty()); + assert_eq!(left, [Event::Copy]); + + // The web build sends the key press and the clipboard event it causes: one run each. + for (time, key, mods, event, id) in [ + (3.0, EguiKey::C, CTRL, Event::Copy, "edit.copy"), + (4.0, EguiKey::Insert, CTRL, Event::Copy, "edit.copy"), + (5.0, EguiKey::Insert, Modifiers::SHIFT, Event::Paste("text".into()), "edit.paste"), + ] { + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(time, mods, vec![press(key, mods), event])); + assert_eq!(ran, [id], "{key:?}"); + } +} + +#[test] +fn a_web_paste_carries_its_text_and_takes_its_event() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + // The web build sends the Cmd+V press and then the Paste event with the clipboard text. + let events = vec![press(EguiKey::V, CTRL), Event::Paste("text".into()), press(EguiKey::C, CTRL), Event::Copy]; + let mut input = frame(1.0, CTRL, events.clone()); + let mut seen = Vec::new(); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| { + seen.push(hit.pasted.clone()); + true + }); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.paste", Some("text")), ("edit.copy", None)]); + assert_eq!(seen, [Some("text".to_string()), None], "the filter sees the pasted text"); + assert!(input.events.is_empty(), "{:?}", input.events); + + // A declined press leaves the press and its event in place, and the event does not run again. + let mut input = frame(2.0, CTRL, events.clone()); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| hit.command.id != "edit.paste"); + let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect(); + assert_eq!(ran, ["edit.copy"]); + assert_eq!(input.events, events[..2]); +} + +#[test] +fn a_held_web_paste_runs_once_when_repeats_are_off() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + let repeat = Event::Key { key: EguiKey::V, physical_key: Some(EguiKey::V), pressed: true, repeat: true, modifiers: CTRL }; + let paste = || Event::Paste("text".into()); + let events = vec![press(EguiKey::V, CTRL), paste(), repeat.clone(), paste()]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, events)); + assert_eq!(ran, ["edit.paste"]); + assert_eq!(left, [repeat, paste()], "the repeat and its event stay in egui's input"); +} + +#[test] +fn declined_hits_leave_their_events_in_order() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let shift = Modifiers::SHIFT; + let events = vec![ + press(EguiKey::B, Modifiers::NONE), + Event::Text("b".into()), + press(EguiKey::S, CTRL), + press(EguiKey::Colon, shift), + Event::Text(":".into()), + press(EguiKey::Equals, Modifiers::NONE), + ]; + let mut input = frame(1.0, Modifiers::NONE, events.clone()); + let mut seen = Vec::new(); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| { + seen.push((hit.command.id.clone(), hit.trigger.chord.to_string(), hit.exact)); + hit.command.id != "file.save" && hit.command.id != "tool.brush" + }); + let s = |id: &str, chord: &str, exact| (id.to_string(), chord.to_string(), exact); + assert_eq!(seen, [s("tool.brush", "B", true), s("file.save", "Cmd+S", true), s("help.find", "Shift+;", false), s("view.zoomIn", "=", true)]); + let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect(); + assert_eq!(ran, ["help.find", "view.zoomIn"]); + assert_eq!(input.events, events[..3], "the declined B keeps its text, and Ctrl+S stays in its place"); +} + +#[test] +fn clipboard_presses_stay_with_the_text_field() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // The web build's Cmd+C press and its Copy event both reach the text field. + let events = vec![press(EguiKey::C, CTRL), Event::Copy, press(EguiKey::Insert, CTRL)]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, CTRL, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, vec![press(EguiKey::Insert, CTRL)])); + assert_eq!(ran, ["edit.copy"]); +} + +#[test] +fn a_press_that_types_text_stays_with_the_text_field() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // A press the web build's text agent follows with the text it typed. + let events = vec![press(EguiKey::B, Modifiers::NONE), Event::Text("b".into())]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, Modifiers::NONE, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); +} + +#[test] +fn ctrl_alt_is_left_to_the_text_field_only_when_it_types() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // AltGr+Q on a German layout types `@`: the browser on Windows reports Ctrl+Alt+Q and the text + // agent sends the text after it. + let alt_gr = Modifiers { alt: true, ..CTRL }; + let events = vec![press(EguiKey::Q, alt_gr), Event::Text("@".into())]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, alt_gr, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + // A real Ctrl+Alt chord types nothing, so it runs while typing. + let all = Modifiers { shift: true, ..alt_gr }; + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, all, vec![press(EguiKey::N, all)])); + assert_eq!(ran, ["type.pageNumber"]); + assert!(left.is_empty()); + let (ran, _) = run(&mut d, &km, &typing, frame(3.0, alt_gr, vec![press(EguiKey::Q, alt_gr)])); + assert_eq!(ran, ["app.quit"]); +} + +#[test] +fn typing_blocks_plain_keys_but_not_command_chords() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + let events = vec![press(EguiKey::B, Modifiers::NONE), Event::Text("b".into()), press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, CTRL, events)); + assert_eq!(ran, ["file.save"]); + assert_eq!(left.len(), 2); + assert_eq!(left[1], Event::Text("b".into())); +} + +#[test] +fn undo_goes_to_the_text_field_and_save_still_runs() { + let km = Keymap::new( + vec![ + Bindable::new("edit.undo", "Undo", Scope::app()).with_default(Binding::parse("Cmd+Z").unwrap()), + Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()), + ], + Scopes::new(), + Platform::Windows, + ); + let typing = Active { typing: true, ..Active::NONE }; + let events = vec![press(EguiKey::Z, CTRL), press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut Dispatcher::new(), &km, &typing, frame(1.0, CTRL, events)); + assert_eq!(ran, ["file.save"]); + assert_eq!(left, [press(EguiKey::Z, CTRL)]); +} + +#[test] +fn the_focused_panel_wins_and_tool_groups_cycle() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let l = || frame(1.0, Modifiers::NONE, vec![press(EguiKey::L, Modifiers::NONE)]); + assert_eq!(run(&mut d, &km, &Active::panel("Timeline"), l()).0, ["timeline.loop"]); + assert_eq!(run(&mut d, &km, &Active::NONE, l()).0, ["app.loop"]); + + let mut input = frame(2.0, Modifiers::NONE, vec![press(EguiKey::M, Modifiers::NONE)]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let tools = vec![CommandRef::new("tool.marquee"), CommandRef::new("tool.ellipse")]; + let trigger = Trigger::single(chord("M")); + let got: Vec<_> = hits.iter().map(|h: &Hit| (&h.command, &h.cycle, h.trigger, h.exact, h.pasted.as_deref())).collect(); + assert_eq!(got, [(&tools[0], &tools, trigger, true, None)]); +} + +#[test] +fn a_second_press_runs_the_double_binding() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let key = |d: &mut Dispatcher, k: EguiKey, t: f64| run(d, &km, &Active::NONE, frame(t, Modifiers::NONE, vec![press(k, Modifiers::NONE)])).0; + let u = |d: &mut Dispatcher, t: f64| key(d, EguiKey::U, t); + assert_eq!(u(&mut d, 1.0), ["reveal.one"]); + assert_eq!(u(&mut d, 1.3), ["reveal.all"]); + // The press after a double starts over. + assert_eq!(u(&mut d, 1.5), ["reveal.one"]); + assert_eq!(u(&mut d, 2.5), ["reveal.one"]); + // B in between breaks the pair. + key(&mut d, EguiKey::B, 2.6); + assert_eq!(u(&mut d, 2.7), ["reveal.one"]); +} + +#[test] +fn a_double_press_needs_both_presses_in_one_panel() { + let km = Keymap::new( + vec![ + Bindable::new("x.one", "One", Scope::new("X").unwrap()).with_default(Binding::parse("A").unwrap()), + Bindable::new("y.one", "One", Scope::new("Y").unwrap()).with_default(Binding::parse("A").unwrap()), + Bindable::new("y.two", "Two", Scope::new("Y").unwrap()).with_default(Binding::parse("A A").unwrap()), + ], + Scopes::new().with_panel("X").with_panel("Y").with_panel_fallthrough(false), + Platform::Windows, + ); + let mut d = Dispatcher::new(); + let a = |d: &mut Dispatcher, panel, t| run(d, &km, &Active::panel(panel), frame(t, Modifiers::NONE, vec![press(EguiKey::A, Modifiers::NONE)])).0; + assert_eq!(a(&mut d, "X", 1.0), ["x.one"]); + assert_eq!(a(&mut d, "Y", 1.2), ["y.one"]); + assert_eq!(a(&mut d, "Y", 1.4), ["y.two"]); +} + +#[test] +fn two_presses_in_one_frame_make_a_double() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let u = press(EguiKey::U, Modifiers::NONE); + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(4.0, Modifiers::NONE, vec![u.clone(), u])); + assert_eq!(ran, ["reveal.one", "reveal.all"]); + assert!(left.is_empty(), "{left:?}"); +} + +#[test] +fn key_repeats_run_only_when_turned_on() { + let km = Keymap::new( + vec![ + Bindable::new("nudge.right", "Nudge", Scope::app()).with_default(Binding::parse("Right").unwrap()), + Bindable::new("jump.right", "Jump", Scope::app()).with_default(Binding::parse("Right Right").unwrap()), + ], + Scopes::new(), + Platform::Windows, + ); + let held = |repeat: bool| Event::Key { key: EguiKey::ArrowRight, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }; + let events = || vec![held(false), held(true), held(true)]; + + let mut on = Dispatcher::new().with_repeat(true); + let (ran, left) = run(&mut on, &km, &Active::NONE, frame(1.0, Modifiers::NONE, events())); + assert_eq!(ran, ["nudge.right", "nudge.right", "nudge.right"]); + assert!(left.is_empty()); + // Repeats neither made nor broke a double: the next press within the window is one. + let (ran, _) = run(&mut on, &km, &Active::NONE, frame(1.3, Modifiers::NONE, vec![held(false)])); + assert_eq!(ran, ["jump.right"]); + + let (ran, left) = run(&mut Dispatcher::new(), &km, &Active::NONE, frame(1.0, Modifiers::NONE, events())); + assert_eq!(ran, ["nudge.right"]); + assert_eq!(left, [held(true), held(true)]); +} + +#[test] +fn the_dispatcher_reads_presses_by_the_layout() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new( + vec![app("edit.duplicate", "Cmd+Shift+D"), app("tab.next", "Cmd+Shift+]"), app("edit.comment", "Cmd+/")], + Scopes::new(), + Platform::Windows, + ); + let at = |key, physical, modifiers| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers }; + let ctrl_shift = Modifiers { shift: true, ..CTRL }; + + // Arabic 101: typing ي on the D key shows the layout is not Latin, so ⌃⇧D (which types ]) is + // Ctrl+Shift+D. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::D, EguiKey::D, Modifiers::NONE), Event::Text("ي".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + assert!(!d.layout().is_latin()); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); + assert_eq!(ran, ["edit.duplicate"]); + + // German: ⇧7 types /, so Ctrl+Shift+7 runs Ctrl+/; the ü key's label is learned from typing. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::OpenBracket, EguiKey::OpenBracket, Modifiers::NONE), Event::Text("ü".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + assert_eq!(chord("Cmd+[").display_for(Platform::Windows, d.layout()), "Ctrl+Ü"); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::Slash, EguiKey::Num7, ctrl_shift)])); + assert_eq!(ran, ["edit.comment"]); + + // German on egui-winit: AltGr+M types µ and AltGr+] types ~ with no modifiers reported, so + // presses while right Alt is held teach nothing and Ctrl+Z stays Ctrl+Z on the Y position. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::Plus, EguiKey::CloseBracket, Modifiers::NONE), Event::Text("+".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + let right_alt = + |pressed| Event::Key { key: EguiKey::AltRight, physical_key: Some(EguiKey::AltRight), pressed, repeat: false, modifiers: Modifiers::NONE }; + run(&mut d, &km, &Active::NONE, frame(2.0, Modifiers::NONE, vec![right_alt(true)])); + let alt_graph = vec![ + at(EguiKey::M, EguiKey::M, Modifiers::NONE), + Event::Text("µ".into()), + at(EguiKey::CloseBracket, EguiKey::CloseBracket, Modifiers::NONE), + Event::Text("~".into()), + right_alt(false), + ]; + run(&mut d, &km, &Active::NONE, frame(3.0, Modifiers::NONE, alt_graph)); + assert!(d.layout().is_latin()); + assert_eq!(d.layout().char_at(Key::CloseBracket), Some('+')); + assert_eq!(d.layout().resolve(&KeyInput::new(Mods::CMD).with_typed(Key::Z).with_physical(Key::Y)), Some(chord("Cmd+Z"))); + + // A layout the platform reports applies from the first press. + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false)); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); + assert_eq!(ran, ["edit.duplicate"]); +} + +#[test] +fn letters_follow_the_latin_layout_from_egui_winit_to_the_dispatcher() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new(vec![app("d", "Cmd+D"), app("e", "Cmd+E")], Scopes::new(), Platform::Linux); + let at = |key, modifiers| Event::Key { key, physical_key: Some(EguiKey::D), pressed: true, repeat: false, modifiers }; + + // The QWERTY D key, as the suite's egui-winit reports it: on a layout that is not Latin, the + // logical key is the Latin layout's letter (Dvorak types e there), and the text is Cyrillic. + // (layout, logical key, text typed, platform report, command Ctrl runs) + let cases = [ + ("Dvorak + Russian", EguiKey::E, "в", Some(false), "e"), + ("QWERTY + Russian", EguiKey::D, "в", Some(false), "d"), + ("Dvorak", EguiKey::E, "e", Some(true), "e"), + ("QWERTY", EguiKey::D, "d", Some(true), "d"), + ]; + for (name, key, text, latin, expected) in cases { + // The layout learned from typing. + let mut d = Dispatcher::new(); + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, vec![at(key, Modifiers::NONE), Event::Text(text.into())])); + assert_eq!(Some(d.layout().is_latin()), latin, "{name}"); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, vec![at(key, CTRL)])); + assert_eq!(ran, [expected], "{name}, learned"); + + // The layout the platform reports, before anything is typed. + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(latin == Some(true))); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![at(key, CTRL)])); + assert_eq!(ran, [expected], "{name}, reported"); + } +} + +/// `input` with the Latin layout characters the suite's egui-winit reports per physical key, set +/// through serde because crates.io egui has no such field; `None` there. +fn with_latin_layout_chars(mut input: InputState, chars: &[(EguiKey, char)]) -> Option { + let mut raw = serde_json::to_value(&input.raw).unwrap(); + raw.get("latin_layout_chars")?; + raw["latin_layout_chars"] = serde_json::to_value(chars).unwrap(); + input.raw = serde_json::from_value(raw).unwrap(); + Some(input) +} + +#[test] +fn a_key_without_a_latin_letter_runs_no_letter_binding() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new(vec![app("quit", "Cmd+Q"), app("quote", "Cmd+'"), app("j", "Cmd+J")], Scopes::new(), Platform::Linux); + let at = |key, physical| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: CTRL }; + + // Dvorak + Russian. The QWERTY Q key types й, and Dvorak ' there: egui-winit reports the key + // as ', and egui without a key for the character as its position, Q. The QWERTY C key types + // с, and Dvorak j. + let latin = [(EguiKey::Q, '\''), (EguiKey::C, 'j')]; + let cases = [(at(EguiKey::Quote, EguiKey::Q), "quote"), (at(EguiKey::Q, EguiKey::Q), "quote"), (at(EguiKey::J, EguiKey::C), "j")]; + for (press, expected) in cases { + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false)); + let Some(input) = with_latin_layout_chars(frame(1.0, CTRL, vec![press.clone()]), &latin) else { + // crates.io egui reports no Latin layout, so the position decides: Ctrl+Q. + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![at(EguiKey::Q, EguiKey::Q)])); + assert_eq!(ran, ["quit"]); + return; + }; + let (ran, _) = run(&mut d, &km, &Active::NONE, input); + assert_eq!(ran, [expected], "{press:?}"); + } +} diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs new file mode 100644 index 00000000..60a9f321 --- /dev/null +++ b/crates/keymap/tests/import.rs @@ -0,0 +1,838 @@ +//! Import of synthetic shortcut sets written for these tests. + +use std::collections::BTreeMap; + +use craft_keymap::chord::foreign::ReadNote; +use craft_keymap::chord::foreign::{Flavor, ForeignError}; +use craft_keymap::import::{ + Failed, FailedChange, MAX_IMPORT_ENTRIES, MAX_IMPORT_TEXT, MAX_KEYS_PER_ENTRY, MAX_SUGGESTIONS, NoChangeReason, SkipReason, Skipped, + UnmatchedReason, normalize_label, +}; +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportReport, Keymap, Kind, MAX_COMMANDS, + NoneDefined, Overrides, Platform, Scope, Scopes, Start, plan, reserved, +}; +use serde_json::json; + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn bindings(texts: &[&str]) -> Vec { + texts.iter().map(|t| b(t)).collect() +} + +fn command(id: impl Into, label: &str, path: &[&str], keys: &[&str]) -> Bindable { + let mut out = Bindable::new(id, label, Scope::app()); + if !path.is_empty() { + out = out.with_path(path.iter().map(|s| s.to_string()).collect()); + } + keys.iter().fold(out, |e, k| e.with_default(b(k))) +} + +fn panel(name: &str) -> CommandRef { + CommandRef::with_params("window.panel", json!({ "panel": name })) +} + +fn catalog() -> Vec { + vec![ + command("file.new", "Document…", &["File", "New", "Document…"], &["Cmd+N"]), + command("dialog.newDocument", "New Document Dialog", &[], &["Cmd+N"]), + command("file.close", "Close", &["File", "Close"], &["Cmd+W"]), + command("file.save", "Save", &["File", "Save"], &["Cmd+S"]), + command("file.exit", "Exit", &["File", "Exit"], &[]), + command("edit.paste", "Paste", &["Edit", "Paste"], &["Cmd+V"]), + command("edit.clear", "Clear", &["Edit", "Clear"], &[]), + command("edit.duplicate", "Duplicate", &["Edit", "Duplicate"], &["Cmd+Alt+Shift+D"]), + command("edit.checkSpelling", "Check Spelling…", &["Edit", "Proofing", "Check Spelling…"], &[]), + command("edit.pen", "Pen", &["Edit", "Preferences", "Pen"], &[]), + command("app.palette", "Command Palette", &[], &["Cmd+Alt+K"]), + command(panel("layers"), "Layers", &["Window", "Layers"], &["F7"]), + command(panel("pages"), "Pages", &["Window", "Pages"], &["F12"]), + command("view.guides", "Guides", &["View", "Grids & Guides", "Guides"], &["Cmd+;"]).toggle(), + command("view.goToPage", "Go to Page…", &["View", "Go to Page…"], &["Cmd+J"]), + command("view.zoomIn", "Zoom In", &["View", "Zoom In"], &["Cmd+="]), + command("object.textFrameOptions", "Text Frame Options…", &["Object", "Text Frame Options…"], &["Cmd+B"]), + command("type.break", "Break", &["Type", "Breaks", "Break"], &[]), + command("type.columnBreak", "Insert Break", &["Type", "Column", "Insert Break"], &[]), + command("type.frameBreak", "Insert Break", &["Type", "Frame", "Insert Break"], &[]), + command("type.bold", "Bold", &["Type", "Style", "Bold"], &[]), + command("type.italic", "Italic", &["Type", "Style", "Italic"], &[]), + command("tool.pen", "Pen Tool", &[], &["P"]).with_kind(Kind::Tool { group: None }).with_alias("Pen"), + command("tool.type", "Type Tool", &[], &["T"]).with_kind(Kind::Tool { group: None }), + command("scripts.run", "Run Script", &[], &[]), + ] +} + +fn keymap(platform: Platform) -> Keymap { + Keymap::new(catalog(), Scopes::new().with_context("Text"), platform).with_reserved(reserved::system(platform)) +} + +fn key(flavor: Flavor, context: &str, text: &str) -> ForeignKey { + ForeignKey::read(Some(context), text, flavor) +} + +fn entry(menu: Option<&str>, path: &[&str], keys: &[(&str, &str)], flavor: Flavor) -> ForeignEntry { + keys.iter().fold(ForeignEntry::new(menu, path.iter().copied()), |e, (c, t)| e.with_key(key(flavor, c, t))) +} + +fn win(menu: &str, path: &[&str], keys: &[(&str, &str)]) -> ForeignEntry { + entry(Some(menu), path, keys, Flavor::Windows) +} + +/// An InDesign-like Windows set, one entry per match rule or apply rule. +fn windows_set() -> ForeignSet { + let d = "Default"; + let entries = vec![ + win("File", &["New", "Document..."], &[(d, "Ctrl+N")]), + win("File", &["Close"], &[(d, "Ctrl+W"), (d, "Ctrl+F4")]), + win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]), + win("File", &["Exit"], &[(d, "Alt+F4")]), + win("Edit", &["Paste"], &[(d, "Ctrl+E")]), + win("Edit", &["Clear"], &[(d, "Ctrl+E")]), + win("Edit", &["Duplicate"], &[]), + win("Edit", &["Duplicate"], &[]), + win("Edit", &["Spelling", "Check Spelling..."], &[(d, "Ctrl+Alt+K"), ("Text", "F7")]), + win("Edit", &["Frobnicate Everything"], &[(d, "Ctrl+F12")]), + win("Edit", &["Never Heard Of It"], &[]), + win("Window", &["Layers"], &[(d, "F7")]), + win("Window", &["Pages"], &[(d, "Shift+F12")]), + win("View", &["Grids & Guides", "SHOW/HIDE Guides"], &[(d, "Ctrl+Alt+;")]), + win("Layout", &["Go To Page"], &[(d, "Ctrl+Alt+J")]), + win("Object", &["Text Frame", "Options"], &[(d, "Ctrl+Alt+B")]), + win("Type", &["Insert", "Break"], &[(d, "Ctrl+Alt+Enter")]), + win("Type", &["Insert Break"], &[(d, "Shift+Enter")]), + win("Type", &["Style", "Bold"], &[("Text", "Shift+Ctrl+B")]), + win("Type", &["Style", "Italic"], &[(d, "Ctrl+I"), ("Table", "Ctrl+Alt+I")]), + win("Tools", &["Pen"], &[(d, "Shift+P")]).tool(), + win("Tools", &["Zoom In"], &[(d, "Z")]).tool(), + entry(None, &["Zoom In"], &[(d, "Ctrl+Alt+I")], Flavor::Windows), + entry(None, &["Somewhere", "Zoom In"], &[(d, "Ctrl+F11")], Flavor::Windows), + win("Scripts", &["Frobnicate"], &[(d, "Ctrl+Alt+R")]), + ]; + ForeignSet::new(entries).with_name("My Set") +} + +fn numpad_set() -> ForeignSet { + ForeignSet::new(vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)]) +} + +fn windows_options() -> ImportOptions { + let mut remembered = BTreeMap::new(); + remembered.insert(ForeignEntry::new(Some("Scripts"), ["Frobnicate"]).remember_key(), CommandRef::new("scripts.run")); + ImportOptions { + contexts: vec![("default".into(), ContextRule::Home), ("TEXT".into(), ContextRule::HomeWithoutHomeEntry)], + remembered, + ..ImportOptions::default() + } +} + +fn import_windows() -> (Keymap, ImportReport) { + let mut km = keymap(Platform::Windows); + let report = plan(&windows_set(), &km, &windows_options()).apply(&mut km); + (km, report) +} + +fn has(km: &Keymap, id: impl Into, texts: &[&str]) { + let c = id.into(); + assert_eq!(km.bindings(&c), bindings(texts), "{}", c.key()); +} + +#[test] +fn labels_normalize_for_matching() { + assert_eq!(normalize_label("Check Spelling..."), "check spelling"); + assert_eq!(normalize_label("Check Spelling…"), "check spelling"); + assert_eq!(normalize_label("Grids && Guides"), "grids guides"); + assert_eq!(normalize_label("SHOW/HIDE Guides"), "show/hide guides"); + assert_eq!(normalize_label("Preferences: General…"), "preferences general"); + assert_eq!(normalize_label("ÄRGER"), "ärger"); +} + +#[test] +fn every_match_step_finds_its_command() { + let (km, r) = import_windows(); + assert_eq!(r.set.as_deref(), Some("My Set")); + // 1: full path, with `&`, case and spacing normalized, and Show/Hide dropped for a toggle. + has(&km, "view.guides", &["Cmd+Alt+;"]); + // 2: joined segments `Text Frame Options` in Object. + has(&km, "object.textFrameOptions", &["Cmd+Alt+B"]); + // 2 is ambiguous (two `Insert Break` in Type), 3 finds `Break`. + has(&km, "type.break", &["Cmd+Alt+Enter"]); + // 3: last segment in the menu, under another submenu. + has(&km, "edit.checkSpelling", &["Cmd+Alt+K"]); + // 4: the label anywhere; also with no menu for a one-segment path. + has(&km, "view.goToPage", &["Cmd+Alt+J"]); + has(&km, "view.zoomIn", &["Cmd+Alt+I"]); + // 0: a remembered answer. + has(&km, "scripts.run", &["Cmd+Alt+R"]); + // A command with parameters, by its path. + has(&km, panel("pages"), &["Shift+F12"]); + // A tool entry matches the tool, not the command with the same label. + has(&km, "tool.pen", &["Shift+P"]); + has(&km, "edit.pen", &[]); + + let unmatched: Vec<(&str, UnmatchedReason)> = r.unmatched.iter().map(|u| (u.item.as_str(), u.reason)).collect(); + assert_eq!( + unmatched, + [ + ("Edit > Frobnicate Everything", UnmatchedReason::NoMatch), + ("Type > Insert Break", UnmatchedReason::Ambiguous), + ("Type > Style > Italic", UnmatchedReason::Context), + ("Tools > Zoom In", UnmatchedReason::Tool), + ("Somewhere > Zoom In", UnmatchedReason::NoMatch), + ] + ); + assert_eq!(r.unmatched[2].keys, "Table: Ctrl+Alt+I"); + assert_eq!(r.unmatched_without_shortcut, 1); + assert_eq!(r.unreadable.len(), 1); + assert_eq!(r.unreadable[0].item, "File > Save"); + assert_eq!(r.unreadable[0].keys, "Default: Ctrl+Hyperspace"); + assert!(matches!(r.unreadable[0].reason, ForeignError::UnknownKey(_))); + assert!(r.suggestions.is_empty(), "not asked for"); + assert_eq!(r.truncated, 0); +} + +#[test] +fn the_set_wins_and_the_report_says_how() { + let (km, r) = import_windows(); + // Two keys for one command, both kept in order. + has(&km, "file.close", &["Cmd+W", "Cmd+F4"]); + // The Text context counts only without a Default key. + has(&km, "type.bold", &["Cmd+Shift+B"]); + has(&km, "type.italic", &["Cmd+I"]); + has(&km, panel("layers"), &["F7"]); + // A later entry takes a chord from an earlier one; the earlier command keeps what it had. + has(&km, "edit.paste", &["Cmd+V"]); + has(&km, "edit.clear", &["Cmd+E"]); + let skipped: Vec<_> = r.skipped.iter().map(|s| (s.command.id.as_str(), s.shortcut.as_str(), s.kept_by.clone(), s.reason)).collect(); + assert_eq!( + skipped, + [("file.exit", "Alt+F4", None, SkipReason::Reserved), ("edit.paste", "Cmd+E", Some(CommandRef::new("edit.clear")), SkipReason::Taken)] + ); + // Taken from a command outside the set; the twin keeps its shared default. + has(&km, "app.palette", &[]); + has(&km, "dialog.newDocument", &["Cmd+N"]); + let cleared: Vec<_> = r.conflicts_cleared.iter().map(|c| (c.command.id.as_str(), c.label.as_str(), c.shortcut.as_str())).collect(); + assert_eq!(cleared, [("app.palette", "Command Palette", "Cmd+Alt+K")]); + // None defined clears; an unreadable key leaves the command as it was. + has(&km, "edit.duplicate", &[]); + has(&km, "file.save", &["Cmd+S"]); + // Bindings equal to the defaults leave no override. + let o = km.overrides(); + assert_eq!(o.get("file.new"), None); + assert_eq!(o.get(&panel("layers").key()), None); + assert_eq!(o.get("edit.duplicate"), Some(&[][..])); + assert_eq!(o.get("file.close"), Some(&["Cmd+W".to_string(), "Cmd+F4".to_string()][..])); + + let changed: Vec<&str> = r.changed.iter().map(|c| c.command.id.as_str()).collect(); + assert_eq!( + changed, + [ + "file.close", + "edit.clear", + "edit.duplicate", + "edit.checkSpelling", + "window.panel", + "view.guides", + "view.goToPage", + "object.textFrameOptions", + "type.break", + "type.bold", + "type.italic", + "tool.pen", + "view.zoomIn", + "scripts.run", + ] + ); + assert_eq!(r.imported, changed.len()); + let close = &r.changed[0]; + assert_eq!((close.label.as_str(), close.item.as_str()), ("Close", "File > Close")); + assert_eq!((close.from.clone(), close.to.clone()), (vec!["Cmd+W".to_string()], vec!["Cmd+W".to_string(), "Cmd+F4".to_string()])); +} + +#[test] +fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { + let mut km = keymap(Platform::Mac); + let mac = |menu: &str, path: &[&str], keys: &[(&str, &str)]| entry(Some(menu), path, keys, Flavor::Mac); + let set = ForeignSet::new(vec![ + mac("File", &["Save"], &[("Default", "⇧⌘S")]), + mac("Edit", &["Paste"], &[("Default", "Cmd+Ctrl+V")]), + mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]), + mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]), + ]) + .with_name("Mac"); + let text = Scope::new("Text").unwrap(); + let options = ImportOptions { + contexts: vec![("Default".into(), ContextRule::Home), ("Text".into(), ContextRule::Scope(text))], + ..ImportOptions::default() + }; + let r = plan(&set, &km, &options).apply(&mut km); + has(&km, "file.save", &["Cmd+Shift+S"]); + has(&km, "edit.paste", &["Cmd+Ctrl+V"]); + has(&km, "type.italic", &["Text: Cmd+Shift+I"]); + has(&km, "type.bold", &["Cmd+B"]); + has(&km, "object.textFrameOptions", &[]); + assert_eq!(r.conflicts_cleared[0].command.id, "object.textFrameOptions"); + assert_eq!(r.unmatched.len(), 1); + assert_eq!((r.unmatched[0].keys.as_str(), r.unmatched[0].reason), ("Tables: Cmd+Opt+B", UnmatchedReason::Context)); + assert_eq!(r.changed.iter().find(|c| c.command.id == "type.italic").map(|c| c.to.clone()), Some(vec!["Text: Cmd+Shift+I".to_string()])); +} + +#[test] +fn start_and_none_defined_options() { + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])]); + let user = || { + let mut o = Overrides::new(); + o.insert("file.save", vec!["Cmd+Alt+S".into()]); + o + }; + let options = |start, none_defined| ImportOptions { + contexts: vec![("Default".into(), ContextRule::Home)], + start, + none_defined, + ..ImportOptions::default() + }; + + let mut km = keymap(Platform::Windows).with_overrides(user()); + plan(&set, &km, &options(Start::Current, NoneDefined::Keep)).apply(&mut km); + has(&km, "file.save", &["Cmd+Alt+S"]); + has(&km, "file.close", &["Cmd+Q"]); + has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); + + let mut km = keymap(Platform::Windows).with_overrides(user()); + let r = plan(&set, &km, &options(Start::Defaults, NoneDefined::Clear)).apply(&mut km); + has(&km, "file.save", &["Cmd+S"]); + has(&km, "file.close", &["Cmd+Q"]); + has(&km, "edit.duplicate", &[]); + assert_eq!(r.imported, 2); +} + +#[test] +fn a_failed_change_keeps_its_shortcut_when_a_later_change_frees_room() { + // Save's change finds the overrides full. Close's change then frees an entry, and Zoom In + // takes Cmd+S. Removing Cmd+S from Save would fit now, but Save's import failed, so Save + // keeps it and Zoom In stays as it was. + let set = ForeignSet::new(vec![ + win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), + win("File", &["Close"], &[("Default", "Ctrl+W")]), + win("View", &["Zoom In"], &[("Default", "Ctrl+S")]), + ]); + let mut o = Overrides::new(); + o.insert("file.close", vec!["Cmd+Q".into()]); + o.insert("view.zoomIn", vec!["Cmd+Alt+Z".into()]); + for n in 2..MAX_COMMANDS { + o.insert(format!("junk.{n}"), vec!["Cmd+K".into()]); + } + assert_eq!(o.len(), MAX_COMMANDS); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; + let mut km = keymap(Platform::Windows).with_overrides(o); + let r = plan(&set, &km, &options).apply(&mut km); + has(&km, "file.save", &["Cmd+S"]); + has(&km, "file.close", &["Cmd+W"]); + has(&km, "view.zoomIn", &["Cmd+Alt+Z"]); + let failed: Vec<(&str, FailedChange)> = r.failed.iter().map(|f| (f.command.id.as_str(), f.change)).collect(); + assert_eq!(failed, [("file.save", FailedChange::Bind), ("view.zoomIn", FailedChange::Bind)]); + assert!(r.conflicts_cleared.is_empty(), "{:?}", r.conflicts_cleared); + assert_eq!(r.imported, 1); +} + +#[test] +fn changes_that_find_the_overrides_full_are_reported() { + let set = ForeignSet::new(vec![ + win("File", &["Close"], &[("Default", "Ctrl+J")]), + win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), + win("Edit", &["Duplicate"], &[]), + ]); + let full = || { + let mut o = Overrides::new(); + o.insert("file.close", vec!["Cmd+Q".into()]); + for n in 1..MAX_COMMANDS { + o.insert(format!("junk.{n}"), vec!["Cmd+K".into()]); + } + assert_eq!(o.len(), MAX_COMMANDS); + o + }; + let options = |start| ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start, ..ImportOptions::default() }; + + // file.close has an entry to rewrite, but taking Cmd+J from view.goToPage needs a new one, so + // both stay as they were. The other changes each need a new entry too. + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); + has(&km, "file.close", &["Cmd+Q"]); + has(&km, "view.goToPage", &["Cmd+J"]); + has(&km, "file.save", &["Cmd+S"]); + has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); + let rows = |failed: &[Failed]| -> Vec<(String, String, FailedChange, Vec)> { + failed.iter().map(|f| (f.command.id.clone(), f.label.clone(), f.change, f.shortcuts.clone())).collect() + }; + let row = |id: &str, label: &str, change, shortcuts: &[&str]| { + (id.to_string(), label.to_string(), change, shortcuts.iter().map(|s| s.to_string()).collect::>()) + }; + assert_eq!( + rows(&r.failed), + [ + row("file.close", "Close", FailedChange::Bind, &["Cmd+J"]), + row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+S"]), + row("edit.duplicate", "Duplicate", FailedChange::Clear, &[]), + ] + ); + assert_eq!(r.imported, 0); + assert!(r.conflicts_cleared.is_empty()); + let v = serde_json::to_value(&r).unwrap(); + assert_eq!( + v["failed"][1], + json!({"command": {"id": "file.save", "params": null}, "label": "Save", "change": "bind", "shortcuts": ["Cmd+Alt+S"]}) + ); + + // Starting from the defaults drops the entries, so everything fits. + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Defaults)).apply(&mut km); + assert!(r.failed.is_empty(), "{:?}", r.failed); + has(&km, "file.close", &["Cmd+J"]); + has(&km, "view.goToPage", &[]); + has(&km, "file.save", &["Cmd+Alt+S"]); + has(&km, "edit.duplicate", &[]); + + // A later entry whose change finds the overrides full leaves the chord with the earlier one. + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])]); + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); + has(&km, "file.close", &["Cmd+Alt+J"]); + has(&km, "file.save", &["Cmd+S"]); + assert_eq!(rows(&r.failed), [row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+J"])]); + assert!(r.skipped.is_empty(), "{:?}", r.skipped); + assert_eq!(r.imported, 1); +} + +#[test] +fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])]); + let options = ImportOptions { + contexts: vec![ + ("Default".into(), ContextRule::Home), + ("Story".into(), ContextRule::Scope(Scope::new("Story").unwrap())), + ("Text".into(), ContextRule::Scope(Scope::new("Text").unwrap())), + ], + ..ImportOptions::default() + }; + let mut km = keymap(Platform::Windows); + let r = plan(&set, &km, &options).apply(&mut km); + has(&km, "file.close", &["Cmd+Q", "Text: Cmd+L"]); + let skipped: Vec<_> = r + .skipped + .iter() + .map(|s: &Skipped| (s.command.id.as_str(), s.item.as_str(), s.line, s.shortcut.as_str(), s.kept_by.clone(), s.reason)) + .collect(); + assert_eq!(skipped, [("file.close", "File > Close", None, "Story: Cmd+K", None, SkipReason::UnknownScope)]); + assert_eq!(serde_json::to_value(r.skipped[0].reason).unwrap(), "unknownScope"); +} + +#[test] +fn suggestions_are_reported_never_applied() { + let mut km = keymap(Platform::Windows); + let set = ForeignSet::new(vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])]); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let p = plan(&set, &km, &options); + assert!(p.commands.is_empty()); + let r = p.apply(&mut km); + assert_eq!(r.suggestions.len(), 1); + assert_eq!(r.suggestions[0].item, "Edit > Chek Spelling Now"); + assert!(r.suggestions[0].candidates.contains(&CommandRef::new("edit.checkSpelling")), "{:?}", r.suggestions); + has(&km, "edit.checkSpelling", &[]); + assert!(km.overrides().is_empty()); + assert_eq!(r.imported, 0); +} + +#[test] +fn entries_the_reader_dropped_count_as_truncated() { + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q")])]).with_truncated(7); + assert_eq!(plan(&set, &keymap(Platform::Windows), &default_context()).report.truncated, 7); +} + +#[test] +fn hostile_sets_are_capped_and_counted() { + let mut km = keymap(Platform::Windows); + let long: String = "ä€𝄞 x".repeat(MAX_IMPORT_TEXT); + let mut big = ForeignEntry::new(Some(long.as_str()), std::iter::repeat_n(long.clone(), 100)); + for _ in 0..MAX_KEYS_PER_ENTRY + 10 { + let trigger = Err(ForeignError::UnknownKey(long.clone())); + big = big.with_key(ForeignKey { context: Some(long.clone()), text: long.clone(), trigger, notes: vec![ReadNote::Numpad; 1000] }); + } + // Unmatched entries with keys, more than the suggestions take. + let near = win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+F12")]); + let mut entries: Vec = std::iter::repeat_n(near, MAX_SUGGESTIONS + 10).collect(); + entries.push(big); + entries.push(win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); + // Entries without keys fill the set to the cap and 500 past it. + let keyless = MAX_IMPORT_ENTRIES - entries.len(); + entries.extend(std::iter::repeat_n(ForeignEntry::new(None, Vec::::new()), keyless + 500)); + let set = ForeignSet::new(entries).with_name(long.clone()); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let r = plan(&set, &km, &options).apply(&mut km); + assert_eq!(r.suggestions.len(), MAX_SUGGESTIONS); + // The set name, entries and keys past the caps, and suggestions past the cap (with the big entry's). + assert!(r.truncated >= 1 + 500 + 10 + 11, "{}", r.truncated); + assert_eq!(r.set.as_ref().map(|s| s.chars().count()), Some(MAX_IMPORT_TEXT)); + let row = r.unmatched.iter().find(|u| u.item.starts_with('ä')).unwrap(); + assert!(row.item.chars().count() <= MAX_IMPORT_TEXT * 34, "item of capped segments"); + assert!(row.keys.chars().count() <= (2 * MAX_IMPORT_TEXT + 4) * MAX_KEYS_PER_ENTRY); + assert_eq!(r.unreadable.len(), 1, "Paste's keys"); + has(&km, "edit.paste", &["Cmd+V"]); + // Entries past the cap are left out. + assert_eq!(r.unmatched_without_shortcut, keyless); +} + +#[test] +#[ignore = "worst-case timing; run with --ignored"] +fn hostile_sets_finish_in_time() { + // A full catalog whose labels share words with every unmatched entry below, so each + // suggestion scores every command. + let mut catalog = catalog(); + let filler = MAX_COMMANDS - catalog.len(); + catalog.extend((0..filler).map(|i| command(format!("filler.c{i}").as_str(), &format!("Filler Command {i}"), &[], &[]))); + let mut km = Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); + let long: String = "ä€𝄞 x".repeat(MAX_IMPORT_TEXT); + // Odd entries have keys and long labels that match nothing, so they reach the suggestions: + // words in the whole label, or two words and symbols, which score high enough to suggest. + let wordy = "Filler Command ä€𝄞 ".repeat(MAX_IMPORT_TEXT / 8); + let symbols = format!("Filler Command {}", "€𝄞 ".repeat(MAX_IMPORT_TEXT)); + let filler_entry = |i: usize| match i % 4 { + 1 => win("Edit", &[&symbols], &[("Default", "Ctrl+F12")]), + 3 => win("Edit", &[&format!("{wordy}{i}")], &[("Default", "Ctrl+F12")]), + _ => win("Edit", &[&format!("Item {i}")], &[]), + }; + let mut entries: Vec = (0..MAX_IMPORT_ENTRIES + 500).map(filler_entry).collect(); + let mut big = ForeignEntry::new(Some(long.as_str()), std::iter::repeat_n(long.clone(), 100)); + for _ in 0..MAX_KEYS_PER_ENTRY + 10 { + let trigger = Err(ForeignError::UnknownKey(long.clone())); + big = big.with_key(ForeignKey { context: Some(long.clone()), text: long.clone(), trigger, notes: vec![ReadNote::Numpad; 1000] }); + } + entries.insert(0, big); + entries.insert(1, win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); + let set = ForeignSet::new(entries).with_name(long.clone()); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let started = std::time::Instant::now(); + let r = plan(&set, &km, &options).apply(&mut km); + assert!(started.elapsed() < std::time::Duration::from_secs(240), "{:?}", started.elapsed()); + // Read filler entries are those before the cap, after the two inserted ones. + let read = MAX_IMPORT_ENTRIES - 2; + let keyed = read / 2; + assert!(!r.suggestions.is_empty() && r.suggestions.len() <= MAX_SUGGESTIONS, "{}", r.suggestions.len()); + assert!(r.truncated >= 500 + 10 + keyed + 1 - MAX_SUGGESTIONS, "{}", r.truncated); + assert_eq!(r.set.as_ref().map(|s| s.chars().count()), Some(MAX_IMPORT_TEXT)); + let row = r.unmatched.iter().find(|u| u.item.starts_with('ä')).unwrap(); + assert!(row.item.chars().count() <= MAX_IMPORT_TEXT * 34, "item of capped segments"); + assert!(row.keys.chars().count() <= (2 * MAX_IMPORT_TEXT + 4) * MAX_KEYS_PER_ENTRY); + assert_eq!(r.unreadable.len(), 1, "Paste's keys"); + has(&km, "edit.paste", &["Cmd+V"]); + // Entries past the cap are left out. + assert_eq!(r.unmatched_without_shortcut, read - keyed); +} + +#[test] +fn rows_carry_source_lines_and_approximated_keys() { + let mut km = keymap(Platform::Windows); + let d = "Default"; + let set = ForeignSet::new(vec![ + win("Window", &["Layers"], &[(d, "Ctrl+Num 5"), (d, "Num 7")]).at_line(3), + win("Window", &["Layers"], &[(d, "Shift+F7")]).at_line(4), + win("Edit", &["Paste"], &[(d, "Ctrl+Num 5")]).at_line(5), + win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8), + win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9), + ]); + let options = ImportOptions { contexts: vec![(d.into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let r = plan(&set, &km, &options).apply(&mut km); + // A keypad key binds the main key and is reported. Paste, a later entry, takes Cmd+5. + has(&km, panel("layers"), &["7", "Shift+F7"]); + has(&km, "edit.paste", &["Cmd+5"]); + let approximated: Vec<_> = r.approximated.iter().map(|a| (a.item.as_str(), a.line, a.keys.as_str(), a.note)).collect(); + assert_eq!( + approximated, + [ + ("Window > Layers", Some(3), "Default: Ctrl+Num 5, Default: Num 7", ReadNote::Numpad), + ("Edit > Paste", Some(5), "Default: Ctrl+Num 5", ReadNote::Numpad) + ] + ); + // Merged entries report the first one's line. + let layers = r.changed.iter().find(|c| c.command == panel("layers")).unwrap(); + assert_eq!(layers.line, Some(3)); + assert_eq!((r.skipped[0].item.as_str(), r.skipped[0].line), ("Window > Layers", Some(3))); + assert_eq!(r.unmatched[0].line, Some(8)); + assert_eq!(r.suggestions[0].line, Some(8)); + assert_eq!(r.unreadable[0].line, Some(9)); +} + +#[test] +fn report_json_uses_camel_case_names() { + let mut km = keymap(Platform::Windows); + let mut options = windows_options(); + options.suggest = true; + let r = plan(&windows_set(), &km, &options).apply(&mut km); + let v = serde_json::to_value(&r).unwrap(); + let names = |v: &serde_json::Value| v.as_object().unwrap().keys().cloned().collect::>(); + let mut top = names(&v); + top.sort(); + assert_eq!( + top, + [ + "approximated", + "changed", + "conflictsCleared", + "failed", + "imported", + "matchedWithoutChange", + "set", + "skipped", + "suggestions", + "truncated", + "unmatched", + "unmatchedWithoutShortcut", + "unreadable" + ] + ); + let sorted = |v: &serde_json::Value| { + let mut n = names(v); + n.sort(); + n + }; + assert_eq!(sorted(&v["changed"][0]), ["command", "from", "item", "label", "line", "to"]); + assert_eq!(sorted(&v["conflictsCleared"][0]), ["command", "label", "shortcut"]); + assert_eq!(sorted(&v["skipped"][1]), ["command", "item", "keptBy", "line", "reason", "shortcut"]); + assert_eq!(v["skipped"][1]["keptBy"], json!({"id": "edit.clear", "params": null})); + assert_eq!(v["skipped"][0]["reason"], "reserved"); + assert_eq!(sorted(&v["unmatched"][0]), ["item", "keys", "line", "reason"]); + let reasons: Vec<&str> = v["unmatched"].as_array().unwrap().iter().filter_map(|u| u["reason"].as_str()).collect(); + assert_eq!(reasons, ["noMatch", "ambiguous", "context", "tool", "noMatch"]); + assert_eq!(sorted(&v["unreadable"][0]), ["item", "keys", "line", "reason"]); + assert_eq!(sorted(&v["suggestions"][0]), ["candidates", "item", "line"]); + let mut approximated = ImportReport::default(); + approximated.approximated = plan(&numpad_set(), &km, &options).report.approximated; + let a = serde_json::to_value(&approximated).unwrap(); + assert_eq!(sorted(&a["approximated"][0]), ["item", "keys", "line", "note"]); + assert_eq!(a["approximated"][0]["note"], "numpad"); + assert_eq!(v["changed"][0]["from"], json!(["Cmd+W"])); +} + +fn import_into(catalog: Vec, entries: Vec, options: &ImportOptions) -> (Keymap, ImportReport) { + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Windows); + let report = plan(&ForeignSet::new(entries), &km, options).apply(&mut km); + (km, report) +} + +fn default_context() -> ImportOptions { + ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() } +} + +fn unmatched(r: &ImportReport) -> Vec<(&str, UnmatchedReason)> { + r.unmatched.iter().map(|u| (u.item.as_str(), u.reason)).collect() +} + +#[test] +fn panel_menu_entries_match_by_path_end_or_label() { + let catalog = vec![ + command("type.allCaps", "All Caps", &["Panels", "Character", "All Caps"], &[]), + command("pages.overrideAll", "Override All Parent Items", &["Pages", "Override All Parent Page Items"], &[]), + command("paragraph.justification", "Justification…", &[], &[]), + command("swatches.new", "New Swatch", &["Swatches", "New Swatch"], &[]), + command("gradient.new", "New Swatch", &["Gradient", "New Swatch"], &[]), + command("character.smallCaps", "Small Caps", &[], &[]).with_alias("Character: Small Caps"), + // The app has Paragraph and Color panels. + command(panel("paragraph"), "Paragraph", &["Window", "Paragraph"], &[]), + command(panel("color"), "Color", &["Window", "Color"], &[]), + ]; + let d = "Default"; + let panel_entry = |path: &[&str], key: &str| entry(None, path, &[(d, key)], Flavor::Windows).in_panel_menu(); + let entries = vec![ + // The end of a path, label last. + panel_entry(&["Character", "All Caps"], "Ctrl+F1"), + // The end of a path whose last segment differs from the label. + panel_entry(&["Pages", "Override All Parent Page Items"], "Ctrl+F2"), + // The last segment alone, as one command's label, for a panel the app has. + panel_entry(&["Paragraph", "Justification..."], "Ctrl+F3"), + // The last segment alone matches nothing for a panel the app lacks. + panel_entry(&["History", "Justification..."], "Ctrl+F8"), + // The panel picks one of two commands with the label. + panel_entry(&["Swatches", "New Swatch"], "Ctrl+F4"), + // The joined segments as an alias. + panel_entry(&["Character", "Small Caps"], "Ctrl+F5"), + // No path end fits and the label alone fits two commands. + panel_entry(&["Color", "New Swatch"], "Ctrl+F6"), + // Without the mark, a path with no menu matches only a whole catalog path. + entry(None, &["Character", "All Caps"], &[(d, "Ctrl+F7")], Flavor::Windows), + ]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, "type.allCaps", &["Cmd+F1"]); + has(&km, "pages.overrideAll", &["Cmd+F2"]); + has(&km, "paragraph.justification", &["Cmd+F3"]); + has(&km, "swatches.new", &["Cmd+F4"]); + has(&km, "character.smallCaps", &["Cmd+F5"]); + has(&km, "gradient.new", &[]); + assert_eq!( + unmatched(&r), + [ + ("History > Justification...", UnmatchedReason::NoMatch), + ("Color > New Swatch", UnmatchedReason::Ambiguous), + ("Character > All Caps", UnmatchedReason::NoMatch) + ] + ); +} + +#[test] +fn aliases_match_like_labels() { + let catalog = vec![ + command("app.quit", "Quit DesignCraft", &["File", "Quit DesignCraft"], &[]).with_alias("Exit"), + command("app.preferences", "Preferences…", &["Edit", "Preferences…"], &[]).with_alias("Preferences: General…"), + command("type.bold", "Bold", &["Type", "Style", "Bold"], &[]).with_alias("Apply bold"), + command("edit.paste", "Paste", &["Edit", "Paste"], &[]).with_alias("Einfügen"), + ]; + let d = "Default"; + let entries = vec![ + win("File", &["Exit"], &[(d, "Ctrl+Q")]), + win("Edit", &["Preferences", "General..."], &[(d, "Ctrl+K")]), + entry(None, &["Apply Bold"], &[(d, "Ctrl+Shift+B")], Flavor::Windows), + win("Bearbeiten", &["Einfügen"], &[(d, "Ctrl+Shift+V")]), + ]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, "app.quit", &["Cmd+Q"]); + has(&km, "app.preferences", &["Cmd+K"]); + has(&km, "type.bold", &["Cmd+Shift+B"]); + has(&km, "edit.paste", &["Cmd+Shift+V"]); + assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); +} + +#[test] +fn import_paths_match_like_menu_paths() { + let catalog = vec![ + command("object.color", "Apply Color", &["Object", "Apply Color"], &[]), + command("color.applyColor", "Apply Color", &[], &[]).with_import_path(vec!["Tools".into(), "Apply Color".into()]), + ]; + let d = "Default"; + let entries = vec![win("Tools", &["Apply Color"], &[(d, "Ctrl+,")])]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, "color.applyColor", &["Cmd+,"]); + has(&km, "object.color", &[]); + assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); +} + +#[test] +fn show_and_hide_are_read_off_only_for_toggles() { + let catalog = vec![ + command(panel("conveyor"), "Conveyor", &["Window", "Conveyor"], &[]), + command("view.rulers", "Rulers", &["View", "Rulers"], &[]).toggle(), + command("view.frameEdges", "Show Frame Edges", &["View", "Extras", "Show Frame Edges"], &[]).toggle(), + command("view.hiddenCharacters", "Show Hidden Characters", &["Type", "Show Hidden Characters"], &[]), + ]; + let d = "Default"; + let entries = vec![ + // Not a toggle: the name must fit as written. + win("View", &["Extras", "Hide Conveyor"], &[(d, "Ctrl+F1")]), + // One toggle for both of the source's items. + win("View", &["Show Rulers"], &[(d, "Ctrl+R")]), + win("View", &["Hide Rulers"], &[(d, "Ctrl+Shift+R")]), + win("View", &["Extras", "Hide Frame Edges"], &[(d, "Ctrl+H")]), + // Exact names match any command. + win("Type", &["Show Hidden Characters"], &[(d, "Ctrl+Alt+I")]), + win("Type", &["Hide Hidden Characters"], &[(d, "Ctrl+Alt+H")]), + ]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, panel("conveyor"), &[]); + has(&km, "view.rulers", &["Cmd+R", "Cmd+Shift+R"]); + has(&km, "view.frameEdges", &["Cmd+H"]); + has(&km, "view.hiddenCharacters", &["Cmd+Alt+I"]); + assert_eq!( + unmatched(&r), + [("View > Extras > Hide Conveyor", UnmatchedReason::NoMatch), ("Type > Hide Hidden Characters", UnmatchedReason::NoMatch)] + ); +} + +#[test] +fn only_entries_with_keys_to_bind_count_toward_suggestions() { + let catalog = vec![command("edit.checkSpelling", "Check Spelling…", &["Edit", "Check Spelling…"], &[])]; + let d = "Default"; + // Twice the cap of unmatched entries without keys, or with keys only in a skipped context, + // before one with a key. + let mut entries: Vec = (0..MAX_SUGGESTIONS) + .flat_map(|i| [win("Edit", &[&format!("Nothing {i}")], &[]), win("Edit", &[&format!("Tables {i}")], &[("Table", "Ctrl+T")])]) + .collect(); + entries.push(win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K"), ("Table", "Ctrl+T")])); + let options = ImportOptions { + contexts: vec![(d.into(), ContextRule::Home), ("Table".into(), ContextRule::Skip)], + suggest: true, + ..ImportOptions::default() + }; + let (_, r) = import_into(catalog, entries, &options); + assert_eq!(r.unmatched_without_shortcut, 2 * MAX_SUGGESTIONS); + assert_eq!(r.unmatched.len(), 1); + assert_eq!(r.unmatched[0].keys, "Default: Ctrl+K", "keys in a skipped context are left out"); + assert_eq!(r.suggestions.len(), 1); + assert_eq!(r.suggestions[0].candidates, [CommandRef::new("edit.checkSpelling")]); + assert_eq!(r.truncated, 0); +} + +fn pages_catalog() -> Vec { + vec![ + command("app.newDocumentDialog", "New Document…", &["File", "New Document…"], &["Cmd+N"]), + command("layout.pages.insert", "Insert Pages…", &["Layout", "Pages", "Insert Pages…"], &["Cmd+Shift+P"]), + command("file.close", "Close", &["File", "Close"], &["Cmd+W"]), + ] +} + +#[test] +fn panel_menu_entries_without_keys_change_nothing() { + let none = |path: &[&str]| ForeignEntry::new(None, path.iter().copied()).in_panel_menu(); + let entries = vec![ + // The app has no History panel, so the last segment alone does not match. + none(&["History", "New Document"]), + // A path end matches, but without keys the command keeps its shortcuts. + none(&["Pages", "Insert Pages..."]), + // The same for the last segment alone, with a panel the app has. + none(&["Pages", "New Document"]), + ]; + let (km, r) = import_into(pages_catalog(), entries, &default_context()); + has(&km, "app.newDocumentDialog", &["Cmd+N"]); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert!(r.changed.is_empty(), "{:?}", r.changed); + assert_eq!(r.unmatched_without_shortcut, 1); + let rows: Vec<(&str, &str, NoChangeReason)> = + r.matched_without_change.iter().map(|m| (m.item.as_str(), m.command.id.as_str(), m.reason)).collect(); + assert_eq!( + rows, + [ + ("Pages > Insert Pages...", "layout.pages.insert", NoChangeReason::PanelMenu), + ("Pages > New Document", "app.newDocumentDialog", NoChangeReason::PanelMenu) + ] + ); + let v = serde_json::to_value(&r).unwrap(); + let mut fields: Vec = v["matchedWithoutChange"][0].as_object().unwrap().keys().cloned().collect(); + fields.sort(); + assert_eq!(fields, ["command", "item", "label", "line", "reason"]); + assert_eq!(v["matchedWithoutChange"][0]["reason"], "panelMenu"); +} + +#[test] +fn an_entry_without_keys_never_clears_keys_another_entry_gives() { + let d = "Default"; + let keyed = || win("Layout", &["Pages", "Insert Pages..."], &[(d, "Ctrl+Alt+P")]); + let keyless = || win("Layout", &["Insert Pages..."], &[]); + for entries in [vec![keyed(), keyless()], vec![keyless(), keyed()]] { + let (km, _) = import_into(pages_catalog(), entries, &default_context()); + has(&km, "layout.pages.insert", &["Cmd+Alt+P"]); + } + + // A panel-menu entry without keys, and the menu entry with the command's keys. + let entries = vec![ + ForeignEntry::new(None, ["Pages", "Insert Pages..."]).in_panel_menu(), + win("Layout", &["Pages", "Insert Pages..."], &[(d, "Ctrl+Shift+P")]), + ]; + let (km, r) = import_into(pages_catalog(), entries, &default_context()); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert_eq!(r.matched_without_change.len(), 1); + + // The later entry still takes the chord from another command; the entry without keys does + // not clear the command that lost it. + let entries = vec![keyless(), keyed(), win("File", &["Close"], &[(d, "Ctrl+Alt+P")])]; + let (km, r) = import_into(pages_catalog(), entries, &default_context()); + has(&km, "file.close", &["Cmd+Alt+P"]); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert_eq!(r.skipped.iter().map(|s| (s.command.id.as_str(), s.reason)).collect::>(), [("layout.pages.insert", SkipReason::Taken)]); +} diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs new file mode 100644 index 00000000..58a904b0 --- /dev/null +++ b/crates/keymap/tests/keymap.rs @@ -0,0 +1,665 @@ +use std::time::{Duration, Instant}; + +use craft_keymap::{ + Active, AssignError, Bindable, Binding, Chord, CommandRef, ConflictKind, DropReason, Key, Keymap, Kind, Lookup, MAX_BINDINGS, MAX_COMMANDS, + MAX_CONFLICTS, Mods, Overrides, Platform, Policy, Problem, Scope, Scopes, Trigger, TypingRule, reserved, +}; +use serde_json::json; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn scope(name: &str) -> Scope { + Scope::new(name).unwrap() +} + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn cmd(id: &str) -> CommandRef { + CommandRef::new(id) +} + +fn app(id: &str, keys: &[&str]) -> Bindable { + keys.iter().fold(Bindable::new(id, id, Scope::app()), |e, k| e.with_default(b(k))) +} + +fn in_scope(id: &str, home: &str, keys: &[&str]) -> Bindable { + keys.iter().fold(Bindable::new(id, id, scope(home)), |e, k| e.with_default(b(k))) +} + +fn run(id: &str) -> Lookup { + Lookup::Run(cmd(id)) +} + +fn scopes() -> Scopes { + Scopes::new().with_panel("Timeline").with_panel("Composition").with_share("Timeline", "Composition").with_context("Text") +} + +fn press(km: &Keymap, text: &str, active: &Active) -> Lookup { + km.lookup(&chord(text), false, active) +} + +#[test] +fn command_keys_are_canonical_whatever_the_map_order() { + let c = CommandRef::with_params("window.panel", json!({"z": [1, "a b"], "panel": "layers"})); + assert_eq!(c.key(), r#"window.panel {"panel":"layers","z":[1,"a b"]}"#); + assert_eq!(CommandRef::parse_key(&c.key()), Some(c)); + assert_eq!(CommandRef::with_params("edit.copy", json!({})).key(), "edit.copy"); + assert_eq!(CommandRef::parse_key("x {bad"), None); + assert_eq!(CommandRef::parse_key(&"x".repeat(300)), None); +} + +#[test] +fn lookup_picks_the_nearest_scope() { + let catalog = vec![ + app("edit.copy", &["Cmd+C"]), + in_scope("timeline.copy", "Timeline", &["Cmd+C"]), + app("file.bake", &["Cmd+B"]), + in_scope("text.bold", "Text", &["Cmd+B", "Cmd+I"]), + in_scope("timeline.loop", "Timeline", &["L"]), + ]; + let km = Keymap::new(catalog.clone(), scopes(), Platform::Mac); + let text = ["Text"]; + assert_eq!(press(&km, "Cmd+C", &Active::NONE), run("edit.copy")); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Timeline")), run("timeline.copy")); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Composition")), run("timeline.copy"), "sharing panel"); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Effects")), run("edit.copy")); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Timeline")), run("file.bake")); + assert_eq!(press(&km, "Cmd+B", &Active { panel: Some("Timeline"), contexts: &text, typing: false }), run("text.bold")); + assert_eq!(press(&km, "Cmd+I", &Active::NONE), Lookup::None, "inactive context"); + assert_eq!(press(&km, "L", &Active::panel("Effects")), run("timeline.loop"), "fallthrough"); + assert_eq!(km.lookup(&chord("L"), true, &Active::panel("Timeline")), Lookup::None, "double press is its own trigger"); + + let strict = Keymap::new(catalog, scopes().with_panel_fallthrough(false), Platform::Mac); + assert_eq!(press(&strict, "L", &Active::panel("Effects")), Lookup::None); + assert_eq!(press(&strict, "L", &Active::panel("Timeline")), run("timeline.loop")); +} + +#[test] +fn typing_rules_decide_what_fires_in_a_text_field() { + let catalog = vec![app("tool.brush", &["B"]), app("edit.copy", &["Cmd+C"]), app("help", &["F1"]), app("view.alt", &["Alt+V"])]; + let typing = Active { panel: None, contexts: &[], typing: true }; + let cases = [ + (TypingRule::PlainKeysWait, [false, true, true, true]), + (TypingRule::CommandKeysOnly, [false, true, true, false]), + (TypingRule::AllWait, [false, false, false, false]), + ]; + for (rule, fires) in cases { + let km = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows).with_typing_rule(rule); + for ((key, id), fire) in [("B", "tool.brush"), ("Cmd+C", "edit.copy"), ("F1", "help"), ("Alt+V", "view.alt")].into_iter().zip(fires) { + let want = if fire { run(id) } else { Lookup::None }; + assert_eq!(press(&km, key, &typing), want, "{rule:?} {key}"); + assert_eq!(press(&km, key, &Active::NONE), run(id), "{rule:?} {key} not typing"); + } + } +} + +#[test] +fn text_editing_chords_go_to_the_text_field_under_every_rule() { + let catalog = vec![app("edit.undo", &["Cmd+Z"]), app("file.save", &["Cmd+S"]), app("nav.word", &["Alt+Left"]), app("nav.start", &["Cmd+Home"])]; + let typing = Active { typing: true, ..Active::NONE }; + for rule in [TypingRule::PlainKeysWait, TypingRule::CommandKeysOnly] { + for platform in [Platform::Mac, Platform::Windows] { + let km = Keymap::new(catalog.clone(), Scopes::new(), platform).with_typing_rule(rule); + assert_eq!(press(&km, "Cmd+Z", &typing), Lookup::None, "{rule:?} {platform:?}"); + assert_eq!(press(&km, "Cmd+Z", &Active::NONE), run("edit.undo")); + assert_eq!(press(&km, "Cmd+S", &typing), run("file.save"), "{rule:?} {platform:?}"); + } + } + // Word and line keys follow the platform. + let mac = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac); + let windows = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows); + assert_eq!(press(&mac, "Alt+Left", &typing), Lookup::None); + assert_eq!(press(&windows, "Alt+Left", &typing), run("nav.word")); + assert_eq!(press(&windows, "Ctrl+Home", &typing), Lookup::None); + assert_eq!(press(&mac, "Cmd+Home", &typing), run("nav.start")); + + // A binding in an active context takes the chord from the field. + let catalog = vec![app("edit.undo", &["Cmd+Z"]), app("text.undo", &["Text: Cmd+Z"])]; + let km = Keymap::new(catalog, scopes(), Platform::Windows); + let editing = Active { contexts: &["Text"], typing: true, ..Active::NONE }; + assert_eq!(press(&km, "Cmd+Z", &editing), run("text.undo")); + assert_eq!(press(&km, "Cmd+Z", &typing), Lookup::None); + assert!(!TypingRule::PlainKeysWait.fires(&chord("Ctrl+Z"), Platform::Linux)); +} + +#[test] +fn bindings_of_an_active_context_fire_while_typing_under_every_rule() { + let catalog = vec![ + app("text.bold", &["Text: Cmd+B", "Text: B"]), + app("file.bake", &["Cmd+B"]), + in_scope("timeline.cut", "Timeline", &["Cmd+K"]), + app("file.save", &["Cmd+S"]), + ]; + let editing = Active { panel: Some("Timeline"), contexts: &["Text"], typing: true }; + let elsewhere = Active { contexts: &[], ..editing }; + let km = Keymap::new(catalog, scopes(), Platform::Mac).with_typing_rule(TypingRule::AllWait); + assert_eq!(press(&km, "Cmd+B", &editing), run("text.bold")); + assert_eq!(press(&km, "B", &editing), run("text.bold")); + assert_eq!(press(&km, "Cmd+K", &editing), Lookup::None, "panel bindings wait"); + assert_eq!(press(&km, "Cmd+S", &editing), Lookup::None, "app bindings wait"); + assert_eq!(press(&km, "Cmd+B", &elsewhere), Lookup::None, "the app binding waits when the context is not active"); +} + +#[test] +fn tools_of_one_group_cycle_and_keep_their_shared_key() { + let tool = |id: &str, group: &str| app(id, &["B"]).with_kind(Kind::Tool { group: Some(group.into()) }); + let mut km = + Keymap::new(vec![tool("tool.brush", "brush"), tool("tool.pencil", "brush"), tool("tool.mixer", "brush")], Scopes::new(), Platform::Mac); + assert_eq!(press(&km, "B", &Active::NONE), Lookup::Cycle(vec![cmd("tool.brush"), cmd("tool.pencil"), cmd("tool.mixer")])); + assert!(km.conflicts().is_empty()); + + km.clear(&cmd("tool.mixer")); + let done = km.assign(&cmd("tool.mixer"), b("B"), Policy::Steal).unwrap(); + assert!(done.taken_from.is_empty()); + assert_eq!(km.bindings(&cmd("tool.brush")), vec![b("B")]); + + let mixed = Keymap::new(vec![tool("tool.brush", "brush"), app("layer.bake", &["B"]), tool("tool.pencil", "brush")], Scopes::new(), Platform::Mac); + assert_eq!(press(&mixed, "B", &Active::NONE), run("tool.brush")); +} + +#[test] +fn steal_keep_and_refuse() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), in_scope("timeline.copy", "Timeline", &[])]; + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); + let dup = cmd("edit.duplicate"); + + let err = km.assign(&dup, b("Cmd+C"), Policy::Refuse).unwrap_err(); + let AssignError::Taken(holders) = err else { panic!("{err:?}") }; + assert_eq!(holders[0].command, cmd("edit.copy")); + assert_eq!(km.bindings(&dup), vec![b("Cmd+D")]); + + let done = km.assign(&dup, b("Cmd+C"), Policy::Keep).unwrap(); + assert_eq!(done.clashes.len(), 1); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")]); + assert_eq!(km.bindings(&dup), vec![b("Cmd+D"), b("Cmd+C")]); + + km.reset_all(); + let done = km.assign(&dup, b("Cmd+C"), Policy::Steal).unwrap(); + assert_eq!(done.taken_from[0].command, cmd("edit.copy")); + assert!(km.bindings(&cmd("edit.copy")).is_empty()); + assert_eq!(km.overrides().get("edit.copy"), Some(&[][..])); + assert_eq!(press(&km, "Cmd+C", &Active::NONE), run("edit.duplicate")); + + // A panel binding shadows the application one; nothing is taken. + let done = km.assign(&cmd("timeline.copy"), b("Cmd+D"), Policy::Refuse).unwrap(); + assert!(done.taken_from.is_empty() && done.clashes.is_empty()); + assert_eq!(km.assign(&cmd("nope"), b("Cmd+D"), Policy::Steal), Err(AssignError::UnknownCommand)); +} + +#[test] +fn twins_keep_their_shared_default_key() { + let mut km = Keymap::new(vec![app("file.close", &["Cmd+W"]), app("sequence.close", &["Cmd+W"])], Scopes::new(), Platform::Mac); + km.clear(&cmd("sequence.close")); + let done = km.assign(&cmd("sequence.close"), b("Cmd+W"), Policy::Refuse).unwrap(); + assert!(done.taken_from.is_empty()); + assert_eq!(km.bindings(&cmd("file.close")), vec![b("Cmd+W")]); + assert!(km.overrides().is_empty(), "back to the defaults"); +} + +#[test] +fn clearing_a_command_frees_its_panel_copies() { + // filmcraft#1333: New Bin holds Cmd+B in the application and in the Project panel. + let catalog = vec![app("project.newBin", &["Cmd+B", "Project: Cmd+B"]), app("clip.bold", &[])]; + let mut km = Keymap::new(catalog, Scopes::new().with_panel("Project"), Platform::Mac); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Project")), run("project.newBin")); + assert!(km.clear(&cmd("project.newBin"))); + assert!(km.bindings(&cmd("project.newBin")).is_empty()); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Project")), Lookup::None); + assert!(km.assign(&cmd("clip.bold"), b("Project: Cmd+B"), Policy::Refuse).is_ok()); +} + +#[test] +fn reserved_chords_are_refused() { + let catalog = vec![app("view.next", &[])]; + let mut mac = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_reserved(reserved::system(Platform::Mac)); + let err = mac.assign(&cmd("view.next"), b("Cmd+Tab"), Policy::Steal).unwrap_err(); + assert_eq!(err, AssignError::Reserved(Trigger::parse("Cmd+Tab").unwrap())); + + let mut win = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); + assert!(matches!(win.assign(&cmd("view.next"), b("Ctrl+Alt+Delete"), Policy::Steal), Err(AssignError::Reserved(_)))); + assert!(reserved::system(Platform::Linux).contains(&chord("Cmd+Alt+F12"))); + + let mut o = Overrides::new(); + o.insert("view.next", vec!["Alt+F4".into()]); + let linux = Keymap::new(catalog, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); + assert!(linux.bindings(&cmd("view.next")).is_empty()); + assert_eq!(linux.problems(), vec![Problem::Reserved { key: "view.next".into(), text: "Alt+F4".into() }]); +} + +#[test] +fn conflicts_tell_clashes_from_shadows() { + let catalog = vec![ + app("edit.copy", &["Cmd+C"]), + app("color.copy", &["Cmd+C"]), + in_scope("timeline.copy", "Timeline", &["Cmd+C"]), + in_scope("text.cut", "Text", &["Cmd+X"]), + app("edit.cut", &["Cmd+X"]), + app("edit.find", &["Cmd+F"]), + in_scope("tab.find", "Effects", &["Cmd+F"]), + in_scope("render.find", "Render", &["Cmd+R"]), + in_scope("effects.render", "Effects", &["Cmd+R"]), + ]; + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); + let summary = |km: &Keymap| -> Vec<(String, String, String, ConflictKind, bool)> { + km.conflicts().into_iter().map(|c| (c.chord.to_string(), c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect() + }; + let s = |a: &str, x: &str, y: &str, k, d| (a.to_string(), x.to_string(), y.to_string(), k, d); + let mut want = vec![ + s("Cmd+C", "edit.copy", "color.copy", ConflictKind::Clash, true), + s("Cmd+C", "edit.copy", "timeline.copy", ConflictKind::Shadow, true), + s("Cmd+C", "color.copy", "timeline.copy", ConflictKind::Shadow, true), + s("Cmd+X", "text.cut", "edit.cut", ConflictKind::Shadow, true), + s("Cmd+F", "edit.find", "tab.find", ConflictKind::Shadow, true), + // Fallthrough: in a third panel the two tie. + s("Cmd+R", "render.find", "effects.render", ConflictKind::Shadow, true), + ]; + let sort = |mut v: Vec<(String, String, String, ConflictKind, bool)>| { + v.sort(); + v + }; + assert_eq!(sort(summary(&km)), sort(want.clone())); + + km.assign(&cmd("edit.find"), b("Cmd+D"), Policy::Steal).unwrap(); + km.assign(&cmd("edit.cut"), b("Cmd+D"), Policy::Keep).unwrap(); + want.push(s("Cmd+D", "edit.cut", "edit.find", ConflictKind::Clash, false)); + assert_eq!(sort(summary(&km)), sort(want.clone())); + + let order: Vec<_> = summary(&km).into_iter().map(|(c, a, b, ..)| (c, a, b)).collect(); + let chords: Vec = km.conflicts().iter().map(|c| c.chord).collect(); + assert!(chords.is_sorted(), "{order:?}"); + let c = |x: &str, y: &str| order.iter().position(|(_, a, b)| a == x && b == y); + assert!(c("edit.copy", "color.copy") < c("edit.copy", "timeline.copy"), "{order:?}"); + assert!(c("edit.copy", "timeline.copy") < c("color.copy", "timeline.copy"), "{order:?}"); + + // One command's conflicts, with that command as `a`. + let with = |id: &str| -> Vec<(String, String, ConflictKind)> { + km.conflicts_with(&cmd(id)).into_iter().map(|c| (c.chord.to_string(), c.b.0.id, c.kind)).collect() + }; + let w = |chord: &str, other: &str, kind| (chord.to_string(), other.to_string(), kind); + assert_eq!(with("edit.copy"), [w("Cmd+C", "color.copy", ConflictKind::Clash), w("Cmd+C", "timeline.copy", ConflictKind::Shadow)]); + assert_eq!(with("edit.cut"), [w("Cmd+X", "text.cut", ConflictKind::Shadow), w("Cmd+D", "edit.find", ConflictKind::Clash)]); + assert!(with("nope").is_empty()); + for entry in km.catalog() { + let id = entry.command.id.as_str(); + let mut direct: Vec<_> = + km.conflicts_with(&entry.command).into_iter().map(|c| (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect(); + let mut filtered: Vec<_> = km + .conflicts() + .into_iter() + .filter(|c| c.a.0.id == id || c.b.0.id == id) + .map(|c| { + if c.a.0.id == id { + (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults) + } else { + (c.chord, c.b.0.id, c.a.0.id, c.kind, c.in_defaults) + } + }) + .collect(); + direct.sort(); + filtered.sort(); + assert_eq!(direct, filtered, "{id}"); + } + + let strict = Keymap::new(km.catalog().to_vec(), scopes().with_panel_fallthrough(false), Platform::Mac); + assert!(!strict.conflicts().iter().any(|c| c.chord.to_string() == "Cmd+R")); +} + +/// `commands` commands, each with 16 bindings on one chord in 16 panels. +fn crowded(commands: usize, scopes: Scopes, kind: Kind) -> (Keymap, Overrides) { + let catalog: Vec = (0..commands).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); + let mut o = Overrides::new(); + for n in 0..commands { + assert!(o.insert(format!("c.{n}"), (1..=MAX_BINDINGS).map(|p| format!("P{p}: K")).collect())); + } + (Keymap::new(catalog, scopes, Platform::Mac), o) +} + +fn panels(n: usize) -> Scopes { + (1..=n).fold(Scopes::new(), |s, p| s.with_panel(&format!("P{p}"))) +} + +#[test] +fn crowded_chords_are_capped() { + // Panels the app never registered are not bound. + let (km, o) = crowded(2, Scopes::new(), Kind::Command); + let km = km.with_overrides(o); + assert!(km.conflicts().is_empty()); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(km.overrides().get("c.0").map(<[String]>::len), Some(MAX_BINDINGS), "kept verbatim"); + assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); + + // Registered panels that never share: 32 commands make 16 × 496 pairs, capped. + let (km, o) = crowded(32, panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + assert_eq!(km.with_overrides(o).conflicts().len(), MAX_CONFLICTS); + + // Tools of one group: no conflicts. + let (km, o) = crowded(32, panels(MAX_BINDINGS), Kind::Tool { group: None }); + assert!(km.with_overrides(o).conflicts().is_empty()); +} + +#[test] +#[ignore = "worst-case timing; run with --ignored"] +fn crowded_chords_stay_fast() { + let limit = Duration::from_secs(8); + + // Panels the app never registered are not bound. + let (km, o) = crowded(MAX_COMMANDS, Scopes::new(), Kind::Command); + let km = km.with_overrides(o); + let t = Instant::now(); + assert!(km.conflicts().is_empty()); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(km.overrides().get("c.0").map(<[String]>::len), Some(MAX_BINDINGS), "kept verbatim"); + assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); + + // Registered panels that never share: many conflicts, capped. + let (km, o) = crowded(MAX_COMMANDS, panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + let km = km.with_overrides(o); + let t = Instant::now(); + assert_eq!(km.conflicts().len(), MAX_CONFLICTS); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); + + // Tools of one group: no conflicts, and no pair is compared. + let (km, o) = crowded(MAX_COMMANDS, panels(MAX_BINDINGS), Kind::Tool { group: None }); + let km = km.with_overrides(o); + let t = Instant::now(); + assert!(km.conflicts().is_empty()); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); +} + +#[test] +fn assigning_many_commands_stays_fast() { + let catalog: Vec = (0..MAX_COMMANDS).map(|n| app(&format!("c.{n}"), &[])).collect(); + let mut km = Keymap::new(catalog, panels(MAX_BINDINGS), Platform::Mac); + let list: Vec = (1..=MAX_BINDINGS).map(|p| b(&format!("P{p}: K"))).collect(); + let t = Instant::now(); + for n in 0..MAX_COMMANDS { + let done = km.set_bindings(&cmd(&format!("c.{n}")), list.clone(), Policy::Steal).unwrap(); + assert_eq!(done.taken_from.len(), if n == 0 { 0 } else { MAX_BINDINGS }); + } + assert!(t.elapsed() < Duration::from_secs(8), "{:?}", t.elapsed()); + assert_eq!(km.bindings(&cmd(&format!("c.{}", MAX_COMMANDS - 1))), list); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(press(&km, "K", &Active::panel("P3")), run(&format!("c.{}", MAX_COMMANDS - 1))); +} + +#[test] +fn binding_text_round_trips_for_every_key() { + let panel = scope("Timeline"); + for &key in Key::ALL { + for mods in [Mods::NONE, Mods { cmd: true, ctrl: true, alt: true, shift: true }] { + let chord = Chord::new(mods, key); + for trigger in [Trigger::single(chord), Trigger::double(chord)] { + for binding in [Binding::new(trigger), Binding::in_scope(trigger, panel.clone())] { + let text = binding.to_string(); + assert_eq!(Binding::parse(&text).as_ref(), Ok(&binding), "{text}"); + } + } + } + } + assert_eq!(Binding::parse(": :"), Ok(Binding::new(Trigger::double(Chord::plain(Key::Colon))))); +} + +#[test] +fn scope_names_with_outer_whitespace_are_refused() { + for name in [" Timeline", "Timeline ", "\u{a0}Timeline", ""] { + assert_eq!(Scope::new(name), None, "{name:?}"); + } + assert_eq!(Scope::new("Time line").map(|s| s.to_string()).as_deref(), Some("Time line")); + assert_eq!(Binding::parse(" Timeline : K").map(|b| b.scope), Ok(Some(scope("Timeline")))); +} + +#[test] +fn edits_are_refused_when_the_overrides_are_full() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), app("edit.cut", &[])]; + let mut o = Overrides::new(); + o.insert("edit.cut", vec!["Cmd+X".into()]); + for n in 1..MAX_COMMANDS { + o.insert(format!("junk.{n}"), vec![]); + } + assert_eq!(o.len(), MAX_COMMANDS); + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(o.clone()); + let (copy, dup, cut) = (cmd("edit.copy"), cmd("edit.duplicate"), cmd("edit.cut")); + + assert_eq!(km.assign(&dup, b("Cmd+K"), Policy::Steal), Err(AssignError::Full)); + assert_eq!(km.set_bindings(&dup, vec![], Policy::Steal), Err(AssignError::Full)); + assert!(!km.clear(©)); + assert!(!km.remove(©, &b("Cmd+C"))); + // Taking Cmd+C from edit.copy would need an entry for it too. + assert_eq!(km.assign(&cut, b("Cmd+C"), Policy::Steal), Err(AssignError::Full)); + assert_eq!(km.bindings(©), vec![b("Cmd+C")]); + assert_eq!(km.bindings(&cut), vec![b("Cmd+X")]); + assert_eq!(km.overrides(), &o); + + // Commands that have an entry still change, and back to the defaults frees one. + assert!(km.assign(&cut, b("Cmd+Shift+X"), Policy::Steal).is_ok()); + assert!(km.reset(&cut)); + assert!(km.assign(&dup, b("Cmd+K"), Policy::Steal).is_ok()); +} + +#[test] +fn bindings_in_unknown_scopes_are_refused() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; + let mut km = Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Mac); + let copy = cmd("edit.copy"); + assert_eq!(km.assign(©, b("Nowhere: Cmd+K"), Policy::Steal), Err(AssignError::UnknownScope(scope("Nowhere")))); + assert!(km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).is_ok(), "a catalog scope"); + assert!(km.assign(©, b("Text: Cmd+K"), Policy::Steal).is_ok(), "a registered scope"); + + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Nowhere: Cmd+K".into(), "Cmd+J".into()]); + km.set_overrides(o); + assert_eq!(km.bindings(©), vec![b("Cmd+J")]); + assert_eq!(km.problems(), vec![Problem::UnknownScope { key: "edit.copy".into(), text: "Nowhere: Cmd+K".into() }]); +} + +#[test] +fn unreadable_entries_keep_the_defaults() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"])]; + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Cmd+Nope".into()]); + o.insert("edit.paste", vec![]); + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(o); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")]); + assert!(km.bindings(&cmd("edit.paste")).is_empty()); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+Nope".to_string()][..]), "verbatim"); + assert!(km.reset(&cmd("edit.copy"))); + assert_eq!(km.overrides().get("edit.copy"), None); +} + +#[test] +fn entries_with_nothing_usable_keep_the_defaults() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.cut", &["Cmd+X"]), app("edit.paste", &["Cmd+V"]), app("edit.undo", &["Cmd+Z"])]; + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Nowhere: Cmd+K".into()]); + o.insert("edit.cut", vec!["Alt+F4".into()]); + o.insert("edit.paste", vec!["Nowhere: Cmd+K".into(), "Alt+F4".into(), "Cmd+J".into()]); + o.insert("edit.undo", vec![]); + let km = Keymap::new(catalog, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")], "only an unknown scope"); + assert_eq!(km.bindings(&cmd("edit.cut")), vec![b("Cmd+X")], "only a reserved chord"); + assert_eq!(km.bindings(&cmd("edit.paste")), vec![b("Cmd+J")]); + assert!(km.bindings(&cmd("edit.undo")).is_empty()); +} + +#[test] +fn overrides_hold_only_what_differs_from_the_defaults() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); + let copy = cmd("edit.copy"); + km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).unwrap(); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+C".to_string(), "Timeline: Cmd+K".to_string()][..])); + assert_eq!(km.bindings(©)[1], Binding::in_scope(Trigger::parse("Cmd+K").unwrap(), scope("Timeline"))); + assert!(km.remove(©, &b("Timeline: Cmd+K"))); + assert!(km.overrides().is_empty()); + + km.set_bindings(&cmd("timeline.mark"), vec![b("Timeline: M")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty(), "the home scope written out is the same binding"); + km.set_bindings(©, vec![b("Cmd+Shift+C")], Policy::Steal).unwrap(); + assert!(km.reset(©)); + assert!(km.overrides().is_empty()); +} + +#[test] +fn edits_keep_the_texts_this_build_cannot_use() { + let catalog = vec![app("file.quit", &["Cmd+Q"]), app("file.close", &["Cmd+W"]), app("file.save", &["Cmd+S"])]; + let mut o = Overrides::new(); + o.insert("file.quit", vec!["Cmd+Q".into(), "Cmd+FutureKey".into(), "Nowhere: Cmd+J".into()]); + let mut km = Keymap::new(catalog.clone(), scopes(), Platform::Mac).with_overrides(o); + let quit = cmd("file.quit"); + let texts = |km: &Keymap| km.overrides().get("file.quit").map(<[String]>::to_vec); + let strings = |t: &[&str]| Some(t.iter().map(|s| s.to_string()).collect::>()); + + km.assign(&quit, b("Cmd+W"), Policy::Steal).unwrap(); + assert_eq!(texts(&km), strings(&["Cmd+Q", "Cmd+W", "Cmd+FutureKey", "Nowhere: Cmd+J"])); + km.assign(&cmd("file.save"), b("Cmd+Q"), Policy::Steal).unwrap(); + assert_eq!(texts(&km), strings(&["Cmd+W", "Cmd+FutureKey", "Nowhere: Cmd+J"]), "taken by a Steal"); + let reloaded = Keymap::new(catalog, scopes(), Platform::Mac).with_overrides(km.overrides().clone()); + assert_eq!(reloaded.bindings(&quit), vec![b("Cmd+W")]); + + // An entry of only such texts would load as the defaults, so a cleared command drops them. + assert!(km.clear(&quit)); + assert_eq!(texts(&km), strings(&[])); + assert!(km.reset(&quit)); + assert_eq!(texts(&km), None); +} + +#[test] +fn an_override_equal_to_the_defaults_on_this_platform_is_not_stored() { + let mut km = Keymap::new(vec![app("file.save", &["Cmd+S"])], Scopes::new(), Platform::Windows); + km.set_bindings(&cmd("file.save"), vec![b("Ctrl+S")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty()); + assert_eq!(press(&km, "Ctrl+S", &Active::NONE), run("file.save")); +} + +#[test] +fn an_empty_text_in_the_map_form_removes_the_shortcut() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"]), app("edit.cut", &["Cmd+X"])]; + let o: Overrides = serde_json::from_str(r#"{"edit.copy": "", "edit.paste": [""], "edit.cut": "Cmd+K"}"#).unwrap(); + assert!(o.load_notes().is_empty(), "{:?}", o.load_notes()); + let km = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(o); + assert!(km.bindings(&cmd("edit.copy")).is_empty(), "a removed default stays removed"); + assert!(km.bindings(&cmd("edit.paste")).is_empty()); + assert_eq!(km.bindings(&cmd("edit.cut")), vec![b("Cmd+K")]); + assert!(km.problems().is_empty()); + + let saved = serde_json::to_string(km.overrides()).unwrap(); + let again = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(serde_json::from_str(&saved).unwrap()); + assert!(again.bindings(&cmd("edit.copy")).is_empty(), "{saved}"); +} + +#[test] +fn overrides_write_the_map_form_unless_a_command_has_several_keys() { + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Cmd+K".into()]); + o.insert("edit.paste", vec![]); + let text = serde_json::to_string(&o).unwrap(); + assert_eq!(text, r#"{"edit.copy":"Cmd+K","edit.paste":""}"#, "older builds read one text per command"); + assert_eq!(serde_json::from_str::(&text).unwrap(), o); + + o.insert("edit.cut", vec!["Cmd+X".into(), "Shift+Delete".into()]); + let text = serde_json::to_string(&o).unwrap(); + assert!(text.starts_with(r#"[{"command":"edit.copy","keys":["Cmd+K"]},"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), o); + + // A blank text is kept verbatim (it loads as the defaults), which the map form cannot say. + let mut blank = Overrides::new(); + blank.insert("edit.copy", vec![" ".into()]); + let text = serde_json::to_string(&blank).unwrap(); + assert!(text.starts_with('['), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), blank); + + #[derive(serde::Serialize)] + struct Prefs { + #[serde(with = "craft_keymap::keymap::overrides_list")] + shortcuts: Overrides, + } + let mut single = Overrides::new(); + single.insert("edit.copy", vec!["Cmd+K".into()]); + let text = serde_json::to_string(&Prefs { shortcuts: single }).unwrap(); + assert_eq!(text, r#"{"shortcuts":[{"command":"edit.copy","keys":["Cmd+K"]}]}"#); +} + +#[test] +fn ctrl_folds_into_cmd_off_macos() { + let catalog = vec![app("edit.cut", &["Cmd+X"]), app("mac.cut", &["Ctrl+X"])]; + let win = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows); + let mac = Keymap::new(catalog, Scopes::new(), Platform::Mac); + let conflicts = win.conflicts(); + assert_eq!(conflicts.len(), 1); + assert_eq!((conflicts[0].kind, conflicts[0].chord.to_string()), (ConflictKind::Clash, "Cmd+X".to_string())); + assert!(mac.conflicts().is_empty()); + assert_eq!(press(&win, "Ctrl+X", &Active::NONE), run("edit.cut")); + assert_eq!(press(&mac, "Ctrl+X", &Active::NONE), run("mac.cut")); +} + +#[test] +fn hostile_overrides_load_without_panic_and_report_problems() { + let mut doc = serde_json::Map::new(); + for i in 0..(MAX_COMMANDS + 100) { + doc.insert(format!("junk.{i}"), json!(["Cmd+Z"])); + } + doc.insert("edit.copy".into(), json!(["Cmd+Nope", "x".repeat(10_000), 7, "Shift+C"])); + doc.insert("a".into(), json!(["Shift+A"])); + doc.insert("é".repeat(300), json!([])); + doc.insert("b".into(), json!({"deep": [[[[1]]]]})); + let o: Overrides = serde_json::from_value(serde_json::Value::Object(doc)).unwrap(); + assert!(o.len() <= MAX_COMMANDS); + + let km = Keymap::new(vec![app("edit.copy", &["Cmd+C"]), app("a", &[])], Scopes::new(), Platform::Linux).with_overrides(o.clone()); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Shift+C")]); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+Nope".to_string(), "Shift+C".to_string()][..]), "verbatim"); + let problems = km.problems(); + assert!(problems.iter().any(|p| matches!(p, Problem::TooManyCommands { .. }))); + assert!(problems.contains(&Problem::Dropped { key: "edit.copy".into(), reason: DropReason::TextTooLong })); + assert!(problems.contains(&Problem::Dropped { key: "edit.copy".into(), reason: DropReason::NotText })); + assert!(problems.iter().any(|p| matches!(p, Problem::Dropped { reason: DropReason::KeyTooLong, .. }))); + assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope"))); + assert!(problems.iter().any(|p| matches!(p, Problem::UnknownCommand { key } if key == "junk.0"))); + + let text = serde_json::to_string(&o).unwrap(); + let again: Overrides = serde_json::from_str(&text).unwrap(); + assert_eq!(again, o); + assert!(text.starts_with(r#"[{"command":"a","keys":["Shift+A"]},"#), "{}", &text[..80]); + assert!(serde_json::from_str::(r#"{"x": {"y": 1}, "z": true, "w": null, "v": "Cmd+V"}"#).is_ok()); + let items: Overrides = + serde_json::from_str(r#"[{"command": "v", "keys": "Cmd+V"}, {"command": "x"}, {"keys": []}, 3, [], {"command": "y", "keys": [1]}]"#).unwrap(); + assert_eq!(items.iter().collect::>(), [("v", &["Cmd+V".to_string()][..]), ("y", &[][..])]); + assert_eq!(items.load_notes().len(), 5); + + let twice: Overrides = serde_json::from_str(r#"[{"command": "v", "keys": ["Cmd+V"]}, {"command": "v", "keys": []}]"#).unwrap(); + assert_eq!(twice.get("v"), Some(&["Cmd+V".to_string()][..]), "the first entry stays"); + assert_eq!(twice.load_notes(), [Problem::DuplicateKey { key: "v".into() }]); + for text in [r#""x""#, "5", "true", "null"] { + let o: Overrides = serde_json::from_str(text).unwrap(); + assert!(o.is_empty(), "{text}"); + assert_eq!(o.load_notes(), [Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{text}"); + } +} + +#[test] +fn reset_removes_hand_written_variants_of_a_key() { + let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); + let catalog = vec![Bindable::new(layers.clone(), "Layers", Scope::app()).with_default(b("F7"))]; + let load = || { + let mut o = Overrides::new(); + o.insert(r#"window.panel {"panel":"layers"}"#, vec!["Cmd+L".into()]); + o.insert(r#"window.panel { "panel": "layers" }"#, vec!["Cmd+K".into()]); + Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(o) + }; + let mut km = load(); + assert_eq!(km.bindings(&layers), vec![b("Cmd+L")], "the canonical entry wins"); + km.reset(&layers); + assert!(km.overrides().is_empty(), "{:?}", km.overrides()); + + // Setting the defaults drops the entries too. + let mut km = load(); + km.set_bindings(&layers, vec![b("F7")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty(), "{:?}", km.overrides()); + let again = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(km.overrides().clone()); + assert_eq!(again.bindings(&layers), vec![b("F7")]); +} diff --git a/crates/keymap/tests/layout.rs b/crates/keymap/tests/layout.rs new file mode 100644 index 00000000..46ff8876 --- /dev/null +++ b/crates/keymap/tests/layout.rs @@ -0,0 +1,226 @@ +use craft_keymap::{Chord, Key, KeyInput, Layout, Mods, Platform}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +/// A press as egui reports it at the US position `physical`: the modifiers held and the logical +/// key, which is the physical key when the character typed has no key (`ф`, `é`, `ข`). +fn at(reported: &str, physical: Key) -> KeyInput { + let c = chord(reported); + KeyInput::new(c.mods).with_typed(c.key).with_physical(physical) +} + +fn texts(chords: &[Chord]) -> Vec { + chords.iter().map(Chord::to_string).collect() +} + +#[test] +fn each_layout_resolves_presses_by_the_rule() { + let latin = Layout::new(); + let other = Layout::new().with_latin(false); + let rows: &[(&str, &Layout, KeyInput, &str)] = &[ + // Russian: letters type Cyrillic, which egui reports as the physical key. + ("Russian ⌘С", &other, at("Cmd+C", Key::C), "Cmd+C"), + ("Russian ⌘Х", &other, at("Cmd+[", Key::OpenBracket), "Cmd+["), + ("Russian / key types .", &other, at("Cmd+.", Key::Slash), "Cmd+."), + ("Russian ⇧7 types ?", &other, at("Shift+?", Key::Num7), "?"), + // Arabic 101: ⇧D types ]. + ("Arabic ⌃⇧D", &other, at("Cmd+Shift+]", Key::D), "Cmd+Shift+D"), + ("Arabic ⌃V", &other, at("Cmd+V", Key::V), "Cmd+V"), + // Hebrew: Q types /, W types ', the / position types .. + ("Hebrew ⌃Q", &other, at("Cmd+/", Key::Q), "Cmd+Q"), + ("Hebrew ⌃W", &other, at("Cmd+'", Key::W), "Cmd+W"), + ("Hebrew / key types .", &other, at("Cmd+.", Key::Slash), "Cmd+."), + // Greek: Q types ;. + ("Greek ⌃Q", &other, at("Cmd+;", Key::Q), "Cmd+Q"), + ("Greek ⌃Z", &other, at("Cmd+Z", Key::Z), "Cmd+Z"), + // Dvorak + Russian, as the suite's egui-winit reports them: the Latin layout's character + // for each key. Dvorak types ' on the Q position, which egui reports as Q or as '. + ("Dvorak + Russian ⌃С", &other, at("Cmd+J", Key::C).with_latin('j'), "Cmd+J"), + ("Dvorak + Russian ⌃Й", &other, at("Cmd+Q", Key::Q).with_latin('\''), "Cmd+'"), + ("Dvorak + Russian ⌃Й as '", &other, at("Cmd+'", Key::Q).with_latin('\''), "Cmd+'"), + ("Dvorak + Russian / key types .", &other, at("Cmd+.", Key::Slash).with_latin('z'), "Cmd+Z"), + // Dvorak + Hebrew: the Q position types / and has no Dvorak letter. + ("Dvorak + Hebrew ⌃Q", &other, at("Cmd+/", Key::Q).with_latin('\''), "Cmd+/"), + // Thai Kedmanee: 2 types /, - types ข, D types ก. + ("Thai 2 key types /", &other, at("Cmd+/", Key::Num2), "Cmd+/"), + ("Thai - key types ข", &other, at("Cmd+-", Key::Minus), "Cmd+-"), + ("Thai ⌃D", &other, at("Cmd+D", Key::D), "Cmd+D"), + // AZERTY. + ("AZERTY A", &latin, at("Cmd+A", Key::Q), "Cmd+A"), + ("AZERTY M", &latin, at("Cmd+M", Key::Semicolon), "Cmd+M"), + ("AZERTY M key types ,", &latin, at("Cmd+,", Key::M), "Cmd+,"), + ("AZERTY & key", &latin, at("Cmd+1", Key::Num1), "Cmd+1"), + ("AZERTY ⇧& types 1", &latin, at("Cmd+Shift+1", Key::Num1), "Cmd+Shift+1"), + ("AZERTY 6 key types -", &latin, at("Cmd+-", Key::Num6), "Cmd+-"), + ("AZERTY ⇧; types .", &latin, at("Shift+.", Key::Comma), "."), + // Dvorak. + ("Dvorak O", &latin, at("Cmd+O", Key::S), "Cmd+O"), + ("Dvorak ⇧O keeps Shift", &latin, at("Cmd+Shift+O", Key::S), "Cmd+Shift+O"), + ("Dvorak Q key types '", &latin, at("Cmd+'", Key::Q), "Cmd+'"), + ("Dvorak W key types ,", &latin, at("Cmd+,", Key::W), "Cmd+,"), + // QWERTZ (German). + ("QWERTZ ⇧⌘7 types /", &latin, at("Cmd+Shift+/", Key::Num7), "Cmd+/"), + ("QWERTZ Z", &latin, at("Cmd+Z", Key::Y), "Cmd+Z"), + ("QWERTZ ⇧ß types ?", &latin, at("Cmd+Shift+?", Key::Minus), "Cmd+?"), + ("QWERTZ + key", &latin, at("Cmd++", Key::CloseBracket), "Cmd++"), + ("QWERTZ ⇧, types ;", &latin, at("Cmd+Shift+;", Key::Comma), "Cmd+;"), + // US. + ("US ⇧;", &latin, at("Shift+:", Key::Semicolon), ":"), + ("US ⇧⌘=", &latin, at("Cmd+Shift++", Key::Equals), "Cmd++"), + ("US ⇧- types _, which has no key", &latin, at("Shift+-", Key::Minus), "Shift+-"), + // macOS "Dvorak – QWERTY ⌘": with ⌘ held the layout's ⌘ table is QWERTY, while the + // logical key egui reports is Dvorak's K. + ("Dvorak – QWERTY ⌘ ⌘V", &latin, at("Cmd+K", Key::V).with_latin('v'), "Cmd+V"), + // A Shift level the platform reports wins over the inference. + ("reported Shift level", &latin, at("Cmd+Shift+/", Key::Num7).with_shift_needed(false), "Cmd+Shift+/"), + ]; + for (name, layout, input, want) in rows { + assert_eq!(layout.resolve(input).map(|c| c.to_string()).as_deref(), Some(*want), "{name}"); + } +} + +#[test] +fn reported_cases_match_only_the_chord_the_press_means() { + let p = Platform::Windows; + let arabic = Layout::new().with_latin(false); + let ctrl_shift_d = at("Cmd+Shift+]", Key::D); + assert!(arabic.matches(&chord("Ctrl+Shift+D"), &ctrl_shift_d, p)); + assert!(!arabic.matches(&chord("Ctrl+Shift+]"), &ctrl_shift_d, p), "Arabic Ctrl+Shift+D must not run Ctrl+Shift+]"); + + let dvorak_russian_q = at("Cmd+Q", Key::Q).with_latin('\''); + assert!(!Layout::new().with_latin(false).matches(&chord("Ctrl+Q"), &dvorak_russian_q, p), "Dvorak + Russian й must not quit"); + + let qwerty_cmd = at("Cmd+K", Key::V).with_latin('v'); + assert!(Layout::new().matches(&chord("Cmd+V"), &qwerty_cmd, Platform::Mac)); + assert!(!Layout::new().matches(&chord("Cmd+K"), &qwerty_cmd, Platform::Mac), "⌘V must not open the command palette"); + + let german = Layout::new(); + let slash = at("Cmd+Shift+/", Key::Num7); + assert_eq!(texts(&german.candidates(&slash)), ["Cmd+/", "Cmd+Shift+7"]); + assert!(german.matches(&chord("⌘/"), &slash, Platform::Mac)); + // A US-written ⌘? binding is reached with German's ⇧ß, not with ⇧7. + assert!(!german.matches(&chord("Cmd+Shift+/"), &slash, p)); + assert_eq!(texts(&german.candidates(&at("Cmd+Shift+?", Key::Minus))), ["Cmd+?", "Cmd+Shift+/"]); + // German Ctrl+; is Ctrl+Shift+, and must not run a Ctrl+Shift+, (font size) binding. + let semicolon = at("Cmd+Shift+;", Key::Comma); + assert!(german.matches(&chord("Ctrl+;"), &semicolon, p)); + assert!(!german.matches(&chord("Ctrl+Shift+,"), &semicolon, p)); + + // AZERTY's 6 key types -: Ctrl+- first, then Ctrl+6. + assert_eq!(texts(&Layout::new().candidates(&at("Cmd+-", Key::Num6))), ["Cmd+-", "Cmd+6"]); + // Zoom keys: + also runs =, and Shift+= runs +. + assert_eq!(texts(&Layout::new().candidates(&at("Cmd+Shift++", Key::Equals))), ["Cmd++", "Cmd+Shift+=", "Cmd+="]); + assert_eq!(texts(&Layout::new().candidates(&KeyInput::new(Mods::SHIFT).with_typed(Key::Equals))), ["Shift+=", "+"]); +} + +#[test] +fn typing_decides_whether_the_layout_is_latin() { + let mut layout = Layout::new(); + assert!(layout.is_latin(), "nothing known counts as Latin"); + let ctrl_shift_d = at("Cmd+Shift+]", Key::D); + // Read as punctuation until the layout is known: ] needs Shift on the D key. + assert_eq!(layout.resolve(&ctrl_shift_d), Some(chord("Cmd+]"))); + + layout.observe(Key::D, Mods::NONE, "ي"); + assert!(!layout.is_latin()); + assert_eq!(layout.resolve(&ctrl_shift_d), Some(chord("Cmd+Shift+D"))); + + // Punctuation on a letter key says nothing: Hebrew Q and Dvorak Q both type a symbol. + layout.observe(Key::Q, Mods::NONE, "/"); + assert!(!layout.is_latin()); + layout.observe(Key::A, Mods::SHIFT, "A"); + assert!(layout.is_latin()); + layout.observe(Key::W, Mods::NONE, "ς"); + assert!(!layout.is_latin()); + layout.observe(Key::S, Mods::CMD, "s"); + assert!(!layout.is_latin(), "a command chord teaches nothing"); + layout.observe(Key::S, Mods::NONE, "š"); + assert!(layout.is_latin(), "Latin letters beyond ASCII count as Latin"); + layout.observe(Key::M, Mods::NONE, "µ"); + assert!(layout.is_latin(), "Latin-1 symbols that count as letters say nothing"); + + // What the platform reports wins. + let mut reported = Layout::new().with_latin(false); + reported.observe(Key::A, Mods::NONE, "a"); + assert!(!reported.is_latin()); + reported.set_latin(None); + assert!(reported.is_latin()); +} + +#[test] +fn dvorak_shift_punctuation_never_runs_its_position() { + // Dvorak's , key sits on W and types < with Shift, which egui has no key for, so egui + // reports the press as W. + let ctrl_shift_lt = at("Cmd+Shift+W", Key::W); + let mut dvorak = Layout::new(); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W")), "nothing known yet"); + + dvorak.observe(Key::S, Mods::NONE, "o"); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W")), "the W position is unseen"); + dvorak.observe(Key::W, Mods::NONE, ","); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), None); + assert!(!dvorak.matches(&chord("Ctrl+Shift+W"), &ctrl_shift_lt, Platform::Windows)); + // Without Shift, and for letters typed where they sit (Dvorak's A) or elsewhere, presses + // resolve as before. + assert_eq!(dvorak.resolve(&at("Cmd+W", Key::W)), Some(chord("Cmd+W"))); + dvorak.observe(Key::A, Mods::NONE, "a"); + assert_eq!(dvorak.resolve(&at("Cmd+Shift+A", Key::A)), Some(chord("Cmd+Shift+A"))); + assert_eq!(dvorak.resolve(&at("Cmd+Shift+O", Key::S)), Some(chord("Cmd+Shift+O"))); + + // AZERTY: A is moved, but the unseen E position still runs Ctrl+Shift+E. + let mut azerty = Layout::new(); + azerty.observe(Key::Q, Mods::NONE, "a"); + assert_eq!(azerty.resolve(&at("Cmd+Shift+E", Key::E)), Some(chord("Cmd+Shift+E"))); + + // QWERTY: a position typing its own letter keeps the position fallback. + let mut qwerty = Layout::new(); + qwerty.observe(Key::S, Mods::NONE, "s"); + qwerty.observe(Key::W, Mods::SHIFT, "W"); + assert_eq!(qwerty.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W"))); + assert_eq!(qwerty.resolve(&at("Cmd+Shift+E", Key::E)), Some(chord("Cmd+Shift+E"))); +} + +#[test] +fn a_layout_switch_forgets_what_letter_positions_typed() { + // On Dvorak the W position types `,`; after a switch to QWERTY, Ctrl+Shift+W is W again. + let ctrl_shift_w = at("Cmd+Shift+W", Key::W); + let mut layout = Layout::new().with_latin(true); + layout.observe(Key::W, Mods::NONE, ","); + assert_eq!(layout.resolve(&ctrl_shift_w), None); + layout.set_latin(Some(true)); + assert_eq!(layout.resolve(&ctrl_shift_w), None, "the same report is the same layout"); + + // Dvorak, then Russian, then QWERTY, as the platform reports them. + layout.set_latin(Some(false)); + layout.set_latin(Some(true)); + assert_eq!(layout.resolve(&ctrl_shift_w), Some(chord("Cmd+Shift+W"))); + + // An app that learns of a switch itself. + let mut unreported = Layout::new(); + unreported.observe(Key::W, Mods::NONE, ","); + assert_eq!(unreported.resolve(&ctrl_shift_w), None); + unreported.forget_letters(); + assert_eq!(unreported.resolve(&ctrl_shift_w), Some(chord("Cmd+Shift+W"))); +} + +#[test] +fn labels_show_the_layouts_own_punctuation() { + let mut german = Layout::new().with_char(Key::OpenBracket, 'ü').with_char(Key::Slash, '-').with_char(Key::CloseBracket, '+'); + german.observe(Key::Minus, Mods::NONE, "ß"); + let label = |layout: &Layout, text: &str, p| chord(text).display_for(p, layout); + assert_eq!(label(&german, "Cmd+[", Platform::Windows), "Ctrl+Ü"); + assert_eq!(label(&german, "Cmd+[", Platform::Mac), "⌘Ü"); + // German types - at the / position, so Ctrl+- shows -; its + key types a key of its own. + assert_eq!(label(&german, "Cmd+-", Platform::Windows), "Ctrl+-"); + assert_eq!(label(&german, "Cmd+]", Platform::Windows), "Ctrl+]"); + assert_eq!(label(&german, "Cmd+Z", Platform::Windows), "Ctrl+Z"); + assert_eq!(label(&german, "Cmd+1", Platform::Windows), "Ctrl+1"); + + let russian = Layout::new().with_latin(false).with_char(Key::OpenBracket, 'х'); + assert_eq!(label(&russian, "Cmd+[", Platform::Linux), "Ctrl+Х"); + assert_eq!(label(&russian, "Cmd+C", Platform::Linux), "Ctrl+C"); + assert_eq!(craft_keymap::Trigger::double(chord("[")).display_for(Platform::Linux, &russian), "Х Х"); + assert_eq!(label(&Layout::new(), "Cmd+[", Platform::Linux), "Ctrl+["); +} diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs new file mode 100644 index 00000000..e3b789b8 --- /dev/null +++ b/crates/keymap/tests/profiles.rs @@ -0,0 +1,210 @@ +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, + Policy, ProfileError, Profiles, Scope, Scopes, SetProblem, Start, plan, +}; +use serde_json::json; + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap() -> Keymap { + let catalog = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(b("Cmd+S")), + Bindable::new("edit.copy", "Copy", Scope::app()).with_path(vec!["Edit".into(), "Copy".into()]).with_default(b("Cmd+C")), + Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()).with_default(b("F7")), + Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(b("L")), + ]; + Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Mac) +} + +#[test] +fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let mut edited = Overrides::new(); + edited.insert("edit.copy", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom".into()))); + assert_eq!(p.active_name(&builtins), "Custom"); + assert_eq!(p.store(Overrides::new(), &builtins, "Custom"), Ok(None), "a user set is edited in place"); + assert!(p.active_overrides(&builtins).is_empty()); + + p.select("Classic", &builtins).unwrap(); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.store(classic.clone(), &builtins, "Custom"), Ok(None), "no change, no copy"); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom 2".into()))); + assert_eq!(builtins[1].1, classic); + + let exported = p.export("Custom 2", &builtins).unwrap(); + assert_eq!(p.import(&exported, &builtins), Ok("Custom 3".into())); + assert_eq!(p.active_overrides(&builtins), &edited); + + assert_eq!(p.delete("Classic", &builtins), Err(ProfileError::BuiltIn)); + assert_eq!(p.rename("Custom 3", "classic", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.rename("Custom 3", "Mine", &builtins), Ok("Mine".into())); + assert_eq!(p.active(), "Mine"); + assert_eq!(p.save_as(&"x".repeat(65), Overrides::new(), &builtins), Err(ProfileError::BadName)); + p.delete("Mine", &builtins).unwrap(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let wrong = exported.replace("craft-keymap", "other"); + assert_eq!(p.import(&wrong, &builtins), Err(ProfileError::WrongFormat)); + let newer = exported.replace("\"version\": 1", "\"version\": 2"); + assert_eq!(p.import(&newer, &builtins), Err(ProfileError::NewerVersion(2))); + + let text = serde_json::to_string(&p).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), p); + let junk: Profiles = serde_json::from_str(r#"{"active": 5, "sets": {"": {}, "ok": {"a": ["A"]}, "bad": 3}}"#).unwrap(); + assert_eq!(junk.sets().keys().collect::>(), ["ok"]); + let junk: Profiles = + serde_json::from_str(r#"{"sets": [{"name": "ok", "overrides": [{"command": "a", "keys": ["A"]}, 4]}, {"name": ""}, 7]}"#).unwrap(); + assert_eq!(junk.sets()["ok"].get("a"), Some(&["A".to_string()][..])); + assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); +} + +#[test] +fn rename_keeps_a_set_whose_name_differs_only_in_case() { + let builtins = [("Craft", Overrides::new())]; + let mut p: Profiles = serde_json::from_str(r#"{"sets": {"Mine": {"a": "A"}, "mine": {"b": "B"}}}"#).unwrap(); + assert_eq!(p.rename("Mine", "mine", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.sets()["mine"].get("b"), Some(&["B".to_string()][..])); + assert_eq!(p.rename("Mine", "MINE", &builtins), Err(ProfileError::NameTaken)); + p.delete("mine", &builtins).unwrap(); + assert_eq!(p.rename("Mine", "MINE", &builtins), Ok("MINE".into())); +} + +#[test] +fn sets_that_are_not_a_list_or_map_load_as_none() { + for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { + let p: Profiles = serde_json::from_str(text).unwrap_or_else(|e| panic!("{text}: {e}")); + assert!(p.sets().is_empty(), "{text}"); + assert_eq!(p.active(), "Mine", "{text}"); + } +} + +#[test] +fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { + let mut mine = Overrides::new(); + mine.insert("edit.copy", vec!["Cmd+K".into()]); + let mut p = Profiles::default(); + p.save_as("Classic", mine.clone(), &[("Craft", Overrides::new())]).unwrap(); + + // An update adds a built-in of that name. + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + assert_eq!(p.active_overrides(&builtins), &mine, "the user set is what the name reads"); + + let mut edited = mine.clone(); + edited.insert("edit.paste", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Classic 2".into()))); + assert_eq!(p.active_name(&builtins), "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &edited); + assert_eq!(p.get("Classic", &builtins), Some(&classic)); + assert_eq!(builtins[1].1, classic); + + let mut q: Profiles = + serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap(); + assert!(q.reconcile(&builtins)); + assert_eq!(q.active_name(&builtins), "CRAFT 2"); + assert_eq!(q.active_overrides(&builtins), &mine); + assert!(!q.reconcile(&builtins)); +} + +#[test] +fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { + let mut km = keymap(); + km.assign(&"file.save".into(), b("Cmd+Alt+S"), Policy::Steal).unwrap(); + km.assign(&CommandRef::with_params("window.panel", json!({"panel": "layers"})), b("Timeline: Cmd+L"), Policy::Steal).unwrap(); + km.clear(&"edit.copy".into()); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + p.save_as("Mine", km.overrides().clone(), &builtins).unwrap(); + + let text = p.export("Mine", &builtins).unwrap(); + assert_eq!(p.import(&text, &builtins), Ok("Mine 2".into())); + assert_eq!(p.active(), "Mine 2"); + + let mut again = keymap(); + again.set_overrides(p.active_overrides(&builtins).clone()); + for bindable in km.catalog() { + assert_eq!(again.bindings(&bindable.command), km.bindings(&bindable.command), "{}", bindable.command.key()); + } + assert_eq!(again.overrides(), km.overrides()); +} + +#[test] +fn duplicate_copies_built_in_and_user_sets() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + + assert_eq!(p.duplicate("Classic", "Classic", &builtins), Ok("Classic 2".into())); + assert_eq!(p.active(), "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.duplicate("Classic 2", " Mine ", &builtins), Ok("Mine".into())); + assert_eq!(p.duplicate("Mine", "mine", &builtins), Ok("mine 2".into()), "names compare without case"); + assert_eq!(p.duplicate("Nope", "X", &builtins), Err(ProfileError::NotFound)); + assert_eq!(p.duplicate("Craft", "", &builtins), Err(ProfileError::BadName)); + + while p.sets().len() < MAX_SETS { + p.duplicate("Craft", "Spare", &builtins).unwrap(); + } + assert_eq!(p.duplicate("Craft", "Spare", &builtins), Err(ProfileError::TooMany)); +} + +#[test] +fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { + let km = keymap(); + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac); + let set = ForeignSet::new(vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))]).with_name("Other App"); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() }; + let planned = plan(&set, &km, &options); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + + let (name, report) = p.import_plan(&planned, &km, None, &builtins).unwrap(); + assert_eq!(name, "Other App"); + assert_eq!(report.changed.len(), 1); + assert!(km.overrides().is_empty()); + assert_eq!(p.active(), "Other App"); + let mut imported = keymap(); + imported.set_overrides(p.active_overrides(&builtins).clone()); + assert_eq!(imported.bindings(&"file.save".into()), [b("Cmd+Shift+S")]); + + let (name, _) = p.import_plan(&planned, &km, Some("Craft"), &builtins).unwrap(); + assert_eq!(name, "Craft 2", "a built-in's name is not reused"); +} + +#[test] +fn loading_lists_the_sets_it_drops_and_the_store_has_a_version() { + let text = r#"{"version": 2, "active": "ok", "sets": [ + {"name": "ok", "overrides": {}}, {"name": " pad ", "overrides": {}}, {"name": "ok", "overrides": {"a": "A"}}, {"name": "bad", "overrides": 3} + ]}"#; + let p: Profiles = serde_json::from_str(text).unwrap(); + assert_eq!(p.sets().keys().collect::>(), ["ok"]); + assert!(p.sets()["ok"].is_empty(), "the first of two sets with one name stays"); + let name = |n: &str| n.to_string(); + assert_eq!( + p.problems(), + [ + SetProblem::NewerVersion(2), + SetProblem::BadName { name: name(" pad ") }, + SetProblem::Duplicate { name: name("ok") }, + SetProblem::BadOverrides { name: name("bad") } + ] + ); + + let many: Vec<_> = (0..MAX_SETS + 3).map(|i| json!({"name": format!("s{i}"), "overrides": {}})).collect(); + let p: Profiles = serde_json::from_value(json!({ "sets": many })).unwrap(); + assert_eq!((p.sets().len(), p.problems()), (MAX_SETS, &[SetProblem::TooMany { ignored: 3 }][..])); + + assert_eq!(serde_json::to_value(&p).unwrap()["version"], 1); + let unversioned: Profiles = serde_json::from_str(r#"{"active": "x", "sets": []}"#).unwrap(); + assert!(unversioned.problems().is_empty()); +} diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs new file mode 100644 index 00000000..2361693e --- /dev/null +++ b/crates/keymap/tests/settings.rs @@ -0,0 +1,146 @@ +use std::time::Duration; + +use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Profiles, Scope, Scopes}; +use craft_settings::{Location, OpenOptions, Probe, Settings, SettingsStore}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] +#[serde(default)] +struct Prefs { + general: General, + #[serde(with = "craft_keymap::keymap::overrides_list")] + shortcuts: Overrides, + profiles: Profiles, +} + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] +#[serde(default)] +struct General { + language: String, +} + +impl Settings for Prefs { + const APP_ID: &'static str = "keymaptest"; + const SCHEMA_VERSION: u32 = 1; +} + +fn open(dir: &std::path::Path) -> SettingsStore { + let probe = Probe { env: Box::new(|_| None), exe_dir: None, platform_config_dir: None }; + let options = OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(probe).debounce(Duration::from_millis(10)); + let (store, report) = SettingsStore::open_with(options); + assert!(report.section_errors.is_empty(), "{report:?}"); + store +} + +fn keymap(overrides: Overrides) -> Keymap { + let catalog = vec![ + Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()), + Bindable::new("text.bold", "Bold", Scope::app()), + Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), + Bindable::new("edit.paste", "Paste", Scope::app()).with_default(Binding::parse("Cmd+V").unwrap()), + ]; + Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Mac).with_overrides(overrides) +} + +#[test] +fn overrides_round_trip_through_craft_settings() { + let tmp = tempfile::tempdir().unwrap(); + let mut km = keymap(Overrides::new()); + let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); + km.assign(&layers, Binding::parse("Cmd+Shift+L").unwrap(), Policy::Steal).unwrap(); + km.assign(&"text.bold".into(), Binding::parse("Text: Cmd+B").unwrap(), Policy::Steal).unwrap(); + km.clear(&"edit.copy".into()); + + let mut store = open(tmp.path()); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.save_as("Mine", km.overrides().clone(), &[]).unwrap(); + p.profiles.save_as("Spare", Overrides::new(), &[]).unwrap(); + }); + store.flush().unwrap(); + + // A hand edit: one text that does not parse, one value that is not a list. + let path = tmp.path().join("config.toml"); + let text = std::fs::read_to_string(&path).unwrap(); + let edit = r#"shortcuts = [{ command = "edit.paste", keys = ["Cmd+Nope"] }, { command = "edit.cut", keys = 5 }, "#; + assert!(text.contains("shortcuts = ["), "{text}"); + std::fs::write(&path, text.replacen("shortcuts = [", edit, 1)).unwrap(); + + let mut store = open(tmp.path()); + let mut km = keymap(store.get().shortcuts.clone()); + assert_eq!(km.bindings(&layers), vec![Binding::parse("Cmd+Shift+L").unwrap()]); + assert_eq!(km.bindings(&"text.bold".into()), vec![Binding::parse("Text: Cmd+B").unwrap()]); + assert!(km.bindings(&"edit.copy".into()).is_empty()); + assert_eq!(km.bindings(&"edit.paste".into()), vec![Binding::parse("Cmd+V").unwrap()], "nothing readable: the defaults"); + assert_eq!(km.overrides().get("edit.paste"), Some(&["Cmd+Nope".to_string()][..])); + let problems = km.problems(); + assert!(problems.contains(&Problem::Dropped { key: "edit.cut".into(), reason: DropReason::NotAList }), "{problems:?}"); + assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope")), "{problems:?}"); + assert_eq!(store.get().profiles.sets().len(), 2); + + store.update(|p| p.general.language = "de".into()); + store.flush().unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("keys = [\"Cmd+Nope\"]"), "{text}"); + assert!( + text.contains(r#"command = "window.panel {\"panel\":\"layers\"}""#) || text.contains(r#"command = 'window.panel {"panel":"layers"}'"#), + "{text}" + ); + assert_eq!(open(tmp.path()).get().shortcuts, store.get().shortcuts); + + // Removals reach the file. + km.reset(&layers); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.delete("Spare", &[]).unwrap(); + }); + store.flush().unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let line = text.lines().find(|l| l.starts_with("shortcuts = ")).unwrap(); + assert!(!line.contains("window.panel") && !text.contains("Spare"), "{text}"); + let reloaded = open(tmp.path()); + assert!(keymap(reloaded.get().shortcuts.clone()).bindings(&layers).is_empty()); + assert_eq!(reloaded.get().profiles.sets().keys().collect::>(), ["Mine"]); + assert_eq!(reloaded.get().profiles.sets()["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); +} + +#[test] +fn hand_written_array_of_tables_and_map_forms_load() { + for text in [ + "schema_version = 1\n\n[[shortcuts]]\ncommand = \"edit.copy\"\nkeys = [\"Cmd+K\"]\n\n[[shortcuts]]\ncommand = \"edit.paste\"\nkeys = []\n", + "schema_version = 1\n\n[shortcuts]\n\"edit.copy\" = [\"Cmd+K\"]\n\"edit.paste\" = []\n", + ] { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let km = keymap(open(tmp.path()).get().shortcuts.clone()); + assert_eq!(km.bindings(&"edit.copy".into()), vec![Binding::parse("Cmd+K").unwrap()], "{text}"); + assert!(km.bindings(&"edit.paste".into()).is_empty(), "{text}"); + } +} + +#[test] +fn a_shortcuts_value_that_is_not_a_list_loads_as_no_overrides() { + for value in ["\"x\"", "5", "true"] { + let tmp = tempfile::tempdir().unwrap(); + let text = format!("schema_version = 1\nshortcuts = {value}\n\n[general]\nlanguage = \"de\"\n"); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let store = open(tmp.path()); + assert!(store.get().shortcuts.is_empty(), "{value}"); + assert_eq!(store.get().general.language, "de", "{value}"); + let problems = keymap(store.get().shortcuts.clone()).problems(); + assert_eq!(problems, vec![Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{value}"); + } +} + +#[test] +fn a_sets_value_that_is_not_a_list_keeps_the_rest_of_the_profiles() { + for value in ["\"x\"", "5", "true"] { + let tmp = tempfile::tempdir().unwrap(); + let text = format!("schema_version = 1\n\n[profiles]\nactive = \"Mine\"\nsets = {value}\n"); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let store = open(tmp.path()); + assert_eq!(store.get().profiles.active(), "Mine", "{value}"); + assert!(store.get().profiles.sets().is_empty(), "{value}"); + } +} diff --git a/crates/keymap/tests/support/fonts.rs b/crates/keymap/tests/support/fonts.rs new file mode 100644 index 00000000..a6845650 --- /dev/null +++ b/crates/keymap/tests/support/fonts.rs @@ -0,0 +1,68 @@ +//! Deterministic app-supplied fonts for dialog tests, including their first layout pass. + +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; + +use egui::{FontData, FontDefinitions, FontId}; +use sha2::{Digest, Sha256}; + +// Assets from craft-fonts b076417b5880bd4154bd22f00695b1d28c6bea83. Keep the +// input outside this repository; do not fall back to machine-installed fonts. +const INPUTS: [(&str, &str, &str); 2] = [ + ("keymap-symbols", "fonts/noto-sans-symbols/NotoSansSymbols.ttf", "f7e7e04b4a24b6c78893d50cbfd2b2f6cae49617ab047bfef668d252adb128f7"), + ( + "keymap-symbols-2", + "fonts/noto-sans-symbols-2/NotoSansSymbols2-Regular.ttf", + "7d5fb73b7ca67a6798101741f5d280a3d016a56a197afcd4199dbb57b4b82a21", + ), +]; + +/// Load the pinned craft-fonts inputs once for all dialog tests. +/// +/// Requires an absolute `CRAFT_FONTS_DIR`, verifies the SHA-256 of both symbol faces, refuses any +/// installed-font fallback, and appends the two pinned faces plus egui's Hack to every family. +fn definitions() -> FontDefinitions { + static FONTS: OnceLock = OnceLock::new(); + FONTS + .get_or_init(|| { + let root = PathBuf::from(std::env::var_os("CRAFT_FONTS_DIR").unwrap_or_else(|| { + panic!("UI tests require absolute CRAFT_FONTS_DIR at craft-fonts b076417b5880bd4154bd22f00695b1d28c6bea83; see README.md") + })); + assert!(root.is_absolute(), "CRAFT_FONTS_DIR must be absolute: {}", root.display()); + let mut fonts = FontDefinitions::default(); + assert!(fonts.font_data.contains_key("Hack"), "UI tests require egui default_fonts"); + for (name, relative, expected) in INPUTS { + let path = root.join(relative); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("required font {}: {e}", path.display())); + assert_eq!(format!("{:x}", Sha256::digest(&bytes)), expected, "unreviewed font input: {}", path.display()); + fonts.font_data.insert(name.into(), Arc::new(FontData::from_owned(bytes))); + } + for family in fonts.families.values_mut() { + for name in ["keymap-symbols", "keymap-symbols-2", "Hack"] { + if !family.iter().any(|existing| existing == name) { + family.push(name.into()); + } + } + } + fonts + }) + .clone() +} + +/// Install the deterministic fonts on `ctx` before its first layout pass. +pub fn configure(ctx: &egui::Context) { + ctx.set_fonts(definitions()); +} + +/// All printable ASCII plus the key symbols, dialog icons and accented pseudolocale +/// repertoire. Direction marks are intentionally invisible, rather than drawn glyphs. +pub fn assert_coverage(ctx: &egui::Context) { + const SYMBOLS: &str = "×·—“”…‹›←↑→↓↩↺⇥⇧⌃⌘⌥⌦⌫⎋⏎⌨▾⚠✕⟦⟧ÅÉØáëïöüÜ"; + ctx.fonts_mut(|fonts| { + for font in [FontId::proportional(16.0), FontId::monospace(16.0)] { + for c in (' '..='~').chain(SYMBOLS.chars()) { + assert!(fonts.has_glyph(&font, c), "test font {:?} lacks {c} (U+{:04X})", font.family, u32::from(c)); + } + } + }); +} diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs new file mode 100644 index 00000000..1187b613 --- /dev/null +++ b/crates/keymap/tests/ui_dialog.rs @@ -0,0 +1,1049 @@ +#![cfg(feature = "ui")] +//! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas, +//! platform display, accessibility labels, small windows, sets, import review and the summary. + +use std::borrow::Cow; + +use craft_keymap::ui::{ConflictMode, DialogState, English, FixedRow, Options, Outcome, ShortcutsDialog, Text}; +use craft_keymap::{ + Active, Bindable, Binding, Chord, CommandRef, ConflictKind, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, + Kind, Lookup, Overrides, Platform, Profiles, Scope, Scopes, Trigger, +}; +use egui::accesskit::Role; +use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use ui_fixture::{Pseudo, fixture, with_long_label}; + +#[path = "support/fonts.rs"] +mod test_fonts; +mod ui_fixture; + +struct Fx { + km: Keymap, + options: Options, + state: DialogState, + text: Box, + window: bool, + /// Profiles and built-in sets, when the dialog shows sets. + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, + outcomes: Vec, + /// A key press was still in the frame's input after the dialog ran. + keys_left: bool, +} + +/// The dialog of `fx` for a call outside the frame (import_json, review_import). +fn dialog(fx: &mut Fx) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, options, state, text, sets, .. } = fx; + let d = ShortcutsDialog::new(km, &**text).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) +} + +impl Fx { + fn new(km: Keymap) -> Fx { + Fx { + km, + options: Options::default(), + state: DialogState::new(), + text: Box::new(English), + window: false, + sets: None, + outcomes: Vec::new(), + keys_left: false, + } + } + + fn bindings(&self, id: &str) -> Vec { + self.km.bindings(&CommandRef::from(id)).iter().map(|b| b.to_string()).collect() + } + + /// Whether any outcome recorded by the dialog harness is `Outcome::Cancel`. + fn canceled(&self) -> bool { + self.outcomes.iter().any(|o| matches!(o, Outcome::Cancel)) + } +} + +/// Build an egui_kittest harness around `Fx`. +/// +/// Installs and checks the pinned fonts every frame, selects window or inline dialog mode, records +/// emitted outcomes, and flags any key press still present after the dialog ran. +fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> { + let mut h = Harness::builder().with_size(size).with_context_setup(test_fonts::configure).build_ui_state( + |ui, fx: &mut Fx| { + test_fonts::assert_coverage(ui.ctx()); + let Fx { km, options, state, text, window, sets, outcomes, keys_left } = fx; + let dialog = ShortcutsDialog::new(km, text.as_ref()).options(options); + let dialog = match sets { + Some((p, b)) => dialog.sets(p, b), + None => dialog, + }; + let out = if *window { dialog.show(ui.ctx(), state) } else { dialog.ui(ui, state) }; + outcomes.extend(out); + *keys_left |= ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. }))); + }, + fx, + ); + h.run(); + h +} + +fn open(fx: Fx) -> Harness<'static, Fx> { + harness(fx, vec2(1000.0, 700.0)) +} + +fn mac() -> Harness<'static, Fx> { + open(Fx::new(fixture(Platform::Mac))) +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +fn press(h: &mut Harness<'_, Fx>, modifiers: Modifiers, key: EguiKey) { + h.key_press_modifiers(modifiers, key); + h.run(); +} + +fn raw_key(h: &mut Harness<'_, Fx>, key: EguiKey, time: f64) { + h.input_mut().time = Some(time); + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().time = Some(time + 0.01); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +fn has(h: &Harness<'_, Fx>, label: &str) -> bool { + h.query_by_label(label).is_some() +} + +/// The table itself has focus (a screen reader sees its selected row, the active descendant). +fn table_focused(h: &Harness<'_, Fx>) -> bool { + h.get_by_label("Commands").accesskit_node().is_focused_in_tree() +} + +fn chord(text: &str, platform: Platform) -> String { + Trigger::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")).display(platform) +} + +// ---- Recording ---- + +#[test] +fn recording_captures_a_chord_another_command_would_run() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + assert!(has(&h, "Save: press keys")); + h.state_mut().keys_left = false; + press(&mut h, Modifiers::COMMAND, EguiKey::E); + assert!(!h.state().keys_left, "the dialog took the key press"); + assert!(has(&h, "⚠ ⌘E is used by Export…")); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "nothing applied before an answer"); +} + +#[test] +fn recording_takes_tab_arrows_and_shift_escape() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Tab); + assert_eq!(h.state().bindings("file.save"), ["Tab"]); + assert!(table_focused(&h), "Tab did not move focus"); + + click(&mut h, &format!("Save: {}", chord("Tab", Platform::Mac))); + press(&mut h, Modifiers::NONE, EguiKey::ArrowDown); + assert_eq!(h.state().bindings("file.save"), ["Down"]); + + click(&mut h, &format!("Save: {}", chord("Down", Platform::Mac))); + press(&mut h, Modifiers::SHIFT, EguiKey::Escape); + assert_eq!(h.state().bindings("file.save"), ["Shift+Escape"]); + assert!(table_focused(&h)); + assert!(h.state().state.is_open()); +} + +#[test] +fn escape_cancels_recording_only() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().state.is_open()); + assert!(!h.state().state.is_recording()); + assert!(!h.state().canceled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().canceled()); + assert!(!h.state().state.is_open()); +} + +#[test] +fn copy_event_records_cmd_c() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + h.event(Event::Copy); + h.run(); + assert_eq!(h.state().bindings("file.save"), ["Cmd+C"]); +} + +#[test] +fn double_press_records_k_k() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.double_press = true; + let mut h = open(fx); + click(&mut h, "Save: ⌘S"); + raw_key(&mut h, EguiKey::K, 10.0); + assert!(has(&h, "Save: K (press again for a double press)")); + raw_key(&mut h, EguiKey::K, 10.3); + assert_eq!(h.state().bindings("file.save"), ["K K"]); + + click(&mut h, "Save: K K"); + raw_key(&mut h, EguiKey::K, 20.0); + raw_key(&mut h, EguiKey::K, 20.9); + assert_eq!(h.state().bindings("file.save"), ["K"]); +} + +#[test] +fn the_new_shortcut_field_waits_for_assign() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.conflicts = ConflictMode::Keep; + let mut h = open(fx); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::COMMAND, EguiKey::E); + assert!(has(&h, "Currently assigned to:")); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + click(&mut h, "Assign"); + assert!(has(&h, "Also used by Export…"), "Keep mode keeps both"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+E"]); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"]); +} + +/// Presses Tab until the control labeled `label` has focus; false after `max` presses. +fn tab_to(h: &mut Harness<'_, Fx>, label: &str, max: usize) -> bool { + for _ in 0..max { + press(h, Modifiers::NONE, EguiKey::Tab); + if h.get_by_label(label).is_focused() { + return true; + } + } + false +} + +#[test] +fn tab_leaves_the_new_shortcut_field_after_recording() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::NONE, EguiKey::F7); + assert!(!h.state().state.is_recording()); + assert!(h.get_by_label("New shortcut: F7").is_focused()); + assert!(tab_to(&mut h, "Assign", 6), "Tab moves on to Assign"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "Tab is not recorded"); +} + +#[test] +fn tab_leaves_search_by_key_after_recording() { + let mut h = mac(); + click(&mut h, "Search by key"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(!h.state().state.is_recording()); + assert!(h.get_by_label("Search by key").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Tab); + assert!(table_focused(&h), "Tab moves on to the table"); +} + +// ---- Conflicts ---- + +#[test] +fn conflict_take_it_steals_and_undo_restores() { + let mut h = mac(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Take It"); + assert_eq!(h.state().bindings("file.save"), Vec::::new()); + assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]); + assert!(has(&h, "⌘S was removed from Save")); + + // The footer's Undo is after the message's. + let undo = h.get_all_by_label("Undo").last().map(|n| n.rect()); + let Some(undo) = undo else { panic!("no Undo") }; + h.get_all_by_label("Undo").filter(|n| n.rect() == undo).for_each(|n| n.click()); + h.run(); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"]); + click(&mut h, "Redo"); + assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]); + press(&mut h, Modifiers::COMMAND, EguiKey::Z); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"], "Cmd+Z undoes when no text field has focus"); +} + +#[test] +fn conflict_keep_both_lists_the_clash() { + let mut h = mac(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Keep Both"); + let clashes = h.state().km.conflicts().into_iter().filter(|c| c.kind == ConflictKind::Clash).count(); + assert_eq!(clashes, 1); + assert!(has(&h, "Also used by Save")); + click(&mut h, "⚠ 1 shortcut is assigned more than once"); + assert!(has(&h, "Save: ⌘S")); + assert!(has(&h, "Export…: ⌘S")); + assert!(!has(&h, "Save As…: ⇧⌘S"), "the Conflicts filter shows only the clash"); +} + +#[test] +fn assign_takes_a_held_shortcut_and_keeps_the_others() { + let key = |text: &str| Binding::parse(text).unwrap_or_else(|e| panic!("{e}")); + let catalog = vec![ + Bindable::new("file.print", "Print…", Scope::app()).with_default(key("Cmd+P")), + Bindable::new("type.paragraphStyles", "Paragraph Styles", Scope::app()).with_default(key("Cmd+F11")), + ]; + let mut h = open(Fx::new(Keymap::new(catalog, Scopes::new(), Platform::Mac))); + h.get_by_label("Paragraph Styles").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::COMMAND, EguiKey::P); + assert!(has(&h, "Currently assigned to:")); + assert!(has(&h, "⚠ Print…"), "the holder is listed under the field"); + click(&mut h, "Assign"); + assert!(has(&h, "⌘P was removed from Print…")); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); + + let km = &h.state().km; + let run = |text: &str| km.lookup(&Chord::parse(text).unwrap_or_else(|e| panic!("{e}")), false, &Active::NONE); + assert_eq!(run("Cmd+P"), Lookup::Run("type.paragraphStyles".into())); + assert_eq!(run("Cmd+F11"), Lookup::Run("type.paragraphStyles".into())); + assert!(h.state().bindings("file.print").is_empty()); +} + +#[test] +fn steal_mode_says_what_it_took() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.conflicts = ConflictMode::Steal; + let mut h = open(fx); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, "⌘S was removed from Save")); + click(&mut h, "Go to Conflict"); + assert!(selected(&h, "Save (Changed)")); +} + +/// Whether a row with this label reports itself selected. +fn selected(h: &Harness<'_, Fx>, label: &str) -> bool { + h.get_all_by_label(label).any(|n| n.accesskit_node().is_selected() == Some(true)) +} + +#[test] +fn go_to_conflict_selects_and_reveals_the_holder() { + let mut h = mac(); + choose_area(&mut h, "Tools"); + search(&mut h, "marquee"); + assert!(!has(&h, "Save: ⌘S")); + click(&mut h, "Marquee Tool: M"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Take It and Go to Conflict"); + assert_eq!(h.state().bindings("tool.marquee"), ["Cmd+S"]); + assert!(selected(&h, "Save (Changed)"), "the holder is selected"); + assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), ""); + assert!(has(&h, "Export…: ⌘E"), "area All, no filter"); +} + +#[test] +fn reserved_chord_is_refused() { + let km = fixture(Platform::Mac).with_reserved(vec![Chord::parse("Cmd+Tab").unwrap_or_else(|e| panic!("{e}"))]); + let mut h = open(Fx::new(km)); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::COMMAND, EguiKey::Tab); + assert!(has(&h, &format!("⚠ {} is reserved by the system", chord("Cmd+Tab", Platform::Mac)))); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); +} + +#[test] +fn app_rule_refuses() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.accept = Some(Box::new(|b, binding| { + if matches!(b.kind, Kind::Tool { .. }) && !binding.trigger.chord.mods.is_none() { + Err("Tools take keys without modifiers".into()) + } else { + Ok(()) + } + })); + let mut h = open(fx); + click(&mut h, "Marquee Tool: M"); + press(&mut h, Modifiers::COMMAND, EguiKey::K); + assert!(has(&h, "⚠ Tools take keys without modifiers")); + assert_eq!(h.state().bindings("tool.marquee"), ["M"]); +} + +// ---- Search, areas, display ---- + +fn search(h: &mut Harness<'_, Fx>, text: &str) { + let field = h.get_by_label("Search:"); + field.focus(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::A); + h.run(); + h.get_by_label("Search:").type_text(text); + h.run(); +} + +fn choose_area(h: &mut Harness<'_, Fx>, area: &str) { + h.get_by_role_and_label(Role::ComboBox, "Show:").click(); + h.run(); + // The popup's item, after a group header of the same name. + h.get_all_by_role_and_label(Role::Button, area).last().unwrap_or_else(|| panic!("{area}")).click(); + h.run(); +} + +struct German; +impl Text for German { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, one: &str, other: &str) -> String { + if n == 1 { one } else { other }.replace("{n}", &n.to_string()) + } + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + if b.command.id == "file.save" { "Sichern".into() } else { b.label.as_str().into() } + } +} + +#[test] +fn search_by_name_translation_or_chord() { + let mut h = mac(); + search(&mut h, "save"); + assert!(has(&h, "Save: ⌘S") && has(&h, "Save As…: ⇧⌘S")); + assert!(!has(&h, "Export…: ⌘E")); + search(&mut h, "cmd+s"); + assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); + search(&mut h, "⌘S"); + assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); + search(&mut h, "zzz"); + assert!(has(&h, "No matching commands")); + + click(&mut h, "Search by key"); + press(&mut h, Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::S); + assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), "⇧⌘S"); + assert!(has(&h, "Save As…: ⇧⌘S") && !has(&h, "Save: ⌘S")); + + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.text = Box::new(German); + let mut h = open(fx); + search(&mut h, "sichern"); + assert!(has(&h, "Sichern: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); +} + +#[test] +fn areas_follow_paths_kind_and_scope() { + let mut h = mac(); + h.get_by_role_and_label(Role::ComboBox, "Show:").click(); + h.run(); + for area in ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other"] { + assert!(h.get_all_by_label(area).count() >= 1, "{area}"); + } + h.get_all_by_role_and_label(Role::Button, "Tools").last().expect("the Tools item").click(); + h.run(); + assert!(has(&h, "Marquee Tool: M") && has(&h, "Elliptical Marquee Tool: M")); + assert!(!has(&h, "Save: ⌘S")); + choose_area(&mut h, "Timeline Panel"); + assert!(has(&h, "Loop: L") && !has(&h, "Marquee Tool: M")); +} + +#[test] +fn display_follows_the_keymap_platform() { + let h = mac(); + assert!(has(&h, "Save As…: ⇧⌘S")); + assert!(has(&h, "Bold: ⌘B (Text)"), "a scope other than home is named"); + let h = open(Fx::new(fixture(Platform::Windows))); + assert!(has(&h, "Save As…: Ctrl+Shift+S")); +} + +// ---- Labels, focus, small windows, RTL ---- + +#[test] +fn every_control_has_a_label() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, "Take It")); + let mut seen = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + if matches!(n.role(), Role::Button | Role::Tab | Role::TextInput | Role::ComboBox) { + seen += 1; + assert!(n.label().is_some_and(|l| !l.trim().is_empty()), "{:?} without a label at {:?}", n.role(), node.rect()); + } + } + assert!(seen > 20, "{seen} controls"); +} + +#[test] +fn table_keys_move_record_and_delete() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(table_focused(&h)); + press(&mut h, Modifiers::NONE, EguiKey::ArrowDown); + assert!(selected(&h, "Save As…")); + press(&mut h, Modifiers::NONE, EguiKey::Delete); + assert_eq!(h.state().bindings("file.saveAs"), Vec::::new()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(h.state().state.is_recording()); + press(&mut h, Modifiers::NONE, EguiKey::F7); + assert_eq!(h.state().bindings("file.saveAs"), ["F7"]); +} + +#[test] +fn group_headers_take_focus_and_toggle_with_enter() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(tab_to(&mut h, "File", 3)); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "Export…: ⌘E"), "File is collapsed"); + assert!(h.get_by_label("File").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "Export…: ⌘E")); +} + +#[test] +fn table_names_the_selected_row_and_messages_are_live() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + let row = h.get_by_role_and_label(Role::Button, "Save").accesskit_node().id(); + assert_eq!(h.get_by_label("Commands").accesskit_node().active_descendant().map(|n| n.id()), Some(row)); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert_eq!(h.get_by_label("⚠ ⌘S is used by Save").accesskit_node().live(), egui::accesskit::Live::Polite); +} + +#[test] +fn small_window_keeps_ok_and_long_labels_truncate() { + let long = "A very long command label ".repeat(12); + let mut km = fixture(Platform::Mac); + let mut catalog = km.catalog().to_vec(); + catalog.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()])); + km = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut fx = Fx::new(km); + fx.window = true; + let size = vec2(480.0, 360.0); + let mut h = harness(fx, size); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let ok = h.get_by_label("OK").rect(); + assert!(screen.contains_rect(ok), "OK at {ok:?}"); + let row = h.get_by_label(long.trim()); + assert!(row.rect().max.x <= size.x + 0.5, "the row stays inside: {:?}", row.rect()); + row.hover(); + h.run(); + assert!(h.query_all_by_label_contains(long.trim()).count() >= 2, "the tooltip has the full label"); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); +} + +#[test] +fn window_close_is_cancel() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.window = true; + let mut h = open(fx); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert_eq!(h.state().bindings("file.save"), ["F2"]); + click(&mut h, "Cancel"); + assert!(h.state().canceled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + let after: Vec = h.state().outcomes.iter().skip_while(|o| !matches!(o, Outcome::Cancel)).map(|o| matches!(o, Outcome::Changed)).collect(); + assert_eq!(after, [false, true], "Changed follows Cancel, which undid an edit"); + assert!(!h.state().state.is_open()); +} + +#[test] +fn automation_ids_are_reported() { + let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.keyboard = true; + let sink = seen.clone(); + fx.options.on_widget = Some(Box::new(move |id, rect, label| { + assert!(rect.is_finite() && !label.is_empty(), "{id}"); + sink.borrow_mut().push(id.to_string()); + })); + let mut h = open(fx); + h.get_by_label("Save").click(); + h.run(); + let ids = seen.borrow(); + for id in [ + "shortcuts.area", + "shortcuts.filter", + "shortcuts.search", + "shortcuts.searchKey", + "shortcuts.row.file.save", + "shortcuts.cell.file.save.0", + "shortcuts.add.file.save", + "shortcuts.reset.file.save", + "shortcuts.remove.0", + "shortcuts.new", + "shortcuts.context", + "shortcuts.assign", + "shortcuts.clear", + "shortcuts.undo", + "shortcuts.redo", + "shortcuts.resetAll", + "shortcuts.cancel", + "shortcuts.ok", + "shortcuts.keyboard", + "shortcuts.mod.cmd", + "shortcuts.key.F7", + ] { + assert!(ids.iter().any(|s| s == id), "{id} not reported"); + } +} + +#[test] +fn typed_entry_parses_and_shows_errors() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "Type…"); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Nope"); + h.run(); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "⚠ unknown key Nope")); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").focus(); + h.run(); + press(&mut h, Modifiers::COMMAND, EguiKey::A); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Shift+K"); + h.run(); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "⚠ unknown key Nope")); + click(&mut h, "Assign"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+Shift+K"]); +} + +struct Rtl; +impl Text for Rtl { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, _n: u64, _one: &str, other: &str) -> String { + other.to_string() + } + fn rtl(&self) -> bool { + true + } +} + +#[test] +fn rtl_puts_the_detail_pane_left_and_keeps_chords() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.text = Box::new(Rtl); + let h = open(fx); + let table = h.get_by_label("Commands").rect(); + assert!(table.min.x > 300.0, "table on the right: {table:?}"); + let chip = h.get_by_label("Save As…: ⇧⌘S").rect(); + assert!(chip.center().x < table.center().x, "shortcut column on the table's left: {chip:?} in {table:?}"); +} + +// ---- Sets ---- + +fn with_sets(profiles: Profiles) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((profiles, vec![("Fixture Defaults", Overrides::new())])); + open(fx) +} + +fn profiles<'h>(h: &'h Harness<'_, Fx>) -> &'h Profiles { + &h.state().sets.as_ref().expect("sets").0 +} + +/// The last control with this label: a prompt's button rather than the set row's. +fn click_last(h: &mut Harness<'_, Fx>, label: &str) { + h.get_all_by_label(label).last().unwrap_or_else(|| panic!("{label}")).click(); + h.run(); +} + +fn new_set(h: &mut Harness<'_, Fx>, name: &str) { + click(h, "New Set…"); + h.get_by_role_and_label(Role::TextInput, "Name:").type_text(name); + h.run(); + click(h, "Create"); +} + +#[test] +fn cancel_restores_keymap_and_sets() { + let mut p = Profiles::default(); + p.save_as("Mine", Overrides::new(), &[("Fixture Defaults", Overrides::new())]).expect("save_as"); + let before = p.clone(); + let mut h = with_sets(p); + click(&mut h, "Set:"); + click_last(&mut h, "Mine"); + assert_eq!(profiles(&h).active(), "Mine"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert!(has(&h, "(modified)")); + new_set(&mut h, "Other"); + assert_eq!(profiles(&h).active(), "Other"); + assert_eq!(h.state().bindings("file.save"), ["F2"], "based on the current shortcuts"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + click_last(&mut h, "Delete"); + assert!(!profiles(&h).sets().contains_key("Other")); + click(&mut h, "Cancel"); + assert!(h.state().canceled()); + assert_eq!(profiles(&h), &before); + assert!(h.state().km.overrides().is_empty()); +} + +#[test] +fn an_open_prompt_holds_the_set_row_table_and_edit_actions() { + let mut h = with_sets(Profiles::default()); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + new_set(&mut h, "Other"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + for label in ["Set:", "New Set…", "Save: F2", "Undo", "Reset All…", "OK"] { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label}"); + } + assert!(h.get_all_by_label("Cancel").all(|n| !n.accesskit_node().is_disabled()), "the prompt's and the dialog's"); + h.get_all_by_label("Cancel").next().expect("the prompt's Cancel").click(); + h.run(); + assert!(h.state().state.is_open()); + assert!(!h.get_by_label("OK").accesskit_node().is_disabled()); +} + +#[test] +fn ok_stores_edits_of_a_builtin_as_a_copy() { + let mut h = with_sets(Profiles::default()); + assert!(h.get_by_label("Rename…").accesskit_node().is_disabled(), "built-ins cannot be renamed"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); + let p = profiles(&h); + assert_eq!(p.active(), "Custom"); + assert_eq!(p.sets().get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); +} + +#[test] +fn set_export_import_round_trip() { + let mut h = with_sets(Profiles::default()); + new_set(&mut h, "Name"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + h.get_all_by_label("Export…").next().expect("the set row's Export…").click(); + h.run(); + let (name, json) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Export { name, json } = o { Some((name.clone(), json.clone())) } else { None }) + .expect("Outcome::Export"); + assert_eq!(name, "Name"); + let edited = h.state().km.overrides().clone(); + + let mut fresh = with_sets(profiles(&h).clone()); + let (d, state) = dialog(fresh.state_mut()); + assert_eq!(d.import_json(state, &json), Ok("Name 2".into())); + fresh.run(); + assert_eq!(profiles(&fresh).active(), "Name 2"); + assert_eq!(fresh.state().km.overrides(), &edited); + assert_eq!(fresh.get_by_label("Set:").accesskit_node().value().as_deref(), Some("Name 2"), "the picker shows it"); +} + +fn foreign_set() -> ForeignSet { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + ForeignSet::new(vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ]) + .with_name("Theirs") +} + +fn review(h: &mut Harness<'_, Fx>) { + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = dialog(h.state_mut()); + d.review_import(state, foreign_set(), options); + h.run(); +} + +#[test] +fn foreign_import_review_lists_skipped_and_unreadable() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(has(&h, "Import “Theirs”")); + for (item, line) in [("File > Save", 3), ("File > Frobnicate", 4), ("File > Export…", 5), ("Object > Arrange > Bring to Front", 6)] { + let found = h.query_all_by_label_contains(item).any(|n| n.accesskit_node().value().is_some_and(|l| l.contains(&format!("line {line}")))); + assert!(found, "{item} with line {line}"); + } + assert!(h.query_by_label_contains("unknown key").is_some(), "the unreadable key says why"); + assert!(h.query_by_label_contains("numeric keypad key read as main key").is_some()); + assert!(h.state().km.overrides().is_empty(), "the review changes nothing"); + click(&mut h, "Import as New Set"); + assert_eq!(profiles(&h).active(), "Theirs"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+Alt+S"]); + assert!(has(&h, "Imported as “Theirs”")); + click(&mut h, "Done"); + assert!(h.state().state.is_open() && has(&h, "Commands")); +} + +#[test] +fn picking_a_suggestion_replans_and_is_remembered() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(h.query_by_label_contains("3 commands will change").is_some()); + click(&mut h, "Command for File > Sve Az"); + click_last(&mut h, "Save As…"); + let remembered = h.state().outcomes.iter().find_map(|o| if let Outcome::Remembered(m) = o { Some(m.clone()) } else { None }); + assert!(remembered.is_some_and(|m| m.values().any(|c| c.id == "file.saveAs"))); + assert!(h.query_by_label_contains("4 commands will change").is_some()); + assert!(h.query_by_label_contains("File > Sve Az · line 7 — Save As…: ").is_some(), "the pick moved to Will change"); + assert!(h.query_by_label("Command for File > Sve Az").is_none()); +} + +// ---- Summary ---- + +#[test] +fn summary_escapes_html_and_keeps_text_lines() { + let mut km = fixture(Platform::Mac); + let mut catalog = km.catalog().to_vec(); + let label = "\"Tom\" & 'Jerry'\nNext"; + catalog.push( + Bindable::new("edit.tricky", label, Scope::app()) + .with_path(vec!["Edit".into(), "Tricky".into()]) + .with_default(craft_keymap::Binding::parse("F6").expect("F6")), + ); + km = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut h = open(Fx::new(km)); + click(&mut h, "Summarize…"); + let (html, text) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Summary { html, text, .. } = o { Some((html.clone(), text.clone())) } else { None }) + .expect("Outcome::Summary"); + assert!(html.contains("<b>"Tom" & 'Jerry'</b>"), "{html}"); + assert!(!html.contains("")); + assert!(html.contains("⌘S")); + assert!(text.lines().any(|l| l == " \"Tom\" & 'Jerry' Next\tF6"), "{text}"); + assert!(text.lines().any(|l| l == " Save\t⌘S"), "{text}"); +} + +#[test] +fn narrow_set_row_moves_actions_into_more() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(560.0, 600.0)); + assert!(h.query_by_label("Summarize…").is_none(), "Summarize… is in More"); + click(&mut h, "More"); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { name, .. } if name == "Fixture Defaults"))); +} + +// ---- On-screen keyboard, fixed rows, read-only ---- + +fn with_keyboard(options: impl FnOnce(&mut Fx)) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.keyboard = true; + options(&mut fx); + open(fx) +} + +/// What a key's color means, as its accessibility description says it. +fn holders_class(h: &Harness<'_, Fx>, key: &str) -> String { + h.get_by_role_and_label(Role::Button, key).accesskit_node().description().unwrap_or_default().to_string() +} + +#[test] +fn keyboard_colors_keys_and_assigns_to_the_selected_command() { + let mut h = with_keyboard(|_| {}); + assert_eq!(holders_class(&h, "M: Marquee Tool, Elliptical Marquee Tool"), "Application"); + assert_eq!(holders_class(&h, "L: Loop (Timeline)"), "Panel or context"); + assert_eq!(holders_class(&h, "F7"), "Not assigned"); + + click(&mut h, "⌘ Command"); + assert_eq!(holders_class(&h, "⌘S: Save"), "Application"); + assert_eq!(holders_class(&h, "⌘B: Bold (Text)"), "Panel or context"); + // Held modifiers add to the toggles. + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.run(); + assert!(has(&h, "⇧⌘S: Save As…")); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run(); + click(&mut h, "⌘ Command"); + + click(&mut h, "L: Loop (Timeline)"); + assert!(has(&h, "Key: L")); + assert!(h.get_by_label("Assign to Selected").accesskit_node().is_disabled(), "no command selected"); + click(&mut h, "Save"); + click(&mut h, "Assign to Selected"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "L"]); + assert_eq!(holders_class(&h, "L: Save, Loop (Timeline)"), "Both"); + + // A holder in the key list selects its command. + click(&mut h, "Loop (Timeline)"); + assert!(selected(&h, "Loop")); +} + +#[test] +fn fixed_rows_are_struck_through_when_a_command_takes_their_key() { + let mut fx = Fx::new(fixture(Platform::Mac)); + let row = |label: &str, keys: &str, chord: Option<&str>| FixedRow { + group: "Rating".into(), + label: label.into(), + keys: keys.into(), + chord: chord.map(|c| Trigger::parse(c).unwrap_or_else(|e| panic!("{c}: {e}"))), + }; + fx.options.fixed = vec![row("Rate 1", "1", Some("1")), row("Rate 2", "2", Some("2")), row("Pan", "Space (hold)", None)]; + let mut h = open(fx); + choose_area(&mut h, "Fixed Keys"); + assert!(has(&h, "Rate 1: 1")); + assert!(has(&h, "Pan: Space (hold)")); + + choose_area(&mut h, "All Commands"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Num1); + assert_eq!(h.state().bindings("file.save"), ["1"]); + choose_area(&mut h, "Fixed Keys"); + assert!(has(&h, "Rate 1: 1 (Now used by Save)")); + assert!(has(&h, "Rate 2: 2")); +} + +#[test] +fn read_only_has_no_edit_controls() { + let mut h = with_keyboard(|fx| { + fx.options.editable = false; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + }); + click(&mut h, "Save"); + click(&mut h, "Save: ⌘S"); + assert!(!has(&h, "Save: press keys"), "no recording"); + click(&mut h, "S"); + assert!(has(&h, "Key: S")); + for label in [ + "Undo", + "Redo", + "Reset All…", + "OK", + "Cancel", + "Set:", + "New Set…", + "Import…", + "Add a shortcut to Save", + "Restore the default for Save", + "Remove ⌘S from Save", + "New shortcut:", + "Context:", + "Assign", + "Use Default", + "Clear", + "Assign to Selected", + ] { + assert!(!has(&h, label), "{label} is shown"); + } + press(&mut h, Modifiers::COMMAND, EguiKey::Z); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { .. }))); + click(&mut h, "Close"); + assert!(h.state().canceled()); +} + +// ---- Pseudo locale ---- + +/// Text a test put there or read from a foreign file, which no lookup translates. +const RAW: &[&str] = &["Theirs", "Frobnicate", "Sve Az", "Blorp", "Num", "Name 2"]; + +/// Every control inside the `width` px dialog, and every label with words marked by `Pseudo`. +fn check_pseudo(h: &Harness<'_, Fx>, width: f32, step: &str) { + let mut seen = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + let Some(label) = n.label() else { continue }; + seen += 1; + let r = node.rect(); + assert!(r.min.x >= -1.0 && r.max.x <= width + 1.0, "{step}: {:?} {label:?} at {r:?} leaves the {width} px dialog", n.role()); + let mut rest = String::new(); + let mut depth = 0usize; + for c in label.chars() { + match c { + '⟦' => depth += 1, + '⟧' => depth = depth.saturating_sub(1), + _ if depth == 0 => rest.push(c), + _ => {} + } + } + for raw in RAW { + rest = rest.replace(raw, ""); + } + let words = rest.split(|c: char| !c.is_ascii_lowercase()).any(|w| w.len() > 1); + assert!(!words, "{step}: {label:?} has text that did not go through Text"); + } + assert!(seen > 15, "{step}: {seen} labeled nodes"); +} + +#[test] +fn pseudo_locale_marks_every_string_and_fits() { + let p = Pseudo::mark; + // Each direction at one width: the narrow layout left to right, the wide one right to left. + for (rtl, width) in [(false, 560.0), (true, 1100.0)] { + let unlisted = std::rc::Rc::default(); + let mut fx = Fx::new(with_long_label(fixture(Platform::Mac))); + fx.text = Box::new(Pseudo { unlisted: std::rc::Rc::clone(&unlisted), rtl }); + fx.options.keyboard = true; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(width, 720.0)); + let step = |s: &str| format!("{} {width}: {s}", if rtl { "rtl" } else { "ltr" }); + check_pseudo(&h, width, &step("opened")); + h.state_mut().state.select(&CommandRef::from("object.distribute")); + h.run(); + check_pseudo(&h, width, &step("long label selected")); + let export = craft_i18n::fmt(&p("{label}: {chord}"), &[("label", &p("Export…")), ("chord", "⌘E")]); + click(&mut h, &export); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, &p("Take It")), "{}", step("conflict bar")); + check_pseudo(&h, width, &step("conflict")); + click(&mut h, &p("Keep Both")); + check_pseudo(&h, width, &step("kept both")); + click(&mut h, &p("Keyboard")); + check_pseudo(&h, width, &step("keyboard hidden")); + review(&mut h); + check_pseudo(&h, width, &step("review")); + assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); + } +} + +#[test] +fn an_app_error_shows_until_dismissed() { + let mut h = mac(); + h.state_mut().state.show_error("The file could not be read"); + h.run(); + assert!(has(&h, "⚠ The file could not be read")); + click(&mut h, "Dismiss"); + assert!(!has(&h, "⚠ The file could not be read")); +} + +#[test] +fn one_pointer_click_on_a_chip_of_another_row_records() { + let mut h = mac(); + click(&mut h, "Save"); + let at = h.get_by_label("Export…: ⌘E").rect().center(); + h.event(Event::PointerMoved(at)); + h.run(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run(); + assert!(has(&h, "Export…: press keys")); +} diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs new file mode 100644 index 00000000..f2f930cc --- /dev/null +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -0,0 +1,156 @@ +//! The catalog the dialog tests and screenshots use. + +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use craft_keymap::ui::{PLURALS, STRINGS, Text}; +use craft_keymap::{Bindable, Binding, Key, Keymap, Kind, Platform, Scope, Scopes}; + +fn binding(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +pub fn fixture(platform: Platform) -> Keymap { + let tool = Kind::Tool { group: Some("Marquee".into()) }; + let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); + let catalog = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(binding("Cmd+S")), + Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(binding("Cmd+Shift+S")), + Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(binding("Cmd+E")), + Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])), + Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(binding("M")), + Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(binding("M")), + Bindable::new("timeline.loop", "Loop", timeline).with_default(binding("L")), + Bindable::new("type.bold", "Bold", Scope::app()).with_default(binding("Text: Cmd+B")), + ]; + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform) +} + +/// `km` with a command whose label and menu path are far too long for a row. +#[allow(dead_code)] +pub fn with_long_label(km: Keymap) -> Keymap { + let long = "Distribute Selected Objects Evenly Along the Active Guide Path and Keep Their Spacing".to_string(); + let mut catalog = km.catalog().to_vec(); + catalog.insert( + 3, + Bindable::new("object.distribute", long.as_str(), Scope::app()) + .with_path(vec!["Object".into(), "Arrange and Distribute Along a Path".into(), long.clone()]) + .with_default(binding("Cmd+Alt+Shift+D")) + .with_default(binding("Ctrl+Alt+Shift+F12")), + ); + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform()) +} + +/// A pseudo translation: every string about 40 % longer, accented, between right-to-left marks and +/// in `⟦…⟧`, so text drawn without the marks never went through [`Text`]. Placeholders stay as +/// they are. Labels and path parts are marked too. Looked-up dialog strings missing from +/// `STRINGS` or `PLURALS` are collected in `unlisted`. +#[allow(dead_code)] +#[derive(Default)] +pub struct Pseudo { + pub unlisted: Rc>>, + /// Lay the dialog out right to left. + pub rtl: bool, +} + +#[allow(dead_code)] +impl Pseudo { + pub fn mark(source: &str) -> String { + let mut out = String::from("⟦\u{200F}"); + let mut in_placeholder = false; + let mut letters = 0; + for c in source.chars() { + match c { + '{' => in_placeholder = true, + '}' => in_placeholder = false, + _ => {} + } + let accented = match c { + _ if in_placeholder => c, + 'a' => 'á', + 'e' => 'ë', + 'i' => 'ï', + 'o' => 'ö', + 'u' => 'ü', + 'A' => 'Å', + 'E' => 'É', + 'O' => 'Ø', + _ => c, + }; + if !in_placeholder && c.is_alphabetic() { + letters += 1; + } + out.push(accented); + } + out.extend(std::iter::repeat_n('~', letters * 2 / 5)); + out.push_str("\u{200F}⟧"); + out + } +} + +impl Text for Pseudo { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + if !STRINGS.contains(&source) { + self.unlisted.borrow_mut().insert(source.to_string()); + } + Pseudo::mark(source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + if !PLURALS.contains(&(one, other)) { + self.unlisted.borrow_mut().insert(format!("{one}|{other}")); + } + Pseudo::mark(if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + Pseudo::mark(&b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + Pseudo::mark(s).into() + } + + /// Key names spelled out in words (Escape, Space), on every platform. + fn key(&self, k: Key, _p: Platform) -> Option { + let name = k.to_string(); + name.chars().any(|c| c.is_ascii_lowercase()).then(|| Pseudo::mark(&name)) + } + + fn rtl(&self) -> bool { + self.rtl + } +} + +/// English laid out right to left, to look at the mirrored layout. +#[allow(dead_code)] +pub struct EnglishRtl; + +impl Text for EnglishRtl { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + (if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn rtl(&self) -> bool { + true + } + + /// The words stay in English order; spaces at either end change sides, as in a right-to-left + /// paragraph. + fn visual(&self, s: &str) -> String { + let core = s.trim(); + if core.is_empty() { + return s.to_string(); + } + let start = s.len() - s.trim_start().len(); + let end = s.trim_end().len(); + format!("{}{core}{}", &s[end..], &s[..start]) + } +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs new file mode 100644 index 00000000..c9779cda --- /dev/null +++ b/crates/keymap/tests/ui_snapshot.rs @@ -0,0 +1,250 @@ +#![cfg(feature = "snapshot")] +//! Screenshots of the Keyboard Shortcuts dialog for review, not a golden comparison. With +//! `CRAFT_KEYMAP_SNAPSHOT_DIR` set, writes PNGs of a few states in light and dark at 1× and 2×; +//! without it the test does nothing. +//! +//! `CRAFT_KEYMAP_SNAPSHOT_DIR=/tmp/shots cargo test -p craft-keymap --features snapshot --test ui_snapshot` +//! +//! Uses the same hash-verified `CRAFT_FONTS_DIR` input as ui_dialog (see README.md). +//! Fonts are installed before the first layout pass; missing glyphs remain errors. + +use std::path::PathBuf; + +use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog, Text}; +use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Overrides, Platform, Profiles}; +use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ui_fixture::{EnglishRtl, Pseudo, fixture, with_long_label}; + +#[path = "support/fonts.rs"] +mod test_fonts; +mod ui_fixture; + +struct Fx { + km: Keymap, + text: Box, + options: Options, + state: DialogState, + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, +} + +impl Fx { + fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, text, options, state, sets } = self; + let d = ShortcutsDialog::new(km, &**text).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) + } +} + +/// A named state of the dialog: window size and what to do before the picture. +struct Case { + name: &'static str, + size: Vec2, + platform: Platform, + /// Show named sets. + sets: bool, + /// Show the on-screen keyboard. + keyboard: bool, + words: Words, + /// Add a command with a very long label and name the user set at length. + long: bool, + act: fn(&mut Harness<'_, Fx>), +} + +#[derive(Clone, Copy)] +enum Words { + English, + /// English laid out right to left. + Rtl, + /// The pseudo translation of the tests, left to right. + Pseudo, +} + +impl Case { + const fn new(name: &'static str, width: f32, height: f32, act: fn(&mut Harness<'_, Fx>)) -> Case { + Case { name, size: Vec2::new(width, height), platform: Platform::Mac, sets: false, keyboard: false, words: Words::English, long: false, act } + } + + const fn windows(mut self) -> Case { + self.platform = Platform::Windows; + self + } + + const fn sets(mut self) -> Case { + self.sets = true; + self + } + + const fn keyboard(mut self) -> Case { + self.keyboard = true; + self + } + + const fn words(mut self, words: Words) -> Case { + self.words = words; + self + } + + const fn long(mut self) -> Case { + self.long = true; + self + } +} + +fn foreign_review(h: &mut Harness<'_, Fx>) { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + let set = ForeignSet::new(vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ]) + .with_name("Theirs"); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = h.state_mut().dialog(); + d.review_import(state, set, options); + h.run(); +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +/// Click the widget carrying `label` and run a frame; used to put a command row +/// into the snapshot's selected state. +fn select(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +const CASES: &[Case] = &[ + Case::new("selected", 1000.0, 640.0, |h| select(h, "Save As…")), + Case::new("conflict", 1000.0, 640.0, |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }), + Case::new("recording", 1000.0, 640.0, |h| click(h, "Save: Ctrl+S")).windows(), + Case::new("narrow", 560.0, 640.0, |h| select(h, "Save")), + Case::new("sets", 1000.0, 640.0, |h| { + click(h, "Save: ⌘S"); + h.key_press(EguiKey::F2); + h.run(); + click(h, "New Set…"); + }) + .sets(), + Case::new("sets-narrow", 560.0, 640.0, |h| click(h, "More")).sets(), + Case::new("keyboard", 1100.0, 720.0, |h| { + click(h, "Save As…"); + click(h, "⌘ Command"); + click(h, "⌘S: Save"); + }) + .keyboard(), + Case::new("keyboard-windows", 860.0, 640.0, |h| click(h, "L: Loop (Timeline)")).windows().keyboard(), + Case::new("review", 1000.0, 640.0, foreign_review).sets(), + Case::new("long", 1000.0, 640.0, |h| { + let state = &mut h.state_mut().state; + state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long(), + Case::new("long-narrow", 560.0, 640.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long(), + Case::new("rtl", 1000.0, 640.0, |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }) + .sets() + .long() + .words(Words::Rtl), + Case::new("rtl-narrow", 560.0, 640.0, |h| select(h, "Bold")).words(Words::Rtl).long(), + Case::new("rtl-keyboard", 1100.0, 720.0, |h| click(h, "L: Loop (Timeline)")).keyboard().words(Words::Rtl), + Case::new("rtl-review", 1000.0, 640.0, foreign_review).sets().words(Words::Rtl), + Case::new("pseudo", 1100.0, 720.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("file.save")); + h.run(); + }) + .sets() + .keyboard() + .long() + .words(Words::Pseudo), + Case::new("pseudo-narrow", 560.0, 640.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long() + .words(Words::Pseudo), +]; + +#[test] +/// Write review screenshots when `CRAFT_KEYMAP_SNAPSHOT_DIR` is set. +/// +/// For every case whose name starts with `CRAFT_KEYMAP_SNAPSHOT_ONLY`, renders light and dark at +/// 1× and 2× with the pinned fonts, optional sets and long labels, after running the case action. +/// Without the directory variable the test does nothing; these are not golden comparisons. +fn dialog_screenshots() { + let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return }; + std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + // `CRAFT_KEYMAP_SNAPSHOT_ONLY=keyboard` renders only the cases whose name starts with it. + let only = std::env::var("CRAFT_KEYMAP_SNAPSHOT_ONLY").unwrap_or_default(); + for case in CASES.iter().filter(|c| c.name.starts_with(&only)) { + for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { + for scale in [1.0, 2.0] { + let sets = case.sets.then(|| { + let mut p = Profiles::default(); + let builtins = vec![("Fixture Defaults", Overrides::new())]; + if case.long { + let name = "My Shortcuts for Long Evenings of Retouching Photographs"; + if let Err(e) = p.save_as(name, Overrides::new(), &builtins).and_then(|n| p.select(&n, &builtins)) { + panic!("{name}: {e}"); + } + } + (p, builtins) + }); + let options = Options::default().with(|o| o.keyboard = case.keyboard); + let km = if case.long { with_long_label(fixture(case.platform)) } else { fixture(case.platform) }; + let text: Box = match case.words { + Words::English => Box::new(English), + Words::Rtl => Box::new(EnglishRtl), + Words::Pseudo => Box::new(Pseudo::default()), + }; + let fx = Fx { km, text, options, state: DialogState::new(), sets }; + let mut h = Harness::builder() + .with_size(case.size) + .with_pixels_per_point(scale) + .with_theme(theme) + .with_context_setup(test_fonts::configure) + .wgpu() + .build_ui_state( + |ui, fx: &mut Fx| { + test_fonts::assert_coverage(ui.ctx()); + egui::Frame::central_panel(ui.style()).show(ui, |ui| { + let (d, state) = fx.dialog(); + d.ui(ui, state); + }); + }, + fx, + ); + h.run(); + (case.act)(&mut h); + let image = h.render().unwrap_or_else(|e| panic!("render {}: {e}", case.name)); + let path = dir.join(format!("shortcuts-{}-{theme_name}@{scale}x.png", case.name)); + image.save(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + } + } + } +} diff --git a/crates/menu/Cargo.toml b/crates/menu/Cargo.toml new file mode 100644 index 00000000..cac69ca2 --- /dev/null +++ b/crates/menu/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "craft-menu" +description = "A Craft app's menus: layouts for macOS, Windows, Linux and the web, shortcuts from craft-keymap, a macOS backend, and key equivalents fed back to egui." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +craft-keymap = { workspace = true, default-features = false, features = ["egui"] } +craft-quit = { workspace = true, features = ["egui"] } +egui = { workspace = true } + +# Already in the apps' Cargo.lock: muda through craft-quit's `menu` feature, objc2 and objc2-app-kit +# through eframe. +[target.'cfg(target_os = "macos")'.dependencies] +muda = { version = "0.21.2", default-features = false } +objc2 = { version = "0.6.4", default-features = false, features = ["std"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSResponder"] } + +[dev-dependencies] +# `default_fonts` so that the bar's tests lay out text. +egui = { workspace = true, features = ["default_fonts"] } + +[lints] +workspace = true diff --git a/crates/menu/README.md b/crates/menu/README.md new file mode 100644 index 00000000..063c2d0e --- /dev/null +++ b/crates/menu/README.md @@ -0,0 +1,280 @@ +# craft-menu + +A Craft app's menus, laid out for macOS, Windows, Linux and the web. The app supplies its menu +tree and runs its commands; this crate lays the menus out the way each platform's apps do, takes +shortcuts from craft-keymap, drives a platform `Backend`, and turns the menu's key +equivalents back into the input egui would have had without it. It depends on egui, +[`craft-keymap`](../keymap/src/lib.rs) (commands and chords) and [`craft-quit`](../quit/src/lib.rs), +and on no app. Everything but the macOS backend +(`craft_menu::mac`, built on muda) is platform-free and also builds for `wasm32`. The layout and driver come from PhotoCraft's `native_menu.rs`, the egui bar from its `menu_nav.rs` and in-window menu bar. + +## The menus + +Build a `MenuBar` directly, or from the flat rows an in-window bar draws (`from_items`, with +`Row { path, command, label, shortcut, enabled, kind }`, `---` for a separator). Give each menu +its role (`MenuRole::{App, File, Edit, View, Window, Help, Other}`), then tag what macOS puts in +fixed places: + +```rust +let mut bar = craft_menu::from_items(&["File", "Edit", "View", "Window", "Help"], &rows); +bar.tag_menu("File", MenuRole::File); +bar.tag_menu("Edit", MenuRole::Edit); +bar.tag_menu("View", MenuRole::View); +bar.tag_menu("Window", MenuRole::Window); +bar.tag_menu("Help", MenuRole::Help); +bar.tag_item(&"help.about".into(), ItemRole::About); +bar.tag_item(&"file.exit".into(), ItemRole::Quit); // the app's quit, with its unsaved-changes prompt +bar.tag_submenu(MenuRole::Edit, "Preferences", ItemRole::Settings); +bar.tag_submenu(MenuRole::View, "Theme", ItemRole::Appearance); +``` + +Each item runs a `craft_keymap::CommandRef`, and a click reports it. A command that takes a +parameter carries it in the `CommandRef`: +`CommandRef::with_params("file.open_recent", json!({"index": 3}))`. Items built directly use +`Item::new(command, label)` with `.shortcut(chord)`, `.enabled(..)`, `.check(..)` or +`.radio(..)` (the item's `ItemKind`), `.role(..)` and `.platforms(..)`; submenus use +`Node::submenu(label, children)`, with `.role(..)` for a tagged one. A shortcut is a +`craft_keymap::Chord`: `cmd` is ⌘ and `ctrl` the Control key. macOS shows radio items as check +items. The app's open documents go in `MenuBar::documents` (`Document::new(command, name)`, +`.active(..)` for the one in front, `hidden` for one kept open but not listed), and the layouts +list them in the Window menu. The items the +layout adds itself run a `Builtin` command (Hide, Hide Others, Minimize, Quit), which the crate +handles. + +## Layouts + +`layout(&bar, target, &LayoutOptions::new("PhotoCraft"))` lays the menus out for a `Target` +(`Mac`, `Windows`, `Linux`, `Web`; `Target::current()` for this build) and returns the bar and +the clashes it resolved. Every layout first drops the items and submenus whose `.platforms(..)` +leave the target out, and lists the open documents (but the hidden ones) last in the Window menu +as radio items, the one in front selected. A submenu left empty goes; for an empty dynamic list +(no recent files) the app supplies a disabled placeholder item, which keeps the submenu. + +| Target | About | Settings | Quit | Also | +|---|---|---|---|---| +| `Mac` | app menu | app menu, Settings… (⌘,) | app menu, ⌘Q | Services, Hide, Minimize, Zoom, Close Window | +| `Windows` | last in Help | last in Edit, Preferences… | last in File, Exit | | +| `Linux` | last in Help | last in Edit, Preferences… | last in File, Quit | | +| `Web` | last in Help | last in Edit, Preferences… | none | browser shortcuts removed | + +The layouts find these items by role, never by title, and add a File, Edit, Window or Help menu +when the app has none to put them in. Language and Appearance stay where the app put them off +macOS, and the Windows, Linux and web layouts keep the app's shortcuts. The browser keeps Ctrl or +⌘ with W, N, T and Q, and with Shift+N, Shift+T and Shift+W, for its tabs and windows, so the +web layout takes those shortcuts off the items and reports each as a `Clash`. + +`mac_layout(&bar, &LayoutOptions::new("PhotoCraft"))` returns the Mac menu bar and the clashes +it resolved: + +- **App menu:** About, Settings (⌘,), Language, Appearance, Services, Hide (⌘H), Hide Others + (⌥⌘H), Show All, Quit (⌘Q). The tagged items leave their old menus. Without a tagged Quit item + the layout adds one with `craft_quit::QUIT_MENU_ID`. A menu the app tagged `MenuRole::App` is + merged in: its other items follow Appearance. +- **Window:** Minimize, Zoom, the app's own Window items, Bring All to Front, then the open + documents. A Window menu is added if the app has none. **File** gets Close Window (⌘W). +- **Clashes:** when the app binds ⌘H, ⌥⌘H, ⌘M, ⌘W, ⌘, or ⌘Q to a command, the command keeps + it. Hide and Minimize move to ⌃⌘H and ⌃⌘M, the others go without a shortcut, and each case is + reported as a `Clash`. Shortcuts bound outside the menus (tool and canvas keys) count when the + app lists them: `LayoutOptions::new("PhotoCraft").extra_shortcuts(&[(chord, command), ..])`. + +The labels the layouts write (`About {app}`, `Preferences…`, `Exit`, `Window`, `Minimize`, …) +are English. To translate them, pass `LayoutOptions { label, ..LayoutOptions::new(app_name) }`, +where `label` maps an English label such as `"Quit {app}"` to the app's translation; the crate +substitutes `{app}`. + +## Keymap + +`apply_keymap(&mut bar, &keymap)` gives each item the shortcut its command has in the app's +`craft_keymap::Keymap`, the user's overrides included: the first single-press binding, one in +the command's home scope first. A command the keymap knows but binds to nothing loses its +shortcut; an item whose command the keymap doesn't know keeps its own. Items of toggle commands +(`Bindable::toggle`) become check items, keeping their checked state; radio items stay radio +items. + +Each frame, `apply_state(&mut bar, |command| ItemState { enabled, checked })` sets enabled and +checked from the app's state. `checked: Some(..)` makes a plain item a check item and selects or +clears a radio item; `None` leaves the kind as it is. The built-in items and the open documents +are left alone. Both run on the app's bar, before the layout. + +## Windows, Linux and web: the egui bar + +`MenuBarUi` draws a laid-out bar with egui, in a top panel, and returns the commands chosen that +frame: + +```rust +let laid_out = craft_menu::layout(&menu_bar, Target::current(), &LayoutOptions::new("PhotoCraft")).bar; +egui::Panel::top("menu-bar").show(ui, |ui| { + let gate = Gate { modal: dialog_open, recording_shortcut, text_editing: ui.ctx().text_edit_focused() }; + for command in MenuBarUi::new(&laid_out).gate(gate).label(&tr).show(ui) { + app.run(&command); + } +}); +``` + +- **Mouse:** a press on a title opens its menu and releasing over an item runs it; a click on + the title again, Esc or a click outside closes it. While a menu is open, moving onto another + title opens that one. Submenus open on hover and stay open while the pointer heads for them + across other rows. A menu taller than the window scrolls, with the wheel or by resting on the + ▲ and ▼ arrows. +- **Keys, while a menu is open:** ↑ and ↓ move the highlight, → opens a submenu or the next + menu, ← closes the submenu or opens the previous menu, ↩ and Space run the item, Esc closes. + `craft_menu::bar::is_open(ctx)` tells the app's shortcut handling to wait meanwhile. +- **Keyboard access (Windows, Linux, web):** a tap on Alt alone, or F10, puts the keyboard on + the bar: the first title is lit, ← and → move along the titles, ↓, ↩ and Space open the lit + menu, Esc, a second tap or a click leaves. Alt+letter opens the menu with that mnemonic, and + in an open menu a letter runs the row with that mnemonic or opens its submenu. Mnemonics are + underlined while Alt is held, while the bar has the keyboard, and in a menu opened from the + keyboard. Each menu level assigns them from the labels as shown, so translated: unique within + the level, the first unused letter, word starts first; `.mnemonics(&[("View", 'W'), + ("file.open", 'P')])` names preferred letters by title as shown or by command id. A label + without an ASCII letter or digit shows its English label's letter after it, "文件(F)", before a + trailing ellipsis ("開く(O)…"), when `.english(&hook)` returns that English label for the + `Labeled::Command` or `Labeled::Title`; without one it has no mnemonic. While the bar has the keyboard the app's shortcuts wait, as + for an open menu (`craft_menu::bar::is_focused(ctx)`); call + `craft_menu::bar::leave_for_shortcut(ctx)` before them so that a command shortcut (Ctrl+S, + F7) leaves the bar and runs. While a dialog is open or a shortcut is being recorded (`Gate`), + Alt, F10 and the letters leave the bar alone. While a text field has the keys, a tap on Alt + still focuses the bar, as on Windows, but a letter without Alt goes on to the field (and takes + the keyboard off the bar) unless a menu is open. On `Target::Mac`, and on `Target::Web` in a + browser on a Mac (`ctx.os()`), the bar has no mnemonics and Alt does nothing: Option is a typing + key there. +- **Items:** disabled items, and the items the `Gate` forbids (`Gate::allows`), are greyed and + never run. Check items show ✓, the selected radio item a dot, and the shortcut column shows + every shortcut, the display-only ones too, in the target's names (⌘ glyphs on `Target::Mac`, + Ctrl names on Windows and Linux, the browser's system's names on the web; `.target(..)` to + choose). +- **Narrow windows:** the menus that don't fit move into a trailing `⋯` menu; when not even the + first fits, one `Menu` title holds them all. `.label(&tr)` translates `⋯`, `Menu` and the + scroll arrows' names, with the same hook as `LayoutOptions::label`. The bar fills its row; + `.shrink_to_fit(max_width)` makes it take only the width its titles need, up to `max_width`, + so other controls can share the row, and the menus past that overflow. +- **Quit:** without an app Quit item, the Windows and Linux layouts add `Builtin::Quit` + (`craft_quit::QUIT_MENU_ID`). The bar runs it itself through `craft_quit::on_menu_event`, + which asks the window to close so the unsaved-changes check runs, and doesn't return it; the + same goes for `Builtin::Minimize`. +- **Accessibility:** AccessKit sees a menu bar of menu items, the open menus, check and radio + items with their state, disabled items, and each item's shortcut and mnemonic (`access_key`). +- **Style:** `BarStyle::from_style(ui.style())` by default; `.style(..)` sets title padding and + color, the menu width, separator room and the check and radio marks. + +## macOS + +Install the menu bar once, on the main thread, in eframe's creator closure (so winit's default +menu doesn't stay up). `None` means AppKit refused the menus: keep the in-window ones. + +```rust +#[cfg(target_os = "macos")] +let native_menu = craft_menu::mac::install(&cc.egui_ctx, &craft_menu::mac_layout(&menu_bar, &LayoutOptions::new("PhotoCraft")).bar); +``` + +An app without a `MenuBar` yet installs the minimal app menu instead: About, Services, Hide, +Hide Others, Show All and Quit, where Quit asks the app first. + +```rust +#[cfg(target_os = "macos")] +craft_menu::mac::install_app_menu(&cc.egui_ctx, "WordCraft")?; +``` + +craft-menu owns muda's menu event handler. muda keeps the first one set per process, so the +crate sets it once and both `mac::install` and `mac::install_app_menu` use it; a later +`mac::install` replaces the minimal app menu, and a second `mac::install` returns `None`. +`craft_quit::install_app_menu` is deprecated in favor of `mac::install_app_menu`: it sets its own +handler, so an app calls it or craft-menu's installers, never both. Called first, it leaves +craft-menu's menus without events; called after, it replaces their menu bar. Quit is +never AppKit's predefined Quit: it is the app's item tagged `ItemRole::Quit`, or the item with +`craft_quit::QUIT_MENU_ID`, which runs `craft_quit::on_menu_event` (the window is asked to close, +so the app's unsaved-changes prompt runs). Each menu event wakes egui, so a chosen item runs at +once rather than with the next input event. + +The backend calls AppKit's Window and Help menu hooks for the menus with `MenuRole::Window` and +`MenuRole::Help`; it never looks at titles. Services is AppKit's own submenu. AppKit's Window +menu lists the app's windows after the menu's own items. An app that keeps its documents in one +window and lists them in the Window menu itself (`MenuBar::documents`) would see each twice, so it +installs with +`mac::install_with(ctx, &bar, MacOptions::default().window_list(false))`. + +Only shortcuts with ⌘ or ⌃ become key equivalents. The others (`B`, `⇧B`, `⌥B`, a bare `F5`) are +tool keys the app handles itself; as key equivalents AppKit would take them from text fields. +The native menu does not show them: muda 0.21 gives no access to the `NSMenuItem`, and its +`set_attributed_title` needs an `NSAttributedString` with a font and a right tab stop, which +objc2 builds only through `unsafe` calls, and the workspace forbids unsafe code. The egui bar +shows them in its shortcut column. + +Check and radio items are AppKit check items. AppKit flips one when it is chosen; the next sync +sets it back to the app's state, so the app's state always wins. + +While a menu is open, a click or Escape in the window closes it and does not reach egui (no +canvas gesture, no tool action); pointer moves pass through. Only one click is kept this way +until the menu reports closed, so a menu wrongly reported open costs one click. Menu updates wait +until the menu closes. A rebuild that fails keeps the menus as they were. + +## Per frame + +Key equivalents stay with egui: the backend reports them as `Event::Key`, and `raw_input` feeds +each one to egui as egui-winit would have, so the app's own key handling (text fields, dialogs, +tool-specific undo) runs as without a native menu. ⌘C, ⌘X and ⌘V become egui's Copy, Cut and +Paste (`ClipboardKeys::Egui`, the default), so text fields copy and paste; an app that runs its +clipboard commands from the keys itself sets `menu.clipboard_keys(ClipboardKeys::Raw)` to get +key presses. A shortcut the layout assigned (⌘Q on the app's Quit item, ⌘, on Settings) comes +back as a click instead, since the app's key handling doesn't bind it. + +The app tells the menu what may run with a `Gate`, every frame (`set_gate`): + +| Gate field | Effect on the native menu | +| --- | --- | +| `modal` | The app's items are greyed (updated in place) and their clicks dropped, also clicks that arrived before the dialog opened. Hide, Hide Others, Minimize and Quit stay, and so do the items whose shortcut a text field handles (Undo, Copy, Select All): AppKit doesn't pass a disabled item's key equivalent on, so ⌘C and ⌘Z would die in the dialog's fields. | +| `recording_shortcut` | As `modal`, and every key equivalent reaches egui as the plain key press (⌘Q, ⌘H, ⌘C included), so the recorder sees it. | +| `text_editing` | A key equivalent comes back as a click (⌘S saves while typing), except the keys the field handles itself: ⌘C, ⌘X and ⌘V (as `ClipboardKeys` says) and `craft_keymap::text_editing_chords` (⌘A, ⌘Z, ⇧⌘Z, ⌘←, …). A mouse click on one of those items (Edit › Copy, Undo) is a press and release of its shortcut, so it acts on the field. | + +Each frame, in this order: + +1. `raw_input`, before egui reads the input (`eframe::App::raw_input_hook`). It uses the gate + from the last `set_gate`. +2. `set_gate`, with the app's state for this frame. +3. `take_clicks`, and run the commands it returns: the clicks since the last frame that the gate + allows now. Minimize and Quit are already handled. +4. `sync_with`, which lays the menus out again only when the app's state changed or a menu event + arrived, and re-syncs them in place when the gate changed. + +```rust +// eframe::App::raw_input_hook, before egui reads the input: +if let Some(menu) = &mut self.native_menu { + menu.raw_input(raw_input); +} + +// In the frame: +if let Some(menu) = &mut self.native_menu { + menu.set_gate(Gate { + modal: self.dialog.is_some(), + recording_shortcut: self.shortcut_editor.is_recording(), + text_editing: ctx.wants_keyboard_input(), + }); + for command in menu.take_clicks(ctx) { + self.run_command(ctx, &command); + } + let state = self.menu_state_hash(craft_menu::had_input(ctx).then_some(ctx.cumulative_frame_nr())); + for c in menu.sync_with(state, || craft_menu::mac_layout(&self.menu_bar(), &LayoutOptions::new("PhotoCraft"))) { + log::info!("menu: {} is {} on macOS and {} in the app; {}", c.shortcut, c.system, c.command, c.resolution); + } +} +``` + +`sync_with` builds the layout only when `state` changed or a menu event arrived, because +building the rows usually walks every command's enabled state. The backend then updates labels, +enabled and checked in place and rebuilds only when `structure_key` (or, for the open +documents, `window_key`) changes. Adding or removing an item in a dynamic list (recent files, +workspaces, plugins) changes `structure_key`; relabelling one doesn't. An item that was a check +item once stays one, unchecked, when the app's state stops giving it a checked state, so that +doesn't rebuild the menus. + +`state` must cover everything the menus show. A change that comes without input, such as a load +or save finishing on a worker or a timer, leaves the menus stale until the next input or menu +event unless it is in the hash. An app that can't keep the hash complete calls +`menu.sync(layout)` instead, with the layout built every frame: it is compared with the last +one and synced only when it differs. + +An app that needs a click while egui is idle (to wake its own loop, or hand the command to a +worker) sets `menu.on_command(Box::new(|command| ..))`. The hook runs from the menu event +handler as the click arrives, on the main thread, for the app commands the gate allows; key +equivalents reported as key presses and the built-in items don't reach it. It returns `true` +when it ran the command; `take_clicks` leaves those out and returns the others. diff --git a/crates/menu/src/bar/keys.rs b/crates/menu/src/bar/keys.rs new file mode 100644 index 00000000..ba79290b --- /dev/null +++ b/crates/menu/src/bar/keys.rs @@ -0,0 +1,414 @@ +//! The bar from the keyboard, as a Windows menu bar: a tap on Alt (or F10) puts the keyboard on +//! the bar (the first title lit; ← / → move along it, ↓ / ↩ / Space open the lit menu, Esc or a +//! click leaves), Alt+letter opens the menu with that mnemonic straight away, and in an open menu +//! a letter runs the row with that mnemonic (or opens its submenu). +//! +//! Mnemonics are underlined while Alt is held, the bar is focused or a menu was opened from the +//! keyboard; a menu opened with the mouse shows none until Alt is pressed, as Windows does. +//! They are assigned per menu level from the labels as shown (so translated), unique within +//! the level: the app's preferred letter where the label has it, else the first unused letter, +//! word starts first. Only ASCII letters and digits are keys the keyboard reports as such, so a +//! label without one (Japanese, Chinese…) shows its English label's letter after it, as Windows +//! menus do in those languages: "文件(F)" ([`localized`]). +//! +//! On a Mac target none of this runs: Option is a typing key there. + +use egui::{Color32, Event, Id, Key, Modifiers, Stroke, TextFormat}; + +use craft_keymap::CommandRef; + +use super::nav::{Nav, is_open}; +use crate::Gate; + +/// Keyboard state of the bar between frames. +#[derive(Clone, Debug, Default)] +pub(crate) struct Keys { + /// The title the keyboard is on with no menu open, in screen order (Alt tap, F10). + pub focus: Option, + /// Underline the mnemonics: the bar is focused or a menu was opened from the keyboard. + pub underline: bool, + /// Alt is down (from its key events, whatever the platform reports as modifiers). + alt_down: bool, + /// Alt went down and nothing else was pressed since: releasing it is a tap. + alt_tap: bool, + /// The mouse buttons that are down, one bit each, from the button events in order (the + /// frame's pointer state is its end state, which a whole click within one frame doesn't show). + buttons_held: u8, + /// A menu was just opened from the keyboard: light its first row once it is drawn. + first_row: bool, +} + +fn focus_id() -> Id { + Id::unique("craft-menu-bar-focus") +} + +fn leave_id() -> Id { + Id::unique("craft-menu-bar-leave") +} + +/// Whether the keyboard is on the bar (Alt tap or F10, no menu open yet). The app's shortcuts +/// leave the keys to it meanwhile, as they do for an open menu ([`super::is_open`]): ← / → / ↓ / +/// ↩ / Esc and the letters drive the bar. Call [`leave_for_shortcut`] first so that a command +/// shortcut still runs. +pub fn is_focused(ctx: &egui::Context) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(focus_id())).is_some_and(|seen| seen + 2 >= pass) +} + +/// A command shortcut pressed while the keyboard is on the bar with no menu open (Ctrl+S, F7…) +/// takes it off the bar, so the shortcut runs in the same press, as in a native menu bar. Keys +/// the bar uses (arrows, ↩, Esc, letters, Alt+letter, F10) keep driving it, and an open menu +/// still holds the keys. True when the bar was left: [`is_focused`] is false from then on. +pub fn leave_for_shortcut(ctx: &egui::Context) -> bool { + if !is_focused(ctx) || is_open(ctx) { + return false; + } + let shortcut = + ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, modifiers, .. } if is_command_key(*key, *modifiers)))); + if !shortcut { + return false; + } + ctx.data_mut(|d| { + d.remove::(focus_id()); + d.insert_temp(leave_id(), true); + }); + ctx.request_repaint(); + true +} + +/// A key press that is a command shortcut rather than a key of the bar: Cmd / Ctrl with a key +/// (not the modifier keys themselves), or a function key other than F10 (which toggles the bar). +pub(crate) fn is_command_key(key: Key, mods: Modifiers) -> bool { + let modifier_key = matches!( + key, + Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight + ); + if modifier_key { + return false; + } + let function_key = key != Key::F10 && key.name().strip_prefix('F').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); + mods.command || mods.ctrl || mods.mac_cmd || function_key +} + +/// The mnemonic a key press stands for: the letter or digit of a plain key. +pub(crate) fn key_char(key: Key) -> Option { + let name = key.name(); + let mut chars = name.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphanumeric() => Some(c.to_ascii_uppercase()), + _ => None, + } +} + +/// Mnemonics for `labels`, unique within the group: the preferred letter when the label has it, +/// else the first unused ASCII letter or digit, trying the first character of each word before +/// the others. Compared case-insensitively; the returned letter is upper case. +pub(crate) fn assign(labels: &[&str], preferred: &[Option]) -> Vec> { + let mut used: Vec = Vec::new(); + let mut out = Vec::with_capacity(labels.len()); + for (i, label) in labels.iter().enumerate() { + let upper: Vec = label.chars().map(|c| c.to_ascii_uppercase()).collect(); + let has = |c: char| upper.contains(&c.to_ascii_uppercase()); + let mut pick = preferred.get(i).copied().flatten().map(|c| c.to_ascii_uppercase()).filter(|&c| has(c) && !used.contains(&c)); + if pick.is_none() { + let starts = label.split(|c: char| !c.is_alphanumeric()).filter_map(|w| w.chars().next()); + let rest = label.chars(); + pick = starts.chain(rest).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).find(|c| !used.contains(c)); + } + if let Some(c) = pick { + used.push(c); + } + out.push(pick); + } + out +} + +/// The labels to show and their mnemonics for `labels` as the app shows them, whose English +/// originals are `english` (same order; `None` when the app gave none). A label with an ASCII +/// letter or digit keeps its text and is assigned as [`assign`] does. One without (Chinese, +/// Japanese, Korean…) can't be typed on its own, so it shows its English label's letter after it +/// in parentheses and takes that letter, as Windows menus do in those languages ("文件(F)"). +/// Without an English label it keeps its text and gets no mnemonic. Mnemonics stay unique within +/// the group, and a shown "(F)" is always the row's key. +pub(crate) fn localized(labels: &[&str], english: &[Option], preferred: &[Option]) -> (Vec, Vec>) { + let typed = |l: &str| l.chars().any(|c| c.is_ascii_alphanumeric()); + // The key goes before a trailing ellipsis, as Windows writes it: "新建(N)…". + let with_key = |l: &str, k: char| match l.strip_suffix('…').map(|b| (b, "…")).or_else(|| l.strip_suffix("...").map(|b| (b, "..."))) { + Some((base, tail)) => format!("{base}({k}){tail}"), + None => format!("{l}({k})"), + }; + // Labels without an English one take part with a letterless stand-in, so the others keep + // their places. + let english: Vec<&str> = english.iter().map(|e| e.as_deref().unwrap_or("")).collect(); + let english_keys = assign(&english, preferred); + let mut shown = Vec::with_capacity(labels.len()); + let mut pref = Vec::with_capacity(labels.len()); + for (i, label) in labels.iter().enumerate() { + match english_keys.get(i).copied().flatten().filter(|_| !typed(label)) { + Some(k) => { + shown.push(with_key(label, k)); + pref.push(Some(k)); + } + None => { + shown.push((*label).to_owned()); + pref.push(preferred.get(i).copied().flatten()); + } + } + } + // The suffixed labels take their keys first: a label with letters of its own must not take + // the key a suffix shows ("Camera Raw 滤镜…" before "转换为智能滤镜(C)" leaves C to the latter). + let suffixed = |i: &usize| shown.get(*i).map(String::as_str) != labels.get(*i).copied(); + let order: Vec = (0..shown.len()).filter(suffixed).chain((0..shown.len()).filter(|i| !suffixed(i))).collect(); + let ordered: Vec<&str> = order.iter().map(|&i| shown.get(i).map_or("", String::as_str)).collect(); + let ordered_pref: Vec> = order.iter().map(|&i| pref.get(i).copied().flatten()).collect(); + let mut keys = vec![None; shown.len()]; + for (&i, key) in order.iter().zip(assign(&ordered, &ordered_pref)) { + if let Some(slot) = keys.get_mut(i) { + *slot = key; + } + } + (shown, keys) +} + +/// Append `label` to `job` after `lead` points, with the first occurrence of `mnemonic` +/// (case-insensitively) underlined. +pub(crate) fn append_underlined(job: &mut egui::text::LayoutJob, label: &str, mut lead: f32, mnemonic: Option, format: &TextFormat) { + let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8()))); + let parts = match at { + Some((i, len)) => [label.get(..i), label.get(i..i + len), label.get(i + len..)], + None => [Some(label), None, None], + }; + let mut any = false; + for (k, part) in parts.into_iter().enumerate() { + let Some(part) = part.filter(|p| !p.is_empty()) else { continue }; + let underline = if k == 1 { Stroke::new(1.0, format.color) } else { Stroke::NONE }; + job.append(part, lead, TextFormat { underline, ..format.clone() }); + lead = 0.0; + any = true; + } + if !any { + // An empty label still keeps its lead. + job.append("", lead, format.clone()); + } +} + +/// A title's text: `label` in `color`, its mnemonic underlined. +pub(crate) fn title_text(ui: &egui::Ui, label: &str, mnemonic: Option, color: Color32) -> egui::WidgetText { + let font = egui::TextStyle::Button.resolve(ui.style()); + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = f32::INFINITY; + append_underlined(&mut job, label, 0.0, mnemonic, &TextFormat { font_id: font, color, ..Default::default() }); + job.into() +} + +/// Take `key` (with `mods`) out of this frame's events, and the text it typed: a letter the bar +/// used doesn't also land in a text field. +fn consume_letter(ctx: &egui::Context, mods: Modifiers, key: Key, c: char) { + ctx.input_mut(|i| { + i.consume_key(mods, key); + i.events.retain(|e| !matches!(e, Event::Text(t) if t.chars().count() == 1 && t.chars().all(|t| t.to_ascii_uppercase() == c))); + }); +} + +impl Keys { + /// Last frame's state, without the focus when [`leave_for_shortcut`] took it. + pub fn load(ctx: &egui::Context, id: Id) -> Keys { + let mut keys: Keys = ctx.data(|d| d.get_temp(id)).unwrap_or_default(); + if ctx.data_mut(|d| d.remove_temp::(leave_id())).unwrap_or(false) { + keys.focus = None; + keys.underline = false; + } + keys + } + + /// The state as the last frame left it. + #[cfg(test)] + pub fn current(ctx: &egui::Context, id: Id) -> Keys { + ctx.data(|d| d.get_temp(id)).unwrap_or_default() + } + + pub fn store(self, ctx: &egui::Context, id: Id) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + if self.focus.is_some() { + d.insert_temp(focus_id(), pass); + } else { + d.remove::(focus_id()); + } + d.insert_temp(id, self); + }); + } + + /// Underline the mnemonics this frame? + pub fn show_underlines(&self, ctx: &egui::Context) -> bool { + self.underline || self.focus.is_some() || self.alt_down || ctx.input(|i| i.modifiers.alt) + } + + /// Before drawing: a menu just opened from the keyboard lights its first row (once, when it + /// is drawn); with no menu open and the keyboard off the bar, nothing is underlined any more. + pub fn begin(&mut self, nav: &mut Nav) { + if nav.top.is_some() { + if self.first_row { + nav.highlight_first(0); + self.first_row = false; + } + } else if self.focus.is_none() && !self.first_row { + self.underline = false; + } + } + + /// Off: a Mac target, or a dialog or the shortcut recorder has the keys. Forget the focus + /// and any tap in progress. + pub fn off(&mut self) { + *self = Keys { buttons_held: self.buttons_held, ..Keys::default() }; + } + + /// Handle this frame's keys, after the menus were drawn and [`Nav::keys`] took the arrows. + /// `tops` are the titles' popup ids and `mnemonics` their letters, both in screen order; + /// `nav` is the open menu's state. A command a mnemonic ran lands in `clicked`. + pub fn keys( + &mut self, + ctx: &egui::Context, + gate: Gate, + tops: &[Id], + mnemonics: &[Option], + nav: &mut Nav, + clicked: &mut Option, + ) { + if tops.is_empty() { + return; + } + let events = ctx.input(|i| i.events.clone()); + let mut tap = false; + // The tap is judged on every event of the frame, also those another handler consumed + // before the bar's turn: a shortcut such as Ctrl+Alt+I that ran first leaves no trace in + // `events`, and releasing Alt must not then count as a tap. + let raw = ctx.input(|i| i.raw.events.clone()); + for e in &raw { + match e { + Event::Key { key: Key::AltLeft | Key::AltRight, pressed: true, .. } => { + self.alt_down = true; + // Alt pressed while a mouse button is down is a modifier for that drag, never + // a tap. + self.alt_tap = self.buttons_held == 0; + } + Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => { + self.alt_down = false; + tap |= std::mem::take(&mut self.alt_tap); + } + // A click or the end of a drag while Alt is held is no tap. It also cancels a tap + // taken earlier in this frame: a whole drag can arrive in one frame (press, Alt + // down, Alt up, release). + Event::PointerButton { button, pressed, .. } => { + let bit = 1u8 << (*button as u8); + if *pressed { + self.buttons_held |= bit; + } else { + self.buttons_held &= !bit; + } + self.alt_tap = false; + tap = false; + } + // Alt going to the desktop is no tap, and a button let go out there is up. + Event::WindowFocused(false) => { + self.alt_tap = false; + self.buttons_held = 0; + } + // Alt+wheel (zoom, say) is no tap either. + Event::Key { pressed: true, .. } | Event::Text(_) | Event::MouseWheel { .. } => self.alt_tap = false, + _ => {} + } + } + if events.iter().any(|e| matches!(e, Event::PointerButton { pressed: true, .. })) { + self.focus = None; + self.underline = false; + } + let open = nav.top.is_some(); + let n = tops.len(); + let open_top = |i: usize, keys: &mut Keys| { + if let Some(&id) = tops.get(i) { + egui::Popup::open_id(ctx, id); + keys.focus = None; + keys.underline = true; + keys.first_row = true; + ctx.request_repaint(); + } + }; + // Alt+letter opens that menu, open or not (an open one switches), as on Windows. + let alt_letter = events.iter().find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if modifiers.alt && !modifiers.command && !modifiers.ctrl => { + key_char(*key).map(|c| (*key, c)) + } + _ => None, + }); + if let Some((key, c)) = alt_letter + && let Some(i) = mnemonics.iter().position(|m| *m == Some(c)) + { + consume_letter(ctx, Modifiers::ALT, key, c); + self.alt_tap = false; + open_top(i, self); + return; + } + let letter = || { + events.iter().find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.ctrl && !modifiers.alt && !modifiers.mac_cmd => { + key_char(*key).map(|c| (*key, c)) + } + _ => None, + }) + }; + if open { + // A letter runs the row with that mnemonic in the level the arrows are in, a text + // field having the keys or not: the open menu has them. + if let Some((key, c)) = letter() + && let Some(row) = nav.row_with_mnemonic(c) + { + consume_letter(ctx, Modifiers::NONE, key, c); + self.underline = true; + if let Some(cmd) = row.command.clone() { + *clicked = Some(cmd); + egui::Popup::close_all(ctx); + } else { + nav.open_sub_row(ctx, row.widget); + } + ctx.request_repaint(); + } + if tap { + egui::Popup::close_all(ctx); + self.underline = false; + ctx.request_repaint(); + } + return; + } + // No menu open: Alt (tapped) or F10 puts the keyboard on the bar or takes it off again. + let f10 = ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::F10)); + if tap || f10 { + self.focus = if self.focus.is_some() { None } else { Some(0) }; + self.underline = false; + ctx.request_repaint(); + return; + } + let Some(focus) = self.focus.map(|f| f.min(n - 1)) else { return }; + let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + if press(Key::Escape) { + self.focus = None; + } else if press(Key::ArrowRight) { + self.focus = Some((focus + 1) % n); + } else if press(Key::ArrowLeft) { + self.focus = Some((focus + n - 1) % n); + } else if press(Key::ArrowDown) || press(Key::Enter) || press(Key::Space) { + open_top(focus, self); + } else if let Some((key, c)) = letter() { + if gate.text_editing { + // Typing goes on in the text field, and the keyboard leaves the bar for it. + self.focus = None; + } else if let Some(i) = mnemonics.iter().position(|m| *m == Some(c)) { + consume_letter(ctx, Modifiers::NONE, key, c); + open_top(i, self); + } + } else { + return; + } + ctx.request_repaint(); + } +} diff --git a/crates/menu/src/bar/mod.rs b/crates/menu/src/bar/mod.rs new file mode 100644 index 00000000..d034b9ad --- /dev/null +++ b/crates/menu/src/bar/mod.rs @@ -0,0 +1,705 @@ +//! The menu bar drawn with egui, for Windows, Linux and the web (and macOS, for testing): an +//! already laid-out [`MenuBar`] as a row of menu titles whose menus open below them. +//! +//! A press on a title opens its menu (releasing over an item runs it, as in a native bar), a +//! second press or Esc closes it, and while one is open the pointer moving onto another title +//! opens that one. Submenus open on hover and stay open while the pointer heads for them. Tall +//! menus scroll, and the arrow keys, ↩ and Space drive an open menu ([`nav`]). Except on a Mac +//! target, a tap on Alt or F10 puts the keyboard on the bar and the menus have mnemonics, as on +//! Windows ([`keys`]). When the titles don't fit, the last ones move into a trailing "⋯" menu, +//! or all of them into one "Menu". + +pub(crate) mod keys; +pub(crate) mod nav; + +use craft_keymap::{CommandRef, Platform}; +use egui::accesskit::{HasPopup, Role, Toggled}; +use egui::containers::menu::{MenuConfig, MenuState, SubMenu, SubMenuButton}; +use egui::text::LayoutJob; +use egui::{Color32, Id, PopupCloseBehavior, Response, TextFormat, TextStyle, Ui, UiKind, UiStackInfo, WidgetText}; + +use crate::model::Builtin; +use crate::{Gate, Item, ItemKind, Menu, MenuBar, Node, Target}; +use keys::Keys; +use nav::Nav; + +pub use keys::{is_focused, leave_for_shortcut}; +pub use nav::is_open; + +/// A row or title, as the bar names it to the app's mnemonic hooks ([`MenuBarUi::english`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Labeled<'s> { + /// An item, by its command. + Command(&'s CommandRef), + /// A menu or submenu, by its title as shown. + Title(&'s str), +} + +/// The app's English labels ([`MenuBarUi::english`]). +type EnglishHook<'h> = dyn Fn(Labeled<'_>) -> Option + 'h; + +/// How the bar looks. [`BarStyle::from_style`] takes its defaults from an egui style. +#[derive(Clone, Debug, PartialEq)] +pub struct BarStyle { + /// Padding around a menu title. + pub title_padding: egui::Vec2, + /// The menu titles' text color. + pub title_color: Color32, + /// The narrowest a menu is. + pub menu_width: f32, + /// Room a separator takes: the line and the space above and below it. + pub separator_spacing: f32, + /// Shown before the label of a checked check item. + pub check_mark: String, + /// Shown before the label of the selected radio item. + pub radio_mark: String, +} + +impl BarStyle { + /// Title padding, title color and a menu width in step with the row height, from `style`. + pub fn from_style(style: &egui::Style) -> BarStyle { + BarStyle { + title_padding: style.spacing.button_padding, + title_color: style.visuals.widgets.inactive.text_color(), + // egui's `menu_width` is where menu text wraps, far wider than a menu needs. + menu_width: 12.0 * style.spacing.interact_size.y, + separator_spacing: 9.0, + check_mark: "✓".to_owned(), + radio_mark: "●".to_owned(), + } + } +} + +impl Default for BarStyle { + fn default() -> BarStyle { + BarStyle::from_style(&egui::Style::default()) + } +} + +/// The platform whose key names a target's shortcut column shows; for the web, the one the +/// browser runs on (`os`, [`egui::Context::os`]). +fn keymap_platform(target: Target, os: egui::os::OperatingSystem) -> Platform { + use egui::os::OperatingSystem; + match target { + Target::Mac => Platform::Mac, + Target::Windows => Platform::Windows, + Target::Linux => Platform::Linux, + Target::Web => match os { + OperatingSystem::Mac | OperatingSystem::IOS => Platform::Mac, + OperatingSystem::Windows => Platform::Windows, + _ => Platform::Linux, + }, + } +} + +fn english(label: &'static str) -> String { + label.to_owned() +} + +/// The bar for one frame: `MenuBarUi::new(&bar).gate(gate).show(ui)` returns the commands +/// chosen. Draw it at the top of the window, in a top panel. +pub struct MenuBarUi<'a> { + bar: &'a MenuBar, + gate: Gate, + target: Target, + style: Option, + label: &'a dyn Fn(&'static str) -> String, + english: Option<&'a EnglishHook<'a>>, + preferred: &'a [(&'a str, char)], + id: Id, + fit: Option, +} + +impl<'a> MenuBarUi<'a> { + /// The bar for `bar`, already laid out ([`crate::layout`]), on [`Target::current`]. + pub fn new(bar: &'a MenuBar) -> MenuBarUi<'a> { + MenuBarUi { + bar, + gate: Gate::default(), + target: Target::current(), + style: None, + label: &english, + english: None, + preferred: &[], + id: Id::unique("craft-menu-bar"), + fit: None, + } + } + + /// What may run this frame: the items `gate` forbids ([`Gate::allows`]) are drawn and + /// behave as disabled. + pub fn gate(mut self, gate: Gate) -> MenuBarUi<'a> { + self.gate = gate; + self + } + + /// The platform the bar is on, for the shortcut column ([`craft_keymap::Chord::display`]): ⌘ + /// glyphs on [`Target::Mac`] and in a browser on a Mac ([`egui::Context::os`]), Ctrl names + /// everywhere else. Where the names are a Mac's, the bar has no mnemonics. + pub fn target(mut self, target: Target) -> MenuBarUi<'a> { + self.target = target; + self + } + + /// The bar's look; [`BarStyle::from_style`] of the `Ui` by default. + pub fn style(mut self, style: BarStyle) -> MenuBarUi<'a> { + self.style = Some(style); + self + } + + /// The app's translation of the labels the bar writes itself: `⋯` (the menu of titles that + /// don't fit), `Menu` (the one menu when none fits), `Scroll menu up` and `Scroll menu down` + /// (the scroll arrows' accessible names). The same hook as [`crate::LayoutOptions::label`]. + pub fn label(mut self, label: &'a dyn Fn(&'static str) -> String) -> MenuBarUi<'a> { + self.label = label; + self + } + + /// The English label of a row or title, for labels the keyboard can't type a letter of + /// (Chinese, Japanese, Korean…): such a label shows its English label's mnemonic after it, + /// "文件(F)", before a trailing ellipsis ("開く(O)…"), as Windows menus do. Without this hook (or when it returns `None`) such a label + /// has no mnemonic. Not asked on a Mac target, which has no mnemonics. + pub fn english(mut self, english: &'a dyn Fn(Labeled<'_>) -> Option) -> MenuBarUi<'a> { + self.english = Some(english); + self + } + + /// Preferred mnemonics, each for a menu or submenu by its title as shown or for an item by + /// its command id: the row takes that letter when its label has it and no row before it in + /// its menu took it. Every other row takes the first unused letter of its label, word starts + /// first. + pub fn mnemonics(mut self, preferred: &'a [(&'a str, char)]) -> MenuBarUi<'a> { + self.preferred = preferred; + self + } + + /// Take only the width the titles need, at most `max_width` (`f32::INFINITY` for no limit) + /// and the room the `Ui` has, so that other controls can share the row; past that the menus + /// overflow into "⋯". By default the bar fills the row. + pub fn shrink_to_fit(mut self, max_width: f32) -> MenuBarUi<'a> { + self.fit = Some(max_width); + self + } + + /// The bar's id, where its state lives in egui memory: tells two bars in one window apart. + pub fn id(mut self, id: Id) -> MenuBarUi<'a> { + self.id = id; + self + } + + /// Draw the bar and its open menu; returns the commands chosen this frame (at most one), + /// clicked or run with ↩ or Space. Disabled items, and items the gate forbids, never run. + /// [`Builtin::Quit`] (asks the window to close, see [`craft_quit::on_menu_event`]) and + /// [`Builtin::Minimize`] run here and are left out. + pub fn show(self, ui: &mut Ui) -> Vec { + let ctx = ui.ctx().clone(); + let style = self.style.clone().unwrap_or_else(|| BarStyle::from_style(ui.style())); + let nav_id = self.id.with("nav"); + let mut nav = Nav::load(&ctx, nav_id); + let keys_id = self.id.with("keys"); + let mut keys = Keys::load(&ctx, keys_id); + // Option is a typing key on a Mac, also in a browser there; a dialog or the shortcut + // recorder has the keys. + let platform = keymap_platform(self.target, ctx.os()); + let mnemonics = platform != Platform::Mac; + let keys_on = mnemonics && !self.gate.modal && !self.gate.recording_shortcut; + if keys_on { + keys.begin(&mut nav); + nav.underline = keys.show_underlines(&ctx); + } else { + keys.off(); + } + let mut draw = Draw { + gate: self.gate, + platform, + style: &style, + label: self.label, + mnemonics, + english: self.english, + preferred: self.preferred, + press: self.id.with("press"), + clicked: None, + }; + let room = self.fit.map_or(ui.available_width(), |max| max.min(ui.available_width())).max(0.0); + let width = match self.fit { + // The half point keeps rounding from pushing the last title into "⋯". + Some(_) => (self.title_widths(ui, &draw, style.title_padding.x).iter().sum::() + 0.5).min(room), + None => room, + }; + ui.allocate_ui(egui::vec2(width, ui.available_height()), |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + egui::MenuBar::new().ui(ui, |ui| { + ctx.accesskit_node_builder(ui.unique_id(), |n| n.set_role(Role::MenuBar)); + ui.spacing_mut().button_padding = style.title_padding; + nav.bar_bottom = Some(ui.max_rect().bottom()); + let tops = self.tops(ui, &draw); + let names: Vec<(&str, Name<'_>)> = tops.iter().map(Top::name).collect(); + let (titles, title_keys) = draw.mnemonics(&names); + let mut buttons = Vec::with_capacity(tops.len()); + for (k, top) in tops.iter().enumerate() { + let shown = titles.get(k).map_or(top.title(), String::as_str); + let mnemonic = title_keys.get(k).copied().flatten(); + let text: WidgetText = if nav.underline { + keys::title_text(ui, shown, mnemonic, style.title_color) + } else { + egui::RichText::new(shown).color(style.title_color).into() + }; + let mut button = egui::Button::new(text); + if keys.focus == Some(k) { + // The title the keyboard is on is lit like a hovered one. + button = button.fill(ui.visuals().widgets.hovered.weak_bg_fill); + } + let title = ui.add(button); + let open_now = egui::Popup::is_id_open(&ctx, egui::Popup::default_response_id(&title)); + ctx.accesskit_node_builder(title.id, |n| { + n.set_role(Role::MenuItem); + n.set_label(shown); + n.set_has_popup(HasPopup::Menu); + n.set_expanded(open_now); + if let Some(m) = mnemonic { + n.set_access_key(m.to_string()); + } + }); + // The bar's style, as a menu (not bar) config so that submenus inside draw as + // submenus. Only a click outside closes the menus, as in a native menu: a + // click on a disabled item or a separator leaves them open, and the item + // that runs closes them itself. + let bar = MenuConfig::find(ui); + let config = MenuConfig::new().close_behavior(PopupCloseBehavior::CloseOnClickOutside).style(bar.style.clone()); + let open = title_press(&ctx, &title, draw.press); + // The release ending the press that opened this menu is a click "outside" + // the popup: it must not close it again. + let opening = title.clicked() && ctx.data(|d| d.get_temp::(draw.press)).unwrap_or(false); + let close = if opening { PopupCloseBehavior::IgnoreClicks } else { config.close_behavior }; + egui::Popup::menu(&title) + .open_memory(open) + .close_behavior(close) + .style(config.style.clone()) + .info(UiStackInfo::new(UiKind::Menu).with_tag_value(MenuConfig::MENU_CONFIG_TAG, config)) + .show(|ui| { + ctx.accesskit_node_builder(ui.unique_id(), |n| n.set_role(Role::Menu)); + ui.set_min_width(style.menu_width); + let rows = match top { + Top::Menu(m) => entries(&m.children), + Top::Group(_, _, menus) => menus.iter().map(|m| Entry::Sub(&m.title, &m.children)).collect(), + }; + draw.level(ui, 1, &rows, &mut nav); + }); + buttons.push(title); + } + switch_on_hover(&ctx, &buttons); + let ids: Vec = buttons.iter().map(egui::Popup::default_response_id).collect(); + nav.keys(&ctx, &ids, &mut draw.clicked); + if keys_on { + keys.keys(&ctx, self.gate, &ids, &title_keys, &mut nav, &mut draw.clicked); + } + }); + }); + keys.store(&ctx, keys_id); + nav.store(&ctx, nav_id); + // The press-drag gesture ends with the button (its release was handled by the rows). + if ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(draw.press)); + } + draw.clicked + .into_iter() + .filter(|command| match Builtin::of(command) { + Some(Builtin::Quit) => !craft_quit::on_menu_event(&ctx, Builtin::Quit.id()), + Some(Builtin::Minimize) => { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + false + } + _ => true, + }) + .collect() + } + + /// Each title's width as shown, with any "(F)" it takes, and `pad` on either side. + fn title_widths(&self, ui: &Ui, draw: &Draw<'_>, pad: f32) -> Vec { + let font = TextStyle::Button.resolve(ui.style()); + let names: Vec<(&str, Name<'_>)> = self.bar.menus.iter().map(|m| (m.title.as_str(), Name::App(Labeled::Title(&m.title)))).collect(); + draw.mnemonics(&names) + .0 + .iter() + .map(|t| ui.painter().layout_no_wrap(t.to_owned(), font.clone(), Color32::PLACEHOLDER).size().x + 2.0 * pad) + .collect() + } + + /// The titles to draw: every menu when they fit in the row; else the menus that fit with + /// room for a "⋯" menu holding the rest; else one "Menu" holding them all. + fn tops(&self, ui: &Ui, draw: &Draw<'_>) -> Vec> { + let menus = &self.bar.menus; + let font = TextStyle::Button.resolve(ui.style()); + let pad = 2.0 * ui.spacing().button_padding.x; + let width = |t: &str| ui.painter().layout_no_wrap(t.to_owned(), font.clone(), Color32::PLACEHOLDER).size().x + pad; + let room = ui.available_width(); + let widths = self.title_widths(ui, draw, ui.spacing().button_padding.x); + if widths.iter().sum::() <= room { + return menus.iter().map(Top::Menu).collect(); + } + let more = (self.label)("⋯"); + let room = room - width(&more); + let mut used = 0.0; + let fit = widths + .iter() + .take_while(|w| { + used += **w; + used <= room + }) + .count(); + if fit == 0 { + return vec![Top::Group((self.label)("Menu"), "Menu", menus)]; + } + let (shown, rest) = menus.split_at(fit); + shown.iter().map(Top::Menu).chain([Top::Group(more, "⋯", rest)]).collect() + } +} + +/// A title of the bar: a menu, or the overflow menu (its title, translated and in English) +/// holding whole menus as submenus. +enum Top<'a> { + Menu(&'a Menu), + Group(String, &'static str, &'a [Menu]), +} + +impl Top<'_> { + fn title(&self) -> &str { + match self { + Top::Menu(m) => &m.title, + Top::Group(title, ..) => title, + } + } + + /// The title as shown, and what it is. + fn name(&self) -> (&str, Name<'_>) { + match self { + Top::Menu(m) => (&m.title, Name::App(Labeled::Title(&m.title))), + Top::Group(title, english, _) => (title, Name::Own(english)), + } + } +} + +/// A row or title to assign a mnemonic to: one of the app's, or one the bar writes itself (by +/// its English label). +#[derive(Clone, Copy)] +enum Name<'s> { + App(Labeled<'s>), + Own(&'static str), +} + +/// A row of a menu level. +#[derive(Clone, Copy)] +enum Entry<'a> { + Item(&'a Item), + Separator, + Sub(&'a str, &'a [Node]), +} + +/// The rows of `nodes`. AppKit's own items ([`Node::Standard`]) have nothing to run here and +/// are left out; a desktop or web layout has none. +fn entries(nodes: &[Node]) -> Vec> { + nodes + .iter() + .filter_map(|n| match n { + Node::Item(it) => Some(Entry::Item(it)), + Node::Separator => Some(Entry::Separator), + Node::Submenu { label, children, .. } => Some(Entry::Sub(label, children)), + Node::Standard { .. } => None, + }) + .collect() +} + +/// A submenu is enabled only while some item under it (at any depth) can run, so a submenu of +/// disabled items is disabled too instead of opening onto them. +fn any_enabled(nodes: &[Node], gate: Gate) -> bool { + nodes.iter().any(|n| match n { + Node::Item(it) => it.enabled && gate.allows(it), + Node::Submenu { children, .. } => any_enabled(children, gate), + Node::Separator | Node::Standard { .. } => false, + }) +} + +/// What the rows need while drawing, and the command chosen. +struct Draw<'s> { + gate: Gate, + platform: Platform, + style: &'s BarStyle, + label: &'s dyn Fn(&'static str) -> String, + /// The target has mnemonics (not a Mac). + mnemonics: bool, + english: Option<&'s EnglishHook<'s>>, + preferred: &'s [(&'s str, char)], + /// Where the press-drag gesture's flag lives: a press on a title opened the menus and the + /// button is still down. + press: Id, + clicked: Option, +} + +impl Draw<'_> { + /// The labels to show for one menu level (or the titles) and their mnemonics, from the + /// labels as the app shows them ([`keys::localized`]). On a Mac target, the labels as they + /// are and no mnemonics. + fn mnemonics(&self, rows: &[(&str, Name<'_>)]) -> (Vec, Vec>) { + if !self.mnemonics { + return (rows.iter().map(|(l, _)| (*l).to_owned()).collect(), vec![None; rows.len()]); + } + let labels: Vec<&str> = rows.iter().map(|(l, _)| *l).collect(); + let english: Vec> = rows + .iter() + .map(|(_, name)| match *name { + Name::App(labeled) => self.english.and_then(|f| f(labeled)), + Name::Own(english) => Some(english.to_owned()), + }) + .collect(); + let preferred: Vec> = rows + .iter() + .map(|(_, name)| { + let key = match *name { + Name::App(Labeled::Command(c)) => c.id.as_str(), + Name::App(Labeled::Title(t)) => t, + Name::Own(_) => return None, + }; + self.preferred.iter().find(|(k, _)| *k == key).map(|(_, c)| *c) + }) + .collect(); + keys::localized(&labels, &english, &preferred) + } + + /// One level of a menu, scrolling when taller than the window. + fn level(&mut self, ui: &mut Ui, depth: usize, rows: &[Entry<'_>], nav: &mut Nav) { + let label = self.label; + nav::level(ui, depth, nav, label, |ui, nav| self.rows(ui, depth, rows, nav)); + } + + fn rows(&mut self, ui: &mut Ui, depth: usize, rows: &[Entry<'_>], nav: &mut Nav) { + // Items never wrap: the menu widens to its longest label and shortcut. + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + // Rows touch, as in native menus. + ui.spacing_mut().item_spacing.y = 0.0; + let marks = Marks::new(ui, self.style); + // Mnemonics are unique within the level, separators aside. + let names: Vec<(&str, Name<'_>)> = rows + .iter() + .filter_map(|r| match *r { + Entry::Item(it) => Some((it.label.as_str(), Name::App(Labeled::Command(&it.command)))), + Entry::Sub(label, _) => Some((label, Name::App(Labeled::Title(label)))), + Entry::Separator => None, + }) + .collect(); + let (shown, mnemonics) = self.mnemonics(&names); + let mut next = 0; + let mut last_was_sep = true; + for (i, row) in rows.iter().enumerate() { + let label = |next: usize, fallback: &str| -> (String, Option) { + (shown.get(next).cloned().unwrap_or_else(|| fallback.to_owned()), mnemonics.get(next).copied().flatten()) + }; + match *row { + Entry::Separator => { + let more = rows.get(i + 1..).is_some_and(|rest| rest.iter().any(|r| !matches!(r, Entry::Separator))); + if !last_was_sep && more { + ui.add(egui::Separator::default().spacing(self.style.separator_spacing)); + last_was_sep = true; + } + } + Entry::Item(it) => { + let (shown, mnemonic) = label(next, &it.label); + next += 1; + self.item(ui, depth, it, (&shown, mnemonic), &marks, nav); + last_was_sep = false; + } + Entry::Sub(title, children) => { + let (shown, mnemonic) = label(next, title); + next += 1; + self.submenu(ui, depth, (&shown, mnemonic), children, &marks, nav); + last_was_sep = false; + } + } + } + } + + /// An item's row; `shown` is its label as shown and its mnemonic. + fn item(&mut self, ui: &mut Ui, depth: usize, it: &Item, shown: (&str, Option), marks: &Marks, nav: &mut Nav) { + let (label, mnemonic) = shown; + let enabled = it.enabled && self.gate.allows(it); + let (role, mark) = match it.kind { + ItemKind::Plain => (Role::MenuItem, None), + ItemKind::Check(on) => (Role::MenuItemCheckBox, on.then_some(self.style.check_mark.as_str())), + ItemKind::Radio(on) => (Role::MenuItemRadio, on.then_some(self.style.radio_mark.as_str())), + }; + let shortcut = it.shortcut.map(|c| c.display(self.platform)); + let underline = mnemonic.filter(|_| nav.underline); + let mut button = egui::Button::new(marks.text(ui, mark, label, underline)); + if let Some(s) = &shortcut { + button = button.shortcut_text(s.as_str()); + } + let press = self.press; + let hit = nav.row(ui, depth - 1, enabled, Some(&it.command), mnemonic, |ui, _| { + let r = ui.add_enabled(enabled, button); + access(&r, role, label, mnemonic, it.kind.checked(), shortcut.as_deref()); + let hit = r.clicked() || released_on(ui, &r, press); + (r, hit) + }); + if hit { + self.clicked = Some(it.command.clone()); + ui.close(); + } + } + + /// A submenu's row; `shown` is its title as shown and its mnemonic. + fn submenu(&mut self, ui: &mut Ui, depth: usize, shown: (&str, Option), children: &[Node], marks: &Marks, nav: &mut Nav) { + let (label, mnemonic) = shown; + let enabled = any_enabled(children, self.gate); + let text = marks.text(ui, None, label, mnemonic.filter(|_| nav.underline)); + ui.add_enabled_ui(enabled, |ui| { + nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| { + let (response, open) = submenu_row(ui, text, |ui| { + ui.ctx().accesskit_node_builder(ui.unique_id(), |n| n.set_role(Role::Menu)); + self.level(ui, depth + 1, &entries(children), nav); + }); + if open { + // Where the submenu hangs from, to keep it below the bar. + nav.set_anchor(depth, response.rect); + } + access(&response, Role::MenuItem, label, mnemonic, None, None); + response.ctx.accesskit_node_builder(response.id, |n| { + n.set_has_popup(HasPopup::Menu); + n.set_expanded(open); + }); + (response, ()) + }); + }); + } +} + +/// The mark column every row of a level keeps before its label, so that checked, unchecked +/// and plain rows (and submenu rows) start their labels at the same x. +struct Marks { + font: egui::FontId, + /// The column's width: the wider mark and a space. + gutter: f32, +} + +impl Marks { + fn new(ui: &Ui, style: &BarStyle) -> Marks { + let font = TextStyle::Button.resolve(ui.style()); + let w = |s: &str| ui.painter().layout_no_wrap(format!("{s} "), font.clone(), Color32::PLACEHOLDER).size().x; + let gutter = w(&style.check_mark).max(w(&style.radio_mark)); + Marks { font, gutter } + } + + /// A row's text: `mark` in the column, then the label with `mnemonic` underlined. The text + /// takes the button's color (dimmed when disabled). + fn text(&self, ui: &Ui, mark: Option<&str>, label: &str, mnemonic: Option) -> WidgetText { + let plain = TextFormat { font_id: self.font.clone(), color: Color32::PLACEHOLDER, ..Default::default() }; + let mut job = LayoutJob::default(); + job.wrap.max_width = f32::INFINITY; + let mut lead = self.gutter; + if let Some(mark) = mark { + let w = ui.painter().layout_no_wrap(mark.to_owned(), self.font.clone(), Color32::PLACEHOLDER).size().x; + job.append(mark, 0.0, plain.clone()); + lead = (self.gutter - w).max(0.0); + } + keys::append_underlined(&mut job, label, lead, mnemonic, &plain); + job.into() + } +} + +/// Give a row its AccessKit role, its label without the mark, its mnemonic, its checked state +/// and its shortcut. egui marks it disabled already. +fn access(r: &Response, role: Role, label: &str, mnemonic: Option, checked: Option, shortcut: Option<&str>) { + r.ctx.accesskit_node_builder(r.id, |n| { + n.set_role(role); + n.set_label(label); + if let Some(m) = mnemonic { + n.set_access_key(m.to_string()); + } + match checked { + Some(true) => n.set_toggled(Toggled::True), + Some(false) => n.set_toggled(Toggled::False), + None => n.clear_toggled(), + } + if let Some(s) = shortcut { + n.set_keyboard_shortcut(s); + } + }); +} + +/// A submenu row, drawn as `ui.menu_button` draws it, except that a row scrolled out of view +/// doesn't open its submenu. egui opens a submenu whenever the pointer is over its row, clipped +/// or not: a menu reopened scrolled down has rows hidden above its view, over the bar, and the +/// hidden one under the title would open on the press; the release on the title then counted +/// as a click outside that submenu and closed the whole menu. An open submenu (opened from the +/// keyboard, say) is drawn as usual. Returns the row and whether its submenu is shown. +fn submenu_row(ui: &mut Ui, title: WidgetText, content: impl FnOnce(&mut Ui)) -> (Response, bool) { + let sub = SubMenu::id_from_widget_id(ui.next_auto_id()); + let open = MenuState::from_ui(ui, |state, _| state.open_item == Some(sub)); + let inactive = ui.style().visuals.widgets.inactive; + if open { + ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; + } + let response = ui.add(egui::Button::new(title).right_text(SubMenuButton::arrow_atom(None))); + ui.style_mut().visuals.widgets.inactive = inactive; + // The rect egui tests the pointer against (`SubMenu::show`), against the part that can be seen. + let reach = response.rect.expand2(ui.spacing().item_spacing / 2.0); + let hidden = ui.ctx().pointer_hover_pos().is_some_and(|p| reach.contains(p) && !ui.clip_rect().contains(p)); + if hidden && !open { + return (response, false); + } + let shown = SubMenu::new().show(ui, &response, content).is_some(); + (response, shown) +} + +/// A title's open or close command this frame: the press opens a closed menu (and starts a +/// press-drag gesture: releasing on an item runs it) or closes an open one; the release never +/// toggles, so the menu doesn't blink. A keyboard or AccessKit click toggles it. +fn title_press(ctx: &egui::Context, title: &Response, press: Id) -> Option { + // A press this frame on the title (still down, or a whole click within one frame). + let pressed = ctx.input(|i| i.pointer.primary_pressed()) && (title.is_pointer_button_down_on() || title.clicked()); + if !pressed { + return (title.clicked() && !title.clicked_by(egui::PointerButton::Primary)).then_some(egui::SetOpenCommand::Toggle); + } + let open = egui::Popup::is_id_open(ctx, egui::Popup::default_response_id(title)); + if !open { + ctx.data_mut(|d| d.insert_temp(press, true)); + } + Some(egui::SetOpenCommand::Bool(!open)) +} + +/// Did the press-drag gesture that opened the menus end over `item` (released on it)? +fn released_on(ui: &Ui, item: &Response, press: Id) -> bool { + item.enabled() + && item.contains_pointer() + && ui.input(|i| i.pointer.primary_released()) + && ui.ctx().data(|d| d.get_temp::(press)).unwrap_or(false) +} + +/// Whether the pointer can reach a title: a tall submenu that egui moved up over the bar is on +/// top there, and hovering it must not switch menus. The title's column reaches up to the top +/// of the window: egui lights a widget up to `interact_radius` away, so in a maximized window +/// a pointer pushed against the screen edge above the title lights it and switches to it too. +fn pointer_reaches_title(ctx: &egui::Context, response: &Response, p: egui::Pos2) -> bool { + let r = response.interact_rect; + let column = egui::Rect::from_min_max(egui::pos2(r.left(), f32::NEG_INFINITY), r.max); + column.contains(p) && ctx.layer_id_at(p) == Some(response.layer_id) +} + +/// As in a native menu bar: while one menu is open, the pointer moving onto another title opens +/// that menu instead. +fn switch_on_hover(ctx: &egui::Context, buttons: &[Response]) { + let ids: Vec = buttons.iter().map(egui::Popup::default_response_id).collect(); + let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else { return }; + // `Response::hovered` is false while the menu's popup layer is open: hit-test the title, + // but only where its own layer is on top. + let Some(p) = ctx.pointer_hover_pos() else { return }; + // Only a moving pointer switches: a pointer resting on a title must not undo ← and →. + if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) { + return; + } + if let Some(i) = buttons.iter().position(|b| pointer_reaches_title(ctx, b, p)) + && i != open + && let Some(&id) = ids.get(i) + { + egui::Popup::open_id(ctx, id); + ctx.request_repaint(); + } +} diff --git a/crates/menu/src/bar/nav.rs b/crates/menu/src/bar/nav.rs new file mode 100644 index 00000000..acdb5811 --- /dev/null +++ b/crates/menu/src/bar/nav.rs @@ -0,0 +1,532 @@ +//! How an open menu of the bar behaves like a native one: a menu or submenu taller than the +//! window scrolls (wheel, and the ▲ / ▼ arrows at its top and bottom, which scroll while the +//! pointer rests on them), and the keyboard drives it: ↑ / ↓ move the highlight (and keep it in +//! view), → opens a submenu or the next menu, ← closes the submenu or opens the previous menu, +//! ↩ / Space run the highlighted item, Esc closes. +//! +//! The highlight is view state in egui memory, per top-level menu. It follows the pointer too, so +//! the mouse and the keyboard share one highlighted row, as in a native menu. +//! +//! An open submenu stays open while the pointer heads for it: crossing the rows between its row +//! and the submenu, on a diagonal, a curved path or with a pause on the way, neither closes it +//! nor opens the submenu of a row crossed. The pointer resting on another row, or moving away +//! from the submenu, switches as usual. +//! +//! The bar's own keys (Alt and F10 focusing it, mnemonics; [`super::keys`]) drive the same +//! state: the open menu ([`Nav::top`], through the popup ids in [`Nav::tops`]), the level the +//! arrows move in ([`Nav::level`]), its rows' mnemonics ([`Nav::row_with_mnemonic`]) and +//! [`Nav::open_sub_row`] to go into a submenu. + +use egui::containers::menu::{MenuState, SubMenu, find_menu_root}; +use egui::{Id, Key, Modifiers, Pos2, Rect, Sense, Ui, pos2, vec2}; + +use craft_keymap::CommandRef; + +/// Height of a scroll-arrow strip. +pub(crate) const ARROW: f32 = 16.0; +/// Gap kept between a menu and the window edge. +const EDGE: f32 = 6.0; +/// How fast resting on a scroll arrow scrolls, in points per second. +const SPEED: f32 = 600.0; +/// How far above and below an open submenu the pointer may aim and still be heading for it. +const AIM_SLACK: f32 = 24.0; +/// How long the pointer may rest on another row, heading for an open submenu, before that row +/// takes over (macOS waits about as long). +const AIM_REST: f32 = 0.3; + +/// The pointer heading for the open submenu of a row. +#[derive(Clone, Debug)] +struct Aim { + /// The row whose submenu is open, and its index in its level. + row: Id, + index: usize, + /// Where the pointer last was on that row. + from: Pos2, + /// The pointer was off the row with its submenu still open in the last frame. + held: bool, +} + +/// One row of a menu level: a command or a submenu. +#[derive(Clone, Debug)] +pub(crate) struct Row { + /// The row's widget id (a submenu's popup id derives from it). + pub widget: Id, + pub enabled: bool, + /// The command it runs; `None` for a submenu. + pub command: Option, + /// Where it was drawn (scrolled out of view when outside its level's `views` rect). + #[cfg_attr(not(test), allow(dead_code))] + pub rect: Rect, + /// The letter that runs it from the keyboard, upper case. + pub mnemonic: Option, +} + +/// Keyboard and highlight state of the open menu. +#[derive(Clone, Debug, Default)] +pub(crate) struct Nav { + /// The open top-level menu (index into the bar's titles). + pub top: Option, + /// Popup ids of the top-level menus, from the last frame. + pub tops: Vec, + /// The level the arrow keys move in (0 = the top-level menu). + pub level: usize, + /// Highlighted row per level. + hi: Vec>, + /// This frame's rows per level (deeper levels are dropped when their parent redraws). + pub rows: Vec>, + /// Menu-root id per level (the owner of egui's `MenuState` for that level). + roots: Vec, + /// Underline the rows' mnemonics this frame. + pub underline: bool, + /// Bottom of the menu bar: where top-level menus hang from. + pub bar_bottom: Option, + /// The visible (scrolled) part of each level. + pub views: Vec, + /// The full height of each level's rows (taller than its view when it scrolls). + pub contents: Vec, + /// Per level: the row its open submenu hangs from (level 0 has none). From the frame + /// before, since a submenu is drawn while its row is. + anchors: Vec>, + /// Scroll the highlighted row of `level` into view when it is next drawn. + reveal: bool, + /// Highlight the first enabled row of this level once it has been drawn (a keyboard-opened + /// submenu). + first_at: Option, + /// Per level: the pointer heading for the open submenu of one of its rows. + aims: Vec>, + /// A held submenu of this level let go: the row under the pointer takes the highlight even + /// if the pointer rests. + follow: Option, +} + +fn open_id() -> Id { + Id::unique("craft-menu-bar-open") +} + +/// Whether one of the bar's menus is open. An app leaves the keys to it meanwhile: its +/// shortcuts wait until the menu closes. +pub fn is_open(ctx: &egui::Context) -> bool { + // Only while the bar is still drawn: an app that hides it with a menu open (full screen) + // gets its keys back. + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(open_id())).is_some_and(|seen| seen + 2 >= pass) +} + +impl Nav { + /// Last frame's state, reset when another top-level menu (or none) is open now. + pub fn load(ctx: &egui::Context, id: Id) -> Nav { + let mut nav: Nav = ctx.data(|d| d.get_temp(id)).unwrap_or_default(); + let top = nav.tops.iter().position(|id| egui::Popup::is_id_open(ctx, *id)); + if top != nav.top { + nav = Nav { top, tops: std::mem::take(&mut nav.tops), ..Default::default() }; + } + nav + } + + /// The state as the last frame left it. + #[cfg(test)] + pub fn current(ctx: &egui::Context, id: Id) -> Nav { + ctx.data(|d| d.get_temp(id)).unwrap_or_default() + } + + /// The highlighted row of `level`, if any. + pub fn highlighted(&self, level: usize) -> Option { + self.hi.get(level).copied().flatten() + } + + fn set_hi(&mut self, level: usize, row: Option) { + if self.hi.len() <= level { + self.hi.resize(level + 1, None); + } + self.hi[level] = row; + } + + /// Start drawing `level`: forget its rows from before, and every deeper level's. + fn begin_level(&mut self, level: usize, root: Id) { + self.rows.truncate(level); + self.rows.resize(level + 1, Vec::new()); + self.roots.truncate(level); + self.roots.resize(level + 1, root); + self.views.truncate(level); + self.views.resize(level + 1, Rect::NOTHING); + self.contents.truncate(level); + self.contents.resize(level + 1, 0.0); + } + + /// Draw a row of `level` with `add`, lit like a hovered row when the keyboard (or the + /// pointer) is on it, and register it for the keys. `add` returns the row's response. A row + /// without a `command` opens a submenu; `mnemonic` is the letter that runs or opens it. + pub fn row( + &mut self, + ui: &mut Ui, + level: usize, + enabled: bool, + command: Option<&CommandRef>, + mnemonic: Option, + add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R), + ) -> R { + let i = self.rows.get(level).map_or(usize::MAX, Vec::len); + // While a submenu is kept open for the pointer heading to it, its row stays lit and the + // rows crossed on the way don't light up. + let held = self.aims.get(level).and_then(Option::as_ref).filter(|a| a.held).map(|a| a.index); + let lit = enabled && (held == Some(i) || held.is_none() && self.highlighted(level) == Some(i)); + let saved = (ui.visuals().widgets.inactive, ui.visuals().widgets.hovered); + if lit { + ui.visuals_mut().widgets.inactive = ui.visuals().widgets.hovered; + } else if held.is_some() { + ui.visuals_mut().widgets.hovered = ui.visuals().widgets.inactive; + } + let root = self.roots.get(level).copied().filter(|_| command.is_none()); + let open = root.and_then(|root| MenuState::from_id(ui.ctx(), root, |s| s.open_item)); + let (r, out) = add(ui, self); + (ui.visuals_mut().widgets.inactive, ui.visuals_mut().widgets.hovered) = saved; + if let Some(root) = root { + self.aim(level, i, root, open, &r); + } + if let Some(rows) = self.rows.get_mut(level) { + rows.push(Row { widget: r.id, enabled, command: command.cloned(), rect: r.rect, mnemonic }); + if let Some(index) = held { + self.set_hi(level, Some(index)); + } else { + self.after_row(level, i, lit, &r); + } + } + out + } + + /// After a submenu row is drawn: if egui just closed its submenu because the pointer is over + /// another row of this menu, keep it open while the pointer heads for it. `open` was the + /// menu's open submenu before the row was drawn. + fn aim(&mut self, level: usize, index: usize, root: Id, open: Option, r: &egui::Response) { + if self.aims.len() <= level { + self.aims.resize(level + 1, None); + } + let Some(p) = r.ctx.pointer_hover_pos() else { return }; + let was_held = self.aims.get(level).and_then(Option::as_ref).is_some_and(|a| a.row == r.id && a.held); + let held = self.hold(level, index, root, open, p, r); + if was_held && !held { + // Let go: the row under the pointer takes the highlight, moving or not. + self.follow = Some(level); + } + } + + /// [`Self::aim`]: is this row's submenu open with the pointer off the row (held) now? + fn hold(&mut self, level: usize, index: usize, root: Id, open: Option, p: Pos2, r: &egui::Response) -> bool { + let ctx = &r.ctx; + let sub = SubMenu::id_from_widget_id(r.id); + let target = self.views.get(level + 1).copied(); + let Some(slot) = self.aims.get_mut(level) else { return false }; + if open != Some(sub) { + if slot.as_ref().is_some_and(|a| a.row == r.id) { + *slot = None; + } + return false; + } + if r.interact_rect.contains(p) { + *slot = Some(Aim { row: r.id, index, from: p, held: false }); + return false; + } + let Some(aim) = slot.as_mut().filter(|a| a.row == r.id) else { return false }; + // Off its row with the submenu still open (egui keeps it while the pointer moves straight + // at it): the row stays lit. + aim.held = true; + if MenuState::from_id(ctx, root, |s| s.open_item) == Some(sub) { + return true; + } + aim.held = false; + // A click or a key closes menus as usual; so does leaving the submenu's direction, or + // resting on another row. + let (acted, rest) = + ctx.input(|i| (i.pointer.any_pressed() || i.pointer.any_click() || !i.keys_down.is_empty(), i.pointer.time_since_last_movement())); + let Some(target) = target else { return false }; + if acted || rest >= AIM_REST || !heading(aim.from, p, target) { + return false; + } + MenuState::from_id(ctx, root, |s| s.open_item = Some(sub)); + aim.held = true; + ctx.request_repaint_after_secs(AIM_REST - rest); + true + } + + /// After a row is drawn: the pointer moving onto it highlights it; a keyboard move scrolls + /// the highlighted row into view. + fn after_row(&mut self, level: usize, i: usize, lit: bool, r: &egui::Response) { + let moved = r.ctx.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO); + let follow = self.follow == Some(level); + if (moved || follow) && r.hovered() && !lit { + if follow { + self.follow = None; + } + self.set_hi(level, Some(i)); + self.level = level; + r.ctx.request_repaint(); + } + if lit && self.reveal && level == self.level { + r.scroll_to_me(None); + self.reveal = false; + } + } + + fn enabled(&self, level: usize) -> Vec { + self.rows.get(level).map(|rows| rows.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect()).unwrap_or_default() + } + + /// ↑ / ↓: the next enabled row (wrapping), or the first / last one. + fn step(&mut self, level: usize, down: bool) { + let on = self.enabled(level); + let (Some(&first), Some(&last)) = (on.first(), on.last()) else { return }; + let next = match self.highlighted(level) { + None => { + if down { + first + } else { + last + } + } + Some(cur) if down => on.iter().copied().find(|&i| i > cur).unwrap_or(first), + Some(cur) => on.iter().rev().copied().find(|&i| i < cur).unwrap_or(last), + }; + self.set_hi(level, Some(next)); + self.hi.truncate(level + 1); + self.level = level; + self.reveal = true; + // The keys move the highlight now, not the pointer heading for a submenu. + if let Some(aim) = self.aims.get_mut(level) { + *aim = None; + } + } + + /// Light the first enabled row of `level` once it has been drawn (a menu opened from the + /// keyboard, as on Windows). + pub fn highlight_first(&mut self, level: usize) { + self.first_at = Some(level); + } + + /// The enabled row of the level the arrows are in whose mnemonic is `c`. + pub fn row_with_mnemonic(&self, c: char) -> Option { + let level = self.level.min(self.rows.len().saturating_sub(1)); + self.rows.get(level)?.iter().find(|r| r.enabled && r.mnemonic == Some(c)).cloned() + } + + /// Open the submenu of row `widget` of the level the arrows are in. + pub fn open_sub_row(&mut self, ctx: &egui::Context, widget: Id) { + let level = self.level.min(self.rows.len().saturating_sub(1)); + self.open_sub(ctx, level, widget); + } + + /// Open the submenu of row `widget` of `level` and move the keyboard into it, on its first + /// enabled row. + pub fn open_sub(&mut self, ctx: &egui::Context, level: usize, widget: Id) { + let Some(&root) = self.roots.get(level) else { return }; + let sub = SubMenu::id_from_widget_id(widget); + // egui drops an open item it has never drawn: mark it shown so it survives to the next frame. + MenuState::mark_shown(ctx, sub); + MenuState::from_id(ctx, root, |s| s.open_item = Some(sub)); + self.level = level + 1; + self.hi.truncate(level + 1); + self.first_at = Some(level + 1); + } + + /// Remember the row the submenu of `level` (1 = a top-level menu's submenu) hangs from. + pub fn set_anchor(&mut self, level: usize, row: Rect) { + if self.anchors.len() <= level { + self.anchors.resize(level + 1, None); + } + if let Some(a) = self.anchors.get_mut(level) { + *a = Some(row); + } + } + + /// Handle this frame's navigation keys (after the menus were drawn). `tops` are the bar's + /// popup ids, in screen order; an activated command lands in `clicked`. + pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], clicked: &mut Option) { + self.tops = tops.to_vec(); + let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return }; + let level = self.level.min(self.rows.len().saturating_sub(1)); + self.level = level; + let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned(); + let switch = |by: usize| { + let n = tops.len(); + if let Some(&id) = tops.get((top + by) % n) { + egui::Popup::open_id(ctx, id); + } + }; + if press(Key::Escape) { + egui::Popup::close_all(ctx); + } else if press(Key::ArrowDown) { + self.step(level, true); + } else if press(Key::ArrowUp) { + self.step(level, false); + } else if press(Key::ArrowRight) { + match current { + Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget), + _ => switch(1), + } + } else if press(Key::ArrowLeft) { + if level > 0 { + if let Some(&root) = self.roots.get(level - 1) { + MenuState::from_id(ctx, root, |s| s.open_item = None); + } + self.hi.truncate(level); + self.level = level - 1; + } else { + switch(tops.len() - 1); + } + } else if press(Key::Enter) || press(Key::Space) { + match current { + Some(Row { enabled: true, command: Some(c), .. }) => { + *clicked = Some(c); + egui::Popup::close_all(ctx); + } + Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget), + _ => {} + } + } else { + return; + } + ctx.request_repaint(); + } + + pub fn store(self, ctx: &egui::Context, id: Id) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + if self.top.is_some() { + d.insert_temp(open_id(), pass); + } else { + d.remove::(open_id()); + } + d.insert_temp(id, self); + }); + } +} + +/// Is the pointer at `p`, having left a submenu's row at `from`, heading for the submenu at +/// `target`? It is while it stays inside the triangle from `from` to the submenu's near edge +/// (with [`AIM_SLACK`] above and below), as macOS menus judge it. +pub(crate) fn heading(from: Pos2, p: Pos2, target: Rect) -> bool { + if !target.is_positive() { + return false; + } + let x = if target.center().x >= from.x { target.left() } else { target.right() }; + let (a, b) = (pos2(x, target.top() - AIM_SLACK), pos2(x, target.bottom() + AIM_SLACK)); + let side = |u: Pos2, v: Pos2| (v.x - u.x) * (p.y - u.y) - (v.y - u.y) * (p.x - u.x); + let (d1, d2, d3) = (side(from, a), side(a, b), side(b, from)); + let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(neg && pos) +} + +/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)? +fn pointer_on(ui: &Ui, rect: Rect) -> bool { + ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id())) +} + +/// The height a menu level's rows may take. Every level stays below the bar: a top-level menu +/// hangs from it, and a submenu that is too tall for the window between its row and the bottom, +/// or (opening upward, as egui does when that fits) between the bar and its row, scrolls instead +/// of egui sliding it up over the bar, where it would hide the menu titles. `anchor` is the +/// submenu's row (unknown on its first frame), `rows` the level's rows' height when known (the +/// popup shrinks to it), `frame` the popup's margins. +pub(crate) fn level_room(screen: Rect, bar_bottom: Option, depth: usize, anchor: Option, rows: Option, frame: f32) -> f32 { + let top = bar_bottom.unwrap_or(screen.top()).max(screen.top()) + EDGE; + let bottom = screen.bottom() - EDGE; + let below_bar = bottom - top; + let room = match anchor.filter(|_| depth > 1) { + Some(row) => { + // Downward from the row, or upward from it, whichever leaves more room. egui opens a + // submenu upward only when the downward one doesn't fit the window (it hangs from the + // row's top less half the frame margin). + let (down, up) = (bottom - row.top(), row.bottom() - top); + let opens_up = |h: f32| row.top() - (frame - 2.0) / 2.0 + h + frame > screen.bottom(); + let want = rows.unwrap_or(f32::INFINITY); + if want > down && up > down && opens_up(want.min(up)) { up } else { down }.min(below_bar) + } + None => below_bar, + } - frame; + if room.is_finite() { room.max(4.0 * ARROW) } else { 4.0 * ARROW } +} + +/// Draw one level of a menu, bounded by the window: when its rows don't fit they scroll, with +/// scroll arrows at the top and bottom. `depth` is 1 for a top-level menu. `label` translates the +/// arrows' accessible names. +pub(crate) fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, label: &dyn Fn(&'static str) -> String, rows: impl FnOnce(&mut Ui, &mut Nav)) { + let ctx = ui.ctx().clone(); + let level = depth.saturating_sub(1); + nav.begin_level(level, find_menu_root(ui).scope_id); + let screen = ctx.content_rect(); + // The menu frame's margin and stroke around the rows. + let frame = ui.spacing().menu_margin.sum().y + 2.0; + let anchor = nav.anchors.get(level).copied().flatten(); + let key = ui.scope_id().with(("craft-menu-level", depth)); + // Rows' height from the last frame: does this level overflow? + let content: Option = ctx.data(|d| d.get_temp(key)); + let room = level_room(screen, nav.bar_bottom, depth, anchor, content, frame); + let over = content.is_some_and(|h| h > room + 0.5); + let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0); + let height = if over { room - 2.0 * ARROW } else { room }; + // The popup's `Ui` is only as tall as the popup was last frame (egui's `default_area_size`, + // 400 pt, on the first): a scroll area never grows past that, so every menu would stop at that + // height and scroll even on a tall window. Ask for the room the window has; auto-shrink then + // fits the area to its rows, so a menu scrolls only when they don't fit. + let mut area = egui::ScrollArea::vertical() + .id_salt(("craft-menu-level", depth)) + .max_height(height) + .min_scrolled_height(height) + .scroll_source(egui::containers::scroll_area::ScrollSource::ALL); + // Where the scroll arrows moved the rows last frame; egui's scroll state has no setter. + if let Some(y) = ctx.data_mut(|d| d.remove_temp::(key.with("scroll-to"))) { + area = area.vertical_scroll_offset(y); + } + let out = area.show(ui, |ui| rows(ui, nav)); + let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0); + ctx.data_mut(|d| d.insert_temp(key, out.content_size.y)); + if let Some(v) = nav.views.get_mut(level) { + // `inner_rect` is the size asked for, before auto-shrink fits it to the rows. + *v = Rect::from_min_size(out.inner_rect.min, vec2(out.inner_rect.width(), out.inner_rect.height().min(out.content_size.y))); + } + if let Some(c) = nav.contents.get_mut(level) { + *c = out.content_size.y; + } + if nav.first_at == Some(level) { + nav.first_at = None; + nav.step(level, true); + ctx.request_repaint(); + } + let (Some(up), Some(down)) = (up, down) else { return }; + let (live_color, dead_color) = (ui.visuals().text_color(), ui.visuals().weak_text_color().gamma_multiply(0.5)); + let x = ui.min_rect().x_range(); + let max = (out.content_size.y - out.inner_rect.height()).max(0.0); + let offset = out.state.clamped_offset().y; + let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1); + let mut target = offset; + for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] { + let strip = Rect::from_x_y_ranges(x, strip.y_range()); + let c = strip.center(); + let (h, w) = (4.5, 6.0); + let tri = if dir < 0.0 { + vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)] + } else { + vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)] + }; + ui.painter().add(egui::Shape::convex_polygon(tri, if live { live_color } else { dead_color }, egui::Stroke::NONE)); + let id = key.with(dir < 0.0); + let r = ui.interact(strip, id, Sense::hover()); + let name = label(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, live, &name)); + if live && pointer_on(ui, strip) { + target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max); + ctx.request_repaint(); + } + // The wheel scrolls over the arrows too. + if pointer_on(ui, strip) { + let wheel = ctx.input(|i| i.smooth_scroll_delta.y); + if wheel != 0.0 { + target = (target - wheel).clamp(0.0, max); + } + } + } + if target != offset { + ctx.data_mut(|d| d.insert_temp(key.with("scroll-to"), target)); + } +} diff --git a/crates/menu/src/driver.rs b/crates/menu/src/driver.rs new file mode 100644 index 00000000..6e20103f --- /dev/null +++ b/crates/menu/src/driver.rs @@ -0,0 +1,492 @@ +//! The app's side: events from the platform menu in, menu bars out. + +use std::borrow::Cow; +use std::collections::HashSet; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use craft_keymap::{Chord, CommandRef, Key, Mods}; + +use crate::layout::{Clash, Layout}; +use crate::model::{Builtin, Item, ItemKind, MenuBar, Node}; + +/// What the native menu reports. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + /// An item was chosen with the mouse (or the keyboard inside an open menu), or by a key + /// equivalent the app's key handling doesn't know ([`crate::Item::layout_shortcut`]): the + /// item's command. + Click(CommandRef), + /// A key equivalent was pressed: the shortcut, to be handled as the key press it was. + Key(Chord), + /// An item whose shortcut a text field handles itself (Edit › Copy, Undo) was clicked while a + /// text field has the keys: the shortcut, to be handled as a press and release of it. + Tap(Chord), + /// A click the app's [`NativeMenu::on_command`] hook ran; [`NativeMenu::take_clicks`] leaves it + /// out. + Handled(CommandRef), +} + +/// How ⌘C, ⌘X and ⌘V reach egui when the native menu takes them as key equivalents (an Edit +/// menu with Copy, Cut and Paste items). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ClipboardKeys { + /// As egui-winit makes them without a native menu: ⌘C is [`egui::Event::Copy`], ⌘X + /// [`egui::Event::Cut`], and ⌘V a [`egui::ViewportCommand::RequestPaste`], which eframe answers + /// with an [`egui::Event::Paste`] of the clipboard's text in the next frame. Text fields copy, + /// cut and paste. + #[default] + Egui, + /// Key presses like any other key equivalent, for an app that runs its clipboard commands + /// from the keys itself. + Raw, +} + +/// What may run from the menus this frame, set by the app. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Gate { + /// A modal dialog is open. + pub modal: bool, + /// The shortcut editor is recording a chord. + pub recording_shortcut: bool, + /// A text field has the keys. + pub text_editing: bool, +} + +impl Gate { + /// Whether `item` may run now: while a modal dialog is open or a shortcut is being recorded, + /// only the built-in items (Hide, Hide Others, Minimize, Quit) do. The built-in Quit stays + /// because it asks the window to close, and the app's close guard knows about its dialogs; an + /// app's own Quit item ([`crate::ItemRole::Quit`]) is greyed with its other commands. A text field having the keys doesn't stop a click: menu commands run while + /// typing, as in a Mac app, and the keys a field keeps are handled before the menus see them. + pub fn allows(&self, item: &Item) -> bool { + self.allows_command(&item.command) + } + + /// [`Gate::allows`] for an item that runs `command`. + pub fn allows_command(&self, command: &CommandRef) -> bool { + !(self.modal || self.recording_shortcut) || Builtin::of(command).is_some() + } + + /// `bar` with every item the gate forbids disabled, so the menus show them greyed, but the + /// items whose shortcut a text field handles itself (Undo, Copy, Select All, …). + pub(crate) fn grey<'a>(&self, bar: &'a MenuBar) -> Cow<'a, MenuBar> { + fn walk(nodes: &mut [Node], gate: &Gate) { + for n in nodes { + match n { + // The field's own keys stay enabled: AppKit doesn't send a disabled item's key + // equivalent on, so greyed Copy or Undo would leave ⌘C and ⌘Z dead in a + // dialog's text fields. Their clicks are still dropped (`take_clicks`). + Node::Item(it) if !gate.allows(it) && !it.shortcut.as_ref().is_some_and(text_editing_key) => it.enabled = false, + Node::Submenu { children, .. } => walk(children, gate), + _ => {} + } + } + } + if !(self.modal || self.recording_shortcut) { + return Cow::Borrowed(bar); + } + let mut bar = bar.clone(); + for m in &mut bar.menus { + walk(&mut m.children, self); + } + Cow::Owned(bar) + } +} + +/// An app's [`NativeMenu::on_command`] hook: `true` when it ran the command. +pub type CommandHook = Box bool + Send + Sync>; + +/// What a backend's menu event handler needs from the driver when an item is chosen: the +/// current [`Gate`] and the app's [`NativeMenu::on_command`] hook. The platform calls the handler +/// between frames, from the event that chose the item, so this is shared with the driver rather +/// than borrowed from it. [`NativeMenu::new`] hands it to the backend ([`Backend::attach`]). +#[derive(Clone, Default)] +pub struct Dispatch(Arc>); + +type SharedHook = Arc bool + Send + Sync>; + +#[derive(Default)] +struct Shared { + gate: Gate, + hook: Option, +} + +impl Dispatch { + /// The event for an item with `command`, its `shortcut` and [`Item::layout_shortcut`], + /// chosen with the modifiers `held` for a key-down (⌘, ⌃, ⌥, ⇧ as AppKit reports them) or + /// `None` for a mouse click, under the current gate. A click the gate allows on an app command + /// (not a [`Builtin`]) also goes to the app's [`NativeMenu::on_command`] hook, before this + /// returns; when the hook ran it, the event is [`Event::Handled`]. + pub fn chosen(&self, command: &CommandRef, shortcut: Option<&Chord>, layout_shortcut: bool, held: Option) -> Event { + let (gate, hook) = { + let shared = self.lock(); + (shared.gate, shared.hook.clone()) + }; + let event = chosen_event(command, shortcut, layout_shortcut, held, gate); + if let (Event::Click(c), Some(hook)) = (&event, hook) + && gate.allows_command(c) + && Builtin::of(c).is_none() + && hook(c) + { + return Event::Handled(c.clone()); + } + event + } + + fn lock(&self) -> MutexGuard<'_, Shared> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// A platform's native menu bar. +pub trait Backend { + /// Bring the menu in step with `bar`: update labels, enabled and checked in place, or + /// rebuild when its structure changed ([`crate::structure_key`], [`crate::window_key`]). + fn sync(&mut self, bar: &MenuBar); + /// Events since the last call. Platform items (Hide, Services, Zoom, …) are handled by the + /// backend and not reported. + fn drain(&mut self) -> Vec; + /// Whether one of the native menus is open. + fn tracking(&self) -> bool { + false + } + /// Close the open native menu, if there is one. + fn cancel_tracking(&mut self) {} + /// Keep the driver's [`Dispatch`]: the backend's menu event handler turns every chosen item + /// into an [`Event`] through [`Dispatch::chosen`]. [`NativeMenu::new`] calls it once. + fn attach(&mut self, dispatch: Dispatch); +} + +/// The native menu bar, while the app uses one (its in-window menus are hidden then). +pub struct NativeMenu { + backend: Box, + /// The state the menus were last built from. + state: Option, + clicks: Vec, + /// An event arrived (AppKit may have toggled a check item itself): re-sync. + dirty: bool, + clashes: Vec, + clipboard: ClipboardKeys, + /// ⌘V arrived: [`NativeMenu::take_clicks`] asks eframe for the paste. + paste: bool, + /// Whether a click may be kept from egui as the one closing an open menu (see + /// [`NativeMenu::raw_input`]). + armed: bool, + gate: Gate, + dispatch: Dispatch, + /// The menus last laid out, before the gate greyed anything, and the gate they were last + /// synced with: a gate change re-syncs them without laying them out again. + bar: Option, + synced_gate: Option, + /// The commands whose items were check items in a layout so far: they stay check items, so + /// that `checked` going from `Some` to `None` doesn't rebuild the menus. + checkable: HashSet, +} + +impl NativeMenu { + pub fn new(mut backend: Box) -> NativeMenu { + let dispatch = Dispatch::default(); + backend.attach(dispatch.clone()); + NativeMenu { + backend, + state: None, + clicks: Vec::new(), + dirty: true, + clashes: Vec::new(), + clipboard: ClipboardKeys::default(), + paste: false, + armed: true, + gate: Gate::default(), + dispatch, + bar: None, + synced_gate: None, + checkable: HashSet::new(), + } + } + + pub fn clipboard_keys(mut self, keys: ClipboardKeys) -> NativeMenu { + self.clipboard = keys; + self + } + + /// What may run from the menus. Call it every frame, before [`NativeMenu::take_clicks`]; it + /// also holds for the menu events that arrive until the next call. + /// + /// - While [`Gate::modal`] or [`Gate::recording_shortcut`] is on, the app's items are greyed + /// (updated in place at the next [`NativeMenu::sync_with`]) and clicks on them are dropped, + /// also a click that arrived before the gate closed and is still waiting for + /// [`NativeMenu::take_clicks`]. The built-in items (Hide, Hide Others, Minimize, Quit) stay. + /// A greyed item's key equivalent goes to the window, so the app's key handling sees it. + /// - While [`Gate::recording_shortcut`] is on, every key equivalent reaches egui as the plain + /// key press that was made (⌘Q, ⌘H and ⌘C included), for the shortcut recorder. + /// - While [`Gate::text_editing`] is on (in egui, `ctx.wants_keyboard_input()`), a key + /// equivalent runs its item as an [`Event::Click`], as a Mac app's menu commands run while + /// typing, except the keys the field handles itself: ⌘C, ⌘X and ⌘V (following + /// [`ClipboardKeys`]) and [`craft_keymap::text_editing_chords`] (⌘A, ⌘Z, ⇧⌘Z, ⌘←, …) stay + /// key presses. + pub fn set_gate(&mut self, gate: Gate) { + self.gate = gate; + self.dispatch.lock().gate = gate; + } + + /// Run `hook` with each command clicked in the menus, from the menu event handler, as the + /// click arrives: on macOS on the main thread, between frames, also while egui is idle. Only + /// app commands the current gate ([`NativeMenu::set_gate`]) allows reach it, and key + /// equivalents reported as key presses don't. It must return quickly; it is for waking the + /// app's own loop or a worker. + /// + /// The hook returns `true` when it ran the command: [`NativeMenu::take_clicks`] then leaves + /// it out. A command it returned `false` for comes back from `take_clicks`, after the gate as + /// it is then. + pub fn on_command(&mut self, hook: CommandHook) { + self.dispatch.lock().hook = Some(Arc::from(hook)); + } + + /// Before egui sees this frame's input: key equivalents become the key presses they were, + /// and clicks wait for [`NativeMenu::take_clicks`]. + /// + /// A click or Escape in the window closes an open native menu. AppKit can deliver it to the + /// window while the menu is still open (asynchronous menu presentation), so while the backend + /// reports one open, the click or Escape is taken out of the input: it closes the menu + /// without starting a canvas gesture or reaching the active tool. Pointer moves stay. Only + /// one such click is kept from egui until the backend reports no menu open again, so a + /// backend that wrongly keeps reporting one open costs one click, not every click. + /// + /// AppKit takes only the key-down: winit still delivers the key-up (it forwards key-ups while + /// ⌘ is held). So a key equivalent adds just its press, ahead of its key-up when that is + /// already queued. Adding a made-up release as well sends two key-ups; in PhotoCraft, which + /// reads a ⌘V release without a press as an image paste, that made ⌘V paste three times. + /// ⌘C, ⌘X and ⌘V follow [`ClipboardKeys`]. + pub fn raw_input(&mut self, raw: &mut egui::RawInput) { + // The window's own input, before key equivalents are added to it. + if let Some(kept) = dismiss_input(raw, || self.armed && self.backend.tracking()) { + if kept { + self.armed = false; + } + // Also without a menu reported open: an asynchronously presented menu can still be + // on screen after it reports closing. + self.backend.cancel_tracking(); + } + if !self.armed && !self.backend.tracking() { + self.armed = true; + } + for e in self.backend.drain() { + match e { + Event::Key(chord) => self.add_key(raw, &chord, false), + Event::Tap(chord) => self.add_key(raw, &chord, true), + Event::Click(command) => self.clicks.push(command), + Event::Handled(_) => {} + } + self.dirty = true; + } + } + + /// `tap`: a press and its release, for a click that stands for the key (no key-up follows). + fn add_key(&mut self, raw: &mut egui::RawInput, chord: &Chord, tap: bool) { + let pressed_key = chord.key.to_egui(); + let clipboard = self.clipboard == ClipboardKeys::Egui && chord.mods == Mods::CMD && !self.gate.recording_shortcut; + let event = match chord.key { + Key::C if clipboard => egui::Event::Copy, + Key::X if clipboard => egui::Event::Cut, + Key::V if clipboard => { + self.paste = true; + return; + } + _ => key_event(chord, true), + }; + let at = raw + .events + .iter() + .position(|e| matches!(e, egui::Event::Key { key, pressed: false, .. } if *key == pressed_key)) + .unwrap_or(raw.events.len()); + let press = matches!(event, egui::Event::Key { .. }); + raw.events.insert(at, event); + if tap && press { + raw.events.insert(at + 1, key_event(chord, false)); + } + } + + /// The commands clicked since the last call that the gate ([`NativeMenu::set_gate`]) allows + /// now, for the app to run like in-window menu clicks; the others are dropped. + /// [`Builtin::Minimize`] (minimizes the window) and [`Builtin::Quit`] (asks the window to + /// close, see [`craft_quit::on_menu_event`]) are run here and left out. A ⌘V from the menu + /// ([`ClipboardKeys::Egui`]) is asked of eframe here too. + pub fn take_clicks(&mut self, ctx: &egui::Context) -> Vec { + if std::mem::take(&mut self.paste) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste); + } + let mut out = Vec::new(); + for command in std::mem::take(&mut self.clicks) { + if !self.gate.allows_command(&command) { + continue; + } + match Builtin::of(&command) { + Some(Builtin::Minimize) => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)), + Some(Builtin::Quit) => { + craft_quit::on_menu_event(ctx, Builtin::Quit.id()); + } + _ => out.push(command), + } + } + out + } + + /// Bring the native menu up to date. `state` is a hash of what the menus show (see + /// [`had_input`]); `build` lays them out ([`crate::mac_layout`]) and runs only when `state` + /// changed or a menu event arrived since the last sync, since building the rows usually walks + /// every command's enabled state. A menu event re-syncs because AppKit flips a check or radio + /// item's state itself when it is chosen: the sync sets it back to the app's state. A gate + /// change ([`NativeMenu::set_gate`]) re-syncs the menus last laid out, greyed or not, without + /// calling `build`. + /// + /// `state` must cover everything the menus show. A change that comes without input, such as + /// a load or save finishing on a worker or a timer, leaves the menus stale until the next + /// input or menu event unless it is in the hash. An app that can't keep the hash complete + /// uses [`NativeMenu::sync`] instead. + /// + /// Returns the layout's clashes when they differ from the last ones returned, for the app to + /// log; otherwise nothing. + pub fn sync_with(&mut self, state: u64, build: impl FnOnce() -> Layout) -> Vec { + let relayout = self.state != Some(state) || self.dirty; + if !relayout && self.synced_gate == Some(self.gate) { + return Vec::new(); + } + let mut clashes = Vec::new(); + if relayout { + let mut layout = build(); + self.keep_kinds(&mut layout.bar); + clashes = self.keep(layout); + self.state = Some(state); + } + self.push(); + clashes + } + + /// [`NativeMenu::sync_with`] without a state hash: the app lays the menus out every frame + /// and they are synced when the layout differs from the last one (or a menu event or gate + /// change calls for it), so they never go stale. It costs a layout per frame. + pub fn sync(&mut self, mut layout: Layout) -> Vec { + self.keep_kinds(&mut layout.bar); + if !self.dirty && self.bar.as_ref() == Some(&layout.bar) && self.clashes == layout.clashes && self.synced_gate == Some(self.gate) { + return Vec::new(); + } + let clashes = self.keep(layout); + // A later `sync_with` lays the menus out again. + self.state = None; + self.push(); + clashes + } + + /// An item that was a check item once stays one, unchecked: switching its kind would change + /// [`crate::structure_key`] and rebuild the menus. + fn keep_kinds(&mut self, bar: &mut MenuBar) { + let checkable = &mut self.checkable; + bar.for_each_item_mut(|it| match it.kind { + ItemKind::Check(_) => { + checkable.insert(it.command.key()); + } + ItemKind::Plain if checkable.contains(&it.command.key()) => it.kind = ItemKind::Check(false), + _ => {} + }); + } + + /// Keep `layout` as the menus to show; its clashes when they differ from the last ones. + fn keep(&mut self, layout: Layout) -> Vec { + self.dirty = false; + self.bar = Some(layout.bar); + if layout.clashes == self.clashes { + return Vec::new(); + } + self.clashes = layout.clashes.clone(); + layout.clashes + } + + /// Sync the backend with the kept menus, greyed by the gate. + fn push(&mut self) { + if let Some(bar) = &self.bar { + self.backend.sync(&self.gate.grey(bar)); + } + self.synced_gate = Some(self.gate); + } +} + +/// `Some` when `raw` has a press or Escape that closes an open menu: `Some(true)` when, with +/// `tracking` (a menu is open), those events were removed, since they belong to the menu. +pub(crate) fn dismiss_input(raw: &mut egui::RawInput, tracking: impl FnOnce() -> bool) -> Option { + if !raw + .events + .iter() + .any(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. } | egui::Event::Key { key: egui::Key::Escape, pressed: true, .. })) + { + return None; + } + let kept = tracking(); + if kept { + raw.events.retain(|e| !matches!(e, egui::Event::PointerButton { .. } | egui::Event::Key { key: egui::Key::Escape, .. })); + } + Some(kept) +} + +/// What a backend reports for an item chosen with `command`, `chord` (its shortcut) and +/// `layout_shortcut` ([`crate::Item::layout_shortcut`]) under `gate`. `held` holds the modifiers +/// held when a key-down chose it, `None` for a mouse click. +/// +/// A key-down is the item's key equivalent, reported as the key press, unless it was ↩ or Space +/// inside the open menu: the item's shortcut is not a key equivalent at all ([`native_ok`]), or +/// neither ⌘ nor ⌃ is held. The key is the item's (AppKit matched on it); the modifiers are the +/// ones held, since AppKit may match ⌘R to a ⇧⌘R item. Minimize, craft-quit's Quit and shortcuts +/// the layout assigned are run by the driver or the app's click handling, not by its key +/// handling, so they stay clicks. While text editing, the app's key handling leaves shortcuts to +/// the field, so a key equivalent is a click too, except the field's own editing keys +/// ([`text_editing_key`]); a mouse click on one of those items (Edit › Copy, Undo) is a tap of +/// its shortcut ([`Event::Tap`]), so it acts on the field rather than the document. While recording a shortcut every key equivalent is a key press, for the +/// recorder. +pub(crate) fn chosen_event(command: &CommandRef, chord: Option<&Chord>, layout_shortcut: bool, held: Option, gate: Gate) -> Event { + let recording = gate.recording_shortcut; + let runs_as_click = !recording && (layout_shortcut || matches!(Builtin::of(command), Some(Builtin::Minimize | Builtin::Quit))); + let Some(held) = held else { + return match chord { + Some(c) if !recording && gate.text_editing && text_editing_key(c) => Event::Tap(*c), + _ => Event::Click(command.clone()), + }; + }; + let pressed = chord + .filter(|c| !runs_as_click && native_ok(c) && (held.cmd || held.ctrl)) + .map(|c| Chord::new(held, c.key)) + .filter(|c| recording || !gate.text_editing || text_editing_key(c)); + match pressed { + Some(chord) => Event::Key(chord), + None => Event::Click(command.clone()), + } +} + +/// A chord a text field handles itself: the clipboard keys and craft-keymap's text-editing +/// chords (undo, redo, select all, moving by word and line). +fn text_editing_key(c: &Chord) -> bool { + const CLIPBOARD: [Chord; 3] = [Chord::new(Mods::CMD, Key::C), Chord::new(Mods::CMD, Key::X), Chord::new(Mods::CMD, Key::V)]; + CLIPBOARD.contains(c) || craft_keymap::text_editing_chords(craft_keymap::Platform::Mac).contains(c) +} + +/// May the native menu make this a key equivalent? Only with ⌘ or ⌃. Other shortcuts (a bare +/// letter, ⇧ or ⌥ alone, a bare function key) are tool keys: as key equivalents AppKit would take +/// a `b` typed into a text field, and the app's key handling may bind them per tool or context. +/// They are shown but not made key equivalents. +pub(crate) fn native_ok(c: &Chord) -> bool { + c.has_command() +} + +/// The key press egui would have received for `chord` on macOS: `cmd` is ⌘, `ctrl` the Control +/// key. +pub(crate) fn key_event(chord: &Chord, pressed: bool) -> egui::Event { + let m = chord.mods; + let key = chord.key.to_egui(); + let modifiers = egui::Modifiers { alt: m.alt, ctrl: m.ctrl, shift: m.shift, mac_cmd: m.cmd, command: m.cmd }; + egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } +} + +/// A key press or click this frame. It may have changed state an app's menu hash doesn't list, +/// so apps fold it into the `state` passed to [`NativeMenu::sync_with`], for example as +/// `had_input(ctx).then_some(ctx.cumulative_frame_nr())`. +pub fn had_input(ctx: &egui::Context) -> bool { + ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: false, .. }))) +} diff --git a/crates/menu/src/keymap.rs b/crates/menu/src/keymap.rs new file mode 100644 index 00000000..352a85d9 --- /dev/null +++ b/crates/menu/src/keymap.rs @@ -0,0 +1,62 @@ +//! Shortcuts and checked states from the app's craft-keymap [`Keymap`], and the per-frame +//! enabled and checked update. + +use craft_keymap::{CommandRef, Keymap}; + +use crate::model::{Builtin, ItemKind, MenuBar}; + +/// Give each item the shortcut its command has in `keymap`, the user's overrides included, and +/// make the items of toggle commands ([`craft_keymap::Bindable::is_toggle`]) check items. +/// +/// The shortcut is the first single-press binding, one in the command's home scope first; a +/// double press can't be a menu shortcut. A command the keymap binds to nothing loses its +/// shortcut, and an item whose command the keymap doesn't know keeps its own. A toggle's check +/// item keeps its checked state; radio items stay radio items. Call it on the app's bar, before +/// the layout. +pub fn apply_keymap(bar: &mut MenuBar, keymap: &Keymap) { + bar.for_each_item_mut(|it| { + let Some(bindable) = keymap.bindable(&it.command) else { return }; + let bindings = keymap.bindings(&it.command); + let single = || bindings.iter().filter(|b| !b.trigger.double); + let home = single().find(|b| *b.scope_or(&bindable.scope) == bindable.scope); + it.shortcut = home.or_else(|| single().next()).map(|b| b.trigger.chord); + if bindable.is_toggle && it.kind == ItemKind::Plain { + it.kind = ItemKind::Check(false); + } + }); +} + +/// An item's state for the frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ItemState { + pub enabled: bool, + /// Checked or selected; `None` leaves the item's [`ItemKind`] as it is. + pub checked: Option, +} + +impl Default for ItemState { + /// Enabled, the kind left as it is. + fn default() -> ItemState { + ItemState { enabled: true, checked: None } + } +} + +/// Set every item's enabled and checked state from `state`, called once per item of the app's +/// commands. `checked: Some(..)` makes a plain item a check item and sets a radio item's +/// selection. The built-in items and the open documents are left alone. +pub fn apply_state(bar: &mut MenuBar, mut state: impl FnMut(&CommandRef) -> ItemState) { + let documents: Vec = bar.documents.iter().map(|d| d.command.clone()).collect(); + bar.for_each_item_mut(|it| { + if Builtin::of(&it.command).is_some() || documents.contains(&it.command) { + return; + } + let s = state(&it.command); + it.enabled = s.enabled; + if let Some(on) = s.checked { + it.kind = match it.kind { + ItemKind::Radio(_) => ItemKind::Radio(on), + ItemKind::Plain | ItemKind::Check(_) => ItemKind::Check(on), + }; + } + }); +} diff --git a/crates/menu/src/layout.rs b/crates/menu/src/layout.rs new file mode 100644 index 00000000..51f45959 --- /dev/null +++ b/crates/menu/src/layout.rs @@ -0,0 +1,480 @@ +//! The platform layouts of an app's menus. See the crate docs for what they do. + +use std::collections::HashMap; + +use craft_keymap::{Chord, CommandRef, Key, Mods}; + +use crate::model::{Builtin, Item, ItemRole, Menu, MenuBar, MenuRole, Node, Platforms, Standard, tidy}; + +const CTRL_CMD: Mods = Mods { ctrl: true, ..Mods::CMD }; +const ALT_CMD: Mods = Mods { alt: true, ..Mods::CMD }; +const CMD_H: Chord = Chord::new(Mods::CMD, Key::H); +const CTRL_CMD_H: Chord = Chord::new(CTRL_CMD, Key::H); +const ALT_CMD_H: Chord = Chord::new(ALT_CMD, Key::H); +const CMD_Q: Chord = Chord::new(Mods::CMD, Key::Q); +const CMD_W: Chord = Chord::new(Mods::CMD, Key::W); +const CMD_M: Chord = Chord::new(Mods::CMD, Key::M); +const CTRL_CMD_M: Chord = Chord::new(CTRL_CMD, Key::M); +const CMD_COMMA: Chord = Chord::new(Mods::CMD, Key::Comma); + +/// The platform a layout is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Target { + Mac, + Windows, + Linux, + /// A browser, on any operating system. + Web, +} + +impl Target { + /// The platform this build runs on: `wasm32` builds are [`Target::Web`], and operating + /// systems other than macOS and Windows are [`Target::Linux`]. + pub const fn current() -> Target { + if cfg!(target_arch = "wasm32") { + Target::Web + } else if cfg!(target_os = "macos") { + Target::Mac + } else if cfg!(target_os = "windows") { + Target::Windows + } else { + Target::Linux + } + } + + /// The [`Platforms`] flag an item needs to be shown on this target. + pub const fn platforms(self) -> Platforms { + match self { + Target::Mac => Platforms::MAC, + Target::Windows => Platforms::WINDOWS, + Target::Linux => Platforms::LINUX, + Target::Web => Platforms::WEB, + } + } +} + +/// The app's name, its translations of the labels the layout writes itself, and its shortcuts +/// that no menu item shows. +pub struct LayoutOptions<'a> { + /// Shown in the About, Hide and Quit items. + pub app_name: &'a str, + /// The app's translation of an English label a layout uses: `About {app}`, `Settings…`, + /// `Settings`, `Language`, `Appearance`, `Hide {app}`, `Hide Others`, `Quit {app}`, `Minimize`, + /// `Preferences…`, `Preferences`, `Exit`, `Quit`, the menu titles `File`, `Edit`, `Window` and + /// `Help`, and each [`Standard::english`]. `{app}` is replaced by [`LayoutOptions::app_name`] + /// afterwards. + pub label: &'a dyn Fn(&'static str) -> String, + /// Shortcuts the app binds outside its menus (tool keys, canvas keys), with their commands: + /// the system items give way to them as to menu shortcuts. + pub extra_shortcuts: &'a [(Chord, CommandRef)], +} + +impl<'a> LayoutOptions<'a> { + /// English labels. + pub fn new(app_name: &'a str) -> LayoutOptions<'a> { + LayoutOptions { app_name, label: &english, extra_shortcuts: &[] } + } + + pub fn extra_shortcuts(mut self, shortcuts: &'a [(Chord, CommandRef)]) -> LayoutOptions<'a> { + self.extra_shortcuts = shortcuts; + self + } + + fn text(&self, english: &'static str) -> String { + (self.label)(english).replace("{app}", self.app_name) + } + + fn standard(&self, kind: Standard) -> Node { + Node::Standard { kind, label: self.text(kind.english()) } + } +} + +fn english(s: &'static str) -> String { + s.into() +} + +/// A system or browser shortcut the app also uses for one of its commands. +#[derive(Clone, Debug, PartialEq)] +pub struct Clash { + pub shortcut: Chord, + /// What macOS or the browser uses it for. + pub system: &'static str, + /// The app's command bound to it. + pub command: CommandRef, + /// What the layout did about it. + pub resolution: &'static str, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Layout { + pub bar: MenuBar, + pub clashes: Vec, +} + +/// Lay an app's menus out for `target`: [`mac_layout`], [`desktop_layout`] or [`web_layout`]. +pub fn layout(bar: &MenuBar, target: Target, options: &LayoutOptions) -> Layout { + match target { + Target::Mac => mac_layout(bar, options), + Target::Windows | Target::Linux => desktop_layout(bar, target, options), + Target::Web => web_layout(bar, options), + } +} + +/// Turn an app's menus, tagged with roles, into a Mac menu bar. See the crate docs. +/// +/// A menu the app tagged [`MenuRole::App`] is merged into the app menu: its tagged items go where +/// their roles put them, the rest after Settings, Language and Appearance. +pub fn mac_layout(bar: &MenuBar, options: &LayoutOptions) -> Layout { + let mut bar = shown_on(bar, Target::Mac); + let mut clashes = Vec::new(); + let mut bound: HashMap = HashMap::new(); + let menu_shortcuts = bar.items().into_iter().filter_map(|it| Some((it.shortcut?, &it.command))); + let extra = options.extra_shortcuts.iter().map(|(sc, command)| (*sc, command)); + for (sc, command) in menu_shortcuts.chain(extra) { + bound.entry(sc).or_insert_with(|| command.clone()); + } + let taken = |sc: Chord, own: Option<&CommandRef>| bound.get(&sc).filter(|cmd| Some(*cmd) != own).cloned(); + let tagged = |role: ItemRole| { + move |n: &Node| match n { + Node::Item(it) => it.role == Some(role), + Node::Submenu { role: r, .. } => *r == Some(role), + _ => false, + } + }; + + let about = take(&mut bar, |n| matches!(n, Node::Item(it) if it.role == Some(ItemRole::About))); + let settings = take(&mut bar, tagged(ItemRole::Settings)); + let language = take(&mut bar, tagged(ItemRole::Language)); + let quit = take(&mut bar, |n| matches!(n, Node::Item(it) if it.role == Some(ItemRole::Quit))); + let appearance = appearance_node(&mut bar, options); + let own_app: Vec = match bar.menus.iter().position(|m| m.role == MenuRole::App) { + Some(i) => bar.menus.remove(i).children, + None => Vec::new(), + }; + + let mut menu = Vec::new(); + if let Some(Node::Item(mut it)) = about { + it.label = options.text("About {app}"); + menu.extend([Node::Item(it), Node::Separator]); + } + if let Some(s) = settings { + let own = node_commands(&s); + let comma = bound.get(&CMD_COMMA).filter(|cmd| !own.contains(*cmd)).cloned(); + menu.push(settings_node(s, comma, options, &mut clashes)); + } + if let Some(mut l) = language { + if let Node::Submenu { label, .. } = &mut l { + *label = options.text("Language"); + } + menu.push(l); + } + menu.extend(appearance); + if !own_app.is_empty() { + menu.push(Node::Separator); + menu.extend(own_app); + } + if !menu.is_empty() && menu.last() != Some(&Node::Separator) { + menu.push(Node::Separator); + } + menu.extend([options.standard(Standard::Services), Node::Separator]); + let mut hide = Item::new(Builtin::Hide.command(), options.text("Hide {app}")).role(ItemRole::Hide); + hide.layout_shortcut = true; + match taken(CMD_H, None) { + Some(command) => { + let free = taken(CTRL_CMD_H, None).is_none(); + if free { + hide.shortcut = Some(CTRL_CMD_H); + } + let resolution = if free { "Hide moves to ⌃⌘H" } else { "Hide has no shortcut" }; + clashes.push(Clash { shortcut: CMD_H, system: "Hide", command, resolution }); + } + None => hide.shortcut = Some(CMD_H), + } + menu.push(Node::Item(hide)); + let mut hide_others = Item::new(Builtin::HideOthers.command(), options.text("Hide Others")); + hide_others.layout_shortcut = true; + match taken(ALT_CMD_H, None) { + Some(command) => clashes.push(Clash { shortcut: ALT_CMD_H, system: "Hide Others", command, resolution: "Hide Others has no shortcut" }), + None => hide_others.shortcut = Some(ALT_CMD_H), + } + menu.extend([Node::Item(hide_others), options.standard(Standard::ShowAll), Node::Separator]); + let mut quit = match quit { + Some(Node::Item(it)) => it, + _ => Item::new(Builtin::Quit.command(), "").role(ItemRole::Quit), + }; + quit.label = options.text("Quit {app}"); + match taken(CMD_Q, Some(&quit.command)) { + Some(command) => { + clashes.push(Clash { shortcut: CMD_Q, system: "Quit", command, resolution: "Quit has no shortcut" }); + quit.shortcut = None; + } + None => { + quit.layout_shortcut = quit.shortcut != Some(CMD_Q); + quit.shortcut = Some(CMD_Q); + } + } + menu.push(Node::Item(quit)); + bar.menus.insert(0, Menu::new(options.app_name, menu).role(MenuRole::App)); + + match taken(CMD_W, None) { + Some(command) => clashes.push(Clash { shortcut: CMD_W, system: "Close Window", command, resolution: "no system Close Window item" }), + None => { + if let Some(file) = bar.menus.iter_mut().find(|m| m.role == MenuRole::File) { + file.children.extend([Node::Separator, options.standard(Standard::CloseWindow)]); + } + } + } + + let minimize = match taken(CMD_M, None) { + Some(command) => { + let mut it = Item::new(Builtin::Minimize.command(), options.text("Minimize")).role(ItemRole::Minimize); + it.layout_shortcut = true; + let free = taken(CTRL_CMD_M, None).is_none(); + if free { + it.shortcut = Some(CTRL_CMD_M); + } + let resolution = if free { "Minimize moves to ⌃⌘M" } else { "Minimize has no shortcut" }; + clashes.push(Clash { shortcut: CMD_M, system: "Minimize", command, resolution }); + Node::Item(it) + } + None => options.standard(Standard::Minimize), + }; + let documents = document_items(&bar); + let window = ensure_menu(&mut bar, MenuRole::Window, "Window", options); + let mut children = vec![minimize, options.standard(Standard::Zoom), Node::Separator]; + children.append(&mut window.children); + children.extend([Node::Separator, options.standard(Standard::BringAllToFront), Node::Separator]); + children.extend(documents); + window.children = children; + + // The Help menu stays even when empty: it has the search field. + finish(&mut bar, |m| m.role == MenuRole::Help); + Layout { bar, clashes } +} + +/// Lay an app's menus out the Windows or Linux way: +/// - About goes last in Help, as `About {app}`; +/// - Settings goes last in Edit, as `Preferences…` (an item) or `Preferences` (a submenu); +/// - Quit goes last in File, as `Exit` on Windows and `Quit` elsewhere: the app's item tagged +/// [`ItemRole::Quit`], or [`Builtin::Quit`]; +/// - the open documents go last in Window, as radio items. +/// +/// The layout creates the File, Edit, Help and Window menus these need, and adds no Services, +/// Hide or Minimize items. Language and Appearance stay where the app put them, and items keep +/// the app's shortcuts. AppKit's items ([`Node::Standard`]) are dropped. +pub fn desktop_layout(bar: &MenuBar, target: Target, options: &LayoutOptions) -> Layout { + let quit = if target == Target::Windows { "Exit" } else { "Quit" }; + Layout { bar: desktop_bar(bar, target, Some(quit), options), clashes: Vec::new() } +} + +/// Lay an app's menus out for a browser: [`desktop_layout`] without a Quit item. Shortcuts the +/// browser keeps for itself (Ctrl or ⌘ with W, N, T or Q, and with Shift+N, Shift+T or Shift+W) +/// are taken off the items and reported as clashes, since the page never sees them. +pub fn web_layout(bar: &MenuBar, options: &LayoutOptions) -> Layout { + let mut bar = desktop_bar(bar, Target::Web, None, options); + let mut clashes = Vec::new(); + bar.for_each_item_mut(|it| { + if let Some(shortcut) = it.shortcut + && let Some(system) = browser_reserved(shortcut) + { + clashes.push(Clash { shortcut, system, command: it.command.clone(), resolution: "not shown: the browser keeps it" }); + it.shortcut = None; + } + }); + Layout { bar, clashes } +} + +// Browsers run these themselves and never pass them to the page. craft_keymap::reserved lists +// only the operating systems' chords. +const BROWSER: &[(Key, bool, &str)] = &[ + (Key::W, false, "Close Tab"), + (Key::N, false, "New Window"), + (Key::T, false, "New Tab"), + (Key::Q, false, "Quit"), + (Key::N, true, "New Private Window"), + (Key::T, true, "Reopen Closed Tab"), + (Key::W, true, "Close Window"), +]; + +/// What the browser uses `chord` for, if it keeps it. Either command modifier counts (Ctrl off +/// macOS, ⌘ on it), since a web build serves both. +fn browser_reserved(chord: Chord) -> Option<&'static str> { + let m = chord.mods; + if m.cmd == m.ctrl || m.alt { + return None; + } + BROWSER.iter().find(|(key, shift, _)| *key == chord.key && *shift == m.shift).map(|(_, _, what)| *what) +} + +/// [`desktop_layout`], with Quit labelled `quit` or left out. +fn desktop_bar(bar: &MenuBar, target: Target, quit: Option<&'static str>, options: &LayoutOptions) -> MenuBar { + let mut bar = shown_on(bar, target); + let item = |role: ItemRole| move |n: &Node| matches!(n, Node::Item(it) if it.role == Some(role)); + let about = take(&mut bar, item(ItemRole::About)); + let settings = take(&mut bar, |n| match n { + Node::Item(it) => it.role == Some(ItemRole::Settings), + Node::Submenu { role, .. } => *role == Some(ItemRole::Settings), + _ => false, + }); + let own_quit = take(&mut bar, item(ItemRole::Quit)); + + if let Some(label) = quit { + let mut it = match own_quit { + Some(Node::Item(it)) => it, + _ => Item::new(Builtin::Quit.command(), "").role(ItemRole::Quit), + }; + it.label = options.text(label); + ensure_menu(&mut bar, MenuRole::File, "File", options).children.extend([Node::Separator, Node::Item(it)]); + } + if let Some(s) = settings { + let s = match s { + Node::Item(mut it) => { + it.label = options.text("Preferences…"); + Node::Item(it) + } + Node::Submenu { children, role, platforms, .. } => Node::Submenu { label: options.text("Preferences"), children, role, platforms }, + other => other, + }; + ensure_menu(&mut bar, MenuRole::Edit, "Edit", options).children.extend([Node::Separator, s]); + } + let documents = document_items(&bar); + if !documents.is_empty() { + let window = ensure_menu(&mut bar, MenuRole::Window, "Window", options); + window.children.push(Node::Separator); + window.children.extend(documents); + } + if let Some(Node::Item(mut it)) = about { + it.label = options.text("About {app}"); + ensure_menu(&mut bar, MenuRole::Help, "Help", options).children.extend([Node::Separator, Node::Item(it)]); + } + finish(&mut bar, |_| false); + bar +} + +/// The app's menus with only what `target` shows: no items or submenus whose [`Platforms`] +/// leave it out, and no AppKit items off macOS. Tidied, so a submenu left empty goes. +fn shown_on(bar: &MenuBar, target: Target) -> MenuBar { + fn keep(nodes: &[Node], on: Platforms, appkit: bool) -> Vec { + let shown = nodes.iter().filter_map(|n| match n { + Node::Item(it) if !it.platforms.contains(on) => None, + Node::Submenu { platforms, .. } if !platforms.contains(on) => None, + Node::Submenu { label, children, role, platforms } => { + Some(Node::Submenu { label: label.clone(), children: keep(children, on, appkit), role: *role, platforms: *platforms }) + } + Node::Standard { .. } if !appkit => None, + other => Some(other.clone()), + }); + tidy(shown.collect()) + } + let on = target.platforms(); + let menus = bar.menus.iter().map(|m| Menu { children: keep(&m.children, on, target == Target::Mac), ..m.clone() }).collect(); + MenuBar { menus, documents: bar.documents.clone() } +} + +/// The listed documents as radio items, the one in front selected, after a separator. +fn document_items(bar: &MenuBar) -> Vec { + let items: Vec = + bar.documents.iter().filter(|d| !d.hidden).map(|d| Item::new(d.command.clone(), d.label.clone()).radio(d.active).into()).collect(); + if items.is_empty() { + return items; + } + std::iter::once(Node::Separator).chain(items).collect() +} + +/// The menu with `role`, added with the title `english` when the app has none: File first, Edit +/// after File, Help last, any other before Help. +fn ensure_menu<'b>(bar: &'b mut MenuBar, role: MenuRole, english: &'static str, options: &LayoutOptions) -> &'b mut Menu { + let at = match bar.menus.iter().position(|m| m.role == role) { + Some(i) => i, + None => { + let at = match role { + MenuRole::File => 0, + MenuRole::Edit => bar.menus.iter().position(|m| m.role == MenuRole::File).map_or(0, |i| i + 1), + MenuRole::Help => bar.menus.len(), + _ => bar.menus.iter().position(|m| m.role == MenuRole::Help).unwrap_or(bar.menus.len()), + }; + bar.menus.insert(at, Menu::new(options.text(english), Vec::new()).role(role)); + at + } + }; + &mut bar.menus[at] +} + +/// Tidy every menu and drop the ones the moves emptied, unless `keep_empty`. +fn finish(bar: &mut MenuBar, keep_empty: impl Fn(&Menu) -> bool) { + for m in &mut bar.menus { + m.children = tidy(std::mem::take(&mut m.children)); + } + bar.menus.retain(|m| !m.children.is_empty() || keep_empty(m)); +} + +/// Settings in the app menu: an item becomes "Settings…", a submenu of settings pages +/// "Settings". ⌘, goes to the item or the first enabled page, unless the app binds ⌘, elsewhere. +fn settings_node(node: Node, comma_taken: Option, options: &LayoutOptions, clashes: &mut Vec) -> Node { + if let Some(command) = &comma_taken { + clashes.push(Clash { shortcut: CMD_COMMA, system: "Settings", command: command.clone(), resolution: "Settings has no shortcut" }); + } + let give = |it: &mut Item| { + if comma_taken.is_none() && it.shortcut.is_none() { + it.shortcut = Some(CMD_COMMA); + it.layout_shortcut = true; + } + }; + match node { + Node::Item(mut it) => { + it.label = options.text("Settings…"); + give(&mut it); + Node::Item(it) + } + Node::Submenu { mut children, role, platforms, .. } => { + if let Some(Node::Item(first)) = children.iter_mut().find(|n| matches!(n, Node::Item(i) if i.enabled)) { + give(first); + } + Node::Submenu { label: options.text("Settings"), children, role, platforms } + } + other => other, + } +} + +/// Appearance in the app menu: the submenu tagged [`ItemRole::Appearance`], renamed, with the +/// items tagged the same (Next Theme) after it. Without such a submenu the tagged items go into +/// the app menu as they are. +fn appearance_node(bar: &mut MenuBar, options: &LayoutOptions) -> Vec { + let submenu = take(bar, |n| matches!(n, Node::Submenu { role, .. } if *role == Some(ItemRole::Appearance))); + let mut items = Vec::new(); + while let Some(it) = take(bar, |n| matches!(n, Node::Item(it) if it.role == Some(ItemRole::Appearance))) { + items.push(it); + } + match submenu { + Some(Node::Submenu { mut children, role, platforms, .. }) => { + if !items.is_empty() { + children.push(Node::Separator); + children.extend(items); + } + vec![Node::Submenu { label: options.text("Appearance"), children, role, platforms }] + } + _ => items, + } +} + +fn node_commands(node: &Node) -> Vec { + match node { + Node::Item(it) => vec![it.command.clone()], + Node::Submenu { children, .. } => children.iter().flat_map(node_commands).collect(), + _ => Vec::new(), + } +} + +/// Remove and return the first node matching `pred`, at any depth. +fn take(bar: &mut MenuBar, pred: impl Fn(&Node) -> bool + Copy) -> Option { + fn walk(nodes: &mut Vec, pred: impl Fn(&Node) -> bool + Copy) -> Option { + if let Some(i) = nodes.iter().position(pred) { + return Some(nodes.remove(i)); + } + nodes.iter_mut().find_map(|n| match n { + Node::Submenu { children, .. } => walk(children, pred), + _ => None, + }) + } + bar.menus.iter_mut().find_map(|m| walk(&mut m.children, pred)) +} diff --git a/crates/menu/src/lib.rs b/crates/menu/src/lib.rs new file mode 100644 index 00000000..6e46250c --- /dev/null +++ b/crates/menu/src/lib.rs @@ -0,0 +1,100 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! A Craft app's menus, laid out for macOS, Windows, Linux and the web. +//! +//! The app supplies its menus as a tree ([`MenuBar`]), built directly or from the flat rows of +//! its in-window bar ([`from_items`]), gives each top-level menu its role ([`MenuRole`]), and +//! tags the items the layouts put in fixed places ([`MenuBar::tag_item`], [`MenuBar::tag_submenu`]). +//! Each item runs a [`craft_keymap::CommandRef`], its shortcut is a [`craft_keymap::Chord`], and +//! its [`ItemKind`] makes it plain, a check item or a radio item. [`layout`] +//! arranges them the way the platform's apps do, and [`NativeMenu`] hands the result to a platform +//! [`Backend`] and brings its events back. This part is pure and platform-free, so it is tested +//! everywhere and builds for the web; only the backend, `mac` (macOS only, built on muda), touches +//! AppKit. Its `mac::install` builds the menu bar and returns the [`NativeMenu`]. +//! +//! **Layouts.** [`layout`] takes a [`Target`] ([`Target::current`] for this build) and calls +//! [`mac_layout`], [`desktop_layout`] (Windows and Linux) or [`web_layout`]. Each first drops the +//! items and submenus whose [`Platforms`] leave the target out, and every one lists +//! [`MenuBar::documents`] (but the hidden ones) last in the Window menu, as radio items with the +//! active document selected. On Windows and Linux About goes last in Help, Settings last in Edit +//! as Preferences, and Quit last in File, as Exit on Windows; the web layout has no Quit and takes +//! the browser's own shortcuts (Ctrl+W, Ctrl+T, …) off the items, reporting each as a [`Clash`]. +//! A layout adds the menus it needs when the app has none. A submenu left empty goes; a dynamic +//! list with nothing in it shows the disabled placeholder item the app supplies. +//! +//! **Keymap.** [`apply_keymap`] sets the items' shortcuts from the app's +//! [`craft_keymap::Keymap`], user overrides included, and makes toggle commands check items; +//! [`apply_state`] sets enabled and checked per frame from an [`ItemState`] the app returns for +//! each command. Both run on the app's bar, before the layout. +//! +//! What the Mac layout does: +//! - an **app menu** named after the app: About (the item tagged [`ItemRole::About`]), Settings +//! ([`ItemRole::Settings`], an item or a submenu of pages), Language ([`ItemRole::Language`]), +//! Appearance ([`ItemRole::Appearance`]), Services, Hide ([`Builtin::Hide`]), Hide Others +//! ([`Builtin::HideOthers`]), Show All and Quit. Quit is the app's own item tagged +//! [`ItemRole::Quit`], so its unsaved-changes prompt runs; without one the layout adds +//! [`Builtin::Quit`], which asks the window to close. These items leave the menus they came from. A menu the app +//! tagged [`MenuRole::App`] is merged into this one; +//! - **Window** ([`MenuRole::Window`], added when the app has none) gets Minimize, Zoom and Bring +//! All to Front, with the open documents ([`MenuBar::documents`]) after them, where a Mac app +//! lists its windows. **Help** ([`MenuRole::Help`]) gets the system search field from the +//! backend. **File** ([`MenuRole::File`]) gets Close Window unless the app binds ⌘W; +//! - **clashes** with the system's keys (⌘H, ⌥⌘H, ⌘M, ⌘W, ⌘,, ⌘Q) are resolved in the app's +//! favour and reported ([`Clash`]); Hide and Minimize move to ⌃⌘H and ⌃⌘M, as Photoshop does, +//! the others go without a shortcut. Shortcuts the app binds outside its menus count too +//! ([`LayoutOptions::extra_shortcuts`]). +//! +//! The labels the layout writes itself are English unless the app passes its translations in +//! [`LayoutOptions::label`]. +//! +//! **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, +//! which would skip the app's own key rules (a focused text field keeps ⌘C, ⌘V and ⌘Z, dialogs +//! are modal, a tool may have its own ⌘Z). So the backend reports a key equivalent as +//! [`Event::Key`], and [`NativeMenu::raw_input`] feeds it to egui as the input egui-winit would +//! have made of it: a key press, or for ⌘C, ⌘X and ⌘V egui's copy, cut and paste events +//! ([`ClipboardKeys`]). The shortcut runs through the app's key handling as without a native +//! menu. A click is [`Event::Click`], and [`NativeMenu::take_clicks`] returns its command for the app +//! to run like an in-window menu click; so is a key equivalent the layout assigned (⌘Q on the +//! app's Quit item, ⌘, on Settings), which the app's key handling doesn't know, and, while a text +//! field has the keys ([`Gate::text_editing`]), every key equivalent but the field's own +//! (⌘C, ⌘X, ⌘V and [`craft_keymap::text_editing_chords`]): an app's key handling skips shortcuts +//! while text has the keys, and a Mac app's menu commands (⌘S, ⌘N) run while typing. Only +//! shortcuts with ⌘ or ⌃ become key equivalents; tool keys (`B`, `⇧B`, `F5`) stay with the app. +//! +//! What may run from the menus is the app's [`Gate`], set every frame ([`NativeMenu::set_gate`]): +//! while a modal dialog is open or a shortcut is being recorded, the app's items are greyed (but +//! the text fields' own Undo, Copy, …) and their clicks dropped, also clicks that arrived before +//! the gate closed. While a shortcut is +//! recorded, key equivalents reach egui as plain key presses. +//! +//! craft-menu owns muda's one menu event handler: `mac::install` (a full menu bar) and +//! `mac::install_app_menu` (a minimal app menu, for apps without a [`MenuBar`]) both use it. +//! [`NativeMenu::on_command`] sees each allowed click as it arrives, also while egui is idle. +//! +//! Per frame: [`NativeMenu::raw_input`] before egui reads the input (`eframe::App::raw_input_hook`), +//! then [`NativeMenu::set_gate`], [`NativeMenu::take_clicks`] and run the commands, then +//! [`NativeMenu::sync_with`], which lays the menus out again only when the app's state changed +//! (or [`NativeMenu::sync`], which compares a layout built every frame). +//! [`NativeMenu::raw_input`] also keeps a click or Escape that closes an open native menu away +//! from egui. + +pub mod bar; +mod driver; +mod keymap; +mod layout; +mod model; + +#[cfg(target_os = "macos")] +pub mod mac; + +pub use bar::{BarStyle, Labeled, MenuBarUi}; +pub use craft_keymap::{Chord, CommandRef}; +pub use driver::{Backend, ClipboardKeys, CommandHook, Dispatch, Event, Gate, NativeMenu, had_input}; +pub use keymap::{ItemState, apply_keymap, apply_state}; +pub use layout::{Clash, Layout, LayoutOptions, Target, desktop_layout, layout, mac_layout, web_layout}; +pub use model::{ + Builtin, Document, Item, ItemKind, ItemRole, Menu, MenuBar, MenuRole, Node, Platforms, Row, Standard, from_items, structure_key, tidy, window_key, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/menu/src/mac.rs b/crates/menu/src/mac.rs new file mode 100644 index 00000000..e5bdd306 --- /dev/null +++ b/crates/menu/src/mac.rs @@ -0,0 +1,694 @@ +//! The macOS menu bar, through muda (AppKit menus behind a safe API; no GTK with default features +//! off). This module only builds and updates the `NSMenu`s; the layout and how keys reach egui are +//! in the platform-free part of the crate. +//! +//! - An item's muda id is its command's [`craft_keymap::CommandRef::key`]. muda ids must be +//! unique: a command shown twice (Keyboard Shortcuts in Edit and Window) has `key` for its first +//! item and `key#2` for its second, and both report the command. +//! - A radio item is a check item: AppKit menus have no radio look. +//! - A structure change ([`crate::structure_key`]: a new Open Recent file, a language switch) +//! rebuilds the menu bar; labels, enabled and checked are updated in place. Opening or closing a +//! document ([`crate::window_key`]) rebuilds only the Window menu: AppKit adds its own items to +//! that menu (window tiling, the window list), so muda can't insert rows there by position. +//! - A chosen item is a click or a key equivalent: AppKit's current event says which. A key +//! equivalent goes back to egui as the key press that was made (the event's modifiers, not the +//! item's: AppKit may match ⌘R to a ⇧⌘R item), so the app's key rules still apply. A shortcut +//! the layout assigned ([`crate::Item::layout_shortcut`]) stays a click: the app's keys don't +//! know it. While a text field has the keys, a key equivalent is a click unless the field +//! handles it ([`NativeMenu::set_gate`]). +//! - Only shortcuts with ⌘ or ⌃ become key equivalents. The others (`B`, `⇧B`, `⌥B`, `F5`) are not +//! shown in the native menu: muda 0.21 has no handle on the `NSMenuItem`, and its +//! `set_attributed_title` needs an `NSAttributedString` with a font and a tab stop, which +//! objc2 builds only with `unsafe` calls (the workspace forbids unsafe code). +//! - The process has one muda menu event handler, set by the first [`install`] or +//! [`install_app_menu`]; each install points it at its own menu. +//! - A check or radio item: AppKit (muda) flips its state when it is chosen. The next sync sets it +//! back to the app's state; the driver re-syncs after every menu event. +//! - Hide ([`crate::Builtin::Hide`]) and Hide Others ([`crate::Builtin::HideOthers`]) are custom items calling +//! `NSApplication hide:` and `hideOtherApplications:`, because the predefined ones always take ⌘H +//! and ⌥⌘H. +//! - While a menu is open, changes wait until it closes: replacing or changing an `NSMenu` while +//! AppKit tracks it can leave the displayed menu stranded. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; +use std::time::Duration; + +use muda::accelerator::{Accelerator, Code, Modifiers}; +use muda::{CheckMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType}; + +use craft_keymap::{Chord, CommandRef, Key, Mods}; + +use crate::driver::{Backend, Dispatch, Event, NativeMenu, native_ok}; +use crate::model::{Builtin, Item, ItemKind, Menu, MenuBar, MenuRole, Node, Standard, native_id, structure_key, window_key}; + +/// Set by [`install`]: there is one full menu bar per process. +static INSTALLED: AtomicBool = AtomicBool::new(false); + +/// Where the menu event handler sends events: the menu installed last. +static ROUTE: Mutex> = Mutex::new(None); + +/// muda keeps the first menu event handler set and ignores later ones, so it is set once. +static HANDLER: OnceLock<()> = OnceLock::new(); + +thread_local! { + /// The minimal app menu ([`install_app_menu`]). muda's items must outlive the native menu + /// that shows them. + static APP_MENU: RefCell> = const { RefCell::new(None) }; +} + +/// Install the native menu bar for `bar` (a [`crate::mac_layout`] result) and return the driver +/// for it. Call it once, on the main thread, in eframe's creator closure, so winit's default menu +/// doesn't stay up. `None` when AppKit refused the menus (the app keeps its in-window menus), off +/// the main thread, or when a menu bar is already installed. It replaces an app menu from +/// [`install_app_menu`]. +/// +/// craft-menu owns muda's menu event handler. muda keeps the first one set, so an app calls this +/// or the deprecated `craft_quit::install_app_menu`, never both: called first, that one leaves +/// this menu without events; called after, it replaces this menu bar with its app menu. +pub fn install(ctx: &egui::Context, bar: &MenuBar) -> Option { + install_with(ctx, bar, MacOptions::default()) +} + +/// How [`install_with`] sets up the native menus. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct MacOptions { + /// Make the [`MenuRole::Window`] menu AppKit's Windows menu, which lists the app's windows + /// after the menu's own items. On by default. An app that lists its documents itself + /// in the Window menu ([`MenuBar::documents`]) and keeps them in one window turns it off: AppKit + /// would list that window as well. + pub window_list: bool, +} + +impl Default for MacOptions { + fn default() -> MacOptions { + MacOptions { window_list: true } + } +} + +impl MacOptions { + pub fn window_list(mut self, on: bool) -> MacOptions { + self.window_list = on; + self + } +} + +/// [`install`] with `options`. +pub fn install_with(ctx: &egui::Context, bar: &MenuBar, options: MacOptions) -> Option { + MainThreadMarker::new()?; + if INSTALLED.swap(true, Ordering::SeqCst) { + return None; + } + let mut menu = MacMenu::new(ctx, options); + if !menu.build(bar) { + INSTALLED.store(false, Ordering::SeqCst); + return None; + } + // `NativeMenu::new` attaches the driver's dispatch, which points the handler at this menu. + Some(NativeMenu::new(Box::new(menu))) +} + +/// Replace winit's default macOS app menu with a minimal one whose Quit (⌘Q) asks the app first, +/// for an app without a [`MenuBar`] of its own: About, Services, Hide, Hide Others, Show All and +/// Quit, labelled in English with `app_name` ("Quit WordCraft"). +/// +/// winit's Quit ends the process without asking, so unsaved work is lost. This Quit has the id +/// [`craft_quit::QUIT_MENU_ID`] and runs [`craft_quit::on_menu_event`]: it asks the root window to +/// close, the app's unsaved-changes check runs as for the close button, and +/// [`craft_quit::on_close_request`] then quits. Call it once, from the eframe app creator: winit +/// builds its default menu before that and would replace one installed earlier. +/// +/// Does nothing when this menu or a full menu bar ([`install`]) is already installed. On error +/// winit's menu stays: the app still runs, but ⌘Q quits without asking. +pub fn install_app_menu(ctx: &egui::Context, app_name: &str) -> Result<(), Box> { + if MainThreadMarker::new().is_none() { + return Err("craft_menu::mac::install_app_menu runs on the main thread".into()); + } + if INSTALLED.load(Ordering::SeqCst) || APP_MENU.with_borrow(Option::is_some) { + return Ok(()); + } + let quit = MenuItem::with_id(craft_quit::QUIT_MENU_ID, format!("Quit {app_name}"), true, Some(Accelerator::new(Modifiers::META, Code::KeyQ))); + let app_menu = Submenu::new(app_name, true); + app_menu.append_items(&[ + &PredefinedMenuItem::about(Some(&format!("About {app_name}")), None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::services(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::hide(Some(&format!("Hide {app_name}"))), + &PredefinedMenuItem::hide_others(None), + &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::separator(), + &quit, + ])?; + let menu = muda::Menu::new(); + menu.append(&app_menu)?; + menu.init_for_nsapp(); + route_events(Route::AppMenu(ctx.clone())); + APP_MENU.set(Some(menu)); + Ok(()) +} + +/// The installed menu the handler reports to. +#[derive(Clone)] +enum Route { + /// [`install_app_menu`]'s: only Quit reports, to craft-quit. + AppMenu(egui::Context), + /// A full menu bar's. + Menu(MenuRoute), +} + +#[derive(Clone)] +struct MenuRoute { + ctx: egui::Context, + targets: Targets, + dispatch: Dispatch, + tx: Sender, +} + +/// What each native item runs, by muda id, for the handler. +type Targets = Arc>>; + +/// Point the process's menu event handler at `route`, setting the handler the first time. +fn route_events(route: Route) { + *lock(&ROUTE) = Some(route); + HANDLER.get_or_init(|| MenuEvent::set_event_handler(Some(handle_event))); +} + +/// The menu event handler. AppKit calls it on the main thread, from the event that chose the +/// item: a key equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu). +fn handle_event(e: MenuEvent) { + let held = MainThreadMarker::new() + .and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent()) + .filter(|ev| ev.r#type() == NSEventType::KeyDown) + .map(|ev| { + let flags = ev.modifierFlags(); + Mods { + cmd: flags.contains(NSEventModifierFlags::Command), + ctrl: flags.contains(NSEventModifierFlags::Control), + alt: flags.contains(NSEventModifierFlags::Option), + shift: flags.contains(NSEventModifierFlags::Shift), + } + }); + // A copy, so nothing is locked while the app's hook runs. + let route = lock(&ROUTE).clone(); + let ctx = match route { + None => return, + Some(Route::AppMenu(ctx)) => { + craft_quit::on_menu_event(&ctx, e.id.as_ref()); + ctx + } + Some(Route::Menu(r)) => { + let target = lock(&r.targets).get(e.id.as_ref()).cloned(); + // Ids that aren't ours are AppKit's own items (Services, Zoom, …), already handled. + if let Some(t) = target { + let event = r.dispatch.chosen(&t.command, t.chord.as_ref(), t.layout_shortcut, held); + // The receiver goes away only with the app. + let _ = r.tx.send(event); + } + r.ctx + } + }; + // Choosing an item is not window input, so nothing else wakes egui: without this the item + // runs only with the next input event, 8 to 90 seconds later in measurements. + ctx.request_repaint(); +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +#[derive(Clone)] +enum Handle { + Plain(MenuItem), + Check(CheckMenuItem), +} + +/// One native item. +#[derive(Clone)] +struct Entry { + handle: Handle, + target: Target, + label: String, + enabled: bool, +} + +/// What choosing an item reports. +#[derive(Clone)] +struct Target { + /// The command (several items can share one). + command: CommandRef, + chord: Option, + layout_shortcut: bool, +} + +/// The native items by muda id. +type Entries = HashMap; + +/// The Window menu, the muda ids of its items, and its [`window_key`]. +type WindowMenu = (Submenu, Vec, u64); + +struct MacMenu { + ctx: egui::Context, + menu: Option, + entries: Entries, + /// The entries' targets, for the menu event handler. + targets: Targets, + tx: Sender, + rx: Receiver, + structure: u64, + window: Option, + /// The latest menus, waiting for the open menu to close. + pending: Option, + options: MacOptions, +} + +impl MacMenu { + fn new(ctx: &egui::Context, options: MacOptions) -> MacMenu { + let (tx, rx) = channel(); + MacMenu { + ctx: ctx.clone(), + menu: None, + entries: HashMap::new(), + targets: Targets::default(), + tx, + rx, + structure: 0, + window: None, + pending: None, + options, + } + } + + /// Hand the entries' targets to the menu event handler. + fn publish(&self) { + *lock(&self.targets) = self.entries.iter().map(|(id, e)| (id.clone(), e.target.clone())).collect(); + } + + /// Build the menu bar for `bar` and make it NSApp's. On an error or a panic in muda or AppKit + /// the menus stay as they were (never crash for a menu) and this returns false. + fn build(&mut self, bar: &MenuBar) -> bool { + let built = catch_unwind(AssertUnwindSafe(|| -> muda::Result<(muda::Menu, Entries, Option)> { + let mut entries = Entries::new(); + let mut window = None; + let menu = muda::Menu::new(); + let mut special = Vec::new(); + for m in &bar.menus { + let (sub, ids) = submenu(&mut entries, m)?; + menu.append(&sub)?; + if m.role == MenuRole::Window { + window = Some((sub.clone(), ids, window_key(bar))); + } + if m.role == MenuRole::Help || (m.role == MenuRole::Window && self.options.window_list) { + special.push((m.role, sub)); + } + } + menu.init_for_nsapp(); + // Only once the menu is NSApp's: muda looks the submenus up in the installed main menu. + for (role, sub) in special { + match role { + MenuRole::Window => sub.set_as_windows_menu_for_nsapp(), + _ => sub.set_as_help_menu_for_nsapp(), + } + } + Ok((menu, entries, window)) + })); + match built { + Ok(Ok((menu, entries, window))) => { + self.menu = Some(menu); + self.entries = entries; + self.window = window; + self.structure = structure_key(bar); + self.publish(); + true + } + _ => { + // The new menu may have been made NSApp's before the failure. + if let Some(old) = &self.menu { + let _ = catch_unwind(AssertUnwindSafe(|| { + old.init_for_nsapp(); + if let Some((sub, ..)) = &self.window + && self.options.window_list + { + sub.set_as_windows_menu_for_nsapp(); + } + })); + } + false + } + } + } + + /// Replace the Window menu (a document opened or closed), leaving the other menus as they are. + /// Falls back to a full build when that fails. + fn rebuild_window(&mut self, bar: &MenuBar) { + let (Some(menu), Some((old, old_ids, _))) = (self.menu.clone(), self.window.clone()) else { + self.build(bar); + return; + }; + let Some((at, spec)) = bar.menus.iter().enumerate().find(|(_, m)| m.role == MenuRole::Window) else { + self.build(bar); + return; + }; + let mut entries = self.entries.clone(); + for id in &old_ids { + entries.remove(id); + } + let replaced = catch_unwind(AssertUnwindSafe(|| -> muda::Result { + let (sub, ids) = submenu(&mut entries, spec)?; + menu.remove(&old)?; + if let Err(e) = menu.insert(&sub, at) { + let _ = menu.insert(&old, at); + return Err(e); + } + if self.options.window_list { + sub.set_as_windows_menu_for_nsapp(); + } + Ok((sub, ids, window_key(bar))) + })); + match replaced { + Ok(Ok(window)) => { + self.entries = entries; + self.window = Some(window); + self.publish(); + } + _ => { + self.build(bar); + } + } + } + + fn update(&mut self, bar: &MenuBar) { + if self.menu.is_none() || structure_key(bar) != self.structure { + self.build(bar); + return; + } + if self.window.as_ref().map(|w| w.2) != Some(window_key(bar)) { + self.rebuild_window(bar); + } + // Items by muda id: the n-th item of a command, depth first, is its n-th native item. + let mut items: HashMap = HashMap::new(); + for it in bar.items() { + let id = native_id(&it.command, |id| items.contains_key(id)); + items.insert(id, it); + } + for (id, e) in &mut self.entries { + let Some(it) = items.get(id) else { continue }; + if e.label != it.label { + let text = escape(&it.label); + match &e.handle { + Handle::Plain(h) => h.set_text(text), + Handle::Check(h) => h.set_text(text), + } + e.label = it.label.clone(); + } + if e.enabled != it.enabled { + match &e.handle { + Handle::Plain(h) => h.set_enabled(it.enabled), + Handle::Check(h) => h.set_enabled(it.enabled), + } + e.enabled = it.enabled; + } + // Compare with AppKit's own state: muda flips a check item when it's chosen, and the + // app's state wins (a radio item chosen again stays selected). + if let (Handle::Check(h), Some(c)) = (&e.handle, it.kind.checked()) + && h.is_checked() != c + { + h.set_checked(c); + } + } + } + + /// Nothing else may wake egui when the menu closes (Escape goes to the menu), so look again. + fn retry_later(&mut self, bar: MenuBar) { + self.pending = Some(bar); + self.ctx.request_repaint_after(Duration::from_millis(250)); + } +} + +/// A top-level menu, and the muda ids of the items it added to `entries`. +fn submenu(entries: &mut Entries, m: &Menu) -> muda::Result<(Submenu, Vec)> { + let before: HashSet = entries.keys().cloned().collect(); + let sub = Submenu::new(escape(&m.title), true); + append(entries, &sub, &m.children)?; + let ids = entries.keys().filter(|id| !before.contains(*id)).cloned().collect(); + Ok((sub, ids)) +} + +fn append(entries: &mut Entries, sub: &Submenu, nodes: &[Node]) -> muda::Result<()> { + for n in nodes { + match n { + Node::Separator => sub.append(&PredefinedMenuItem::separator())?, + Node::Submenu { label, children, .. } => { + let s = Submenu::new(escape(label), !children.is_empty()); + append(entries, &s, children)?; + sub.append(&s)?; + } + Node::Standard { kind, label } => { + let text = Some(label.as_str()); + let p = match kind { + Standard::Services => PredefinedMenuItem::services(text), + Standard::ShowAll => PredefinedMenuItem::show_all(text), + Standard::Minimize => PredefinedMenuItem::minimize(text), + Standard::Zoom => PredefinedMenuItem::maximize(text), + Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(text), + Standard::CloseWindow => PredefinedMenuItem::close_window(text), + }; + sub.append(&p)?; + } + Node::Item(it) => { + let id = native_id(&it.command, |id| entries.contains_key(id)); + let chord = it.shortcut; + let accel = chord.as_ref().filter(|c| native_ok(c)).and_then(accelerator); + let handle = match it.kind { + ItemKind::Check(c) | ItemKind::Radio(c) => { + let h = CheckMenuItem::with_id(id.clone(), escape(&it.label), it.enabled, c, accel); + sub.append(&h)?; + Handle::Check(h) + } + ItemKind::Plain => { + let h = MenuItem::with_id(id.clone(), escape(&it.label), it.enabled, accel); + sub.append(&h)?; + Handle::Plain(h) + } + }; + let target = Target { command: it.command.clone(), chord, layout_shortcut: it.layout_shortcut }; + let entry = Entry { handle, target, label: it.label.clone(), enabled: it.enabled }; + entries.insert(id, entry); + } + } + } + Ok(()) +} + +impl Backend for MacMenu { + fn sync(&mut self, bar: &MenuBar) { + if self.tracking() { + // The driver counts this sync as done and may not send these menus again. + self.retry_later(bar.clone()); + return; + } + self.pending = None; + self.update(bar); + } + + fn drain(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(event) = self.rx.try_recv() { + // With asynchronous menu presentation the menu can stay on screen after its item ran. + self.cancel_tracking(); + // Hide and Hide Others run here. While a shortcut is recorded their key equivalents + // arrive as key presses instead, for the recorder. + if let Event::Click(command) = &event + && let Some(hide @ (Builtin::Hide | Builtin::HideOthers)) = Builtin::of(command) + { + if let Some(mtm) = MainThreadMarker::new() { + let app = NSApplication::sharedApplication(mtm); + if hide == Builtin::Hide { app.hide(None) } else { app.hideOtherApplications(None) } + } + continue; + } + out.push(event); + } + // After the events: they were chosen from the menus as they were. + if let Some(bar) = self.pending.take() { + if self.tracking() { + self.retry_later(bar); + } else { + self.update(&bar); + } + } + out + } + + /// A menu bar menu is open while one of its titles is highlighted. + /// + /// This assumes AppKit sets the main menu's `highlightedItem` while one of its menus is open + /// and clears it when the menu closes; not yet confirmed on every macOS version. If it stayed + /// set, menu updates would wait (retried every 250 ms) and the driver would keep only one + /// window click from egui ([`NativeMenu::raw_input`]). + fn tracking(&self) -> bool { + MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).mainMenu()).is_some_and(|m| m.highlightedItem().is_some()) + } + + fn attach(&mut self, dispatch: Dispatch) { + route_events(Route::Menu(MenuRoute { ctx: self.ctx.clone(), targets: self.targets.clone(), dispatch, tx: self.tx.clone() })); + } + + fn cancel_tracking(&mut self) { + if let Some(menu) = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).mainMenu()) { + menu.cancelTracking(); + } + } +} + +/// `&` marks a mnemonic in muda labels. +fn escape(s: &str) -> String { + s.replace('&', "&&") +} + +/// A key equivalent for AppKit. `cmd` is ⌘ (`META`) and `ctrl` is ⌃: never folded together. +fn accelerator(c: &Chord) -> Option { + let m = c.mods; + let mut mods = Modifiers::empty(); + for (on, flag) in [(m.cmd, Modifiers::META), (m.ctrl, Modifiers::CONTROL), (m.alt, Modifiers::ALT), (m.shift, Modifiers::SHIFT)] { + if on { + mods |= flag; + } + } + let code = match c.key { + Key::A => Code::KeyA, + Key::B => Code::KeyB, + Key::C => Code::KeyC, + Key::D => Code::KeyD, + Key::E => Code::KeyE, + Key::F => Code::KeyF, + Key::G => Code::KeyG, + Key::H => Code::KeyH, + Key::I => Code::KeyI, + Key::J => Code::KeyJ, + Key::K => Code::KeyK, + Key::L => Code::KeyL, + Key::M => Code::KeyM, + Key::N => Code::KeyN, + Key::O => Code::KeyO, + Key::P => Code::KeyP, + Key::Q => Code::KeyQ, + Key::R => Code::KeyR, + Key::S => Code::KeyS, + Key::T => Code::KeyT, + Key::U => Code::KeyU, + Key::V => Code::KeyV, + Key::W => Code::KeyW, + Key::X => Code::KeyX, + Key::Y => Code::KeyY, + Key::Z => Code::KeyZ, + Key::Num0 => Code::Digit0, + Key::Num1 => Code::Digit1, + Key::Num2 => Code::Digit2, + Key::Num3 => Code::Digit3, + Key::Num4 => Code::Digit4, + Key::Num5 => Code::Digit5, + Key::Num6 => Code::Digit6, + Key::Num7 => Code::Digit7, + Key::Num8 => Code::Digit8, + Key::Num9 => Code::Digit9, + Key::F1 => Code::F1, + Key::F2 => Code::F2, + Key::F3 => Code::F3, + Key::F4 => Code::F4, + Key::F5 => Code::F5, + Key::F6 => Code::F6, + Key::F7 => Code::F7, + Key::F8 => Code::F8, + Key::F9 => Code::F9, + Key::F10 => Code::F10, + Key::F11 => Code::F11, + Key::F12 => Code::F12, + Key::F13 => Code::F13, + Key::F14 => Code::F14, + Key::F15 => Code::F15, + Key::F16 => Code::F16, + Key::F17 => Code::F17, + Key::F18 => Code::F18, + Key::F19 => Code::F19, + Key::F20 => Code::F20, + Key::F21 => Code::F21, + Key::F22 => Code::F22, + Key::F23 => Code::F23, + Key::F24 => Code::F24, + Key::OpenBracket => Code::BracketLeft, + Key::CloseBracket => Code::BracketRight, + Key::Backslash => Code::Backslash, + Key::Slash => Code::Slash, + // `+` is ⇧= on the keyboards AppKit matches by key: Zoom In shows ⌘=. + Key::Equals | Key::Plus => Code::Equal, + Key::Minus => Code::Minus, + Key::Quote => Code::Quote, + Key::Semicolon => Code::Semicolon, + Key::Comma => Code::Comma, + Key::Period => Code::Period, + Key::Backtick => Code::Backquote, + // `Delete` is forward delete (egui's `Key::Delete`), `Backspace` the key Macs label + // delete. muda writes them as U+007F and U+0008; which physical key AppKit matches to + // each is not yet checked on a Mac. + Key::Delete => Code::Delete, + Key::Backspace => Code::Backspace, + Key::Insert => Code::Insert, + Key::Left => Code::ArrowLeft, + Key::Right => Code::ArrowRight, + Key::Up => Code::ArrowUp, + Key::Down => Code::ArrowDown, + Key::Home => Code::Home, + Key::End => Code::End, + Key::PageUp => Code::PageUp, + Key::PageDown => Code::PageDown, + Key::Escape if !mods.is_empty() => Code::Escape, + Key::Enter if !mods.is_empty() => Code::Enter, + Key::Tab if !mods.is_empty() => Code::Tab, + Key::Space if !mods.is_empty() => Code::Space, + _ => return None, + }; + Some(Accelerator::new(mods, code)) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::driver::key_event; + + /// The shortcuts the layout writes itself, and common app ones, become key equivalents. + #[test] + fn shortcuts_map_to_key_equivalents() { + #[rustfmt::skip] + let shortcuts = [ + "Cmd+Q", "Cmd+H", "Cmd+Alt+H", "Ctrl+Cmd+H", "Ctrl+Cmd+M", "Cmd+,", "Cmd+Shift+Z", "Cmd+1", "Cmd+[", "Cmd+=", "Cmd++", + "Cmd+-", "Cmd+Delete", "Cmd+Backspace", "Cmd+Left", "Alt+Cmd+ArrowDown", "Cmd+Escape", "Cmd+Home", "Cmd+End", + "Cmd+PageUp", "Cmd+PageDown", "Cmd+Enter", "Ctrl+F7", + ]; + for s in shortcuts { + let c = Chord::parse(s).unwrap(); + assert!(native_ok(&c) && accelerator(&c).is_some(), "{s} has no key equivalent"); + assert!(matches!(key_event(&c, true), egui::Event::Key { .. }), "{s} has no egui key press"); + } + let code = |s: &str| accelerator(&Chord::parse(s).unwrap()).unwrap(); + assert_eq!(code("Cmd+Delete"), Accelerator::new(Modifiers::META, Code::Delete), "forward delete, as egui's Key::Delete"); + assert_eq!(code("Cmd+Backspace"), Accelerator::new(Modifiers::META, Code::Backspace)); + assert_eq!(code("Cmd++"), code("Cmd+="), "⌘+ is the ⌘= key"); + } + + #[test] + fn control_and_command_stay_distinct() { + let a = accelerator(&Chord::parse("Cmd+M").unwrap()).unwrap(); + let b = accelerator(&Chord::parse("Ctrl+Cmd+M").unwrap()).unwrap(); + assert_ne!(a, b); + } +} diff --git a/crates/menu/src/model.rs b/crates/menu/src/model.rs new file mode 100644 index 00000000..f8a295ab --- /dev/null +++ b/crates/menu/src/model.rs @@ -0,0 +1,561 @@ +//! The menu tree, the flat rows it can be built from, and the keys that tell a backend what +//! changed. + +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::ops::BitOr; + +use craft_keymap::{Chord, CommandRef}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MenuBar { + pub menus: Vec, + /// The app's open documents, for the Window menu. + pub documents: Vec, +} + +/// An open document, listed in the Window menu. +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + /// What choosing it runs (bring the document to the front). + pub command: CommandRef, + /// The document's name. + pub label: String, + /// The document in front. + pub active: bool, + /// Open but not listed: a document the app keeps open in the background. + pub hidden: bool, +} + +impl Document { + /// A listed document, not the one in front. + pub fn new(command: impl Into, label: impl Into) -> Document { + Document { command: command.into(), label: label.into(), active: false, hidden: false } + } + + pub fn active(mut self, active: bool) -> Document { + self.active = active; + self + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Menu { + pub title: String, + pub role: MenuRole, + pub children: Vec, +} + +/// What a top-level menu is for, set by the app. macOS treats the app, Window and Help menus +/// specially; File is where the system Close Window item goes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum MenuRole { + /// Any menu without a fixed place. + #[default] + Other, + App, + File, + Edit, + View, + Window, + Help, +} + +/// The platforms an item or submenu is shown on. All of them by default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Platforms(u8); + +impl Platforms { + pub const NONE: Platforms = Platforms(0); + pub const MAC: Platforms = Platforms(1); + pub const WINDOWS: Platforms = Platforms(1 << 1); + pub const LINUX: Platforms = Platforms(1 << 2); + pub const WEB: Platforms = Platforms(1 << 3); + pub const ALL: Platforms = Platforms(0b1111); + + /// Whether every platform in `other` is in `self`. + pub const fn contains(self, other: Platforms) -> bool { + self.0 & other.0 == other.0 + } + + /// `self` without the platforms in `other`: `Platforms::ALL.without(Platforms::WEB)`. + pub const fn without(self, other: Platforms) -> Platforms { + Platforms(self.0 & !other.0) + } + + pub const fn is_empty(self) -> bool { + self.0 == 0 + } +} + +impl Default for Platforms { + fn default() -> Platforms { + Platforms::ALL + } +} + +impl BitOr for Platforms { + type Output = Platforms; + fn bitor(self, other: Platforms) -> Platforms { + Platforms(self.0 | other.0) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Node { + Item(Item), + Separator, + Submenu { + label: String, + children: Vec, + role: Option, + platforms: Platforms, + }, + /// An item AppKit provides and runs itself (Services, Zoom, …), with the label to show. + Standard { + kind: Standard, + label: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Item { + /// What the item runs: the app's command (`file.open`), with its parameters + /// (`CommandRef::with_params("file.open_recent", json!({"index": 3}))`), or a [`Builtin`]. A + /// click reports it. + pub command: CommandRef, + pub label: String, + /// `cmd` is ⌘ and `ctrl` the Control key: ⌃⌘M and ⌘M are different shortcuts. + pub shortcut: Option, + pub enabled: bool, + pub kind: ItemKind, + pub role: Option, + /// The platforms the item is shown on. + pub platforms: Platforms, + /// The layout gave this item its shortcut (⌘Q on the app's Quit, ⌘, on Settings): the app's + /// key handling doesn't know it, so a backend reports the key equivalent as a click. + pub layout_shortcut: bool, +} + +/// A plain item, or one with a checked state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ItemKind { + #[default] + Plain, + /// A check item, and whether it is checked. + Check(bool), + /// One choice of a group: the radio items next to each other between separators. Whether it + /// is the selected one. macOS shows it as a check item. + Radio(bool), +} + +impl ItemKind { + /// The checked or selected state, `None` for a plain item. + pub fn checked(self) -> Option { + match self { + ItemKind::Plain => None, + ItemKind::Check(on) | ItemKind::Radio(on) => Some(on), + } + } +} + +/// Items the layouts put in fixed places. The app tags its own About, Settings, Quit, Appearance +/// and Language items; the Mac layout adds Hide and Minimize. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ItemRole { + About, + /// The app's settings: an item, or a submenu of settings pages. + Settings, + /// The app's own quit command, which asks about unsaved changes. Without one the layout adds + /// [`Builtin::Quit`]. + Quit, + Hide, + Minimize, + /// A submenu of themes or appearance modes, and items that go with it (Next Theme). + Appearance, + /// The app's language chooser, a submenu. + Language, +} + +/// The items AppKit provides. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Standard { + Services, + ShowAll, + Minimize, + Zoom, + BringAllToFront, + CloseWindow, +} + +impl Standard { + /// The English label, as passed to [`crate::LayoutOptions::label`]. + pub fn english(self) -> &'static str { + match self { + Standard::Services => "Services", + Standard::ShowAll => "Show All", + Standard::Minimize => "Minimize", + Standard::Zoom => "Zoom", + Standard::BringAllToFront => "Bring All to Front", + Standard::CloseWindow => "Close Window", + } + } +} + +/// The commands of the items the crate adds itself. The crate runs them; an app sees them only +/// in a layout's result. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Builtin { + /// Hide the app: a custom item, because AppKit's own always takes ⌘H, which apps often bind. + Hide, + /// Hide the other apps: a custom item, because AppKit's own always takes ⌥⌘H. + HideOthers, + /// Minimize: a custom item, used when the app binds ⌘M (AppKit's own always takes ⌘M). + /// [`crate::NativeMenu::take_clicks`] runs it. + Minimize, + /// Quit for an app without its own quit item: asks the window to close, through + /// [`craft_quit::on_menu_event`], so the app's unsaved-changes check runs. + Quit, +} + +impl Builtin { + /// The command id. + pub const fn id(self) -> &'static str { + match self { + Builtin::Hide => "craft-menu.hide", + Builtin::HideOthers => "craft-menu.hide-others", + Builtin::Minimize => "craft-menu.minimize", + Builtin::Quit => craft_quit::QUIT_MENU_ID, + } + } + + pub fn command(self) -> CommandRef { + CommandRef::new(self.id()) + } + + /// The built-in command `command` is, if any. + pub fn of(command: &CommandRef) -> Option { + if !command.params.is_null() { + return None; + } + [Builtin::Hide, Builtin::HideOthers, Builtin::Minimize, Builtin::Quit].into_iter().find(|b| b.id() == command.id) + } +} + +impl Item { + /// An enabled plain item with no shortcut, shown on every platform. + pub fn new(command: impl Into, label: impl Into) -> Item { + Item { + command: command.into(), + label: label.into(), + shortcut: None, + enabled: true, + kind: ItemKind::Plain, + role: None, + platforms: Platforms::ALL, + layout_shortcut: false, + } + } + + pub fn shortcut(mut self, shortcut: Chord) -> Item { + self.shortcut = Some(shortcut); + self + } + + pub fn enabled(mut self, enabled: bool) -> Item { + self.enabled = enabled; + self + } + + /// Make this a check item. + pub fn check(mut self, checked: bool) -> Item { + self.kind = ItemKind::Check(checked); + self + } + + /// Make this a radio item, `selected` or not. + pub fn radio(mut self, selected: bool) -> Item { + self.kind = ItemKind::Radio(selected); + self + } + + pub fn role(mut self, role: ItemRole) -> Item { + self.role = Some(role); + self + } + + pub fn platforms(mut self, platforms: Platforms) -> Item { + self.platforms = platforms; + self + } +} + +impl From for Node { + fn from(item: Item) -> Node { + Node::Item(item) + } +} + +impl Node { + /// A submenu shown on every platform. + pub fn submenu(label: impl Into, children: Vec) -> Node { + Node::Submenu { label: label.into(), children, role: None, platforms: Platforms::ALL } + } + + /// Give an item or a submenu a role: `Node::submenu("Theme", pages).role(ItemRole::Appearance)`. + pub fn role(mut self, role: ItemRole) -> Node { + match &mut self { + Node::Item(it) => it.role = Some(role), + Node::Submenu { role: r, .. } => *r = Some(role), + Node::Separator | Node::Standard { .. } => {} + } + self + } + + /// Show an item or a submenu only on `platforms`. + pub fn platforms(mut self, platforms: Platforms) -> Node { + match &mut self { + Node::Item(it) => it.platforms = platforms, + Node::Submenu { platforms: p, .. } => *p = platforms, + Node::Separator | Node::Standard { .. } => {} + } + self + } +} + +impl Menu { + pub fn new(title: impl Into, children: Vec) -> Menu { + Menu { title: title.into(), role: MenuRole::Other, children } + } + + pub fn role(mut self, role: MenuRole) -> Menu { + self.role = role; + self + } +} + +impl MenuBar { + /// Every item, depth first. + pub fn items(&self) -> Vec<&Item> { + fn walk<'a>(nodes: &'a [Node], out: &mut Vec<&'a Item>) { + for n in nodes { + match n { + Node::Item(it) => out.push(it), + Node::Submenu { children, .. } => walk(children, out), + Node::Separator | Node::Standard { .. } => {} + } + } + } + let mut out = Vec::new(); + for m in &self.menus { + walk(&m.children, &mut out); + } + out + } + + /// Every item, depth first, changed in place. + pub(crate) fn for_each_item_mut(&mut self, mut f: impl FnMut(&mut Item)) { + fn walk(nodes: &mut [Node], f: &mut dyn FnMut(&mut Item)) { + for n in nodes { + match n { + Node::Item(it) => f(it), + Node::Submenu { children, .. } => walk(children, f), + Node::Separator | Node::Standard { .. } => {} + } + } + } + for m in &mut self.menus { + walk(&mut m.children, &mut f); + } + } + + /// Whether `command` opens one of [`MenuBar::documents`]. + pub(crate) fn is_document(&self, command: &CommandRef) -> bool { + self.documents.iter().any(|d| d.command == *command) + } + + pub fn find(&self, command: &CommandRef) -> Option<&Item> { + self.items().into_iter().find(|it| it.command == *command) + } + + pub fn menu(&self, role: MenuRole) -> Option<&Menu> { + self.menus.iter().find(|m| m.role == role) + } + + /// Give every item with this command the role, at any depth. + pub fn tag_item(&mut self, command: &CommandRef, role: ItemRole) { + fn walk(nodes: &mut [Node], command: &CommandRef, role: ItemRole) { + for n in nodes { + match n { + Node::Item(it) if it.command == *command => it.role = Some(role), + Node::Submenu { children, .. } => walk(children, command, role), + _ => {} + } + } + } + for m in &mut self.menus { + walk(&mut m.children, command, role); + } + } + + /// Give the submenu `name`, directly in the menu with the role `menu`, the role. + pub fn tag_submenu(&mut self, menu: MenuRole, name: &str, role: ItemRole) { + let Some(menu) = self.menus.iter_mut().find(|m| m.role == menu) else { return }; + for n in &mut menu.children { + if let Node::Submenu { label, role: r, .. } = n + && label == name + { + *r = Some(role); + } + } + } + + /// Give the menu titled `title` the role: a shortcut for apps that build the bar from rows. + pub fn tag_menu(&mut self, title: &str, role: MenuRole) { + if let Some(m) = self.menus.iter_mut().find(|m| m.title == title) { + m.role = role; + } + } +} + +/// One row of a flat menu list: the menu path (`["File", "Open Recent"]`) and the item in it. +/// A row labelled `---` is a separator. +#[derive(Clone, Debug, PartialEq)] +pub struct Row { + pub path: Vec, + pub command: CommandRef, + pub label: String, + pub shortcut: Option, + pub enabled: bool, + pub kind: ItemKind, +} + +/// The menus as a tree, from flat rows, in the order an in-window bar draws them: one menu per +/// entry of `tops`, and at each depth leaves and separators in order, with a submenu where its +/// first row is. The result is [`tidy`]. +pub fn from_items(tops: &[&str], rows: &[Row]) -> MenuBar { + fn level(rows: &[&Row], depth: usize) -> Vec { + let mut out = Vec::new(); + let mut shown: Vec<&str> = Vec::new(); + for it in rows { + if it.path.len() == depth { + if it.label == "---" { + out.push(Node::Separator); + } else { + out.push(Node::Item(Item { + shortcut: it.shortcut, + enabled: it.enabled, + kind: it.kind, + ..Item::new(it.command.clone(), it.label.clone()) + })); + } + } else if let Some(name) = it.path.get(depth) { + let name = name.as_str(); + if shown.contains(&name) { + continue; + } + shown.push(name); + let child: Vec<&Row> = rows.iter().copied().filter(|c| c.path.get(depth).map(String::as_str) == Some(name)).collect(); + out.push(Node::submenu(name, level(&child, depth + 1))); + } + } + tidy(out) + } + let menus = tops + .iter() + .map(|top| { + let rows: Vec<&Row> = rows.iter().filter(|r| r.path.first().map(String::as_str) == Some(*top)).collect(); + Menu::new(*top, level(&rows, 1)) + }) + .collect(); + MenuBar { menus, documents: Vec::new() } +} + +/// Drop separators at the start or end of a level and collapse runs of them, and drop empty +/// submenus, at every depth. A submenu holding only a disabled placeholder (`No Recent Files`) +/// stays. +pub fn tidy(nodes: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(nodes.len()); + for n in nodes { + let n = match n { + Node::Submenu { label, children, role, platforms } => { + let children = tidy(children); + if children.is_empty() { + continue; + } + Node::Submenu { label, children, role, platforms } + } + other => other, + }; + if matches!(n, Node::Separator) && matches!(out.last(), None | Some(Node::Separator)) { + continue; + } + out.push(n); + } + while matches!(out.last(), Some(Node::Separator)) { + out.pop(); + } + out +} + +/// A hash of everything a native menu can only change by rebuilding: menus, roles, item +/// commands, kinds (plain, check or radio), shortcuts, submenu labels and the labels of AppKit's items. Item +/// labels, enabled and checked are updated in place. Window's open documents (the items running a +/// [`MenuBar::documents`] command) are left out: opening or closing a document rebuilds only the +/// Window menu ([`window_key`]). +pub fn structure_key(bar: &MenuBar) -> u64 { + let mut h = DefaultHasher::new(); + for m in &bar.menus { + (&m.title, m.role).hash(&mut h); + if m.role == MenuRole::Window { + let document = |n: &Node| matches!(n, Node::Item(it) if bar.is_document(&it.command)); + let mut rest: Vec = m.children.iter().filter(|n| !document(n)).cloned().collect(); + while matches!(rest.last(), Some(Node::Separator)) { + rest.pop(); + } + hash_nodes(&rest, &mut h); + } else { + hash_nodes(&m.children, &mut h); + } + } + h.finish() +} + +/// The Window menu's structure, its open documents included: when it changes and nothing else +/// did, a native menu rebuilds just the Window menu. +pub fn window_key(bar: &MenuBar) -> u64 { + let mut h = DefaultHasher::new(); + if let Some(m) = bar.menu(MenuRole::Window) { + hash_nodes(&m.children, &mut h); + } + h.finish() +} + +fn hash_nodes(nodes: &[Node], h: &mut DefaultHasher) { + for n in nodes { + match n { + Node::Item(it) => ('i', it.command.key(), std::mem::discriminant(&it.kind), &it.shortcut, it.role, it.layout_shortcut).hash(h), + Node::Separator => '-'.hash(h), + Node::Submenu { label, children, role, .. } => { + ('[', label, role).hash(h); + hash_nodes(children, h); + ']'.hash(h); + } + Node::Standard { kind, label } => ('s', kind, label).hash(h), + } + } +} + +/// The native id of an item with `command`: its [`CommandRef::key`], or `key#2`, `key#3`, … +/// while `taken`, since native ids must be unique and a command can be shown twice (Keyboard +/// Shortcuts in Edit and Window). +#[cfg(any(target_os = "macos", test))] +pub(crate) fn native_id(command: &CommandRef, taken: impl Fn(&str) -> bool) -> String { + let key = command.key(); + let mut unique = key.clone(); + let mut n = 2; + while taken(&unique) { + unique = format!("{key}#{n}"); + n += 1; + } + unique +} diff --git a/crates/menu/src/tests.rs b/crates/menu/src/tests.rs new file mode 100644 index 00000000..d0854f73 --- /dev/null +++ b/crates/menu/src/tests.rs @@ -0,0 +1,604 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use egui::{Key, Modifiers}; + +use super::*; +use crate::driver::key_event; + +mod bar; +mod layout; +mod native; + +fn chord(s: &str) -> Chord { + Chord::parse(s).unwrap() +} + +/// The modifiers held for a key-down: ⌘, ⌃, ⌥, ⇧. +fn held([cmd, ctrl, alt, shift]: [bool; 4]) -> Option { + Some(craft_keymap::Mods { cmd, ctrl, alt, shift }) +} + +fn typing(text_editing: bool) -> Gate { + Gate { text_editing, ..Gate::default() } +} + +fn cmd(id: &str) -> CommandRef { + CommandRef::new(id) +} + +fn row(path: &[&str], id: &str, label: &str, shortcut: Option<&str>) -> Row { + Row { + path: path.iter().map(|s| s.to_string()).collect(), + command: id.into(), + label: label.into(), + shortcut: shortcut.map(chord), + enabled: true, + kind: ItemKind::Plain, + } +} + +fn sep(path: &[&str]) -> Row { + row(path, "", "---", None) +} + +/// A small app's menus, tagged. With `clashing`, app commands take ⌘H, ⌘M, ⌘W and ⌘,. +fn sample(clashing: bool, tag_quit: bool) -> MenuBar { + let k = |own: &'static str| clashing.then_some(own); + let rows = vec![ + row(&["File"], "file.new", "New", Some("Cmd+N")), + sep(&["File"]), + row(&["File"], "file.close", "Close", k("Cmd+W")), + sep(&["File"]), + row(&["File"], "file.exit", "Exit", None), + row(&["Edit"], "edit.undo", "Undo", Some("Cmd+Z")), + row(&["Edit"], "edit.redo", "Redo", Some("Cmd+Shift+Z")), + sep(&["Edit"]), + row(&["Edit", "Preferences"], "prefs.general", "General", None), + row(&["Edit", "Preferences"], "prefs.keys", "Keys", None), + row(&["Edit", "Language"], "lang.en", "English", None), + row(&["View"], "view.extras", "Extras", k("Cmd+H")), + row(&["View"], "view.hideLayers", "Hide Layers", k("Cmd+,")), + row(&["View"], "view.curves", "Curves", k("Cmd+M")), + row(&["View"], "view.zoomIn", "Zoom In", Some("Cmd++")), + row(&["Window", "Theme"], "theme.dark", "Dark", None), + row(&["Window"], "theme.next", "Next Theme", None), + row(&["Window"], "panel.layers", "Layers", Some("F7")), + row(&["Help"], "help.about", "About", None), + row(&["Help"], "help.docs", "Docs", None), + ]; + let mut bar = from_items(&["File", "Edit", "View", "Window", "Help"], &rows); + bar.tag_menu("File", MenuRole::File); + bar.tag_menu("Edit", MenuRole::Edit); + bar.tag_menu("View", MenuRole::View); + bar.tag_menu("Window", MenuRole::Window); + bar.tag_menu("Help", MenuRole::Help); + bar.tag_item(&cmd("help.about"), ItemRole::About); + if tag_quit { + bar.tag_item(&cmd("file.exit"), ItemRole::Quit); + } + bar.documents = vec![Document::new("doc.0", "Untitled-1").active(true)]; + bar.tag_item(&cmd("theme.next"), ItemRole::Appearance); + bar.tag_submenu(MenuRole::Edit, "Preferences", ItemRole::Settings); + bar.tag_submenu(MenuRole::Edit, "Language", ItemRole::Language); + bar.tag_submenu(MenuRole::Window, "Theme", ItemRole::Appearance); + bar +} + +fn ids(nodes: &[Node]) -> Vec { + nodes + .iter() + .map(|n| match n { + Node::Item(it) => it.command.key(), + Node::Separator => "---".into(), + Node::Submenu { label, .. } => format!("[{label}]"), + Node::Standard { kind, .. } => format!("<{kind:?}>"), + }) + .collect() +} + +fn titled<'a>(bar: &'a MenuBar, title: &str) -> &'a Menu { + bar.menus.iter().find(|m| m.title == title).unwrap() +} + +#[test] +fn the_app_menu_is_laid_out_the_mac_way() { + let l = mac_layout(&sample(false, true), &LayoutOptions::new("Sample")); + assert!(l.clashes.is_empty(), "{:?}", l.clashes); + let app = &l.bar.menus[0]; + assert_eq!((app.title.as_str(), app.role), ("Sample", MenuRole::App)); + #[rustfmt::skip] + let expected = [ + "help.about", "---", "[Settings]", "[Language]", "[Appearance]", "---", + "", "---", Builtin::Hide.id(), Builtin::HideOthers.id(), "", "---", "file.exit", + ]; + assert_eq!(ids(&app.children), expected); + let quit = l.bar.find(&cmd("file.exit")).unwrap(); + assert_eq!((quit.label.as_str(), quit.shortcut), ("Quit Sample", Some(chord("Cmd+Q"))), "the app's own Quit"); + assert_eq!(l.bar.find(&cmd("help.about")).unwrap().label, "About Sample"); + assert_eq!(l.bar.find(&Builtin::Hide.command()).unwrap().shortcut, Some(chord("Cmd+H"))); + assert_eq!(l.bar.find(&Builtin::HideOthers.command()).unwrap().shortcut, Some(chord("Cmd+Alt+H"))); + assert_eq!(l.bar.find(&cmd("prefs.general")).unwrap().shortcut, Some(chord("Cmd+,")), "⌘, opens the first settings page"); + let Some(Node::Submenu { children, .. }) = app.children.iter().find(|n| matches!(n, Node::Submenu { label, .. } if label == "Appearance")) else { + panic!("an Appearance submenu"); + }; + assert_eq!(ids(children), ["theme.dark", "---", "theme.next"]); + + // The moved items leave their menus, and emptied separators go with them. + let titles: Vec<&str> = l.bar.menus.iter().map(|m| m.title.as_str()).collect(); + assert_eq!(titles, ["Sample", "File", "Edit", "View", "Window", "Help"]); + assert_eq!(ids(&titled(&l.bar, "File").children), ["file.new", "---", "file.close", "---", ""]); + assert_eq!(ids(&titled(&l.bar, "Edit").children), ["edit.undo", "edit.redo"]); + assert_eq!(ids(&titled(&l.bar, "Help").children), ["help.docs"]); + assert_eq!( + ids(&l.bar.menu(MenuRole::Window).unwrap().children), + ["", "", "---", "panel.layers", "---", "", "---", "doc.0"], + "the open documents follow Bring All to Front" + ); +} + +/// Without a tagged Quit the layout adds craft-quit's; the app's translations name what the +/// layout writes, AppKit's items included. +#[test] +fn an_untagged_quit_is_craft_quits_and_labels_are_translated() { + let mut bar = sample(false, false); + bar.menus.retain(|m| m.role != MenuRole::Window); + let german = |s: &'static str| match s { + "Quit {app}" => "{app} beenden".to_string(), + "Window" => "Fenster".into(), + "Services" => "Dienste".into(), + other => other.into(), + }; + let l = mac_layout(&bar, &LayoutOptions { label: &german, ..LayoutOptions::new("Sample") }); + let quit = l.bar.find(&Builtin::Quit.command()).unwrap(); + assert_eq!((quit.label.as_str(), quit.shortcut, quit.role), ("Sample beenden", Some(chord("Cmd+Q")), Some(ItemRole::Quit))); + assert!(ids(&titled(&l.bar, "File").children).contains(&"file.exit".to_string()), "File › Exit stays"); + let app = l.bar.menu(MenuRole::App).unwrap(); + assert!(app.children.contains(&Node::Standard { kind: Standard::Services, label: "Dienste".into() })); + let titles: Vec<&str> = l.bar.menus.iter().map(|m| m.title.as_str()).collect(); + assert_eq!(titles, ["Sample", "File", "Edit", "View", "Fenster", "Help"], "a Window menu before Help"); + assert_eq!(ids(&l.bar.menu(MenuRole::Window).unwrap().children), ["", "", "---", "", "---", "doc.0"]); +} + +/// App commands keep ⌘H, ⌘M, ⌘W and ⌘,: the system items give way, and each clash is reported. +#[test] +fn clashes_are_resolved_in_the_apps_favour() { + let l = mac_layout(&sample(true, true), &LayoutOptions::new("Sample")); + let clash = |sc: &str| l.clashes.iter().find(|c| c.shortcut == chord(sc)).map(|c| c.command.id.as_str()); + assert_eq!(clash("Cmd+H"), Some("view.extras")); + assert_eq!(clash("Cmd+M"), Some("view.curves")); + assert_eq!(clash("Cmd+W"), Some("file.close")); + assert_eq!(clash("Cmd+,"), Some("view.hideLayers")); + assert_eq!(l.clashes.len(), 4); + assert_eq!(l.bar.find(&Builtin::Hide.command()).unwrap().shortcut, Some(chord("Ctrl+Cmd+H"))); + assert_eq!(l.bar.find(&Builtin::Minimize.command()).unwrap().shortcut, Some(chord("Ctrl+Cmd+M"))); + assert_eq!(l.bar.find(&cmd("prefs.general")).unwrap().shortcut, None); + assert_eq!(ids(&l.bar.menu(MenuRole::Window).unwrap().children)[..2], [Builtin::Minimize.id(), ""]); + assert!(!ids(&titled(&l.bar, "File").children).contains(&"".to_string()), "⌘W stays File › Close"); + + // With ⌃⌘H taken too, Hide goes without a shortcut. + let mut bar = sample(true, true); + bar.menus[0].children.push(Item::new("file.other", "Other").shortcut(chord("Ctrl+Cmd+H")).into()); + let l = mac_layout(&bar, &LayoutOptions::new("Sample")); + assert_eq!(l.bar.find(&Builtin::Hide.command()).unwrap().shortcut, None); + assert_eq!(l.clashes.iter().find(|c| c.shortcut == chord("Cmd+H")).map(|c| c.resolution), Some("Hide has no shortcut")); +} + +#[test] +fn rows_become_a_tidy_tree() { + let rows = [ + sep(&["A"]), + row(&["A"], "a", "a", None), + sep(&["A"]), + sep(&["A"]), + sep(&["A", "Sub"]), + row(&["A", "Sub"], "b", "b", None), + sep(&["A", "Sub"]), + ]; + let bar = from_items(&["A", "Empty"], &rows); + assert_eq!(bar.menus.len(), 2); + assert_eq!(bar.menus[0].children, [Item::new("a", "a").into(), Node::Separator, Node::submenu("Sub", vec![Item::new("b", "b").into()])]); + assert!(bar.menus[1].children.is_empty()); + let nested = vec![Node::Separator, Node::submenu("S", vec![Node::Separator, Node::Separator]), Node::Separator, Node::Separator]; + assert_eq!(tidy(nested), [], "a submenu left empty goes"); +} + +/// Labels, enabled and checked are updated in place; a shortcut or a check item that used to be +/// plain needs a rebuild. A document opening or closing rebuilds only the Window menu. +#[test] +fn keys_separate_rebuilds_from_updates_in_place() { + let opts = LayoutOptions::new("Sample"); + let base = mac_layout(&sample(false, true), &opts).bar; + let key = |bar: &MenuBar| (structure_key(bar), window_key(bar)); + let edit = |f: &dyn Fn(&mut Item)| { + let mut bar = sample(false, true); + for m in &mut bar.menus { + for n in &mut m.children { + if let Node::Item(it) = n + && it.command.id == "edit.undo" + { + f(it); + } + } + } + mac_layout(&bar, &opts).bar + }; + assert_eq!(key(&edit(&|it| it.label = "Undo Typing".into())), key(&base)); + assert_eq!(key(&edit(&|it| it.enabled = false)), key(&base)); + assert_ne!(structure_key(&edit(&|it| it.shortcut = Some(chord("Ctrl+Z")))), structure_key(&base)); + assert_ne!(structure_key(&edit(&|it| it.kind = ItemKind::Check(false))), structure_key(&base)); + + let mut two = sample(false, true); + two.documents.push(Document::new("doc.1", "Untitled-2")); + let two = mac_layout(&two, &opts).bar; + assert_eq!(structure_key(&two), structure_key(&base)); + assert_ne!(window_key(&two), window_key(&base)); + assert_eq!(ids(&two.menu(MenuRole::Window).unwrap().children).last().map(String::as_str), Some("doc.1")); +} + +#[test] +fn chords_become_egui_key_presses() { + use crate::driver::native_ok; + assert!(native_ok(&chord("Cmd+B"))); + assert!(native_ok(&chord("Ctrl+B"))); + assert!(native_ok(&chord("Ctrl+F5"))); + assert!(!native_ok(&chord("F5")), "a bare function key is display-only"); + assert!(!native_ok(&chord("Alt+F12"))); + assert!(!native_ok(&chord("B"))); + assert!(!native_ok(&chord("Alt+F"))); + assert!(!native_ok(&chord("Shift+X"))); + + let egui::Event::Key { key, modifiers, pressed: true, .. } = key_event(&chord("Shift+Cmd+Z"), true) else { panic!("a key press") }; + assert_eq!(key, Key::Z); + assert!(modifiers.command && modifiers.mac_cmd && modifiers.shift && !modifiers.ctrl && !modifiers.alt); + let ctrl = key_event(&chord("Ctrl+Cmd+M"), true); + assert!(matches!(ctrl, egui::Event::Key { modifiers, .. } if modifiers.ctrl && modifiers.command)); + + // Every shortcut the layout writes is a key equivalent; the app's F7 is display-only. + let bar = mac_layout(&sample(true, true), &LayoutOptions::new("Sample")).bar; + for it in bar.items().into_iter().filter(|it| it.layout_shortcut) { + let Some(sc) = &it.shortcut else { continue }; + assert!(native_ok(sc), "{sc} on {}", it.command); + } + assert!(!native_ok(&bar.find(&cmd("panel.layers")).unwrap().shortcut.unwrap())); +} + +/// A command's native id is its key; a command shown twice gets `key#2` for its second item, also +/// when it has parameters. +#[test] +fn a_command_shown_twice_gets_a_second_native_id() { + use crate::model::native_id; + let keys = cmd("edit.keys"); + let recent = CommandRef::parse_key(r#"file.open_recent {"index":3}"#).unwrap(); + let mut taken: Vec = Vec::new(); + for c in [&keys, &recent, &keys, &recent, &keys] { + let id = native_id(c, |id| taken.iter().any(|t| t == id)); + taken.push(id); + } + assert_eq!(taken, ["edit.keys", r#"file.open_recent {"index":3}"#, "edit.keys#2", r#"file.open_recent {"index":3}#2"#, "edit.keys#3"]); +} + +#[derive(Default)] +struct Fake { + synced: Rc>>, + events: Rc>>, + tracking: Rc>, + cancels: Rc>, + /// Keeps reporting a menu open after `cancel_tracking`. + stuck: bool, + /// What the backend's menu event handler would call. + dispatch: Rc>>, +} + +impl Backend for Fake { + fn sync(&mut self, bar: &MenuBar) { + self.synced.borrow_mut().push(bar.clone()); + } + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events.borrow_mut()) + } + fn tracking(&self) -> bool { + self.tracking.get() + } + fn cancel_tracking(&mut self) { + self.cancels.set(self.cancels.get() + 1); + if !self.stuck { + self.tracking.set(false); + } + } + fn attach(&mut self, dispatch: Dispatch) { + *self.dispatch.borrow_mut() = Some(dispatch); + } +} + +/// AppKit takes a key equivalent's key-down but winit still delivers the key-up: the menu adds +/// only the press, before a key-up already queued, so the shortcut runs once. (⌘V as a key, as +/// an app taking the raw clipboard keys gets it.) +#[test] +fn key_equivalents_become_one_key_press() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut menu = NativeMenu::new(Box::new(Fake { events: events.clone(), ..Default::default() })).clipboard_keys(ClipboardKeys::Raw); + let paste = Chord::parse("Cmd+V").unwrap(); + let release = egui::Event::Key { key: Key::V, physical_key: Some(Key::V), pressed: false, repeat: false, modifiers: Modifiers::COMMAND }; + let press = key_event(&paste, true); + + events.borrow_mut().push(Event::Key(paste)); + let mut raw = egui::RawInput { events: vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), release.clone()], ..Default::default() }; + menu.raw_input(&mut raw); + assert_eq!(raw.events, [egui::Event::ModifiersChanged(Modifiers::COMMAND), press.clone(), release]); + + events.borrow_mut().push(Event::Key(paste)); + let mut raw = egui::RawInput::default(); + menu.raw_input(&mut raw); + assert_eq!(raw.events, [press], "no made-up release: the real one comes in a later frame"); +} + +/// Minimize and craft-quit's Quit run in the crate; other clicks go to the app. The menus are +/// laid out again only when the state changed or an event arrived. +#[test] +fn clicks_and_syncs() { + let synced = Rc::new(RefCell::new(Vec::new())); + let events = Rc::new(RefCell::new(vec![ + Event::Click(Builtin::Minimize.command()), + Event::Click("edit.undo".into()), + Event::Click(craft_quit::QUIT_MENU_ID.into()), + ])); + let mut menu = NativeMenu::new(Box::new(Fake { synced: synced.clone(), events: events.clone(), ..Default::default() })); + let mut raw = egui::RawInput::default(); + menu.raw_input(&mut raw); + assert!(raw.events.is_empty()); + + let ctx = egui::Context::default(); + let mut clicks = Vec::new(); + let mut out = ctx.run_ui(raw, |ui| clicks = menu.take_clicks(ui.ctx())); + out.textures_delta.clear(); // not painted + assert_eq!(clicks, [cmd("edit.undo")]); + let commands = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|o| o.commands).unwrap_or_default(); + assert!(commands.contains(&egui::ViewportCommand::Minimized(true))); + assert!(commands.contains(&egui::ViewportCommand::Close), "Quit asks the window to close"); + + let opts = LayoutOptions::new("Sample"); + let builds = Cell::new(0); + let build = || { + builds.set(builds.get() + 1); + mac_layout(&sample(true, true), &opts) + }; + assert_eq!(menu.sync_with(1, build).len(), 4, "the clashes, once"); + assert!(menu.sync_with(1, build).is_empty()); + assert_eq!(builds.get(), 1, "same state: nothing built"); + assert!(menu.sync_with(2, build).is_empty(), "same clashes: not reported again"); + events.borrow_mut().push(Event::Click("edit.redo".into())); + menu.raw_input(&mut egui::RawInput::default()); + menu.sync_with(2, build); + assert_eq!((builds.get(), synced.borrow().len()), (3, 3), "an event re-syncs"); +} + +fn press() -> egui::Event { + egui::Event::PointerButton { pos: egui::pos2(300.0, 200.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() } +} + +#[test] +fn outside_press_dismisses_without_starting_a_canvas_gesture() { + use crate::driver::dismiss_input; + let moved = egui::Event::PointerMoved(egui::pos2(300.0, 200.0)); + let mut raw = egui::RawInput { events: vec![moved.clone(), press()], ..Default::default() }; + assert_eq!(dismiss_input(&mut raw, || true), Some(true)); + assert_eq!(raw.events, [moved]); + assert_eq!(dismiss_input(&mut raw, || true), None); + raw.events.push(press()); + assert_eq!(dismiss_input(&mut raw, || false), Some(false)); + assert_eq!(raw.events.len(), 2, "ordinary canvas presses still reach egui"); +} + +#[test] +fn escape_dismisses_without_reaching_the_active_tool() { + let escape = |pressed| egui::Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let mut raw = egui::RawInput { events: vec![escape(true), escape(false)], ..Default::default() }; + assert_eq!(crate::driver::dismiss_input(&mut raw, || true), Some(true)); + assert!(raw.events.is_empty()); +} + +#[test] +fn moving_the_pointer_does_not_dismiss_a_tracking_menu() { + let mut raw = egui::RawInput { events: vec![egui::Event::PointerMoved(egui::pos2(300.0, 200.0))], ..Default::default() }; + assert_eq!(crate::driver::dismiss_input(&mut raw, || true), None); + assert_eq!(raw.events.len(), 1); +} + +/// `raw_input` closes an open menu on a click and keeps the click from egui; with no menu open +/// the click reaches egui (the menu is still asked to close, in case one is on screen). +#[test] +fn a_click_closes_an_open_menu() { + let tracking = Rc::new(Cell::new(true)); + let cancels = Rc::new(Cell::new(0)); + let mut menu = NativeMenu::new(Box::new(Fake { tracking: tracking.clone(), cancels: cancels.clone(), ..Default::default() })); + let mut raw = egui::RawInput { events: vec![press()], ..Default::default() }; + menu.raw_input(&mut raw); + assert!(raw.events.is_empty()); + assert_eq!((cancels.get(), tracking.get()), (1, false)); + + let mut raw = egui::RawInput { events: vec![press()], ..Default::default() }; + menu.raw_input(&mut raw); + assert_eq!(raw.events, [press()]); + assert_eq!(cancels.get(), 2); + + menu.raw_input(&mut egui::RawInput { events: vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))], ..Default::default() }); + assert_eq!(cancels.get(), 2, "a pointer move closes nothing"); +} + +/// A backend that keeps reporting a menu open costs one window click, not all of them; once it +/// reports the menu closed, the next open menu's closing click is kept from egui again. +#[test] +fn a_stuck_open_menu_keeps_only_one_click() { + let tracking = Rc::new(Cell::new(true)); + let mut menu = NativeMenu::new(Box::new(Fake { tracking: tracking.clone(), stuck: true, ..Default::default() })); + let mut raw = egui::RawInput { events: vec![press()], ..Default::default() }; + menu.raw_input(&mut raw); + assert!(raw.events.is_empty(), "the click that closes the menu"); + for _ in 0..3 { + let mut raw = egui::RawInput { events: vec![press()], ..Default::default() }; + menu.raw_input(&mut raw); + assert_eq!(raw.events, [press()], "later clicks reach egui"); + } + tracking.set(false); + menu.raw_input(&mut egui::RawInput::default()); + tracking.set(true); + let mut raw = egui::RawInput { events: vec![press()], ..Default::default() }; + menu.raw_input(&mut raw); + assert!(raw.events.is_empty()); +} + +fn key(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } +} + +/// AppKit takes ⌘C, ⌘X and ⌘V for the Edit menu, so egui-winit never makes its Copy, Cut and +/// Paste events: the driver makes them, unless the app takes the raw keys. +#[test] +fn clipboard_key_equivalents_reach_text_fields() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut menu = NativeMenu::new(Box::new(Fake { events: events.clone(), ..Default::default() })); + let equivalent = |s: &str| Event::Key(chord(s)); + events.borrow_mut().extend([equivalent("Cmd+C"), equivalent("Cmd+X"), equivalent("Cmd+Shift+V")]); + let release = key(Key::C, false, Modifiers::COMMAND); + let mut raw = egui::RawInput { events: vec![release.clone()], ..Default::default() }; + menu.raw_input(&mut raw); + let shift_v = key_event(&chord("Cmd+Shift+V"), true); + assert_eq!(raw.events, [egui::Event::Copy, release, egui::Event::Cut, shift_v], "⌘⇧V is an app shortcut"); + + events.borrow_mut().push(equivalent("Cmd+V")); + let mut raw = egui::RawInput::default(); + menu.raw_input(&mut raw); + assert!(raw.events.is_empty(), "the paste comes from eframe, with the clipboard's text"); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(raw, |ui| { + menu.take_clicks(ui.ctx()); + }); + out.textures_delta.clear(); + let commands = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|o| o.commands).unwrap_or_default(); + assert!(commands.contains(&egui::ViewportCommand::RequestPaste)); + + let mut menu = NativeMenu::new(Box::new(Fake { events: events.clone(), ..Default::default() })).clipboard_keys(ClipboardKeys::Raw); + events.borrow_mut().extend([equivalent("Cmd+C"), equivalent("Cmd+V")]); + let mut raw = egui::RawInput::default(); + menu.raw_input(&mut raw); + let press = |s: &str| key_event(&chord(s), true); + assert_eq!(raw.events, [press("Cmd+C"), press("Cmd+V")]); +} + +/// The layout marks the shortcuts it gives (the app's key handling doesn't bind them), and a key +/// equivalent on such an item comes back as a click. +#[test] +fn shortcuts_the_layout_assigns_come_back_as_clicks() { + use crate::driver::chosen_event; + let l = mac_layout(&sample(false, true), &LayoutOptions::new("Sample")); + let assigned = |id: &str| l.bar.find(&cmd(id)).unwrap().layout_shortcut; + assert!(assigned("file.exit") && assigned("prefs.general") && assigned(Builtin::Hide.id())); + assert!(!assigned("edit.undo") && !assigned("prefs.keys")); + let mut bar = sample(false, true); + bar.menus[0].children.push(Item::new("file.quit", "Quit").shortcut(chord("Cmd+Q")).role(ItemRole::Quit).into()); + bar.tag_item(&cmd("file.exit"), ItemRole::About); + assert!(!mac_layout(&bar, &LayoutOptions::new("Sample")).bar.find(&cmd("file.quit")).unwrap().layout_shortcut, "the app's own ⌘Q"); + + let cmd_q = Chord::parse("Cmd+Q").unwrap(); + let cmd_held = held([true, false, false, false]); + let open = Gate::default(); + assert_eq!(chosen_event(&cmd("file.exit"), Some(&cmd_q), true, cmd_held, open), Event::Click("file.exit".into())); + assert_eq!(chosen_event(&cmd("file.quit"), Some(&cmd_q), false, cmd_held, open), Event::Key(cmd_q)); + // ⇧ held on a ⌘R item: the key press that was made. + let shift_r = Chord::parse("Cmd+Shift+R").unwrap(); + assert_eq!( + chosen_event(&cmd("view.reload"), Some(&Chord::parse("Cmd+R").unwrap()), false, held([true, false, false, true]), open), + Event::Key(shift_r) + ); + assert_eq!(chosen_event(&cmd("file.quit"), Some(&cmd_q), false, None, open), Event::Click("file.quit".into()), "a mouse click"); + assert_eq!( + chosen_event(&Builtin::Minimize.command(), Some(&Chord::parse("Ctrl+Cmd+M").unwrap()), false, held([true, true, false, false]), open), + Event::Click(Builtin::Minimize.command()) + ); + // ↩ inside the open menu: on a ⌘ item without ⌘ held, and on a bare tool key. + let enter = held([false; 4]); + assert_eq!(chosen_event(&cmd("file.quit"), Some(&cmd_q), false, enter, open), Event::Click("file.quit".into())); + assert_eq!(chosen_event(&cmd("tool.brush"), Some(&Chord::parse("B").unwrap()), false, enter, open), Event::Click("tool.brush".into())); +} + +/// While a text field has the keys, a key equivalent runs its command as a click (⌘S saves while +/// typing), except the editing keys the field handles itself. +#[test] +fn key_equivalents_run_commands_while_text_editing() { + use crate::driver::chosen_event; + let dispatch = Rc::new(RefCell::new(None)); + let mut menu = NativeMenu::new(Box::new(Fake { dispatch: dispatch.clone(), ..Default::default() })); + menu.set_gate(typing(true)); + let dispatch = dispatch.borrow().clone().unwrap(); + assert_eq!( + dispatch.chosen(&cmd("file.save"), Some(&chord("Cmd+S")), false, held([true, false, false, false])), + Event::Click(cmd("file.save")), + "the backend's handler sees the gate" + ); + + let chosen = |shortcut: &str, mods: [bool; 4], text_editing: bool| { + chosen_event(&cmd("cmd"), Some(&Chord::parse(shortcut).unwrap()), false, held(mods), typing(text_editing)) + }; + let cmd = [true, false, false, false]; + let shift_cmd = [true, false, false, true]; + for (shortcut, held) in [ + ("Cmd+S", cmd), + ("Cmd+N", cmd), + ("Cmd+Shift+S", shift_cmd), + ("Ctrl+Cmd+F", [true, true, false, false]), + ("Ctrl+F5", [false, true, false, false]), + ] { + assert_eq!(chosen(shortcut, held, true), Event::Click("cmd".into()), "{shortcut} while typing"); + assert!(matches!(chosen(shortcut, held, false), Event::Key(_)), "{shortcut} with no text field"); + } + for (shortcut, held) in [("Cmd+C", cmd), ("Cmd+X", cmd), ("Cmd+V", cmd), ("Cmd+A", cmd), ("Cmd+Z", cmd), ("Cmd+Shift+Z", shift_cmd)] { + assert_eq!(chosen(shortcut, held, true), Event::Key(Chord::parse(shortcut).unwrap()), "{shortcut} stays with the field"); + } + assert_eq!(chosen("Cmd+A", shift_cmd, true), Event::Click("cmd".into()), "⇧⌘A is not select all"); +} + +/// Hide Others is the crate's own item: an app command on ⌥⌘H, in a menu or bound elsewhere, +/// keeps the key, and shortcuts bound outside the menus clash like menu ones. +#[test] +fn hide_others_and_extra_shortcuts_give_way_to_the_app() { + let extra = [(chord("Cmd+Alt+H"), cmd("canvas.hideGuides")), (chord("Shift+Cmd+Q"), cmd("canvas.other"))]; + let l = mac_layout(&sample(false, true), &LayoutOptions::new("Sample").extra_shortcuts(&extra)); + assert_eq!(l.bar.find(&Builtin::HideOthers.command()).unwrap().shortcut, None); + assert_eq!( + l.clashes, + [Clash { shortcut: chord("Cmd+Alt+H"), system: "Hide Others", command: cmd("canvas.hideGuides"), resolution: "Hide Others has no shortcut" }] + ); + let extra = [(chord("Cmd+H"), cmd("canvas.hand"))]; + let l = mac_layout(&sample(false, true), &LayoutOptions::new("Sample").extra_shortcuts(&extra)); + assert_eq!(l.bar.find(&Builtin::Hide.command()).unwrap().shortcut, Some(chord("Ctrl+Cmd+H"))); +} + +/// An app menu in the app's own tree merges into the layout's: no second app menu. +#[test] +fn an_apps_own_app_menu_is_merged() { + let mut bar = sample(false, true); + let own = vec![ + Item::new("app.discord", "Join Our Discord").into(), + Node::submenu("Theme", vec![Item::new("theme.light", "Light").into()]).role(ItemRole::Appearance), + ]; + bar.menus.insert(0, Menu::new("Sample", own).role(MenuRole::App)); + bar.menus.iter_mut().find(|m| m.title == "Window").unwrap().children.retain(|n| !matches!(n, Node::Submenu { .. })); + let l = mac_layout(&bar, &LayoutOptions::new("Sample")); + assert_eq!(l.bar.menus.iter().filter(|m| m.role == MenuRole::App).count(), 1); + let app = l.bar.menu(MenuRole::App).unwrap(); + assert_eq!(ids(&app.children)[..7], ["help.about", "---", "[Settings]", "[Language]", "[Appearance]", "---", "app.discord"]); +} + +#[test] +fn a_modal_dialog_or_shortcut_recording_lets_only_the_built_in_items_run() { + let save = Item::new(CommandRef::new("file.save"), "Save"); + let quit = Item::new(Builtin::Quit.command(), "Quit"); + for gate in [Gate { modal: true, ..Gate::default() }, Gate { recording_shortcut: true, ..Gate::default() }] { + assert!(!gate.allows(&save) && gate.allows(&quit), "{gate:?}"); + } + assert!(Gate { text_editing: true, ..Gate::default() }.allows(&save)); + assert!(Gate::default().allows(&save)); +} diff --git a/crates/menu/src/tests/bar.rs b/crates/menu/src/tests/bar.rs new file mode 100644 index 00000000..5227839b --- /dev/null +++ b/crates/menu/src/tests/bar.rs @@ -0,0 +1,700 @@ +//! The egui bar: clicks, hover, the keys, overflow, scrolling, submenu hover intent and AccessKit. + +use egui::accesskit::{self, NodeId, Role, Toggled}; +use egui::{Event, Id, Key, Modifiers, Pos2, Rect, Vec2, pos2, vec2}; + +use super::*; +use crate::bar::Labeled; +use crate::bar::nav::{ARROW, Nav, heading, is_open}; + +#[path = "bar_keys.rs"] +mod keys; + +fn item(id: &str, label: &str) -> Item { + Item::new(id, label) +} + +/// A small app's menus, as a desktop layout leaves them. +fn sample() -> MenuBar { + MenuBar { + menus: vec![ + Menu::new( + "File", + vec![ + item("file.new", "New").shortcut(Chord::parse("Cmd+N").unwrap()).into(), + item("file.open", "Open…").into(), + Node::Separator, + Node::submenu("Open Recent", vec![item("file.recent.a", "a.txt").into(), item("file.recent.b", "b.txt").into()]), + Node::submenu("Nothing Here", vec![item("file.gone", "Gone").enabled(false).into()]), + Node::Separator, + item("file.locked", "Locked").enabled(false).into(), + Item::new(Builtin::Quit.command(), "Quit").into(), + ], + ) + .role(MenuRole::File), + Menu::new("Edit", vec![item("edit.undo", "Undo").into(), item("edit.redo", "Redo").into()]).role(MenuRole::Edit), + Menu::new( + "View", + vec![ + item("view.grid", "Grid").check(true).shortcut(Chord::parse("G").unwrap()).into(), + item("view.rulers", "Rulers").check(false).into(), + Node::Separator, + item("view.light", "Light").radio(true).into(), + item("view.dark", "Dark").radio(false).into(), + ], + ) + .role(MenuRole::View), + Menu::new("Help", vec![item("help.about", "About").into()]).role(MenuRole::Help), + ], + documents: Vec::new(), + } +} + +/// The bar alone in a window, driven frame by frame with synthetic input. +struct Bar { + ctx: egui::Context, + bar: MenuBar, + gate: Gate, + target: Target, + english: Option) -> Option>, + preferred: Vec<(&'static str, char)>, + size: Vec2, + dt: f64, + time: f64, + events: Vec, + pointer: Pos2, + tree: Vec<(NodeId, accesskit::Node)>, + clicked: Vec, + /// `shrink_to_fit`'s limit; the bar then shares a row with a label, which starts at `after`. + fit: Option, + after: Option, + /// The viewport commands the bar sent. + sent: Vec, +} + +impl Bar { + fn new(bar: MenuBar, size: Vec2) -> Bar { + let ctx = egui::Context::default(); + // Not the host's: the web target's key names follow it. + ctx.set_os(egui::os::OperatingSystem::Nix); + ctx.enable_accesskit(); + ctx.all_styles_mut(|s| s.scroll_animation = egui::style::ScrollAnimation::none()); + let mut b = Bar { + ctx, + bar, + gate: Gate::default(), + target: Target::Linux, + english: None, + preferred: Vec::new(), + size, + dt: 1.0 / 60.0, + time: 0.0, + events: Vec::new(), + pointer: pos2(-10.0, -10.0), + tree: Vec::new(), + clicked: Vec::new(), + fit: None, + after: None, + sent: Vec::new(), + }; + b.run(3); + b + } + + fn frame(&mut self) { + self.time += self.dt; + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.size)), + time: Some(self.time), + predicted_dt: self.dt as f32, + events: std::mem::take(&mut self.events), + ..Default::default() + }; + let (bar, gate, target, english, preferred, fit) = (&self.bar, self.gate, self.target, self.english, &self.preferred, self.fit); + let mut out = Vec::new(); + let mut after = None; + let mut full = self.ctx.run_ui(raw, |ui| { + let mut ui_bar = MenuBarUi::new(bar).gate(gate).target(target).mnemonics(preferred); + if let Some(english) = &english { + ui_bar = ui_bar.english(english); + } + match fit { + Some(max) => { + ui.horizontal(|ui| { + out = ui_bar.shrink_to_fit(max).show(ui); + after = Some(ui.label("after").rect.left()); + }); + } + None => out = ui_bar.show(ui), + } + }); + full.textures_delta.clear(); + self.after = after; + if let Some(root) = full.viewport_output.remove(&egui::ViewportId::ROOT) { + self.sent.extend(root.commands); + } + if let Some(update) = full.platform_output.accesskit_update { + self.tree = update.nodes; + } + self.clicked.extend(out); + } + + fn run(&mut self, frames: usize) { + for _ in 0..frames { + self.frame(); + } + } + + /// The commands chosen since the last call. + fn take(&mut self) -> Vec { + std::mem::take(&mut self.clicked) + } + + fn nodes(&self, label: &str) -> impl Iterator { + self.tree.iter().map(|(_, n)| n).filter(move |n| n.label() == Some(label)) + } + + fn node(&self, label: &str) -> Option<&accesskit::Node> { + self.nodes(label).next() + } + + fn shown(&self, label: &str) -> bool { + self.node(label).is_some() + } + + /// A title of the bar (not a submenu row of the same name). + fn title(&self, label: &str) -> Option<&accesskit::Node> { + let (_, bar) = self.tree.iter().find(|(_, n)| n.role() == Role::MenuBar)?; + self.tree.iter().filter(|(id, _)| bar.children().contains(id)).map(|(_, n)| n).find(|n| n.label() == Some(label)) + } + + fn rect(&self, label: &str) -> Rect { + let n = self.node(label).unwrap_or_else(|| panic!("no {label:?}")); + let b = n.bounds().unwrap_or_else(|| panic!("{label:?} has no bounds")); + Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)) + } + + fn move_to(&mut self, p: Pos2) { + self.pointer = p; + self.events.push(Event::PointerMoved(p)); + self.frame(); + } + + /// A point on `label`, near its left end: a menu row can be wider than a narrow window. + fn point(&self, label: &str) -> Pos2 { + let r = self.rect(label); + pos2(r.left() + (r.width() / 2.0).min(20.0), r.center().y) + } + + fn hover(&mut self, label: &str) { + let p = self.point(label); + self.move_to(p); + self.run(3); + } + + fn button(&mut self, pressed: bool) { + self.events.push(Event::PointerButton { pos: self.pointer, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + self.frame(); + } + + fn click_at(&mut self, p: Pos2) { + self.move_to(p); + self.button(true); + self.button(false); + self.run(3); + } + + fn click(&mut self, label: &str) { + let p = self.point(label); + self.click_at(p); + } + + fn key(&mut self, key: Key) { + for pressed in [true, false] { + self.events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + self.run(3); + } + + fn nav(&self) -> Nav { + Nav::current(&self.ctx, Id::unique("craft-menu-bar").with("nav")) + } + + /// Is row `i` of `level` entirely inside its level's visible (scrolled) part, in the window? + fn visible(&self, level: usize, i: usize) -> bool { + let nav = self.nav(); + let (Some(row), Some(view)) = (nav.rows.get(level).and_then(|r| r.get(i)), nav.views.get(level)) else { return false }; + view.expand(0.5).contains_rect(row.rect) && self.ctx.content_rect().expand(0.5).contains_rect(*view) + } +} + +fn wide() -> Bar { + Bar::new(sample(), vec2(800.0, 600.0)) +} + +#[test] +fn clicking_an_item_returns_its_command_and_closes_the_menu() { + let mut b = wide(); + assert!(!b.shown("New")); + b.click("File"); + assert!(b.shown("New") && is_open(&b.ctx), "a click on a title opens its menu"); + b.click("New"); + assert_eq!(b.take(), vec![CommandRef::new("file.new")]); + assert!(!b.shown("New") && !is_open(&b.ctx), "choosing an item closes the menu"); +} + +#[test] +fn a_second_click_escape_or_a_click_outside_closes_the_menu() { + let mut b = wide(); + b.click("File"); + b.click("File"); + assert!(!b.shown("New"), "a second click on the title"); + b.click("File"); + b.key(Key::Escape); + assert!(!b.shown("New"), "Esc"); + b.click("File"); + b.click_at(pos2(700.0, 500.0)); + assert!(!b.shown("New"), "a click outside"); + assert!(b.take().is_empty()); +} + +#[test] +fn press_drag_release_runs_an_item() { + let mut b = wide(); + let file = b.rect("File").center(); + b.move_to(file); + b.button(true); + b.run(3); + assert!(b.shown("Open…"), "the press opens the menu"); + // Down out of the bar first, then across: crossing the Edit title would open Edit. + let open = b.rect("Open…").center(); + let below = pos2(file.x, open.y); + for k in 1..=6 { + b.move_to(file + (below - file) * (k as f32 / 6.0)); + } + b.move_to(open); + b.button(false); + b.run(2); + assert_eq!(b.take(), vec![CommandRef::new("file.open")]); +} + +#[test] +fn disabled_and_gate_forbidden_items_return_nothing() { + let mut b = wide(); + b.click("File"); + b.click("Locked"); + assert!(b.take().is_empty(), "a disabled item"); + assert!(b.node("Locked").is_some_and(|n| n.is_disabled())); + assert!(b.node("Nothing Here").is_some_and(|n| n.is_disabled()), "a submenu of disabled items is disabled"); + + b.key(Key::Escape); + b.gate = Gate { modal: true, ..Gate::default() }; + b.click("File"); + assert!(b.node("New").is_some_and(|n| n.is_disabled()), "a modal dialog disables the app's items"); + b.click("New"); + assert!(b.take().is_empty(), "and they don't run"); + b.click("Quit"); + assert!(b.take().is_empty(), "the bar runs the built-in Quit itself"); + assert!(b.sent.contains(&egui::ViewportCommand::Close), "and it still runs: the window is asked to close"); + + b.gate = Gate { text_editing: true, ..Gate::default() }; + b.click("File"); + b.click("New"); + assert_eq!(b.take(), vec![CommandRef::new("file.new")], "a text field having the keys doesn't stop a click"); +} + +#[test] +fn hovering_another_title_switches_menus_only_while_one_is_open() { + let mut b = wide(); + b.hover("Edit"); + assert!(!b.shown("Undo"), "hover alone opens nothing"); + b.click("File"); + b.hover("Edit"); + assert!(b.shown("Undo") && !b.shown("New"), "hover switches to Edit"); + b.hover("View"); + assert!(b.shown("Grid") && !b.shown("Undo")); +} + +#[test] +fn submenus_open_on_hover() { + let mut b = wide(); + b.click("File"); + assert!(!b.shown("a.txt")); + b.hover("Open Recent"); + b.run(3); + assert!(b.shown("a.txt"), "hovering a submenu row opens it"); + assert_eq!(b.node("Open Recent").and_then(|n| n.is_expanded()), Some(true)); + b.click("b.txt"); + assert_eq!(b.take(), vec![CommandRef::new("file.recent.b")]); +} + +#[test] +fn the_arrow_keys_move_open_submenus_and_enter_runs_an_item() { + let mut b = wide(); + b.click("File"); + b.key(Key::ArrowDown); + assert_eq!(b.nav().highlighted(0), Some(0), "↓ lights the first row"); + b.key(Key::ArrowDown); + b.key(Key::ArrowDown); + let nav = b.nav(); + let row = nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).cloned().unwrap(); + assert!(row.command.is_none(), "↓ skips the separator to Open Recent"); + b.key(Key::ArrowRight); + let nav = b.nav(); + assert_eq!(nav.level, 1, "→ moves into the submenu"); + assert_eq!(nav.highlighted(1), Some(0), "on its first row"); + assert!(b.shown("a.txt")); + b.key(Key::ArrowLeft); + let nav = b.nav(); + assert_eq!((nav.level, nav.rows.len()), (0, 1), "← closes the submenu"); + // ↓ past the disabled submenu and item, then wrapping to the first row. + b.key(Key::ArrowDown); + let nav = b.nav(); + assert_eq!(nav.highlighted(0).and_then(|i| nav.rows[0][i].command.clone()), Some(Builtin::Quit.command()), "disabled rows are skipped"); + b.key(Key::ArrowDown); + assert_eq!(b.nav().highlighted(0), Some(0), "↓ wraps"); + b.key(Key::Enter); + assert_eq!(b.take(), vec![CommandRef::new("file.new")]); + assert!(!is_open(&b.ctx), "↩ closes the menu"); + + b.click("Edit"); + b.key(Key::ArrowUp); + b.key(Key::Space); + assert_eq!(b.take(), vec![CommandRef::new("edit.redo")], "↑ from nothing lights the last row; Space runs it"); +} + +#[test] +fn left_and_right_switch_menus() { + let mut b = wide(); + b.click("File"); + b.key(Key::ArrowRight); + assert!(b.shown("Undo") && !b.shown("New"), "→ on a plain row opens the next menu"); + b.key(Key::ArrowLeft); + assert!(b.shown("New"), "← back to File"); + b.key(Key::ArrowLeft); + assert!(b.shown("About"), "← from the first menu wraps to the last"); + b.key(Key::Escape); + assert!(!is_open(&b.ctx)); +} + +#[test] +fn a_gate_forbidden_item_doesnt_run_from_the_keys() { + let mut b = wide(); + b.gate = Gate { recording_shortcut: true, ..Gate::default() }; + b.click("Edit"); + b.key(Key::ArrowDown); + b.key(Key::Enter); + assert!(b.take().is_empty()); + assert_eq!(b.nav().highlighted(0), None, "no enabled row to light"); +} + +#[test] +fn titles_that_dont_fit_move_into_the_overflow_menu() { + let file_and_edit = { + let b = wide(); + b.rect("Edit").right() - b.rect("File").left() + }; + let mut b = Bar::new(sample(), vec2(file_and_edit + 30.0, 400.0)); + assert!(b.title("File").is_some() && b.title("Edit").is_some()); + assert!(b.title("Help").is_none() && b.title("⋯").is_some(), "Help moved into ⋯"); + b.click("⋯"); + assert!(b.shown("View") && b.shown("Help"), "⋯ lists the menus that don't fit"); + b.hover("Help"); + b.run(3); + b.click("About"); + assert_eq!(b.take(), vec![CommandRef::new("help.about")]); + + let b = Bar::new(sample(), vec2(20.0, 400.0)); + assert!(b.title("File").is_none() && b.title("⋯").is_none()); + assert!(b.title("Menu").is_some(), "one Menu holds all when none fits"); +} + +#[test] +fn the_overflow_labels_come_from_the_label_hook() { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let bar = sample(); + let label = |english: &'static str| if english == "Menu" { "Menü".to_owned() } else { english.to_owned() }; + let mut tree = Vec::new(); + for _ in 0..2 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(20.0, 300.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + MenuBarUi::new(&bar).label(&label).show(ui); + }); + out.textures_delta.clear(); + tree = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default(); + } + assert!(tree.iter().any(|(_, n)| n.label() == Some("Menü"))); +} + +#[test] +fn accesskit_sees_a_menu_bar_of_menus_with_checked_states_and_shortcuts() { + let mut b = wide(); + assert!(b.tree.iter().any(|(_, n)| n.role() == Role::MenuBar), "the bar"); + let file = b.title("File").expect("File is a title in the bar"); + assert_eq!(file.role(), Role::MenuItem); + assert_eq!(file.is_expanded(), Some(false)); + b.click("View"); + assert_eq!(b.title("View").and_then(|n| n.is_expanded()), Some(true)); + assert!(b.tree.iter().any(|(_, n)| n.role() == Role::Menu), "the open menu"); + let state = |label: &str| b.node(label).map(|n| (n.role(), n.toggled())); + assert_eq!(state("Grid"), Some((Role::MenuItemCheckBox, Some(Toggled::True)))); + assert_eq!(state("Rulers"), Some((Role::MenuItemCheckBox, Some(Toggled::False)))); + assert_eq!(state("Light"), Some((Role::MenuItemRadio, Some(Toggled::True)))); + assert_eq!(state("Dark"), Some((Role::MenuItemRadio, Some(Toggled::False)))); + assert_eq!(b.node("Grid").and_then(|n| n.keyboard_shortcut()), Some("G"), "a display-only shortcut too"); + b.hover("File"); + assert_eq!(b.node("New").map(|n| (n.role(), n.keyboard_shortcut())), Some((Role::MenuItem, Some("Ctrl+N")))); +} + +/// In a browser the names are those of the system it runs on, and on a Mac there are no +/// mnemonics: Option types there. +#[test] +fn the_web_shows_the_browsers_system_names_and_the_mac_its_glyphs() { + use egui::os::OperatingSystem as Os; + for (target, os, text, mnemonic) in [ + (Target::Web, Os::Nix, "Ctrl+N", true), + (Target::Web, Os::Mac, "⌘N", false), + (Target::Windows, Os::Nix, "Ctrl+N", true), + (Target::Mac, Os::Nix, "⌘N", false), + ] { + let mut b = wide(); + b.ctx.set_os(os); + b.target = target; + b.click("File"); + let new = b.node("New"); + assert_eq!(new.and_then(|n| n.keyboard_shortcut()), Some(text), "{target:?} on {os:?}"); + assert_eq!(new.and_then(|n| n.access_key()).is_some(), mnemonic, "{target:?} on {os:?}"); + } +} + +/// `shrink_to_fit` leaves the rest of the row to other controls; past its limit the menus +/// overflow into "⋯". +#[test] +fn a_bar_shrunk_to_fit_shares_its_row() { + let mut b = wide(); + b.fit = Some(f32::INFINITY); + b.run(2); + let after = b.after.expect("the label after the bar"); + assert!(after < b.size.x / 2.0, "the bar takes only its titles' width: {after}"); + assert!(b.title("Help").is_some() && b.title("⋯").is_none()); + b.fit = Some(100.0); + b.run(2); + assert!(b.title("⋯").is_some(), "the menus past 100 points overflow"); + // The bar's 100 points and the row's item spacing (8). + assert!(b.after.is_some_and(|x| x <= 109.0), "{:?}", b.after); + // A limit an app computed from a full row can go negative. + b.fit = Some(-20.0); + b.run(2); +} + +/// A menu far taller than the window. +fn tall() -> Bar { + let rows = (0..60).map(|i| item(&format!("cmd.{i}"), &format!("Item {i}")).into()).collect(); + Bar::new(MenuBar { menus: vec![Menu::new("Long", rows)], documents: Vec::new() }, vec2(600.0, 300.0)) +} + +fn open_tall() -> Bar { + let mut b = tall(); + b.click("Long"); + b.run(3); + assert_eq!(b.nav().rows[0].len(), 60); + b +} + +#[test] +fn a_tall_menu_stays_in_the_window_below_the_bar_and_shows_scroll_arrows() { + let b = open_tall(); + let nav = b.nav(); + let screen = b.ctx.content_rect(); + assert!(!b.visible(0, 59), "the menu must overflow for this test to mean anything"); + assert!(nav.views[0].bottom() <= screen.bottom() && nav.views[0].top() >= b.rect("Long").bottom()); + assert!(b.shown("Scroll menu down") && b.shown("Scroll menu up")); +} + +#[test] +fn every_row_of_a_tall_menu_is_reachable_with_the_arrow_keys() { + let mut b = open_tall(); + for i in 0..60 { + b.key(Key::ArrowDown); + // The scroll lands a frame after the row is revealed. + b.run(2); + assert_eq!(b.nav().highlighted(0), Some(i)); + assert!(b.visible(0, i), "row {i} is scrolled into view: {:?} in {:?}", b.nav().rows[0][i].rect, b.nav().views[0]); + } + b.key(Key::ArrowDown); + b.run(2); + assert_eq!(b.nav().highlighted(0), Some(0)); + assert!(b.visible(0, 0), "↓ wraps to the first row, scrolling back up"); +} + +#[test] +fn the_wheel_scrolls_a_tall_menu() { + let mut b = open_tall(); + let view = b.nav().views[0]; + b.move_to(view.center()); + for _ in 0..60 { + if b.visible(0, 59) { + break; + } + b.events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -60.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + b.run(2); + } + assert!(b.visible(0, 59), "the wheel reaches the last row"); + assert_eq!(b.nav().rows[0].len(), 60, "the menu stays open while scrolling"); +} + +#[test] +fn resting_on_the_scroll_arrows_scrolls() { + let mut b = open_tall(); + let view = b.nav().views[0]; + b.move_to(pos2(view.center().x, view.bottom() + ARROW / 2.0)); + for _ in 0..240 { + if b.visible(0, 59) { + break; + } + b.frame(); + } + assert!(b.visible(0, 59), "resting on ▼ scrolls to the end"); + let view = b.nav().views[0]; + b.move_to(pos2(view.center().x, view.top() - ARROW / 2.0)); + for _ in 0..240 { + if b.visible(0, 0) { + break; + } + b.frame(); + } + assert!(b.visible(0, 0), "resting on ▲ scrolls back to the top"); +} + +/// A menu with two submenus one above the other, as Image › Mode and Image › Adjustments. +fn two_submenus() -> Bar { + let sub = |name: &str, n: usize| Node::submenu(name, (0..n).map(|i| item(&format!("{name}.{i}"), &format!("{name} {i}")).into()).collect()); + let menu = Menu::new("Image", vec![sub("Mode", 8), sub("Adjust", 8)]); + let mut b = Bar::new(MenuBar { menus: vec![menu], documents: Vec::new() }, vec2(800.0, 600.0)); + b.dt = 1.0 / 120.0; + b +} + +/// Open Image › Mode with the pointer resting on it; returns where the pointer is, the Adjust +/// row below it, and the row low in Mode's submenu the pointer heads for. +fn rest_on_mode(b: &mut Bar) -> (Pos2, Rect, Rect) { + b.click("Image"); + let mode = b.rect("Mode"); + let from = pos2(mode.right() - 40.0, mode.center().y); + for k in 0..=12 { + b.move_to(from - vec2(30.0 * (1.0 - k as f32 / 12.0), 0.0)); + } + b.run(30); + assert_eq!(open_submenu(b), "Mode", "resting on Mode opens it"); + (from, b.rect("Adjust"), b.rect("Mode 6")) +} + +fn open_submenu(b: &Bar) -> &'static str { + match (b.shown("Mode 0"), b.shown("Adjust 0")) { + (true, false) => "Mode", + (false, true) => "Adjust", + (false, false) => "none", + (true, true) => "both", + } +} + +/// Move the pointer along `path` (0…1) in `frames` frames; `pause` holds it still for that many +/// frames at that fraction of the way. +fn glide(b: &mut Bar, frames: usize, pause: Option<(f32, usize)>, mut path: impl FnMut(f32) -> Pos2) { + let mut paused = false; + for k in 0..=frames { + let t = k as f32 / frames as f32; + b.move_to(path(t)); + if let Some((_, n)) = pause.filter(|(at, _)| !paused && t >= *at) { + paused = true; + b.run(n); + } + } +} + +/// A person's path from `a` to `b`: it bows, down first and then across, by `bow`. +fn bowed(a: Pos2, b: Pos2, bow: f32) -> impl Fn(f32) -> Pos2 { + let c = pos2(a.x + (b.x - a.x) * (0.5 - bow), a.y + (b.y - a.y) * (0.5 + bow)); + move |t| { + let u = 1.0 - t; + pos2(u * u * a.x + 2.0 * u * t * c.x + t * t * b.x, u * u * a.y + 2.0 * u * t * c.y + t * t * b.y) + } +} + +/// Heading from Mode for a row low in its submenu, the pointer crosses the Adjust row. On a +/// diagonal, a bowed path or with a short pause on the way, Mode's submenu stays open, Adjust's +/// doesn't open, and the crossed row isn't highlighted. +#[test] +fn a_submenu_stays_open_while_the_pointer_heads_for_it() { + let paths = [("straight", 36, None, 0.0), ("hesitate", 36, Some((0.3, 18)), 0.0), ("bowed", 42, None, 0.2), ("slow and bowed", 96, None, 0.2)]; + let mut wrong = Vec::new(); + for (name, frames, pause, bow) in paths { + let mut b = two_submenus(); + let (from, adjust, target) = rest_on_mode(&mut b); + let to = pos2(target.left() + 40.0, target.center().y); + let path = bowed(from, to, bow); + let mut crossed = false; + glide(&mut b, frames, pause, |t| { + let p = path(t); + crossed |= adjust.contains(p); + p + }); + assert!(crossed, "{name}: the path crosses the Adjust row"); + let open = open_submenu(&b); + if open != "Mode" { + wrong.push(format!("{name}: arrived with {open} open, not Mode")); + } + if let Some(row) = b.nav().highlighted(0).filter(|&row| row != 0) { + wrong.push(format!("{name}: row {row} is highlighted, not Mode")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Resting on the row crossed, or moving away from the submenu onto it, still opens its submenu, +/// and the highlight moves to it. +#[test] +fn resting_on_another_row_or_moving_away_switches_submenus() { + let mut b = two_submenus(); + let (from, adjust, _) = rest_on_mode(&mut b); + let stop = pos2(from.x + 15.0, adjust.center().y); + glide(&mut b, 12, None, |t| from + (stop - from) * t); + assert_eq!(open_submenu(&b), "Mode", "just arrived on Adjust, still heading for Mode's submenu"); + b.run(48); + assert_eq!(open_submenu(&b), "Adjust", "resting on Adjust opens it"); + assert_eq!(b.nav().highlighted(0), Some(1)); + + for dx in [0.0, -60.0] { + let mut b = two_submenus(); + let (from, adjust, _) = rest_on_mode(&mut b); + let to = pos2(from.x + dx, adjust.center().y); + glide(&mut b, 10, None, |t| from + (to - from) * t); + b.run(30); + assert_eq!(open_submenu(&b), "Adjust", "moving away ({dx}) from Mode's submenu opens Adjust's"); + assert_eq!(b.nav().highlighted(0), Some(1)); + } +} + +#[test] +fn heading_is_the_triangle_to_the_submenus_near_edge() { + let sub = Rect::from_min_max(pos2(200.0, 100.0), pos2(400.0, 400.0)); + let from = pos2(150.0, 110.0); + assert!(heading(from, from, sub)); + assert!(heading(from, pos2(170.0, 160.0), sub), "down and right, toward the submenu"); + assert!(heading(from, pos2(190.0, 105.0), sub), "straight across"); + assert!(!heading(from, pos2(150.0, 160.0), sub), "straight down"); + assert!(!heading(from, pos2(120.0, 140.0), sub), "away to the left"); + assert!(!heading(from, pos2(170.0, 60.0), sub), "up past the submenu's top"); + // A submenu that opened to the left. + let left = Rect::from_min_max(pos2(0.0, 100.0), pos2(100.0, 400.0)); + assert!(heading(pos2(150.0, 110.0), pos2(130.0, 160.0), left)); + assert!(!heading(pos2(150.0, 110.0), pos2(170.0, 160.0), left)); + assert!(!heading(from, from, Rect::NOTHING)); +} diff --git a/crates/menu/src/tests/bar_keys.rs b/crates/menu/src/tests/bar_keys.rs new file mode 100644 index 00000000..9b463aef --- /dev/null +++ b/crates/menu/src/tests/bar_keys.rs @@ -0,0 +1,362 @@ +//! The bar from the keyboard: Alt and F10 focus it, Alt+letter opens a menu, letters run rows, +//! mnemonics are assigned per level (with "(F)" for letterless labels) and underlined only when +//! the keyboard drives the bar. + +use egui::{Event, Key, Modifiers, PointerButton, pos2}; + +use super::*; +use crate::bar::keys::{Keys, assign, is_command_key, key_char, localized}; +use crate::bar::{Labeled, is_focused, leave_for_shortcut}; + +impl Bar { + fn keys(&self) -> Keys { + Keys::current(&self.ctx, Id::unique("craft-menu-bar").with("keys")) + } + + fn focus(&self) -> Option { + self.keys().focus + } + + fn alt(&mut self, pressed: bool) { + let modifiers = if pressed { Modifiers::ALT } else { Modifiers::NONE }; + self.events.push(Event::Key { key: Key::AltLeft, physical_key: None, pressed, repeat: false, modifiers }); + self.run(2); + } + + /// A tap on Alt: down, then up with nothing else pressed between. + fn alt_tap(&mut self) { + self.alt(true); + self.alt(false); + self.run(1); + } + + /// `key` pressed and released with Alt held. + fn alt_key(&mut self, key: Key) { + for pressed in [true, false] { + self.events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::ALT }); + } + self.run(3); + } + + /// A letter typed: its key and the text it types. + fn letter(&mut self, c: char) { + let key = Key::from_name(&c.to_ascii_uppercase().to_string()).unwrap(); + self.events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + self.events.push(Event::Text(c.to_ascii_lowercase().to_string())); + self.events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + self.run(3); + } + + fn mnemonic(&self, level: usize, command: &str) -> Option { + self.nav().rows.get(level)?.iter().find(|r| r.command.as_ref().is_some_and(|c| c.id == command))?.mnemonic + } +} + +fn chars(s: &str) -> Vec> { + s.chars().map(Some).collect() +} + +#[test] +fn mnemonics_are_unique_word_starts_first_and_take_preferred_letters() { + let labels = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]; + assert_eq!(assign(&labels, &[]), chars("FEILTSRVWH"), "first letters, then the next free one"); + let preferred = chars("FEILYSTVWH"); + assert_eq!(assign(&labels, &preferred), chars("FEILYSTVWH"), "the app's letters where the labels have them"); + // Items: first letters first, then the next free letter of the word starts, then any. + assert_eq!(assign(&["New…", "Open…", "Open Recent", "Close", "Close All", "Save", "Save As…"], &[]), chars("NORCASV")); + // A preferred letter the label lacks (a translation) falls back; a taken one too. + assert_eq!(assign(&["Datei", "Bearbeiten"], &[Some('F'), Some('E')]), [Some('D'), Some('E')]); + assert_eq!(assign(&["Aa", "Ab", "Ba"], &[Some('A'), Some('A'), Some('A')]), [Some('A'), Some('B'), None]); + // Only ASCII letters and digits are keys: no mnemonic without one. + assert_eq!(assign(&["ファイル", "Zoom 100%", "✔ Rulers"], &[]), [None, Some('Z'), Some('R')]); +} + +#[test] +fn key_chars_are_plain_letters_and_digits_and_command_keys_leave_the_bar() { + assert_eq!(key_char(Key::A), Some('A')); + assert_eq!(key_char(Key::Num7), Some('7')); + assert_eq!(key_char(Key::F10), None); + assert_eq!(key_char(Key::ArrowDown), None); + assert_eq!(key_char(Key::Space), None); + assert!(is_command_key(Key::D, Modifiers::COMMAND)); + assert!(is_command_key(Key::F7, Modifiers::NONE)); + assert!(!is_command_key(Key::F10, Modifiers::NONE), "F10 toggles the bar"); + assert!(!is_command_key(Key::ControlLeft, Modifiers::CTRL), "Ctrl going down is no shortcut yet"); + assert!(!is_command_key(Key::E, Modifiers::NONE), "a letter is a mnemonic"); + assert!(!is_command_key(Key::E, Modifiers::ALT), "Alt+letter opens a menu"); + assert!(!is_command_key(Key::Enter, Modifiers::NONE)); +} + +/// A label with no Latin letter (Chinese, Japanese…) shows its English label's letter in +/// parentheses, before a trailing ellipsis, and takes it as its mnemonic; the letters stay unique. +#[test] +fn labels_without_latin_letters_show_their_english_key() { + let en = |v: &[&str]| v.iter().map(|s| Some((*s).to_owned())).collect::>(); + let (shown, keys) = localized(&["文件", "编辑", "Image", "图层"], &en(&["File", "Edit", "Image", "Layer"]), &[]); + assert_eq!(shown, ["文件(F)", "编辑(E)", "Image", "图层(L)"]); + assert_eq!(keys, chars("FEIL")); + // Rows: the key goes before the ellipsis; a clash takes the next letter of the shown label. + let (shown, keys) = localized(&["新建…", "打开...", "关闭"], &en(&["New…", "Open...", "Close"]), &[]); + assert_eq!(shown, ["新建(N)…", "打开(O)...", "关闭(C)"]); + assert_eq!(keys, chars("NOC")); + let (shown, keys) = localized(&["关闭", "复制"], &en(&["Close", "Copy"]), &[]); + assert_eq!(shown, ["关闭(C)", "复制(O)"]); + assert_eq!(keys, chars("CO")); + // A shown "(C)" keeps its key even when a label with letters of its own comes first. + let (shown, keys) = localized(&["Copy", "关闭"], &en(&["Open", "Close"]), &[]); + assert_eq!(shown, ["Copy", "关闭(C)"]); + assert_eq!(keys, chars("OC")); + // Without an English label, no suffix and no key. + let (shown, keys) = localized(&["文件", "Edit"], &[None, None], &[]); + assert_eq!(shown, ["文件", "Edit"]); + assert_eq!(keys, [None, Some('E')]); + // An English UI is unchanged. + assert_eq!(localized(&["File", "Edit"], &en(&["File", "Edit"]), &[]).0, ["File", "Edit"]); +} + +#[test] +fn alt_focuses_the_bar_arrows_move_and_down_opens_the_lit_menu() { + let mut b = wide(); + b.alt_tap(); + assert_eq!(b.focus(), Some(0), "Alt puts the keyboard on File"); + assert!(is_focused(&b.ctx) && !is_open(&b.ctx), "the shortcuts wait for the bar; no menu opens yet"); + b.key(Key::ArrowRight); + assert_eq!(b.focus(), Some(1), "→ moves to Edit"); + b.key(Key::ArrowLeft); + b.key(Key::ArrowLeft); + assert_eq!(b.focus(), Some(3), "← wraps round to Help"); + b.key(Key::ArrowRight); + b.key(Key::ArrowRight); + b.key(Key::ArrowDown); + assert!(is_open(&b.ctx) && b.shown("Undo"), "↓ opens the lit menu, Edit"); + assert_eq!(b.focus(), None, "the focus moves into the menu"); + assert_eq!(b.nav().highlighted(0), Some(0), "its first row is lit, as on Windows"); + assert!(b.nav().underline, "a menu opened from the keyboard shows its mnemonics"); + b.key(Key::Escape); + assert!(!is_open(&b.ctx) && b.focus().is_none(), "Esc leaves the menu and the bar"); + assert!(!b.nav().underline && !b.keys().show_underlines(&b.ctx), "and the underlines go"); +} + +#[test] +fn a_second_alt_esc_or_a_click_takes_the_keyboard_off_the_bar_and_f10_focuses_it() { + let mut b = wide(); + b.alt_tap(); + b.alt_tap(); + assert_eq!(b.focus(), None, "a second tap leaves"); + b.key(Key::F10); + assert_eq!(b.focus(), Some(0), "F10 focuses the bar too"); + b.key(Key::Escape); + assert!(b.focus().is_none() && !is_focused(&b.ctx)); + b.key(Key::F10); + b.click_at(pos2(600.0, 300.0)); + assert_eq!(b.focus(), None, "a click anywhere leaves"); +} + +/// Alt+wheel zooms in many apps, and Alt that went to the desktop (Alt+Tab) is no tap either. +#[test] +fn alt_with_a_wheel_scroll_a_lost_focus_or_a_drag_is_no_tap() { + let mut b = wide(); + b.alt(true); + b.events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers: Modifiers::ALT, + }); + b.run(2); + b.alt(false); + assert_eq!(b.focus(), None, "Alt+scroll"); + b.alt(true); + b.events.push(Event::WindowFocused(false)); + b.events.push(Event::WindowFocused(true)); + b.run(1); + b.alt(false); + assert_eq!(b.focus(), None, "Alt+Tab"); + // Alt pressed during a drag is a modifier for the drag, with the button let go before or after. + b.move_to(pos2(600.0, 300.0)); + for button_up_first in [true, false] { + b.button(true); + b.alt(true); + if button_up_first { + b.button(false); + b.alt(false); + } else { + b.alt(false); + b.button(false); + } + assert_eq!(b.focus(), None, "a drag, button up first: {button_up_first}"); + } + // The whole gesture within one frame: press, Alt down, Alt up, release. + let (at, alt, none) = (b.pointer, Modifiers::ALT, Modifiers::NONE); + b.events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: none }); + b.events.push(Event::Key { key: Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: alt }); + b.events.push(Event::Key { key: Key::AltLeft, physical_key: None, pressed: false, repeat: false, modifiers: none }); + b.events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: none }); + b.run(2); + assert_eq!(b.focus(), None, "one frame"); + b.alt_tap(); + assert_eq!(b.focus(), Some(0), "a lone tap still focuses the bar"); +} + +#[test] +fn alt_with_a_letter_opens_that_menu_and_enter_or_a_letter_opens_one_from_the_bar() { + let mut b = wide(); + b.alt_key(Key::E); + assert!(is_open(&b.ctx) && b.shown("Undo"), "Alt+E opens Edit"); + b.alt_key(Key::F); + assert!(b.shown("Open…") && !b.shown("Undo"), "Alt+F switches to File while a menu is open"); + b.key(Key::Escape); + b.key(Key::F10); + b.key(Key::Enter); + assert!(b.shown("Open…"), "Enter opens the lit File menu"); + b.key(Key::Escape); + b.key(Key::F10); + b.letter('v'); + assert!(is_open(&b.ctx) && b.shown("Grid"), "a letter on the focused bar opens that menu (V = View)"); + assert!(b.take().is_empty()); +} + +#[test] +fn a_letter_in_an_open_menu_runs_the_row_with_that_mnemonic_or_opens_its_submenu() { + let mut b = wide(); + b.alt_key(Key::V); + assert_eq!(b.mnemonic(0, "view.grid"), Some('G')); + let letters: Vec = b.nav().rows[0].iter().filter_map(|r| r.mnemonic).collect(); + let mut unique = letters.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(letters.len(), unique.len(), "mnemonics are unique within a level: {letters:?}"); + b.letter('g'); + assert_eq!(b.take(), vec![CommandRef::new("view.grid")], "G ran Grid"); + assert!(!is_open(&b.ctx), "and closed the menu"); + // File: New N, Open… O, Open Recent R; a submenu's letter opens it, with its first row lit. + b.alt_key(Key::F); + b.letter('r'); + let nav = b.nav(); + assert_eq!((nav.level, nav.highlighted(1)), (1, Some(0)), "R opened Open Recent on its first row"); + assert!(b.shown("a.txt")); + b.letter('b'); + assert_eq!(b.take(), vec![CommandRef::new("file.recent.b")], "B ran b.txt from the submenu"); + // A disabled row's letter does nothing. + b.alt_key(Key::F); + assert_eq!(b.mnemonic(0, "file.locked"), Some('L')); + b.letter('l'); + assert!(b.take().is_empty() && is_open(&b.ctx), "Locked is disabled"); +} + +#[test] +fn mnemonics_are_underlined_only_with_alt_or_a_keyboard_opened_menu() { + let mut b = wide(); + b.click("File"); + assert!(is_open(&b.ctx)); + assert!(!b.nav().underline, "a menu opened with the mouse shows no underlines"); + let underlined = |b: &Bar| { + let ctx = b.ctx.clone(); + // The rows' mnemonics are in AccessKit either way; the drawn text has the underline. + b.node("New").and_then(|n| n.access_key().map(str::to_owned)).is_some() + && Nav::current(&ctx, Id::unique("craft-menu-bar").with("nav")).underline + }; + assert!(!underlined(&b)); + b.alt(true); + assert!(underlined(&b), "while Alt is held they show"); + b.alt(false); + assert!(!is_open(&b.ctx), "an Alt tap closes an open menu, as on Windows"); + b.alt_key(Key::F); + assert!(is_open(&b.ctx) && underlined(&b), "a menu opened from the keyboard shows them"); + assert_eq!(b.node("New").and_then(|n| n.access_key()), Some("N"), "AccessKit has the mnemonic"); + assert_eq!(b.title("File").and_then(|n| n.access_key()), Some("F")); +} + +#[test] +fn the_app_can_prefer_letters_and_name_english_labels_for_letterless_ones() { + let mut b = wide(); + b.preferred = vec![("View", 'W'), ("file.open", 'P')]; + b.english = Some(|l: Labeled<'_>| match l { + Labeled::Title("帮助") => Some("Help".to_owned()), + Labeled::Command(c) if c.id == "file.new" => Some("New".to_owned()), + _ => None, + }); + b.bar.menus[3].title = "帮助".to_owned(); + b.bar.menus[0].children[0] = item("file.new", "新建…").into(); + b.run(2); + assert!(b.title("帮助(H)").is_some(), "a letterless title shows its English key"); + b.alt_key(Key::W); + assert!(b.shown("Grid"), "View took the app's W"); + b.alt_key(Key::H); + assert!(b.shown("About"), "Alt+H opens 帮助(H)"); + b.alt_key(Key::F); + assert!(b.shown("新建(N)…"), "the key goes before the ellipsis"); + assert_eq!(b.mnemonic(0, "file.open"), Some('P')); + b.letter('n'); + assert_eq!(b.take(), vec![CommandRef::new("file.new")]); +} + +#[test] +fn a_dialog_or_a_text_field_keeps_its_keys() { + let mut b = wide(); + b.gate = Gate { modal: true, ..Gate::default() }; + b.alt_tap(); + assert_eq!(b.focus(), None, "a dialog keeps Enter and Esc: Alt doesn't take the keyboard"); + b.alt_key(Key::F); + assert!(!is_open(&b.ctx), "nor does Alt+F"); + b.click("File"); + b.letter('n'); + assert!(b.take().is_empty() && is_open(&b.ctx), "a gate-forbidden item's letter doesn't run it"); + b.key(Key::Escape); + + b.gate = Gate { text_editing: true, ..Gate::default() }; + b.alt_tap(); + assert_eq!(b.focus(), Some(0), "a tap on Alt still focuses the bar from a text field"); + b.letter('e'); + assert!(!is_open(&b.ctx) && b.focus().is_none(), "a letter goes on to the field and leaves the bar"); + b.alt_key(Key::E); + assert!(b.shown("Undo"), "Alt+E opens Edit"); + b.letter('u'); + assert_eq!(b.take(), vec![CommandRef::new("edit.undo")], "with a menu open the letters are its"); +} + +#[test] +fn a_command_shortcut_takes_the_keyboard_off_the_bar() { + let mut b = wide(); + b.alt_tap(); + assert!(is_focused(&b.ctx)); + b.events.push(Event::Key { key: Key::S, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + let mut left = false; + b.time += b.dt; + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, b.size)), + time: Some(b.time), + events: std::mem::take(&mut b.events), + ..Default::default() + }; + let bar = &b.bar; + let mut out = b.ctx.run_ui(raw, |ui| { + left = leave_for_shortcut(ui.ctx()); + MenuBarUi::new(bar).target(Target::Linux).show(ui); + }); + out.textures_delta.clear(); + assert!(left && !is_focused(&b.ctx), "Ctrl+S leaves the bar for the app's shortcut"); + b.run(2); + assert_eq!(b.focus(), None); + assert!(!is_open(&b.ctx)); +} + +#[test] +fn a_mac_target_has_no_mnemonics() { + let mut b = wide(); + b.target = Target::Mac; + b.alt_tap(); + b.key(Key::F10); + assert!(b.focus().is_none() && !is_focused(&b.ctx), "Alt and F10 leave the bar alone"); + b.alt_key(Key::F); + assert!(!is_open(&b.ctx), "Option+F is a typing key"); + b.click("File"); + b.alt(true); + assert!(!b.nav().underline, "Option shows no underlines"); + assert!(b.nav().rows[0].iter().all(|r| r.mnemonic.is_none()), "the rows have no mnemonics"); + assert_eq!(b.node("New").and_then(|n| n.access_key()), None); + b.alt(false); + b.letter('n'); + assert!(b.take().is_empty(), "letters run nothing"); +} diff --git a/crates/menu/src/tests/layout.rs b/crates/menu/src/tests/layout.rs new file mode 100644 index 00000000..d50940fd --- /dev/null +++ b/crates/menu/src/tests/layout.rs @@ -0,0 +1,146 @@ +//! The Windows, Linux and web layouts, platform filtering, and the keymap adapter. + +use craft_keymap::{Bindable, Binding, Keymap, Platform, Policy, Scope, Scopes}; + +use super::*; + +fn menu(bar: &MenuBar, role: MenuRole) -> &Menu { + bar.menu(role).unwrap() +} + +fn has(bar: &MenuBar, id: &str) -> bool { + bar.find(&cmd(id)).is_some() +} + +#[test] +fn windows_puts_exit_last_in_file_and_documents_in_window() { + let l = layout(&sample(false, true), Target::Windows, &LayoutOptions::new("Sample")); + assert!(l.clashes.is_empty()); + assert_eq!(ids(&menu(&l.bar, MenuRole::File).children), ["file.new", "---", "file.close", "---", "file.exit"]); + assert_eq!(l.bar.find(&cmd("file.exit")).unwrap().label, "Exit"); + assert_eq!(ids(&menu(&l.bar, MenuRole::Edit).children), ["edit.undo", "edit.redo", "---", "[Language]", "---", "[Preferences]"]); + assert_eq!(ids(&menu(&l.bar, MenuRole::Help).children), ["help.docs", "---", "help.about"]); + assert_eq!(l.bar.find(&cmd("help.about")).unwrap().label, "About Sample"); + assert_eq!(ids(&menu(&l.bar, MenuRole::Window).children), ["[Theme]", "theme.next", "panel.layers", "---", "doc.0"]); + assert_eq!(l.bar.find(&cmd("doc.0")).unwrap().kind, ItemKind::Radio(true), "the document in front is selected"); + assert!(!has(&l.bar, Builtin::Hide.id()) && !has(&l.bar, Builtin::Minimize.id())); + assert_eq!(l.bar.menus.len(), 5); + + let mac = mac_layout(&sample(false, true), &LayoutOptions::new("Sample")).bar; + assert_eq!(mac.find(&cmd("doc.0")).unwrap().kind, ItemKind::Radio(true)); +} + +/// Without the menus the moved items need, the layout adds them, with translated titles. +#[test] +fn linux_adds_quit_and_the_menus_it_needs() { + let view = vec![ + Item::new("help.about", "About").role(ItemRole::About).into(), + Item::new("app.settings", "Settings").role(ItemRole::Settings).into(), + Item::new("view.zoom", "Zoom").into(), + ]; + let mut bar = MenuBar { menus: vec![Menu::new("View", view).role(MenuRole::View)], documents: vec![Document::new("doc.0", "Untitled")] }; + bar.documents.push(Document { hidden: true, ..Document::new("doc.1", "Background") }); + let label = |s: &'static str| format!("«{s}»"); + let l = layout(&bar, Target::Linux, &LayoutOptions { label: &label, ..LayoutOptions::new("Sample") }); + let titles: Vec<&str> = l.bar.menus.iter().map(|m| m.title.as_str()).collect(); + assert_eq!(titles, ["«File»", "«Edit»", "View", "«Window»", "«Help»"]); + let quit = &menu(&l.bar, MenuRole::File).children; + assert_eq!(ids(quit), [Builtin::Quit.id()]); + assert_eq!(l.bar.find(&Builtin::Quit.command()).unwrap().label, "«Quit»"); + assert_eq!(l.bar.find(&cmd("app.settings")).unwrap().label, "«Preferences…»"); + assert_eq!(l.bar.find(&cmd("help.about")).unwrap().label, "«About Sample»"); + assert_eq!(ids(&menu(&l.bar, MenuRole::Window).children), ["doc.0"], "a hidden document isn't listed"); +} + +#[test] +fn the_web_has_no_quit_and_gives_the_browser_its_shortcuts() { + let mut bar = sample(false, true); + let file = bar.menus.iter_mut().find(|m| m.role == MenuRole::File).unwrap(); + file.children.push(Item::new("file.reopen", "Reopen").shortcut(chord("Cmd+Shift+T")).into()); + file.children.push(Item::new("file.tab", "Tab").shortcut(chord("Cmd+Alt+T")).into()); + let l = layout(&bar, Target::Web, &LayoutOptions::new("Sample")); + assert!(!has(&l.bar, "file.exit") && !has(&l.bar, Builtin::Quit.id())); + let clashes: Vec<(String, &str)> = l.clashes.iter().map(|c| (c.command.key(), c.system)).collect(); + assert_eq!(clashes, [("file.new".into(), "New Window"), ("file.reopen".into(), "Reopen Closed Tab")]); + assert_eq!(l.bar.find(&cmd("file.new")).unwrap().shortcut, None); + assert_eq!(l.bar.find(&cmd("file.tab")).unwrap().shortcut, Some(chord("Cmd+Alt+T"))); + assert_eq!(l.bar.find(&cmd("edit.undo")).unwrap().shortcut, Some(chord("Cmd+Z"))); +} + +/// Items and submenus show only on their platforms; a submenu left empty goes, one holding only +/// the app's disabled placeholder stays. +#[test] +fn layouts_keep_only_what_the_target_shows() { + let file = vec![ + Item::new("file.new", "New").into(), + Item::new("file.tabs", "Show Tab Bar").platforms(Platforms::MAC).into(), + Node::submenu("Export", vec![Item::new("file.export_native", "Native").platforms(Platforms::MAC).into()]), + Node::submenu("Open Recent", vec![Item::new("file.no_recent", "No Recent Files").enabled(false).into()]), + Node::submenu("Plugins", vec![Item::new("plugin.a", "A").into()]).platforms(Platforms::ALL.without(Platforms::WEB)), + ]; + let bar = MenuBar { menus: vec![Menu::new("File", file).role(MenuRole::File)], documents: Vec::new() }; + let file_on = |target| { + let l = layout(&bar, target, &LayoutOptions::new("Sample")); + let file = menu(&l.bar, MenuRole::File).children.iter().take_while(|n| **n != Node::Separator).cloned().collect::>(); + ids(&file) + }; + assert_eq!(file_on(Target::Mac), ["file.new", "file.tabs", "[Export]", "[Open Recent]", "[Plugins]"]); + assert_eq!(file_on(Target::Linux), ["file.new", "[Open Recent]", "[Plugins]"]); + assert_eq!(file_on(Target::Web), ["file.new", "[Open Recent]"]); +} + +fn keymap() -> Keymap { + let app = Scope::app(); + let layers = Scope::new("Layers").unwrap(); + let catalog = vec![ + Bindable::new("file.save", "Save", app.clone()).with_default(Binding::new(chord("Cmd+S"))), + Bindable::new("view.rulers", "Rulers", app.clone()).toggle().with_default(Binding::new(chord("Cmd+R"))), + Bindable::new("view.grid", "Grid", app.clone()).toggle(), + Bindable::new("layer.new", "New Layer", layers.clone()) + .with_default(Binding::in_scope(chord("Cmd+Shift+N"), app.clone())) + .with_default(Binding::new(chord("Cmd+L"))), + Bindable::new("view.mode", "Mode", app).with_default(Binding::new(chord("M"))), + ]; + Keymap::new(catalog, Scopes::new().with_panel("Layers"), Platform::Linux) +} + +#[test] +fn the_keymap_gives_shortcuts_and_check_items() { + let mut keys = keymap(); + keys.set_bindings(&cmd("file.save"), vec![Binding::new(chord("Cmd+Alt+S"))], Policy::Steal).unwrap(); + let view = vec![ + Item::new("file.save", "Save").shortcut(chord("Cmd+S")).into(), + Item::new("view.rulers", "Rulers").into(), + Item::new("view.grid", "Grid").check(true).into(), + Item::new("layer.new", "New Layer").into(), + Item::new("view.mode", "Mode").radio(true).into(), + Item::new("view.other", "Other").shortcut(chord("F9")).into(), + ]; + let mut bar = MenuBar { menus: vec![Menu::new("View", view)], documents: Vec::new() }; + apply_keymap(&mut bar, &keys); + let item = |id: &str| bar.find(&cmd(id)).unwrap().clone(); + assert_eq!(item("file.save").shortcut, Some(chord("Cmd+Alt+S")), "the user's override"); + assert_eq!((item("view.rulers").shortcut, item("view.rulers").kind), (Some(chord("Cmd+R")), ItemKind::Check(false))); + assert_eq!((item("view.grid").shortcut, item("view.grid").kind), (None, ItemKind::Check(true)), "a toggle keeps its state"); + assert_eq!(item("layer.new").shortcut, Some(chord("Cmd+L")), "the home scope's binding first"); + assert_eq!(item("view.mode").kind, ItemKind::Radio(true)); + assert_eq!(item("view.other").shortcut, Some(chord("F9")), "a command the keymap doesn't know keeps its shortcut"); +} + +#[test] +fn the_app_sets_enabled_and_checked_each_frame() { + let mut bar = mac_layout(&sample(false, true), &LayoutOptions::new("Sample")).bar; + let mut asked = Vec::new(); + apply_state(&mut bar, |c| { + asked.push(c.key()); + match c.id.as_str() { + "edit.undo" => ItemState { enabled: false, checked: None }, + "panel.layers" => ItemState { checked: Some(true), ..ItemState::default() }, + _ => ItemState::default(), + } + }); + assert!(!bar.find(&cmd("edit.undo")).unwrap().enabled); + assert_eq!(bar.find(&cmd("panel.layers")).unwrap().kind, ItemKind::Check(true)); + assert!(asked.contains(&"file.new".to_string())); + assert!(!asked.iter().any(|k| k == Builtin::Hide.id() || k == "doc.0"), "built-ins and documents are left alone: {asked:?}"); +} diff --git a/crates/menu/src/tests/native.rs b/crates/menu/src/tests/native.rs new file mode 100644 index 00000000..f067ca77 --- /dev/null +++ b/crates/menu/src/tests/native.rs @@ -0,0 +1,230 @@ +//! The driver's side of the native menu: the gate, the command hook, display-only shortcuts and +//! shortcut recording. + +use std::sync::{Arc, Mutex}; + +use super::*; +use crate::driver::chosen_event; + +fn modal() -> Gate { + Gate { modal: true, ..Gate::default() } +} + +fn recording() -> Gate { + Gate { recording_shortcut: true, ..Gate::default() } +} + +type Shared = Rc>>; + +/// A driver over a fake backend, with what it synced, its pending events and its dispatch. +fn driver() -> (NativeMenu, Shared, Shared, Dispatch) { + let fake = Fake::default(); + let (synced, events, dispatch) = (fake.synced.clone(), fake.events.clone(), fake.dispatch.clone()); + let menu = NativeMenu::new(Box::new(fake)); + let dispatch = dispatch.borrow().clone().expect("NativeMenu::new attaches the dispatch"); + (menu, synced, events, dispatch) +} + +fn take_clicks(menu: &mut NativeMenu) -> Vec { + let ctx = egui::Context::default(); + let mut clicks = Vec::new(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| clicks = menu.take_clicks(ui.ctx())); + out.textures_delta.clear(); + clicks +} + +/// The gate is checked when the app takes the clicks, not only when the menu was open: a click +/// that arrived before a modal dialog opened doesn't run behind it. Built-ins still do. +#[test] +fn a_click_waiting_when_a_dialog_opens_is_dropped() { + let (mut menu, _, events, _) = driver(); + events.borrow_mut().extend([Event::Click(cmd("file.save")), Event::Click(Builtin::HideOthers.command())]); + menu.raw_input(&mut egui::RawInput::default()); + menu.set_gate(modal()); + assert_eq!(take_clicks(&mut menu), [Builtin::HideOthers.command()]); + + events.borrow_mut().push(Event::Click(cmd("file.save"))); + menu.raw_input(&mut egui::RawInput::default()); + menu.set_gate(Gate::default()); + assert_eq!(take_clicks(&mut menu), [cmd("file.save")]); +} + +/// While a dialog is open the app's items are greyed and the built-ins aren't. Opening and +/// closing it re-syncs the last menus in place, without laying them out again. +#[test] +fn a_modal_gate_greys_the_apps_items() { + let (mut menu, synced, _, _) = driver(); + let opts = LayoutOptions::new("Sample"); + let builds = Cell::new(0); + let build = || { + builds.set(builds.get() + 1); + mac_layout(&sample(false, true), &opts) + }; + menu.sync_with(1, build); + menu.set_gate(modal()); + menu.sync_with(1, build); + menu.sync_with(1, build); + menu.set_gate(Gate::default()); + menu.sync_with(1, build); + assert_eq!((builds.get(), synced.borrow().len()), (1, 3), "one layout; synced open, greyed, open"); + + let synced = synced.borrow(); + let enabled = |bar: &MenuBar, c: &CommandRef| bar.find(c).unwrap().enabled; + let greyed = &synced[1]; + assert!(!enabled(greyed, &cmd("file.new")) && !enabled(greyed, &cmd("help.about"))); + // AppKit sends a disabled item's key equivalent nowhere: ⌘Z must reach the dialog's fields. + assert!(enabled(greyed, &cmd("edit.undo")), "the text-editing keys stay"); + assert!(enabled(greyed, &Builtin::Hide.command()) && enabled(greyed, &Builtin::HideOthers.command())); + assert!(enabled(&synced[2], &cmd("file.new"))); + assert_eq!(structure_key(greyed), structure_key(&synced[0]), "greying is an update in place"); + + assert!(Gate::default().grey(&synced[0]).as_ref() == &synced[0]); + assert!(typing(true).grey(&synced[0]).as_ref() == &synced[0], "text editing greys nothing"); +} + +/// The hook sees app commands clicked while the gate allows them, as they arrive; key presses and +/// built-ins don't reach it. `take_clicks` still returns the commands. +#[test] +fn on_command_sees_allowed_clicks_only() { + let (mut menu, _, events, dispatch) = driver(); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + menu.on_command(Box::new(move |c| { + log.lock().unwrap().push(c.clone()); + false + })); + + let save = chord("Cmd+S"); + let mouse = |c: &CommandRef| dispatch.chosen(c, Some(&save), false, None); + events.borrow_mut().push(mouse(&cmd("file.save"))); + assert_eq!(dispatch.chosen(&cmd("file.save"), Some(&save), false, held([true, false, false, false])), Event::Key(save)); + mouse(&Builtin::Quit.command()); + menu.set_gate(modal()); + mouse(&cmd("file.export")); + menu.set_gate(Gate::default()); + assert_eq!(*seen.lock().unwrap(), [cmd("file.save")]); + + menu.raw_input(&mut egui::RawInput::default()); + assert_eq!(take_clicks(&mut menu), [cmd("file.save")], "a click the hook didn't run"); + + menu.on_command(Box::new(|c| c.id == "file.save")); + events.borrow_mut().extend([mouse(&cmd("file.save")), mouse(&cmd("file.export"))]); + assert_eq!(events.borrow()[0], Event::Handled(cmd("file.save"))); + menu.raw_input(&mut egui::RawInput::default()); + assert_eq!(take_clicks(&mut menu), [cmd("file.export")], "the hook ran file.save"); +} + +/// While a text field has the keys, clicking Edit › Copy or Undo acts on the field: the item's +/// shortcut is tapped (press and release, since a click has no key-up), not the app's command run. +#[test] +fn a_click_on_a_text_editing_item_while_typing_taps_its_shortcut() { + let (copy, undo, save) = (chord("Cmd+C"), chord("Cmd+Z"), chord("Cmd+S")); + assert_eq!(chosen_event(&cmd("edit.copy"), Some(©), false, None, typing(true)), Event::Tap(copy)); + assert_eq!(chosen_event(&cmd("edit.undo"), Some(&undo), false, None, typing(true)), Event::Tap(undo)); + assert_eq!(chosen_event(&cmd("file.save"), Some(&save), false, None, typing(true)), Event::Click(cmd("file.save"))); + assert_eq!(chosen_event(&cmd("edit.undo"), Some(&undo), false, None, typing(false)), Event::Click(cmd("edit.undo"))); + + let (mut menu, _, events, _) = driver(); + menu.set_gate(typing(true)); + events.borrow_mut().extend([Event::Tap(undo), Event::Tap(copy)]); + let mut raw = egui::RawInput::default(); + menu.raw_input(&mut raw); + assert_eq!(raw.events, [key_event(&undo, true), key_event(&undo, false), egui::Event::Copy]); + assert!(take_clicks(&mut menu).is_empty()); +} + +/// `sync` lays nothing out itself: it compares each frame's layout with the last, so a change +/// without input still reaches the menus, and an unchanged one costs no sync. +#[test] +fn sync_compares_each_frames_layout() { + let (mut menu, synced, _, _) = driver(); + let opts = LayoutOptions::new("Sample"); + let mut bar = sample(false, true); + menu.sync(mac_layout(&bar, &opts)); + menu.sync(mac_layout(&bar, &opts)); + assert_eq!(synced.borrow().len(), 1, "unchanged: no sync"); + bar.for_each_item_mut(|it| { + if it.command.id == "file.new" { + it.enabled = false; + } + }); + menu.sync(mac_layout(&bar, &opts)); + assert_eq!(synced.borrow().len(), 2); + assert!(!synced.borrow()[1].find(&cmd("file.new")).unwrap().enabled); +} + +/// An item that was a check item stays one when the app's state stops saying checked +/// (`checked: None`): unchecked, with the same structure, so the menus aren't rebuilt. +#[test] +fn a_check_item_keeps_its_kind() { + let (mut menu, synced, _, _) = driver(); + let opts = LayoutOptions::new("Sample"); + let with = |checked: Option| { + let mut bar = sample(false, true); + apply_state(&mut bar, |c| ItemState { checked: checked.filter(|_| c.id == "file.new"), ..ItemState::default() }); + mac_layout(&bar, &opts) + }; + menu.sync(with(Some(true))); + menu.sync(with(None)); + let synced = synced.borrow(); + assert_eq!(synced[1].find(&cmd("file.new")).unwrap().kind, ItemKind::Check(false)); + assert_eq!(structure_key(&synced[1]), structure_key(&synced[0])); +} + +/// Only shortcuts with ⌘ or ⌃ are key equivalents. A tool key (`B`, `⇧B`, `⌥B`, `F5`) is +/// display-only: choosing its item with ↩ in the open menu is a click. +#[test] +fn only_command_or_control_shortcuts_are_key_equivalents() { + use crate::driver::native_ok; + for s in ["Cmd+B", "Ctrl+B", "Ctrl+F5", "Alt+Cmd+F12", "Shift+Ctrl+Tab"] { + assert!(native_ok(&chord(s)), "{s}"); + } + for s in ["B", "Shift+B", "Alt+B", "Shift+Alt+B", "F5", "Shift+F5", "Escape"] { + assert!(!native_ok(&chord(s)), "{s}"); + let event = chosen_event(&cmd("tool"), Some(&chord(s)), false, held([false; 4]), Gate::default()); + assert_eq!(event, Event::Click(cmd("tool")), "{s}"); + } +} + +/// While a shortcut is recorded every key equivalent reaches egui as the key that was pressed, +/// also those that are clicks otherwise (⌘Q on the app's Quit, Minimize, while typing) and the +/// clipboard keys. +#[test] +fn recording_a_shortcut_passes_key_equivalents_through() { + let cmd_held = held([true, false, false, false]); + let quit = chord("Cmd+Q"); + assert_eq!(chosen_event(&cmd("file.exit"), Some(&quit), true, cmd_held, recording()), Event::Key(quit)); + let min = chord("Ctrl+Cmd+M"); + assert_eq!(chosen_event(&Builtin::Minimize.command(), Some(&min), false, held([true, true, false, false]), recording()), Event::Key(min)); + let hide = chord("Cmd+H"); + assert_eq!(chosen_event(&Builtin::Hide.command(), Some(&hide), true, cmd_held, recording()), Event::Key(hide)); + let typing_and_recording = Gate { text_editing: true, recording_shortcut: true, ..Gate::default() }; + assert_eq!(chosen_event(&cmd("file.save"), Some(&chord("Cmd+S")), false, cmd_held, typing_and_recording), Event::Key(chord("Cmd+S"))); + assert_eq!(chosen_event(&cmd("file.exit"), Some(&quit), true, None, recording()), Event::Click(cmd("file.exit")), "a mouse click"); + + let (mut menu, _, events, _) = driver(); + menu.set_gate(recording()); + events.borrow_mut().extend([Event::Key(chord("Cmd+C")), Event::Key(chord("Cmd+V"))]); + let mut raw = egui::RawInput::default(); + menu.raw_input(&mut raw); + assert_eq!(raw.events, [key_event(&chord("Cmd+C"), true), key_event(&chord("Cmd+V"), true)], "no Copy or Paste events"); +} + +/// Dynamic lists (recent files, workspaces) change the tree: adding or removing one rebuilds, +/// relabelling one updates in place. +#[test] +fn dynamic_lists_rebuild_when_they_grow_or_shrink() { + let recents = |files: &[&str]| { + let items = files + .iter() + .enumerate() + .map(|(i, f)| Item::new(CommandRef::parse_key(&format!(r#"file.open_recent {{"index":{i}}}"#)).unwrap(), *f).into()) + .collect(); + let file = Menu::new("File", vec![Node::submenu("Open Recent", items)]).role(MenuRole::File); + MenuBar { menus: vec![file], documents: Vec::new() } + }; + let two = structure_key(&recents(&["a.pdf", "b.pdf"])); + assert_ne!(structure_key(&recents(&["a.pdf", "b.pdf", "c.pdf"])), two); + assert_ne!(structure_key(&recents(&["a.pdf"])), two); + assert_eq!(structure_key(&recents(&["b.pdf", "a.pdf"])), two, "same commands, new labels"); +} diff --git a/crates/pdf/README.md b/crates/pdf/README.md index f35c5b1b..4a73fdc6 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -56,7 +56,7 @@ Cancellation is polled between operators, so a decoder may finish before noticin ## Backend provenance and consumer pins The shared reader vendors the current patched Hayro trees from PdfCraft revision -`7437bde378f43856eada40af25b983812e615e8a`. See +`ec0a965074f376a5bf610435948e4e5184bf3299`. See [`vendor/README.craft-reader.md`](../../vendor/README.craft-reader.md) for the transfer and local patches. Lopdf, COS editing, inspection fallback and application caches remain app-owned. @@ -103,7 +103,7 @@ that supply shaped glyphs do not need that shaper. Adapters that call `draw_text DeckCraft, also need `write-simple-text` when adopting this revision. `write-pdf` adds embedded-PDF support independently of text shaping. -The writer pins Krilla 0.8.2, with a local unwind-cleanup patch documented in +The writer pins Krilla 0.8.2, with local unwind-cleanup, print color and optional content patches documented in [`vendor/krilla/README.craft.md`](../../vendor/krilla/README.craft.md). Successful serialization uses the same backend version as before extraction. It provides: @@ -119,6 +119,49 @@ serialization uses the same backend version as before extraction. It provides: postprocessing. App errors pass through; serialization errors retain backend diagnostics. Caught panic messages retain up to 1024 Unicode scalar values; the installed panic hook remains responsible for logging source locations and full diagnostics. +- `write::uri_target`: the Krilla link target for a URI link, or `None` when `safe_url` + refuses it. Apps build URI links with it instead of `backend::action::LinkAction`, so a + link target from someone else's document becomes a PDF link only when it is an `http`/`https` + URL with a host or a `mailto` address. A refused target writes no link annotation. +- `safe_url` (crate root, any feature): the same rule, for apps that open links themselves, + such as a slide show. + +### Print color + +`write::print::cmyk_blending(profile)` returns `Result` +with the settings for a plain CMYK print PDF: DeviceCMYK color and transparency blending in +CMYK (`GroupColorSpace::Cmyk`), with no output intent. `write::print::settings` adds the +document's CMYK profile as a `GTS_PDFX` output intent and `/Trapped /False`, for PDF/X. Both +fail with `WriteError::InvalidProfile` when the profile is not a readable CMYK ICC profile. +The profile is the document's working space. craft-pdf takes the profile bytes and does not +depend on craft-cms. Its CC0 `builtin::COATED_FOGRA39_ICC` is the default working space, described by +`PrintColor::fogra39`: + +```rust +use craft_pdf::write::print::{self, PrintColor}; + +let settings = print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC))?; +let writer = Writer::new(SerializeSettings { configuration, ..settings }, metadata); +``` + +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 `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)). + +### Optional content + +Drawing layers are written as optional content groups. `Document::add_optional_content_group(name, +visible)` returns an `OcgId`; `Surface::start_optional_content(id)` and +`Surface::end_optional_content()` mark the content between them, and nest independently of +tagged sections. Both return `KrillaError` on misuse instead of panicking, and a mark left open +when its stream finishes fails `Writer::finish`. The catalog lists every group in +`/OCProperties`, hidden ones in `/OFF`; `Document::set_optional_content_page_mode(true)` adds +`/PageMode /UseOC`. Optional content needs PDF 1.5 and fails PDF/A-1 validation (patch 12 in +[`vendor/krilla/README.craft.md`](../../vendor/krilla/README.craft.md), marker +`CRAFT_OPTIONAL_CONTENT`). `Writer::document_mut` and `write::backend` deliberately expose Krilla's page, resource and painting APIs. The apps keep their document walks, pagination, font selection/licensing, diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e540157a..9814c469 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -6,8 +6,10 @@ #[cfg(feature = "interpret")] pub use hayro_interpret as interpret; +mod link; #[cfg(feature = "read")] pub use hayro_syntax as syntax; +pub use link::safe_url; #[cfg(feature = "read")] mod read; diff --git a/crates/pdf/src/link.rs b/crates/pdf/src/link.rs new file mode 100644 index 00000000..f4c7b6a7 --- /dev/null +++ b/crates/pdf/src/link.rs @@ -0,0 +1,84 @@ +//! Which link targets from a document may become clickable links in a written PDF. + +/// Whether a link target from a (possibly untrusted) document may be opened by whoever opens +/// the file: `http`/`https` URLs with a host, and `mailto` with an address. +/// +/// Everything else is refused: `file:`, `javascript:`, `data:`, relative paths, network +/// shares (`\\host\share`, `//host/share`, `smb:`) and other schemes, which an operating system +/// may hand to a protocol handler. Leading and trailing whitespace is ignored; schemes compare +/// case-insensitively. +pub fn safe_url(url: &str) -> bool { + let u = url.trim(); + let Some((scheme, rest)) = u.split_once(':') else { return false }; + match scheme.to_ascii_lowercase().as_str() { + "http" | "https" => rest.strip_prefix("//").is_some_and(|r| !host(r).is_empty()), + "mailto" => has_recipient(rest), + _ => false, + } +} + +/// The host of an authority-first URL remainder: before the path, query or fragment, after any +/// userinfo, without the port. +fn host(rest: &str) -> &str { + let authority = rest.split(['/', '?', '#', '\\']).next().unwrap_or(""); + let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + match host_port.strip_prefix('[') { + Some(v6) => v6.split_once(']').map_or("", |(h, _)| h), + None => host_port.split(':').next().unwrap_or(""), + } +} + +/// Whether a `mailto:` remainder names an address, before its headers or in a `to` header. +fn has_recipient(rest: &str) -> bool { + let (to, headers) = rest.split_once('?').unwrap_or((rest, "")); + !to.trim().is_empty() || headers.split('&').any(|h| h.split_once('=').is_some_and(|(k, v)| k.eq_ignore_ascii_case("to") && !v.trim().is_empty())) +} + +#[cfg(test)] +mod tests { + use super::safe_url; + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn only_web_and_mail_links_are_safe_to_open() { + for url in [ + "https://example.com", + " HTTP://example.com/x", + "https://user@example.com:8443/x?q#f", + "https://[::1]/", + "mailto:someone@example.com", + "mailto:?to=someone@example.com&subject=hi", + ] { + assert!(safe_url(url), "{url}"); + } + let refused = [ + "https://", + "https://?q=x", + "https:///x", + "https://user@/x", + "https://:80/", + "mailto:", + "mailto:?subject=hello", + "file:///C:/notes.pdf", + "notes.pdf", + "javascript:alert(1)", + "\t JavaScript:alert(1)", + "java\nscript:alert(1)", + "vbscript:MsgBox(1)", + "data:text/html,", + "ms-msdt:/id PCWDiagnostic", + "ms-word:ofe|u|\\\\attacker.example\\share\\x.docx", + "ldap://attacker.example/x", + "//attacker.example/share", + "\\\\attacker.example\\share", + "file://attacker.example/share/x", + "file:////attacker.example/share/x", + "smb://attacker.example/share", + "search-ms:query=x&crumb=location:\\\\attacker.example\\share", + "search:query=x&crumb=location:\\\\attacker.example\\share", + ]; + for url in refused { + assert!(!safe_url(url), "{url}"); + } + } +} diff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs index bf1fc931..9b170d1f 100644 --- a/crates/pdf/src/raster.rs +++ b/crates/pdf/src/raster.rs @@ -26,6 +26,8 @@ pub struct Options { /// Straight-alpha sRGB bytes; output pixels are always premultiplied. pub background: [u8; 4], /// Generic solid-colour mapping; reading presets and print bypass policy belong to callers. + /// It also maps a plate's gray film (`InterpreterSettings::plate`), so a plate render that + /// must keep its tints leaves it `None`. Knockout clears are never mapped. pub color_replacement: Option, } diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 1c2e0413..8114327d 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -8,8 +8,13 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; pub use krilla as backend; -// Require the root workspace patch; an unpatched backend can abort during unwind. +// Require the root workspace patch; an unpatched backend can abort during unwind, writes +// RGB blending groups and no print output intent, and has no optional content. const _: bool = backend::CRAFT_UNWIND_POISONING; +const _: bool = backend::CRAFT_PRINT_COLOR; +const _: bool = backend::CRAFT_OPTIONAL_CONTENT; + +pub mod print; use backend::configure::{Archival, Configuration, ConfigurationBuilder, ConfigurationError, PdfVersion}; use backend::text::{Font, Tag}; @@ -36,6 +41,8 @@ pub enum WriteError { Allocation, #[error("font could not be read for embedding")] InvalidFont, + #[error("the print profile is not a readable CMYK ICC profile")] + InvalidProfile, } /// Run an entire app export, including drawing and postprocessing, behind a native @@ -77,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 { @@ -92,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 } } @@ -132,6 +144,14 @@ impl Writer { } } +/// The link target that opens `url`, or `None` when [`safe_url`](crate::safe_url) refuses it. +/// Build URI links with this instead of `backend::action::LinkAction` so a refused target +/// writes no link annotation; the page content stays as drawn. The URI is written trimmed. +pub fn uri_target(url: &str) -> Option { + let url = url.trim(); + crate::safe_url(url).then(|| backend::annotation::Target::Action(backend::action::LinkAction::new(url.to_owned()).into())) +} + fn check_font_input(size: usize, variations: Option<&[([u8; 4], f32)]>) -> Result<(), WriteError> { if size > MAX_FONT_BYTES { return Err(WriteError::FontLimit); diff --git a/crates/pdf/src/write/print.rs b/crates/pdf/src/write/print.rs new file mode 100644 index 00000000..951ce5a8 --- /dev/null +++ b/crates/pdf/src/write/print.rs @@ -0,0 +1,83 @@ +//! Serialization settings for CMYK print output. +//! +//! The document's CMYK profile, its working space, describes the printing condition. +//! [`cmyk_blending`] blends transparency in CMYK and writes no output intent, like a plain +//! print PDF; [`settings`] also writes the profile as the `GTS_PDFX` output intent, through +//! which DeviceCMYK content and the CMYK blending groups are interpreted, as PDF/X needs. This module takes the profile bytes and has no +//! color-management dependency; craft-cms's `builtin::COATED_FOGRA39_ICC` is a suitable +//! default profile, described by [`PrintColor::fogra39`]. +//! +//! The settings carry no PDF/X version and are not checked against PDF/X: callers that claim +//! conformance set [`SerializeSettings::pdfx_version`] and run their own checks. + +use super::backend::icc::ICCProfile; +use super::backend::{GroupColorSpace, OutputIntent, SerializeSettings, Trapped}; +use super::{WriteError, guard}; + +/// The printing condition of a CMYK export. +#[derive(Clone, Copy, Debug)] +pub struct PrintColor<'a> { + /// The CMYK ICC profile, written as `/DestOutputProfile`. + pub profile: &'a [u8], + /// `/OutputConditionIdentifier`: a registered name such as `FOGRA39`, or `Custom`. + pub condition_identifier: &'a str, + /// `/OutputCondition`: a human-readable description. + pub condition: Option<&'a str>, + /// `/RegistryName`, such as `http://www.color.org` for registered conditions. + pub registry_name: Option<&'a str>, + /// `/Info`: PDF/X requires it when the condition is not registered. + pub info: Option<&'a str>, +} + +impl<'a> PrintColor<'a> { + /// A profile for the registered FOGRA39 condition (ISO 12647-2:2004 coated paper), such + /// as craft-cms's `builtin::COATED_FOGRA39_ICC`. + pub fn fogra39(profile: &'a [u8]) -> Self { + Self { + profile, + condition_identifier: "FOGRA39", + condition: Some("Coated FOGRA39 (ISO 12647-2:2004)"), + registry_name: Some("http://www.color.org"), + info: Some("Coated FOGRA39 (ISO 12647-2:2004)"), + } + } +} + +/// Settings for a CMYK print PDF without an output intent: DeviceCMYK color and CMYK +/// blending groups, with `profile` as `cmyk_profile`. Other fields keep their defaults; +/// override them with struct update syntax. +/// +/// Fails with [`WriteError::InvalidProfile`] when `profile` is not a readable CMYK ICC +/// profile. +pub fn cmyk_blending(profile: &[u8]) -> Result { + guard(|| { + let profile = ICCProfile::<4>::new(profile).ok_or(WriteError::InvalidProfile)?; + Ok(SerializeSettings { + no_device_cs: false, + cmyk_profile: Some(profile), + group_color_space: GroupColorSpace::Cmyk, + ..SerializeSettings::default() + }) + }) +} + +/// [`cmyk_blending`] plus the profile as the `GTS_PDFX` output intent and `/Trapped /False`, +/// for PDF/X export. +/// +/// Fails with [`WriteError::InvalidProfile`] when `profile` is not a readable CMYK ICC +/// profile. +pub fn settings(color: PrintColor<'_>) -> Result { + let settings = cmyk_blending(color.profile)?; + let profile = settings.cmyk_profile.clone().ok_or(WriteError::InvalidProfile)?; + Ok(SerializeSettings { + output_intent: Some(OutputIntent { + profile, + condition_identifier: color.condition_identifier.to_owned(), + condition: color.condition.map(str::to_owned), + registry_name: color.registry_name.map(str::to_owned), + info: color.info.map(str::to_owned), + }), + trapped: Some(Trapped::False), + ..settings + }) +} diff --git a/crates/pdf/tests/knockout.rs b/crates/pdf/tests/knockout.rs new file mode 100644 index 00000000..039674b9 --- /dev/null +++ b/crates/pdf/tests/knockout.rs @@ -0,0 +1,162 @@ +//! Knockout transparency groups through the raster path. Adapted from PdfCraft #2974 by mtlcl410 +//! (`raster::tests::knockout_groups_replace_what_their_elements_cover` in pdfcraft-render at +//! bae69819594a6385a027fec1f67e6b3c36c259b4, MIT OR Apache-2.0). +#![cfg(feature = "raster")] + +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::{ + open_slice, + raster::{self, Options, RenderCache}, +}; + +/// A 300 × 200 pt page that draws form `Fm1` (a transparency group with `group` entries and +/// content `form`), then a green square at 50 % opacity. +fn knockout_pdf(group: &str, form: &str) -> Vec { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> /ExtGState << /G << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length 39 >> stream +/Fm1 Do /G gs 0 1 0 rg 10 10 30 30 re f +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency {group} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> /XObject << /In 6 0 R /Msk 7 0 R /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 0 R /Shg 16 0 R /Big 17 0 R >> >> /Length {} >> stream +{form} +endstream endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length 34 >> stream +/A gs 0 0 1 rg 100 50 150 120 re f +endstream endobj +7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >> stream +0000> +endstream endobj +8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> >> /Length 62 >> stream +/H gs 0 1 0 rg 80 40 100 100 re f 0 0 1 rg 130 80 100 100 re f +endstream endobj +9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +10 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode /Length 13 >> stream +0000FF0000FF> +endstream endobj +11 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 5 >> stream +00FF> +endstream endobj +12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /Img 9 0 R >> >> /Length 38 >> stream +/H gs q 160 0 0 100 60 50 cm /Img Do Q +endstream endobj +13 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /In3 14 0 R >> >> /Length 13 >> stream +/H gs /In3 Do +endstream endobj +14 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Length 28 >> stream +0 0 1 rg 100 50 150 120 re f +endstream endobj +15 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> /Extend [true true] /BBox [100 50 140 170] >> >> endobj +16 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /Pattern << /P 15 0 R >> >> /Length 38 >> stream +/Pattern cs /P scn 100 50 150 120 re f +endstream endobj +17 0 obj << /Type /XObject /Subtype /Image /Width 20000 /Height 20000 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + form.len() + ) + .into_bytes() +} + +/// The page rendered over white, as RGBA8, 300 × 200 pixels. +fn render(group: &str, form: &str) -> Vec { + let pdf = knockout_pdf(group, form); + let doc = open_slice(&pdf, None).unwrap(); + let options = Options { background: [255; 4], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), &InterpreterSettings::default(), &options).unwrap(); + assert_eq!((raster.width, raster.height), (300, 200)); + raster.bytes().to_vec() +} + +/// The RGB at PDF point (x, y); pixel rows run top down. +fn px(page: &[u8], x: usize, y: usize) -> [u8; 3] { + let p = &page[((200 - y) * 300 + x) * 4..][..3]; + [p[0], p[1], p[2]] +} + +fn check(page: &[u8], x: usize, y: usize, want: [u8; 3], what: &str) { + let got = px(page, x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what}: {got:?}, expected {want:?}"); +} + +/// In a knockout transparency group (`/K true`) each element composites with the group's initial +/// backdrop, not with the elements drawn before it in the group, over the element's shape. A blue +/// square at 50 % opacity over a red one at 50 % shows only the blue where they overlap; without +/// `/K` the two blend. The same holds for an image (its shape is its rectangle; a soft-masked +/// image's shape is its mask, so its transparent part leaves what is under it) and for a nested +/// group (its shape is the union of its elements' shapes, while its own elements still blend with +/// each other). +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn knockout_groups_replace_what_their_elements_cover() { + let red = "/A gs 1 0 0 rg 20 20 150 150 re f "; + let ko = "/I true /K true"; + + let squares = render(ko, &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&squares, 130, 100, [128, 128, 255], "knocked out"); + check(&squares, 50, 40, [255, 128, 128], "red alone"); + check(&squares, 210, 100, [128, 128, 255], "blue alone"); + check(&squares, 280, 190, [255, 255, 255], "paper"); + // Page content after the group composites normally again: green at 50 % over the red. + check(&squares, 30, 30, [128, 191, 64], "after the group"); + let plain = render("/I true", &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&plain, 130, 100, [128, 64, 191], "blended without /K"); + + // An image at 50 % replaces the red under its rectangle. + let image = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Img Do Q")); + check(&image, 100, 100, [128, 128, 255], "image knocks out"); + check(&image, 40, 40, [255, 128, 128], "red beside the image"); + // A soft-masked image: its transparent left half leaves the red, its opaque half replaces it. + let masked = render(ko, "1 0 0 rg 20 20 150 150 re f q 160 0 0 100 60 50 cm /Sm Do Q"); + check(&masked, 100, 100, [255, 0, 0], "under the transparent half"); + check(&masked, 155, 100, [0, 0, 255], "under the opaque half"); + + // A nested group entered at 50 % (blue at 25 % in all) replaces the red under its shape. + let nested = render(ko, &format!("{red}/In Do")); + check(&nested, 130, 100, [191, 191, 255], "nested group knocks out"); + check(&nested, 50, 40, [255, 128, 128], "red beside the nested group"); + // A nested group at full opacity: it knocks out the red as a whole, and its own two squares + // at 50 % still blend with each other where they overlap. + let neutral = render(ko, "1 0 0 rg 20 20 150 150 re f /Neu Do"); + check(&neutral, 100, 60, [128, 255, 128], "green over the knocked-out red"); + check(&neutral, 150, 110, [64, 128, 191], "the nested squares blend"); + check(&neutral, 40, 40, [255, 0, 0], "red beside the nested group"); + + // An image inside a nested group is drawn once (blue at 50 %), over the knocked-out red. + let nested_image = render(ko, "1 0 0 rg 20 20 150 150 re f /Nimg Do"); + check(&nested_image, 100, 100, [128, 128, 255], "image in a nested group"); + // A group inside a nested group knocks out the red as well. + let deep = render(ko, "1 0 0 rg 20 20 150 150 re f /Deep Do"); + check(&deep, 130, 100, [128, 128, 255], "group in a nested group"); + check(&deep, 40, 40, [255, 0, 0], "red beside it"); + + // A stencil mask must not erase the red beside it. Where the stencil paints (every sample 0) + // its blue is drawn; a stencil composites rather than knocking the red out, and its exact + // color also depends on its fill opacity (pdfcraft #2975), so only "blue was drawn here" is + // pinned. + let stencil = render(ko, &format!("{red}0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q")); + check(&stencil, 50, 40, [255, 128, 128], "red beside a stencil"); + let under = px(&stencil, 130, 100); + assert!(under != [255, 128, 128] && under[2] >= 150 && under[0] < 230, "stencil drew nothing: {under:?}"); + + // A shading's /BBox clips it (ISO 32000-2 Table 77), so a shading fill's shape is its path + // inside the box: the erase pass clears only that, and the red outside the box but inside + // the path stays. + let shaded = render(ko, &format!("{red}/Shg Do")); + check(&shaded, 120, 100, [128, 128, 255], "the shading inside its box"); + check(&shaded, 155, 100, [255, 128, 128], "red outside the shading's box"); + + // An image that isn't decoded (here over the renderer's pixel limit) draws nothing, so it + // doesn't clear what is under its rectangle either. + let undecoded = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Big Do Q")); + check(&undecoded, 100, 100, [255, 128, 128], "red under an image that isn't drawn"); + // A tiny image still knocks out in a final render. + let tiny = render(ko, &format!("{red}q 3 0 0 3 100 100 cm /Img Do Q")); + check(&tiny, 101, 101, [128, 128, 255], "a tiny image"); +} diff --git a/crates/pdf/tests/link_annotations.rs b/crates/pdf/tests/link_annotations.rs new file mode 100644 index 00000000..e637b3c7 --- /dev/null +++ b/crates/pdf/tests/link_annotations.rs @@ -0,0 +1,66 @@ +//! Link annotations, destinations and page boxes whose geometry has no finite place on the +//! page make `Writer::finish` return an error instead of panicking (krilla patch 13). +#![cfg(feature = "write")] +#![allow(clippy::unwrap_used, clippy::panic)] + +use backend::action::LinkAction; +use backend::annotation::{LinkAnnotation, Target}; +use backend::destination::XyzDestination; +use backend::error::KrillaError; +use backend::geom::{Point, Quadrilateral, Rect}; +use backend::page::PageSettings; +use craft_pdf::write::{Metadata, WriteError, Writer, backend}; + +fn link() -> Target { + Target::Action(LinkAction::new("https://example.com".into()).into()) +} + +fn quad(x: f32, y: f32) -> Quadrilateral { + Quadrilateral([Point::from_xy(x, y); 4]) +} + +fn write(settings: PageSettings, annotation: Option) -> Result, KrillaError> { + let mut writer = Writer::new(Default::default(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(settings); + if let Some(annotation) = annotation { + page.add_annotation(annotation.into()); + } + page.finish(); + match writer.finish() { + Ok(pdf) => Ok(pdf), + Err(WriteError::Serialization(error)) => Err(*error), + Err(error) => panic!("unexpected error: {error}"), + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn unplaceable_geometry_is_an_error() { + let page = || PageSettings::from_wh(40.0, 20.0).unwrap(); + let big_page = || PageSettings::from_wh(40.0, 2e38).unwrap(); + + let ok = write(page(), Some(LinkAnnotation::new_with_quad_points(vec![quad(1.0, 2.0)], link()))); + assert!(ok.is_ok(), "{ok:?}"); + + let quads = [vec![], vec![quad(f32::NAN, f32::NAN)], vec![quad(1.0, 2.0), quad(f32::INFINITY, 2.0)], vec![quad(-3e38, 0.0), quad(3e38, 0.0)]]; + for (case, quads) in quads.into_iter().enumerate() { + let result = write(page(), Some(LinkAnnotation::new_with_quad_points(quads, link()))); + assert!(matches!(result, Err(KrillaError::InvalidAnnotationGeometry(None))), "case {case}: {result:?}"); + } + + // Finite, but 2e38 + 2e38 overflows when flipped into PDF coordinates. + let far = Rect::from_ltrb(0.0, -2e38, 10.0, -1e38).unwrap(); + let result = write(big_page(), Some(LinkAnnotation::new(far, link()))); + assert!(matches!(result, Err(KrillaError::InvalidAnnotationGeometry(None))), "{result:?}"); + + let result = write(big_page().with_crop_box(Some(far)), None); + assert!(matches!(result, Err(KrillaError::InvalidPageBox(0))), "{result:?}"); + + let missing_page = Target::Destination(XyzDestination::new(5, Point::from_xy(1.0, 1.0)).into()); + let result = write(page(), Some(LinkAnnotation::new(Rect::from_xywh(0.0, 0.0, 5.0, 5.0).unwrap(), missing_page))); + assert!(matches!(result, Err(KrillaError::InvalidDestination(5))), "{result:?}"); + + let far_point = Target::Destination(XyzDestination::new(0, Point::from_xy(1.0, -3e38)).into()); + let result = write(big_page(), Some(LinkAnnotation::new(Rect::from_xywh(0.0, 0.0, 5.0, 5.0).unwrap(), far_point))); + assert!(matches!(result, Err(KrillaError::InvalidDestination(0))), "{result:?}"); +} diff --git a/crates/pdf/tests/optional_content.rs b/crates/pdf/tests/optional_content.rs new file mode 100644 index 00000000..38b77334 --- /dev/null +++ b/crates/pdf/tests/optional_content.rs @@ -0,0 +1,287 @@ +//! Optional content (layers): `Document::add_optional_content_group`, +//! `Surface::start_optional_content` and `Surface::end_optional_content`, read back with the +//! shared reader. +#![cfg(feature = "write")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::BTreeMap; +use std::sync::Arc; + +use backend::SerializeSettings; +use backend::configure::{Archival, PdfVersion, ValidationError}; +use backend::error::KrillaError; +use backend::geom::{PathBuilder, Rect, Size}; +use backend::optional_content::OcgId; +use backend::page::PageSettings; +use backend::paint::Fill; +use backend::surface::Surface; +use backend::tagging::ContentTag; +use craft_pdf::write::{self, Metadata, WriteError, Writer, backend}; +use hayro_syntax::object::{Array, Dict, Name, Stream, String as PdfString}; + +const NOTES: &str = "注記"; + +fn rect(surface: &mut Surface<'_>, x: f32) { + let mut path = PathBuilder::new(); + path.push_rect(Rect::from_xywh(x, 10.0, 20.0, 20.0).unwrap()); + surface.set_fill(Some(Fill::default())); + surface.draw_path(&path.finish().unwrap()); +} + +fn page_settings() -> PageSettings { + PageSettings::new(Size::from_wh(200.0, 60.0).unwrap()) +} + +/// Two pages and three groups: "Walls" (shown), the hidden notes layer and "Unused" (shown, +/// on no page). Page 1 marks one rect per layer, plus a mark inside an isolated group. Page 2 +/// nests the notes inside the walls and a tagged section inside the notes. +fn layered(settings: SerializeSettings, page_mode: bool) -> Result, WriteError> { + let mut writer = Writer::new(settings, Metadata::default()); + let document = writer.document_mut(); + let walls = document.add_optional_content_group("Walls", true); + let notes = document.add_optional_content_group(NOTES, false); + document.add_optional_content_group("Unused", true); + document.set_optional_content_page_mode(page_mode); + { + let mut page = document.start_page_with(page_settings()); + let mut surface = page.surface(); + surface.start_optional_content(walls).unwrap(); + rect(&mut surface, 0.0); + surface.end_optional_content().unwrap(); + surface.start_optional_content(notes).unwrap(); + rect(&mut surface, 30.0); + surface.end_optional_content().unwrap(); + surface.push_isolated(); + surface.start_optional_content(notes).unwrap(); + rect(&mut surface, 60.0); + surface.end_optional_content().unwrap(); + surface.pop(); + surface.finish(); + page.finish(); + } + { + let mut page = document.start_page_with(page_settings()); + let mut surface = page.surface(); + surface.start_optional_content(walls).unwrap(); + rect(&mut surface, 0.0); + surface.start_optional_content(notes).unwrap(); + surface.start_tagged(ContentTag::Other); + rect(&mut surface, 30.0); + surface.end_tagged(); + surface.end_optional_content().unwrap(); + surface.end_optional_content().unwrap(); + surface.finish(); + page.finish(); + } + writer.finish() +} + +fn text(string: &PdfString<'_>) -> String { + let bytes = string.as_bytes(); + match bytes.strip_prefix(&[0xFE, 0xFF]) { + Some(utf16) => String::from_utf16(&utf16.chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect::>()).unwrap(), + None => String::from_utf8(bytes.to_vec()).unwrap(), + } +} + +fn ocg_name(ocg: &Dict<'_>) -> String { + assert_eq!(ocg.get::>(b"Type".as_slice()).unwrap().as_str(), "OCG"); + text(&ocg.get::>(b"Name".as_slice()).unwrap()) +} + +fn names(array: &Array<'_>) -> Vec { + array.iter::>().map(|ocg| ocg_name(&ocg)).collect() +} + +fn open(bytes: &[u8]) -> hayro_syntax::Pdf { + hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap() +} + +fn root(pdf: &hayro_syntax::Pdf) -> Dict<'_> { + pdf.xref().get::>(pdf.xref().root_id()).unwrap() +} + +/// The `/OC /name BDC` names in a content stream, after checking that every `BDC` has its +/// `EMC` and that nothing ends before it starts. +fn oc_marks(content: &[u8]) -> Vec { + // pdf-writer omits the space before a name and around dictionaries. + let content = String::from_utf8_lossy(content).replace('/', " /").replace("<<", " << ").replace(">>", " >> "); + let tokens: Vec<&str> = content.split_whitespace().collect(); + let mut depth = 0usize; + let mut marks = Vec::new(); + for (i, token) in tokens.iter().enumerate() { + match *token { + "BDC" => { + depth += 1; + if i >= 2 && tokens[i - 2] == "/OC" { + marks.push(tokens[i - 1].trim_start_matches('/').to_owned()); + } + } + "EMC" => depth = depth.checked_sub(1).expect("EMC without BDC"), + _ => {} + } + } + assert_eq!(depth, 0, "unbalanced marked content"); + marks +} + +/// Each `/Properties` entry of a resource dictionary, as resource name to group name. +fn properties(resources: &Dict<'_>) -> BTreeMap { + resources.keys().map(|key| (key.as_str().to_owned(), ocg_name(&resources.get::>(key.as_ref()).unwrap()))).collect() +} + +/// Per page: the layer of each optional content mark, in content order. +fn page_layers(bytes: &[u8]) -> Vec> { + let pdf = open(bytes); + pdf.pages() + .iter() + .map(|page| { + let map = properties(&page.resources().properties); + oc_marks(page.page_stream().unwrap()) + .iter() + .map(|mark| map.get(mark).unwrap_or_else(|| panic!("/{mark} is not in /Properties")).clone()) + .collect() + }) + .collect() +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn groups_are_listed_in_the_catalog_with_hidden_ones_off() { + let bytes = layered(SerializeSettings::default(), false).unwrap(); + let pdf = open(&bytes); + let root = root(&pdf); + let properties = root.get::>(b"OCProperties".as_slice()).unwrap(); + let all = ["Walls", NOTES, "Unused"].map(str::to_owned).to_vec(); + assert_eq!(names(&properties.get::>(b"OCGs".as_slice()).unwrap()), all); + let config = properties.get::>(b"D".as_slice()).unwrap(); + assert_eq!(text(&config.get::>(b"Name".as_slice()).unwrap()), "Layers"); + assert_eq!(names(&config.get::>(b"Order".as_slice()).unwrap()), all); + assert_eq!(names(&config.get::>(b"OFF".as_slice()).unwrap()), vec![NOTES.to_owned()]); + assert!(root.get::>(b"PageMode".as_slice()).is_none()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn page_mode_use_oc_is_opt_in() { + let bytes = layered(SerializeSettings::default(), true).unwrap(); + let pdf = open(&bytes); + assert_eq!(root(&pdf).get::>(b"PageMode".as_slice()).unwrap().as_str(), "UseOC"); + + // Without groups there is nothing to show. + let mut writer = Writer::new(SerializeSettings::default(), Metadata::default()); + writer.document_mut().set_optional_content_page_mode(true); + let bytes = writer.finish().unwrap(); + let pdf = open(&bytes); + assert!(root(&pdf).get::>(b"PageMode".as_slice()).is_none()); + assert!(root(&pdf).get::>(b"OCProperties".as_slice()).is_none()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn marks_name_groups_in_their_own_stream_resources() { + let bytes = layered(SerializeSettings::default(), false).unwrap(); + assert_eq!(page_layers(&bytes), vec![vec!["Walls".to_owned(), NOTES.to_owned()], vec!["Walls".to_owned(), NOTES.to_owned()]]); + + // Page 2 holds a tagged section inside the nested marks: three BDCs, balanced. + let pdf = open(&bytes); + let page = &pdf.pages()[1]; + let content = String::from_utf8_lossy(page.page_stream().unwrap()).into_owned(); + assert_eq!(content.matches("BDC").count(), 3); + + // The isolated group on page 1 is a form XObject with its own `/Properties`. + let page = &pdf.pages()[0]; + let x_objects = &page.resources().x_objects; + let forms: Vec> = x_objects.keys().filter_map(|key| x_objects.get::>(key.as_ref())).collect(); + assert_eq!(forms.len(), 1); + let resources = forms[0].dict().get::>(b"Resources".as_slice()).unwrap(); + let map = properties(&resources.get::>(b"Properties".as_slice()).unwrap()); + let decoded = forms[0].decoded().unwrap(); + let layers: Vec = oc_marks(&decoded).iter().map(|mark| map[mark].clone()).collect(); + assert_eq!(layers, vec![NOTES.to_owned()]); +} + +fn validation_errors(version: PdfVersion, archival: Option) -> Vec { + let settings = SerializeSettings { configuration: write::configuration(version, archival).unwrap(), ..Default::default() }; + match layered(settings, false) { + Ok(_) => Vec::new(), + Err(WriteError::Serialization(error)) => match *error { + KrillaError::Validation(errors) => errors.into_iter().map(|(error, _)| error).collect(), + other => panic!("expected validation errors, got {other:?}"), + }, + Err(other) => panic!("expected validation errors, got {other:?}"), + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn optional_content_is_a_pdf_a1_error() { + let is_oc = |error: &ValidationError| matches!(error, ValidationError::OptionalContent(_)); + // The metadata is missing, so every archival export fails; only PDF/A-1 names layers. + assert!(validation_errors(PdfVersion::Pdf14, Some(Archival::A1_B)).iter().any(is_oc)); + assert!(!validation_errors(PdfVersion::Pdf17, Some(Archival::A2_B)).iter().any(is_oc)); + assert!(validation_errors(PdfVersion::Pdf14, None).is_empty()); +} + +fn one_page(draw: impl FnOnce(&mut Surface<'_>, OcgId)) -> Result, WriteError> { + let mut writer = Writer::new(SerializeSettings::default(), Metadata::default()); + let layer = writer.document_mut().add_optional_content_group("Layer", true); + { + let mut page = writer.document_mut().start_page_with(page_settings()); + let mut surface = page.surface(); + draw(&mut surface, layer); + surface.finish(); + page.finish(); + } + writer.finish() +} + +fn serialization_error(result: Result, WriteError>) -> KrillaError { + match result { + Err(WriteError::Serialization(error)) => *error, + other => panic!("expected a serialization error, got {:?}", other.map(|bytes| bytes.len())), + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn misuse_returns_errors_instead_of_panicking() { + // An end without a start, and an end that would close a tagged section started inside + // the mark, are refused and write nothing; the document stays valid. + let bytes = one_page(|surface, layer| { + assert!(matches!(surface.end_optional_content(), Err(KrillaError::UnbalancedOptionalContent(_)))); + surface.start_optional_content(layer).unwrap(); + surface.start_tagged(ContentTag::Other); + assert!(matches!(surface.end_optional_content(), Err(KrillaError::UnbalancedOptionalContent(_)))); + surface.end_tagged(); + surface.end_optional_content().unwrap(); + }) + .unwrap(); + assert_eq!(page_layers(&bytes), vec![vec!["Layer".to_owned()]]); + + // A group of another document. + let mut other = backend::Document::new(); + let foreign = (0..4).map(|i| other.add_optional_content_group(format!("{i}"), true)).last().unwrap(); + one_page(|surface, _| { + assert!(matches!(surface.start_optional_content(foreign), Err(KrillaError::UnknownOptionalContentGroup(_)))); + }) + .unwrap(); + + // Marks left open when the page or a group finishes, and a tagged section that ends + // inside a mark started within it, fail the document. + let unbalanced = |draw: fn(&mut Surface<'_>, OcgId)| { + assert!(matches!(serialization_error(one_page(draw)), KrillaError::UnbalancedOptionalContent(_))); + }; + unbalanced(|surface, layer| surface.start_optional_content(layer).unwrap()); + unbalanced(|surface, layer| { + surface.push_isolated(); + surface.start_optional_content(layer).unwrap(); + surface.pop(); + }); + unbalanced(|surface, layer| { + surface.start_tagged(ContentTag::Other); + surface.start_optional_content(layer).unwrap(); + surface.end_tagged(); + assert!(surface.end_optional_content().is_err()); + }); +} diff --git a/crates/pdf/tests/plates.rs b/crates/pdf/tests/plates.rs new file mode 100644 index 00000000..41e6d653 --- /dev/null +++ b/crates/pdf/tests/plates.rs @@ -0,0 +1,539 @@ +//! Plate mode through the raster path: each ink's separation drawn as a gray film. +//! Adapted from PdfCraft #2770 by Guillem Hermida (`separations::tests` in pdfcraft-render at +//! 6fcf2a4aca29e943e28cb69017ff8f87d00656c6, MIT OR Apache-2.0). +#![cfg(feature = "raster")] + +#[path = "support/synthetic_icc.rs"] +mod synthetic_icc; + +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::interpret::color::{CmykConversion, CmykSimulation, Ink, InkSinkFn, NoSimulation, PCS_WHITE, Plate}; +use craft_pdf::{ + open_slice, + raster::{self, Options, RenderCache}, +}; +use std::sync::{Arc, Mutex}; + +/// A 40 × 40 pt page with `content` and `resources`, and `objects` numbered from 5 (each a +/// whole object body: a dictionary, or a dictionary and its stream). +fn page_pdf(content: &str, resources: &str, objects: &[Vec]) -> 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 40 40] /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 ", i + 5).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b" endobj\n"); + } + out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + out +} + +/// A stream object body with `dict` entries and `data`. +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +fn render(pdf: &[u8], settings: &InterpreterSettings) -> Vec { + render_each(pdf, std::slice::from_ref(settings)).remove(0) +} + +/// The page rendered once per settings, sharing one render cache as a separating caller does. +fn render_each(pdf: &[u8], settings: &[InterpreterSettings]) -> Vec> { + let doc = open_slice(pdf, None).unwrap(); + let cache = RenderCache::new(); + let options = Options { background: [255; 4], ..Default::default() }; + settings + .iter() + .map(|settings| { + let raster = raster::render(&doc.pages()[0], &cache, settings, &options).unwrap(); + assert_eq!((raster.width, raster.height), (40, 40)); + raster.bytes().to_vec() + }) + .collect() +} + +/// One film per ink, as RGBA8 over white. +struct Plates(Vec>); + +impl Plates { + /// Each ink's tint at device pixel (x, y), in whole percent. + fn at(&self, x: usize, y: usize) -> Vec { + self.0 + .iter() + .map(|film| { + let px = &film[(y * 40 + x) * 4..][..4]; + assert_eq!((px[0], px[3]), (px[2], 255), "a film is opaque gray"); + (f32::from(255 - px[0]) * 100.0 / 255.0).round() as u32 + }) + .collect() + } +} + +fn plates(pdf: &[u8], inks: &[Ink], overprint: bool, simulation: Option<&CmykSimulation>) -> Plates { + let settings: Vec = inks + .iter() + .map(|ink| InterpreterSettings { + plate: Some(Plate { ink: ink.clone(), overprint, simulation: simulation.cloned(), spots_as_process: false }), + ..Default::default() + }) + .collect(); + Plates(render_each(pdf, &settings)) +} + +fn spot(name: &str) -> Ink { + Ink::Spot(name.into()) +} + +/// The four process plates, then `spots`. +fn inks(spots: &[&str]) -> Vec { + Ink::PROCESS.into_iter().chain(spots.iter().map(|s| spot(s))).collect() +} + +/// The process plates and Gold, overprint honored. +fn separate(pdf: &[u8]) -> Plates { + plates(pdf, &inks(&["Gold"]), true, None) +} + +/// A spot color whose alternate is a yellowish CMYK. +const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]"; + +fn gold_resource() -> String { + format!("/ColorSpace << /CS0 {GOLD} >>") +} + +const CGATS: &[u8] = include_bytes!("../../../vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc"); + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn device_cmyk_puts_each_component_on_its_own_plate() { + let s = plates(&page_pdf("0.2 0.4 0.6 0.8 k 0 0 40 40 re f", "", &[]), &inks(&[]), true, None); + assert_eq!(s.at(20, 20), [20, 40, 60, 80]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_spot_color_paints_its_own_plate_and_knocks_out_the_process_plates() { + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /CS0 cs 0.5 scn 10 10 20 20 re f", &gold_resource(), &[])); + assert_eq!(s.at(20, 20), [0, 0, 0, 0, 50], "knocked out under the spot color"); + assert_eq!(s.at(2, 2), [0, 0, 0, 100, 0]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn overprinting_keeps_the_plates_a_color_does_not_specify() { + let resources = format!("{} /ExtGState << /GS1 << /op true >> >>", gold_resource()); + let pdf = page_pdf("0 0 0 1 k 0 0 40 40 re f /GS1 gs /CS0 cs 0.5 scn 10 10 20 20 re f", &resources, &[]); + assert_eq!(separate(&pdf).at(20, 20), [0, 0, 0, 100, 50], "overprinted"); + assert_eq!(plates(&pdf, &inks(&["Gold"]), false, None).at(20, 20), [0, 0, 0, 0, 50], "overprint simulation off: knocked out"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn op_sets_stroke_overprint_and_fill_overprint_too_without_op() { + let resources = format!("{} /ExtGState << /GS1 << /OP true >> /GS2 << /OP true /op false >> >>", gold_resource()); + let fill = |gs: &str| page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f /{gs} gs /CS0 cs 1 scn 10 10 20 20 re f"), &resources, &[]); + assert_eq!(separate(&fill("GS1")).at(20, 20)[3], 100, "OP alone also sets op"); + assert_eq!(separate(&fill("GS2")).at(20, 20)[3], 0, "op false keeps fills knocking out"); + let stroke = page_pdf("0 0 0 1 k 0 0 40 40 re f /GS2 gs /CS0 CS 1 SCN 8 w 0 20 m 40 20 l S", &resources, &[]); + assert_eq!(separate(&stroke).at(20, 20), [0, 0, 0, 100, 100], "strokes overprint with OP"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn nonzero_overprint_mode_leaves_zero_device_cmyk_components() { + let resources = "/ExtGState << /M1 << /OP true /OPM 1 >> /M0 << /OP true /OPM 0 >> >>"; + let pdf = |gs: &str| page_pdf(&format!("1 0 0 0 k 0 0 40 40 re f /{gs} gs 0 1 0 0 k 10 10 20 20 re f"), resources, &[]); + let process = inks(&[]); + assert_eq!(plates(&pdf("M1"), &process, true, None).at(20, 20), [100, 100, 0, 0], "OPM 1: the zero cyan leaves the plate"); + assert_eq!(plates(&pdf("M0"), &process, true, None).at(20, 20), [0, 100, 0, 0], "OPM 0: zero components paint"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn gray_and_rgb_are_process_color_and_never_take_the_nonzero_overprint_mode() { + let process = inks(&[]); + let gray = page_pdf("1 0 0 0 k 0 0 40 40 re f /M1 gs 0.25 g 10 10 20 20 re f", "/ExtGState << /M1 << /OP true /OPM 1 >> >>", &[]); + assert_eq!(plates(&gray, &process, true, None).at(20, 20), [0, 0, 0, 75], "gray is black only, and knocks out cyan"); + let s = plates(&page_pdf("1 0 0 rg 0 0 20 40 re f 0.2 0.2 0.2 rg 20 0 20 40 re f", "", &[]), &process, true, None); + assert_eq!(s.at(10, 20), [0, 100, 100, 0], "red"); + assert_eq!(s.at(30, 20), [0, 0, 0, 80], "a neutral gray goes to black (10.4.2.4)"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn devicen_components_go_to_the_plates_they_name() { + let resources = "/ColorSpace << /CS0 [/DeviceN [/Cyan /Silver /None] /DeviceCMYK 5 0 R] >>"; + let function = stream("/FunctionType 4 /Domain [0 1 0 1 0 1] /Range [0 1 0 1 0 1 0 1]", b"{ pop 0.5 mul 0 0 }"); + let s = plates(&page_pdf("/CS0 cs 0.3 0.7 1 scn 0 0 40 40 re f", resources, &[function]), &inks(&["Silver"]), true, None); + assert_eq!(s.at(20, 20), [30, 0, 0, 0, 70]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn the_all_colorant_paints_every_plate_and_none_paints_nothing() { + let gray = "/DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>"; + let resources = format!("/ColorSpace << /CS0 {GOLD} /Reg [/Separation /All {gray}] /No [/Separation /None {gray}] >>"); + let content = "/CS0 cs 0.2 scn 0 0 40 40 re f /Reg cs 1 scn 0 0 20 40 re f /No cs 1 scn 20 0 20 40 re f"; + let s = separate(&page_pdf(content, &resources, &[])); + assert_eq!(s.at(10, 20), [100, 100, 100, 100, 100], "registration color"); + assert_eq!(s.at(30, 20), [0, 0, 0, 0, 20], "None leaves every plate as it is"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn icc_based_cmyk_is_separated_as_its_components() { + let pdf = page_pdf("/CS0 cs 0.1 0.3 0.5 0.7 scn 0 0 40 40 re f", "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", &[stream("/N 4", CGATS)]); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [10, 30, 50, 70]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn images_are_separated_sample_by_sample() { + let cmyk = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceCMYK", &[255, 0, 0, 0, 0, 0, 0, 128]); + let s = plates(&page_pdf("q 40 0 0 40 0 0 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &[cmyk]), &inks(&[]), true, None); + assert_eq!(s.at(10, 20), [100, 0, 0, 0]); + assert_eq!(s.at(30, 20), [0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_spot_color_image_overprints_like_any_paint() { + let image = stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace {GOLD}"), &[128]); + let resources = "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true >> >>"; + let pdf = |gs: &str| page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} q 20 0 0 20 10 10 cm /Im1 Do Q"), resources, std::slice::from_ref(&image)); + assert_eq!(separate(&pdf("/GS1 gs")).at(20, 20), [0, 0, 0, 100, 50]); + assert_eq!(separate(&pdf("")).at(20, 20), [0, 0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn shadings_are_separated_through_their_color_space() { + let shading = format!( + "<< /ShadingType 2 /ColorSpace {GOLD} /Coords [0 0 40 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> /Extend [true true] >>" + ); + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /Sh1 sh", "/Shading << /Sh1 5 0 R >>", &[shading.into_bytes()])); + let (left, right) = (s.at(4, 20), s.at(36, 20)); + assert!(left[4] < 20 && right[4] > 80, "a gradient on the spot plate: {left:?} {right:?}"); + assert_eq!((left[3], right[3]), (0, 0), "the shading knocks out the black under it"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn an_uncolored_pattern_paints_the_film_of_the_color_it_is_used_with() { + let cell = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", b"0 0 10 10 re f"); + let resources = format!("/ColorSpace << /PCS [/Pattern {GOLD}] >> /Pattern << /P1 5 0 R >>"); + let s = separate(&page_pdf("0 0 0 1 k 0 0 40 40 re f /PCS cs 0.5 /P1 scn 0 0 40 40 re f", &resources, &[cell])); + assert_eq!(s.at(20, 20), [0, 0, 0, 0, 50], "the pattern's spot color, knocking out black"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn soft_masks_keep_the_luminosity_of_their_own_colors() { + // A mask drawn in blue lets about a tenth of the black through. Drawn as the black + // plate's film, blue (no black ink) would read as white and let all of it through. + let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceRGB >>", b"0 0 1 rg 0 0 40 40 re f"); + let resources = "/ExtGState << /SM << /SMask << /S /Luminosity /G 5 0 R >> >> >>"; + let s = plates(&page_pdf("/SM gs 0 0 0 1 k 0 0 40 40 re f", resources, &[group]), &[Ink::Black], true, None); + let black = s.at(20, 20)[0]; + assert!((1..30).contains(&black), "black through a blue luminosity mask: {black}%"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_device_cmyk_luminosity_mask_reads_its_inks_by_the_device_formula() { + // Black ink through a mask whose DeviceCMYK group paints the left half, at (10, 20), and + // leaves the backdrop on the right, at (30, 20). Luminosity is 1 - min(1, 0.3c + 0.59m + + // 0.11y + k) (ISO 32000-2 §10.4.2.2); Gold's alternate is C0 M30 Y100 K0. + let masked = |paint: &str, backdrop: &str| { + let group = stream( + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceCMYK >> /Resources << {} >>", + gold_resource() + ), + format!("{paint} 0 0 20 40 re f").as_bytes(), + ); + let resources = format!("/ExtGState << /SM << /SMask << /S /Luminosity /G 5 0 R {backdrop} >> >> >>"); + let s = plates(&page_pdf("/SM gs 0 0 0 1 k 0 0 40 40 re f", &resources, &[group]), &[Ink::Black], true, None); + [s.at(10, 20)[0], s.at(30, 20)[0]] + }; + assert_eq!(masked("0 0 0 1 k", ""), [0, 0], "K 100% and the default black backdrop hide all of it"); + assert_eq!(masked("0 0 0 0 k", "/BC [0 0 0 0]"), [100, 100], "paper shows all of it"); + assert_eq!(masked("0.5 0 0 0 k", "/BC [0 0 0 0.25]"), [85, 75]); + assert_eq!(masked("/CS0 cs 1 scn", ""), [71, 0], "a spot ink reads as its alternate CMYK"); + assert_eq!(masked("1 0 0 rg", ""), [30, 0], "RGB red is C0 M100 Y100 K0 by the device formulas"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn the_ink_sink_reports_spot_inks_of_patterns_and_images_but_not_of_soft_masks() { + let image = stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace {GOLD}"), &[255]); + let cell = stream( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << /ColorSpace << /S [/Separation /Silver /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0.5] /N 1 >>] >> >>", + b"/S cs 1 scn 0 0 5 5 re f", + ); + let mask_group = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /ColorSpace << /M [/Separation /MaskOnly /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>", + b"/M cs 0.5 scn 0 0 40 40 re f", + ); + let resources = "/XObject << /Im1 5 0 R >> /Pattern << /P1 6 0 R >> /ExtGState << /SM << /SMask << /S /Luminosity /G 7 0 R >> >> >>"; + let content = "/Pattern cs /P1 scn 0 0 20 20 re f q 10 0 0 10 25 25 cm /Im1 Do Q /SM gs 0 g 0 0 40 40 re f"; + // Each spot ink with its appearance at 100%. + type Seen = Vec<(Ink, [u8; 3])>; + let seen: Arc> = Arc::default(); + let log = seen.clone(); + let sink: InkSinkFn = Arc::new(move |ink: &Ink, appearance: &dyn Fn(f32) -> [u8; 3]| { + let mut log = log.lock().unwrap(); + if !log.iter().any(|(seen, _)| seen == ink) { + log.push((ink.clone(), appearance(1.0))); + } + }); + render(&page_pdf(content, resources, &[image, cell, mask_group]), &InterpreterSettings { ink_sink: Some(sink), ..Default::default() }); + let seen = seen.lock().unwrap(); + let names: Vec<&str> = seen.iter().map(|(ink, _)| ink.name()).collect(); + assert_eq!(names, ["Silver", "Gold"], "in the order first painted; a mask's ink doesn't print"); + assert_eq!(seen[0].1, [128, 128, 128], "Silver at 100% is its alternate color"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn malformed_color_spaces_and_overprint_parameters_draw_what_they_can() { + let resources = "/ColorSpace << /A [/DeviceN [] /DeviceGray 0] /B [/Separation] /C [/Indexed /DeviceCMYK 300 <00>] >> /ExtGState << /G << /OP 7 /OPM /X /op (y) >> >>"; + let pdf = page_pdf("/G gs /A cs 1 scn 0 0 9 9 re f /B cs 1 scn /C cs 255 scn 0 0 40 40 re f", resources, &[]); + plates(&pdf, &inks(&["Gold"]), true, None); +} + +// Separating through an output intent's CMYK profile (vendored patch 36). + +const PRESS: &str = "PdfCraft synthetic press"; + +fn press_simulation() -> CmykSimulation { + CmykSimulation::new(&synthetic_icc::cmyk_profile(PRESS, true)).unwrap() +} + +/// What the synthetic press prints for CIE L*a*b*, in whole percent. +fn press(l: f64, a: f64, b: f64) -> Vec { + synthetic_icc::btoa(l, a, b).iter().map(|v| (v * 100.0).round() as u32).collect() +} + +/// Whether every ink is within `tolerance` percent of `expected`. +fn near(got: &[u32], expected: &[u32], tolerance: u32) -> bool { + got.len() == expected.len() && got.iter().zip(expected).all(|(g, e)| g.abs_diff(*e) <= tolerance) +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn rgb_separates_through_the_simulated_cmyk_profile() { + let simulation = press_simulation(); + assert_eq!(simulation.description(), Some(PRESS)); + assert!(!simulation.perceptual()); + let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]); + // The press prints a neutral with equal inks, where the device formulas put it on black + // alone. + let gray = press(synthetic_icc::srgb_gray_lightness(0.5), 0.0, 0.0); + let got = plates(&pdf, &inks(&[]), true, Some(&simulation)).at(20, 20); + assert!(near(&got, &gray, 2), "{got:?}, the press prints {gray:?}"); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(20, 20), [0, 0, 0, 50]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn only_cmyk_profiles_with_pcs_to_device_tables_simulate() { + let whole = synthetic_icc::cmyk_profile(PRESS, true); + assert_eq!(CmykSimulation::new(&synthetic_icc::gray_profile("Gray")).err(), Some(NoSimulation::NotCmyk)); + assert_eq!(CmykSimulation::new(&synthetic_icc::cmyk_profile("Proofing only", false)).err(), Some(NoSimulation::NoPcsToDevice)); + assert_eq!(CmykSimulation::new(b"not a profile").err(), Some(NoSimulation::Unreadable)); + assert_eq!(CmykSimulation::new(&whole[..300]).err(), Some(NoSimulation::Unreadable)); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn cmyk_in_the_simulated_profile_passes_through_and_other_cmyk_converts() { + let profile = synthetic_icc::cmyk_profile(PRESS, true); + let simulation = CmykSimulation::new(&profile).unwrap(); + // Object 5 is the press profile, 6 another CMYK profile. + let resources = "/ColorSpace << /Same [/ICCBased 5 0 R] /Other [/ICCBased 6 0 R] >>"; + let content = "0.2 0.4 0.6 0.8 k 0 0 10 40 re f /Same cs 0.1 0.3 0.5 0.7 scn 10 0 10 40 re f /Other cs 0 0 0 1 scn 30 0 10 40 re f"; + let pdf = page_pdf(content, resources, &[stream("/N 4", &profile), stream("/N 4", CGATS)]); + let s = plates(&pdf, &inks(&[]), true, Some(&simulation)); + assert_eq!(s.at(5, 20), [20, 40, 60, 80], "DeviceCMYK is the press's own CMYK"); + assert_eq!(s.at(15, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile"); + let other = s.at(35, 20); + assert!(other.iter().all(|v| *v > 70), "black alone in another profile is a four-ink black on this press: {other:?}"); + assert_eq!(plates(&pdf, &inks(&[]), true, None).at(35, 20), [0, 0, 0, 100], "without a simulation ICC CMYK is taken as it is"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn device_gray_stays_on_the_black_plate_under_a_simulation() { + let s = plates(&page_pdf("0.25 g 0 0 40 40 re f", "", &[]), &inks(&[]), true, Some(&press_simulation())); + assert_eq!(s.at(20, 20), [0, 0, 0, 75]); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn cie_and_icc_colors_convert_through_the_profile_relative_to_their_white() { + let resources = + "/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Gray [/ICCBased 5 0 R] >>"; + let content = "/D50 cs 50 20 -10 scn 0 0 10 40 re f /D65 cs 100 0 0 scn 10 0 10 40 re f /Gray cs 0.5 scn 20 0 10 40 re f"; + let pdf = page_pdf(content, resources, &[stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2"))]); + let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation())); + assert!(near(&s.at(5, 20), &press(50.0, 20.0, -10.0), 2), "Lab: {:?}", s.at(5, 20)); + assert_eq!(s.at(15, 20), [0, 0, 0, 0], "a Lab space's own white prints as paper, whatever its white point"); + let lightness = 116.0 * 0.5_f64.powf(2.2).cbrt() - 16.0; + assert!(near(&s.at(25, 20), &press(lightness, 0.0, 0.0), 2), "ICC gray: {:?}", s.at(25, 20)); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn images_convert_through_the_simulation_sample_by_sample() { + let rgb = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceRGB", &[128, 128, 128, 255, 255, 255]); + // More samples than palette entries: the palette is separated once. + let indexed = stream( + "/Type /XObject /Subtype /Image /Width 4 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 <808080FFFFFF>]", + &[0, 0, 1, 1], + ); + let content = "q 40 0 0 20 0 20 cm /Im1 Do Q q 40 0 0 20 0 0 cm /Im2 Do Q"; + let pdf = page_pdf(content, "/XObject << /Im1 5 0 R /Im2 6 0 R >>", &[rgb, indexed]); + let s = plates(&pdf, &inks(&[]), true, Some(&press_simulation())); + let gray = press(synthetic_icc::srgb_gray_lightness(128.0 / 255.0), 0.0, 0.0); + for y in [10, 30] { + assert!(near(&s.at(10, y), &gray, 2), "y = {y}: {:?}, the press prints {gray:?}", s.at(10, y)); + assert_eq!(s.at(30, y), [0, 0, 0, 0], "y = {y}: white prints no ink"); + } +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_simulation_shows_its_cmyk_through_its_own_profile() { + // The inks the press prints sRGB gray 128 with show as that gray: its two tables invert + // each other. + let ink = (255.0 * (1.0 - synthetic_icc::srgb_gray_lightness(128.0 / 255.0) / 100.0)).round() as u8; + let mut shown = [0; 3]; + press_simulation().cmyk_to_srgb8(&[ink; 4], &mut shown).unwrap(); + assert!(shown.iter().all(|c| c.abs_diff(128) <= 4), "{shown:?}"); + assert!(press_simulation().cmyk_to_srgb8(&[0; 4], &mut [0; 4]).is_none(), "lengths disagree"); +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn mutated_profiles_simulate_or_are_refused_without_panicking() { + // The header fields a reader checks, every 16th byte of the tag table and bytes spread + // over the tag data, changed in turn. Making a simulation takes a fifth of a second in a + // debug build, so header bytes readers skip (date, creator, profile ID, reserved) are left + // out and the data is sampled. + let whole = synthetic_icc::cmyk_profile(PRESS, true); + let pdf = page_pdf("0.5 0.5 0.5 rg 0 0 40 40 re f", "", &[]); + // Size, version, class, color space, PCS, `acsp`, rendering intent, illuminant. + let header = [3, 8, 12, 16, 20, 36, 67, 70]; + let positions = header.into_iter().chain((128..256).step_by(16)).chain((256..whole.len()).step_by(15_000)); + for (n, at_byte) in positions.enumerate() { + let mut bent = whole.clone(); + bent[at_byte] = [0x00, 0x7F, 0xFF][n % 3]; + if let Ok(simulation) = CmykSimulation::new(&bent) { + let plate = Plate { ink: Ink::Cyan, overprint: true, simulation: Some(simulation), spots_as_process: false }; + render(&pdf, &InterpreterSettings { plate: Some(plate), ..Default::default() }); + } + } +} + +/// A caller's conversion: sRGB prints as 10/20/30/40%, XYZ as 50/60/70/80%, and the XYZ it is +/// given is kept. +struct Fixed(Arc>>); + +impl CmykConversion for Fixed { + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> { + (rgb.len() / 3 == cmyk.len() / 4).then(|| cmyk.as_chunks_mut::<4>().0.fill([0.1, 0.2, 0.3, 0.4])) + } + + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> { + self.0.lock().unwrap().extend(xyz.as_chunks::<3>().0.iter().copied()); + cmyk.as_chunks_mut::<4>().0.fill([0.5, 0.6, 0.7, 0.8]); + Some(()) + } + + fn description(&self) -> Option<&str> { + Some("fixed") + } +} + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_callers_cmyk_conversion_separates_process_color() { + let profile = synthetic_icc::cmyk_profile(PRESS, true); + let xyz = Arc::default(); + let simulation = CmykSimulation::with_conversion(&profile, Fixed(Arc::clone(&xyz))); + assert_eq!(simulation.description(), Some("fixed")); + assert!(simulation.cmyk_to_srgb8(&[0; 4], &mut [0; 3]).is_none(), "no way to show CMYK"); + let resources = "/ColorSpace << /Lab [/Lab << /WhitePoint [0.9505 1 1.089] >>] /Same [/ICCBased 5 0 R] >>"; + let content = "1 0 0 rg 0 0 10 40 re f /Lab cs 100 0 0 scn 10 0 10 40 re f 0.2 0.4 0.6 0.8 k 20 0 10 40 re f \ + /Same cs 0.1 0.3 0.5 0.7 scn 30 0 10 40 re f"; + let s = plates(&page_pdf(content, resources, &[stream("/N 4", &profile)]), &inks(&[]), true, Some(&simulation)); + assert_eq!(s.at(5, 20), [10, 20, 30, 40], "DeviceRGB through from_srgb"); + assert_eq!(s.at(15, 20), [50, 60, 70, 80], "Lab through from_pcs_xyz"); + assert_eq!(s.at(25, 20), [20, 40, 60, 80], "DeviceCMYK as it is"); + assert_eq!(s.at(35, 20), [10, 30, 50, 70], "ICC CMYK in the simulated profile as it is"); + let xyz = xyz.lock().unwrap(); + assert!(!xyz.is_empty()); + for c in xyz.iter() { + assert!(c.iter().zip(PCS_WHITE).all(|(v, w)| (v - w).abs() < 1e-3), "a Lab white arrives as the PCS white: {c:?}"); + } +} + +/// A knockout group (`/K true`, pdfcraft #2974) of two process squares at 50 % opacity separates +/// with only the top square where they overlap: its plates replace the bottom square's there. +/// Without `/K` the two blend on every plate. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn a_knockout_group_separates_with_only_the_top_object_in_the_overlap() { + let group = |entries: &str| { + let form = "/A gs 1 0 0 0 k 0 0 25 25 re f 0 1 0 0 k 15 15 25 25 re f"; + page_pdf( + "/Fm Do", + "/XObject << /Fm 5 0 R >>", + &[stream( + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency {entries} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >>" + ), + form.as_bytes(), + )], + ) + }; + // Device rows run top down: the overlap is at (20, 20), cyan alone at (5, 35), magenta alone + // at (35, 5). + let knockout = plates(&group("/K true"), &inks(&[]), true, None); + assert_eq!(knockout.at(20, 20), [0, 50, 0, 0], "only the magenta square in the overlap"); + assert_eq!(knockout.at(5, 35), [50, 0, 0, 0]); + assert_eq!(knockout.at(35, 5), [0, 50, 0, 0]); + let blended = plates(&group(""), &inks(&[]), true, None); + assert_eq!(blended.at(20, 20), [25, 50, 0, 0], "both squares without /K"); +} + +/// PdfCraft #3018: `/DefaultCMYK` and `/DefaultGray` change how device color looks, not the +/// inks it prints with. Under them DeviceCMYK still separates as its components (and keeps the +/// nonzero overprint mode), a DeviceCMYK image sample by sample, and DeviceGray on black alone, +/// with or without a simulation whose profile differs from the default. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn default_color_spaces_leave_device_color_on_its_plates() { + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceCMYK", &[51, 102, 153, 204]); + let objects = [stream("/N 4", CGATS), stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2")), image]; + let content = "0 0 0 1 k 0 0 10 40 re f 1 0 0 0 k 10 0 10 40 re f /M1 gs 0 1 0 0 k 10 0 10 40 re f \ + q 10 0 0 40 20 0 cm /Im1 Do Q 0.25 g 30 0 10 40 re f"; + let resources = "/ExtGState << /M1 << /OP true /OPM 1 >> >> /XObject << /Im1 7 0 R >>"; + let calibrated = format!("{resources} /ColorSpace << /DefaultCMYK [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] >>"); + let expected: [[u32; 4]; 4] = [[0, 0, 0, 100], [100, 100, 0, 0], [20, 40, 60, 80], [0, 0, 0, 75]]; + for simulation in [None, Some(press_simulation())] { + let s = plates(&page_pdf(content, &calibrated, &objects), &inks(&[]), true, simulation.as_ref()); + for (x, want) in [5, 15, 25, 35].into_iter().zip(expected) { + assert_eq!(s.at(x, 20), want, "x {x}, simulated: {}", simulation.is_some()); + } + } +} diff --git a/crates/pdf/tests/print_color.rs b/crates/pdf/tests/print_color.rs new file mode 100644 index 00000000..c1f2ccc9 --- /dev/null +++ b/crates/pdf/tests/print_color.rs @@ -0,0 +1,1186 @@ +//! Print color: the blending color space of transparency groups +//! (`SerializeSettings::group_color_space`), overprint (`Surface::push_overprint`), knockout +//! groups (`Surface::push_group`), soft-mask backdrop and transfer function, DeviceN colors, +//! images and shadings, and the print output intent with the PDF/X markers. +#![cfg(feature = "write")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use backend::blend::{GroupOptions, Overprint}; +use backend::color::device_n::{self, Colorant, DeviceNSpace, Process}; +use backend::color::{cmyk, luma}; +use backend::configure::{Archival, PdfVersion, ValidationError}; +use backend::geom::{PathBuilder, Rect, Size, Transform}; +use backend::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use backend::mask::{Mask, MaskType, TransferFunction}; +use backend::num::NormalizedF32; +use backend::page::PageSettings; +use backend::paint::{Fill, LinearGradient, MeshGradient, MeshPatch, SpreadMethod, Stop, SweepGradient}; +use backend::surface::Surface; +use backend::{GroupColorSpace, SerializeSettings, Trapped}; +use craft_pdf::write::print::{self, PrintColor}; +use craft_pdf::write::{Metadata, WriteError, Writer, backend}; +use hayro_syntax::object::{Array, Dict, Name, Object, Stream}; + +const CMYK_PROFILE: &[u8] = include_bytes!("../../cms/profiles/photocraft-coated-cmyk.icc"); + +fn rect(surface: &mut Surface<'_>, x: f32, color: cmyk::Color, opacity: f32) { + let mut path = PathBuilder::new(); + path.push_rect(Rect::from_xywh(x, 10.0, 40.0, 40.0).unwrap()); + surface.set_fill(Some(Fill { paint: color.into(), opacity: NormalizedF32::new(opacity).unwrap(), ..Default::default() })); + surface.draw_path(&path.finish().unwrap()); +} + +fn k(value: u8) -> cmyk::Color { + cmyk::Color::new(0, 0, 0, value) +} + +/// Page 1: opacity group, isolated multiply group, soft mask, fill opacity and (with +/// `write-pdf`) a placed page. Page 2: opaque. Page 3: only the fill opacity of page 1, whose +/// graphics state is already written. +fn sample(settings: SerializeSettings, placed: Option<&[u8]>) -> Result, WriteError> { + let mut writer = Writer::new(settings, Metadata::default()); + let page_settings = PageSettings::new(Size::from_wh(300.0, 60.0).unwrap()); + { + let mut page = writer.document_mut().start_page_with(page_settings.clone()); + let mut surface = page.surface(); + rect(&mut surface, 0.0, cmyk::Color::new(255, 0, 0, 0), 1.0); + surface.push_opacity(NormalizedF32::new(0.5).unwrap()); + rect(&mut surface, 20.0, k(255), 1.0); + surface.pop(); + surface.push_isolated(); + surface.push_blend_mode(backend::blend::BlendMode::Multiply); + rect(&mut surface, 60.0, cmyk::Color::new(0, 255, 0, 0), 1.0); + surface.pop(); + surface.pop(); + let mut builder = surface.stream_builder(); + let mut mask_surface = builder.surface(); + let mut path = PathBuilder::new(); + path.push_rect(Rect::from_xywh(110.0, 10.0, 20.0, 40.0).unwrap()); + mask_surface.set_fill(Some(Fill { paint: luma::Color::white().into(), ..Default::default() })); + mask_surface.draw_path(&path.finish().unwrap()); + mask_surface.finish(); + let stream = builder.finish(); + surface.push_mask(Mask::new(stream, MaskType::Luminosity)); + rect(&mut surface, 100.0, k(255), 1.0); + surface.pop(); + rect(&mut surface, 150.0, k(255), 0.5); + #[cfg(feature = "write-pdf")] + if let Some(placed) = placed { + let pdf = backend::pdf::Pdf::new(Arc::new(placed.to_vec())).unwrap(); + let doc = backend::pdf::PdfDocument::new(Arc::new(pdf)); + surface.draw_pdf_page(&doc, Size::from_wh(60.0, 12.0).unwrap(), 0); + } + #[cfg(not(feature = "write-pdf"))] + let _ = placed; + surface.finish(); + page.finish(); + } + for page_index in [2, 3] { + let mut page = writer.document_mut().start_page_with(page_settings.clone()); + let mut surface = page.surface(); + rect(&mut surface, 150.0, k(255), if page_index == 3 { 0.5 } else { 1.0 }); + surface.finish(); + page.finish(); + } + writer.finish() +} + +fn cmyk_settings() -> SerializeSettings { + SerializeSettings { group_color_space: GroupColorSpace::Cmyk, ..Default::default() } +} + +/// Every transparency group in the file, as (where, `/CS` family or `None`). +#[derive(Debug, Default)] +struct Groups { + pages: Vec>>, + forms: Vec>, + masks: Vec>, +} + +fn groups(bytes: &[u8]) -> Groups { + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let mut out = Groups::default(); + let mut seen = std::collections::HashSet::new(); + for page in pdf.pages().iter() { + out.pages.push(page.raw().get::>(b"Group".as_slice()).map(|g| cs(&g))); + visit(&page.resources().x_objects, &page.resources().ext_g_states, &mut out, &mut seen); + } + out +} + +fn visit(x_objects: &Dict<'_>, states: &Dict<'_>, out: &mut Groups, seen: &mut std::collections::HashSet) { + let mut forms = Vec::new(); + for key in x_objects.keys() { + if let Some(x) = x_objects.get::>(key.as_ref()) { + forms.push((x, false)); + } + } + for key in states.keys() { + if let Some(mask) = states.get::>(key.as_ref()).and_then(|s| s.get::>(b"SMask".as_slice())) + && let Some(source) = mask.get::>(b"G".as_slice()) + { + forms.push((source, true)); + } + } + for (x, is_mask) in forms { + if !seen.insert(format!("{:?}", x.obj_id())) { + continue; + } + let dict = x.dict(); + if let Some(group) = dict.get::>(b"Group".as_slice()) { + if is_mask { out.masks.push(cs(&group)) } else { out.forms.push(cs(&group)) } + } + 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"), out, seen); + } + } +} + +fn cs(group: &Dict<'_>) -> Option { + assert_eq!(group.get::>(b"S".as_slice()).unwrap().as_str(), "Transparency"); + group.get::>(b"CS".as_slice()).map(|cs| match cs { + Object::Name(name) => name.as_str().to_owned(), + Object::Array(array) => { + let array: Array<'_> = array; + let n = array.iter::>().nth(1).and_then(|o| match o { + Object::Stream(s) => s.dict().get::(b"N".as_slice()), + _ => None, + }); + format!("ICCBased(N={})", n.unwrap_or(0)) + } + _ => "?".to_owned(), + }) +} + +/// A copy of the sample to place on page 1, written with the same settings. +fn placed(settings: &SerializeSettings) -> Option> { + cfg!(feature = "write-pdf").then(|| sample(settings.clone(), None).unwrap()) +} + +/// Opacity, isolated and masked content groups; with `write-pdf` also the placed page's +/// group and the three inside it. +const FORMS: usize = if cfg!(feature = "write-pdf") { 7 } else { 3 }; +const MASKS: usize = if cfg!(feature = "write-pdf") { 2 } else { 1 }; + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn rgb_default_names_rgb_on_every_group_and_writes_no_page_group() { + let settings = SerializeSettings::default(); + assert_eq!(settings.group_color_space, GroupColorSpace::Rgb); + let groups = groups(&sample(settings.clone(), placed(&settings).as_deref()).unwrap()); + assert_eq!(groups.pages, vec![None, None, None]); + assert_eq!(groups.forms, vec![Some("DeviceRGB".to_owned()); FORMS], "{groups:?}"); + assert_eq!(groups.masks, vec![Some("DeviceRGB".to_owned()); MASKS], "{groups:?}"); +} + +/// Content groups and the placed page write no `/CS`; pages that blend (also only through a +/// graphics state already written for page 1) get a DeviceCMYK page group; the soft-mask +/// group names DeviceCMYK; nothing mentions DeviceRGB. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn cmyk_groups_inherit_a_cmyk_page_group_on_pages_that_blend() { + let settings = cmyk_settings(); + let bytes = sample(settings.clone(), placed(&settings).as_deref()).unwrap(); + let groups = groups(&bytes); + let cmyk_group = Some(Some("DeviceCMYK".to_owned())); + assert_eq!(groups.pages, vec![cmyk_group.clone(), None, cmyk_group], "{groups:?}"); + assert_eq!(groups.forms, vec![None; FORMS], "{groups:?}"); + assert_eq!(groups.masks, vec![Some("DeviceCMYK".to_owned()); MASKS], "{groups:?}"); + assert!(!String::from_utf8_lossy(&bytes).contains("DeviceRGB")); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn cmyk_groups_use_the_cmyk_profile_without_device_spaces() { + let settings = + SerializeSettings { no_device_cs: true, cmyk_profile: Some(backend::icc::ICCProfile::<4>::new(CMYK_PROFILE).unwrap()), ..cmyk_settings() }; + let bytes = sample(settings.clone(), placed(&settings).as_deref()).unwrap(); + let groups = groups(&bytes); + let icc = Some(Some("ICCBased(N=4)".to_owned())); + assert_eq!(groups.pages, vec![icc.clone(), None, icc], "{groups:?}"); + assert_eq!(groups.forms, vec![None; FORMS], "{groups:?}"); + assert_eq!(groups.masks, vec![Some("ICCBased(N=1)".to_owned()); MASKS], "{groups:?}"); + assert!(!String::from_utf8_lossy(&bytes).contains("DeviceRGB")); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn cmyk_groups_without_device_spaces_need_a_cmyk_profile() { + let settings = SerializeSettings { no_device_cs: true, ..cmyk_settings() }; + let Err(WriteError::Serialization(error)) = sample(settings, None) else { panic!("expected an error") }; + assert_eq!(*error, backend::error::KrillaError::MissingGroupCmykProfile); +} + +/// Each page graphics state as its overprint and alpha entries, sorted. +fn page_states(bytes: &[u8]) -> Vec { + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let page = pdf.pages().iter().next().unwrap(); + let states = &page.resources().ext_g_states; + let mut out: Vec = states + .keys() + .map(|key| { + let state = states.get::>(key.as_ref()).unwrap(); + let mut entries = Vec::new(); + for name in ["op", "OP"] { + if let Some(value) = state.get::(name.as_bytes()) { + entries.push(format!("{name}={value}")); + } + } + if let Some(mode) = state.get::(b"OPM".as_slice()) { + entries.push(format!("OPM={mode}")); + } + for name in ["ca", "CA"] { + if let Some(value) = state.get::(name.as_bytes()) { + entries.push(format!("{name}={value}")); + } + } + entries.join(" ") + }) + .collect(); + out.sort(); + out +} + +fn overprinted(settings: SerializeSettings, overprint: Overprint) -> Result, WriteError> { + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(300.0, 60.0).unwrap())); + let mut surface = page.surface(); + surface.push_overprint(overprint); + rect(&mut surface, 0.0, k(255), 1.0); + surface.pop(); + surface.finish(); + page.finish(); + writer.finish() +} + +/// Every push writes `/op`, `/OP` and `/OPM`; a fill opacity and an opacity group pushed +/// inside combine with it; an inner push replaces the outer one. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn overprint_writes_op_and_opm_and_combines_with_inner_state() { + let mut writer = Writer::new(SerializeSettings::default(), Metadata::default()); + { + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(300.0, 60.0).unwrap())); + let mut surface = page.surface(); + surface.push_overprint(Overprint { fill: true, stroke: false, nonzero: true }); + rect(&mut surface, 0.0, k(255), 1.0); + rect(&mut surface, 50.0, k(255), 0.5); + surface.push_opacity(NormalizedF32::new(0.25).unwrap()); + rect(&mut surface, 100.0, k(255), 1.0); + surface.pop(); + surface.push_overprint(Overprint { stroke: true, ..Default::default() }); + rect(&mut surface, 150.0, k(255), 1.0); + surface.pop(); + surface.pop(); + surface.finish(); + page.finish(); + } + let bytes = writer.finish().unwrap(); + assert_eq!( + page_states(&bytes), + vec!["op=false OP=true OPM=0", "op=true OP=false OPM=1", "op=true OP=false OPM=1 ca=0.25 CA=0.25", "op=true OP=false OPM=1 ca=0.5",] + ); +} + +/// PDF/A-2 and later forbid OPM 1 with overprint when CMYK is ICCBased, as it always is +/// under PDF/A; OPM 0 is allowed. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn overprint_mode_one_is_a_pdf_a_error() { + let has_opm_error = |nonzero: bool| { + let settings = SerializeSettings { + configuration: craft_pdf::write::configuration(PdfVersion::Pdf17, Some(Archival::A2_B)).unwrap(), + cmyk_profile: Some(backend::icc::ICCProfile::<4>::new(CMYK_PROFILE).unwrap()), + ..Default::default() + }; + let Err(WriteError::Serialization(error)) = overprinted(settings, Overprint { fill: true, stroke: false, nonzero }) else { + panic!("expected validation errors (the metadata is missing)") + }; + let backend::error::KrillaError::Validation(errors) = *error else { panic!("expected validation errors") }; + errors.iter().any(|(error, _)| matches!(error, ValidationError::OverprintModeIccCmyk(_))) + }; + assert!(has_opm_error(true)); + assert!(!has_opm_error(false)); + assert!(overprinted(SerializeSettings::default(), Overprint { fill: true, stroke: true, nonzero: true }).is_ok()); +} + +/// Draws two overlapping rects in each of the nested groups (outermost first) and returns +/// the page `/Group` `/CS` and, for each form from the outside in, `I=… K=… CS=…`. +fn grouped(settings: SerializeSettings, nested: &[GroupOptions]) -> (Option>, Vec) { + let mut writer = Writer::new(settings, Metadata::default()); + { + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(300.0, 60.0).unwrap())); + let mut surface = page.surface(); + for options in nested { + surface.push_group(*options); + rect(&mut surface, 0.0, cmyk::Color::new(255, 0, 0, 0), 1.0); + rect(&mut surface, 20.0, k(255), 0.5); + } + for _ in nested { + surface.pop(); + } + surface.finish(); + page.finish(); + } + let bytes = writer.finish().unwrap(); + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes)).unwrap(); + let page = pdf.pages().iter().next().unwrap(); + let page_group = page.raw().get::>(b"Group".as_slice()).map(|g| cs(&g)); + let mut forms = Vec::new(); + let mut x_objects = page.resources().x_objects.clone(); + loop { + let keys: Vec<_> = x_objects.keys().collect(); + let [key] = keys.as_slice() else { break }; + let form = x_objects.get::>(key.as_ref()).unwrap(); + let dict = form.dict(); + let flag = |group: &Dict<'_>, key: &[u8]| group.get::(key).map_or("-".to_owned(), |b| b.to_string()); + forms.push(match dict.get::>(b"Group".as_slice()) { + Some(group) => format!("I={} K={} CS={}", flag(&group, b"I"), flag(&group, b"K"), cs(&group).unwrap_or("-".to_owned())), + None => "no group".to_owned(), + }); + x_objects = dict.get::>(b"Resources".as_slice()).and_then(|r| r.get::>(b"XObject".as_slice())).unwrap_or_default(); + } + (page_group, forms) +} + +const KNOCKOUT: GroupOptions = GroupOptions { isolated: false, knockout: true }; +const ISOLATED: GroupOptions = GroupOptions { isolated: true, knockout: false }; +const BOTH: GroupOptions = GroupOptions { isolated: true, knockout: true }; + +/// A knockout group that is not isolated is still a transparency group: `/K true`, no `/I`, +/// RGB `/CS` by default and no `/CS` under a CMYK page group. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn knockout_group_without_isolation_is_a_transparency_group() { + assert_eq!(grouped(SerializeSettings::default(), &[KNOCKOUT]), (None, vec!["I=- K=true CS=DeviceRGB".to_owned()])); + assert_eq!(grouped(cmyk_settings(), &[KNOCKOUT]), (Some(Some("DeviceCMYK".to_owned())), vec!["I=- K=true CS=-".to_owned()])); +} + +/// `/I` and `/K` are set independently; `push_isolated` writes `/I` only. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn knockout_and_isolation_are_independent() { + assert_eq!(grouped(cmyk_settings(), &[BOTH]).1, vec!["I=true K=true CS=-"]); + assert_eq!(grouped(cmyk_settings(), &[ISOLATED]).1, vec!["I=true K=- CS=-"]); + assert_eq!(grouped(cmyk_settings(), &[GroupOptions::default()]).1, vec!["no group"]); +} + +/// Nested groups each keep their own attributes. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn nested_groups_keep_their_own_attributes() { + assert_eq!( + grouped(SerializeSettings::default(), &[KNOCKOUT, ISOLATED, BOTH]).1, + vec!["I=- K=true CS=DeviceRGB", "I=true K=- CS=DeviceRGB", "I=true K=true CS=DeviceRGB"] + ); +} + +/// One page with a luminosity mask (white backdrop, sampled transfer curve) and an alpha mask +/// (backdrop ignored, identity transfer). +fn masked(settings: SerializeSettings) -> Vec { + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(200.0, 60.0).unwrap())); + let mut surface = page.surface(); + let curve: Vec = [0.0, 0.2, 1.0].iter().map(|v| NormalizedF32::new(*v).unwrap()).collect(); + let masks = [(MaskType::Luminosity, TransferFunction::Sampled(curve), 0.0), (MaskType::Alpha, TransferFunction::Identity, 100.0)]; + for (kind, transfer, x) in masks { + let mut builder = surface.stream_builder(); + let mut mask_surface = builder.surface(); + let mut path = PathBuilder::new(); + path.push_rect(Rect::from_xywh(x + 10.0, 10.0, 20.0, 40.0).unwrap()); + mask_surface.set_fill(Some(Fill { paint: luma::Color::black().into(), ..Default::default() })); + mask_surface.draw_path(&path.finish().unwrap()); + mask_surface.finish(); + let stream = builder.finish(); + surface.push_mask(Mask::new(stream, kind).with_backdrop(luma::Color::white()).with_transfer(transfer)); + rect(&mut surface, x, k(255), 1.0); + surface.pop(); + } + surface.finish(); + page.finish(); + writer.finish().unwrap() +} + +/// `(/S, /BC, /TR)` of each soft mask on page 1, `/TR` as `Identity` or `Type0 Size=n`. +fn soft_masks(bytes: &[u8]) -> Vec<(String, Option>, Option)> { + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let page = &pdf.pages()[0]; + let states = &page.resources().ext_g_states; + let mut out = Vec::new(); + for key in states.keys() { + let Some(mask) = states.get::>(key.as_ref()).and_then(|s| s.get::>(b"SMask".as_slice())) else { continue }; + let kind = mask.get::>(b"S".as_slice()).unwrap().as_str().to_owned(); + let backdrop = mask.get::>(b"BC".as_slice()).map(|a| a.iter::().collect()); + let transfer = mask.get::>(b"TR".as_slice()).map(|tr| match tr { + Object::Name(name) => name.as_str().to_owned(), + Object::Stream(function) => { + let dict = function.dict(); + let size: Vec = dict.get::>(b"Size".as_slice()).unwrap().iter::().collect(); + assert_eq!(dict.get::(b"BitsPerSample".as_slice()), Some(16)); + assert_eq!(function.decoded().unwrap().len(), 2 * size[0] as usize); + format!("Type{} Size={}", dict.get::(b"FunctionType".as_slice()).unwrap(), size[0]) + } + _ => "?".to_owned(), + }); + out.push((kind, backdrop, transfer)); + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +/// `/BC` has the components of the mask group's color space (RGB; under +/// `GroupColorSpace::Cmyk`, CMYK, or gray with `no_device_cs`); an alpha mask gets no `/BC`; `/TR` is a Type 0 function or +/// `/Identity`. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn soft_masks_write_backdrop_in_the_group_space_and_transfer() { + let profile = || Some(backend::icc::ICCProfile::<4>::new(CMYK_PROFILE).unwrap()); + let cases = [ + (SerializeSettings::default(), vec![1.0, 1.0, 1.0]), + (SerializeSettings { no_device_cs: true, ..Default::default() }, vec![1.0, 1.0, 1.0]), + (cmyk_settings(), vec![0.0, 0.0, 0.0, 0.0]), + (SerializeSettings { no_device_cs: true, cmyk_profile: profile(), ..cmyk_settings() }, vec![1.0]), + ]; + for (settings, white) in cases { + let masks = soft_masks(&masked(settings)); + assert_eq!( + masks, + vec![("Alpha".to_owned(), None, Some("Identity".to_owned())), ("Luminosity".to_owned(), Some(white), Some("Type0 Size=3".to_owned())),] + ); + } +} + +/// With an ICC CMYK page group, a luminosity mask hides the masked object wherever its content +/// paints nothing: without `/BC`, and with a black `/BC` given as gray or as K 100%. Through a +/// CMYK profile K 100% is not black, so a mask group in that space would leave the object +/// showing there. +#[cfg(feature = "raster")] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn icc_cmyk_luminosity_masks_hide_the_object_outside_their_content() { + use craft_pdf::interpret::InterpreterSettings; + use craft_pdf::raster::{self, Options, RenderCache}; + + let settings = + SerializeSettings { no_device_cs: true, cmyk_profile: Some(backend::icc::ICCProfile::<4>::new(CMYK_PROFILE).unwrap()), ..cmyk_settings() }; + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(200.0, 60.0).unwrap())); + let mut surface = page.surface(); + let backdrops: [Option; 3] = [None, Some(luma::Color::black().into()), Some(k(255).into())]; + for (i, backdrop) in backdrops.into_iter().enumerate() { + let x = 60.0 * i as f32; + let mut builder = surface.stream_builder(); + let mut mask_surface = builder.surface(); + let mut path = PathBuilder::new(); + path.push_rect(Rect::from_xywh(x + 10.0, 10.0, 20.0, 40.0).unwrap()); + mask_surface.set_fill(Some(Fill { paint: luma::Color::white().into(), ..Default::default() })); + mask_surface.draw_path(&path.finish().unwrap()); + mask_surface.finish(); + let mut mask = Mask::new(builder.finish(), MaskType::Luminosity); + if let Some(backdrop) = backdrop { + mask = mask.with_backdrop(backdrop); + } + surface.push_mask(mask); + rect(&mut surface, x, k(255), 1.0); + surface.pop(); + } + surface.finish(); + page.finish(); + let bytes = writer.finish().unwrap(); + + let doc = craft_pdf::open_slice(&bytes, None).unwrap(); + let options = Options { background: [255; 4], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), &InterpreterSettings::default(), &options).unwrap(); + assert_eq!((raster.width, raster.height), (200, 60)); + let page = raster.bytes(); + let red = |x: usize, y: usize| page[(y * 200 + x) * 4]; + for i in 0..3 { + let x = 60 * i; + assert!(red(x + 20, 30) < 128, "mask {i}: the object shows inside the mask content"); + assert_eq!((red(x + 5, 30), red(x + 35, 30)), (255, 255), "mask {i}: the object shows outside the mask content"); + } +} + +// DeviceN colors (`color::device_n`). + +fn spot(name: &str, alternate: cmyk::Color) -> Colorant { + Colorant::Spot { name: name.to_owned(), alternate } +} + +fn gold() -> cmyk::Color { + cmyk::Color::new(0, 51, 255, 26) +} + +/// One page with a fill in each color; returns the PDF or the writer's error. +fn device_n_sample(settings: SerializeSettings, colors: Vec) -> Result, WriteError> { + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(300.0, 60.0).unwrap())); + let mut surface = page.surface(); + for (i, color) in colors.into_iter().enumerate() { + let mut path = PathBuilder::new(); + path.push_rect(Rect::from_xywh(10.0 + 50.0 * i as f32, 10.0, 40.0, 40.0).unwrap()); + surface.set_fill(Some(Fill { paint: color.into(), ..Default::default() })); + surface.draw_path(&path.finish().unwrap()); + } + surface.finish(); + page.finish(); + writer.finish() +} + +fn device_n(colorants: Vec, tints: Vec) -> backend::color::Color { + device_n::Color::new(tints, DeviceNSpace::new(colorants).unwrap()).unwrap().into() +} + +/// The page's DeviceN color space arrays, as (names, alternate, tint transform, attributes). +fn device_n_spaces(bytes: &[u8], mut each: impl FnMut(Vec, Object<'_>, Stream<'_>, Option>)) { + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let page = pdf.pages().iter().next().unwrap(); + let spaces = &page.resources().color_spaces; + for key in spaces.keys() { + let array = spaces.get::>(key.as_ref()).unwrap(); + let mut items = array.iter::>(); + let Some(Object::Name(family)) = items.next() else { panic!("color space without a family") }; + if family.as_str() != "DeviceN" { + continue; + } + let Some(Object::Array(names)) = items.next() else { panic!("DeviceN without names") }; + let names = names.iter::>().map(|n| n.as_str().to_owned()).collect(); + let alternate = items.next().unwrap(); + let Some(Object::Stream(tint)) = items.next() else { panic!("DeviceN tint transform is not a stream") }; + assert_eq!(tint.dict().get::(b"FunctionType".as_slice()), Some(4)); + let attrs = items.next().map(|a| match a { + Object::Dict(d) => d, + _ => panic!("DeviceN attributes are not a dictionary"), + }); + each(names, alternate, tint, attrs); + } +} + +fn family(cs: &Object<'_>) -> String { + match cs { + Object::Name(n) => n.as_str().to_owned(), + Object::Array(a) => a.iter::>().next().map(|n| n.as_str().to_owned()).unwrap_or_default(), + _ => "?".to_owned(), + } +} + +/// The tint transform evaluated by hayro on the given tints. +#[cfg(feature = "interpret")] +fn evaluate(tint: &Stream<'_>, tints: Vec) -> Vec { + let function = hayro_interpret::Function::new(&Object::Stream(tint.clone())).unwrap(); + function.eval(tints.into()).unwrap().iter().map(|v| (v * 100.0).round() / 100.0).collect() +} + +/// [Cyan Black] needs no attributes; its tint transform passes the tints to C and K. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn device_n_process_fill_maps_tints_to_their_cmyk_components() { + let color = device_n(vec![Colorant::Process(Process::Cyan), Colorant::Process(Process::Black)], vec![153, 102]); + let bytes = device_n_sample(cmyk_settings(), vec![color]).unwrap(); + let mut found = 0; + device_n_spaces(&bytes, |names, alternate, _tint, attrs| { + found += 1; + assert_eq!(names, ["Cyan", "Black"]); + assert_eq!(family(&alternate), "DeviceCMYK"); + assert!(attrs.is_none()); + #[cfg(feature = "interpret")] + assert_eq!(evaluate(&_tint, vec![0.6, 0.4]), [0.6, 0.0, 0.0, 0.4]); + }); + assert_eq!(found, 1); +} + +/// Spot and process mixed: an NChannel space whose `/Colorants` holds the spot's +/// Separation and whose `/Process` names the alternate space; with `no_device_cs` both use +/// the `cmyk_profile` ICC space. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn device_n_mixing_spots_and_process_writes_nchannel_attributes() { + let icc = + SerializeSettings { no_device_cs: true, cmyk_profile: Some(backend::icc::ICCProfile::<4>::new(CMYK_PROFILE).unwrap()), ..cmyk_settings() }; + for (settings, alternate_family) in [(cmyk_settings(), "DeviceCMYK"), (icc, "ICCBased")] { + 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 mut found = 0; + device_n_spaces(&bytes, |names, alternate, _tint, attrs| { + found += 1; + assert_eq!(names, ["Cyan", "Gold"]); + assert_eq!(family(&alternate), alternate_family); + let attrs = attrs.expect("attributes"); + assert_eq!(attrs.get::>(b"Subtype".as_slice()).unwrap().as_str(), "NChannel"); + let colorants = attrs.get::>(b"Colorants".as_slice()).unwrap(); + assert_eq!(colorants.keys().count(), 1); + let gold = colorants.get::>(b"Gold".as_slice()).unwrap(); + let gold: Vec> = gold.iter::>().collect(); + assert_eq!(family(&gold[0]), "Separation"); + assert!(matches!(&gold[1], Object::Name(n) if n.as_str() == "Gold")); + assert_eq!(family(&gold[2]), alternate_family); + let process = attrs.get::>(b"Process".as_slice()).unwrap(); + assert_eq!(family(&process.get::>(b"ColorSpace".as_slice()).unwrap()), alternate_family); + let components: Vec = + process.get::>(b"Components".as_slice()).unwrap().iter::>().map(|n| n.as_str().to_owned()).collect(); + assert_eq!(components, ["Cyan", "Magenta", "Yellow", "Black"]); + // Gold at 50% (M 0.1, Y 0.5, K 0.05) over full cyan. + #[cfg(feature = "interpret")] + assert_eq!(evaluate(&_tint, vec![1.0, 0.5]), [1.0, 0.1, 0.5, 0.05]); + }); + assert_eq!(found, 1); + } +} + +/// 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)] +fn device_n_spot_alternate_must_match_its_separation_fallback() { + use backend::color::separation::{self, SeparationColorant, SeparationSpace}; + use backend::configure::{Archival, ConfigurationBuilder, ValidationError}; + let settings = SerializeSettings { + configuration: ConfigurationBuilder::new().with_archival_validator(Archival::A2_B).finish().unwrap(), + ..Default::default() + }; + let separation = |alternate: cmyk::Color| -> backend::color::Color { + separation::Color::new(255, SeparationSpace::new(SeparationColorant::Custom("Gold".to_owned()), alternate.into())).into() + }; + let gold_device_n = || device_n(vec![spot("Gold", gold()), Colorant::Process(Process::Black)], vec![255, 255]); + let inconsistent = |colors| match device_n_sample(settings.clone(), colors) { + Err(WriteError::Serialization(error)) => match *error { + backend::error::KrillaError::Validation(errors) => { + errors.iter().any(|(e, _)| *e == ValidationError::InconsistentSeparationFallback(SeparationColorant::Custom("Gold".to_owned()))) + } + other => panic!("unexpected error {other:?}"), + }, + Err(other) => panic!("unexpected error {other:?}"), + Ok(_) => false, + }; + assert!(!inconsistent(vec![separation(gold()), gold_device_n()])); + assert!(inconsistent(vec![separation(cmyk::Color::new(0, 40, 255, 0)), gold_device_n()])); + assert!(inconsistent(vec![gold_device_n(), device_n(vec![spot("Gold", cmyk::Color::new(0, 0, 255, 0))], vec![255])])); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn device_n_space_rejects_bad_colorant_lists() { + assert!(DeviceNSpace::new([]).is_none()); + assert!(DeviceNSpace::new([Colorant::Process(Process::Cyan), Colorant::Process(Process::Cyan)]).is_none()); + assert!(DeviceNSpace::new([spot("Black", gold())]).is_none()); + assert!(DeviceNSpace::new([spot("All", gold())]).is_none()); + let space = DeviceNSpace::new([spot("Gold", gold())]).unwrap(); + assert!(device_n::Color::new(vec![1, 2], space).is_none()); +} + +// DeviceN images and shadings. + +#[derive(Clone, Hash)] +struct Samples { + data: Vec, + size: (u32, u32), + space: ImageColorspace, + device_n: Option, +} + +impl CustomImage for Samples { + fn color_channel(&self) -> &[u8] { + &self.data + } + fn alpha_channel(&self) -> Option<&[u8]> { + None + } + 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() + } +} + +/// One page drawn by `draw`, written with CMYK blending. +fn page_with(draw: impl FnOnce(&mut Surface<'_>)) -> Vec { + let mut writer = Writer::new(cmyk_settings(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(300.0, 60.0).unwrap())); + let mut surface = page.surface(); + draw(&mut surface); + surface.finish(); + page.finish(); + writer.finish().unwrap() +} + +/// The names of a Separation (one) or DeviceN color space array; the family for others. +fn colorant_names(cs: &Object<'_>) -> (String, Vec) { + let Object::Array(array) = cs else { return (family(cs), Vec::new()) }; + let items: Vec> = array.iter::>().collect(); + let names = match items.get(1) { + Some(Object::Name(n)) => vec![n.as_str().to_owned()], + Some(Object::Array(names)) => names.iter::>().map(|n| n.as_str().to_owned()).collect(), + _ => Vec::new(), + }; + (family(cs), names) +} + +/// A [Black Gold] duotone: the image's `/ColorSpace` is the DeviceN space, with two 8-bit +/// samples a pixel in colorant order. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn device_n_image_writes_one_sample_per_colorant() { + let space = DeviceNSpace::new([Colorant::Process(Process::Black), spot("Gold", gold())]).unwrap(); + let data = vec![0, 0, 128, 64, 255, 153]; + let image = + Image::from_custom(Samples { data: data.clone(), size: (3, 1), space: ImageColorspace::DeviceN, device_n: Some(space) }, false).unwrap(); + let bytes = page_with(|surface| surface.draw_image(image, Size::from_wh(30.0, 10.0).unwrap())); + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes)).unwrap(); + let page = pdf.pages().iter().next().unwrap(); + let x_objects = &page.resources().x_objects; + let mut found = 0; + for key in x_objects.keys() { + let image = x_objects.get::>(key.as_ref()).unwrap(); + let dict = image.dict(); + assert_eq!(dict.get::>(b"Subtype".as_slice()).unwrap().as_str(), "Image"); + found += 1; + let cs = dict.get::>(b"ColorSpace".as_slice()).unwrap(); + assert_eq!(colorant_names(&cs), ("DeviceN".to_owned(), vec!["Black".to_owned(), "Gold".to_owned()])); + assert_eq!(dict.get::(b"BitsPerComponent".as_slice()), Some(8)); + assert_eq!((dict.get::(b"Width".as_slice()), dict.get::(b"Height".as_slice())), (Some(3), Some(1))); + assert_eq!(image.decoded().unwrap().as_ref(), data.as_slice()); + } + assert_eq!(found, 1); +} + +/// `Image::from_custom` refuses channel data that doesn't match the dimensions, and dimensions +/// whose channel length overflows, instead of panicking when the page is written. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn custom_image_with_mismatched_or_huge_dimensions_is_refused() { + let cmyk = |data: Vec, size| Samples { data, size, space: ImageColorspace::Cmyk, device_n: None }; + assert!(Image::from_custom(cmyk(vec![0; 3], (1, 1)), false).is_err()); + assert!(Image::from_custom(cmyk(vec![0; 4], (u32::MAX, u32::MAX)), false).is_err()); + assert!(Image::from_custom(cmyk(vec![0; 4], (1, 1)), false).is_ok()); +} + +/// A DeviceN image needs its space from `CustomImage::device_n_space`. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn device_n_image_without_a_space_is_refused() { + let image = Samples { data: vec![0; 6], size: (3, 1), space: ImageColorspace::DeviceN, device_n: None }; + assert!(Image::from_custom(image, false).is_err()); +} + +/// A shading's color space family and colorant names, function `/Range` length and +/// function `/Domain`. +type ShadingSummary = ((String, Vec), usize, Vec); + +/// Each shading on the page. +fn shadings(bytes: &[u8]) -> Vec { + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let page = pdf.pages().iter().next().unwrap(); + let patterns = &page.resources().patterns; + let mut out = Vec::new(); + for key in patterns.keys() { + let pattern = patterns.get::>(key.as_ref()).unwrap(); + let shading = pattern.get::>(b"Shading".as_slice()).unwrap(); + let cs = shading.get::>(b"ColorSpace".as_slice()).unwrap(); + let function = match shading.get::>(b"Function".as_slice()).unwrap() { + Object::Dict(d) => d, + Object::Stream(s) => s.dict().clone(), + _ => panic!("shading function is not a dictionary or stream"), + }; + let floats = |key: &[u8]| -> Vec { function.get::>(key).unwrap().iter::().collect() }; + out.push((colorant_names(&cs), floats(b"Range").len(), floats(b"Domain"))); + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +fn stops(colors: [backend::color::Color; 2]) -> Vec { + let [a, b] = colors; + vec![ + Stop { offset: NormalizedF32::ZERO, color: a, opacity: NormalizedF32::ONE }, + Stop { offset: NormalizedF32::ONE, color: b, opacity: NormalizedF32::ONE }, + ] +} + +fn fill_rect(surface: &mut Surface<'_>, x: f32, paint: backend::paint::Paint) { + let mut path = PathBuilder::new(); + path.push_rect(Rect::from_xywh(x, 10.0, 40.0, 40.0).unwrap()); + surface.set_fill(Some(Fill { paint, ..Default::default() })); + surface.draw_path(&path.finish().unwrap()); +} + +fn linear(colors: [backend::color::Color; 2]) -> backend::paint::Paint { + LinearGradient { + x1: 0.0, + y1: 0.0, + x2: 40.0, + y2: 0.0, + transform: Transform::identity(), + spread_method: SpreadMethod::Pad, + stops: stops(colors), + anti_alias: false, + } + .into() +} + +/// Stops mixing a spot with process colors (linear: exponential function; sweep: +/// PostScript function) become DeviceN shadings with one function output per colorant; pure +/// CMYK stays DeviceCMYK. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn device_n_shading_mixes_a_spot_with_process_colors() { + use backend::color::separation::{self, SeparationColorant, SeparationSpace}; + let gold_spot = || -> backend::color::Color { + separation::Color::new(255, SeparationSpace::new(SeparationColorant::Custom("Gold".to_owned()), gold().into())).into() + }; + let cyan: backend::color::Color = cmyk::Color::new(255, 0, 0, 0).into(); + let bytes = page_with(|surface| { + fill_rect(surface, 0.0, linear([gold_spot(), cyan.clone()])); + let sweep = SweepGradient { + cx: 70.0, + cy: 30.0, + start_angle: 0.0, + end_angle: 360.0, + transform: Transform::identity(), + spread_method: SpreadMethod::Pad, + stops: stops([gold_spot(), luma::Color::black().into()]), + anti_alias: false, + }; + fill_rect(surface, 50.0, sweep.into()); + fill_rect(surface, 100.0, linear([cyan.clone(), k(255).into()])); + }); + let names = |v: &[&str]| v.iter().map(|s| (*s).to_owned()).collect::>(); + let found = shadings(&bytes); + assert_eq!(found.len(), 3, "{found:?}"); + assert_eq!(found[0], (("DeviceCMYK".to_owned(), vec![]), 8, vec![0.0, 1.0])); + assert_eq!(found[1].0, ("DeviceN".to_owned(), names(&["Black", "Gold"]))); + assert_eq!(found[1].1, 4, "sweep: one range pair per colorant"); + assert_eq!(found[1].2.len(), 4, "sweep: the function's domain is the bounding box"); + assert_eq!(found[2], (("DeviceN".to_owned(), names(&["Cyan", "Gold"])), 4, vec![0.0, 1.0])); +} + +/// A spot to paper (CMYK 0 or a spot at tint 0) stays a Separation shading. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn device_n_shading_keeps_a_spot_to_paper_as_separation() { + use backend::color::separation::{self, SeparationColorant, SeparationSpace}; + let gold_spot = |tint| -> backend::color::Color { + separation::Color::new(tint, SeparationSpace::new(SeparationColorant::Custom("Gold".to_owned()), gold().into())).into() + }; + let bytes = page_with(|surface| { + fill_rect(surface, 0.0, linear([gold_spot(255), cmyk::Color::new(0, 0, 0, 0).into()])); + fill_rect(surface, 50.0, linear([gold_spot(128), gold_spot(0)])); + }); + let found = shadings(&bytes); + assert_eq!(found.len(), 2, "{found:?}"); + for (cs, range, domain) in found { + assert_eq!(cs, ("Separation".to_owned(), vec!["Gold".to_owned()])); + assert_eq!((range, domain), (2, vec![0.0, 1.0])); + } +} + +// Mesh gradients (`paint::MeshGradient`, Type 6 shadings). + +/// A square Coons patch with straight edges, corners clockwise from the top left at (x, y). +fn square(x: f32, y: f32, size: f32, colors: [backend::color::Color; 4], opacities: [f32; 4]) -> MeshPatch { + let corners = [(x, y), (x + size, y), (x + size, y + size), (x, y + size)]; + let mut points = [(0.0, 0.0); 12]; + for (i, &(ax, ay)) in corners.iter().enumerate() { + let (bx, by) = corners[(i + 1) % 4]; + for step in 0..3 { + let t = step as f32 / 3.0; + points[3 * i + step] = (ax + (bx - ax) * t, ay + (by - ay) * t); + } + } + MeshPatch { points, colors, opacities: opacities.map(|o| NormalizedF32::new(o).unwrap()) } +} + +fn mesh(patches: Vec) -> backend::paint::Paint { + MeshGradient { patches, transform: Transform::identity(), anti_alias: false }.into() +} + +/// Each Type 6 shading in the file: color space family and colorant names, components a +/// corner (from `/Decode`), and the corner color bytes of each patch. +type MeshSummary = ((String, Vec), usize, Vec>); + +fn mesh_shadings(bytes: &[u8]) -> Vec { + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let mut out = Vec::new(); + for object in pdf.objects() { + let Object::Stream(stream) = object else { continue }; + let dict = stream.dict(); + if dict.get::(b"ShadingType".as_slice()) != Some(6) { + continue; + } + assert_eq!(dict.get::(b"BitsPerCoordinate".as_slice()), Some(32)); + assert_eq!(dict.get::(b"BitsPerComponent".as_slice()), Some(8)); + assert_eq!(dict.get::(b"BitsPerFlag".as_slice()), Some(8)); + let cs = dict.get::>(b"ColorSpace".as_slice()).unwrap(); + let decode = dict.get::>(b"Decode".as_slice()).unwrap().iter::().count(); + let components = (decode - 4) / 2; + let data = stream.decoded().unwrap(); + let patch_len = 1 + 12 * 8 + 4 * components; + assert_eq!(data.len() % patch_len, 0, "whole patches of {components} components a corner"); + let corners = data.chunks(patch_len).map(|patch| patch[1 + 12 * 8..].to_vec()).collect(); + out.push((colorant_names(&cs), components, corners)); + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +fn gold_spot(tint: u8) -> backend::color::Color { + use backend::color::separation::{self, SeparationColorant, SeparationSpace}; + separation::Color::new(tint, SeparationSpace::new(SeparationColorant::Custom("Gold".to_owned()), gold().into())).into() +} + +/// Mesh corner colors follow the gradient stop rule across every patch: CMYK, gray and a +/// spot become one DeviceN space with process colorants first, so a later patch's spot is +/// written as itself. Corner opacities below 1 add the same mesh in gray as a soft mask. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn mesh_mixing_process_and_spot_corners_is_a_device_n_shading() { + let cyan = || -> backend::color::Color { cmyk::Color::new(255, 0, 0, 0).into() }; + let paper = || -> backend::color::Color { cmyk::Color::new(0, 0, 0, 0).into() }; + let patches = |opacity: f32| { + vec![ + square(10.0, 10.0, 40.0, [cyan(), cyan(), luma::Color::black().into(), paper()], [1.0; 4]), + square(50.0, 10.0, 40.0, [gold_spot(255), gold_spot(128), cyan(), cyan()], [1.0, 1.0, 1.0, opacity]), + ] + }; + let names = |v: &[&str]| v.iter().map(|s| (*s).to_owned()).collect::>(); + let expected = vec![vec![255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0], vec![0, 0, 255, 0, 0, 128, 255, 0, 0, 255, 0, 0]]; + + let opaque = page_with(|surface| fill_rect(surface, 0.0, mesh(patches(1.0)))); + assert_eq!(mesh_shadings(&opaque), vec![(("DeviceN".to_owned(), names(&["Cyan", "Black", "Gold"])), 3, expected.clone())]); + + let translucent = page_with(|surface| fill_rect(surface, 0.0, mesh(patches(0.5)))); + let found = mesh_shadings(&translucent); + assert_eq!(found.len(), 2, "{found:?}"); + assert_eq!(found[0], (("DeviceGray".to_owned(), vec![]), 1, vec![vec![255; 4], vec![255, 255, 255, 128]])); + assert_eq!(found[1], (("DeviceN".to_owned(), names(&["Cyan", "Black", "Gold"])), 3, expected)); + assert_eq!(soft_masks(&translucent).len(), 1); +} + +/// RGB corners can't share a space with CMYK ones: the document fails instead of writing +/// one patch's colors in the other's space. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn mesh_mixing_rgb_and_cmyk_corners_is_refused() { + let red: backend::color::Color = backend::color::rgb::Color::new(255, 0, 0).into(); + let cyan: backend::color::Color = cmyk::Color::new(255, 0, 0, 0).into(); + let patches = vec![ + square(10.0, 10.0, 40.0, [red.clone(), red.clone(), red.clone(), red.clone()], [1.0; 4]), + square(50.0, 10.0, 40.0, [cyan.clone(), cyan.clone(), red.clone(), red], [1.0; 4]), + ]; + let mut writer = Writer::new(cmyk_settings(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(300.0, 60.0).unwrap())); + let mut surface = page.surface(); + fill_rect(&mut surface, 0.0, mesh(patches)); + surface.finish(); + page.finish(); + let Err(WriteError::Serialization(error)) = writer.finish() else { panic!("a mixed RGB and CMYK mesh was written") }; + assert!(matches!(*error, backend::error::KrillaError::MixedMeshColorSpaces(_)), "{error:?}"); +} + +/// A mesh in one color space is written in that space, three bytes a corner for RGB. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn rgb_mesh_is_a_device_rgb_shading() { + let rgb = |r, g, b| -> backend::color::Color { backend::color::rgb::Color::new(r, g, b).into() }; + let patch = square(10.0, 10.0, 40.0, [rgb(255, 0, 0), rgb(0, 255, 0), rgb(0, 0, 255), rgb(255, 255, 255)], [1.0; 4]); + let bytes = page_with(|surface| fill_rect(surface, 0.0, mesh(vec![patch]))); + let corners = vec![vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]]; + assert_eq!(mesh_shadings(&bytes), vec![(("DeviceRGB".to_owned(), vec![]), 3, corners)]); +} + +const FOGRA39: &[u8] = include_bytes!("../../cms/profiles/craft-coated-fogra39.icc"); + +fn text(dict: &Dict<'_>, key: &[u8]) -> Option { + dict.get::>(key).map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned()) +} + +/// One `/OutputIntents` entry. +#[derive(Debug)] +struct Intent { + subtype: String, + identifier: Option, + registry: Option, + /// The `/DestOutputProfile` object and its decoded bytes. + profile_object: String, + profile: Vec, +} + +fn output_intents(bytes: &[u8]) -> Vec { + let pdf = hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let Some(intents) = catalog.get::>(b"OutputIntents".as_slice()) else { return Vec::new() }; + intents + .iter::>() + .map(|intent| { + let profile = intent.get::>(b"DestOutputProfile".as_slice()).unwrap(); + Intent { + subtype: intent.get::>(b"S".as_slice()).unwrap().as_str().to_owned(), + identifier: text(&intent, b"OutputConditionIdentifier"), + registry: text(&intent, b"RegistryName"), + profile_object: format!("{:?}", profile.obj_id()), + profile: profile.decoded().unwrap().into_owned(), + } + }) + .collect() +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn print_settings_blend_in_cmyk_and_name_the_profile_as_the_output_intent() { + let settings = print::settings(PrintColor::fogra39(FOGRA39)).unwrap(); + assert!(!settings.no_device_cs); + assert_eq!(settings.group_color_space, GroupColorSpace::Cmyk); + assert_eq!(settings.trapped, Some(Trapped::False)); + assert_eq!(settings.pdfx_version, None); + let intent = settings.output_intent.as_ref().unwrap(); + assert_eq!((intent.condition_identifier.as_str(), intent.registry_name.as_deref()), ("FOGRA39", Some("http://www.color.org"))); + + let bytes = sample(settings.clone(), placed(&settings).as_deref()).unwrap(); + let cmyk_group = Some(Some("DeviceCMYK".to_owned())); + assert_eq!(groups(&bytes).pages, vec![cmyk_group.clone(), None, cmyk_group]); + let intents = output_intents(&bytes); + assert_eq!(intents.len(), 1); + let intent = &intents[0]; + assert_eq!( + (intent.subtype.as_str(), intent.identifier.as_deref(), intent.registry.as_deref()), + ("GTS_PDFX", Some("FOGRA39"), Some("http://www.color.org")) + ); + assert!(intent.profile == FOGRA39, "the output intent embeds the given profile"); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Trapped/False") && text.contains("False")); + assert!(!text.contains("GTS_PDFXVersion")); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn cmyk_blending_writes_no_output_intent_or_trapped() { + let settings = print::cmyk_blending(FOGRA39).unwrap(); + assert_eq!(settings.group_color_space, GroupColorSpace::Cmyk); + assert!(settings.output_intent.is_none() && settings.trapped.is_none()); + + let bytes = sample(settings.clone(), placed(&settings).as_deref()).unwrap(); + let cmyk_group = Some(Some("DeviceCMYK".to_owned())); + assert_eq!(groups(&bytes).pages, vec![cmyk_group.clone(), None, cmyk_group]); + assert!(output_intents(&bytes).is_empty()); + assert!(!String::from_utf8_lossy(&bytes).contains("Trapped")); + assert!(matches!(print::cmyk_blending(b"not a profile"), Err(WriteError::InvalidProfile))); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn print_settings_reject_a_profile_that_is_not_cmyk() { + assert!(matches!(print::settings(PrintColor::fogra39(b"not a profile")), Err(WriteError::InvalidProfile))); +} + +/// `/Trapped` (`Unknown` included) and `GTS_PDFXVersion` go to the information dictionary and +/// 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-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-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-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(); + assert!(unknown.contains("/Trapped/Unknown") && unknown.contains("Unknown")); + + let plain = String::from_utf8_lossy(&sample(SerializeSettings::default(), None).unwrap()).into_owned(); + assert!(!plain.contains("Trapped") && !plain.contains("GTS_PDFX")); +} + +/// A one-page PDF/A document with FOGRA39 print settings and the given PDF/X version. +fn pdfa_print(version: PdfVersion, archival: Archival, pdfx_version: Option<&str>) -> Result, WriteError> { + let settings = SerializeSettings { + no_device_cs: true, + configuration: craft_pdf::write::configuration(version, Some(archival)).unwrap(), + pdfx_version: pdfx_version.map(str::to_owned), + ..print::settings(PrintColor::fogra39(FOGRA39)).unwrap() + }; + let created = Some(backend::metadata::DateTime::new(2026)); + let metadata = Metadata { title: Some("Print".to_owned()), created, ..Metadata::default() }; + let mut writer = Writer::new_with_language(settings, metadata, Some("en".to_owned())); + { + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(60.0, 60.0).unwrap())); + let mut surface = page.surface(); + rect(&mut surface, 0.0, k(255), 0.5); + surface.finish(); + page.finish(); + } + writer.finish() +} + +/// Under PDF/A the archival intent names the same profile object as the print intent. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn pdfa_and_print_intents_share_the_profile() { + let intents = output_intents(&pdfa_print(PdfVersion::Pdf17, Archival::A2_B, None).unwrap()); + let subtypes: Vec<_> = intents.iter().map(|i| i.subtype.as_str()).collect(); + assert_eq!(subtypes, ["GTS_PDFA1", "GTS_PDFX"]); + assert_eq!(intents[0].profile_object, intents[1].profile_object); + assert!(intents[0].profile == FOGRA39); +} + +/// `pdfxid:GTS_PDFXVersion` needs an XMP extension schema under PDF/A-1 to 3, which krilla +/// doesn't write, so a PDF/X version fails validation there; PDF/A-4 needs none. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn pdfx_version_is_a_pdf_a_1_to_3_error() { + for (version, archival) in [(PdfVersion::Pdf14, Archival::A1_B), (PdfVersion::Pdf17, Archival::A2_B), (PdfVersion::Pdf17, Archival::A3_B)] { + let Err(WriteError::Serialization(error)) = pdfa_print(version, archival, Some("PDF/X-4")) else { + panic!("{archival:?}: expected a validation error") + }; + let backend::error::KrillaError::Validation(errors) = *error else { panic!("{archival:?}: expected validation errors") }; + assert!(errors.iter().any(|(error, _)| *error == ValidationError::PdfxVersionWithoutExtensionSchema), "{archival:?}: {errors:?}"); + } + let bytes = pdfa_print(PdfVersion::Pdf20, Archival::A4, Some("PDF/X-4")).unwrap(); + assert!(String::from_utf8_lossy(&bytes).contains("PDF/X-4")); +} diff --git a/crates/pdf/tests/support/synthetic_icc.rs b/crates/pdf/tests/support/synthetic_icc.rs new file mode 100644 index 00000000..97aeb30a --- /dev/null +++ b/crates/pdf/tests/support/synthetic_icc.rs @@ -0,0 +1,227 @@ +//! Synthetic ICC profiles for the plate-mode tests, written byte by byte from ICC.1 (version +//! 2.1 profiles with `lut16Type` tables and the legacy 16-bit PCS L*a*b* encoding). They are +//! contributor-original and generated when a test runs; no profile file is committed. +//! +//! The CMYK printing condition is made up so that its conversions can be worked out by hand: +//! +//! - AToB (CMYK → PCS L*a*b*): L* = 100 · (1 − (c + m + y + k) / 4), a* = 60 · (m − c), +//! b* = 60 · (y − (c + m) / 2). It is affine, so a two-point grid holds it exactly. +//! - BToA (PCS → CMYK), its inverse with equal inks along the neutral axis: with +//! t = 1 − L* / 100, c = t − a* / 120, m = t + a* / 120, y = t + b* / 60, k = t − b* / 60, each +//! clamped to 0..1. A neutral of L* 50 prints as 50% of each ink, where the ISO 32000-2 +//! §10.4.2.4 device formulas would print it on black alone. +//! +//! From PdfCraft #2770 by Guillem Hermida (`crates/render/tests/support/synthetic_icc.rs` at +//! 6fcf2a4aca29e943e28cb69017ff8f87d00656c6, MIT OR Apache-2.0). Used by `tests/plates.rs` +//! through `#[path]`. + +#![allow(dead_code)] + +/// Grid points per input of the BToA table: fine enough that colors a little off the neutral +/// axis interpolate between unclamped grid points. +const BTOA_GRID: usize = 17; + +/// What the synthetic printing condition prints for PCS L*a*b*, each ink `0.0..=1.0`. +pub fn btoa(l: f64, a: f64, b: f64) -> [f64; 4] { + let t = 1.0 - l / 100.0; + [t - a / 120.0, t + a / 120.0, t + b / 60.0, t - b / 60.0].map(|v| v.clamp(0.0, 1.0)) +} + +/// The PCS L*a*b* the synthetic printing condition gives an ink combination. +pub fn atob([c, m, y, k]: [f64; 4]) -> [f64; 3] { + [100.0 * (1.0 - (c + m + y + k) / 4.0), 60.0 * (m - c), 60.0 * (y - (c + m) / 2.0)] +} + +/// The CIE L* of an sRGB gray `v` (`0.0..=1.0`); a neutral's a* and b* are 0. +pub fn srgb_gray_lightness(v: f64) -> f64 { + let linear = if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let f = if linear > 216.0 / 24389.0 { linear.cbrt() } else { (24389.0 / 27.0 * linear + 16.0) / 116.0 }; + 116.0 * f - 16.0 +} + +/// A CMYK output profile (`prtr`) of the synthetic printing condition, described as +/// `description`. With `btoa` false it has no PCS-to-device tables (and so can't be printed to). +pub fn cmyk_profile(description: &str, btoa_tables: bool) -> Vec { + let atob_table = lut16(4, 3, 2, false, |node| { + let cmyk = [node[0], node[1], node[2], node[3]]; + encode_lab(atob(cmyk)) + }); + let mut tags: Vec<(&[u8; 4], Vec)> = vec![ + (b"desc", text_description(description)), + (b"cprt", text(b"No copyright, synthetic test data")), + (b"wtpt", xyz(D50)), + (b"A2B0", atob_table.clone()), + (b"A2B1", atob_table.clone()), + (b"A2B2", atob_table), + ]; + if btoa_tables { + let btoa_table = lut16(3, 4, BTOA_GRID, true, |node| { + let [l, a, b] = grid_lab([node[0], node[1], node[2]]); + btoa(l, a, b).map(|v| v * 65535.0).to_vec() + }); + tags.push((b"B2A0", btoa_table.clone())); + tags.push((b"B2A1", btoa_table.clone())); + tags.push((b"B2A2", btoa_table)); + // Everything is in gamut. + tags.push((b"gamt", lut16(3, 1, 2, false, |_| vec![0.0]))); + } + profile(b"prtr", b"CMYK", b"Lab ", &tags) +} + +/// A gray display profile (gamma 2.2), described as `description`: an output intent that is +/// not CMYK. +pub fn gray_profile(description: &str) -> Vec { + // curveType with one entry: a gamma as u8Fixed8Number (2.2 ≈ 0x0233). + let mut curve = b"curv\0\0\0\0".to_vec(); + curve.extend_from_slice(&1_u32.to_be_bytes()); + curve.extend_from_slice(&0x0233_u16.to_be_bytes()); + let tags: Vec<(&[u8; 4], Vec)> = + vec![(b"desc", text_description(description)), (b"cprt", text(b"No copyright, synthetic test data")), (b"wtpt", xyz(D50)), (b"kTRC", curve)]; + profile(b"mntr", b"GRAY", b"XYZ ", &tags) +} + +/// The D50 illuminant of the PCS. +const D50: [f64; 3] = [0.9642, 1.0, 0.8249]; + +/// PCS L*a*b* in the legacy 16-bit encoding (ICC.1 Annex A), as `0.0..=65535.0` table values: +/// L* 100 is 0xFF00 and a* or b* 0 is 0x8000. +fn encode_lab([l, a, b]: [f64; 3]) -> Vec { + vec![l * 65280.0 / 100.0, (a + 128.0) * 256.0, (b + 128.0) * 256.0].into_iter().map(|v| v.clamp(0.0, 65535.0)).collect() +} + +/// A BToA grid point's PCS L*a*b*: `node` holds its inputs as `0.0..=1.0`. The L* input +/// table has stretched L* 100 (0xFF00) onto the last grid point, so white prints no ink; a* +/// and b* are in the legacy encoding. +fn grid_lab(node: [f64; 3]) -> [f64; 3] { + let [l, a, b] = node; + [l * 100.0, a * 65535.0 / 256.0 - 128.0, b * 65535.0 / 256.0 - 128.0] +} + +/// A `lut16Type` with `inputs` × `outputs` channels, `grid` points per input, identity +/// matrix and output tables, and the grid filled by `value` (each grid point's inputs as +/// `0.0..=1.0`, returning its outputs as `0.0..=65535.0`). The input tables are the identity, +/// except that `stretch_lightness` scales the first input by 0xFFFF / 0xFF00 (see [`grid_lab`]). +fn lut16(inputs: usize, outputs: usize, grid: usize, stretch_lightness: bool, value: impl Fn(&[f64]) -> Vec) -> Vec { + const ENTRIES: usize = 257; + let mut out = b"mft2\0\0\0\0".to_vec(); + out.extend_from_slice(&[inputs as u8, outputs as u8, grid as u8, 0]); + for k in 0_usize..9 { + let one = if k.is_multiple_of(4) { 0x0001_0000_i32 } else { 0 }; + out.extend_from_slice(&one.to_be_bytes()); + } + out.extend_from_slice(&(ENTRIES as u16).to_be_bytes()); + out.extend_from_slice(&2_u16.to_be_bytes()); + for channel in 0..inputs { + for e in 0..ENTRIES { + let x = e as f64 / (ENTRIES - 1) as f64; + let y = if stretch_lightness && channel == 0 { (x * 65535.0 / 65280.0).min(1.0) } else { x }; + out.extend_from_slice(&((y * 65535.0).round() as u16).to_be_bytes()); + } + } + // The first input varies slowest. + let points = grid.pow(inputs as u32); + for index in 0..points { + let mut node = vec![0.0; inputs]; + let mut rest = index; + for i in (0..inputs).rev() { + node[i] = (rest % grid) as f64 / (grid - 1) as f64; + rest /= grid; + } + let values = value(&node); + assert_eq!(values.len(), outputs); + for v in values { + out.extend_from_slice(&(v.round().clamp(0.0, 65535.0) as u16).to_be_bytes()); + } + } + for _ in 0..outputs { + out.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + out +} + +/// A `textDescriptionType` (ICC.1:2001, the version 2 `desc` tag) with an ASCII description. +fn text_description(description: &str) -> Vec { + let ascii: Vec = description.bytes().filter(u8::is_ascii).collect(); + let mut out = b"desc\0\0\0\0".to_vec(); + out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes()); + out.extend_from_slice(&ascii); + out.push(0); + out.extend_from_slice(&[0; 4]); // Unicode language code + out.extend_from_slice(&[0; 4]); // Unicode count + out.extend_from_slice(&[0; 2]); // ScriptCode code + out.push(0); // ScriptCode count + out.extend_from_slice(&[0; 67]); + out +} + +/// A `textType`. +fn text(ascii: &[u8]) -> Vec { + let mut out = b"text\0\0\0\0".to_vec(); + out.extend_from_slice(ascii); + out.push(0); + out +} + +fn s15_fixed16(v: f64) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +/// An `XYZType` with one value. +fn xyz(v: [f64; 3]) -> Vec { + let mut out = b"XYZ \0\0\0\0".to_vec(); + for c in v { + out.extend_from_slice(&s15_fixed16(c)); + } + out +} + +/// A whole version 2.1 profile: the 128-byte header, the tag table, then each tag's data, +/// 4-byte aligned. Tags with equal data share it. +fn profile(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tags: &[(&[u8; 4], Vec)]) -> Vec { + let table_end = 128 + 4 + 12 * tags.len(); + let mut data: Vec = Vec::new(); + let mut entries: Vec<(&[u8; 4], usize, usize)> = Vec::new(); + for (signature, body) in tags { + let shared = entries.iter().zip(tags).find(|(_, (_, b))| b == body).map(|((_, offset, len), _)| (*offset, *len)); + let (offset, len) = match shared { + Some(place) => place, + None => { + while !data.len().is_multiple_of(4) { + data.push(0); + } + let offset = table_end + data.len(); + data.extend_from_slice(body); + (offset, body.len()) + } + }; + entries.push((signature, offset, len)); + } + while !data.len().is_multiple_of(4) { + data.push(0); + } + let size = table_end + data.len(); + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&(size as u32).to_be_bytes()); + out.extend_from_slice(&[0; 4]); // preferred CMM + out.extend_from_slice(&0x0210_0000_u32.to_be_bytes()); + out.extend_from_slice(class); + out.extend_from_slice(space); + out.extend_from_slice(pcs); + for v in [2026_u16, 10, 16, 0, 0, 0] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.extend_from_slice(b"acsp"); + out.extend_from_slice(&[0; 4 + 4 + 4 + 4 + 8 + 4]); // platform, flags, manufacturer, model, attributes, intent + for c in D50 { + out.extend_from_slice(&s15_fixed16(c)); + } + out.extend_from_slice(&[0; 4 + 16 + 28]); // creator, ID, reserved + out.extend_from_slice(&(tags.len() as u32).to_be_bytes()); + for (signature, offset, len) in &entries { + out.extend_from_slice(*signature); + out.extend_from_slice(&(*offset as u32).to_be_bytes()); + out.extend_from_slice(&(*len as u32).to_be_bytes()); + } + out.extend_from_slice(&data); + out +} diff --git a/crates/pdf/tests/upstream_compatibility.rs b/crates/pdf/tests/upstream_compatibility.rs index 38ffa0c7..d8aface5 100644 --- a/crates/pdf/tests/upstream_compatibility.rs +++ b/crates/pdf/tests/upstream_compatibility.rs @@ -280,6 +280,7 @@ mod painting { GlyphDrawMode::Fill => 'F', GlyphDrawMode::Stroke(_) => 'S', GlyphDrawMode::Invisible => 'I', + GlyphDrawMode::Clip => 'C', }; self.glyphs.push(GlyphEvent { text, mode, rgba: rgba(paint), origin: glyph_transform * Point::ORIGIN }); } diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 9f27f989..44170098 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -1,9 +1,12 @@ -//! Portable regressions adapted from PdfCraft 7437bde378f43856eada40af25b983812e615e8a (MIT OR Apache-2.0). +//! Portable regressions adapted from PdfCraft 7437bde378f43856eada40af25b983812e615e8a and +//! a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0). #![cfg(feature = "raster")] +use craft_pdf::interpret::InterpreterSettings; use craft_pdf::{ open_slice, raster::{self, Options, RenderCache}, }; +use std::sync::Arc; struct Pixels { width: u32, @@ -269,3 +272,882 @@ fn tiff_predictor_packed_rgb_image_renders_every_row() { assert_eq!(pixel(10), &[255, 0, 0, 255]); assert_eq!(pixel(30), &[0, 255, 0, 255]); } + +// An 8 x 4 all-black Group 4 stream, hand-encoded from ITU-T T.6: one horizontal-mode row +// (white 0, black 8), three V0 rows, then EOFB. +const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; + +fn fax_page(space: &str, params: &str) -> Pixels { + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {params} >> /Length {} >> stream\n", + content.len(), + ALL_BLACK_8X4.len() + ) + .into_bytes(); + pdf.extend_from_slice(&ALL_BLACK_8X4); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + render(&pdf) +} + +fn pixel(page: &Pixels, x: u32, y: u32) -> &[u8] { + let i = ((y * page.width + x) * 4) as usize; + &page.rgba[i..i + 4] +} + +/// Scanners and fax services write Group 4 pages with `/Rows 0`, which ISO 32000-2 Table 11 +/// defines as "height not predetermined" (the default), not as zero rows. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn group4_fax_with_rows_zero_decodes_to_the_image_height() { + for rows in ["", "/Rows 4", "/Rows 0"] { + let page = fax_page("/DeviceGray", rows); + assert_eq!(pixel(&page, 15, 24), &[0, 0, 0, 255], "{rows:?}: the fax image paints black"); + assert_eq!(pixel(&page, 1, 38), &[255, 255, 255, 255], "{rows:?}: outside the image stays white"); + } +} + +/// PdfCraft #3308: a fax image whose `/Rows` is below its `/Height` is decoded to its height. +/// `/BlackIs1` turns the fixture white, so rows left undecoded show as black. Also through an +/// `/Indexed` color space, the filter's other output path. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() { + for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] { + let page = fax_page(space, &format!("{rows} /BlackIs1 true")); + for y in [17, 22, 27, 32] { + assert_eq!(pixel(&page, 15, y), &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded"); + } + } +} + +/// PdfCraft ledger hayro-interpret P2 (#3178): a shaded Type 3 glyph clips to its `/FontBBox` in +/// device space at every position, not to the glyph-space box. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn shaded_type3_glyph_draws_at_multiple_positions() { + let content = "BT /F 30 Tf 1 0 0 1 50 70 Tm (A) Tj 1 0 0 1 220 70 Tm (A) Tj ET"; + let glyph = "100 0 d0 0 0 100 100 re W n /S sh"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Resources << /Font << /F 5 0 R >> >> /Contents 4 0 R >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /A 6 0 R >> /Encoding << /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [100] /Resources << /Shading << /S 7 0 R >> >> >> endobj +6 0 obj << /Length {} >> stream +{glyph} +endstream endobj +7 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.len() + ); + let page = render(pdf.as_bytes()); + for x in [65, 235] { + let px = pixel(&page, x, 65); + assert!(px[0] < 240 && px[2] < 240 && px[1] < 10, "glyph at {x}: {px:?}"); + } +} + +/// PdfCraft ledger hayro-interpret (26) extension: comma-below letters in non-embedded standard +/// fonts are the letter with the font's comma under it, apart from the letter and inside the +/// descender, whichever name the font gives them (`/scommaaccent`, `/Kcedilla`, `/uni0219`). +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter() { + let pairs = [ + ("s", "uni0219"), + ("t", "uni021B"), + ("T", "uni021A"), + ("K", "uni0136"), + ("k", "kcedilla"), + ("G", "uni0122"), + ("G", "Gcedilla"), + ("R", "uni0156"), + ("s", "scommaaccent"), + ("S", "Scommaaccent"), + ("t", "tcommaaccent"), + ("T", "Tcommaaccent"), + ("k", "kcommaaccent"), + ("K", "Kcommaaccent"), + ("l", "lcommaaccent"), + ("L", "Lcommaaccent"), + ("n", "ncommaaccent"), + ("N", "Ncommaaccent"), + ("r", "rcommaaccent"), + ("R", "Rcommaaccent"), + ("G", "Gcommaaccent"), + ]; + let letters: Vec<&str> = pairs.iter().flat_map(|(base, accented)| [*base, *accented]).collect(); + let (size, baseline) = (40u32, 60u32); + for font in ["Helvetica", "Times-Roman", "Courier", "Helvetica-BoldOblique"] { + let mut content = String::new(); + for i in 0..letters.len() { + content.push_str(&format!("BT /F1 {size} Tf {} {} Td <{:02X}> Tj ET\n", 10 + 40 * i, 80 - baseline, 1 + i)); + } + let differences = letters.iter().map(|n| format!("/{n}")).collect::>().join(" "); + let pdf = format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content}endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /{font} + /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 {differences}] >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + 20 + 40 * letters.len(), + content.len() + ); + let p = render(pdf.as_bytes()); + // The rows (y down) of the i-th letter that have dark pixels. + let ink_rows = |i: u32| -> Vec { + (0..p.height).filter(|&y| ((8 + 40 * i)..(48 + 40 * i).min(p.width)).any(|x| pixel(&p, x, y)[0] < 128)).collect() + }; + for (pair, (_, name)) in pairs.iter().enumerate() { + let (base, accented) = (ink_rows(2 * pair as u32), ink_rows(2 * pair as u32 + 1)); + let (Some(&base_bottom), Some(&bottom)) = (base.last(), accented.last()) else { + panic!("{font} {name}: draws nothing ({} rows of its base letter)", base.len()); + }; + assert!(bottom > base_bottom + 2, "{font} {name}: a mark hangs below the letter ({bottom} vs {base_bottom})"); + assert!( + (base_bottom + 1..bottom).any(|y| !accented.contains(&y)), + "{font} {name}: the mark is apart from the letter, a comma and not a cedilla" + ); + assert!(bottom < baseline + size * 3 / 10, "{font} {name}: the comma stays within the descender ({bottom})"); + } + } +} + +/// A one-page PDF: `page` holds the page dictionary's entries besides its tree links and +/// contents (the media box, resources, annotations), and `objects` the objects from 5 on. +fn one_page(page: &str, content: &[u8], objects: &[u8]) -> Vec { + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R {page} /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n", + content.len() + ) + .into_bytes(); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice(objects); + pdf.extend_from_slice(b"\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf +} + +/// PdfCraft ledger hayro-interpret (46) and the hayro stencil paint (#2975): a stencil mask is painted in the +/// fill color at the fill opacity, once: at `ca 0.5` it is as opaque as a rectangle filled at +/// `ca 0.5` beside it. The same holds when the fill is a pattern, an uncolored tiling pattern +/// (drawn in its fill color, not its stroke color) or a shading pattern. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn stencil_masks_are_drawn_at_the_fill_opacity_once() { + let fills = [("color", "0 0 1 rg"), ("uncolored tiling pattern", "/CsP cs 0 0 1 /Tp scn"), ("shading pattern", "/Pattern cs /Sp scn")]; + for (fill_name, fill) in fills { + for alpha in ["1", "0.5", "0.25"] { + let content = format!("/A gs {fill} q 100 0 0 100 20 50 cm /Msk Do Q 160 50 100 100 re f"); + let page = format!( + "/MediaBox [0 0 300 200] /Resources << /ExtGState << /A << /ca {alpha} >> >> /XObject << /Msk 5 0 R >> \ + /ColorSpace << /CsP [/Pattern /DeviceRGB] >> /Pattern << /Tp 6 0 R /Sp 7 0 R >> >>" + ); + let objects = b"5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Length 2 >> stream\n\0\0\nendstream endobj\n\ +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length 14 >> stream\n0 0 10 10 re f\nendstream endobj\n\ +7 0 obj << /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 300 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> >> >> endobj"; + let p = render(&one_page(&page, content.as_bytes(), objects)); + // Pixel rows run top down: PDF (70, 100) is in the mask, (210, 100) in the rectangle. + let (mask, rect) = (pixel(&p, 70, 100), pixel(&p, 210, 100)); + assert!(rect[..3] != [255, 255, 255], "{fill_name}, ca {alpha}: the rectangle is painted"); + assert!(mask.iter().zip(rect).all(|(m, r)| m.abs_diff(*r) <= 1), "{fill_name}, ca {alpha}: mask {mask:?}, rectangle {rect:?}"); + } + } +} + +/// PdfCraft #3246 (#3176, with ledger hayro-interpret (46)): `CA` and `ca` belong to the graphics +/// state at paint time, so a `gs` after `SCN` or `scn` still fades a shading pattern, and a +/// stroke takes the stroke alpha. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn shading_pattern_uses_the_alpha_current_at_paint_time() { + let content = b"/Pattern CS /P0 SCN /S1 gs 10 w 0 75 m 100 75 l S /Pattern cs /P0 scn /F1 gs 0 0 100 50 re f"; + let page = "/MediaBox [0 0 100 100] /Resources << /ExtGState << /S1 << /CA 0.25 >> /F1 << /ca 0.25 >> >> /Pattern << /P0 5 0 R >> >>"; + let objects = b"5 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] \ +/Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> endobj"; + let p = render(&one_page(page, content, objects)); + // A quarter red over white: (255, 191, 191). + for (what, y) in [("stroke", 25), ("fill", 75)] { + let px = pixel(&p, 50, y); + assert!(px[0] == 255 && (185..=197).contains(&px[1]) && (185..=197).contains(&px[2]), "{what}: {px:?}"); + } +} + +fn type4_mesh_page(data: &[u8]) -> Pixels { + let mut objects = format!( + "5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 \ + /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", + data.len() + ) + .into_bytes(); + objects.extend_from_slice(data); + objects.extend_from_slice(b"\nendstream endobj"); + let page = render(&one_page("/MediaBox [0 0 50 50] /Resources << /Shading << /Sh0 5 0 R >> >>", b"/Sh0 sh", &objects)); + assert_eq!((page.width, page.height), (50, 50)); + page +} + +/// PdfCraft ledger hayro-interpret (36) (#3035): two triangles of a mesh that share an edge cover it +/// together. Testing only each pixel's corner, strictly inside a triangle, left the shared +/// diagonal white, and a pixel row or column along the mesh's own sides. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn mesh_triangles_sharing_an_edge_leave_no_seam() { + // A red square from 10 to 40 on a 50 × 50 page, cut along its diagonal; in device space + // the diagonal runs through pixel centers and corners alike. + let vertex = |flag: u8, x: u8, y: u8| [flag, x, y, 255, 0, 0]; + let (lo, hi) = (10, 40); + let mut triangles = Vec::new(); + for [a, b, c] in [[(lo, lo), (hi, lo), (hi, hi)], [(lo, lo), (hi, hi), (lo, hi)]] { + for (x, y) in [a, b, c] { + triangles.extend(vertex(0, x, y)); + } + } + // Each triangle as its own three vertices, and the second one as a strip (flag 1). + let mut strip = Vec::new(); + for (flag, (x, y)) in [(0, (lo, lo)), (0, (hi, lo)), (0, (hi, hi)), (2, (lo, hi))] { + strip.extend(vertex(flag, x, y)); + } + for (label, data) in [("separate triangles", triangles), ("a strip", strip)] { + let page = type4_mesh_page(&data); + let (mut white_inside, mut red_outside) = (Vec::new(), Vec::new()); + for y in 0..50 { + for x in 0..50 { + let p = pixel(&page, x, y); + let inside = (10..40).contains(&x) && (10..40).contains(&y); + if inside && p != [255, 0, 0, 255] { + white_inside.push((x, y)); + } else if !inside && p != [255, 255, 255, 255] { + red_outside.push((x, y)); + } + } + } + assert!(white_inside.is_empty(), "{label}: unshaded pixels inside the square: {white_inside:?}"); + assert!(red_outside.is_empty(), "{label}: shaded pixels outside the square: {red_outside:?}"); + } + + // One triangle alone, the square's upper left half: a pixel whose corner lies on its + // diagonal is taken by its center, so one wholly across the diagonal (x + y ≥ 50 in + // device space) is not shaded, and one wholly inside is. + let mut data = Vec::new(); + for (x, y) in [(lo, lo), (hi, hi), (lo, hi)] { + data.extend(vertex(0, x, y)); + } + let page = type4_mesh_page(&data); + let (mut spilled, mut missing) = (Vec::new(), Vec::new()); + for y in 10..40 { + for x in 10..40 { + let p = pixel(&page, x, y); + if x + y >= 50 && p == [255, 0, 0, 255] { + spilled.push((x, y)); + } else if x + y + 2 <= 50 && p != [255, 0, 0, 255] { + missing.push((x, y)); + } + } + } + assert!(spilled.is_empty(), "shaded pixels wholly across the diagonal: {spilled:?}"); + assert!(missing.is_empty(), "unshaded pixels wholly inside the triangle: {missing:?}"); +} + +/// PdfCraft ledger hayro-interpret (40) (#2987): an optional content membership dictionary +/// with a visibility expression (`/VE`, ISO 32000-2 §8.11.2.2) shows its content only when the +/// expression is true, and the expression takes precedence over `/OCGs` and `/P`. A malformed +/// expression, one naming an object that isn't a listed group, or one nested too deep falls +/// back to `/OCGs`. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn visibility_expressions_decide_what_a_membership_dictionary_shows() { + let render = |ocmd: &str, off: &[i32]| { + let catalog = "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>"; + let objects = format!("5 0 obj << /Type /OCG /Name (A) >> endobj\n6 0 obj << /Type /OCG /Name (B) >> endobj\n7 0 obj {ocmd} endobj"); + let pdf = String::from_utf8(one_page( + "/MediaBox [0 0 100 100] /Resources << /Properties << /M1 7 0 R >> >>", + b"/OC /M1 BDC 1 0 0 rg 0 0 100 100 re f EMC", + objects.as_bytes(), + )) + .unwrap() + .replacen("<< /Type /Catalog /Pages 2 0 R >>", catalog, 1); + let doc = open_slice(pdf.as_bytes(), None).unwrap(); + let settings = InterpreterSettings { ocg_overrides: Arc::new(off.iter().map(|n| (*n, 0, false)).collect()), ..Default::default() }; + let options = Options { background: [255; 4], scale: [0.5, 0.5], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), &settings, &options).unwrap(); + raster.bytes()[..4] == [255, 0, 0, 255] + }; + let and = "<< /Type /OCMD /VE [/And 5 0 R 6 0 R] >>"; + assert!(render(and, &[]), "And: both on"); + assert!(!render(and, &[5]), "And: one off"); + let or = "<< /Type /OCMD /VE [/Or 5 0 R 6 0 R] >>"; + assert!(render(or, &[5]), "Or: one on"); + assert!(!render(or, &[5, 6]), "Or: both off"); + let not = "<< /Type /OCMD /VE [/Not 5 0 R] >>"; + assert!(!render(not, &[]), "Not: the group is on"); + assert!(render(not, &[5]), "Not: the group is off"); + let nested = "<< /Type /OCMD /VE [/And [/Not 5 0 R] 6 0 R] >>"; + assert!(render(nested, &[5]), "nested: A off, B on"); + assert!(!render(nested, &[]), "nested: A on"); + let both = "<< /Type /OCMD /OCGs [5 0 R] /P /AllOn /VE [/Not 5 0 R] >>"; + assert!(render(both, &[5]), "the expression, not /P, decides"); + // Malformed: an unknown operator, a wrong operand count, a bare group, a string operand, + // a missing object, and the page instead of a group. + for bad in ["[/Xor 5 0 R]", "[/Not 5 0 R 6 0 R]", "[/And]", "[5 0 R]", "[/And (5)]", "[/Or 8 0 R]", "[/Or 3 0 R]"] { + let ocmd = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {bad} >>"); + assert!(render(&ocmd, &[]), "{bad}: /OCGs on"); + assert!(!render(&ocmd, &[5]), "{bad}: /OCGs off"); + } + let deep = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {}5 0 R{} >>", "[/Not ".repeat(41), "]".repeat(41)); + assert!(!render(&deep, &[5]), "too deep: /OCGs decides"); +} + +/// PdfCraft ledger hayro (18) (#2972): a page's objects composite with a transparent backdrop +/// and the page group is then put on the paper, so a blend mode only blends where something +/// was drawn. A green fill with `/BM /Screen` (or Lighten, Difference…) half over a red square +/// takes the mode's color over the red and stays plain green over the empty page. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn blend_modes_over_an_empty_part_of_the_page_keep_their_color() { + for (mode, over_red) in [ + ("Normal", Some([0, 255, 0])), + ("Multiply", Some([0, 0, 0])), + ("Screen", Some([255, 255, 0])), + ("Lighten", Some([255, 255, 0])), + ("Difference", Some([255, 255, 0])), + ("Exclusion", Some([255, 255, 0])), + ("ColorDodge", Some([255, 0, 0])), + ("Overlay", Some([255, 0, 0])), + // The non-separable modes mix hue, saturation and luminosity; over an empty part of + // the page they too keep the source color. + ("Hue", None), + ("Saturation", None), + ("Color", None), + ("Luminosity", None), + ] { + let page = format!("/MediaBox [0 0 300 200] /Resources << /ExtGState << /G1 << /Type /ExtGState /BM /{mode} >> >> >>"); + let p = render(&one_page(&page, b"1 0 0 rg 20 20 150 150 re f /G1 gs 0 1 0 rg 100 50 150 120 re f", b"")); + // Pixel rows run top down: PDF (210, 100) and (130, 100) are rows 100 from the top. + assert_eq!(pixel(&p, 210, 100), [0, 255, 0, 255], "{mode} over the empty page"); + if let Some(over_red) = over_red { + assert_eq!(pixel(&p, 130, 100)[..3], over_red, "{mode} over the red square"); + } + assert_eq!(pixel(&p, 280, 10), [255, 255, 255, 255], "{mode}: the paper stays white"); + } +} + +/// PdfCraft #3018 (ISO 32000-2 §8.6.5.6): the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` +/// color spaces of a resource dictionary stand in for the device spaces its content selects, +/// with color operators, images and shadings. The defaults are synthetic ICC profiles that send +/// device color somewhere it cannot go uncalibrated: red to green (or blue), black to mid gray, +/// black ink to blue. +mod default_color_spaces { + use super::{Pixels, render}; + + const WIDTH: usize = 130; + pub(super) const RED: [u8; 3] = [255, 0, 0]; + const GREEN: [u8; 3] = [0, 255, 0]; + pub(super) const BLUE: [u8; 3] = [0, 0, 255]; + const BLACK: [u8; 3] = [0, 0, 0]; + /// Device black through `gray_profile`: half the luminance of white, sRGB-encoded. + const MID_GRAY: [u8; 3] = [188, 188, 188]; + /// Full black ink through `cmyk_profile`: CIELAB (40, 20, -60) relative to D50. + const INK_BLUE: [u8; 3] = [67, 86, 194]; + const ONE_PIXEL_RGB: &str = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>"; + + /// An s15Fixed16Number (ICC.1 §4.6). + fn s15f16(value: f64) -> [u8; 4] { + ((value * 65536.0).round() as i32).to_be_bytes() + } + + /// An ICC v2 profile of device class `class` for the color space `space`, with `tags` + /// (signature, data) in that order. The device model stays blank: hayro skips the transform + /// of a profile whose model reads `sRGB`. + fn icc(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tags: &[([u8; 4], Vec)]) -> Vec { + let start = 128 + 4 + 12 * tags.len(); + let (mut table, mut data) = (Vec::new(), Vec::new()); + for (signature, tag) in tags { + table.extend_from_slice(signature); + table.extend_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes()); + table.extend_from_slice(&u32::try_from(tag.len()).unwrap().to_be_bytes()); + data.extend_from_slice(tag); + data.resize(data.len().next_multiple_of(4), 0); + } + let mut profile = vec![0; 128]; + profile[0..4].copy_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes()); + profile[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + profile[12..16].copy_from_slice(class); + profile[16..20].copy_from_slice(space); + profile[20..24].copy_from_slice(pcs); + profile[36..40].copy_from_slice(b"acsp"); + for (i, value) in [0.9642, 1.0, 0.8249].into_iter().enumerate() { + profile[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15f16(value)); + } + profile.extend_from_slice(&u32::try_from(tags.len()).unwrap().to_be_bytes()); + profile.extend(table); + profile.extend(data); + profile + } + + fn xyz([x, y, z]: [f64; 3]) -> Vec { + [b"XYZ ".as_slice(), &[0; 4], &s15f16(x), &s15f16(y), &s15f16(z)].concat() + } + + /// A `curveType` tag: one entry is a gamma (u8Fixed8), more are points spread evenly over 0..1. + fn curve(points: &[u16]) -> Vec { + let mut tag = [b"curv".as_slice(), &[0; 4], &u32::try_from(points.len()).unwrap().to_be_bytes()].concat(); + for point in points { + tag.extend_from_slice(&point.to_be_bytes()); + } + tag + } + + /// sRGB's red, green and blue colorants, adapted to D50. + const SRGB_COLORANTS: [[f64; 3]; 3] = [[0.4361, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]]; + + /// A linear RGB profile whose red, green and blue take the sRGB colorants `primaries` names: + /// with `[1, 2, 0]` red is drawn as sRGB green. + pub(super) fn rgb_profile(primaries: [usize; 3]) -> Vec { + let mut tags = Vec::new(); + for (signature, primary) in [*b"rXYZ", *b"gXYZ", *b"bXYZ"].into_iter().zip(primaries) { + tags.push((signature, xyz(SRGB_COLORANTS[primary]))); + } + for signature in [*b"rTRC", *b"gTRC", *b"bTRC"] { + tags.push((signature, curve(&[0x0100]))); + } + icc(b"mntr", b"RGB ", b"XYZ ", &tags) + } + + /// A gray profile whose tone curve rises from half the luminance of white, so `0 g` is mid gray. + fn gray_profile() -> Vec { + icc(b"mntr", b"GRAY", b"XYZ ", &[(*b"kTRC", curve(&[0x8000, 0xffff]))]) + } + + /// A CMYK profile (`lut8Type` into CIELAB) that draws full black ink as L*a*b* (40, 20, -60) + /// and no black ink as white, whatever the other inks. + fn cmyk_profile() -> Vec { + let mut lut = [b"mft1".as_slice(), &[0; 4], &[4, 3, 2, 0]].concat(); + for value in [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] { + lut.extend_from_slice(&s15f16(value)); + } + for _ in 0..4 { + lut.extend(0..=255_u8); + } + // Two grid points per ink, black varying fastest; Lab encoded as L* · 2.55, a* + 128, b* + 128. + for point in 0..16 { + lut.extend_from_slice(if point % 2 == 1 { &[102, 148, 68] } else { &[255, 128, 128] }); + } + for _ in 0..3 { + lut.extend(0..=255_u8); + } + icc(b"prtr", b"CMYK", b"Lab ", &[(*b"A2B0", lut)]) + } + + pub(super) fn profile_stream(n: u8, profile: Vec) -> (String, Vec) { + (format!("<< /N {n} /Length {} >>", profile.len()), profile) + } + + /// A form XObject covering the page, with the dictionary entries `extra`. + fn form(extra: &str, content: &str) -> (String, Vec) { + (format!("<< /Type /XObject /Subtype /Form /BBox [0 0 130 70] {extra} /Length {} >>", content.len()), content.as_bytes().to_vec()) + } + + /// A 130 × 70 pt page that draws `content` with `resources`, whose objects 5, 6, … are the + /// streams `objects`. + pub(super) fn page_pdf(content: &str, resources: &str, objects: &[(String, Vec)]) -> Vec { + let mut pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 130 70] /Contents 4 0 R /Resources {resources} >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + ) + .into_bytes(); + for (i, (dict, data)) in objects.iter().enumerate() { + pdf.extend(format!("{} 0 obj {dict} stream\n", i + 5).bytes()); + pdf.extend_from_slice(data); + pdf.extend_from_slice(b"\nendstream endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + pdf + } + + /// Draw `content` with `resources` on a 130 × 70 pt page whose objects 5, 6, … are the + /// streams `objects`, and give the color at each of `points` (from the top left). + pub(super) fn colors(content: &str, resources: &str, objects: &[(String, Vec)], points: &[(usize, usize)]) -> Vec<[u8; 3]> { + let Pixels { width, height, rgba } = render(&page_pdf(content, resources, objects)); + assert_eq!((width as usize, height), (WIDTH, 70)); + points.iter().map(|&(x, y)| rgba[(y * WIDTH + x) * 4..][..3].try_into().unwrap()).collect() + } + + #[track_caller] + pub(super) fn assert_near(got: [u8; 3], want: [u8; 3], what: &str) { + assert!(got.iter().zip(want).all(|(got, want)| got.abs_diff(want) <= 6), "{what}: got {got:?}, expected about {want:?}"); + } + + /// Device CMYK black ink through hayro's own CMYK profile: near black. + #[track_caller] + fn assert_dark(got: [u8; 3], what: &str) { + assert!(got.iter().all(|c| *c < 90), "{what}: got {got:?}, expected near black"); + } + + /// `rg`, `RG`, `/DeviceRGB cs` and a `/ColorSpace` resource naming DeviceRGB all draw in the + /// page's DefaultRGB; without one they draw device red. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_rgb_operators_draw_in_default_rgb() { + let content = "1 0 0 rg 0 0 30 70 re f /DeviceRGB cs 1 0 0 sc 32 0 30 70 re f /CS0 cs 1 0 0 sc 64 0 30 70 re f \ + 1 0 0 RG 20 w 111 0 m 111 70 l S"; + let points = [(15, 35), (47, 35), (79, 35), (111, 35)]; + let what = ["rg", "/DeviceRGB cs", "/CS0 cs (/DeviceRGB)", "RG"]; + let objects = [profile_stream(3, rgb_profile([1, 2, 0]))]; + let calibrated = colors(content, "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /CS0 /DeviceRGB >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, GREEN, &format!("{what} with DefaultRGB")); + } + let device = colors(content, "<< /ColorSpace << /CS0 /DeviceRGB >> >>", &objects, &points); + for (got, what) in device.into_iter().zip(what) { + assert_eq!(got, RED, "{what} without DefaultRGB"); + } + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_cmyk_operators_draw_in_default_cmyk() { + let content = "0 0 0 1 k 0 0 40 70 re f /DeviceCMYK cs 0 0 0 1 sc 45 0 40 70 re f 0 0 0 1 K 20 w 110 0 m 110 70 l S"; + let points = [(20, 35), (65, 35), (110, 35)]; + let what = ["k", "/DeviceCMYK cs", "K"]; + let objects = [profile_stream(4, cmyk_profile())]; + let calibrated = colors(content, "<< /ColorSpace << /DefaultCMYK [/ICCBased 5 0 R] >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, INK_BLUE, &format!("{what} with DefaultCMYK")); + } + for (got, what) in colors(content, "<< >>", &objects, &points).into_iter().zip(what) { + assert_dark(got, &format!("{what} without DefaultCMYK")); + } + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_gray_operators_draw_in_default_gray() { + let content = "0 g 0 0 40 70 re f /DeviceGray cs 0 sc 45 0 40 70 re f 0 G 20 w 110 0 m 110 70 l S"; + let points = [(20, 35), (65, 35), (110, 35)]; + let what = ["g", "/DeviceGray cs", "G"]; + let objects = [profile_stream(1, gray_profile())]; + let calibrated = colors(content, "<< /ColorSpace << /DefaultGray [/ICCBased 5 0 R] >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, MID_GRAY, &format!("{what} with DefaultGray")); + } + for (got, what) in colors(content, "<< >>", &objects, &points).into_iter().zip(what) { + assert_eq!(got, BLACK, "{what} without DefaultGray"); + } + } + + /// Image XObjects and inline images (whose `/RGB` and `/G` abbreviate the device spaces, or + /// that name a `/ColorSpace` resource that is DeviceRGB) in a device space draw in its default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_images_draw_in_the_default_color_spaces() { + let content = "q 20 0 0 70 0 0 cm /R Do Q q 20 0 0 70 22 0 cm /G Do Q q 20 0 0 70 44 0 cm /K Do Q \ + q 20 0 0 70 66 0 cm BI /W 1 /H 1 /CS /RGB /BPC 8 /F /AHx ID ff0000> EI Q \ + q 20 0 0 70 88 0 cm BI /W 1 /H 1 /CS /CS0 /BPC 8 /F /AHx ID ff0000> EI Q \ + q 20 0 0 70 110 0 cm BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI Q"; + let points = [(10, 35), (32, 35), (54, 35), (76, 35), (98, 35), (120, 35)]; + let image = |space: &str, data: &[u8]| { + let dict = + format!("<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /{space} /BitsPerComponent 8 /Length {} >>", data.len()); + (dict, data.to_vec()) + }; + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + profile_stream(1, gray_profile()), + profile_stream(4, cmyk_profile()), + image("DeviceRGB", &[255, 0, 0]), + image("DeviceGray", &[0]), + image("DeviceCMYK", &[0, 0, 0, 255]), + ]; + let x_objects = "/XObject << /R 8 0 R /G 9 0 R /K 10 0 R >>"; + let defaults = "/DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] /DefaultCMYK [/ICCBased 7 0 R]"; + let calibrated = colors(content, &format!("<< /ColorSpace << {defaults} /CS0 /DeviceRGB >> {x_objects} >>"), &objects, &points); + assert_near(calibrated[0], GREEN, "DeviceRGB image"); + assert_near(calibrated[1], MID_GRAY, "DeviceGray image"); + assert_near(calibrated[2], INK_BLUE, "DeviceCMYK image"); + assert_near(calibrated[3], GREEN, "inline /RGB image"); + assert_near(calibrated[4], GREEN, "inline image in /CS0 (/DeviceRGB)"); + assert_near(calibrated[5], MID_GRAY, "inline /G image"); + + let device = colors(content, &format!("<< /ColorSpace << /CS0 /DeviceRGB >> {x_objects} >>"), &objects, &points); + assert_eq!(device[0], RED, "DeviceRGB image without DefaultRGB"); + assert_eq!(device[1], BLACK, "DeviceGray image without DefaultGray"); + assert_dark(device[2], "DeviceCMYK image without DefaultCMYK"); + assert_eq!(device[3], RED, "inline /RGB image without DefaultRGB"); + assert_eq!(device[4], RED, "inline /CS0 image without DefaultRGB"); + assert_eq!(device[5], BLACK, "inline /G image without DefaultGray"); + } + + /// Shadings in a device space, painted by `sh` or as a shading pattern, with colors from a + /// function (axial) or from their vertices (a Type 4 mesh), draw in its default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_shadings_draw_in_the_default_color_spaces() { + let axial = |space: &str, color: &str| { + format!( + "<< /ShadingType 2 /ColorSpace /{space} /Coords [0 0 130 0] /Extend [true true] \ + /Function << /FunctionType 2 /Domain [0 1] /C0 [{color}] /C1 [{color}] /N 1 >> >>" + ) + }; + // Two red triangles over the page: a flag, x, y and red, green and blue, a byte each. + let mut mesh: Vec = Vec::new(); + for (x, y) in [(0, 0), (255, 0), (0, 255), (255, 0), (255, 255), (0, 255)] { + mesh.extend_from_slice(&[0, x, y, 255, 0, 0]); + } + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + profile_stream(1, gray_profile()), + ( + format!( + "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 \ + /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >>", + mesh.len() + ), + mesh, + ), + ]; + let content = "q 0 0 30 70 re W n /A sh Q /Pattern cs /P scn 32 0 30 70 re f \ + q 64 0 30 70 re W n /M sh Q q 96 0 30 70 re W n /G sh Q"; + let points = [(15, 35), (47, 35), (79, 35), (111, 35)]; + let shadings = format!( + "/Shading << /A {} /M 7 0 R /G {} >> /Pattern << /P << /PatternType 2 /Shading {} >> >>", + axial("DeviceRGB", "1 0 0"), + axial("DeviceGray", "0"), + axial("DeviceRGB", "1 0 0") + ); + let calibrated = colors( + content, + &format!("<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] >> {shadings} >>"), + &objects, + &points, + ); + assert_near(calibrated[0], GREEN, "sh, an axial shading in DeviceRGB"); + assert_near(calibrated[1], GREEN, "a shading pattern in DeviceRGB"); + assert_near(calibrated[2], GREEN, "sh, a Type 4 mesh in DeviceRGB"); + assert_near(calibrated[3], MID_GRAY, "sh, an axial shading in DeviceGray"); + + let device = colors(content, &format!("<< {shadings} >>"), &objects, &points); + assert_near(device[0], RED, "sh, an axial shading without DefaultRGB"); + assert_near(device[1], RED, "a shading pattern without DefaultRGB"); + assert_near(device[2], RED, "sh, a Type 4 mesh without DefaultRGB"); + assert_near(device[3], BLACK, "sh, an axial shading without DefaultGray"); + } + + /// One image drawn by a page with DefaultRGB and by a form that switches it off is decoded + /// for each: the decoded-image cache keys the default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn an_image_drawn_with_and_without_a_default_keeps_both_colors() { + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + (ONE_PIXEL_RGB.to_owned(), vec![255, 0, 0]), + form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> /XObject << /R 6 0 R >> >>", "q 40 0 0 70 70 0 cm /R Do Q"), + ]; + let got = colors( + "q 40 0 0 70 0 0 cm /R Do Q /F Do", + "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] >> /XObject << /R 6 0 R /F 7 0 R >> >>", + &objects, + &[(20, 35), (90, 35)], + ); + assert_near(got[0], GREEN, "the image on the page, in DefaultRGB"); + assert_eq!(got[1], RED, "the same image in the form without DefaultRGB"); + } + + /// A form XObject draws device color in the defaults of the resources in its scope: the + /// page's when it has no `/Resources` (directly or nested in another form), else only its + /// own, as for every other resource here. PdfCraft, Poppler and MuPDF also fall back to the + /// page's default from a form with `/Resources` of its own. A form whose own default is a + /// device space or not a usable default draws device color. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn forms_draw_in_the_default_color_spaces_of_their_resource_scope() { + let blue = rgb_profile([2, 0, 1]); + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + // hayro caches a profile by its stream's dictionary, which would otherwise match the first. + (format!("<< /N 3 /Alternate /DeviceRGB /Length {} >>", blue.len()), blue), + form("", "1 0 0 rg 22 37 20 33 re f"), + form("/Resources << /ProcSet [/PDF] >>", "1 0 0 rg 44 37 20 33 re f"), + form("/Resources << /ColorSpace << /DefaultRGB [/ICCBased 6 0 R] >> /XObject << /N 12 0 R >> >>", "1 0 0 rg 66 37 20 33 re f /N Do"), + form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> >>", "1 0 0 rg 88 37 20 33 re f"), + form("/Resources << /ColorSpace << /DefaultRGB [/Lab << /WhitePoint [0.9642 1 0.8249] >>] >> >>", "1 0 0 rg 110 37 20 33 re f"), + form("", "1 0 0 rg 0 0 20 33 re f"), + form("/Resources << /ColorSpace << /CS1 /DeviceGray >> >>", "1 0 0 rg 22 0 20 33 re f"), + ]; + let got = colors( + "1 0 0 rg 0 37 20 33 re f /A Do /B Do /C Do /D Do /E Do /F Do", + "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] >> /XObject << /A 7 0 R /B 8 0 R /C 9 0 R /D 10 0 R /E 11 0 R /F 13 0 R >> >>", + &objects, + &[(10, 17), (32, 17), (54, 17), (76, 17), (98, 17), (120, 17), (10, 53), (32, 53)], + ); + assert_near(got[0], GREEN, "the page"); + assert_near(got[1], GREEN, "a form without /Resources"); + assert_eq!(got[2], RED, "a form whose /Resources has no /ColorSpace"); + assert_near(got[3], BLUE, "a form with a DefaultRGB of its own"); + assert_eq!(got[4], RED, "a form whose DefaultRGB is /DeviceRGB"); + assert_eq!(got[5], RED, "a form whose DefaultRGB is a Lab space"); + assert_near(got[6], BLUE, "a form nested in the one with its own DefaultRGB"); + assert_eq!(got[7], RED, "a form whose /ColorSpace has no DefaultRGB"); + } + + /// A default that cannot stand in for its device space (Lab, Indexed, Pattern, another device + /// space, the wrong number of components, or no color space) leaves device color as it is, + /// and the dictionary's other defaults still apply. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn unusable_defaults_leave_device_color_alone() { + let objects = [ + profile_stream(1, gray_profile()), + profile_stream(4, cmyk_profile()), + (String::from("<< /N 3 /Length 13 >>"), b"not a profile".to_vec()), + ]; + for default in [ + "[/Lab << /WhitePoint [0.9642 1 0.8249] >>]", + "[/CalGray << /WhitePoint [0.9642 1 0.8249] >>]", + "[/Indexed /DeviceRGB 0 <00FF00>]", + "/Pattern", + "[/Pattern /DeviceRGB]", + "/DeviceCMYK", + "/DeviceGray", + "[/ICCBased 6 0 R]", + "[/ICCBased 7 0 R]", + "42", + "[/NoSuchSpace]", + "/DefaultRGB", + ] { + let got = colors( + "1 0 0 rg 0 0 60 70 re f 0 g 70 0 60 70 re f", + &format!("<< /ColorSpace << /DefaultRGB {default} /DefaultGray [/ICCBased 5 0 R] >> >>"), + &objects, + &[(30, 35), (100, 35)], + ); + assert_eq!(got[0], RED, "DefaultRGB {default}: device RGB is drawn as it is"); + assert_near(got[1], MID_GRAY, &format!("DefaultRGB {default}: DefaultGray still applies")); + } + } +} + +/// PdfCraft ledger hayro-interpret (48) and the ICC profile cache key (#3020): two `/ICCBased` +/// profiles whose stream dictionaries are byte for byte the same (`/N 3` and one `/Length`) +/// each draw through their own profile. hayro cached the color space it read from a profile +/// stream by the dictionary's bytes alone, so the second profile drew through whichever was +/// read first. The profiles are linear sRGB and the same profile with red and blue swapped, +/// which draws red as blue; they have the same length. +mod icc_profile_cache { + use super::default_color_spaces::{BLUE, RED, assert_near, colors, page_pdf, profile_stream, rgb_profile}; + use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + TransformExt, font::Glyph, + }; + use kurbo::{Affine, BezPath, Rect}; + + /// An `/ICCBased` space whose profile stream is written directly in the space's array, not + /// as an object of its own: the profile as ASCIIHex digits, each a token of its own, in + /// upper case (a lone `f` reads as a broken `false`). Its dictionary reports the object + /// holding the array, which is the page for every one of them. + fn direct_space(profile: &[u8]) -> String { + let hex: String = profile.iter().flat_map(|b| [b >> 4, b & 0xf]).map(|d| format!("{d:X} ")).collect(); + format!("[/ICCBased << /N 3 /Filter /ASCIIHexDecode /Length {} >> stream\n{hex}\nendstream]", hex.len()) + } + + /// Six 20 pt squares, side by side, filled with `1 0 0 sc` (red) in `/A` (object 5, linear + /// sRGB), `/B` (object 6, red and blue swapped), `/A2` (object 5 again, through an array of + /// other bytes), `/A3` (object 7, a copy of object 5), `/C` and `/D` (the two profiles + /// again, each written directly in its array). Objects 5, 6 and 7 have the same dictionary, + /// and so do the two direct streams. + fn page() -> (String, String, Vec<(String, Vec)>) { + let (srgb, swapped) = (rgb_profile([0, 1, 2]), rgb_profile([2, 1, 0])); + assert_eq!(srgb.len(), swapped.len(), "the profiles have the same length, so the same dictionary"); + assert_ne!(srgb, swapped); + let resources = format!( + "<< /ColorSpace << /A [/ICCBased 5 0 R] /B [/ICCBased 6 0 R] /A2 [ /ICCBased 5 0 R ] /A3 [/ICCBased 7 0 R] /C {} /D {} >> >>", + direct_space(&srgb), + direct_space(&swapped) + ); + let content = ["A", "B", "A2", "A3", "C", "D"] + .iter() + .enumerate() + .map(|(i, name)| format!("/{name} cs 1 0 0 sc {} 20 20 30 re f", 2 + 21 * i)) + .collect::>() + .join("\n"); + (content, resources, vec![profile_stream(3, srgb.clone()), profile_stream(3, swapped), profile_stream(3, srgb)]) + } + + /// Each square is drawn through its own profile, whether the profile is an object or + /// written in its array, and object 5 looked up again and its copy still draw red. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn icc_profiles_with_identical_dictionaries_draw_their_own_colors() { + let (content, resources, objects) = page(); + let points: Vec<_> = (0..6).map(|i| (12 + 21 * i, 35)).collect(); + let got = colors(&content, &resources, &objects, &points); + let expected = [ + ("/A, object 5, linear sRGB", RED), + ("/B, object 6, red and blue swapped", BLUE), + ("/A2, object 5 again", RED), + ("/A3, object 7, a copy of object 5", RED), + ("/C, linear sRGB written in its array", RED), + ("/D, red and blue swapped written in its array", BLUE), + ]; + for (got, (what, want)) in got.into_iter().zip(expected) { + assert_near(got, want, what); + } + } + + /// Draws nothing: only the color spaces the page selects matter here. + struct Nothing; + impl<'a> Device<'a> for Nothing { + 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<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + /// The profiles are still cached: the four different ones (objects 5 and 6, and the two + /// written in their arrays) are read once each, and neither object 5 looked up again nor its + /// copy, object 7, is read again. A second pass through the same cache reads none. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn icc_profiles_used_again_are_read_once() { + let (content, resources, objects) = page(); + let bytes = page_pdf(&content, &resources, &objects); + let pdf = craft_pdf::open_slice(&bytes, None).unwrap(); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + for pass in ["first", "second"] { + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + craft_pdf::interpret_page(page, &mut ctx, &mut Nothing).unwrap(); + assert_eq!(cache.icc_profile_reads(), 4, "after the {pass} pass: each different profile is read once"); + } + } +} diff --git a/crates/pdf/tests/upstream_text.rs b/crates/pdf/tests/upstream_text.rs new file mode 100644 index 00000000..1e6fdacd --- /dev/null +++ b/crates/pdf/tests/upstream_text.rs @@ -0,0 +1,150 @@ +//! Text contracts adapted from PdfCraft a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0): +//! Annex D.2 alias codes, glyph names outside the Adobe Glyph List, and `/ToUnicode` identity names; +//! and from PdfCraft 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e, clipping-only text. +#![cfg(feature = "interpret")] +#![allow(clippy::unwrap_used, clippy::panic)] + +use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, + font::Glyph, +}; +use hayro_cmap::BfString; +use kurbo::{Affine, BezPath, Rect}; + +/// The text of each glyph drawn, `None` where the font gives none, and how it was drawn. +#[derive(Default)] +struct TextDevice { + glyphs: Vec>, + modes: Vec, +} + +impl Device<'_> for TextDevice { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, mode: &GlyphDrawMode) { + self.modes.push(match mode { + GlyphDrawMode::Fill => 'F', + GlyphDrawMode::Stroke(_) => 'S', + GlyphDrawMode::Clip => 'C', + GlyphDrawMode::Invisible => 'I', + }); + self.glyphs.push(glyph.as_unicode().map(|text| match text { + BfString::Char(c) => c.to_string(), + BfString::String(s) => s, + })); + } + fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +/// The glyphs a one-page PDF draws with its font `/F1`, object 5; `extra` holds further objects. +fn drawn_glyphs(font: &str, extra: &str, content: &[u8]) -> TextDevice { + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n", + content.len() + ) + .into_bytes(); + pdf.extend_from_slice(content); + pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj {font} endobj\n{extra}trailer << /Root 1 0 R >>\n%%EOF\n").as_bytes()); + let pdf = craft_pdf::open_slice(&pdf, None).unwrap(); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + let mut context = + Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 300.0, 300.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = TextDevice::default(); + craft_pdf::interpret_page(page, &mut context, &mut device).unwrap(); + device +} + +fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec> { + drawn_glyphs(font, extra, content).glyphs +} + +fn helvetica_texts(encoding: &str, content: &[u8]) -> Vec { + let font = format!("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding {encoding} >>"); + glyph_texts(&font, "", content).into_iter().map(|text| text.unwrap_or_default()).collect() +} + +/// PdfCraft ledger hayro-interpret (30): WinAnsiEncoding gives 0xAD and 0xA0 the glyph names +/// `hyphen` and `space`, and MacRomanEncoding gives 0xCA `space` (Annex D.2), so they read as the +/// ordinary hyphen and space rather than U+00AD and U+00A0. A `/Differences` entry naming +/// `sfthyphen` or `nbspace` keeps its glyph list value. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() { + assert_eq!( + helvetica_texts("/WinAnsiEncoding", b"BT /F1 20 Tf 20 150 Td (a\xadb\xa0c) Tj ET"), + ["a", "-", "b", " ", "c"], + "WinAnsi 0xAD and 0xA0" + ); + assert_eq!(helvetica_texts("/MacRomanEncoding", b"BT /F1 20 Tf 20 150 Td (a\xcab) Tj ET"), ["a", " ", "b"], "MacRoman 0xCA"); + assert_eq!( + helvetica_texts( + "<< /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [88 /sfthyphen 89 /nbspace] >>", + b"BT /F1 20 Tf 20 150 Td (aXbYc) Tj ET" + ), + ["a", "\u{ad}", "b", "\u{a0}", "c"], + "a named glyph keeps its glyph list value" + ); +} + +/// PdfCraft ledger hayro-interpret P6 (#3169, #3170): ligature (`f_i`) and suffixed +/// (`hyphen.case`) names read by the glyph list specification's full mapping. A name that only +/// numbers the glyph (`C39`, `c251`), a ZapfDingbats name (`a12`) and a name with no meaning +/// left (`foo`) read as nothing. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn glyph_names_outside_the_glyph_list_still_read_as_text() { + let encoding = "<< /Type /Encoding /Differences [30 /f_i /f_l 2 /hyphen.case 39 /C39 97 /c251 174 /C174 \ + 88 /a12 /foo /uni00660069 61 /C39.sc 233 /C1] >>"; + let texts = helvetica_texts(encoding, b"BT /F1 20 Tf 20 150 Td (\x1e\x1f\x02'a\xaeXYZ=\xe9) Tj ET"); + assert_eq!(texts, ["fi", "fl", "-", "", "", "", "", "", "fi", "", ""]); +} + +/// PdfCraft ledger hayro-interpret P6: the `uni` and `u` forms take uppercase hex digits only. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn glyph_names_with_lowercase_hex_read_as_nothing() { + let encoding = "<< /Type /Encoding /Differences [65 /uni20ac0308 /uni20AC0308 /u1040c.alt /u1040C.alt /f_uni20ac /f_uni20AC] >>"; + let texts = helvetica_texts(encoding, b"BT /F1 20 Tf 20 150 Td (ABCDEF) Tj ET"); + assert_eq!(texts, ["", "\u{20ac}\u{308}", "", "\u{1040c}", "f", "f\u{20ac}"]); +} + +/// PdfCraft ledger hayro-interpret (44): `/ToUnicode /Identity-H` (a name, not a CMap stream) +/// reads two-byte codes as their Unicode values. Controls (a DEL and a C1 control) are not text. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn identity_to_unicode_names_give_text() { + for name in ["Identity-H", "Identity-V"] { + let font = format!( + "<< /Type /Font /Subtype /Type0 /BaseFont /RobotoMono-Regular /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode /{name} >>" + ); + let cid = "6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /RobotoMono-Regular \ + /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 600 /CIDToGIDMap /Identity >> endobj\n"; + let texts = glyph_texts(&font, cid, b"BT /F1 12 Tf 20 200 Td <005A00FC0072006900630068002020AC007F0085> Tj ET"); + let texts: Vec = texts.into_iter().map(|text| text.unwrap_or_default()).collect(); + assert_eq!(texts, ["Z", "\u{fc}", "r", "i", "c", "h", " ", "\u{20ac}", "", ""], "{name}"); + } +} + +/// PdfCraft #3109 / #3116: text in mode 7 (clip only) reaches the device as +/// `GlyphDrawMode::Clip`, apart from mode 3 (invisible OCR text), so extraction that leaves out +/// OCR text keeps a heading drawn through its clip. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn clipping_only_text_reaches_the_device_apart_from_invisible_text() { + let font = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + for (mode, expected) in [(0, 'F'), (3, 'I'), (7, 'C')] { + let content = format!("q BT /F1 24 Tf {mode} Tr 30 200 Td (Hi) Tj ET 0 0 1 rg 0 0 300 300 re f Q"); + let drawn = drawn_glyphs(font, "", content.as_bytes()); + assert_eq!(drawn.glyphs, [Some("H".into()), Some("i".into())], "{mode} Tr"); + assert_eq!(drawn.modes, [expected; 2], "{mode} Tr"); + } +} diff --git a/crates/pdf/tests/writing.rs b/crates/pdf/tests/writing.rs index da611b61..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() { @@ -317,3 +334,40 @@ fn empty_language_is_absent_and_keeps_required_language_validation() { } assert_eq!(outputs[0], outputs[1], "empty language and absent language must serialize identically"); } + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn only_web_and_mail_link_targets_become_uri_links() { + use backend::annotation::{Annotation, LinkAnnotation}; + use hayro_syntax::object::{Array, Dict, String as PdfString}; + let urls = [ + " https://example.com/deck ", + "mailto:someone@example.com", + "javascript:alert(1)", + "data:text/html,", + "file://attacker.example/share/x", + "\\\\attacker.example\\share\\x", + "//attacker.example/share/x", + "ms-msdt:/id PCWDiagnostic", + "search-ms:query=x&crumb=location:\\\\attacker.example\\share", + ]; + let settings = backend::SerializeSettings { configuration: write::configuration(PdfVersion::Pdf17, None).unwrap(), ..Default::default() }; + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(200.0, 400.0).unwrap())); + for (i, url) in (0u8..).zip(urls) { + let rect = Rect::from_xywh(10.0, 10.0 + 30.0 * f32::from(i), 100.0, 20.0).unwrap(); + if let Some(target) = write::uri_target(url) { + page.add_annotation(Annotation::new_link(LinkAnnotation::new(rect, target), None)); + } + } + page.finish(); + let bytes = writer.finish().unwrap(); + let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let annotations = pdf.pages()[0].raw().get::>(b"Annots").unwrap(); + let uris: Vec = annotations + .iter::>() + .filter_map(|a| a.get::>(b"A")?.get::>(b"URI")) + .map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned()) + .collect(); + assert_eq!(uris, ["https://example.com/deck", "mailto:someone@example.com"]); +} diff --git a/crates/plate-test/Cargo.toml b/crates/plate-test/Cargo.toml new file mode 100644 index 00000000..f17bbce4 --- /dev/null +++ b/crates/plate-test/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "craft-plate-test" +description = "Print-color test scenes for Craft apps: reference PDFs and PDF structure checks" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +craft-cms = { workspace = true } +craft-pdf = { workspace = true, features = ["write-pdf", "write-simple-text"] } +hayro-syntax = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/plate-test/README.md b/crates/plate-test/README.md new file mode 100644 index 00000000..8b1f7056 --- /dev/null +++ b/crates/plate-test/README.md @@ -0,0 +1,161 @@ +# Craft plate test + +`craft-plate-test` holds print-color test scenes for the Craft apps and the tools to check PDFs +against them. A scene is an app-neutral JSON description of one page (spot inks, CMYK fills, +transparency, overprint, soft masks, placed PDFs), the ink each plate must carry at sample points, +and the PDF structure a correct print export has. + +- `scene`: serde types for the scene files in `scenes/`, and `Scene::validate` for the checks a + schema can't make (declared inks, geometry fields per object type, plates named by points). +- `reference`: writes a scene to PDF with krilla through craft-pdf. It tests the shared writer on + its own. A feature the writer can't express yet returns `Error::Unsupported`. +- `structure`: summarizes a PDF with hayro-syntax (page and form transparency groups with `/S /CS + /I /K`; graphics states with `OP op OPM BM SMask`; color space families; DeviceRGB use; + output intents) and checks the summary against a scene's `structure` block. +- `gs`, `tiff`, `judge`, `report`: the local plate check (below). + +`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]`. +Ink values are percentages (0 = no ink, 100 = solid). Plates use separation names: `Cyan`, +`Magenta`, `Yellow`, `Black` and the spot names. + +| Field | Meaning | +|---|---| +| `id`, `title`, `source`, `note` | Identity and origin (research case, app regression) | +| `page` | `[width, height]` in pt | +| `inks` | Spot inks, each `{"name", "cmyk": [c, m, y, k]}` or `"lab": [L, a, b]`; their order is the DeviceN colorant order after the process inks | +| `settings` | `group_cs` (`"rgb"` or `"cmyk"`: the transparency blending space), `output_intent` (`"fogra39"`: writes the scene with craft-pdf `write::print` settings and craft-cms's `COATED_FOGRA39_ICC`; `tests/scenes.rs` also writes every non-pending scene this way) | +| `objects` | Drawn in order (below) | +| `expect` | `plates`: every plate the separation must produce, process plates included, and no others. `points`: `{at, kind: "area" or "text", region (text), plate (text, default Black), inks: {plate: value or [lo, hi]}, tol, info, note}`; unlisted plates expect 0, `info` points are measured but not judged | +| `structure` | Expected PDF structure (below) | +| `pending` | The task that makes the scene writable, e.g. `"T4"`; pending scenes are parsed but not written | + +Objects have a `type`: `rect` and `ellipse` (`rect`), `text` (`at` = baseline start, `size`, +`text`), `image` (`rect`, `image`), `pdf` (`rect`, `scene` = id of a scene placed as a page), +`group` (`objects`, `isolated`, `knockout`). Any object can carry `opacity` (whole object, as a +transparency group), `blend` (`multiply`, `color`, … in kebab case), `overprint` (`{fill, stroke, +mode}`, mode = OPM, default 1) and `mask` (`{kind: "luminosity" or "alpha", content: [objects], +backdrop, transfer}`; `backdrop` is a process or gray paint, `transfer` a list of samples 0–1). Shapes and text take `fill` and `stroke` (`{paint, width}`). + +Paints: + +| Paint | Example | +|---|---| +| Process | `{"cmyk": [100, 0, 0, 0]}` | +| Gray (black ink %, written as DeviceGray) | `{"gray": 50}` | +| RGB (0–255) | `{"rgb": [255, 0, 0]}` | +| Spot | `{"spot": "Gold", "tint": 50}` (tint defaults to 100) | +| Several inks (DeviceN) | `{"inks": {"Cyan": 60, "Black": 40}}` | +| Registration (`/All`) | `"registration"` | +| Linear gradient | `{"linear": {"from": [x, y], "to": [x, y], "stops": [{"at": 0, "paint": …}, …]}}` | +| Mesh gradient (one Coons patch with straight edges) | `{"mesh": {"rect": [x, y, w, h], "corners": [paint, paint, paint, paint]}}` (top left, top right, bottom right, bottom left) | + +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. 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. + +### The `structure` block + +Each field is optional; only the fields present are checked. + +| Field | Check | +|---|---| +| `page_group` | The page group's `/CS` family; `"none"` = no page group, `"no-cs"` = a group without `/CS` | +| `group_cs` | The set of `/CS` values of form and soft-mask groups (`"none"` = no `/CS`) | +| `knockout`, `isolated` | Some group has `/K true` / `/I true` (page groups excluded for `isolated`) | +| `overprint`, `smask` | Some graphics state sets `OP` or `op` true / a soft mask | +| `blend_modes` | The set of blend modes other than Normal | +| `ext_g_states` | Each entry (keys `OP op OPM BM SMask BC TR`) matches some graphics state | +| `color_spaces`, `color_spaces_include` | The exact set / a subset of color space families, e.g. `DeviceCMYK`, `Separation(Gold, alt DeviceCMYK)` | +| `device_rgb` | Some painting color space, alternates included, is DeviceRGB | +| `output_intents` | The `/S` of each output intent | + +A failing check prints the full summary as JSON, which is also the quickest way to write a new +block. + +## Adding a scene + +1. Add `scenes/-.json`. Reuse the 360 × 216 pt page and the `Gold` spot unless the + case needs otherwise. Use fonts and images that the harness makes; never commit fonts, GWG + files or other third-party test files. +2. Add `pending` when the writer can't express the scene yet, and remove it in the change that + adds the feature. +3. Run `cargo test -p craft-plate-test`. + +Text uses `Inter-Regular.ttf` from `CRAFT_PDF_TEST_FONT_DIR` +(`python scripts/fetch-pdf-test-fonts.py `). Without it, text objects +are skipped with a printed note. + +## Use from an app + +Add the crate as a dev-dependency and write an adapter in the app's tests: build each scene as an +app document, export it through the app's own print path, and check the result with +`structure::summarize` and the scene's `structure` block. Scenes the app can't build yet are +skipped by the adapter, not edited. + +```rust +let scenes = craft_plate_test::scene::load_dir(&craft_plate_test::scene::scenes_dir())?; +for scene in scenes.iter().filter(|s| s.pending.is_none()) { + let pdf = my_app_export(scene)?; // the app's adapter + let summary = craft_plate_test::structure::summarize(&pdf)?; + assert!(scene.structure.check(&summary).is_empty()); +} +``` + +## Plate check (local only) + +```sh +scripts/plate-check.sh +``` + +The check writes every scene, separates it with Ghostscript's `tiffsep` device at 72 dpi with +overprint simulated, and judges the plates: + +- Area points: the median of the 5 × 5 pixels around the point, per plate. +- Text points: the median over the glyph's pixels, those in `region` with at least half the + darkest ink on the point's `plate`. +- Each point's plates within `tol` of the expected value (or inside the `[lo, hi]` range). +- The separation produces exactly the scene's `plates`. + +Pending scenes are written without the features they wait for (`reference::write_partial`) and +are expected to fail; the report shows what the writer prints today. A pending scene that passes +is reported as such: its plates don't show the missing feature under Ghostscript, so its +`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. + +Ghostscript is AGPL. It runs only as an external program on developer machines (`gs` on `PATH`, or +`CRAFT_PLATE_TEST_GS`), never in CI. Don't link it, copy its code, or commit its output. Without +Ghostscript the test returns with a note. diff --git a/crates/plate-test/scenes/D01-spot-control.json b/crates/plate-test/scenes/D01-spot-control.json new file mode 100644 index 00000000..0440f400 --- /dev/null +++ b/crates/plate-test/scenes/D01-spot-control.json @@ -0,0 +1,25 @@ +{ + "id": "D01", + "title": "Gold alone, no transparency", + "source": "DesignCraft DC01", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "objects": [{"type": "rect", "rect": [36, 36, 108, 144], "fill": {"spot": "Gold", "tint": 100}}], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [90, 108], "inks": {"Gold": 100}, "tol": 2, "note": "spot fill"}, + {"at": [300, 108], "inks": {}, "tol": 2, "note": "paper"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": false, + "smask": false, + "blend_modes": [], + "color_spaces": ["Separation(Gold, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/D04-spot-opacity.json b/crates/plate-test/scenes/D04-spot-opacity.json new file mode 100644 index 00000000..bcf8a25d --- /dev/null +++ b/crates/plate-test/scenes/D04-spot-opacity.json @@ -0,0 +1,37 @@ +{ + "id": "D04", + "title": "Gold at 50% opacity over C100", + "source": "DesignCraft DC04", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [36, 36, 180, 144], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [144, 36, 180, 144], + "fill": {"spot": "Gold", "tint": 100}, + "opacity": 0.5 + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [90, 108], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"}, + { + "at": [180, 108], + "inks": {"Cyan": 50, "Gold": 50}, + "tol": 6, + "note": "half the spot, half the cyan" + }, + {"at": [290, 108], "inks": {"Gold": 50}, "tol": 6, "note": "spot 50% over paper"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "group_cs": ["none"], + "color_spaces": ["DeviceCMYK", "Separation(Gold, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/D06-stroke-fill-overprint.json b/crates/plate-test/scenes/D06-stroke-fill-overprint.json new file mode 100644 index 00000000..aa05c33b --- /dev/null +++ b/crates/plate-test/scenes/D06-stroke-fill-overprint.json @@ -0,0 +1,84 @@ +{ + "id": "D06", + "title": "Overprint stroke and overprint fill, process and spot", + "source": "DesignCraft DC06", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "objects": [ + {"type": "rect", "rect": [20, 20, 150, 100], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [40, 40, 110, 60], + "stroke": {"paint": {"spot": "Gold", "tint": 100}, "width": 10}, + "overprint": {"stroke": true} + }, + {"type": "rect", "rect": [190, 20, 150, 100], "fill": {"cmyk": [0, 0, 100, 0]}}, + { + "type": "rect", + "rect": [210, 40, 110, 60], + "stroke": {"paint": {"cmyk": [0, 100, 0, 0]}, "width": 10}, + "overprint": {"stroke": true} + }, + {"type": "rect", "rect": [20, 130, 320, 70], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [40, 140, 120, 50], + "fill": {"cmyk": [0, 100, 0, 0]}, + "overprint": {"fill": true} + }, + { + "type": "rect", + "rect": [200, 140, 120, 50], + "fill": {"spot": "Gold", "tint": 100}, + "overprint": {"fill": true} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + { + "at": [40, 70], + "inks": {"Gold": 100, "Cyan": 100}, + "tol": 2, + "note": "spot stroke over cyan" + }, + {"at": [95, 70], "inks": {"Cyan": 100}, "tol": 2, "note": "inside the spot-stroked frame"}, + { + "at": [210, 70], + "inks": {"Magenta": 100, "Yellow": 100}, + "tol": 2, + "note": "magenta stroke over yellow" + }, + { + "at": [265, 70], + "inks": {"Yellow": 100}, + "tol": 2, + "note": "inside the magenta-stroked frame" + }, + { + "at": [100, 165], + "inks": {"Magenta": 100, "Cyan": 100}, + "tol": 2, + "note": "magenta fill over cyan" + }, + { + "at": [260, 165], + "inks": {"Gold": 100, "Cyan": 100}, + "tol": 2, + "note": "spot fill over cyan" + }, + {"at": [180, 165], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": true, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceCMYK", "Separation(Gold, alt DeviceCMYK)"], + "ext_g_states": [{"OP": true}, {"op": true}], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/D08-feather.json b/crates/plate-test/scenes/D08-feather.json new file mode 100644 index 00000000..8182a48e --- /dev/null +++ b/crates/plate-test/scenes/D08-feather.json @@ -0,0 +1,81 @@ +{ + "id": "D08", + "title": "Feathered Gold (10 pt soft-mask feather), alone and in a group with cyan", + "source": "DesignCraft DC08", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "settings": {"group_cs": "cmyk"}, + "objects": [ + { + "type": "rect", + "rect": [36, 36, 120, 144], + "fill": {"spot": "Gold", "tint": 100}, + "mask": { + "kind": "luminosity", + "backdrop": {"gray": 100}, + "content": [ + { + "type": "image", + "rect": [36, 36, 120, 144], + "image": {"px": [120, 144], "feather": 10} + } + ] + } + }, + { + "type": "group", + "objects": [ + { + "type": "rect", + "rect": [204, 36, 120, 144], + "fill": {"spot": "Gold", "tint": 100}, + "mask": { + "kind": "luminosity", + "backdrop": {"gray": 100}, + "content": [ + { + "type": "image", + "rect": [204, 36, 120, 144], + "image": {"px": [120, 144], "feather": 10} + } + ] + } + }, + {"type": "rect", "rect": [300, 170, 40, 36], "fill": {"cmyk": [100, 0, 0, 0]}} + ] + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [96, 108], "inks": {"Gold": 100}, "tol": 2, "note": "feathered spot, center"}, + { + "at": [38, 108], + "inks": {"Gold": [3, 70]}, + "tol": 10, + "note": "2 pt inside the edge: partial spot, no process ink" + }, + { + "at": [264, 108], + "inks": {"Gold": 100}, + "tol": 2, + "note": "feathered spot in a group, center" + }, + { + "at": [206, 108], + "inks": {"Gold": [3, 70]}, + "tol": 10, + "note": "2 pt inside the edge: partial spot, no process ink" + }, + {"at": [330, 195], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "smask": true, + "ext_g_states": [{"SMask": "Luminosity"}], + "color_spaces_include": ["Separation(Gold, alt DeviceCMYK)", "DeviceGray"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/D10-k100-text.json b/crates/plate-test/scenes/D10-k100-text.json new file mode 100644 index 00000000..54330b1b --- /dev/null +++ b/crates/plate-test/scenes/D10-k100-text.json @@ -0,0 +1,62 @@ +{ + "id": "D10", + "title": "K100 text (overprint) on a page with transparency, over 50% cyan and over paper", + "source": "DesignCraft DC10", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + { + "type": "rect", + "rect": [20, 20, 180, 176], + "fill": {"cmyk": [100, 0, 0, 0]}, + "opacity": 0.5 + }, + { + "type": "text", + "at": [30, 100], + "size": 60, + "text": "HHH", + "fill": {"cmyk": [0, 0, 0, 100]}, + "overprint": {"fill": true} + }, + { + "type": "text", + "at": [210, 100], + "size": 60, + "text": "HHH", + "fill": {"cmyk": [0, 0, 0, 100]}, + "overprint": {"fill": true} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + { + "at": [100, 80], + "kind": "text", + "region": [30, 56.2, 133.2, 43.8], + "inks": {"Black": 100, "Cyan": 50}, + "tol": 2, + "note": "K text over 50% cyan: overprint keeps the cyan" + }, + { + "at": [280, 80], + "kind": "text", + "region": [210, 56.2, 133.2, 43.8], + "inks": {"Black": 100}, + "tol": 2, + "note": "K text over paper: K only, no rich black" + }, + {"at": [100, 160], "inks": {"Cyan": 50}, "tol": 6, "note": "cyan at 50% opacity"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "overprint": true, + "ext_g_states": [{"op": true}], + "group_cs": ["none"], + "color_spaces": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} 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/scenes/M1-mask-backdrop.json b/crates/plate-test/scenes/M1-mask-backdrop.json new file mode 100644 index 00000000..d32cebdf --- /dev/null +++ b/crates/plate-test/scenes/M1-mask-backdrop.json @@ -0,0 +1,46 @@ +{ + "id": "M1", + "title": "M100 under a luminosity mask whose content is a black hole, /BC white", + "source": "new", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [60, 40, 240, 136], + "fill": {"cmyk": [0, 100, 0, 0]}, + "mask": { + "kind": "luminosity", + "backdrop": {"gray": 0}, + "content": [{"type": "rect", "rect": [140, 70, 80, 76], "fill": {"gray": 100}}] + } + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + { + "at": [80, 60], + "inks": {"Magenta": 100}, + "tol": 2, + "note": "outside the mask content: /BC white shows the object" + }, + { + "at": [180, 108], + "inks": {"Cyan": 100}, + "tol": 2, + "note": "inside the black hole: masked out" + }, + {"at": [30, 30], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "smask": true, + "ext_g_states": [{"SMask": "Luminosity", "BC": true}], + "color_spaces_include": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/M2-mask-transfer.json b/crates/plate-test/scenes/M2-mask-transfer.json new file mode 100644 index 00000000..6a744cc0 --- /dev/null +++ b/crates/plate-test/scenes/M2-mask-transfer.json @@ -0,0 +1,46 @@ +{ + "id": "M2", + "title": "M100 under a luminosity mask with an inverting /TR: the white mask content becomes a hole", + "source": "new", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [60, 40, 240, 136], + "fill": {"cmyk": [0, 100, 0, 0]}, + "mask": { + "kind": "luminosity", + "transfer": [1, 0], + "content": [{"type": "rect", "rect": [140, 70, 80, 76], "fill": {"gray": 0}}] + } + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + { + "at": [80, 60], + "inks": {"Magenta": 100}, + "tol": 2, + "note": "outside the mask content: default black backdrop, inverted to fully visible" + }, + { + "at": [180, 108], + "inks": {"Cyan": 100}, + "tol": 2, + "note": "white mask content, inverted: masked out" + }, + {"at": [30, 30], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "smask": true, + "ext_g_states": [{"SMask": "Luminosity", "BC": false, "TR": true}], + "color_spaces_include": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/N1-devicen-fill.json b/crates/plate-test/scenes/N1-devicen-fill.json new file mode 100644 index 00000000..78ee01c1 --- /dev/null +++ b/crates/plate-test/scenes/N1-devicen-fill.json @@ -0,0 +1,38 @@ +{ + "id": "N1", + "title": "DeviceN [Cyan Black] 60/40 fill overprinting Y100", + "source": "GWG 19 idea", + "page": [360, 216], + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [0, 0, 100, 0]}}, + { + "type": "rect", + "rect": [60, 60, 240, 96], + "fill": {"inks": {"Cyan": 60, "Black": 40}}, + "overprint": {"fill": true} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + { + "at": [180, 108], + "inks": {"Yellow": 100, "Cyan": 60, "Black": 40}, + "tol": 2, + "note": "DeviceN overprint leaves unnamed colorants alone" + }, + {"at": [30, 30], "inks": {"Yellow": 100}, "tol": 2, "note": "yellow only"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": true, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceCMYK", "DeviceN(Cyan,Black, alt DeviceCMYK)"], + "ext_g_states": [{"op": true}], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S01-black-overprint.json b/crates/plate-test/scenes/S01-black-overprint.json new file mode 100644 index 00000000..2cfd10d7 --- /dev/null +++ b/crates/plate-test/scenes/S01-black-overprint.json @@ -0,0 +1,50 @@ +{ + "id": "S01", + "title": "[Black] rect and text over C100, overprint on", + "source": "research S1, DesignCraft DC06", + "page": [360, 216], + "objects": [ + {"type": "rect", "rect": [20, 20, 300, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [40, 40, 100, 60], + "fill": {"cmyk": [0, 0, 0, 100]}, + "overprint": {"fill": true} + }, + { + "type": "text", + "at": [40, 160], + "size": 60, + "text": "HHH", + "fill": {"cmyk": [0, 0, 0, 100]}, + "overprint": {"fill": true} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + {"at": [90, 70], "inks": {"Cyan": 100, "Black": 100}, "tol": 2, "note": "K rect"}, + { + "at": [110, 140], + "kind": "text", + "region": [40, 116.2, 133.2, 43.8], + "inks": {"Cyan": 100, "Black": 100}, + "tol": 2, + "note": "K text" + }, + {"at": [250, 70], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"}, + {"at": [340, 108], "inks": {}, "tol": 2, "note": "paper"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": true, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceCMYK"], + "ext_g_states": [{"op": true}], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S02-black-knockout.json b/crates/plate-test/scenes/S02-black-knockout.json new file mode 100644 index 00000000..d7e7f5b0 --- /dev/null +++ b/crates/plate-test/scenes/S02-black-knockout.json @@ -0,0 +1,43 @@ +{ + "id": "S02", + "title": "[Black] rect and text over C100, overprint off", + "source": "research S2", + "page": [360, 216], + "objects": [ + {"type": "rect", "rect": [20, 20, 300, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + {"type": "rect", "rect": [40, 40, 100, 60], "fill": {"cmyk": [0, 0, 0, 100]}}, + { + "type": "text", + "at": [40, 160], + "size": 60, + "text": "HHH", + "fill": {"cmyk": [0, 0, 0, 100]} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + {"at": [90, 70], "inks": {"Black": 100}, "tol": 2, "note": "K rect"}, + { + "at": [110, 140], + "kind": "text", + "region": [40, 116.2, 133.2, 43.8], + "inks": {"Black": 100}, + "tol": 2, + "note": "K text" + }, + {"at": [250, 70], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"}, + {"at": [340, 108], "inks": {}, "tol": 2, "note": "paper"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": false, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S03-opm.json b/crates/plate-test/scenes/S03-opm.json new file mode 100644 index 00000000..7aebb275 --- /dev/null +++ b/crates/plate-test/scenes/S03-opm.json @@ -0,0 +1,51 @@ +{ + "id": "S03", + "title": "C50 Y50 under M100 with fill overprint, OPM 1 (left) and OPM 0 (right)", + "source": "research S3 (GWG 1.1 idea)", + "page": [360, 216], + "objects": [ + {"type": "rect", "rect": [20, 20, 150, 176], "fill": {"cmyk": [50, 0, 50, 0]}}, + { + "type": "rect", + "rect": [40, 60, 110, 96], + "fill": {"cmyk": [0, 100, 0, 0]}, + "overprint": {"fill": true, "mode": 1} + }, + {"type": "rect", "rect": [190, 20, 150, 176], "fill": {"cmyk": [50, 0, 50, 0]}}, + { + "type": "rect", + "rect": [210, 60, 110, 96], + "fill": {"cmyk": [0, 100, 0, 0]}, + "overprint": {"fill": true, "mode": 0} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + { + "at": [95, 108], + "inks": {"Cyan": 50, "Magenta": 100, "Yellow": 50}, + "tol": 2, + "note": "OPM 1: zero components leave the backdrop" + }, + { + "at": [265, 108], + "inks": {"Magenta": 100}, + "tol": 2, + "note": "OPM 0: every DeviceCMYK component paints" + }, + {"at": [30, 30], "inks": {"Cyan": 50, "Yellow": 50}, "tol": 2, "note": "backdrop"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": true, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceCMYK"], + "ext_g_states": [{"op": true, "OPM": 1}, {"op": true, "OPM": 0}], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S04-spot-overprint.json b/crates/plate-test/scenes/S04-spot-overprint.json new file mode 100644 index 00000000..20cd7fa2 --- /dev/null +++ b/crates/plate-test/scenes/S04-spot-overprint.json @@ -0,0 +1,34 @@ +{ + "id": "S04", + "title": "Gold 50 over C100, overprint on", + "source": "research S4 (GWG 2.0 idea)", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [100, 60, 160, 96], + "fill": {"spot": "Gold", "tint": 50}, + "overprint": {"fill": true} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [180, 108], "inks": {"Cyan": 100, "Gold": 50}, "tol": 2, "note": "spot over cyan"}, + {"at": [40, 108], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": true, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceCMYK", "Separation(Gold, alt DeviceCMYK)"], + "ext_g_states": [{"op": true}], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S05-spot-normal.json b/crates/plate-test/scenes/S05-spot-normal.json new file mode 100644 index 00000000..0f7aa15c --- /dev/null +++ b/crates/plate-test/scenes/S05-spot-normal.json @@ -0,0 +1,28 @@ +{ + "id": "S05", + "title": "Gold 50 over C100, overprint off", + "source": "research S5", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + {"type": "rect", "rect": [100, 60, 160, 96], "fill": {"spot": "Gold", "tint": 50}} + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [180, 108], "inks": {"Gold": 50}, "tol": 2, "note": "spot over cyan"}, + {"at": [40, 108], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": false, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceCMYK", "Separation(Gold, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S06-k50-opacity.json b/crates/plate-test/scenes/S06-k50-opacity.json new file mode 100644 index 00000000..13909de8 --- /dev/null +++ b/crates/plate-test/scenes/S06-k50-opacity.json @@ -0,0 +1,36 @@ +{ + "id": "S06", + "title": "K100 at 50% opacity over C100", + "source": "research S6 (the krilla RGB-group regression)", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [20, 20, 200, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [120, 20, 220, 176], + "fill": {"cmyk": [0, 0, 0, 100]}, + "opacity": 0.5 + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + {"at": [60, 108], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"}, + { + "at": [170, 108], + "inks": {"Cyan": 50, "Black": 50}, + "tol": 6, + "note": "CMYK blending: C50 K50, no M or Y" + }, + {"at": [300, 108], "inks": {"Black": 50}, "tol": 6, "note": "K 50% over paper"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "group_cs": ["none"], + "color_spaces": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S07-spot-multiply.json b/crates/plate-test/scenes/S07-spot-multiply.json new file mode 100644 index 00000000..8ca9d9b3 --- /dev/null +++ b/crates/plate-test/scenes/S07-spot-multiply.json @@ -0,0 +1,45 @@ +{ + "id": "S07", + "title": "Gold 100 Multiply over Teal 100 and over C100", + "source": "research S7, DesignCraft DC02", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}, {"name": "Teal", "cmyk": [80, 0, 30, 0]}], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [20, 20, 150, 176], "fill": {"spot": "Teal", "tint": 100}}, + {"type": "rect", "rect": [190, 20, 150, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [60, 60, 240, 96], + "fill": {"spot": "Gold", "tint": 100}, + "blend": "multiply" + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold", "Teal"], + "points": [ + {"at": [40, 108], "inks": {"Teal": 100}, "tol": 2, "note": "Teal only"}, + { + "at": [100, 108], + "inks": {"Teal": 100, "Gold": 100}, + "tol": 6, + "note": "both spots kept" + }, + {"at": [180, 108], "inks": {"Gold": 100}, "tol": 6, "note": "Gold over paper"}, + { + "at": [260, 108], + "inks": {"Cyan": 100, "Gold": 100}, + "tol": 6, + "note": "cyan kept under the spot" + }, + {"at": [330, 108], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "blend_modes": ["Multiply"], + "color_spaces": ["DeviceCMYK", "Separation(Gold, alt DeviceCMYK)", "Separation(Teal, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S08-nonseparable-spot.json b/crates/plate-test/scenes/S08-nonseparable-spot.json new file mode 100644 index 00000000..090ccb6a --- /dev/null +++ b/crates/plate-test/scenes/S08-nonseparable-spot.json @@ -0,0 +1,45 @@ +{ + "id": "S08", + "title": "M100 in Color blend mode over Gold and over C50", + "source": "research S8, DesignCraft DC09", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [36, 36, 180, 144], "fill": {"spot": "Gold", "tint": 100}}, + {"type": "rect", "rect": [216, 36, 124, 144], "fill": {"cmyk": [50, 0, 0, 0]}}, + { + "type": "rect", + "rect": [144, 72, 156, 72], + "fill": {"cmyk": [0, 100, 0, 0]}, + "blend": "color" + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [60, 108], "inks": {"Gold": 100}, "tol": 2, "note": "spot only"}, + { + "at": [180, 108], + "inks": {}, + "tol": 6, + "note": "spot plate composites Normal (knocked out); Color over a paper process backdrop gives paper" + }, + { + "at": [258, 108], + "inks": {}, + "tol": 6, + "info": true, + "note": "non-separable blend of process inks" + }, + {"at": [320, 160], "inks": {"Cyan": 50}, "tol": 2, "note": "cyan 50% only"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "blend_modes": ["Color"], + "color_spaces": ["DeviceCMYK", "Separation(Gold, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S09-knockout.json b/crates/plate-test/scenes/S09-knockout.json new file mode 100644 index 00000000..7c3390d9 --- /dev/null +++ b/crates/plate-test/scenes/S09-knockout.json @@ -0,0 +1,60 @@ +{ + "id": "S09", + "title": "Knockout group of a 50% cyan and a 50% magenta circle over yellow", + "source": "research S9, DesignCraft DC05", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [0, 0, 100, 0]}}, + { + "type": "group", + "knockout": true, + "objects": [ + { + "type": "ellipse", + "rect": [60, 38, 140, 140], + "fill": {"cmyk": [100, 0, 0, 0]}, + "opacity": 0.5 + }, + { + "type": "ellipse", + "rect": [160, 38, 140, 140], + "fill": {"cmyk": [0, 100, 0, 0]}, + "opacity": 0.5 + } + ] + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + {"at": [30, 108], "inks": {"Yellow": 100}, "tol": 2, "note": "yellow only"}, + { + "at": [100, 108], + "inks": {"Yellow": 50, "Cyan": 50}, + "tol": 6, + "note": "cyan 50% over yellow" + }, + { + "at": [180, 108], + "inks": {"Yellow": 50, "Magenta": 50}, + "tol": 6, + "note": "overlap: only the top circle over the backdrop (without knockout C25 M50 Y25)" + }, + { + "at": [260, 108], + "inks": {"Yellow": 50, "Magenta": 50}, + "tol": 6, + "note": "magenta 50% over yellow" + } + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "knockout": true, + "group_cs": ["none"], + "color_spaces": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S10-isolated-multiply.json b/crates/plate-test/scenes/S10-isolated-multiply.json new file mode 100644 index 00000000..aeed7f7e --- /dev/null +++ b/crates/plate-test/scenes/S10-isolated-multiply.json @@ -0,0 +1,51 @@ +{ + "id": "S10", + "title": "Isolated group: M100 Multiply over Y100, both over a C100 backdrop", + "source": "research S10", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "group", + "isolated": true, + "objects": [ + {"type": "rect", "rect": [60, 50, 120, 116], "fill": {"cmyk": [0, 0, 100, 0]}}, + { + "type": "rect", + "rect": [120, 50, 160, 116], + "fill": {"cmyk": [0, 100, 0, 0]}, + "blend": "multiply" + } + ] + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + {"at": [90, 108], "inks": {"Yellow": 100}, "tol": 2, "note": "yellow knocks out cyan"}, + { + "at": [150, 108], + "inks": {"Yellow": 100, "Magenta": 100}, + "tol": 6, + "note": "Multiply with a group member" + }, + { + "at": [250, 108], + "inks": {"Magenta": 100}, + "tol": 6, + "note": "Multiply over the group's transparent backdrop: no cyan (non-isolated would give C100 M100)" + }, + {"at": [320, 108], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "isolated": true, + "group_cs": ["none"], + "blend_modes": ["Multiply"], + "color_spaces": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S11-shadow-over-spot.json b/crates/plate-test/scenes/S11-shadow-over-spot.json new file mode 100644 index 00000000..37463e64 --- /dev/null +++ b/crates/plate-test/scenes/S11-shadow-over-spot.json @@ -0,0 +1,83 @@ +{ + "id": "S11", + "title": "K drop shadows (soft mask, Multiply 75%) over Gold and over a CMYK image", + "source": "research S11, DesignCraft DC03, DC11", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [20, 36, 140, 144], "fill": {"spot": "Gold", "tint": 100}}, + { + "type": "rect", + "rect": [30, 46, 120, 120], + "fill": {"cmyk": [0, 0, 0, 100]}, + "opacity": 0.75, + "blend": "multiply", + "mask": { + "kind": "luminosity", + "backdrop": {"gray": 100}, + "content": [{"type": "image", "rect": [50, 66, 80, 80], "image": {"px": [80, 80], "feather": 10}}] + }, + "note": "mask content is smaller than the K rect: outside it the backdrop (/BC black) hides the shadow" + }, + { + "type": "image", + "rect": [190, 36, 140, 144], + "image": {"px": [140, 144], "split": [{"cmyk": [100, 0, 0, 0]}, {"cmyk": [0, 0, 0, 50]}]} + }, + { + "type": "rect", + "rect": [210, 46, 120, 120], + "fill": {"cmyk": [0, 0, 0, 100]}, + "opacity": 0.75, + "blend": "multiply", + "mask": { + "kind": "luminosity", + "backdrop": {"gray": 100}, + "content": [{"type": "image", "rect": [230, 66, 80, 80], "image": {"px": [80, 80], "feather": 10}}] + }, + "note": "mask content is smaller than the K rect: outside it the backdrop (/BC black) hides the shadow" + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [30, 50], "inks": {"Gold": 100}, "tol": 2, "note": "spot only"}, + { + "at": [90, 106], + "inks": {"Gold": 100, "Black": 75}, + "tol": 10, + "note": "shadow over spot: spot kept, K follows the mask" + }, + { + "at": [35, 52], + "inks": {"Gold": 100}, + "tol": 10, + "note": "inside the K rect, outside the mask content: /BC hides the shadow" + }, + { + "at": [245, 106], + "inks": {"Cyan": 100, "Black": 75}, + "tol": 10, + "note": "shadow over the image's C100 half" + }, + { + "at": [295, 106], + "inks": {"Black": 88}, + "tol": 10, + "note": "shadow (K75 Multiply) over the image's K50 half" + }, + {"at": [205, 50], "inks": {"Cyan": 100}, "tol": 2, "note": "image C100 half"}, + {"at": [320, 170], "inks": {"Black": 50}, "tol": 2, "note": "image K50 half"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "smask": true, + "blend_modes": ["Multiply"], + "ext_g_states": [{"SMask": "Luminosity", "BC": true}], + "color_spaces_include": ["DeviceCMYK", "Separation(Gold, alt DeviceCMYK)", "DeviceGray"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S12-white-overprint.json b/crates/plate-test/scenes/S12-white-overprint.json new file mode 100644 index 00000000..cef5b99b --- /dev/null +++ b/crates/plate-test/scenes/S12-white-overprint.json @@ -0,0 +1,46 @@ +{ + "id": "S12", + "title": "White (C0 M0 Y0 K0) fill overprint over C100, OPM 1 (left) and OPM 0 (right)", + "source": "research S12 (GWG 4.x/12.0 idea)", + "page": [360, 216], + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 0, 0, 0]}}, + { + "type": "rect", + "rect": [60, 60, 100, 96], + "fill": {"cmyk": [0, 0, 0, 0]}, + "overprint": {"fill": true, "mode": 1} + }, + { + "type": "rect", + "rect": [200, 60, 100, 96], + "fill": {"cmyk": [0, 0, 0, 0]}, + "overprint": {"fill": true, "mode": 0} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + { + "at": [110, 108], + "inks": {"Cyan": 100}, + "tol": 2, + "note": "OPM 1: an all-zero overprinting white leaves the backdrop" + }, + {"at": [250, 108], "inks": {}, "tol": 2, "note": "OPM 0: white knocks out"}, + {"at": [40, 108], "inks": {"Cyan": 100}, "tol": 2, "note": "cyan only"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": true, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceCMYK"], + "ext_g_states": [{"op": true, "OPM": 1}, {"op": true, "OPM": 0}], + "device_rgb": false, + "output_intents": [] + }, + "note": "The app warns about white overprint in preflight; the writer keeps the attribute exactly." +} diff --git a/crates/plate-test/scenes/S13-registration.json b/crates/plate-test/scenes/S13-registration.json new file mode 100644 index 00000000..9a66c850 --- /dev/null +++ b/crates/plate-test/scenes/S13-registration.json @@ -0,0 +1,45 @@ +{ + "id": "S13", + "title": "Registration marks print on every plate, spots included", + "source": "research S13", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "objects": [ + {"type": "rect", "rect": [40, 40, 80, 80], "fill": "registration"}, + { + "type": "ellipse", + "rect": [200, 40, 80, 80], + "stroke": {"paint": "registration", "width": 6} + }, + {"type": "rect", "rect": [160, 140, 60, 40], "fill": {"spot": "Gold", "tint": 100}} + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + { + "at": [80, 80], + "inks": {"Cyan": 100, "Magenta": 100, "Yellow": 100, "Black": 100, "Gold": 100}, + "tol": 2, + "note": "registration fill" + }, + { + "at": [240, 40], + "inks": {"Cyan": 100, "Magenta": 100, "Yellow": 100, "Black": 100, "Gold": 100}, + "tol": 2, + "note": "registration stroke (top of the circle)" + }, + {"at": [240, 80], "inks": {}, "tol": 2, "note": "inside the circle"}, + {"at": [190, 160], "inks": {"Gold": 100}, "tol": 2, "note": "Gold"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": false, + "smask": false, + "blend_modes": [], + "color_spaces": ["Separation(All, alt DeviceCMYK)", "Separation(Gold, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S14-duotone-gradients.json b/crates/plate-test/scenes/S14-duotone-gradients.json new file mode 100644 index 00000000..c13d9b6c --- /dev/null +++ b/crates/plate-test/scenes/S14-duotone-gradients.json @@ -0,0 +1,75 @@ +{ + "id": "S14", + "title": "DeviceN [Black Gold] duotone ramp, Gold-to-paper and Gold-to-cyan gradients", + "source": "research S14, DesignCraft DC07", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "objects": [ + { + "type": "image", + "rect": [20, 20, 320, 80], + "image": { + "px": [320, 8], + "ramp": [{"inks": {"Black": 0, "Gold": 0}}, {"inks": {"Black": 100, "Gold": 60}}] + } + }, + { + "type": "rect", + "rect": [20, 120, 150, 76], + "fill": { + "linear": { + "from": [20, 0], + "to": [170, 0], + "stops": [ + {"at": 0, "paint": {"spot": "Gold", "tint": 100}}, + {"at": 1, "paint": {"spot": "Gold", "tint": 0}} + ] + } + } + }, + { + "type": "rect", + "rect": [190, 120, 150, 76], + "fill": { + "linear": { + "from": [190, 0], + "to": [340, 0], + "stops": [ + {"at": 0, "paint": {"spot": "Gold", "tint": 100}}, + {"at": 1, "paint": {"cmyk": [100, 0, 0, 0]}} + ] + } + } + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [100, 60], "inks": {"Black": 25, "Gold": 15}, "tol": 10, "note": "duotone at 25%"}, + {"at": [260, 60], "inks": {"Black": 75, "Gold": 45}, "tol": 10, "note": "duotone at 75%"}, + {"at": [57.5, 158], "inks": {"Gold": 75}, "tol": 10, "note": "spot>paper at 25%"}, + {"at": [95, 158], "inks": {"Gold": 50}, "tol": 10, "note": "spot>paper at 50%"}, + {"at": [132.5, 158], "inks": {"Gold": 25}, "tol": 10, "note": "spot>paper at 75%"}, + { + "at": [227.5, 158], + "inks": {"Gold": 75, "Cyan": 25}, + "tol": 10, + "note": "spot>cyan at 25%" + }, + { + "at": [265, 158], + "inks": {"Gold": 50, "Cyan": 50}, + "tol": 10, + "note": "spot>cyan at 50%" + } + ] + }, + "structure": { + "page_group": "none", + "smask": false, + "color_spaces_include": ["DeviceN(Black,Gold, alt DeviceCMYK)", "Separation(Gold, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + }, + "note": "DeviceN colorants follow process order (Cyan, Magenta, Yellow, Black), then the scene's inks." +} diff --git a/crates/plate-test/scenes/S15-placed-pdf.json b/crates/plate-test/scenes/S15-placed-pdf.json new file mode 100644 index 00000000..66f0b4b8 --- /dev/null +++ b/crates/plate-test/scenes/S15-placed-pdf.json @@ -0,0 +1,43 @@ +{ + "id": "S15", + "title": "S06, S02 and D01 placed as PDF pages at half size", + "source": "research S15", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "pdf", "scene": "S06", "rect": [0, 0, 180, 108]}, + {"type": "pdf", "scene": "S02", "rect": [180, 0, 180, 108]}, + {"type": "pdf", "scene": "D01", "rect": [0, 108, 180, 108]} + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [30, 54], "inks": {"Cyan": 100}, "tol": 2, "note": "S06 cyan only"}, + { + "at": [85, 54], + "inks": {"Cyan": 50, "Black": 50}, + "tol": 6, + "note": "S06 K50% over cyan" + }, + {"at": [225, 35], "inks": {"Black": 100}, "tol": 2, "note": "S02 K rect"}, + { + "at": [235, 70], + "kind": "text", + "region": [200, 58, 67, 22], + "inks": {"Black": 100}, + "tol": 2, + "note": "S02 K text" + }, + {"at": [45, 162], "inks": {"Gold": 100}, "tol": 2, "note": "D01 spot"}, + {"at": [270, 162], "inks": {}, "tol": 2, "note": "paper"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "group_cs": ["none"], + "color_spaces_include": ["DeviceCMYK", "Separation(Gold, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + }, + "note": "The placed pages must separate like their sources: K-only text stays K, the spot stays a spot." +} diff --git a/crates/plate-test/scenes/S16-gray-overprint.json b/crates/plate-test/scenes/S16-gray-overprint.json new file mode 100644 index 00000000..4cbf006a --- /dev/null +++ b/crates/plate-test/scenes/S16-gray-overprint.json @@ -0,0 +1,42 @@ +{ + "id": "S16", + "title": "DeviceGray 50% fill overprint over Gold and Teal", + "source": "research S16 (GWG 3.0 idea)", + "page": [360, 216], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}, {"name": "Teal", "cmyk": [80, 0, 30, 0]}], + "objects": [ + {"type": "rect", "rect": [20, 20, 150, 176], "fill": {"spot": "Gold", "tint": 100}}, + {"type": "rect", "rect": [190, 20, 150, 176], "fill": {"spot": "Teal", "tint": 100}}, + { + "type": "rect", + "rect": [60, 60, 240, 96], + "fill": {"gray": 50}, + "overprint": {"fill": true} + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold", "Teal"], + "points": [ + {"at": [40, 108], "inks": {"Gold": 100}, "tol": 2, "note": "Gold only"}, + { + "at": [100, 108], + "inks": {}, + "tol": 2, + "info": true, + "note": "gray over Gold: devices differ (DeviceGray maps to K; overprint may keep the spot)" + }, + {"at": [260, 108], "inks": {}, "tol": 2, "info": true, "note": "gray over Teal"} + ] + }, + "structure": { + "page_group": "none", + "group_cs": [], + "overprint": true, + "smask": false, + "blend_modes": [], + "color_spaces": ["DeviceGray", "Separation(Gold, alt DeviceCMYK)", "Separation(Teal, alt DeviceCMYK)"], + "ext_g_states": [{"op": true}], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/S17-mesh-spot.json b/crates/plate-test/scenes/S17-mesh-spot.json new file mode 100644 index 00000000..9a0919b3 --- /dev/null +++ b/crates/plate-test/scenes/S17-mesh-spot.json @@ -0,0 +1,52 @@ +{ + "id": "S17", + "title": "Mesh gradient with a spot, process and DeviceN corner", + "source": "storytold/craft-libs#184 (mesh gradients), CodeRabbit review", + "page": [240, 160], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "objects": [ + { + "type": "rect", + "rect": [20, 20, 200, 120], + "fill": { + "mesh": { + "rect": [20, 20, 200, 120], + "corners": [ + {"spot": "Gold", "tint": 100}, + {"cmyk": [100, 0, 0, 0]}, + {"inks": {"Magenta": 100, "Gold": 50}}, + {"cmyk": [0, 0, 0, 0]} + ] + } + } + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [24, 24], "inks": {"Gold": 95}, "tol": 10, "note": "top left: Gold"}, + {"at": [216, 24], "inks": {"Cyan": 95}, "tol": 10, "note": "top right: Cyan"}, + { + "at": [216, 136], + "inks": {"Magenta": 95, "Gold": 47}, + "tol": 10, + "note": "bottom right: Magenta and half Gold" + }, + {"at": [24, 136], "inks": {}, "tol": 10, "note": "bottom left: paper"}, + { + "at": [120, 80], + "inks": {"Gold": 37.5, "Cyan": 25, "Magenta": 25}, + "tol": 10, + "note": "center: bilinear mix of the corners" + } + ] + }, + "structure": { + "page_group": "none", + "smask": false, + "color_spaces_include": ["DeviceN(Cyan,Magenta,Gold, alt DeviceCMYK)"], + "device_rgb": false, + "output_intents": [] + }, + "note": "Corner colors in different ink spaces share one DeviceN space: process colorants first, then the spot." +} diff --git a/crates/plate-test/src/gs.rs b/crates/plate-test/src/gs.rs new file mode 100644 index 00000000..a659647c --- /dev/null +++ b/crates/plate-test/src/gs.rs @@ -0,0 +1,106 @@ +//! Separates a PDF into plates with Ghostscript's `tiffsep` device. +//! +//! Ghostscript is AGPL: it runs here only as an external program on a developer's machine. It is +//! never linked or copied into this crate, and its output is never committed. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::Error; +use crate::tiff::{self, Plate}; + +/// Overrides the Ghostscript program (default `gs` on `PATH`). +pub const GS_ENV: &str = "CRAFT_PLATE_TEST_GS"; + +/// Plate resolution in dpi; at 72 dpi one pixel is one scene point. +pub const RESOLUTION: f32 = 72.0; + +/// The Ghostscript program to run. +pub fn program() -> OsString { + std::env::var_os(GS_ENV).unwrap_or_else(|| "gs".into()) +} + +/// Ghostscript's version, or `None` when the program can't be run. +pub fn version() -> Option { + let output = Command::new(program()).arg("--version").output().ok()?; + output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +/// Separate the first page of `pdf` into plates, keyed by separation name (`Cyan`, …, spot +/// names). Overprint is simulated as a separating RIP would print it (`-dOverprint=/enable`). +pub fn separate(pdf: &[u8], label: &str) -> Result, Error> { + let work = WorkDir::new(label)?; + let input = work.0.join("in.pdf"); + std::fs::write(&input, pdf).map_err(|e| gs_error(&work.0, e))?; + let res = format!("-r{RESOLUTION}"); + let output_file = format!("-sOutputFile={}", work.0.join("p%d.tif").display()); + let run = Command::new(program()) + .args(["-q", "-dSAFER", "-dBATCH", "-dNOPAUSE", "-sDEVICE=tiffsep", &res, "-sCompression=none", "-dOverprint=/enable"]) + .arg(output_file) + .arg(&input) + .output() + .map_err(|e| Error::Ghostscript(format!("{}: {e}", program().to_string_lossy())))?; + if !run.status.success() { + let stderr = String::from_utf8_lossy(&run.stderr); + let tail: Vec<&str> = stderr.lines().rev().take(5).collect(); + return Err(Error::Ghostscript(format!("{label}: {}: {}", run.status, tail.into_iter().rev().collect::>().join(" / ")))); + } + let mut plates = BTreeMap::new(); + for entry in std::fs::read_dir(&work.0).map_err(|e| gs_error(&work.0, e))? { + let path = entry.map_err(|e| gs_error(&work.0, e))?.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).and_then(plate_name) else { continue }; + let data = std::fs::read(&path).map_err(|e| gs_error(&path, e))?; + let plate = tiff::read(&data).map_err(|e| Error::Tiff(format!("{}: {e}", path.display())))?; + plates.insert(name.to_owned(), plate); + } + if plates.is_empty() { + return Err(Error::Ghostscript(format!("{label}: no plates written"))); + } + Ok(plates) +} + +/// `p1(Cyan).tif` → `Cyan`; the composite `p1.tif` and later pages are not plates. +pub fn plate_name(file: &str) -> Option<&str> { + file.strip_prefix("p1(")?.strip_suffix(").tif") +} + +fn gs_error(path: &Path, e: std::io::Error) -> Error { + Error::Ghostscript(format!("{}: {e}", path.display())) +} + +/// A fresh temporary directory, removed on drop. +struct WorkDir(PathBuf); + +impl WorkDir { + fn new(label: &str) -> Result { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let safe: String = label.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect(); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("craft-plate-test-{}-{n}-{safe}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).map_err(|e| gs_error(&dir, e))?; + Ok(Self(dir)) + } +} + +impl Drop for WorkDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[cfg(test)] +mod tests { + use super::plate_name; + + #[test] + fn names_plates_from_tiffsep_files() { + assert_eq!(plate_name("p1(Cyan).tif"), Some("Cyan")); + assert_eq!(plate_name("p1(PANTONE 871 C).tif"), Some("PANTONE 871 C")); + assert_eq!(plate_name("p1.tif"), None); + assert_eq!(plate_name("p2(Cyan).tif"), None); + } +} diff --git a/crates/plate-test/src/judge.rs b/crates/plate-test/src/judge.rs new file mode 100644 index 00000000..693106cb --- /dev/null +++ b/crates/plate-test/src/judge.rs @@ -0,0 +1,311 @@ +//! Judges separated plates against a scene's `expect` block. +//! +//! An area point is the median of the 5 × 5 pixels around it on each plate. A text point is the +//! median over the glyph's pixels: those in its region with at least half the darkest ink on +//! the glyph plate (Black unless the point names another). Plates a point doesn't list expect 0. + +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::scene::{Level, PROCESS, PointKind, SamplePoint, Scene, TEXT_PLATE}; +use crate::tiff::Plate; + +/// Below this much ink on the glyph plate a text region counts as empty. +const TEXT_MIN_INK: f32 = 20.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Verdict { + Pass, + Fail, + /// Measured, not judged. + Info, + /// Not measured: the scene's text was not drawn (no font). + Skipped, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PointResult { + pub at: [f32; 2], + pub kind: PointKind, + pub note: Option, + pub expected: BTreeMap, + pub tol: f32, + /// Ink percentage per plate, rounded to 0.1; every expected and every separated plate. + pub measured: BTreeMap, + pub verdict: Verdict, + /// Plates outside tolerance. + pub off: Vec, + /// How the value was found, or why it wasn't. + pub how: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Judgement { + /// The plates the separation produced. + pub plates: Vec, + /// Expected plates the separation lacks. + pub missing_plates: Vec, + /// Plates the separation produced that the scene doesn't expect. + pub extra_plates: Vec, + pub points: Vec, +} + +impl Judgement { + pub fn passed(&self) -> bool { + self.missing_plates.is_empty() && self.extra_plates.is_empty() && self.failed_points() == 0 + } + + pub fn failed_points(&self) -> usize { + self.points.iter().filter(|p| p.verdict == Verdict::Fail).count() + } + + pub fn judged_points(&self) -> usize { + self.points.iter().filter(|p| matches!(p.verdict, Verdict::Pass | Verdict::Fail)).count() + } +} + +/// Judge `plates` (separated at `res` dpi) against `scene`. `text_drawn` is false when the +/// scene's text was left out, so text points are skipped rather than failed. +pub fn judge(scene: &Scene, plates: &BTreeMap, res: f32, text_drawn: bool) -> Judgement { + let expected = &scene.expect.plates; + let mut names: Vec = expected.iter().chain(plates.keys()).cloned().collect(); + sort_plates(&mut names); + names.dedup(); + let points = scene.expect.points.iter().map(|point| judge_point(point, plates, &names, res, text_drawn)).collect(); + let mut separated: Vec = plates.keys().cloned().collect(); + sort_plates(&mut separated); + Judgement { + missing_plates: expected.iter().filter(|p| !plates.contains_key(*p)).cloned().collect(), + extra_plates: separated.iter().filter(|p| !expected.contains(p)).cloned().collect(), + plates: separated, + points, + } +} + +fn judge_point(point: &SamplePoint, plates: &BTreeMap, names: &[String], res: f32, text_drawn: bool) -> PointResult { + let mut result = PointResult { + at: point.at, + kind: point.kind, + note: point.note.clone(), + expected: point.inks.clone(), + tol: point.tol, + measured: BTreeMap::new(), + verdict: Verdict::Fail, + off: Vec::new(), + how: None, + }; + if point.kind == PointKind::Text && !text_drawn { + result.verdict = Verdict::Skipped; + result.how = Some("text not drawn (no font)".into()); + return result; + } + let pixels = match point_pixels(point, plates, res) { + Ok((pixels, how)) => { + result.how = how; + pixels + } + Err(why) => { + result.how = Some(why); + Vec::new() + } + }; + for name in names { + let value = match plates.get(name) { + Some(plate) => median(&mut pixels.iter().filter_map(|&(x, y)| plate.ink(x, y)).collect::>()).unwrap_or(0.0), + None => 0.0, + }; + result.measured.insert(name.clone(), (value * 10.0).round() / 10.0); + } + if pixels.is_empty() { + return result; + } + for (name, value) in &result.measured { + let ok = match point.inks.get(name).copied().unwrap_or(Level::Value(0.0)) { + Level::Value(expected) => (value - expected).abs() <= point.tol, + Level::Range([lo, hi]) => (lo..=hi).contains(value), + }; + if !ok { + result.off.push(name.clone()); + } + } + sort_plates(&mut result.off); + result.verdict = if point.info { + Verdict::Info + } else if result.off.is_empty() { + Verdict::Pass + } else { + Verdict::Fail + }; + result +} + +/// Pixels a point samples, and a note on how they were found. +type Sample = (Vec<(usize, usize)>, Option); + +/// The pixels a point samples; `Err` says why there are none. +fn point_pixels(point: &SamplePoint, plates: &BTreeMap, res: f32) -> Result { + let (width, height) = plates.values().next().map(|p| (p.width(), p.height())).ok_or("no plates")?; + let px = |v: f32| (v * res / 72.0).round(); + let inside = |x: f32, y: f32| x >= 0.0 && y >= 0.0 && (x as usize) < width && (y as usize) < height; + match (point.kind, point.region) { + (PointKind::Text, Some([x, y, w, h])) => { + let glyph = point.plate.as_deref().unwrap_or(TEXT_PLATE); + let plate = plates.get(glyph).ok_or_else(|| format!("no {glyph} plate to find the text on"))?; + let (x0, y0, x1, y1) = (px(x).max(0.0), px(y).max(0.0), px(x + w), px(y + h)); + let mut region = Vec::new(); + let mut row = y0; + while row < y1 { + let mut col = x0; + while col < x1 { + if inside(col, row) { + let (cx, cy) = (col as usize, row as usize); + region.push(((cx, cy), plate.ink(cx, cy).unwrap_or(0.0))); + } + col += 1.0; + } + row += 1.0; + } + let darkest = region.iter().map(|(_, ink)| *ink).fold(0.0, f32::max); + if darkest < TEXT_MIN_INK { + return Err(format!("text not found on the {glyph} plate")); + } + let glyph_pixels: Vec<(usize, usize)> = region.into_iter().filter(|(_, ink)| *ink >= darkest * 0.5).map(|(at, _)| at).collect(); + let how = format!("{} glyph pixels on {glyph}", glyph_pixels.len()); + Ok((glyph_pixels, Some(how))) + } + _ => { + let (cx, cy) = (px(point.at[0]), px(point.at[1])); + let mut pixels = Vec::with_capacity(25); + for dy in -2..=2 { + for dx in -2..=2 { + let (x, y) = (cx + dx as f32, cy + dy as f32); + if inside(x, y) { + pixels.push((x as usize, y as usize)); + } + } + } + if pixels.is_empty() { Err("outside the page".into()) } else { Ok((pixels, None)) } + } + } +} + +/// The median; the mean of the middle two for an even count. +pub fn median(values: &mut [f32]) -> Option { + values.sort_by(f32::total_cmp); + let n = values.len(); + match n { + 0 => None, + _ if n % 2 == 1 => values.get(n / 2).copied(), + _ => Some((values.get(n / 2 - 1)? + values.get(n / 2)?) / 2.0), + } +} + +/// Process plates first in press order, then spots by name. +pub fn sort_plates(names: &mut [String]) { + names.sort_by_key(|name| (PROCESS.iter().position(|p| p == name).unwrap_or(PROCESS.len()), name.clone())); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + const W: usize = 60; + const H: usize = 40; + + fn plate(paint: impl Fn(usize, usize) -> u8) -> Plate { + let ink = (0..W * H).map(|i| paint(i % W, i / W)).collect(); + Plate::new(W, H, ink).unwrap() + } + + fn scene(json: &str) -> Scene { + serde_json::from_str(json).unwrap() + } + + /// Cyan everywhere; Gold at 50% in the right half; a "glyph" of solid Black in + /// x 10..20, y 10..30 with stray 30% black pixels around it. + fn plates(with_gold: bool, extra: Option<&str>) -> BTreeMap { + let mut plates = BTreeMap::new(); + plates.insert("Cyan".to_owned(), plate(|_, _| 255)); + plates.insert("Magenta".to_owned(), plate(|_, _| 0)); + plates.insert("Yellow".to_owned(), plate(|_, _| 0)); + plates.insert( + "Black".to_owned(), + plate(|x, y| match (x, y) { + (10..20, 10..30) => 255, + (5..25, 5..35) if (x + y) % 3 == 0 => 77, + _ => 0, + }), + ); + if with_gold { + plates.insert("Gold".to_owned(), plate(|x, _| if x >= 30 { 128 } else { 0 })); + } + if let Some(name) = extra { + plates.insert(name.to_owned(), plate(|_, _| 0)); + } + plates + } + + const SCENE: &str = r#"{ + "id": "T", "title": "t", "source": "unit test", "page": [60, 40], + "inks": [{"name": "Gold", "cmyk": [0, 20, 60, 20]}], + "objects": [], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black", "Gold"], + "points": [ + {"at": [45, 20], "inks": {"Cyan": 100, "Gold": 50}, "tol": 2}, + {"at": [15, 20], "kind": "text", "region": [5, 5, 20, 30], "inks": {"Cyan": 100, "Black": 100}, "tol": 2}, + {"at": [45, 20], "inks": {"Gold": [10, 20]}, "tol": 2, "info": true}, + {"at": [45, 20], "inks": {"Cyan": 100, "Gold": [40, 60]}, "tol": 2} + ] + } + }"#; + + #[test] + fn passes_a_matching_separation() { + let judgement = judge(&scene(SCENE), &plates(true, None), 72.0, true); + let verdicts: Vec = judgement.points.iter().map(|p| p.verdict).collect(); + assert_eq!(verdicts, [Verdict::Pass, Verdict::Pass, Verdict::Info, Verdict::Pass], "{judgement:#?}"); + assert!(judgement.passed()); + assert_eq!(judgement.points[0].measured["Gold"], 50.2); + assert_eq!(judgement.points[1].how.as_deref(), Some("200 glyph pixels on Black")); + assert_eq!(judgement.plates, ["Cyan", "Magenta", "Yellow", "Black", "Gold"]); + } + + #[test] + fn fails_wrong_ink_and_wrong_plates() { + let mut knocked = plates(false, Some("Orange")); + knocked.insert("Cyan".to_owned(), plate(|x, _| if x >= 30 { 0 } else { 255 })); + let judgement = judge(&scene(SCENE), &knocked, 72.0, true); + assert_eq!(judgement.missing_plates, ["Gold"]); + assert_eq!(judgement.extra_plates, ["Orange"]); + let first = &judgement.points[0]; + assert_eq!(first.verdict, Verdict::Fail); + assert_eq!(first.off, ["Cyan", "Gold"]); + assert_eq!(judgement.points[2].verdict, Verdict::Info, "info points are never judged"); + assert!(!judgement.passed()); + assert_eq!((judgement.failed_points(), judgement.judged_points()), (2, 3)); + } + + #[test] + fn text_points_need_the_glyph() { + let mut no_text = plates(true, None); + no_text.insert("Black".to_owned(), plate(|_, _| 0)); + let judgement = judge(&scene(SCENE), &no_text, 72.0, true); + assert_eq!(judgement.points[1].verdict, Verdict::Fail); + assert_eq!(judgement.points[1].how.as_deref(), Some("text not found on the Black plate")); + let skipped = judge(&scene(SCENE), &no_text, 72.0, false); + assert_eq!(skipped.points[1].verdict, Verdict::Skipped); + assert_eq!(skipped.failed_points(), 0); + } + + #[test] + fn medians() { + assert_eq!(median(&mut []), None); + assert_eq!(median(&mut [3.0, 1.0, 2.0]), Some(2.0)); + assert_eq!(median(&mut [4.0, 1.0, 2.0, 3.0]), Some(2.5)); + } +} diff --git a/crates/plate-test/src/lib.rs b/crates/plate-test/src/lib.rs new file mode 100644 index 00000000..4a651c92 --- /dev/null +++ b/crates/plate-test/src/lib.rs @@ -0,0 +1,42 @@ +//! Print-color test scenes for the Craft apps. +//! +//! [`scene`] reads the app-neutral scene files in `scenes/`. [`reference`] writes a scene to +//! PDF through craft-pdf, so the shared writer is tested on its own. [`structure`] summarizes a +//! PDF's print-relevant structure (transparency groups, overprint and soft-mask graphics states, +//! color spaces, output intents) and checks it against a scene's `structure` block. Apps use +//! the scenes and [`structure`] from their own adapter, as a dev-dependency. +//! +//! On developer machines, [`report`] separates each scene's PDF into plates with Ghostscript +//! ([`gs`], an external program; [`tiff`] reads the plates) and [`judge`]s the ink at the +//! scene's sample points. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod gs; +pub mod judge; +pub mod reference; +pub mod report; +pub mod scene; +pub mod structure; +pub mod tiff; + +/// Failures of the harness itself; scene results are reported, not raised. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A scene file could not be read, parsed or resolved. + #[error("scene: {0}")] + Scene(String), + /// The writer can't express this feature yet. + #[error("unsupported: {0}")] + Unsupported(String), + #[error("write: {0}")] + Write(#[from] craft_pdf::write::WriteError), + /// The PDF could not be parsed for the structure summary. + #[error("read: {0}")] + Read(String), + /// Ghostscript could not be run or failed to separate a PDF. + #[error("ghostscript: {0}")] + Ghostscript(String), + /// A plate file could not be read. + #[error("tiff: {0}")] + Tiff(String), +} diff --git a/crates/plate-test/src/reference.rs b/crates/plate-test/src/reference.rs new file mode 100644 index 00000000..2bae10ff --- /dev/null +++ b/crates/plate-test/src/reference.rs @@ -0,0 +1,693 @@ +//! The reference writer: draws a scene with krilla through craft-pdf. +//! +//! Each scene feature maps to one plain krilla call, so the output shows what the shared writer +//! does on its own. Features the writer can't express yet return [`Error::Unsupported`] before +//! anything is drawn. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +use craft_pdf::write::backend as krilla; +use craft_pdf::write::print::{self, PrintColor}; +use craft_pdf::write::{self, Metadata, Writer}; +use krilla::blend::{BlendMode, GroupOptions, Overprint}; +use krilla::color::device_n::{self, Colorant, DeviceNSpace, Process}; +use krilla::color::separation::{SeparationColorant, SeparationSpace}; +use krilla::color::{RegularColor, cmyk, luma, rgb, separation}; +use krilla::geom::{Path, PathBuilder, Point, Rect, Size, Transform}; +use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use krilla::mask::{Mask as KrillaMask, MaskType, TransferFunction}; +use krilla::num::NormalizedF32; +use krilla::page::PageSettings; +use krilla::paint::{Fill, LinearGradient, MeshGradient, MeshPatch, Paint as KrillaPaint, SpreadMethod, Stop, Stroke as KrillaStroke}; +use krilla::pdf::{Pdf, PdfDocument}; +use krilla::surface::Surface; +use krilla::text::{Font, TextDirection}; + +use crate::Error; +use crate::scene::{Blend, GroupCs, ImageSource, Kind, MAX_NESTING, MaskKind, Named, Object, Paint, Scene, Stroke}; + +/// The font used for text objects, from `scripts/fetch-pdf-test-fonts.py`. +pub const FONT_FILE: &str = "Inter-Regular.ttf"; +/// Directory holding [`FONT_FILE`]. +pub const FONT_DIR_ENV: &str = "CRAFT_PDF_TEST_FONT_DIR"; + +/// What a scene may refer to outside itself. +pub struct Context<'a> { + /// Scenes that `pdf` objects can place. + pub scenes: &'a [Scene], + /// Font data for text objects; without it text is skipped with a note. + pub font: Option>>, +} + +impl<'a> Context<'a> { + /// Reads the font from [`FONT_DIR_ENV`] when it is set and the file exists. + pub fn from_env(scenes: &'a [Scene]) -> Self { + let font = + std::env::var_os(FONT_DIR_ENV).map(|dir| PathBuf::from(dir).join(FONT_FILE)).and_then(|path| std::fs::read(path).ok()).map(Arc::new); + Self { scenes, font } + } +} + +/// A written scene. +#[derive(Debug)] +pub struct Output { + pub pdf: Vec, + /// What was left out, e.g. text without a font. + pub notes: Vec, +} + +/// Write `scene` as a one-page PDF. +pub fn write(scene: &Scene, ctx: &Context<'_>) -> Result { + write_nested(scene, ctx, 0, false) +} + +/// Write `scene` as far as the writer can, to see what a pending scene prints today. Features +/// the writer lacks but can leave out are left out with a note; the others still return +/// [`Error::Unsupported`]. +pub fn write_partial(scene: &Scene, ctx: &Context<'_>) -> Result { + write_nested(scene, ctx, 0, true) +} + +fn write_nested(scene: &Scene, ctx: &Context<'_>, depth: usize, partial: bool) -> Result { + let mut resolver = Resolver { scene, ctx, depth, partial, notes: Vec::new(), font: None }; + let nodes = resolver.objects(&scene.objects)?; + let notes = resolver.notes; + let [width, height] = scene.page; + let page = PageSettings::from_wh(width, height).ok_or_else(|| Error::Scene(format!("{}: bad page size", scene.id)))?; + let settings = settings(scene)?; + let pdf = write::guard(|| { + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(page); + let mut surface = page.surface(); + for node in &nodes { + draw(node, &mut surface); + } + surface.finish(); + page.finish(); + writer.finish().map_err(Error::from) + })?; + Ok(Output { pdf, notes }) +} + +/// The writer's defaults, or the print settings for the scene's output intent; an explicit +/// `group_cs` overrides either. +fn settings(scene: &Scene) -> Result { + let base = match scene.settings.output_intent.as_deref() { + None => krilla::SerializeSettings::default(), + Some(name) => { + let color = print_color(name).ok_or_else(|| Error::Scene(format!("{}: unknown output intent {name}", scene.id)))?; + print::settings(color)? + } + }; + let group_color_space = match scene.settings.group_cs { + None => base.group_color_space, + Some(GroupCs::Rgb) => krilla::GroupColorSpace::Rgb, + Some(GroupCs::Cmyk) => krilla::GroupColorSpace::Cmyk, + }; + Ok(krilla::SerializeSettings { group_color_space, ..base }) +} + +/// The printing condition for each name in [`OUTPUT_INTENTS`](crate::scene::OUTPUT_INTENTS). +fn print_color(name: &str) -> Option> { + match name { + "fogra39" => Some(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)), + _ => None, + } +} + +fn unsupported(feature: &str) -> Error { + Error::Unsupported(feature.to_owned()) +} + +/// A scene object resolved to krilla values, drawable without failing. +enum Node { + Shape { + path: Path, + fill: Option, + stroke: Option, + }, + Text { + at: Point, + size: f32, + text: String, + font: Font, + fill: Option, + stroke: Option, + }, + Image { + image: Image, + rect: [f32; 4], + }, + Pdf { + doc: PdfDocument, + rect: [f32; 4], + }, + Group { + options: GroupOptions, + children: Vec, + }, + /// Outermost first: blend mode, soft mask, opacity group, overprint. + Wrapped { + blend: Option, + mask: Option, + opacity: Option, + overprint: Option, + inner: Box, + }, +} + +struct SoftMask { + kind: MaskType, + backdrop: Option, + transfer: Option, + content: Vec, +} + +struct Resolver<'s, 'c> { + scene: &'s Scene, + ctx: &'c Context<'c>, + depth: usize, + /// Leave out features the writer lacks instead of failing; see [`write_partial`]. + partial: bool, + notes: Vec, + font: Option, +} + +impl Resolver<'_, '_> { + /// A feature the writer lacks: a note when writing partially, otherwise unsupported. + // Every scene feature is written today; scenes for later work (the flattener) call it again. + #[allow(dead_code)] + fn lacks(&mut self, feature: &str) -> Result<(), Error> { + if !self.partial { + return Err(unsupported(feature)); + } + let note = format!("{}: {feature} left out", self.scene.id); + if !self.notes.contains(¬e) { + self.notes.push(note); + } + Ok(()) + } + + fn objects(&mut self, objects: &[Object]) -> Result, Error> { + let mut nodes = Vec::new(); + for object in objects { + if let Some(node) = self.object(object)? { + nodes.push(node); + } + } + Ok(nodes) + } + + fn object(&mut self, o: &Object) -> Result, Error> { + let overprint = match o.overprint { + Some(op) => Some(Overprint { + fill: op.fill, + stroke: op.stroke, + nonzero: match op.mode { + 0 => false, + 1 => true, + _ => return Err(self.bad("overprint mode must be 0 or 1")), + }, + }), + None => None, + }; + let Some(inner) = self.content(o)? else { return Ok(None) }; + let blend = o.blend.filter(|b| *b != Blend::Normal).map(blend_mode); + let opacity = match o.opacity { + Some(value) if value < 1.0 => Some(NormalizedF32::new(value).ok_or_else(|| self.bad("opacity must be 0–1"))?), + _ => None, + }; + let mask = match &o.mask { + Some(mask) => { + let kind = match mask.kind { + MaskKind::Luminosity => MaskType::Luminosity, + MaskKind::Alpha => MaskType::Alpha, + }; + let backdrop = match &mask.backdrop { + Some(paint) => match self.color(paint)? { + krilla::color::Color::Regular(color) => Some(color), + krilla::color::Color::Special(_) => return Err(self.bad("a mask backdrop must be a process or gray color")), + }, + None => None, + }; + let transfer = match &mask.transfer { + Some(curve) => { + let samples = curve.iter().map(|v| NormalizedF32::new(*v)).collect::>>(); + Some(TransferFunction::Sampled(samples.ok_or_else(|| self.bad("transfer samples must be 0–1"))?)) + } + None => None, + }; + Some(SoftMask { kind, backdrop, transfer, content: self.objects(&mask.content)? }) + } + None => None, + }; + if blend.is_none() && mask.is_none() && opacity.is_none() && overprint.is_none() { + return Ok(Some(inner)); + } + Ok(Some(Node::Wrapped { blend, mask, opacity, overprint, inner: Box::new(inner) })) + } + + fn content(&mut self, o: &Object) -> Result, Error> { + Ok(Some(match o.kind { + Kind::Rect | Kind::Ellipse => { + let [x, y, w, h] = self.rect(o)?; + let path = if o.kind == Kind::Rect { rect_path(x, y, w, h) } else { ellipse_path(x, y, w, h) }; + let path = path.ok_or_else(|| self.bad("degenerate shape"))?; + let (fill, stroke) = self.fill_stroke(o)?; + if fill.is_none() && stroke.is_none() { + return Ok(None); + } + Node::Shape { path, fill, stroke } + } + Kind::Text => { + let (Some([x, y]), Some(size), Some(text)) = (o.at, o.size, o.text.clone()) else { + return Err(self.bad("text needs at, size and text")); + }; + let Some(font) = self.font()? else { + self.notes.push(format!("{}: text {text:?} skipped ({FONT_DIR_ENV} not set)", self.scene.id)); + return Ok(None); + }; + let (fill, stroke) = self.fill_stroke(o)?; + Node::Text { at: Point::from_xy(x, y), size, text, font, fill, stroke } + } + Kind::Image => { + let source = o.image.as_ref().ok_or_else(|| self.bad("missing image"))?; + let rect = self.rect(o)?; + Node::Image { image: self.image(source, rect)?, rect } + } + Kind::Pdf => { + let id = o.scene.as_deref().ok_or_else(|| self.bad("missing scene"))?; + let placed = self.ctx.scenes.iter().find(|s| s.id == id).ok_or_else(|| self.bad(&format!("unknown scene {id}")))?; + if self.depth >= MAX_NESTING { + return Err(self.bad("placed scenes nest too deep")); + } + let rect = self.rect(o)?; + let output = write_nested(placed, self.ctx, self.depth + 1, self.partial)?; + self.notes.extend(output.notes); + let pdf = Pdf::new(Arc::new(output.pdf)).map_err(|e| Error::Read(format!("{id}: {e:?}")))?; + Node::Pdf { doc: PdfDocument::new(Arc::new(pdf)), rect } + } + Kind::Group => Node::Group { options: GroupOptions { isolated: o.isolated, knockout: o.knockout }, children: self.objects(&o.objects)? }, + })) + } + + fn rect(&self, o: &Object) -> Result<[f32; 4], Error> { + o.rect.ok_or_else(|| self.bad("missing rect")) + } + + fn font(&mut self) -> Result, Error> { + if self.font.is_none() + && let Some(data) = &self.ctx.font + { + self.font = Some(write::font(data, 0, None)?); + } + Ok(self.font.clone()) + } + + fn fill_stroke(&self, o: &Object) -> Result<(Option, Option), Error> { + let fill = o.fill.as_ref().map(|p| self.paint(p)).transpose()?.map(|paint| Fill { paint, ..Default::default() }); + let stroke = match &o.stroke { + Some(Stroke { paint, width }) => Some(KrillaStroke { paint: self.paint(paint)?, width: *width, ..Default::default() }), + None => None, + }; + Ok((fill, stroke)) + } + + fn paint(&self, paint: &Paint) -> Result { + match paint { + Paint::Linear { linear } => { + mixable(linear.stops.iter().map(|s| &s.paint))?; + let mut stops = Vec::new(); + for stop in &linear.stops { + let offset = NormalizedF32::new(stop.at).ok_or_else(|| self.bad("gradient stop outside 0–1"))?; + stops.push(Stop { offset, color: self.color(&stop.paint)?, opacity: NormalizedF32::ONE }); + } + Ok(LinearGradient { + x1: linear.from[0], + y1: linear.from[1], + x2: linear.to[0], + y2: linear.to[1], + transform: Transform::identity(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: false, + } + .into()) + } + Paint::Mesh { mesh } => { + mixable(mesh.corners.iter())?; + let [x, y, w, h] = mesh.rect; + let corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]; + let mut points = [(0.0, 0.0); 12]; + for (i, &(ax, ay)) in corners.iter().enumerate() { + let (bx, by) = corners[(i + 1) % 4]; + for step in 0..3 { + let t = step as f32 / 3.0; + points[3 * i + step] = (ax + (bx - ax) * t, ay + (by - ay) * t); + } + } + let [a, b, c, d] = &mesh.corners; + let colors = [self.color(a)?, self.color(b)?, self.color(c)?, self.color(d)?]; + let patch = MeshPatch { points, colors, opacities: [NormalizedF32::ONE; 4] }; + Ok(MeshGradient { patches: vec![patch], transform: Transform::identity(), anti_alias: false }.into()) + } + solid => Ok(self.color(solid)?.into()), + } + } + + fn color(&self, paint: &Paint) -> Result { + Ok(match paint { + Paint::Named(Named::Registration) => { + let all = SeparationSpace::new(SeparationColorant::AllColorants, cmyk::Color::new(255, 255, 255, 255).into()); + separation::Color::new(255, all).into() + } + Paint::Cmyk { cmyk } => cmyk_color(*cmyk).into(), + Paint::Gray { gray } => luma::Color::new(byte(100.0 - gray)).into(), + Paint::Rgb { rgb } => rgb::Color::new(channel(rgb[0]), channel(rgb[1]), channel(rgb[2])).into(), + Paint::Spot { spot, tint } => { + let alternate: RegularColor = self.spot_alternate(spot)?.into(); + separation::Color::new(byte(*tint), SeparationSpace::new(SeparationColorant::Custom(spot.clone()), alternate)).into() + } + Paint::Inks { inks } => self.device_n(inks)?.into(), + Paint::Linear { .. } | Paint::Mesh { .. } => return Err(self.bad("gradients can't nest")), + }) + } + + fn spot_alternate(&self, spot: &str) -> Result { + let ink = self.scene.ink(spot).ok_or_else(|| self.bad(&format!("undeclared spot {spot}")))?; + match ink.cmyk { + Some(c) => Ok(cmyk_color(c)), + None => Err(unsupported("Lab spot alternate")), + } + } + + /// A DeviceN color in the space of its inks (see [`Self::device_n_space`]). + fn device_n(&self, inks: &BTreeMap) -> Result { + let space = self.device_n_space(inks.keys())?; + let tints = space.colorants().iter().map(|c| byte(inks.get(c.name()).copied().unwrap_or(0.0))).collect(); + device_n::Color::new(tints, space).ok_or_else(|| self.bad("DeviceN tints don't match its inks")) + } + + /// A DeviceN space of the named inks: process inks first, in CMYK order, then the spots in + /// the order the scene declares them. + fn device_n_space<'n>(&self, names: impl Iterator + Clone) -> Result { + let named = |name: &str| names.clone().any(|n| n == name); + let mut colorants = Vec::new(); + for process in Process::ALL { + if named(process.name()) { + colorants.push(Colorant::Process(process)); + } + } + for ink in &self.scene.inks { + if named(&ink.name) { + colorants.push(Colorant::Spot { name: ink.name.clone(), alternate: self.spot_alternate(&ink.name)? }); + } + } + if let Some(name) = names.clone().find(|name| !colorants.iter().any(|c| c.name() == name.as_str())) { + return Err(self.bad(&format!("undeclared ink {name}"))); + } + DeviceNSpace::new(colorants).ok_or_else(|| self.bad("bad DeviceN ink list")) + } + + 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 { + let mut data = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + let (fx, fy) = ((x as f32 + 0.5) * w / px as f32, (y as f32 + 0.5) * h / py as f32); + let edge = fx.min(w - fx).min(fy).min(h - fy); + data.push(channel(255.0 * (edge / radius.max(f32::EPSILON)).min(1.0))); + } + } + 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), + (_, Some(pair)) => (pair, false), + _ => return Err(self.bad("image needs ramp, split or feather")), + }; + let (space, device_n, a, b) = match (ink_tints(&pair[0]), ink_tints(&pair[1])) { + // Spot and DeviceN paints: one DeviceN image of every ink either names. + (Some(a), Some(b)) => { + let space = self.device_n_space(a.keys().chain(b.keys()))?; + let samples = |inks: &BTreeMap| -> Vec { + space.colorants().iter().map(|c| inks.get(c.name()).copied().unwrap_or(0.0) * 2.55).collect() + }; + let (a, b) = (samples(&a), samples(&b)); + (ImageColorspace::DeviceN, Some(space), a, b) + } + _ => { + let (space, a) = self.components(&pair[0])?; + let (space_b, b) = self.components(&pair[1])?; + if space != space_b { + return Err(self.bad("image paints must share a color space")); + } + (space, None, a, b) + } + }; + let mut data = Vec::with_capacity(width * height * a.len()); + for _ in 0..height { + for x in 0..width { + let t = if ramp { (x as f32 + 0.5) / px as f32 } else { f32::from(u8::from(2 * x >= width)) }; + data.extend(a.iter().zip(&b).map(|(a, b)| channel(a + (b - a) * t))); + } + } + 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)) + } + + /// Image samples (0–255) for a solid paint. + fn components(&self, paint: &Paint) -> Result<(ImageColorspace, Vec), Error> { + Ok(match paint { + Paint::Cmyk { cmyk } => (ImageColorspace::Cmyk, cmyk.iter().map(|v| v * 2.55).collect()), + Paint::Gray { gray } => (ImageColorspace::Luma, vec![(100.0 - gray) * 2.55]), + Paint::Rgb { rgb } => (ImageColorspace::Rgb, rgb.to_vec()), + Paint::Inks { .. } | Paint::Spot { .. } => return Err(self.bad("image paints must share a color space")), + Paint::Named(_) => return Err(unsupported("registration image")), + Paint::Linear { .. } | Paint::Mesh { .. } => return Err(self.bad("images take solid paints")), + }) + } + + fn bad(&self, message: &str) -> Error { + Error::Scene(format!("{}: {message}", self.scene.id)) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Space<'a> { + Cmyk, + Gray, + Rgb, + Spot(&'a str), + Other, +} + +/// krilla brings ink-based gradient colors into one space; RGB and registration don't mix. +fn mixable<'a>(paints: impl Iterator + Clone) -> Result<(), Error> { + let space = paints.clone().next().map(space_of); + let mixed = paints.clone().any(|p| Some(space_of(p)) != space); + if mixed && paints.into_iter().any(|p| matches!(p, Paint::Rgb { .. } | Paint::Named(_))) { + return Err(unsupported("gradient between RGB or registration and other color spaces")); + } + Ok(()) +} + +fn space_of(paint: &Paint) -> Space<'_> { + match paint { + Paint::Cmyk { .. } => Space::Cmyk, + Paint::Gray { .. } => Space::Gray, + Paint::Rgb { .. } => Space::Rgb, + Paint::Spot { spot, .. } => Space::Spot(spot), + _ => Space::Other, + } +} + +/// Ink percentages of a spot or DeviceN paint. +fn ink_tints(paint: &Paint) -> Option> { + match paint { + Paint::Inks { inks } => Some(inks.clone()), + Paint::Spot { spot, tint } => Some(BTreeMap::from([(spot.clone(), *tint)])), + _ => None, + } +} + +fn byte(percent: f32) -> u8 { + channel(percent * 2.55) +} + +fn channel(value: f32) -> u8 { + value.round().clamp(0.0, 255.0) as u8 +} + +fn cmyk_color([c, m, y, k]: [f32; 4]) -> cmyk::Color { + cmyk::Color::new(byte(c), byte(m), byte(y), byte(k)) +} + +fn blend_mode(blend: Blend) -> BlendMode { + match blend { + Blend::Normal => BlendMode::Normal, + Blend::Multiply => BlendMode::Multiply, + Blend::Screen => BlendMode::Screen, + Blend::Overlay => BlendMode::Overlay, + Blend::Darken => BlendMode::Darken, + Blend::Lighten => BlendMode::Lighten, + Blend::ColorDodge => BlendMode::ColorDodge, + Blend::ColorBurn => BlendMode::ColorBurn, + Blend::HardLight => BlendMode::HardLight, + Blend::SoftLight => BlendMode::SoftLight, + Blend::Difference => BlendMode::Difference, + Blend::Exclusion => BlendMode::Exclusion, + Blend::Hue => BlendMode::Hue, + Blend::Saturation => BlendMode::Saturation, + Blend::Color => BlendMode::Color, + Blend::Luminosity => BlendMode::Luminosity, + } +} + +fn rect_path(x: f32, y: f32, w: f32, h: f32) -> Option { + let mut builder = PathBuilder::new(); + builder.push_rect(Rect::from_xywh(x, y, w, h)?); + builder.finish() +} + +fn ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option { + // Four cubic arcs; KAPPA places the control points for a quarter circle. + const KAPPA: f32 = 0.552_284_8; + let (rx, ry) = (w / 2.0, h / 2.0); + let (cx, cy) = (x + rx, y + ry); + let (ox, oy) = (rx * KAPPA, ry * KAPPA); + let mut b = PathBuilder::new(); + b.move_to(cx + rx, cy); + b.cubic_to(cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry); + b.cubic_to(cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy); + b.cubic_to(cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry); + b.cubic_to(cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy); + b.close(); + b.finish() +} + +#[derive(Clone, Hash)] +struct Synthetic { + data: Vec, + alpha: Option>, + size: (u32, u32), + space: ImageColorspace, + device_n: Option, +} + +impl CustomImage for Synthetic { + fn color_channel(&self) -> &[u8] { + &self.data + } + 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() + } +} + +fn draw(node: &Node, surface: &mut Surface<'_>) { + match node { + Node::Shape { path, fill, stroke } => { + surface.set_fill(fill.clone()); + surface.set_stroke(stroke.clone()); + surface.draw_path(path); + } + Node::Text { at, size, text, font, fill, stroke } => { + surface.set_fill(fill.clone()); + surface.set_stroke(stroke.clone()); + surface.draw_text(*at, font.clone(), *size, text, false, TextDirection::Auto); + } + Node::Image { image, rect: [x, y, w, h] } => { + if let Some(size) = Size::from_wh(*w, *h) { + surface.push_transform(&Transform::from_translate(*x, *y)); + surface.draw_image(image.clone(), size); + surface.pop(); + } + } + Node::Pdf { doc, rect: [x, y, w, h] } => { + if let Some(size) = Size::from_wh(*w, *h) { + surface.push_transform(&Transform::from_translate(*x, *y)); + surface.draw_pdf_page(doc, size, 0); + surface.pop(); + } + } + Node::Group { options, children } => { + let grouped = *options != GroupOptions::default(); + if grouped { + surface.push_group(*options); + } + for child in children { + draw(child, surface); + } + if grouped { + surface.pop(); + } + } + Node::Wrapped { blend, mask, opacity, overprint, inner } => { + if let Some(blend) = blend { + surface.push_blend_mode(*blend); + } + if let Some(mask) = mask { + let mut builder = surface.stream_builder(); + let mut sub = builder.surface(); + for child in &mask.content { + draw(child, &mut sub); + } + sub.finish(); + let stream = builder.finish(); + let mut krilla_mask = KrillaMask::new(stream, mask.kind); + if let Some(backdrop) = mask.backdrop { + krilla_mask = krilla_mask.with_backdrop(backdrop); + } + if let Some(transfer) = &mask.transfer { + krilla_mask = krilla_mask.with_transfer(transfer.clone()); + } + surface.push_mask(krilla_mask); + } + if let Some(opacity) = opacity { + surface.push_opacity(*opacity); + } + if let Some(overprint) = overprint { + surface.push_overprint(*overprint); + } + draw(inner, surface); + for pushed in [overprint.is_some(), opacity.is_some(), mask.is_some(), blend.is_some()] { + if pushed { + surface.pop(); + } + } + } + } +} diff --git a/crates/plate-test/src/report.rs b/crates/plate-test/src/report.rs new file mode 100644 index 00000000..246400c4 --- /dev/null +++ b/crates/plate-test/src/report.rs @@ -0,0 +1,387 @@ +//! Runs the Ghostscript plate check over scenes and writes `report.md` and `results.json`. +//! +//! 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, structure}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Status { + Pass, + Fail, + /// A pending scene fails, as it should until its task lands. + ExpectedFail, + /// A pending scene passes: the feature works, or the scene doesn't catch its absence. + PendingPasses, + /// A pending scene needs a feature the writer can't leave out. + NotWritten, + /// The harness failed (Ghostscript, TIFF, writer error). + Error, +} + +impl Status { + /// Whether the scene result is what the scene's `pending` field says it should be. + pub fn as_expected(self) -> bool { + matches!(self, Status::Pass | Status::ExpectedFail | Status::PendingPasses | Status::NotWritten) + } + + fn label(self) -> &'static str { + match self { + Status::Pass => "pass", + Status::Fail => "**FAIL**", + Status::ExpectedFail => "expected fail", + Status::PendingPasses => "pending, passes", + Status::NotWritten => "not written", + Status::Error => "**ERROR**", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SceneResult { + pub id: String, + pub title: String, + pub pending: Option, + pub status: Status, + /// What the writer left out or skipped. + pub notes: Vec, + /// 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, + pub resolution: f32, + 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(), + pending: scene.pending.clone(), + status: Status::Error, + 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 { + Ok(output) => output, + Err(Error::Unsupported(feature)) if scene.pending.is_some() => { + result.status = Status::NotWritten; + result.error = Some(format!("needs {feature}")); + return result; + } + Err(e) => { + result.error = Some(e.to_string()); + return result; + } + }; + result.notes = output.notes; + 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; + } + } + 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, + } +} + +/// Cargo's target directory: `CARGO_TARGET_DIR` (relative to the workspace root) or `target`. +pub fn target_dir() -> PathBuf { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = manifest.parent().and_then(Path::parent).unwrap_or(manifest); + match std::env::var_os("CARGO_TARGET_DIR") { + Some(dir) => root.join(dir), + None => root.join("target"), + } +} + +/// Where the report goes: `plate-test/` in the target directory. +pub fn output_dir() -> PathBuf { + target_dir().join("plate-test") +} + +/// Write `report.md` and `results.json` into `dir`; returns the report's path. +pub fn write(report: &Report, dir: &Path) -> Result { + let io = |path: &Path, e: std::io::Error| Error::Scene(format!("{}: {e}", path.display())); + std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?; + let json_path = dir.join("results.json"); + let json = serde_json::to_string_pretty(report).map_err(|e| Error::Scene(e.to_string()))?; + std::fs::write(&json_path, json).map_err(|e| io(&json_path, e))?; + let md_path = dir.join("report.md"); + std::fs::write(&md_path, markdown(report)).map_err(|e| io(&md_path, e))?; + Ok(md_path) +} + +/// 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, + "# Plate check\n\nGhostscript {}, `tiffsep` at {} dpi with overprint simulated (`/enable`). Ink % per plate \ + (C M Y K, spots by name); plates under 1% are left out. Pending scenes are written without the features \ + they wait for and are expected to fail. Generated by `scripts/plate-check.sh`.\n", + report.ghostscript, report.resolution + ); + 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) = summary_cells(scene.judgement.as_ref()); + let pending = scene.pending.as_deref().unwrap_or(""); + 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()); + if let Some(task) = &scene.pending { + let _ = writeln!(md, "Pending {task}."); + } + if let Some(error) = &scene.error { + let _ = writeln!(md, "\n{error}"); + } + 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()); + 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 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 _ = write!( + md, + "| {} | {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() { + parts.push(format!("missing {}", judgement.missing_plates.join(", "))); + } + if !judgement.extra_plates.is_empty() { + parts.push(format!("extra {}", judgement.extra_plates.join(", "))); + } + (!parts.is_empty()).then(|| parts.join("; ")) +} + +/// Process plates as `C M Y K`; spots keep their names. +fn short(plate: &str) -> &str { + match PROCESS.iter().position(|p| *p == plate) { + Some(i) => ["C", "M", "Y", "K"].get(i).copied().unwrap_or(plate), + None => plate, + } +} + +fn expected(inks: &std::collections::BTreeMap) -> String { + let mut names: Vec = inks.keys().cloned().collect(); + judge::sort_plates(&mut names); + let parts: Vec = names + .iter() + .filter_map(|name| match inks.get(name)? { + Level::Value(v) if *v >= 1.0 => Some(format!("{}{v}", short(name))), + Level::Value(_) => None, + Level::Range([lo, hi]) => Some(format!("{}{lo}–{hi}", short(name))), + }) + .collect(); + if parts.is_empty() { "paper".into() } else { parts.join(" ") } +} + +fn measured(inks: &std::collections::BTreeMap) -> String { + let mut names: Vec = inks.iter().filter(|(_, v)| **v >= 1.0).map(|(k, _)| k.clone()).collect(); + judge::sort_plates(&mut names); + let parts: Vec = names.iter().filter_map(|name| Some(format!("{}{:.0}", short(name), inks.get(name)?))).collect(); + if parts.is_empty() { "paper".into() } else { parts.join(" ") } +} diff --git a/crates/plate-test/src/scene.rs b/crates/plate-test/src/scene.rs new file mode 100644 index 00000000..422db7dd --- /dev/null +++ b/crates/plate-test/src/scene.rs @@ -0,0 +1,580 @@ +//! The scene format: an app-neutral description of one test page, what each plate should +//! carry at sample points, and what the PDF's structure should look like. +//! +//! Coordinates are points from the page's top-left corner. Ink values are percentages +//! (0 = no ink, 100 = solid). Rectangles are `[x, y, width, height]`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::Error; +use crate::structure::StructureExpect; + +/// How deep placed scenes may nest. +pub const MAX_NESTING: usize = 4; + +/// The plate text points find their glyphs on unless they name another. +pub const TEXT_PLATE: &str = "Black"; + +/// The process plates, named as separation tools (Ghostscript `tiffsep`) name them. +pub const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"]; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Scene { + /// Short id, also the file name stem's prefix (`S01`, `D04`). + pub id: String, + pub title: String, + /// Where the scene comes from (research case or app regression). + pub source: String, + /// Page size `[width, height]` in pt. + pub page: [f32; 2], + /// Spot inks the scene may use. Their order is the colorant order after the process inks. + #[serde(default)] + pub inks: Vec, + #[serde(default)] + pub settings: Settings, + pub objects: Vec, + pub expect: Expect, + #[serde(default)] + pub structure: StructureExpect, + /// The task that makes this scene writable, e.g. `"T4"`. Pending scenes are parsed but not + /// written or checked. + #[serde(default)] + pub pending: Option, + #[serde(default)] + pub note: Option, +} + +/// A spot ink with its alternate (exactly one of `cmyk` or `lab`). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Ink { + pub name: String, + #[serde(default)] + pub cmyk: Option<[f32; 4]>, + #[serde(default)] + pub lab: Option<[f32; 3]>, +} + +/// Output intents a scene can name: `fogra39` is craft-cms's `COATED_FOGRA39_ICC`. +pub const OUTPUT_INTENTS: &[&str] = &["fogra39"]; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Settings { + /// Blending color space of transparency groups; absent means the writer's default. + #[serde(default)] + pub group_cs: Option, + /// Name of the print output intent, one of [`OUTPUT_INTENTS`]; the scene is then written + /// with craft-pdf's print settings. Absent means the writer's defaults. + #[serde(default)] + pub output_intent: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GroupCs { + Rgb, + Cmyk, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + Rect, + Ellipse, + Text, + Image, + /// Another scene, written and placed as a PDF page. + Pdf, + Group, +} + +/// One drawable object. Which geometry fields apply depends on `type`; [`Scene::validate`] +/// reports missing or stray ones. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Object { + #[serde(rename = "type")] + pub kind: Kind, + /// rect, ellipse (bounding box), image, pdf. + #[serde(default)] + pub rect: Option<[f32; 4]>, + /// text: baseline start. + #[serde(default)] + pub at: Option<[f32; 2]>, + /// text: font size in pt. + #[serde(default)] + pub size: Option, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub image: Option, + /// pdf: id of the scene to place. + #[serde(default)] + pub scene: Option, + /// group: children, drawn in order. + #[serde(default)] + pub objects: Vec, + #[serde(default)] + pub isolated: bool, + #[serde(default)] + pub knockout: bool, + #[serde(default)] + pub fill: Option, + #[serde(default)] + pub stroke: Option, + /// Whole-object opacity, 0–1 (a transparency group). + #[serde(default)] + pub opacity: Option, + #[serde(default)] + pub blend: Option, + #[serde(default)] + pub overprint: Option, + #[serde(default)] + pub mask: Option, + #[serde(default)] + pub note: Option, +} + +/// A paint. Ink values are percentages. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum Paint { + /// `"registration"`: the Separation `/All` colorant (every plate). + Named(Named), + Cmyk { + cmyk: [f32; 4], + }, + /// Black ink percentage, written as DeviceGray `1 - gray / 100`. + Gray { + gray: f32, + }, + /// 0–255 per channel. + Rgb { + rgb: [f32; 3], + }, + Spot { + spot: String, + #[serde(default = "full")] + tint: f32, + }, + /// Several named inks at once (DeviceN); names are process plates or declared spots. + Inks { + inks: BTreeMap, + }, + Linear { + linear: Gradient, + }, + Mesh { + mesh: Box, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Named { + Registration, +} + +fn full() -> f32 { + 100.0 +} + +/// A linear gradient in page coordinates. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Gradient { + pub from: [f32; 2], + pub to: [f32; 2], + pub stops: Vec, +} + +/// A mesh gradient of one Coons patch with straight edges over `rect` (page coordinates). +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Mesh { + pub rect: [f32; 4], + /// Solid paints at the top-left, top-right, bottom-right and bottom-left corners. + pub corners: [Paint; 4], +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GradientStop { + /// 0–1 along the gradient. + pub at: f32, + pub paint: Paint, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Stroke { + pub paint: Paint, + pub width: f32, +} + +/// A synthetic image filling the object's `rect` (exactly one of `ramp`, `split`, `feather`). +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImageSource { + /// Pixel size `[width, height]`. + pub px: [u32; 2], + /// Horizontal ramp from the first paint (left) to the second (right). + #[serde(default)] + pub ramp: Option<[Paint; 2]>, + /// Left half the first paint, right half the second. + #[serde(default)] + pub split: Option<[Paint; 2]>, + /// 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)] +#[serde(rename_all = "kebab-case")] +pub enum Blend { + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Overprint { + #[serde(default)] + pub fill: bool, + #[serde(default)] + pub stroke: bool, + /// OPM: 1 = zero components leave the backdrop (nonzero overprint), 0 = all components paint. + #[serde(default = "opm_default")] + pub mode: u8, +} + +fn opm_default() -> u8 { + 1 +} + +/// A soft mask over the object. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Mask { + pub kind: MaskKind, + pub content: Vec, + /// `/BC`: the color outside the mask content (luminosity masks). + #[serde(default)] + pub backdrop: Option, + /// `/TR`: a sampled transfer function, values 0–1 over the input range. + #[serde(default)] + pub transfer: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MaskKind { + Luminosity, + Alpha, +} + +/// What a correct separation gives. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Expect { + /// Every plate the separation must produce (process plates included), and no others. + pub plates: Vec, + pub points: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SamplePoint { + pub at: [f32; 2], + #[serde(default)] + pub kind: PointKind, + /// text: the glyph area to sample, `[x, y, w, h]`. + #[serde(default)] + pub region: Option<[f32; 4]>, + /// text: the plate the glyphs are found on; defaults to [`TEXT_PLATE`]. + #[serde(default)] + pub plate: Option, + /// Expected ink per plate; plates not listed expect 0. + #[serde(default)] + pub inks: BTreeMap, + /// Allowed difference in ink percentage points. + pub tol: f32, + /// Measured and reported, not judged. + #[serde(default)] + pub info: bool, + #[serde(default)] + pub note: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PointKind { + #[default] + Area, + Text, +} + +/// An expected ink value or an inclusive `[lo, hi]` range. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Level { + Value(f32), + Range([f32; 2]), +} + +/// The directory holding this crate's scenes. +pub fn scenes_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("scenes") +} + +/// Parse every `*.json` scene in `dir`, sorted by file name. +pub fn load_dir(dir: &Path) -> Result, Error> { + let mut paths = Vec::new(); + for entry in std::fs::read_dir(dir).map_err(|e| Error::Scene(format!("{}: {e}", dir.display())))? { + let path = entry.map_err(|e| Error::Scene(e.to_string()))?.path(); + if path.extension().is_some_and(|ext| ext == "json") { + paths.push(path); + } + } + paths.sort(); + paths.iter().map(|path| load(path)).collect() +} + +/// Parse one scene file. +pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| Error::Scene(format!("{}: {e}", path.display())))?; + serde_json::from_str(&text).map_err(|e| Error::Scene(format!("{}: {e}", path.display()))) +} + +impl Scene { + pub fn ink(&self, name: &str) -> Option<&Ink> { + self.inks.iter().find(|ink| ink.name == name) + } + + /// Consistency problems a JSON schema can't catch; empty when the scene is well formed. + /// `all` resolves placed scenes. + pub fn validate(&self, all: &[Scene]) -> Vec { + let mut problems = Vec::new(); + let mut say = |message: String| problems.push(format!("{}: {message}", self.id)); + if !(self.page[0] > 0.0 && self.page[1] > 0.0) { + say("page size must be positive".into()); + } + if let Some(name) = &self.settings.output_intent + && !OUTPUT_INTENTS.contains(&name.as_str()) + { + say(format!("unknown output intent {name}")); + } + for ink in &self.inks { + if ink.cmyk.is_some() == ink.lab.is_some() { + say(format!("ink {} needs exactly one of cmyk or lab", ink.name)); + } + if PROCESS.contains(&ink.name.as_str()) || ink.name == "All" || ink.name == "None" { + say(format!("ink {} shadows a process or reserved colorant", ink.name)); + } + } + let mut objects = Vec::new(); + collect(&self.objects, &mut objects); + for object in objects { + for problem in self.object_problems(object, all) { + say(problem); + } + } + for plate in &self.expect.plates { + if !PROCESS.contains(&plate.as_str()) && !self.declares_spot(plate, all, 0) { + say(format!("plate {plate} is neither process nor a declared spot")); + } + } + for name in PROCESS { + if !self.expect.plates.iter().any(|plate| plate == name) { + say(format!("plates must list the process plate {name}")); + } + } + for point in &self.expect.points { + if point.tol <= 0.0 { + say(format!("point {:?}: tol must be positive", point.at)); + } + if (point.kind == PointKind::Text) != point.region.is_some() { + say(format!("point {:?}: text points need a region, area points none", point.at)); + } + if let Some(plate) = &point.plate + && (point.kind != PointKind::Text || !self.expect.plates.contains(plate)) + { + say(format!("point {:?}: plate {plate} must be a text point's plate from plates", point.at)); + } + for name in point.inks.keys() { + if !self.expect.plates.contains(name) { + say(format!("point {:?}: ink {name} is not in plates", point.at)); + } + } + } + problems + } + + /// Declared here or in a placed scene (placements nest at most [`MAX_NESTING`] deep). + fn declares_spot(&self, name: &str, all: &[Scene], depth: usize) -> bool { + self.ink(name).is_some() + || (depth < MAX_NESTING + && placed(&self.objects).iter().any(|id| all.iter().any(|s| s.id == **id && s.declares_spot(name, all, depth + 1)))) + } + + fn object_problems(&self, object: &Object, all: &[Scene]) -> Vec { + let mut out = Vec::new(); + let kind = object.kind; + let needs_rect = matches!(kind, Kind::Rect | Kind::Ellipse | Kind::Image | Kind::Pdf); + if needs_rect != object.rect.is_some() { + out.push(format!("{kind:?}: rect is {}", if needs_rect { "required" } else { "not used" })); + } + if let Some([_, _, w, h]) = object.rect + && !(w > 0.0 && h > 0.0) + { + out.push(format!("{kind:?}: rect needs a positive size")); + } + let is_text = kind == Kind::Text; + if is_text != (object.at.is_some() && object.size.is_some() && object.text.is_some()) { + out.push(format!("{kind:?}: at, size and text belong to (and are required by) text")); + } + if (kind == Kind::Image) != object.image.is_some() { + out.push(format!("{kind:?}: image belongs to (and is required by) image objects")); + } + if let Some(image) = &object.image { + let sources = [image.ramp.is_some(), image.split.is_some(), image.feather.is_some()]; + if sources.iter().filter(|s| **s).count() != 1 { + out.push("image needs exactly one of ramp, split or feather".into()); + } + 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")); + } + if let Some(id) = &object.scene + && (id == &self.id || !all.iter().any(|s| &s.id == id)) + { + out.push(format!("pdf: unknown scene {id}")); + } + if kind != Kind::Group && (!object.objects.is_empty() || object.isolated || object.knockout) { + out.push(format!("{kind:?}: objects, isolated and knockout belong to groups")); + } + if matches!(kind, Kind::Image | Kind::Pdf | Kind::Group) && (object.fill.is_some() || object.stroke.is_some()) { + out.push(format!("{kind:?}: fill and stroke apply to shapes and text")); + } + if let Some(opacity) = object.opacity + && !(0.0..=1.0).contains(&opacity) + { + out.push("opacity must be 0–1".into()); + } + let mut paints: Vec<&Paint> = object.fill.iter().chain(object.stroke.iter().map(|s| &s.paint)).collect(); + if let Some(image) = &object.image { + paints.extend(image.ramp.iter().flatten()); + paints.extend(image.split.iter().flatten()); + } + if let Some(mask) = &object.mask { + paints.extend(mask.backdrop.iter()); + let mut nested = Vec::new(); + collect(&mask.content, &mut nested); + for inner in nested { + out.extend(self.object_problems(inner, all)); + } + } + for paint in paints { + self.paint_problems(paint, &mut out); + } + out + } + + fn paint_problems(&self, paint: &Paint, out: &mut Vec) { + let percent = |v: &f32| (0.0..=100.0).contains(v); + match paint { + Paint::Named(_) => {} + Paint::Cmyk { cmyk } if !cmyk.iter().all(percent) => out.push("cmyk values must be 0–100".into()), + Paint::Gray { gray } if !percent(gray) => out.push("gray must be 0–100".into()), + Paint::Rgb { rgb } if !rgb.iter().all(|v| (0.0..=255.0).contains(v)) => out.push("rgb values must be 0–255".into()), + Paint::Spot { spot, tint } => { + if self.ink(spot).is_none() { + out.push(format!("spot {spot} is not declared in inks")); + } + if !percent(tint) { + out.push("tint must be 0–100".into()); + } + } + Paint::Inks { inks } => { + for (name, value) in inks { + if !PROCESS.contains(&name.as_str()) && self.ink(name).is_none() { + out.push(format!("ink {name} is not process or declared")); + } + if !percent(value) { + out.push("ink values must be 0–100".into()); + } + } + } + Paint::Linear { linear } => { + if linear.stops.len() < 2 { + out.push("gradients need at least two stops".into()); + } + for stop in &linear.stops { + if matches!(stop.paint, Paint::Linear { .. } | Paint::Mesh { .. }) { + out.push("gradient stops must be solid paints".into()); + } + self.paint_problems(&stop.paint, out); + } + } + Paint::Mesh { mesh } => { + for corner in &mesh.corners { + if matches!(corner, Paint::Linear { .. } | Paint::Mesh { .. }) { + out.push("mesh corners must be solid paints".into()); + } + self.paint_problems(corner, out); + } + } + _ => {} + } + } +} + +/// Every object in the tree, depth first (mask contents excluded). +fn collect<'a>(objects: &'a [Object], out: &mut Vec<&'a Object>) { + for object in objects { + out.push(object); + collect(&object.objects, out); + } +} + +fn placed(objects: &[Object]) -> Vec<&String> { + let mut all = Vec::new(); + collect(objects, &mut all); + all.into_iter().filter_map(|o| o.scene.as_ref()).collect() +} diff --git a/crates/plate-test/src/structure.rs b/crates/plate-test/src/structure.rs new file mode 100644 index 00000000..c51ebf0b --- /dev/null +++ b/crates/plate-test/src/structure.rs @@ -0,0 +1,478 @@ +//! A summary of a PDF's print-relevant structure, and the scene `structure` block it is checked +//! against. +//! +//! Color spaces are described as families: `DeviceCMYK`, `ICCBased(N=4)`, +//! `Separation(Gold, alt DeviceCMYK)`, `DeviceN(Cyan,Black, alt DeviceCMYK)`, `Indexed(…)`, +//! `Pattern`. Transparency group color spaces are reported per group, not in `color_spaces`. + +use std::collections::{BTreeSet, HashSet}; +use std::sync::Arc; + +use hayro_syntax::Pdf; +use hayro_syntax::content::UntypedIter; +use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream}; +use serde::{Deserialize, Serialize}; + +use crate::Error; + +/// What [`summarize`] found. +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct Summary { + /// Every transparency group: pages first, then form XObjects and soft-mask groups. + pub groups: Vec, + pub ext_g_states: Vec, + /// Color space families used for painting: resources, images, shadings and the device + /// spaces set directly by content operators (`rg`, `k`, `g`, `cs /DeviceRGB`). + pub color_spaces: BTreeSet, + /// Any painting color space mentions DeviceRGB, including Separation/DeviceN alternates. + pub device_rgb: bool, + /// `/S` of each output intent, in order. + pub output_intents: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Group { + /// `page 1`, `form` or `soft mask`. + pub at: String, + pub s: String, + pub cs: Option, + pub i: bool, + pub k: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct ExtGState { + /// `/OP` (stroke overprint, and fill overprint when `/op` is absent). + #[serde(rename = "OP", skip_serializing_if = "Option::is_none")] + pub op_stroke: Option, + /// `/op` (fill overprint). + #[serde(rename = "op", skip_serializing_if = "Option::is_none")] + pub op_fill: Option, + #[serde(rename = "OPM", skip_serializing_if = "Option::is_none")] + 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, + /// The soft mask has `/BC`. + #[serde(rename = "BC", skip_serializing_if = "std::ops::Not::not")] + pub smask_bc: bool, + /// The soft mask has a `/TR` other than `/Identity`. + #[serde(rename = "TR", skip_serializing_if = "std::ops::Not::not")] + pub smask_tr: bool, +} + +/// Summarize `pdf`. Malformed objects are skipped rather than reported. +pub fn summarize(pdf: &[u8]) -> Result { + let pdf = Pdf::new(Arc::new(pdf.to_vec())).map_err(|e| Error::Read(format!("{e:?}")))?; + let mut walk = Walk::default(); + if let Some(catalog) = pdf.xref().get::>(pdf.xref().root_id()) + && let Some(intents) = catalog.get::>(b"OutputIntents".as_slice()) + { + for intent in intents.iter::>() { + walk.summary.output_intents.push(intent.get::>(b"S".as_slice()).map(|n| n.as_str().to_owned()).unwrap_or_default()); + } + } + for (index, page) in pdf.pages().iter().enumerate() { + let raw = page.raw(); + if let Some(group) = raw.get::>(b"Group".as_slice()) { + walk.group(&format!("page {}", index + 1), &group); + } + let mut ops = page.operations(); + walk.operators(&mut ops); + let res = page.resources(); + walk.resources(&Resources { + ext_g_states: res.ext_g_states.clone(), + color_spaces: res.color_spaces.clone(), + x_objects: res.x_objects.clone(), + patterns: res.patterns.clone(), + shadings: res.shadings.clone(), + }); + } + let mut summary = walk.summary; + summary.device_rgb = summary.color_spaces.iter().any(|cs| cs.contains("DeviceRGB")); + Ok(summary) +} + +struct Resources<'a> { + ext_g_states: Dict<'a>, + color_spaces: Dict<'a>, + x_objects: Dict<'a>, + patterns: Dict<'a>, + shadings: Dict<'a>, +} + +impl<'a> Resources<'a> { + fn of(dict: &Dict<'a>) -> Self { + let sub = |key: &[u8]| dict.get::>(key).unwrap_or_default(); + Self { + ext_g_states: sub(b"ExtGState"), + color_spaces: sub(b"ColorSpace"), + x_objects: sub(b"XObject"), + patterns: sub(b"Pattern"), + shadings: sub(b"Shading"), + } + } +} + +#[derive(Default)] +struct Walk { + summary: Summary, + seen: HashSet, +} + +impl Walk { + /// True the first time an indirect object is visited. + fn first_visit(&mut self, id: Option) -> bool { + id.is_none_or(|id| self.seen.insert(id)) + } + + fn group(&mut self, at: &str, group: &Dict<'_>) { + self.summary.groups.push(Group { + at: at.to_owned(), + s: group.get::>(b"S".as_slice()).map(|n| n.as_str().to_owned()).unwrap_or_default(), + cs: group.get::>(b"CS".as_slice()).map(|cs| describe(&cs, 0)), + i: group.get::(b"I".as_slice()).unwrap_or(false), + k: group.get::(b"K".as_slice()).unwrap_or(false), + }); + } + + fn operators(&mut self, ops: &mut UntypedIter<'_>) { + while let Some(op) = ops.next() { + let family = match &**op.operator { + b"rg" | b"RG" => Some("DeviceRGB".to_owned()), + b"k" | b"K" => Some("DeviceCMYK".to_owned()), + b"g" | b"G" => Some("DeviceGray".to_owned()), + b"cs" | b"CS" => op + .operands() + .next() + .and_then(|o| o.clone().into_name()) + .map(|n| n.as_str().to_owned()) + .filter(|n| matches!(n.as_str(), "DeviceRGB" | "DeviceCMYK" | "DeviceGray")), + _ => None, + }; + if let Some(family) = family { + self.summary.color_spaces.insert(family); + } + } + } + + fn resources(&mut self, res: &Resources<'_>) { + for key in res.color_spaces.keys() { + if let Some(cs) = res.color_spaces.get::>(key.as_ref()) { + self.summary.color_spaces.insert(describe(&cs, 0)); + } + } + for key in res.ext_g_states.keys() { + if let Some(gs) = res.ext_g_states.get::>(key.as_ref()) + && self.first_visit(gs.obj_id()) + { + self.ext_g_state(&gs); + } + } + for key in res.shadings.keys() { + if let Some(shading) = res.shadings.get::>(key.as_ref()) { + self.shading(&shading); + } + } + for key in res.patterns.keys() { + let Some(pattern) = res.patterns.get::>(key.as_ref()) else { continue }; + let (dict, stream) = match pattern { + Object::Dict(dict) => (dict, None), + Object::Stream(stream) => (stream.dict().clone(), Some(stream)), + _ => continue, + }; + if let Some(shading) = dict.get::>(b"Shading".as_slice()) { + self.summary.color_spaces.insert("Pattern".to_owned()); + self.shading(&shading); + } + if let Some(stream) = stream + && self.first_visit(Some(stream.obj_id())) + { + self.summary.color_spaces.insert("Pattern".to_owned()); + self.content(&stream); + } + } + for key in res.x_objects.keys() { + if let Some(xobject) = res.x_objects.get::>(key.as_ref()) + && self.first_visit(Some(xobject.obj_id())) + { + self.x_object(&xobject, "form"); + } + } + } + + fn ext_g_state(&mut self, gs: &Dict<'_>) { + let mut state = ExtGState { + 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()), + _ => None, + }), + ..Default::default() + }; + if let Some(mask) = gs.get::>(b"SMask".as_slice()) { + state.smask = Some(mask.get::>(b"S".as_slice()).map(|n| n.as_str().to_owned()).unwrap_or_default()); + state.smask_bc = mask.contains_key(b"BC".as_slice()); + state.smask_tr = mask.get::>(b"TR".as_slice()).is_some_and(|tr| !matches!(&tr, Object::Name(n) if n.as_str() == "Identity")); + if let Some(source) = mask.get::>(b"G".as_slice()) + && self.first_visit(Some(source.obj_id())) + { + self.x_object(&source, "soft mask"); + } + } + self.summary.ext_g_states.push(state); + } + + fn shading(&mut self, shading: &Object<'_>) { + let dict = match shading { + Object::Dict(dict) => dict.clone(), + Object::Stream(stream) => stream.dict().clone(), + _ => return, + }; + if let Some(cs) = dict.get::>(b"ColorSpace".as_slice()) { + self.summary.color_spaces.insert(describe(&cs, 0)); + } + } + + fn x_object(&mut self, xobject: &Stream<'_>, at: &str) { + let dict = xobject.dict(); + match dict.get::>(b"Subtype".as_slice()).as_ref().map(Name::as_str) { + Some("Image") => { + if !dict.get::(b"ImageMask".as_slice()).unwrap_or(false) + && let Some(cs) = dict.get::>(b"ColorSpace".as_slice()) + { + self.summary.color_spaces.insert(describe(&cs, 0)); + } + } + Some("Form") => { + if let Some(group) = dict.get::>(b"Group".as_slice()) { + self.group(at, &group); + } + self.content(xobject); + } + _ => {} + } + } + + fn content(&mut self, stream: &Stream<'_>) { + if let Ok(data) = stream.decoded() { + self.operators(&mut UntypedIter::new(&data)); + } + if let Some(res) = stream.dict().get::>(b"Resources".as_slice()) { + self.resources(&Resources::of(&res)); + } + } +} + +/// A color space's family, with the names and alternates that matter for separation. +fn describe(cs: &Object<'_>, depth: usize) -> String { + if depth > 4 { + return "?".to_owned(); + } + match cs { + Object::Name(name) => name.as_str().to_owned(), + Object::Array(array) => { + let items: Vec> = array.iter::>().collect(); + let Some(Object::Name(kind)) = items.first() else { return "?".to_owned() }; + let alt = |i: usize| items.get(i).map(|o| describe(o, depth + 1)).unwrap_or_else(|| "?".to_owned()); + match kind.as_str() { + "Separation" => { + let name = items.get(1).and_then(|o| o.clone().into_name()).map(|n| n.as_str().to_owned()).unwrap_or_default(); + format!("Separation({name}, alt {})", alt(2)) + } + "DeviceN" => { + let names: Vec = match items.get(1) { + Some(Object::Array(names)) => names.iter::>().map(|n| n.as_str().to_owned()).collect(), + _ => Vec::new(), + }; + format!("DeviceN({}, alt {})", names.join(","), alt(2)) + } + "ICCBased" => { + let n = match items.get(1) { + Some(Object::Stream(stream)) => stream.dict().get::(b"N".as_slice()).unwrap_or(0), + _ => 0, + }; + format!("ICCBased(N={n})") + } + "Indexed" => format!("Indexed({})", alt(1)), + "Pattern" if items.len() > 1 => format!("Pattern({})", alt(1)), + other => other.to_owned(), + } + } + _ => "?".to_owned(), + } +} + +/// 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)] +pub struct StructureExpect { + /// The page group's `/CS`: a family, `"none"` (no page group) or `"no-cs"` (group without + /// `/CS`). Checked on every page. + #[serde(default)] + pub page_group: Option, + /// The set of `/CS` values of form and soft-mask groups; `"none"` stands for a group + /// without `/CS`. + #[serde(default)] + pub group_cs: Option>, + /// Some group has `/K true`. + #[serde(default)] + pub knockout: Option, + /// Some group (page groups excluded) has `/I true`. + #[serde(default)] + pub isolated: Option, + /// Some graphics state sets `/OP` or `/op` true. + #[serde(default)] + pub overprint: Option, + /// Some graphics state sets a soft mask. + #[serde(default)] + pub smask: Option, + /// The set of blend modes other than Normal. + #[serde(default)] + pub blend_modes: Option>, + /// Each entry must match some graphics state on every key it gives. + #[serde(default)] + pub ext_g_states: Option>, + /// The exact set of painting color space families. + #[serde(default)] + pub color_spaces: Option>, + /// Families that must be among the painting color spaces. + #[serde(default)] + pub color_spaces_include: Option>, + #[serde(default)] + pub device_rgb: Option, + #[serde(default)] + pub output_intents: Option>, +} + +/// A partial graphics state; keys as in the PDF. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExtGStateMatch { + #[serde(rename = "OP", default)] + pub op_stroke: Option, + #[serde(rename = "op", default)] + pub op_fill: Option, + #[serde(rename = "OPM", default)] + pub opm: Option, + #[serde(rename = "BM", default)] + pub bm: Option, + #[serde(rename = "SMask", default)] + pub smask: Option, + #[serde(rename = "BC", default)] + pub smask_bc: Option, + #[serde(rename = "TR", default)] + pub smask_tr: Option, +} + +impl ExtGStateMatch { + fn matches(&self, gs: &ExtGState) -> bool { + (self.op_stroke.is_none() || self.op_stroke == gs.op_stroke) + && (self.op_fill.is_none() || self.op_fill == gs.op_fill) + && (self.opm.is_none() || self.opm == gs.opm) + && (self.bm.is_none() || self.bm == gs.bm) + && (self.smask.is_none() || self.smask == gs.smask) + && self.smask_bc.is_none_or(|bc| bc == gs.smask_bc) + && self.smask_tr.is_none_or(|tr| tr == gs.smask_tr) + } +} + +impl StructureExpect { + /// Differences between the expectation and `summary`; empty when it matches. + pub fn check(&self, summary: &Summary) -> Vec { + let mut out = Vec::new(); + let mut differ = |what: &str, want: String, got: String| { + if want != got { + out.push(format!("{what}: expected {want}, found {got}")); + } + }; + let page_groups: Vec<&Group> = summary.groups.iter().filter(|g| g.at.starts_with("page")).collect(); + let others: Vec<&Group> = summary.groups.iter().filter(|g| !g.at.starts_with("page")).collect(); + if let Some(want) = &self.page_group { + let got = if page_groups.is_empty() { + "none".to_owned() + } else { + let all: BTreeSet = page_groups.iter().map(|g| g.cs.clone().unwrap_or_else(|| "no-cs".to_owned())).collect(); + all.into_iter().collect::>().join(" | ") + }; + differ("page_group", want.clone(), got); + } + if let Some(want) = &self.group_cs { + let got: BTreeSet = others.iter().map(|g| g.cs.clone().unwrap_or_else(|| "none".to_owned())).collect(); + differ("group_cs", format!("{want:?}"), format!("{got:?}")); + } + if let Some(want) = self.knockout { + differ("knockout", want.to_string(), summary.groups.iter().any(|g| g.k).to_string()); + } + if let Some(want) = self.isolated { + differ("isolated", want.to_string(), others.iter().any(|g| g.i).to_string()); + } + let states = &summary.ext_g_states; + if let Some(want) = self.overprint { + let got = states.iter().any(|s| s.op_stroke == Some(true) || s.op_fill == Some(true)); + differ("overprint", want.to_string(), got.to_string()); + } + if let Some(want) = self.smask { + differ("smask", want.to_string(), states.iter().any(|s| s.smask.is_some()).to_string()); + } + if let Some(want) = &self.blend_modes { + let got: BTreeSet = states.iter().filter_map(|s| s.bm.clone()).filter(|bm| bm != "Normal" && bm != "Compatible").collect(); + differ("blend_modes", format!("{want:?}"), format!("{got:?}")); + } + if let Some(want) = &self.color_spaces { + differ("color_spaces", format!("{want:?}"), format!("{:?}", summary.color_spaces)); + } + if let Some(want) = &self.color_spaces_include { + let missing: BTreeSet<&String> = want.difference(&summary.color_spaces).collect(); + differ("color_spaces_include missing", "{}".to_owned(), format!("{missing:?}")); + } + if let Some(want) = self.device_rgb { + differ("device_rgb", want.to_string(), summary.device_rgb.to_string()); + } + if let Some(want) = &self.output_intents { + differ("output_intents", format!("{want:?}"), format!("{:?}", summary.output_intents)); + } + for wanted in self.ext_g_states.iter().flatten() { + if !states.iter().any(|s| wanted.matches(s)) { + out.push(format!("ext_g_states: nothing matches {wanted:?}")); + } + } + out + } +} diff --git a/crates/plate-test/src/tiff.rs b/crates/plate-test/src/tiff.rs new file mode 100644 index 00000000..0d673106 --- /dev/null +++ b/crates/plate-test/src/tiff.rs @@ -0,0 +1,219 @@ +//! A minimal reader for the plates Ghostscript's `tiffsep` device writes: one uncompressed +//! 8-bit gray channel in strips, either byte order. +//! +//! `tiffsep` writes plates as BlackIsZero, so a sample of 0 is solid ink and 255 is paper. + +use crate::Error; + +/// One separation plate, as ink levels (0 = paper, 255 = solid ink), row-major. +#[derive(Debug, Clone, PartialEq)] +pub struct Plate { + width: usize, + height: usize, + ink: Vec, +} + +impl Plate { + /// A plate from ink levels; `None` when `ink` doesn't hold `width × height` values. + pub fn new(width: usize, height: usize, ink: Vec) -> Option { + (width.checked_mul(height) == Some(ink.len())).then_some(Self { width, height, ink }) + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + /// Ink percentage (0–100) at a pixel; `None` outside the plate. + pub fn ink(&self, x: usize, y: usize) -> Option { + if x >= self.width { + return None; + } + self.ink.get(y.checked_mul(self.width)?.checked_add(x)?).map(|level| f32::from(*level) * 100.0 / 255.0) + } +} + +const WIDTH: u16 = 256; +const HEIGHT: u16 = 257; +const BITS_PER_SAMPLE: u16 = 258; +const COMPRESSION: u16 = 259; +const PHOTOMETRIC: u16 = 262; +const STRIP_OFFSETS: u16 = 273; +const SAMPLES_PER_PIXEL: u16 = 277; +const ROWS_PER_STRIP: u16 = 278; + +/// Read a plate from TIFF bytes. +pub fn read(data: &[u8]) -> Result { + let bad = |what: &str| Error::Tiff(what.to_owned()); + let big = match data.get(..4) { + Some(b"II*\0") => false, + Some(b"MM\0*") => true, + _ => return Err(bad("not a TIFF file")), + }; + let file = File { data, big }; + let ifd = file.u32(4).ok_or_else(|| bad("truncated header"))? as usize; + let count = file.u16(ifd).ok_or_else(|| bad("truncated directory"))? as usize; + let mut entries = Vec::with_capacity(count); + for i in 0..count { + let at = ifd + 2 + 12 * i; + let (Some(tag), Some(kind), Some(n)) = (file.u16(at), file.u16(at + 2), file.u32(at + 4)) else { + return Err(bad("truncated directory")); + }; + entries.push(Entry { tag, kind, count: n as usize, value_at: at + 8 }); + } + let get = |tag: u16| -> Result>, Error> { + entries.iter().find(|e| e.tag == tag).map(|e| file.values(e).ok_or_else(|| bad(&format!("bad value for tag {tag}")))).transpose() + }; + let one = |tag: u16, default: Option| -> Result { + match get(tag)?.as_deref() { + Some([value]) => Ok(*value), + Some(_) => Err(bad(&format!("tag {tag} must hold one value"))), + None => default.ok_or_else(|| bad(&format!("missing tag {tag}"))), + } + }; + let (width, height) = (one(WIDTH, None)?, one(HEIGHT, None)?); + if one(BITS_PER_SAMPLE, Some(1))? != 8 || one(SAMPLES_PER_PIXEL, Some(1))? != 1 { + return Err(bad("only 8-bit single-channel plates are supported")); + } + if one(COMPRESSION, Some(1))? != 1 { + return Err(bad("only uncompressed plates are supported (-sCompression=none)")); + } + let white_is_zero = match one(PHOTOMETRIC, None)? { + 0 => true, + 1 => false, + other => return Err(bad(&format!("photometric interpretation {other} is not gray"))), + }; + let rows_per_strip = one(ROWS_PER_STRIP, Some(height))?.clamp(1, height.max(1)); + let offsets = get(STRIP_OFFSETS)?.ok_or_else(|| bad("missing strip offsets"))?; + let size = width.checked_mul(height).ok_or_else(|| bad("plate too large"))?; + let mut ink = Vec::with_capacity(size); + for (strip, offset) in offsets.iter().enumerate() { + let rows = rows_per_strip.min(height.saturating_sub(strip * rows_per_strip)); + let bytes = data.get(*offset..offset.saturating_add(rows * width)).ok_or_else(|| bad("strip outside the file"))?; + ink.extend(bytes.iter().map(|v| if white_is_zero { *v } else { 255 - v })); + } + Plate::new(width, height, ink).ok_or_else(|| bad("strips don't cover the plate")) +} + +struct Entry { + tag: u16, + kind: u16, + count: usize, + /// Where the value (four bytes or less) or the offset to it sits. + value_at: usize, +} + +struct File<'a> { + data: &'a [u8], + big: bool, +} + +impl File<'_> { + fn u16(&self, at: usize) -> Option { + let b: [u8; 2] = self.data.get(at..at + 2)?.try_into().ok()?; + Some(if self.big { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) }) + } + + fn u32(&self, at: usize) -> Option { + let b: [u8; 4] = self.data.get(at..at + 4)?.try_into().ok()?; + Some(if self.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) }) + } + + /// SHORT or LONG values, inline when they fit in four bytes. + fn values(&self, entry: &Entry) -> Option> { + let size = match entry.kind { + 3 => 2, + 4 => 4, + _ => return None, + }; + let start = if entry.count * size <= 4 { entry.value_at } else { self.u32(entry.value_at)? as usize }; + (0..entry.count) + .map(|i| { + let at = start.checked_add(i.checked_mul(size)?)?; + if size == 2 { self.u16(at).map(usize::from) } else { self.u32(at).map(|v| v as usize) } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + /// A gray TIFF as `tiffsep` writes it (BlackIsZero samples), in strips of `rows` rows. + fn encode(width: u16, height: u16, samples: &[u8], rows: u16, big: bool) -> Vec { + let p16 = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() }; + let p32 = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() }; + let strips: Vec<&[u8]> = samples.chunks(usize::from(width) * usize::from(rows)).collect(); + let mut out = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() }; + out.extend(p32(8)); + let entries = 8u16; + let dir_end = 8 + 2 + 12 * u32::from(entries) + 4; + let offsets_at = dir_end; + let data_at = offsets_at + 4 * strips.len() as u32; + let mut dir = p16(entries).to_vec(); + let mut entry = |tag: u16, kind: u16, count: u32, value: u32| { + dir.extend(p16(tag)); + dir.extend(p16(kind)); + dir.extend(p32(count)); + if kind == 3 && count == 1 { + dir.extend(p16(value as u16)); + dir.extend([0, 0]); + } else { + dir.extend(p32(value)); + } + }; + entry(WIDTH, 3, 1, u32::from(width)); + entry(HEIGHT, 3, 1, u32::from(height)); + entry(BITS_PER_SAMPLE, 3, 1, 8); + entry(COMPRESSION, 3, 1, 1); + entry(PHOTOMETRIC, 3, 1, 1); + let first = if strips.len() == 1 { data_at } else { offsets_at }; + entry(STRIP_OFFSETS, 4, strips.len() as u32, first); + entry(SAMPLES_PER_PIXEL, 3, 1, 1); + entry(ROWS_PER_STRIP, 3, 1, u32::from(rows)); + dir.extend(p32(0)); + out.extend(dir); + let mut at = data_at; + for strip in &strips { + out.extend(p32(at)); + at += strip.len() as u32; + } + for strip in strips { + out.extend(strip); + } + out + } + + #[test] + fn reads_strips_in_both_byte_orders() { + let samples: Vec = (0..15).map(|i| (i * 17) as u8).collect(); + for big in [false, true] { + for rows in [1, 2, 3] { + let plate = read(&encode(5, 3, &samples, rows, big)).unwrap(); + assert_eq!((plate.width(), plate.height()), (5, 3)); + assert_eq!(plate.ink(0, 0), Some(100.0), "sample 0 is solid ink"); + assert_eq!(plate.ink(4, 2), Some((255.0 - 238.0) * 100.0 / 255.0)); + assert_eq!(plate.ink(5, 0), None); + assert_eq!(plate.ink(0, 3), None); + } + } + } + + #[test] + fn rejects_what_it_cannot_read() { + let good = encode(2, 2, &[0, 0, 0, 0], 2, false); + assert!(read(b"GIF89a").is_err()); + assert!(read(&good[..40]).is_err()); + assert!(read(&good[..good.len() - 1]).is_err(), "truncated strip"); + let mut packed = good.clone(); + // Compression (the fourth entry's value) set to PackBits. + packed[8 + 2 + 12 * 3 + 8] = 5; + assert!(read(&packed).unwrap_err().to_string().contains("uncompressed")); + } +} diff --git a/crates/plate-test/tests/ghostscript.rs b/crates/plate-test/tests/ghostscript.rs new file mode 100644 index 00000000..ae2cd38a --- /dev/null +++ b/crates/plate-test/tests/ghostscript.rs @@ -0,0 +1,32 @@ +//! Local plate check: separates every scene with Ghostscript and judges its sample points. +//! Run it with `scripts/plate-check.sh`; it never runs in CI. + +#![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}; + +#[test] +#[ignore = "needs Ghostscript; run scripts/plate-check.sh"] +fn every_scene_separates_as_expected() { + 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 report = report::run(&ctx, version); + let path = report::write(&report, &report::output_dir()).unwrap(); + for result in &report.scenes { + let pending = result.pending.as_deref().map(|t| format!(" (pending {t})")).unwrap_or_default(); + println!("{} {:?}{pending}", result.id, result.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.status == Status::Pass)); +} diff --git a/crates/plate-test/tests/scenes.rs b/crates/plate-test/tests/scenes.rs new file mode 100644 index 00000000..0e114471 --- /dev/null +++ b/crates/plate-test/tests/scenes.rs @@ -0,0 +1,137 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use craft_plate_test::reference::{self, Context}; +use craft_plate_test::scene::{self, Scene}; +use craft_plate_test::{Error, structure}; + +fn scenes() -> Vec { + scene::load_dir(&scene::scenes_dir()).unwrap() +} + +/// On CI (`CI` set) the workflow provisions the font, so a scene that left text out fails. +/// Local runs without the font skip text and only note it. +fn text_must_be_written() -> bool { + std::env::var_os("CI").is_some_and(|v| !v.is_empty() && v != "false") +} + +fn missing_font(scene: &Scene) -> String { + format!("{}: text skipped; {} must name a directory with {} on CI", scene.id, reference::FONT_DIR_ENV, reference::FONT_FILE) +} + +#[test] +fn every_scene_parses_and_is_consistent() { + let scenes = scenes(); + assert!(!scenes.is_empty()); + let problems: Vec = scenes.iter().flat_map(|s| s.validate(&scenes)).collect(); + assert!(problems.is_empty(), "{}", problems.join("\n")); + let mut ids: Vec<&str> = scenes.iter().map(|s| s.id.as_str()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), scenes.len(), "scene ids must be unique"); +} + +/// Non-pending scenes are written and match their `structure` block (with text left out for +/// lack of fonts, a mismatch is only a note, except on CI). Pending scenes must +/// either write or stop at a feature the writer lacks, so their other fields stay valid. +#[test] +fn written_scenes_match_their_structure() { + let scenes = scenes(); + let ctx = Context::from_env(&scenes); + if ctx.font.is_none() { + println!("note: {} not set; text objects are skipped", reference::FONT_DIR_ENV); + } + let mut failures = Vec::new(); + let mut written = 0; + for scene in &scenes { + let result = reference::write(scene, &ctx); + if let Some(task) = &scene.pending { + match result { + Ok(_) | Err(Error::Unsupported(_)) => println!("{} pending ({task})", scene.id), + Err(e) => failures.push(format!("{} (pending {task}): {e}", scene.id)), + } + continue; + } + let output = match result { + Ok(output) => output, + Err(e) => { + failures.push(format!("{}: {e}", scene.id)); + continue; + } + }; + for note in &output.notes { + println!("note: {note}"); + } + let summary = structure::summarize(&output.pdf).unwrap(); + let problems = scene.structure.check(&summary); + // A scene whose overprint or other states sit only on text can't match without fonts. + let text_skipped = output.notes.iter().any(|note| note.contains(reference::FONT_DIR_ENV)); + if text_skipped && text_must_be_written() { + failures.push(missing_font(scene)); + } else if !problems.is_empty() && text_skipped { + println!("note: {}: structure not checked with text skipped: {}", scene.id, problems.join("; ")); + } else if !problems.is_empty() { + let json = serde_json::to_string_pretty(&summary).unwrap(); + failures.push(format!("{}:\n {}\nsummary: {json}", scene.id, problems.join("\n "))); + } + written += 1; + } + println!("{written} scenes written, {} pending", scenes.len() - written); + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// The print preset (the FOGRA39 output intent) adds one `GTS_PDFX` intent and leaves the rest +/// of a scene's structure as the scene expects. Three scenes cover blending, overprint and +/// DeviceN; embedding the 700 KB profile in every scene would make this the slowest test. +#[test] +fn print_preset_adds_one_gts_pdfx_intent() { + let scenes = scenes(); + let ctx = Context::from_env(&scenes); + let mut failures = Vec::new(); + for scene in scenes.iter().filter(|s| ["S01", "S06", "S14"].contains(&s.id.as_str())) { + let mut print = scene.clone(); + print.settings.output_intent = Some("fogra39".to_owned()); + let output = reference::write(&print, &ctx).unwrap(); + let summary = structure::summarize(&output.pdf).unwrap(); + let mut want = scene.structure.clone(); + want.output_intents = Some(vec!["GTS_PDFX".to_owned()]); + let problems = want.check(&summary); + let text_skipped = output.notes.iter().any(|note| note.contains(reference::FONT_DIR_ENV)); + if text_skipped && text_must_be_written() { + failures.push(missing_font(scene)); + } else if !problems.is_empty() && text_skipped { + assert!(summary.output_intents.iter().filter(|i| *i == "GTS_PDFX").count() == 1, "{}: {summary:?}", scene.id); + } else if !problems.is_empty() { + failures.push(format!("{}: {}", scene.id, problems.join("; "))); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// The check catches the RGB blending regression: S06 written with the writer's default group +/// space has an RGB form group and no page group, which its `structure` block rejects. +#[test] +fn structure_check_rejects_rgb_blending() { + let scenes = scenes(); + let mut s06 = scenes.iter().find(|s| s.id == "S06").unwrap().clone(); + s06.settings.group_cs = None; + let pdf = reference::write(&s06, &Context { scenes: &scenes, font: None }).unwrap().pdf; + let summary = structure::summarize(&pdf).unwrap(); + assert!(summary.groups.iter().any(|g| g.at == "form" && g.s == "Transparency" && g.cs.as_deref() == Some("DeviceRGB")), "{summary:?}"); + assert!(summary.color_spaces.contains("DeviceCMYK"), "{summary:?}"); + let problems = s06.structure.check(&summary); + assert!(problems.iter().any(|p| p.starts_with("page_group")), "{problems:?}"); + assert!(problems.iter().any(|p| p.starts_with("group_cs")), "{problems:?}"); + assert!(structure::summarize(b"not a pdf").is_err()); +} + +/// N1 waits for overprint (T4); without it, the DeviceN fill and its color spaces already +/// match the scene. +#[test] +fn device_n_scene_writes_its_color_spaces() { + let scenes = scenes(); + let n1 = scenes.iter().find(|s| s.id == "N1").unwrap(); + let output = reference::write_partial(n1, &Context { scenes: &scenes, font: None }).unwrap(); + let summary = structure::summarize(&output.pdf).unwrap(); + assert_eq!(Some(&summary.color_spaces), n1.structure.color_spaces.as_ref(), "{summary:?}"); + assert!(!summary.device_rgb, "{summary:?}"); +} diff --git a/crates/prepress/Cargo.toml b/crates/prepress/Cargo.toml new file mode 100644 index 00000000..e78fbbd8 --- /dev/null +++ b/crates/prepress/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "craft-prepress" +description = "Prepress for the Craft apps: ink model, per-ink plates rendered from a PDF, coverage, ink limits and composite preview" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +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 new file mode 100644 index 00000000..49beb635 --- /dev/null +++ b/crates/prepress/README.md @@ -0,0 +1,224 @@ +# craft-prepress + +Prepress for the Craft apps: separations, ink limits and the ink manager. + +- `ink`: the ink model the writers and previews share. `InkSet` holds the four process inks + (always first, in plate order) and the spot inks of a job, and is the job's ink manager + (aliases, spots to process); `InkColor` is a color as ink tints (`Separation`, `DeviceN`, + registration or none); `Overprint` holds `op`, `OP` and `OPM`. Spot names are compared as + PDF names: `/Gold#20Foil` and `Gold Foil` are one ink. `to_pdf_color` writes an `InkColor` + as a krilla color for export. +- `plates`: one 8-bit plane per ink (255 = 100% ink) rendered from a PDF page. +- `coverage`: ink coverage at a pixel and over a page, total area coverage (TAC) per pixel, + and the pixels and regions over a TAC limit. +- `composite`: an sRGB preview of chosen plates. +- `cms`: process color conversion into a CMYK printing condition through craft-cms. + +- `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 + +An app exports its PDF as usual, then separates a page of it: + +```rust +use craft_prepress::{plates, InkId, Options, Process}; + +let p = plates(&pdf, 0, 150.0, &Options::default())?; +for (id, ink) in p.inks.iter() { + let plane = p.plane(id); // p.width × p.height tints, row-major + println!("{}: {} pixels inked", ink.name(), plane.map_or(0, |t| t.iter().filter(|v| **v > 0).count())); +} +let black = p.tint(InkId::process(Process::Black), 10, 10); +let gold = p.inks.lookup("Gold").and_then(|id| p.tint(id, 10, 10)); +``` + +The plates are the PDF as printed, so a preview built on them shows what the writer produced, +not what the document model intended. + +How a plate is made: the page renders once per ink through craft-pdf's `raster` with +hayro-interpret's plate mode (`InterpreterSettings::plate`). Every paint draws its tint of +that ink as gray film. A paint that overprints (`op`/`OP`, and `OPM 1` for zero DeviceCMYK +components) without naming the ink leaves the film as it is; with `Options::overprint` off, +every paint knocks out. Clipping, anti-aliasing, soft masks, transparency and knockout groups +stay the renderer's own. `Separation` and `DeviceN` colorants paint their own plates (`All` +every plate, `None` none), DeviceCMYK its components, DeviceGray black only. + +Inks are found by a low-resolution pass that records each spot ink a paint uses, in the order +first painted. Spot inks used only inside soft masks are left out. Each spot gets a Lab +alternate measured from its sRGB appearance at 100%, so out-of-sRGB spots are clipped. + +### Process color + +RGB, CIE-based and ICCBased colors become CMYK through, in order: + +1. the CMYK profile of the page's or the document's output intent (a `GTS_PDFX` intent first), + when craft-cms can convert into it; +2. `Options::fallback`: craft-cms's Coated FOGRA39 profile (the default), a profile the caller + gives, or the ISO 32000-2 §10.4.2.4 device formulas. + +`Plates::conversion` says which was used. Conversions are relative colorimetric without black +point compensation. DeviceCMYK, and ICCBased CMYK in the printing condition's own profile, +separate as they are. + +### Caps + +| Constant | Value | Why | +|---|---|---| +| `MAX_SPOT_INKS` | 32 spot inks per page | A `DeviceN` space has at most 32 colorants, and each plate costs a render and a byte per pixel. Further spots are listed in `Plates::unseparated`. | +| `MAX_PLATE_PIXELS` | 4 MP per plate (2000 × 2000) | A page with many inks stays within a few hundred megabytes. The resolution is lowered to fit; `Plates::dpi` reports the one used. | + +The renderer's side limit (`craft_pdf::raster::MAX_SIDE`) also lowers the resolution. + +### Approximations + +- Non-separable blend modes and groups that blend in RGB are approximated per ink. +- The graphics state's rendering intent is not read; transfer functions and halftones are not + applied. + +## Coverage and TAC + +Every reading takes `enabled`, indexed by `InkId::index` like a Separations Preview's +toggles: inks past its end count, so `&[]` counts every ink. Values are percent of one ink at +100%, so TAC runs to 100 × the number of inks. + +| Call | Gives | +|---|---| +| `coverage_at(x, y, enabled)` | `Coverage`: each ink's tint and the TAC of the enabled inks at a pixel | +| `pixel_at(u, v)` | The pixel under a point given as fractions of the page, for a pointer readout | +| `tac(enabled)` | TAC per pixel, row-major | +| `stats(enabled)` | `Stats`: per ink `InkStats { max, mean, area }`; the page's highest TAC, where, and its mean | +| `above(limit, enabled)` | `Above`: a mask of the pixels over `limit` and their `count`; `regions()` lists connected regions with bounding boxes, largest first (at most `MAX_REGIONS`); `paint(rgb, color)` marks them on a preview | + +## Composite preview + +`Plates::composite(enabled, &cms)` returns sRGB8 (three bytes per pixel, row-major) of the +enabled inks: + +- the process plates convert through `cms` (a `CmsConversion` for the plates' printing + condition, `Plates::conversion` names it), relative colorimetric with black point + compensation, or through the bundled CMYK profile when `cms` can't convert; +- each enabled spot plate multiplies over them by the spot's appearance at its tint: a Lab + alternate interpolated from paper white to the solid, or a CMYK alternate scaled by the tint. + +Overprint is already in the plates (the renderer decides per plate what overprints and what +knocks out), so the preview shows it without rewriting any paint as a blend mode. + +```rust +use craft_prepress::{CmsConversion, InkId}; + +let cms = CmsConversion::fogra39(); +let mut enabled = vec![true; p.inks.len()]; +enabled[InkId::process(craft_prepress::Process::Black).index()] = false; // hide Black +let mut rgb = p.composite(&enabled, &cms); +let over = p.above(300.0, &enabled); +over.paint(&mut rgb, [255, 0, 255]); // TAC warning +let worst = over.regions().first().copied(); +``` + +## Ink manager + +`InkSet` carries the job's ink settings next to its inks: + +| Call | Effect | +|---|---| +| `alias(from, to)` | Spot `from` prints on `to`'s plate (a process ink or another spot, chains followed); `alias(from, from)` removes it. Loops and process `from` are refused. | +| `set_to_process(ink, on)` | Spot `ink` prints as process color, from its alternate (`spot_cmyk`). | +| `set_all_to_process(on)` | All Spots to Process; aliases still apply first. | +| `plate_of(ink)` | The plate a tint of `ink` lands on, `None` when it prints as process. | +| `resolve(&color, &cms)` | `color` with both applied. Tints landing on one plate combine as `1 − (1 − a)(1 − b)`; a converted color lists the four process inks first, and `InkColor::as_cmyk` gives the CMYK when no spot is left. | + +`resolve` takes a `CmykConversion` (such as `CmsConversion`) because a spot with a Lab +alternate needs the printing condition to become CMYK. + +Apply it in two places: + +- **Export.** The writer calls `resolve` on every color it emits, then + `to_pdf_color(&resolved, &inks, &cms)` for the krilla color to paint with (see + [Export](#export)). An overprinting color that became CMYK is written with `OPM 1`, so its + zero components leave the plates under it as they are. +- **Preview.** The preview separates that exported PDF with `plates` and shows + `composite` and the coverage readings. Plates are the truth: an ink manager change shows + after the next export, and the preview never recomputes overprint from the document. + +## Export + +`to_pdf_color(&color, &inks, &cms)` gives the krilla color (`craft_pdf::write::backend::color::Color`) +for an `InkColor`, choosing the color space from the inks it names: + +| Color | Written as | +|---|---| +| Tints of process inks only | DeviceCMYK; unlisted process inks are 0 | +| One spot ink | `Separation` with the spot's alternate as DeviceCMYK fallback | +| Any other mix | `DeviceN`: process inks first in plate order, then spots in `InkSet` order | +| `All` | `Separation /All` (registration) | +| `None` | `Separation /None` (marks no plate) | + +A spot's alternate is `spot_cmyk`: a CMYK alternate as given, a Lab one converted through +`cms` into the printing condition, so a spot has one alternate in every space that names it. +`None` when the color names an ink not in the set or a `DeviceN` space would need more than +32 colorants. + +```rust +use craft_prepress::{CmsConversion, InkColor, to_pdf_color}; + +let cms = CmsConversion::fogra39(); +let printed = inks.resolve(&InkColor::Tints(vec![(gold, 1.0), (black, 0.5)]), &cms); +if let Some(color) = to_pdf_color(&printed, &inks, &cms) { + surface.set_fill(Some(Fill { paint: color.into(), ..Default::default() })); // DeviceN [Black Gold] +} +``` + +## In DesignCraft and VectorCraft + +- **Separations Preview**: export the spread to PDF + in memory, `plates(pdf, 0, dpi, &Options::default())` at the zoom's resolution, then + `composite(enabled, &cms)` for the canvas. The panel lists `p.inks` with a toggle per row + feeding `enabled`, each row's percentage from `coverage_at` under the pointer + (`pixel_at`), and the TAC mode from `above(limit, enabled)` with `paint`. Single-plate view + shows `p.plane(id)` as gray film. It would replace VectorCraft's `proof.rs`, which + recolors the document and draws overprint as Darken. +- **Ink Manager** (DesignCraft's `InkManager` aliases and convert-to-process, VectorCraft's + `inks.rs` plates): keep one `InkSet` per document, add each spot swatch with its Lab or + CMYK alternate, and map the dialog's rows onto `alias`, `set_to_process` and + `set_all_to_process`. The PDF writer resolves swatch colors through it; the Separations + Preview then lists the plates the export produced. +- **Preflight**: `stats(&[])` gives per-ink max and mean coverage and the page's highest TAC + and its position for a report line such as "TAC 340% at …, limit 300%". + +## Credits + +- Plate mode, ink discovery, the output-intent handling and the coverage readings and + composite (`Separations::above`, `stats`, `composite`) come from + [storytold/pdfcraft#2770](https://github.com/storytold/pdfcraft/pull/2770) by Guillem Hermida + (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. + +All MIT OR Apache-2.0, as this crate. diff --git a/crates/prepress/src/cms.rs b/crates/prepress/src/cms.rs new file mode 100644 index 00000000..97c5a74d --- /dev/null +++ b/crates/prepress/src/cms.rs @@ -0,0 +1,162 @@ +//! Process color conversion for plate mode through craft-cms. + +use std::sync::{Arc, OnceLock}; + +use craft_cms::{Builtin, ColorSpace, Intent, Profile, Transform, TransformOptions, builtin, cached, math}; +use craft_pdf::interpret::color::{CmykConversion, CmykSimulation}; + +/// Converts process color into a CMYK printing condition with craft-cms: relative +/// colorimetric, without black point compensation, as the plate renderer expects (see +/// [`CmykConversion`]). CMYK shows as sRGB relative colorimetric with black point +/// compensation. Transforms are built on first use and shared through craft-cms's cache. +pub struct CmsConversion { + profile: Profile, + perceptual: bool, + from_srgb: OnceLock>>, + from_lab: OnceLock>>, + to_srgb: OnceLock>>, +} + +/// Why a profile can't be a printing condition. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ProfileError { + #[error("the profile could not be read: {0}")] + Unreadable(String), + #[error("the profile is not CMYK")] + NotCmyk, + #[error("the profile has no PCS-to-device (BToA) tables")] + NoPcsToDevice, +} + +impl CmsConversion { + /// The printing condition of ICC profile bytes `icc`. + pub fn new(icc: &[u8]) -> Result { + Self::from_profile(Profile::parse(icc).map_err(|e| ProfileError::Unreadable(format!("{e:?}")))?) + } + + /// The printing condition of a parsed CMYK profile with PCS-to-device tables. + pub fn from_profile(profile: Profile) -> Result { + if profile.color_space != ColorSpace::Cmyk { + return Err(ProfileError::NotCmyk); + } + if profile.pcs_to_device(Intent::RelativeColorimetric).is_err() { + return Err(ProfileError::NoPcsToDevice); + } + // Without a colorimetric BToA1 table craft-cms uses BToA0, the perceptual one. + let perceptual = profile.b2a[1].is_none() && profile.b2a[0].is_some(); + Ok(Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() }) + } + + /// craft-cms's Coated FOGRA39 profile. + pub fn fogra39() -> Self { + let profile = Builtin::CoatedFogra39.profile().clone(); + let perceptual = profile.b2a[1].is_none() && profile.b2a[0].is_some(); + Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() } + } + + /// A plate-mode printing condition converting through this, for the profile bytes `icc` + /// this was made from. ICCBased CMYK colors embedding those same bytes separate as they + /// are. + pub fn simulation(self, icc: &[u8]) -> CmykSimulation { + CmykSimulation::with_conversion(icc, self) + } + + fn transform(cell: &OnceLock>>, src: &Profile, dst: &Profile, bpc: bool) -> Option> { + cell.get_or_init(|| { + // The fast path interpolates the same tables that 8-bit buffers use: plates convert + // images sample by sample through here. + let opts = TransformOptions { intent: Intent::RelativeColorimetric, bpc, precise_float: false }; + cached(src, dst, opts).ok() + }) + .clone() + } +} + +fn whole(input: &[f32], channels: usize, cmyk: &[f32]) -> bool { + input.len().is_multiple_of(channels) && cmyk.len().is_multiple_of(4) && input.len() / channels == cmyk.len() / 4 +} + +fn finite01(cmyk: &mut [f32]) { + for v in cmyk { + *v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + } +} + +impl CmykConversion for CmsConversion { + fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> { + if !whole(rgb, 3, cmyk) { + return None; + } + let t = Self::transform(&self.from_srgb, builtin::srgb(), &self.profile, false)?; + t.convert_f32(rgb, 3, cmyk, 4, false).ok()?; + finite01(cmyk); + Some(()) + } + + fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> { + if !whole(xyz, 3, cmyk) { + return None; + } + // Into the Lab D50 identity profile's device values (ICC v4 Lab encoding). + let lab: Vec = xyz + .as_chunks::<3>() + .0 + .iter() + .flat_map(|c| { + let [l, a, b] = math::xyz_to_lab_f32(*c); + [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0] + }) + .collect(); + let t = Self::transform(&self.from_lab, Builtin::LabD50.profile(), &self.profile, false)?; + t.convert_f32(&lab, 3, cmyk, 4, false).ok()?; + finite01(cmyk); + Some(()) + } + + fn to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> { + if !cmyk.len().is_multiple_of(4) || cmyk.len() / 4 * 3 != rgb.len() { + return None; + } + let t = Self::transform(&self.to_srgb, &self.profile, builtin::srgb(), true)?; + t.convert_u8(cmyk, 4, rgb, 3, false).ok() + } + + fn description(&self) -> Option<&str> { + let d = self.profile.description.trim(); + (!d.is_empty()).then_some(d) + } + + fn perceptual(&self) -> bool { + self.perceptual + } +} + +/// CIE L\*a\*b\* (D50) of an sRGB color, relative colorimetric; `None` if craft-cms can't +/// convert. +pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> { + static T: OnceLock>> = OnceLock::new(); + let t = CmsConversion::transform(&T, builtin::srgb(), Builtin::LabD50.profile(), false)?; + let mut lab = [0.0_f32; 3]; + let rgb = rgb.map(|c| f32::from(c) / 255.0); + t.convert_f32(&rgb, 3, &mut lab, 3, false).ok()?; + Some([lab[0] * 100.0, lab[1] * 255.0 - 128.0, lab[2] * 255.0 - 128.0]) +} + +/// sRGB of CIE L\*a\*b\* (D50) colors, relative colorimetric, three values per color in +/// both; `None` if craft-cms can't convert or the lengths disagree. +pub(crate) fn lab_to_srgb8(lab: &[f32], rgb: &mut [u8]) -> Option<()> { + static T: OnceLock>> = OnceLock::new(); + if !lab.len().is_multiple_of(3) || lab.len() != rgb.len() { + return None; + } + let t = CmsConversion::transform(&T, Builtin::LabD50.profile(), builtin::srgb(), false)?; + // The Lab D50 identity profile's device values (ICC v4 Lab encoding). + let encoded: Vec = lab.as_chunks::<3>().0.iter().flat_map(|[l, a, b]| [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0]).collect(); + let mut out = vec![0.0_f32; encoded.len()]; + t.convert_f32(&encoded, 3, &mut out, 3, false).ok()?; + for (byte, v) in rgb.iter_mut().zip(out) { + *byte = if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + } + Some(()) +} diff --git a/crates/prepress/src/composite.rs b/crates/prepress/src/composite.rs new file mode 100644 index 00000000..fd5d48ec --- /dev/null +++ b/crates/prepress/src/composite.rs @@ -0,0 +1,155 @@ +//! A color preview composed from plates, as a Separations Preview shows the page. +//! +//! The preview is built from the plates alone, after the renderer has decided per plate what +//! overprints and what knocks out, so it shows overprint as printed without changing any +//! paint's blend mode. + +use craft_pdf::interpret::color::{CmykConversion, cmyk_to_srgb8}; + +use crate::cms::lab_to_srgb8; +use crate::coverage::filled; +use crate::{Alternate, InkId, Plates, Process}; + +/// Pixels converted per call into the CMYK profile, to bound the scratch buffers. +const CHUNK: usize = 16 * 1024; + +fn on(enabled: &[bool], index: usize) -> bool { + enabled.get(index).copied().unwrap_or(true) +} + +/// CMYK bytes to sRGB bytes through `cms`, else through the bundled profile pages are drawn +/// with; paper white if neither converts. +fn to_srgb(cms: &dyn CmykConversion, cmyk: &[u8], rgb: &mut [u8]) { + if cms.to_srgb8(cmyk, rgb).or_else(|| cmyk_to_srgb8(cmyk, rgb)).is_none() { + rgb.fill(255); + } +} + +/// Spot ink `alternate` alone at each tint byte, in sRGB: a Lab alternate runs in a straight +/// line from paper white (L\* 100) to the solid, a CMYK one scales its components, as a +/// `Separation` color space's usual tint transform does. Index the result by tint byte; +/// `None` when a Lab alternate can't be converted. +pub fn appearance(alternate: Alternate, cms: &dyn CmykConversion) -> Option> { + let mut rgb = vec![0_u8; 256 * 3]; + match alternate { + Alternate::Lab([l, a, b]) => { + let lab: Vec = (0..=255_u8) + .flat_map(|t| { + let t = f32::from(t) / 255.0; + [100.0 + t * (l - 100.0), t * a, t * b] + }) + .collect(); + lab_to_srgb8(&lab, &mut rgb)?; + } + Alternate::Cmyk(solid) => { + let cmyk: Vec = (0..=255_u8) + .flat_map(|t| solid.map(|c| (f32::from(t) * if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.0 }).round() as u8)) + .collect(); + to_srgb(cms, &cmyk, &mut rgb); + } + } + Some(rgb.as_chunks::<3>().0.to_vec()) +} + +impl Plates { + /// The enabled inks as they print, as sRGB8 (three bytes per pixel, row-major, + /// `width × height` pixels). `enabled` is indexed by [`InkId::index`]; inks past its end + /// are enabled, so `&[]` shows every ink. + /// + /// The process plates convert through `cms` ([`CmsConversion`](crate::CmsConversion) for + /// the printing condition the plates were made for, see [`Plates::conversion`]), relative + /// colorimetric with black point compensation, or through the bundled CMYK profile when + /// `cms` can't. Each spot plate is then multiplied over them by the spot's appearance at + /// its tint, from its alternate. Spots whose Lab alternate craft-cms can't convert are + /// left out. Empty when `width × height × 3` overflows or can't be allocated. + pub fn composite(&self, enabled: &[bool], cms: &dyn CmykConversion) -> Vec { + let Some(mut rgb) = self.pixels().and_then(|n| n.checked_mul(3)).and_then(|len| filled(len, 255_u8)) else { + return Vec::new(); + }; + let process: Vec> = Process::ALL + .iter() + .map(|p| { + let id = InkId::process(*p); + self.plane(id).filter(|_| on(enabled, id.index())) + }) + .collect(); + let mut cmyk = Vec::with_capacity(CHUNK * 4); + for (c, out) in rgb.chunks_mut(CHUNK * 3).enumerate() { + let start = c * CHUNK; + let len = out.len() / 3; + cmyk.clear(); + for i in start..start + len { + cmyk.extend(process.iter().map(|plane| plane.and_then(|p| p.get(i)).copied().unwrap_or(0))); + } + to_srgb(cms, &cmyk, out); + } + + for (id, spot) in self.inks.spots().filter(|(id, _)| on(enabled, id.index())) { + let Some(plane) = self.plane(id).filter(|p| p.iter().any(|t| *t > 0)) else { continue }; + let Some(ramp) = appearance(spot.alternate, cms) else { continue }; + for (px, tint) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(plane) { + if let Some(ink) = ramp.get(usize::from(*tint)).filter(|_| *tint > 0) { + for (c, s) in px.iter_mut().zip(ink) { + *c = ((u16::from(*c) * u16::from(*s) + 127) / 255) as u8; + } + } + } + } + rgb + } +} + +#[cfg(test)] +mod tests { + use crate::CmsConversion; + use crate::cms::srgb8_to_lab; + use crate::coverage::tests::synthetic; + + fn near(got: &[u8], expected: [u8; 3], tolerance: u8) -> bool { + got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= tolerance) + } + + #[test] + fn a_spot_appearance_runs_from_paper_to_its_solid() { + const GOLD: [u8; 3] = [212, 175, 55]; + let ramp = super::appearance(crate::Alternate::Lab(srgb8_to_lab(GOLD).unwrap()), &CmsConversion::fogra39()).unwrap(); + assert_eq!(ramp.len(), 256); + assert!(near(&ramp[0], [255, 255, 255], 1) && near(&ramp[255], GOLD, 2), "{:?} {:?}", ramp[0], ramp[255]); + } + + #[test] + fn a_black_and_gold_composite_multiplies_gold_over_black() { + const GOLD: [u8; 3] = [212, 175, 55]; + let lab = srgb8_to_lab(GOLD).unwrap(); + // Black alone, Gold alone, Gold over black, Gold at 50%. + let p = synthetic(4, 1, lab, [vec![0; 4], vec![0; 4], vec![0; 4], vec![255, 0, 255, 0], vec![0, 255, 255, 128]]); + let cms = CmsConversion::fogra39(); + let rgb = p.composite(&[], &cms); + assert_eq!(rgb.len(), 12); + + let black = &rgb[0..3]; + let (lo, hi) = (black.iter().min().unwrap(), black.iter().max().unwrap()); + assert!(*hi < 80 && hi - lo < 12, "Coated FOGRA39 black is a dark neutral: {black:?}"); + assert!(near(&rgb[3..6], GOLD, 2), "Gold: {:?}", &rgb[3..6]); + let over: Vec = black.iter().zip(GOLD).map(|(k, g)| ((u16::from(*k) * u16::from(g) + 127) / 255) as u8).collect(); + assert!(near(&rgb[6..9], [over[0], over[1], over[2]], 2), "Gold over black: {:?}", &rgb[6..9]); + let half = &rgb[9..12]; + assert!(half.iter().zip(GOLD).all(|(h, g)| *h > g && *h < 255) && half[2] > 110 && half[2] < 200, "Gold at 50%: {half:?}"); + + let no_black = p.composite(&[true, true, true, false], &cms); + assert!(near(&no_black[0..3], [255, 255, 255], 1), "paper: {:?}", &no_black[0..3]); + assert!(near(&no_black[6..9], GOLD, 2)); + let no_gold = p.composite(&[true, true, true, true, false], &cms); + assert_eq!(&no_gold[6..9], black); + } + + #[test] + fn plates_too_large_to_hold_give_empty_results() { + let p = crate::Plates { width: u32::MAX, height: u32::MAX, ..synthetic(0, 0, [50.0, 0.0, 0.0], Default::default()) }; + assert!(p.composite(&[], &CmsConversion::fogra39()).is_empty()); + assert!(p.tac(&[]).is_empty()); + assert_eq!(p.stats(&[]).max_total_at, None); + assert!(p.above(10.0, &[]).regions().is_empty()); + assert_eq!(p.tint(crate::InkId::process(crate::Process::Black), u32::MAX - 1, u32::MAX - 1), None); + } +} diff --git a/crates/prepress/src/coverage.rs b/crates/prepress/src/coverage.rs new file mode 100644 index 00000000..74563b50 --- /dev/null +++ b/crates/prepress/src/coverage.rs @@ -0,0 +1,307 @@ +//! Ink coverage read from plates: per ink at a point and over a page, total area coverage +//! (TAC) per pixel, and the pixels over a TAC limit. +//! +//! Inks are chosen with an `enabled` list indexed by [`InkId::index`], as a Separations +//! Preview's toggles hold them: inks past its end are enabled, so `&[]` enables every ink. +//! Percentages are of one ink's 100%, so a TAC runs to 100 × the number of inks. + +use crate::{InkId, Plates}; + +/// The most regions [`Above::regions`] lists. +pub const MAX_REGIONS: usize = 256; + +/// Coverage at one pixel, in percent. +#[derive(Clone, Debug, PartialEq)] +pub struct Coverage { + /// Each ink's tint, in [`InkSet::iter`](crate::InkSet::iter) order, enabled or not. + pub inks: Vec, + /// The total area coverage: the sum of the enabled inks' tints. + pub total: f32, +} + +/// One ink's coverage over a page, in percent. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct InkStats { + /// The highest tint. + pub max: f32, + /// The mean tint over every pixel of the page. + pub mean: f32, + /// The share of the page the ink prints on (any tint above 0). + pub area: f32, +} + +/// A page's coverage figures, in percent. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Stats { + /// Each ink's figures, in [`InkSet::iter`](crate::InkSet::iter) order, enabled or not. + pub inks: Vec, + /// The highest TAC of the enabled inks. + pub max_total: f32, + /// The first pixel (in row order) with [`max_total`](Self::max_total); `None` for an empty + /// page. + pub max_total_at: Option<(u32, u32)>, + /// The mean TAC of the enabled inks. + pub mean_total: f32, +} + +/// The pixels whose TAC is over a limit. +#[derive(Clone, Debug, PartialEq)] +pub struct Above { + pub width: u32, + pub height: u32, + /// One entry per pixel, row-major: `true` where the TAC is over the limit. + pub mask: Vec, + /// The number of pixels over the limit. + pub count: usize, +} + +/// A connected group of pixels over the limit (4-connected), with its bounding box in pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Region { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + /// The pixels over the limit in the region. + pub pixels: u32, +} + +/// `len` copies of `value`; `None` when the memory can't be had. +pub(crate) fn filled(len: usize, value: T) -> Option> { + let mut v = Vec::new(); + v.try_reserve_exact(len).ok()?; + v.resize(len, value); + Some(v) +} + +fn on(enabled: &[bool], index: usize) -> bool { + enabled.get(index).copied().unwrap_or(true) +} + +fn percent(tint: u64) -> f32 { + (tint as f64 * 100.0 / 255.0) as f32 +} + +impl Plates { + /// `width × height`; `None` when it overflows `usize`, as it can for hand-made plates. + pub(crate) fn pixels(&self) -> Option { + usize::try_from(self.width).ok()?.checked_mul(usize::try_from(self.height).ok()?) + } + + pub(crate) fn index_of(&self, x: u32, y: u32) -> Option { + if x >= self.width || y >= self.height { + return None; + } + usize::try_from(y).ok()?.checked_mul(usize::try_from(self.width).ok()?)?.checked_add(usize::try_from(x).ok()?) + } + + fn enabled_planes<'a>(&'a self, enabled: &'a [bool]) -> impl Iterator> + 'a { + self.planes.iter().enumerate().filter(|(i, _)| on(enabled, *i)).map(|(_, p)| p) + } + + /// The pixel under a point given as fractions `0.0..1.0` of the page's width and height, + /// as a pointer over a preview gives it. + pub fn pixel_at(&self, u: f32, v: f32) -> Option<(u32, u32)> { + if !(0.0..1.0).contains(&u) || !(0.0..1.0).contains(&v) { + return None; + } + let x = ((u * self.width as f32) as u32).min(self.width.checked_sub(1)?); + let y = ((v * self.height as f32) as u32).min(self.height.checked_sub(1)?); + Some((x, y)) + } + + /// Each ink's tint and the TAC of the enabled inks at pixel (`x`, `y`). + pub fn coverage_at(&self, x: u32, y: u32, enabled: &[bool]) -> Option { + let at = self.index_of(x, y)?; + let tints: Vec = self.planes.iter().map(|p| p.get(at).copied().map_or(0, u64::from)).collect(); + let total = tints.iter().enumerate().filter(|(i, _)| on(enabled, *i)).map(|(_, t)| t).sum(); + Some(Coverage { inks: tints.into_iter().map(percent).collect(), total: percent(total) }) + } + + /// Ink `id`'s tint at pixel (`x`, `y`) in percent. + pub fn percent_at(&self, id: InkId, x: u32, y: u32) -> Option { + self.tint(id, x, y).map(|t| percent(u64::from(t))) + } + + /// The TAC of the enabled inks at every pixel, row-major, as a sum of tint bytes (255 per + /// ink at 100%). Empty when `width × height` overflows or can't be allocated. + fn totals(&self, enabled: &[bool]) -> Vec { + let Some(mut totals) = self.pixels().and_then(|n| filled(n, 0_u32)) else { return Vec::new() }; + for plane in self.enabled_planes(enabled) { + for (sum, tint) in totals.iter_mut().zip(plane) { + *sum = sum.saturating_add(u32::from(*tint)); + } + } + totals + } + + /// The TAC of the enabled inks at every pixel, row-major, in percent. + pub fn tac(&self, enabled: &[bool]) -> Vec { + self.totals(enabled).into_iter().map(|t| percent(u64::from(t))).collect() + } + + /// Each ink's figures over the page, and the TAC of the enabled inks. + pub fn stats(&self, enabled: &[bool]) -> Stats { + let n = self.pixels().unwrap_or(0); + let mean = |sum: u64| if n == 0 { 0.0 } else { percent(sum) / n as f32 }; + let inks = self + .planes + .iter() + .map(|plane| { + let plane = plane.get(..n).unwrap_or(plane); + let max = plane.iter().copied().max().unwrap_or(0); + let sum: u64 = plane.iter().map(|t| u64::from(*t)).sum(); + let inked = plane.iter().filter(|t| **t > 0).count(); + let area = if n == 0 { 0.0 } else { inked as f32 * 100.0 / n as f32 }; + InkStats { max: percent(u64::from(max)), mean: mean(sum), area } + }) + .collect(); + let totals = self.totals(enabled); + // The first pixel with the highest total: `max_by_key` keeps the last of equals. + let top = totals.iter().enumerate().rev().max_by_key(|(_, t)| **t); + let width = self.width as usize; + let max_total_at = top.and_then(|(at, _)| Some((u32::try_from(at % width).ok()?, u32::try_from(at / width).ok()?))); + Stats { + inks, + max_total: top.map_or(0.0, |(_, t)| percent(u64::from(*t))), + max_total_at, + mean_total: mean(totals.iter().map(|t| u64::from(*t)).sum()), + } + } + + /// The pixels whose TAC of the enabled inks is over `limit` percent (such as 300 for + /// coated paper). A limit that isn't finite marks nothing. + pub fn above(&self, limit: f32, enabled: &[bool]) -> Above { + let limit = if limit.is_finite() { limit.max(0.0) } else { f32::MAX }; + let mask: Vec = self.totals(enabled).into_iter().map(|t| percent(u64::from(t)) > limit).collect(); + let count = mask.iter().filter(|m| **m).count(); + Above { width: self.width, height: self.height, mask, count } + } +} + +impl Above { + /// The connected regions of marked pixels, largest first (ties in row order of their first + /// pixel), at most [`MAX_REGIONS`] of them. + pub fn regions(&self) -> Vec { + let (w, h) = (self.width as usize, self.height as usize); + if self.mask.len() != w.saturating_mul(h) { + return Vec::new(); + } + let mut seen = vec![false; self.mask.len()]; + let mut regions = Vec::new(); + let mut stack = Vec::new(); + for start in 0..self.mask.len() { + if !self.mask.get(start).copied().unwrap_or(false) || seen.get(start).copied().unwrap_or(true) { + continue; + } + let (mut x0, mut y0, mut x1, mut y1, mut pixels) = (start % w, start / w, start % w, start / w, 0_u32); + if let Some(s) = seen.get_mut(start) { + *s = true; + } + stack.push(start); + while let Some(at) = stack.pop() { + let (x, y) = (at % w, at / w); + pixels = pixels.saturating_add(1); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + let next = [(x > 0).then(|| at - 1), (x + 1 < w).then(|| at + 1), (y > 0).then(|| at - w), (y + 1 < h).then(|| at + w)]; + for n in next.into_iter().flatten() { + if self.mask.get(n).copied().unwrap_or(false) + && let Some(s) = seen.get_mut(n).filter(|s| !**s) + { + *s = true; + stack.push(n); + } + } + } + // The mask is at most `width × height` with both `u32`, so coordinates fit. + regions.push(Region { x: x0 as u32, y: y0 as u32, width: (x1 - x0 + 1) as u32, height: (y1 - y0 + 1) as u32, pixels }); + } + regions.sort_by_key(|r| std::cmp::Reverse(r.pixels)); + regions.truncate(MAX_REGIONS); + regions + } + + /// Paints the marked pixels of an sRGB8 image of the same size (three bytes per pixel, + /// such as [`Plates::composite`]'s) in `color`, as a TAC warning. + pub fn paint(&self, rgb: &mut [u8], color: [u8; 3]) { + for (px, _) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(&self.mask).filter(|(_, m)| **m) { + *px = color; + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{Alternate, Conversion, Ink, InkSet, Spot}; + + /// Plates of `width × height` pixels for the process inks and a Gold spot (Lab alternate + /// `gold`), from planes given in `C M Y K Gold` order. + pub(crate) fn synthetic(width: u32, height: u32, gold: [f32; 3], planes: [Vec; 5]) -> Plates { + let mut inks = InkSet::new(); + inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Lab(gold), solidity: None })); + Plates { inks, unseparated: Vec::new(), width, height, dpi: 72.0, conversion: Conversion::DeviceFormulas, planes: planes.to_vec() } + } + + fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 + } + + #[test] + fn coverage_tac_and_regions_over_a_limit() { + // 3 × 2 pixels: rich black with Gold at (0,0), (1,0) (Gold at 50%) and (2,1); black + // alone at (2,0). + let p = synthetic( + 3, + 2, + [70.0, 5.0, 60.0], + [vec![0; 6], vec![255, 255, 0, 0, 0, 255], vec![255, 255, 0, 0, 0, 255], vec![255, 255, 255, 0, 0, 255], vec![255, 128, 0, 0, 0, 255]], + ); + let at = p.coverage_at(1, 0, &[]).unwrap(); + assert!(close(at.inks[4], 50.196) && close(at.total, 350.196), "{at:?}"); + assert!(close(p.coverage_at(1, 0, &[true, true, true, true, false]).unwrap().total, 300.0), "Gold hidden"); + assert_eq!(p.coverage_at(3, 0, &[]), None); + assert_eq!(p.pixel_at(0.99, 0.6), Some((2, 1))); + assert_eq!(p.pixel_at(1.0, 0.0), None); + assert!(close(p.tac(&[])[2], 100.0)); + + let s = p.stats(&[]); + assert!(close(s.max_total, 400.0) && s.max_total_at == Some((0, 0)), "{s:?}"); + assert!(close(s.mean_total, 3188.0 / 1530.0 * 100.0), "{s:?}"); + let k = s.inks[3]; + assert!(close(k.max, 100.0) && close(k.mean, 66.667) && close(k.area, 66.667), "{k:?}"); + let gold = s.inks[4]; + assert!(close(gold.mean, 638.0 / 1530.0 * 100.0) && close(gold.area, 50.0), "{gold:?}"); + assert_eq!(s.inks[0], InkStats::default()); + + let over = p.above(300.0, &[]); + assert_eq!(over.count, 3); + assert_eq!(over.mask, [true, true, false, false, false, true]); + let regions = over.regions(); + assert_eq!(regions, [Region { x: 0, y: 0, width: 2, height: 1, pixels: 2 }, Region { x: 2, y: 1, width: 1, height: 1, pixels: 1 }]); + assert_eq!(p.above(300.0, &[true, true, true, true, false]).count, 0, "exactly at the limit is not over it"); + assert_eq!(p.above(f32::NAN, &[]).count, 0); + + let mut rgb = vec![0_u8; 18]; + over.paint(&mut rgb, [255, 0, 255]); + assert_eq!(&rgb[..6], [255, 0, 255, 255, 0, 255]); + assert_eq!(&rgb[6..9], [0, 0, 0]); + } + + #[test] + fn regions_join_four_connected_pixels_and_keep_the_largest() { + // A ring around an unmarked center is one region; the diagonal neighbor is another. + #[rustfmt::skip] + let mask = vec![ + true, true, true, false, + true, false, true, false, + true, true, true, false, + false, false, false, true, + ]; + let above = Above { width: 4, height: 4, count: 9, mask }; + assert_eq!(above.regions(), [Region { x: 0, y: 0, width: 3, height: 3, pixels: 8 }, Region { x: 3, y: 3, width: 1, height: 1, pixels: 1 }]); + let empty = Plates { width: 0, height: 0, planes: Vec::new(), ..synthetic(0, 0, [0.0; 3], Default::default()) }; + assert_eq!(empty.stats(&[]).max_total_at, None); + assert!(empty.above(10.0, &[]).regions().is_empty()); + } +} 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 new file mode 100644 index 00000000..2dc3b472 --- /dev/null +++ b/crates/prepress/src/ink.rs @@ -0,0 +1,589 @@ +//! The ink model: which inks a job prints with, and colors as tints of those inks. + +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; + +use craft_cms::math; +use craft_pdf::interpret::color::CmykConversion; +use craft_pdf::write::backend::color::device_n::{self, Colorant, DeviceNSpace}; +use craft_pdf::write::backend::color::separation::{self, SeparationColorant, SeparationSpace}; +use craft_pdf::write::backend::color::{Color as PdfColor, RegularColor, cmyk}; + +use crate::cms::lab_to_srgb8; + +/// A process ink, in plate order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Process { + Cyan, + Magenta, + Yellow, + Black, +} + +impl Process { + /// The four process inks, in plate order. + pub const ALL: [Process; 4] = [Process::Cyan, Process::Magenta, Process::Yellow, Process::Black]; + + /// The colorant name a `Separation` or `DeviceN` color space gives this ink. + pub fn name(self) -> &'static str { + match self { + Self::Cyan => "Cyan", + Self::Magenta => "Magenta", + Self::Yellow => "Yellow", + Self::Black => "Black", + } + } + + /// The process ink a colorant name denotes (ISO 32000-2 §8.6.6.4). + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|p| p.name() == name) + } + + /// The ink's position in a CMYK color. + pub fn index(self) -> usize { + self as usize + } +} + +/// How a spot ink looks, for previews and for printing it as process color. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Alternate { + /// CMYK components, `0.0..=1.0`. + Cmyk([f32; 4]), + /// CIE L\*a\*b\* relative to D50. + Lab([f32; 3]), +} + +/// A spot ink. +#[derive(Clone, Debug, PartialEq)] +pub struct Spot { + /// The colorant name, as a `Separation` color space would name it, without PDF name + /// escapes (see [`normalize_name`]). + pub name: String, + /// The ink at 100%. + pub alternate: Alternate, + /// How opaque the ink is (`0.0..=1.0`, as InDesign's Ink Manager states it), when known. + pub solidity: Option, +} + +/// A process or spot ink. +#[derive(Clone, Debug, PartialEq)] +pub enum Ink { + Process(Process), + Spot(Spot), +} + +impl Ink { + /// The colorant name: `Cyan` … `Black`, or the spot ink's name. + pub fn name(&self) -> &str { + match self { + Self::Process(p) => p.name(), + Self::Spot(s) => &s.name, + } + } + + pub fn is_spot(&self) -> bool { + matches!(self, Self::Spot(_)) + } +} + +/// An ink's index in its [`InkSet`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct InkId(u16); + +impl InkId { + /// The ink's position in [`InkSet::iter`] order, which is also its plane in + /// [`Plates::planes`](crate::Plates::planes). + pub fn index(self) -> usize { + usize::from(self.0) + } + + /// The id of process ink `p`, the same in every [`InkSet`]. + pub const fn process(p: Process) -> Self { + Self(p as u16) + } +} + +/// The inks of one document or job: the four process inks, always first and in plate order, +/// then spot inks in the order added. Ids stay valid as inks are added. +/// +/// The set is also the job's ink manager, as InDesign's Ink Manager states it: a spot ink can +/// print on another ink's plate ([`alias`](Self::alias)) or as process color +/// ([`set_to_process`](Self::set_to_process), [`set_all_to_process`](Self::set_all_to_process)). +/// [`resolve`](Self::resolve) applies both to a color. +#[derive(Clone, Debug, PartialEq)] +pub struct InkSet { + inks: Vec, + aliases: BTreeMap, + to_process: BTreeSet, + all_to_process: bool, +} + +impl Default for InkSet { + fn default() -> Self { + Self::new() + } +} + +impl InkSet { + /// The four process inks. + pub fn new() -> Self { + Self { + inks: Process::ALL.into_iter().map(Ink::Process).collect(), + aliases: BTreeMap::new(), + to_process: BTreeSet::new(), + all_to_process: false, + } + } + + /// Adds `ink` and returns its id. An ink whose name is already in the set (compared after + /// [`normalize_name`]) is not added again, and its existing id is returned; a spot named + /// `Cyan` … `Black` is the process ink. A spot's name is stored normalized. `None` for the + /// names `All` and `None`, which are not inks (§8.6.6.4), and when the set is full + /// (65 536 inks). + pub fn add(&mut self, ink: Ink) -> Option { + let spot = match ink { + Ink::Process(p) => return Some(InkId::process(p)), + Ink::Spot(spot) => spot, + }; + let name = normalize_name(&spot.name).into_owned(); + self.add_decoded(Spot { name, ..spot }) + } + + /// [`add`](Self::add) for a spot whose name is already decoded, as a PDF reader hands it + /// over: `#` in the name is a literal character. + pub(crate) fn add_decoded(&mut self, spot: Spot) -> Option { + if spot.name == "All" || spot.name == "None" { + return None; + } + if let Some(id) = self.lookup_decoded(&spot.name) { + return Some(id); + } + let id = InkId(u16::try_from(self.inks.len()).ok()?); + self.inks.push(Ink::Spot(spot)); + Some(id) + } + + /// The ink named `name`, compared after [`normalize_name`]: `"/Gold#20Foil"` finds + /// `Gold Foil`. + pub fn lookup(&self, name: &str) -> Option { + self.lookup_decoded(&normalize_name(name)) + } + + fn lookup_decoded(&self, name: &str) -> Option { + if let Some(p) = Process::from_name(name) { + return Some(InkId::process(p)); + } + let at = self.inks.iter().skip(4).position(|ink| ink.name() == name)?; + u16::try_from(at + 4).ok().map(InkId) + } + + pub fn get(&self, id: InkId) -> Option<&Ink> { + self.inks.get(id.index()) + } + + /// Every ink with its id: the process inks, then the spot inks. + pub fn iter(&self) -> impl Iterator { + // At most 65 536 inks (`add`), so every index fits. + self.inks.iter().enumerate().map(|(i, ink)| (InkId(i as u16), ink)) + } + + /// The spot inks with their ids. + pub fn spots(&self) -> impl Iterator { + self.iter().filter_map(|(id, ink)| match ink { + Ink::Spot(s) => Some((id, s)), + Ink::Process(_) => None, + }) + } + + /// The number of inks, process inks included. + pub fn len(&self) -> usize { + self.inks.len() + } + + /// Always `false`: a set holds the process inks. + pub fn is_empty(&self) -> bool { + self.inks.is_empty() + } + + fn spot(&self, id: InkId) -> Option<&Spot> { + match self.get(id)? { + Ink::Spot(s) => Some(s), + Ink::Process(_) => None, + } + } + + /// Prints spot ink `from` on ink `to`'s plate. `to` may be a process ink or another spot, + /// which may itself be aliased or printed as process color; `resolve` follows the chain. + /// `to == from` removes the alias. Returns `false`, changing nothing, when `from` is not a + /// spot ink of the set, `to` is not in the set, or the alias would make a loop. + pub fn alias(&mut self, from: InkId, to: InkId) -> bool { + if self.spot(from).is_none() || self.get(to).is_none() { + return false; + } + if from == to { + self.aliases.remove(&from); + return true; + } + if self.plate_chain(to).any(|id| id == from) { + return false; + } + self.aliases.insert(from, to); + true + } + + /// The ink spot ink `from` is aliased to, if any. + pub fn alias_of(&self, from: InkId) -> Option { + self.aliases.get(&from).copied() + } + + /// Prints spot ink `ink` as process color (`on`), converted from its alternate, or on its + /// own plate. Returns `false`, changing nothing, when `ink` is not a spot ink of the set. + pub fn set_to_process(&mut self, ink: InkId, on: bool) -> bool { + if self.spot(ink).is_none() { + return false; + } + if on { + self.to_process.insert(ink); + } else { + self.to_process.remove(&ink); + } + true + } + + /// Prints every spot ink as process color ("All Spots to Process"), whatever + /// [`set_to_process`](Self::set_to_process) says for each. Aliases still apply first: a + /// spot aliased to a process ink prints on that ink's plate. + pub fn set_all_to_process(&mut self, on: bool) { + self.all_to_process = on; + } + + pub fn all_to_process(&self) -> bool { + self.all_to_process + } + + /// Whether spot ink `ink` prints as process color when a color names it directly (an + /// alias of it is followed first, see [`plate_of`](Self::plate_of)). + pub fn is_to_process(&self, ink: InkId) -> bool { + self.spot(ink).is_some() && (self.all_to_process || self.to_process.contains(&ink)) + } + + /// `id`, then each ink it is aliased to in turn. Stops after as many steps as there are + /// inks, so a loop can't make it endless. + fn plate_chain(&self, id: InkId) -> impl Iterator + '_ { + std::iter::successors(Some(id), |id| self.alias_of(*id)).take(self.inks.len()) + } + + /// The end of `id`'s alias chain: `id` itself when it has no alias. It prints on its own + /// plate unless [`is_to_process`](Self::is_to_process) says it converts. `None` when `id` + /// is not in the set. + pub fn alias_end(&self, id: InkId) -> Option { + self.get(id)?; + self.plate_chain(id).last() + } + + /// The ink whose plate a tint of `id` prints on after aliases: `id` itself, the end of its + /// alias chain, or `None` when that ink prints as process color (or `id` is not in the + /// set). + pub fn plate_of(&self, id: InkId) -> Option { + let end = self.alias_end(id)?; + (!self.is_to_process(end)).then_some(end) + } + + /// Spot ink `id` at 100% as process color, `0.0..=1.0` per component: its CMYK alternate + /// as it is, a Lab alternate through `cms`, or, when `cms` can't convert, through sRGB and + /// the ISO 32000-2 §10.4.2.4 formulas (full black generation and undercolor removal). + /// `None` for process inks, inks not in the set, and Lab colors craft-cms can't convert. + pub fn spot_cmyk(&self, id: InkId, cms: &dyn CmykConversion) -> Option<[f32; 4]> { + let lab = match self.spot(id)?.alternate { + Alternate::Cmyk(cmyk) => return Some(cmyk.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })), + Alternate::Lab(lab) => lab, + }; + let mut cmyk = [0.0_f32; 4]; + if cms.from_pcs_xyz(&math::lab_to_xyz_f32(lab), &mut cmyk).is_some() { + return Some(cmyk); + } + let mut rgb = [0_u8; 3]; + lab_to_srgb8(&lab, &mut rgb)?; + let [c, m, y] = rgb.map(|v| 1.0 - f32::from(v) / 255.0); + let k = c.min(m).min(y); + Some([c - k, m - k, y - k, k]) + } + + /// `color` as it prints with this set's aliases and to-process settings: each tint moves + /// to the plate [`plate_of`](Self::plate_of) names, and a tint of an ink printed as process + /// color becomes that tint of its [`spot_cmyk`](Self::spot_cmyk). Tints that land on one + /// plate combine as overlapping halftones, `1 − (1 − a)(1 − b)`. When any tint became + /// process color the result lists all four process inks first (a CMYK color if no spot + /// is left); otherwise inks keep their order. Inks not in the set, and spots whose Lab + /// alternate can't be converted, stay as they are. `All` and `None` are unchanged. + pub fn resolve(&self, color: &InkColor, cms: &dyn CmykConversion) -> InkColor { + let InkColor::Tints(tints) = color else { return color.clone() }; + let mut out: Vec<(InkId, f32)> = Vec::with_capacity(tints.len()); + let mut process: Option<[f32; 4]> = None; + let combine = |a: f32, b: f32| 1.0 - (1.0 - a) * (1.0 - b); + for &(id, tint) in tints { + let end = match self.get(id) { + Some(_) => self.plate_chain(id).last().unwrap_or(id), + None => id, + }; + if self.is_to_process(end) + && let Some(cmyk) = self.spot_cmyk(end, cms) + { + let acc = process.get_or_insert([0.0; 4]); + for (a, c) in acc.iter_mut().zip(cmyk) { + *a = combine(*a, c * tint); + } + continue; + } + match out.iter_mut().find(|(i, _)| *i == end) { + Some((_, t)) => *t = combine(*t, tint), + None => out.push((end, tint)), + } + } + let Some(mut cmyk) = process else { return InkColor::Tints(out) }; + out.retain(|(id, tint)| match Process::ALL.get(id.index()) { + Some(p) => { + if let Some(c) = cmyk.get_mut(p.index()) { + *c = combine(*c, *tint); + } + false + } + None => true, + }); + let mut all: Vec<(InkId, f32)> = Process::ALL.iter().zip(cmyk).map(|(p, c)| (InkId::process(*p), c)).collect(); + all.extend(out); + InkColor::Tints(all) + } +} + +/// A color as ink tints. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum InkColor { + /// Tints `0.0..=1.0` of the listed inks; other inks are unspecified (they keep what is + /// under the paint when it overprints, and are knocked out otherwise). One entry is a + /// `Separation` color, several a `DeviceN` one. + Tints(Vec<(InkId, f32)>), + /// The same tint on every plate: the registration color (the `All` colorant). + All(f32), + /// No ink on any plate (the `None` colorant). + None, +} + +impl InkColor { + /// The color as DeviceCMYK components when every tint is of a process ink, as + /// [`InkSet::resolve`] leaves a spot printed as process color; process inks not listed + /// are 0. `None` when a spot ink is listed, and for `All` and `None`. + pub fn as_cmyk(&self) -> Option<[f32; 4]> { + let Self::Tints(tints) = self else { return None }; + let mut cmyk = [0.0; 4]; + for (id, tint) in tints { + *cmyk.get_mut(id.index())? = *tint; + } + Some(cmyk) + } +} + +/// `color` as a krilla fill or stroke color, for the PDF writer. Pass the color as it prints, +/// after [`InkSet::resolve`]. +/// +/// - Tints of process inks only: DeviceCMYK, unlisted inks 0 (so with `OPM 1` an overprinting +/// paint leaves their plates as they are). +/// - One spot ink alone: a `Separation` space with the spot's alternate as DeviceCMYK fallback. +/// - Any other mix: a `DeviceN` space, process inks first in plate order, then spot inks in +/// set order. Each spot keeps the alternate its `Separation` space has. +/// - `All`: the registration color, a `Separation /All` space; `None`: a `Separation /None` +/// space, which marks no plate. +/// +/// Spot alternates are [`InkSet::spot_cmyk`]: a CMYK alternate as given, a Lab one through +/// `cms` into the printing condition. A tint listed twice combines as overlapping halftones, +/// as in `resolve`. `None` when `Tints` lists no ink (there is nothing to paint; use +/// [`InkColor::None`] to mark no plate), a tint's ink is not in `inks`, a spot's Lab alternate +/// can't be converted, a spot name is empty, or a `DeviceN` space would have more than 32 +/// colorants. +pub fn to_pdf_color(color: &InkColor, inks: &InkSet, cms: &dyn CmykConversion) -> Option { + let tints = match color { + InkColor::All(tint) => return Some(separation(SeparationColorant::AllColorants, *tint, cmyk::Color::new(255, 255, 255, 255))), + InkColor::None => return Some(separation(SeparationColorant::NoColorant, 0.0, cmyk::Color::new(0, 0, 0, 0))), + InkColor::Tints(tints) if tints.is_empty() => return None, + InkColor::Tints(tints) => tints, + }; + // Sorted by id: process inks first, in plate order, then spots in set order. + let mut merged: BTreeMap = BTreeMap::new(); + for &(id, tint) in tints { + inks.get(id)?; + let t = merged.entry(id).or_insert(0.0); + *t = 1.0 - (1.0 - *t) * (1.0 - unit(tint)); + } + if merged.keys().all(|id| id.index() < Process::ALL.len()) { + let mut cmyk = [0_u8; 4]; + for (id, tint) in &merged { + if let Some(c) = cmyk.get_mut(id.index()) { + *c = byte(*tint); + } + } + let [c, m, y, k] = cmyk; + return Some(RegularColor::Cmyk(cmyk::Color::new(c, m, y, k)).into()); + } + let spot = |id: InkId| -> Option<(String, cmyk::Color)> { + let name = inks.get(id)?.name(); + let [c, m, y, k] = inks.spot_cmyk(id, cms)?.map(byte); + (!name.is_empty()).then(|| (name.to_owned(), cmyk::Color::new(c, m, y, k))) + }; + if let [(&id, &tint)] = merged.iter().collect::>()[..] { + let (name, alternate) = spot(id)?; + return Some(separation(SeparationColorant::Custom(name), tint, alternate)); + } + let mut colorants = Vec::with_capacity(merged.len()); + for &id in merged.keys() { + colorants.push(match Process::ALL.get(id.index()) { + Some(p) => 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, + }), + None => { + let (name, alternate) = spot(id)?; + Colorant::Spot { name, alternate } + } + }); + } + let space = DeviceNSpace::new(colorants)?; + Some(device_n::Color::new(merged.values().map(|t| byte(*t)).collect(), space)?.into()) +} + +fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +fn byte(v: f32) -> u8 { + (unit(v) * 255.0).round() as u8 +} + +fn separation(colorant: SeparationColorant, tint: f32, fallback: cmyk::Color) -> PdfColor { + separation::Color::new(byte(tint), SeparationSpace::new(colorant, RegularColor::Cmyk(fallback))).into() +} + +/// Overprint parameters of a paint (ISO 32000-2 §8.6.7). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Overprint { + /// Fills and other non-stroking paints overprint (`op`). + pub fill: bool, + /// Strokes overprint (`OP`). + pub stroke: bool, + /// Nonzero overprint mode (`OPM 1`): a zero DeviceCMYK component leaves its plate as it is. + pub nonzero: bool, +} + +/// A colorant name as a PDF name object carries it: without a leading `/` and with `#xx` +/// escapes decoded (ISO 32000-2 §7.3.5), read as UTF-8 (invalid sequences replaced). +pub fn normalize_name(name: &str) -> Cow<'_, str> { + let name = name.strip_prefix('/').unwrap_or(name); + if !name.contains('#') { + return Cow::Borrowed(name); + } + let bytes = name.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match (b, hex) { + (b'#', Some(decoded)) => { + out.push(decoded); + i += 3; + } + _ => { + out.push(b); + i += 1; + } + } + } + Cow::Owned(String::from_utf8_lossy(&out).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spot(name: &str) -> Ink { + Ink::Spot(Spot { name: name.into(), alternate: Alternate::Lab([80.0, 5.0, 60.0]), solidity: None }) + } + + #[test] + fn spots_follow_the_process_inks_and_names_are_compared_as_pdf_names() { + let mut set = InkSet::new(); + let gold = set.add(spot("/Gold#20Foil")).unwrap(); + assert_eq!(gold.index(), 4); + assert_eq!(set.get(gold).unwrap().name(), "Gold Foil"); + assert_eq!(set.add(spot("Gold Foil")), Some(gold), "added once"); + assert_eq!(set.lookup("Gold#20Foil"), Some(gold)); + assert_eq!(set.add(spot("Black")), Some(InkId::process(Process::Black))); + assert_eq!(set.lookup("/Cyan"), Some(InkId::process(Process::Cyan))); + assert_eq!(set.add(spot("All")), None); + assert_eq!(set.add(spot("None")), None); + assert_eq!(set.lookup("gold foil"), None, "names are case-sensitive"); + let names: Vec<&str> = set.iter().map(|(_, ink)| ink.name()).collect(); + assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold Foil"]); + assert_eq!(set.spots().count(), 1); + } + + fn close(got: Option<[f32; 4]>, expected: [f32; 4]) -> bool { + got.is_some_and(|got| got.iter().zip(expected).all(|(g, e)| (g - e).abs() < 1e-4)) + } + + #[test] + fn aliases_and_spots_to_process_resolve_colors() { + let mut set = InkSet::new(); + let gold = set.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None })).unwrap(); + let yellow = set.add(spot("Lab Yellow")).unwrap(); + let varnish = set.add(spot("Varnish")).unwrap(); + let k = InkId::process(Process::Black); + let cms = crate::CmsConversion::fogra39(); + let tints = |t: &[(InkId, f32)]| InkColor::Tints(t.to_vec()); + + let plain = tints(&[(gold, 0.5), (k, 1.0)]); + assert_eq!(set.resolve(&plain, &cms), plain, "no settings: unchanged"); + assert_eq!(set.resolve(&InkColor::All(1.0), &cms), InkColor::All(1.0)); + + assert!(set.alias(varnish, gold)); + assert_eq!((set.alias_of(varnish), set.plate_of(varnish)), (Some(gold), Some(gold))); + assert_eq!(set.resolve(&tints(&[(varnish, 0.5)]), &cms), tints(&[(gold, 0.5)])); + assert_eq!(set.resolve(&tints(&[(gold, 0.5), (varnish, 0.5)]), &cms), tints(&[(gold, 0.75)]), "one plate: tints overlap"); + assert!(!set.alias(gold, varnish), "a loop is refused"); + assert!(!set.alias(k, gold), "process inks aren't aliased"); + assert!(!set.alias(gold, InkId(99))); + + assert!(set.set_to_process(gold, true)); + assert!(!set.set_to_process(k, true)); + assert_eq!(set.plate_of(varnish), None, "aliased to a spot printed as process"); + assert_eq!(set.alias_end(varnish), Some(gold), "the converted ink is still named"); + assert!(set.is_to_process(gold)); + assert_eq!(set.alias_end(InkId(99)), None); + assert!(close(set.resolve(&tints(&[(varnish, 0.5)]), &cms).as_cmyk(), [0.0, 0.15, 0.5, 0.0])); + let rich = set.resolve(&tints(&[(k, 0.5), (gold, 1.0)]), &cms); + assert!(close(rich.as_cmyk(), [0.0, 0.3, 1.0, 0.5]), "{rich:?}"); + let InkColor::Tints(mixed) = set.resolve(&tints(&[(yellow, 0.4), (gold, 1.0)]), &cms) else { panic!() }; + let order: Vec = mixed.iter().map(|(id, _)| *id).collect(); + assert_eq!(order[..4], Process::ALL.map(InkId::process), "process inks first"); + assert_eq!(mixed[4], (yellow, 0.4), "the spot left on its plate"); + + set.set_all_to_process(true); + assert!(set.is_to_process(yellow) && !set.is_to_process(k)); + let lab = set.resolve(&tints(&[(yellow, 1.0)]), &cms).as_cmyk().unwrap(); + assert!(lab[2] > 0.4 && lab[2] > lab[0] && lab[3] < 0.2, "a light yellow through FOGRA39: {lab:?}"); + assert!(set.alias(yellow, k)); + assert_eq!(set.resolve(&tints(&[(yellow, 0.5)]), &cms), tints(&[(k, 0.5)]), "aliases apply first"); + + assert!(set.alias(varnish, varnish)); + assert_eq!(set.alias_of(varnish), None); + } + + #[test] + fn malformed_escapes_stay_as_written() { + assert_eq!(normalize_name("A#2"), "A#2"); + assert_eq!(normalize_name("A#zz#41"), "A#zzA"); + assert_eq!(normalize_name("#E2#82#AC"), "€"); + } +} diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs new file mode 100644 index 00000000..4100696b --- /dev/null +++ b/crates/prepress/src/lib.rs @@ -0,0 +1,18 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Prepress for the Craft apps: an ink model and ink manager shared by the writers, +//! plates rendered from a PDF, coverage and a color preview read from the plates, and process +//! color conversion through craft-cms. See the README. + +pub mod cms; +pub mod composite; +pub mod coverage; +#[cfg(feature = "flatten")] +pub mod flatten; +pub mod ink; +pub mod plates; + +pub use cms::{CmsConversion, ProfileError}; +pub use coverage::{Above, Coverage, InkStats, MAX_REGIONS, Region, Stats}; +pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name, to_pdf_color}; +pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; diff --git a/crates/prepress/src/plates.rs b/crates/prepress/src/plates.rs new file mode 100644 index 00000000..332c8b6b --- /dev/null +++ b/crates/prepress/src/plates.rs @@ -0,0 +1,354 @@ +//! A PDF page separated into plates, one gray film per ink. +//! +//! A plate is the page rendered with hayro-interpret's plate mode +//! (`InterpreterSettings::plate`): every paint draws its tint of one ink as gray, and a paint +//! that overprints without naming the ink leaves the film as it is (ISO 32000-2 §8.6.7). +//! Clipping, anti-aliasing, soft masks, transparency groups and blending stay the renderer's +//! own. `Separation` and `DeviceN` colorants paint their own plates, DeviceCMYK its four +//! components, DeviceGray black only, and other process color converts to CMYK through the +//! printing condition (see [`Conversion`]). +//! +//! Ported from PdfCraft #2770 by Guillem Hermida (`crates/render/src/separations.rs` at +//! 6fcf2a4, MIT OR Apache-2.0), with craft-cms in place of moxcms. + +use std::collections::HashSet; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::{Arc, Mutex}; + +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::interpret::color::{CmykSimulation, Ink as PdfInk, InkSinkFn, Plate}; +use craft_pdf::raster::{self, MAX_SIDE, RenderCache}; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::dict::keys::{DEST_OUTPUT_PROFILE, OUTPUT_CONDITION, OUTPUT_CONDITION_IDENTIFIER, OUTPUT_INTENTS, S}; +use craft_pdf::syntax::object::{Array, Dict, Name, Stream, String as PdfText}; +use craft_pdf::{OpenError, open_slice}; + +use crate::cms::{CmsConversion, ProfileError, srgb8_to_lab}; +use crate::ink::{Alternate, InkId, InkSet, Spot}; + +/// Spot inks separated per page, after the four process inks. More than any press runs: a +/// `DeviceN` space has at most 32 colorants, and every plate costs a render and a byte per +/// pixel. Further spot inks are listed in [`Plates::unseparated`]. +pub const MAX_SPOT_INKS: usize = 32; + +/// Device pixels per plate (4 MP, 2000 × 2000), so a page with many inks stays within a few +/// hundred megabytes. The resolution is reduced to fit; [`Plates::dpi`] says what was used. +pub const MAX_PLATE_PIXELS: f32 = 4.0e6; + +/// The resolution, in pixels per point, inks are discovered at before the plates render. +const DISCOVERY_SCALE: f32 = 0.1; + +/// Spot ink names recorded while discovering, past which further names are dropped. +const MAX_LISTED_SPOTS: usize = MAX_SPOT_INKS * 4; + +/// Output intents looked at per page: each can cost a profile's decoding and parsing. +const MAX_OUTPUT_INTENTS: usize = 16; + +/// Decoding limit for an output intent's profile. +const MAX_PROFILE_BYTES: usize = 16 << 20; + +/// The longest output condition kept, in characters. +const MAX_CONDITION: usize = 160; + +/// The printing condition process color converts to when the page has no usable output +/// intent. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub enum Fallback { + /// craft-cms's Coated FOGRA39 profile. + #[default] + Fogra39, + /// The caller's CMYK ICC profile. + Profile(Arc<[u8]>), + /// The ISO 32000-2 §10.4.2.4 device formulas, black generation and undercolor removal the + /// identity: neutral RGB goes to black alone, ICCBased CMYK separates as it is. + DeviceFormulas, +} + +/// How [`plates`] separates. +#[derive(Clone, Debug)] +pub struct Options { + /// Honor `OP`, `op` and `OPM` as a separating device does. Without it every paint knocks + /// out what is under it. + pub overprint: bool, + /// The printing condition when the page has no output intent with a usable CMYK profile. + pub fallback: Fallback, +} + +impl Default for Options { + fn default() -> Self { + Self { overprint: true, fallback: Fallback::default() } + } +} + +/// How process color that isn't CMYK already became CMYK. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum Conversion { + /// Through the CMYK profile of the page's or document's output intent (ISO 32000-2 + /// §14.11.5). + OutputIntent { + /// The profile's description, else the intent's output condition. + profile: String, + /// The intent's `/OutputConditionIdentifier`, such as `FOGRA39`. + condition: Option, + }, + /// Through [`Fallback::Fogra39`] or [`Fallback::Profile`]; the profile's description. + Fallback { profile: String }, + /// By [`Fallback::DeviceFormulas`]. + DeviceFormulas, +} + +/// A page separated into plates. +#[derive(Clone, Debug)] +pub struct Plates { + /// The process inks, then the spot inks the page paints, in the order first painted. Spots + /// carry a Lab alternate measured from their sRGB appearance at 100%. + pub inks: InkSet, + /// Spot inks past [`MAX_SPOT_INKS`]: listed, not separated. + pub unseparated: Vec, + pub width: u32, + pub height: u32, + /// The resolution the plates rendered at, after [`MAX_PLATE_PIXELS`]. + pub dpi: f32, + pub conversion: Conversion, + /// One plane per ink, in [`InkSet::iter`] order: the tint per pixel, row-major, 0 for no + /// ink and 255 for 100%. + pub planes: Vec>, +} + +impl Plates { + /// Ink `id`'s plane. + pub fn plane(&self, id: InkId) -> Option<&[u8]> { + self.planes.get(id.index()).map(Vec::as_slice) + } + + /// Ink `id`'s tint at pixel (`x`, `y`), 0–255. + pub fn tint(&self, id: InkId, x: u32, y: u32) -> Option { + self.plane(id)?.get(self.index_of(x, y)?).copied() + } +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error(transparent)] + Open(#[from] OpenError), + #[error("page {0} does not exist")] + NoPage(usize), + #[error("the resolution must be positive and finite")] + InvalidDpi, + #[error("the fallback profile can't be a printing condition: {0}")] + Profile(#[from] ProfileError), + /// After a [`raster::Error::BackendPanic`] the document is dropped; nothing else is kept. + #[error(transparent)] + Render(#[from] raster::Error), +} + +/// Separates page `page` (from 0) of `pdf` at `dpi`, reduced to fit [`MAX_PLATE_PIXELS`] and +/// the renderer's side limit: one plate per ink, the four process inks and up to +/// [`MAX_SPOT_INKS`] spot inks the page paints. Spot inks only soft masks use are left out +/// (masks don't print). Never panics. +pub fn plates(pdf: &[u8], page: usize, dpi: f32, opts: &Options) -> Result { + if !dpi.is_finite() || dpi <= 0.0 { + return Err(Error::InvalidDpi); + } + let doc = open_slice(pdf, None)?; + let (simulation, conversion) = match catch_unwind(AssertUnwindSafe(|| output_intent(&doc, page))) { + Ok(Some(found)) => (Some(found.0), found.1), + Ok(None) | Err(_) => fallback(&opts.fallback)?, + }; + let pages = doc.pages(); + let p = pages.get(page).ok_or(Error::NoPage(page))?; + let (w, h) = p.render_dimensions(); + let scale = plate_scale(w, h, dpi / 72.0); + let cache = RenderCache::new(); + + let log = Arc::new(Mutex::new(SpotLog::default())); + let discover = InterpreterSettings { ink_sink: Some(spot_sink(&log)), ..Default::default() }; + render(p, &cache, &discover, DISCOVERY_SCALE.min(scale))?; + let spots = std::mem::take(&mut log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).order); + + let mut inks = InkSet::new(); + let mut separated: Vec = PdfInk::PROCESS.to_vec(); + let mut unseparated = Vec::new(); + for (ink, swatch) in spots { + if separated.len() >= 4 + MAX_SPOT_INKS { + unseparated.push(ink.name().to_string()); + continue; + } + let alternate = Alternate::Lab(srgb8_to_lab(swatch).unwrap_or([0.0; 3])); + let spot = Spot { name: ink.name().to_string(), alternate, solidity: None }; + // hayro hands over names with their `#xx` escapes decoded, so `#` here is a literal + // character. Two names that read the same after lossy UTF-8 decoding share one plate. + match inks.add_decoded(spot) { + Some(id) if id.index() == separated.len() => separated.push(ink), + _ => unseparated.push(ink.name().to_string()), + } + } + + let mut size = None; + let mut planes = Vec::with_capacity(separated.len()); + for ink in separated { + let plate = Plate { ink, overprint: opts.overprint, simulation: simulation.clone(), spots_as_process: false }; + let settings = InterpreterSettings { plate: Some(plate), ..Default::default() }; + let film = render(p, &cache, &settings, scale)?; + size = Some((film.width, film.height)); + // The film is gray on white: its red channel is the paper left uncovered. + planes.push(film.bytes().as_chunks::<4>().0.iter().map(|[red, ..]| 255 - red).collect()); + } + let (width, height) = size.unwrap_or((0, 0)); + Ok(Plates { inks, unseparated, width, height, dpi: scale * 72.0, conversion, planes }) +} + +fn render<'a>( + page: &'a craft_pdf::syntax::page::Page<'a>, + cache: &RenderCache<'a>, + settings: &InterpreterSettings, + scale: f32, +) -> Result { + // The plate's gray film must keep its tints: no color replacement. + let options = raster::Options { scale: [scale, scale], background: [255; 4], color_replacement: None, ..Default::default() }; + Ok(raster::render(page, cache, settings, &options)?) +} + +/// The scale a `w` × `h` pt page separates at: `scale`, within [`MAX_PLATE_PIXELS`] and +/// [`MAX_SIDE`]. +fn plate_scale(w: f32, h: f32, scale: f32) -> f32 { + let (w, h) = (w.max(1.0), h.max(1.0)); + let by_area = (MAX_PLATE_PIXELS / (w * h)).sqrt(); + let by_side = MAX_SIDE as f32 / w.max(h); + let s = scale.min(by_area).min(by_side); + if s.is_finite() && s > 0.0 { s } else { f32::MIN_POSITIVE } +} + +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), + Fallback::Profile(icc) => (CmsConversion::new(icc)?, icc), + }; + let simulation = conversion.simulation(icc); + let profile = simulation.description().unwrap_or("unnamed CMYK profile").to_string(); + Ok((Some(simulation), Conversion::Fallback { profile })) +} + +/// Spot inks reported while a page renders, in the order first painted, each with its sRGB +/// appearance at 100%. +#[derive(Default)] +struct SpotLog { + order: Vec<(PdfInk, [u8; 3])>, + seen: HashSet, +} + +fn spot_sink(log: &Arc>) -> InkSinkFn { + let log = log.clone(); + Arc::new(move |ink: &PdfInk, appearance: &dyn Fn(f32) -> [u8; 3]| { + let mut log = log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if log.seen.contains(ink) || log.order.len() >= MAX_LISTED_SPOTS { + return; + } + log.seen.insert(ink.clone()); + log.order.push((ink.clone(), appearance(1.0))); + }) +} + +/// A text string entry of `dict`, without control characters and at most [`MAX_CONDITION`] +/// characters long; `None` when missing or blank. +fn text_entry(dict: &Dict<'_>, key: &[u8]) -> Option { + let s = dict.get::>(key)?; + let text: String = pdf_text(s.as_bytes()).chars().filter(|c| !c.is_control()).take(MAX_CONDITION).collect(); + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) +} + +/// A PDF text string (ISO 32000-2 §7.9.2.2): UTF-16BE or UTF-8 after its byte order mark, else +/// PDFDocEncoding, read here as Latin-1 (they differ only in rarely used punctuation). +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); + } + if let Some(utf8) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(utf8).into_owned(); + } + bytes.iter().map(|b| char::from(*b)).collect() +} + +/// 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. +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); + let profile = simulation + .description() + .map(str::to_string) + .or_else(|| text_entry(intent, OUTPUT_CONDITION)) + .or_else(|| condition.clone()) + .unwrap_or_else(|| "unnamed CMYK profile".to_string()); + Some((simulation, Conversion::OutputIntent { profile, condition })) + }) +} + +/// 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::*; + + #[test] + fn plates_fit_the_pixel_and_side_caps() { + assert_eq!(plate_scale(40.0, 40.0, 1.0), 1.0); + // A4 at 2400 dpi would be 22 000 × 31 000 pixels. + let s = plate_scale(595.0, 842.0, 2400.0 / 72.0); + assert!((595.0 * s) * (842.0 * s) <= MAX_PLATE_PIXELS * 1.0001); + // A 1 × 10 000 pt strip fits the area cap but not the renderer's side limit. + assert!(10_000.0 * plate_scale(1.0, 10_000.0, 1.0) <= MAX_SIDE as f32); + assert!(plate_scale(0.0, f32::NAN, 1.0) > 0.0); + } +} 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/prepress/tests/plates.rs b/crates/prepress/tests/plates.rs new file mode 100644 index 00000000..791d4a44 --- /dev/null +++ b/crates/prepress/tests/plates.rs @@ -0,0 +1,276 @@ +use std::sync::Arc; + +use craft_cms::{Intent, Profile, Transform, builtin}; +use craft_prepress::{Alternate, CmsConversion, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates}; + +/// A 40 × 40 pt page with `content` and `resources`, `catalog` entries, and `objects` +/// numbered from 5 (each a whole object body). +fn page_pdf(content: &str, resources: &str, catalog: &str, objects: &[Vec]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + out.extend_from_slice(format!("1 0 obj << /Type /Catalog /Pages 2 0 R {catalog} >> endobj\n").as_bytes()); + 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 40 40] /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 ", i + 5).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b" endobj\n"); + } + out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + out +} + +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +/// A spot color whose alternate is a yellowish CMYK. +const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]"; + +/// Black over the page, Gold at 50% in the middle square, overprinting when `gs` sets `op`. +fn black_and_gold(gs: &str) -> Vec { + let resources = format!("/ColorSpace << /CS0 {GOLD} >> /ExtGState << /OP << /op true >> >>"); + page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} /CS0 cs 0.5 scn 10 10 20 20 re f"), &resources, "", &[]) +} + +fn black() -> InkId { + InkId::process(Process::Black) +} + +#[test] +fn a_black_and_gold_page_separates_into_the_process_plates_and_gold() { + let p = plates(&black_and_gold(""), 0, 72.0, &Options::default()).unwrap(); + assert_eq!((p.width, p.height, p.dpi), (40, 40, 72.0)); + let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect(); + assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold"]); + assert_eq!(p.planes.len(), 5); + assert!(p.unseparated.is_empty()); + let gold = p.inks.lookup("Gold").unwrap(); + let Some(Ink::Spot(spot)) = p.inks.get(gold) else { panic!("Gold is a spot ink") }; + let Alternate::Lab([l, _, b]) = spot.alternate else { panic!("a Lab alternate") }; + assert!(l > 60.0 && b > 40.0, "Gold looks yellow: {:?}", spot.alternate); + + let half = |t: Option| t.is_some_and(|t| t.abs_diff(128) <= 1); + assert!(half(p.tint(gold, 20, 20)), "Gold at 50%"); + assert_eq!(p.tint(black(), 20, 20), Some(0), "Gold knocks out black"); + assert_eq!(p.tint(black(), 2, 2), Some(255)); + assert_eq!(p.tint(gold, 2, 2), Some(0)); + for c in [Process::Cyan, Process::Magenta, Process::Yellow] { + assert!(p.plane(InkId::process(c)).unwrap().iter().all(|t| *t == 0), "{c:?} is empty"); + } + + let over = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options::default()).unwrap(); + assert!(half(over.tint(gold, 20, 20))); + assert_eq!(over.tint(black(), 20, 20), Some(255), "overprinted"); + let off = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options { overprint: false, ..Options::default() }).unwrap(); + assert_eq!(off.tint(black(), 20, 20), Some(0), "overprint off: knocked out"); + + // The preview reads the plates: knocked-out Gold shows pale on paper, overprinted Gold + // darkens the black under it. + let cms = CmsConversion::fogra39(); + let px = |rgb: &[u8], x: usize, y: usize| -> [u8; 3] { rgb[(y * 40 + x) * 3..][..3].try_into().unwrap() }; + let (knockout, overprint) = (p.composite(&[], &cms), over.composite(&[], &cms)); + let [r, g, b] = px(&knockout, 20, 20); + assert!(r > 200 && g > 180 && b < g, "50% Gold on paper: {:?}", [r, g, b]); + let k = px(&knockout, 2, 2); + assert!(k.iter().all(|c| *c < 80), "black: {k:?}"); + assert_eq!(px(&overprint, 2, 2), k); + let dark = px(&overprint, 20, 20); + assert!(dark.iter().zip(k).all(|(d, k)| *d <= k) && dark[2] < k[2], "Gold over black: {dark:?}, black {k:?}"); +} + +/// What `profile` prints for sRGB red, as tint bytes. +fn red_in(profile: &[u8]) -> [u8; 4] { + let t = Transform::new(builtin::srgb(), &Profile::parse(profile).unwrap(), Intent::RelativeColorimetric, false).unwrap(); + let mut cmyk = [0.0_f32; 4]; + t.convert_f32(&[1.0, 0.0, 0.0], 3, &mut cmyk, 4, false).unwrap(); + cmyk.map(|v| (v * 255.0).round() as u8) +} + +fn red_tints(p: &craft_prepress::Plates) -> [u8; 4] { + Process::ALL.map(|c| p.tint(InkId::process(c), 20, 20).unwrap()) +} + +fn near(got: [u8; 4], expected: [u8; 4]) -> bool { + got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= 3) +} + +#[test] +fn process_color_converts_through_the_output_intent_else_the_fallback() { + let synthetic = builtin::COATED_CMYK_ICC; + let intent = "/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Synthetic press) /DestOutputProfile 5 0 R >>]"; + let red = |catalog: &str| page_pdf("1 0 0 rg 0 0 40 40 re f", "", catalog, &[stream("/N 4", synthetic)]); + + let p = plates(&red(intent), 0, 72.0, &Options::default()).unwrap(); + let profile = craft_cms::synth::DESCRIPTION.to_string(); + assert_eq!(p.conversion, Conversion::OutputIntent { profile: profile.clone(), condition: Some("Synthetic press".into()) }); + let expected = red_in(synthetic); + assert!(near(red_tints(&p), expected), "{:?}, the output intent prints {expected:?}", red_tints(&p)); + + let fogra = plates(&red(""), 0, 72.0, &Options::default()).unwrap(); + assert_eq!(fogra.conversion, Conversion::Fallback { profile: builtin::COATED_FOGRA39_DESCRIPTION.into() }); + let expected = red_in(builtin::COATED_FOGRA39_ICC); + assert!(near(red_tints(&fogra), expected), "{:?}, FOGRA39 prints {expected:?}", red_tints(&fogra)); + + let callers = Options { fallback: Fallback::Profile(Arc::from(synthetic)), ..Options::default() }; + assert_eq!(plates(&red(""), 0, 72.0, &callers).unwrap().conversion, Conversion::Fallback { profile }); + + let formulas = Options { fallback: Fallback::DeviceFormulas, ..Options::default() }; + let device = plates(&red(""), 0, 72.0, &formulas).unwrap(); + assert_eq!((device.conversion.clone(), red_tints(&device)), (Conversion::DeviceFormulas, [0, 255, 255, 0])); + + let broken = Options { fallback: Fallback::Profile(Arc::from(&b"not a profile"[..])), ..Options::default() }; + assert!(matches!(plates(&red(""), 0, 72.0, &broken), Err(Error::Profile(_)))); +} + +#[test] +fn spot_inks_past_the_cap_are_listed_not_separated_and_bad_input_is_refused() { + let n = MAX_SPOT_INKS + 2; + let spaces: String = + (0..n).map(|i| format!("/S{i} [/Separation /Spot{i} /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] ")).collect(); + let content: String = (0..n).map(|i| format!("/S{i} cs 1 scn {i} 0 1 1 re f ")).collect(); + let pdf = page_pdf(&content, &format!("/ColorSpace << {spaces} >>"), "", &[]); + let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap(); + assert_eq!((p.inks.len(), p.planes.len()), (4 + MAX_SPOT_INKS, 4 + MAX_SPOT_INKS)); + assert_eq!(p.unseparated, [format!("Spot{}", n - 2), format!("Spot{}", n - 1)]); + + assert!(matches!(plates(&pdf, 1, 72.0, &Options::default()), Err(Error::NoPage(1)))); + assert!(matches!(plates(&pdf, 0, f32::NAN, &Options::default()), Err(Error::InvalidDpi))); + assert!(matches!(plates(b"not a pdf", 0, 72.0, &Options::default()), Err(Error::Open(_)))); +} + +#[test] +fn escaped_spot_names_are_decoded_once_and_each_gets_its_own_plate() { + // `/A#2341` is the spot `A#41`, not `AA`; `/Spot#20#2312` is `Spot #12`. + let names = ["/A#2341", "/AA", "/Spot#20#2312"]; + let spaces: String = names + .iter() + .enumerate() + .map(|(i, name)| format!("/S{i} [/Separation {name} /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] ")) + .collect(); + let content: String = (0..names.len()).map(|i| format!("/S{i} cs 1 scn {} 0 10 10 re f ", i * 10)).collect(); + let pdf = page_pdf(&content, &format!("/ColorSpace << {spaces} >>"), "", &[]); + let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap(); + let spots: Vec<&str> = p.inks.spots().map(|(_, s)| s.name.as_str()).collect(); + assert_eq!(spots, ["A#41", "AA", "Spot #12"]); + assert!(p.unseparated.is_empty()); + assert_eq!(p.planes.len(), 7); + for (i, (id, _)) in p.inks.spots().enumerate() { + for x in [5u32, 15, 25] { + let expected = if (x / 10) as usize == i { 255 } else { 0 }; + assert_eq!(p.tint(id, x, 35), Some(expected), "spot {i} at x = {x}"); + } + } +} + +#[test] +fn lab_colors_convert_through_the_profile_and_lab_white_is_paper() { + let resources = "/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] >>"; + let pdf = page_pdf("/D50 cs 50 20 -10 scn 0 0 20 40 re f /D65 cs 100 0 0 scn 20 0 20 40 re f", resources, "", &[]); + let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap(); + let at = |x| Process::ALL.map(|c| p.tint(InkId::process(c), x, 20).unwrap()); + let fogra = Profile::parse(builtin::COATED_FOGRA39_ICC).unwrap(); + let t = Transform::new(craft_cms::Builtin::LabD50.profile(), &fogra, Intent::RelativeColorimetric, false).unwrap(); + let mut cmyk = [0.0_f32; 4]; + t.convert_f32(&[0.5, 148.0 / 255.0, 118.0 / 255.0], 3, &mut cmyk, 4, false).unwrap(); + let expected = cmyk.map(|v| (v * 255.0).round() as u8); + assert!(near(at(10), expected), "{:?}, FOGRA39 prints {expected:?}", at(10)); + assert_eq!(at(30), [0, 0, 0, 0], "a Lab space's own white prints as paper"); +} + +/// A 60 × 10 pt page written by the PDF writer, with a 10 pt wide fill of each color. +fn written(colors: Vec) -> Vec { + use craft_pdf::write::backend::{geom, page::PageSettings, paint::Fill}; + let mut writer = craft_pdf::write::Writer::new(Default::default(), Default::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(geom::Size::from_wh(60.0, 10.0).unwrap())); + let mut surface = page.surface(); + for (i, color) in colors.into_iter().enumerate() { + let mut path = geom::PathBuilder::new(); + path.push_rect(geom::Rect::from_xywh(10.0 * i as f32, 0.0, 10.0, 10.0).unwrap()); + surface.set_fill(Some(Fill { paint: color.into(), ..Default::default() })); + surface.draw_path(&path.finish().unwrap()); + } + surface.finish(); + page.finish(); + writer.finish().unwrap() +} + +/// The page's color space resources as (family, colorant names), sorted. +fn color_spaces(pdf: &[u8]) -> Vec<(String, Vec)> { + use craft_pdf::syntax::object::{Array, Name, Object}; + let pdf = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap(); + let page = pdf.pages().iter().next().unwrap(); + let spaces = &page.resources().color_spaces; + let mut out: Vec<(String, Vec)> = spaces + .keys() + .map(|key| { + let array = spaces.get::>(key.as_ref()).unwrap(); + let items: Vec> = array.iter::>().collect(); + let Some(Object::Name(family)) = items.first() else { panic!("a color space without a family") }; + let names = match items.get(1) { + Some(Object::Name(n)) => vec![n.as_str().to_owned()], + Some(Object::Array(a)) => a.iter::>().map(|n| n.as_str().to_owned()).collect(), + _ => Vec::new(), + }; + (family.as_str().to_owned(), names) + }) + .collect(); + out.sort(); + out +} + +#[test] +fn ink_colors_write_as_cmyk_separation_or_device_n_and_separate_onto_their_plates() { + use craft_prepress::{InkColor, InkSet, Spot, to_pdf_color}; + 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(); + let teal = inks.add(Ink::Spot(Spot { name: "Teal".into(), alternate: Alternate::Lab([55.0, -40.0, -10.0]), solidity: None })).unwrap(); + let (c, k) = (InkId::process(Process::Cyan), black()); + let cms = CmsConversion::fogra39(); + let colors = [ + InkColor::Tints(vec![(k, 0.4), (c, 0.6)]), + InkColor::Tints(vec![(gold, 0.5)]), + InkColor::Tints(vec![(gold, 1.0), (k, 0.5)]), + InkColor::Tints(vec![(teal, 1.0)]), + InkColor::All(1.0), + InkColor::None, + ]; + let pdf = written(colors.iter().map(|color| to_pdf_color(color, &inks, &cms).unwrap()).collect()); + let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::>(); + let mut expected = vec![ + ("DeviceN".to_owned(), names(&["Black", "Gold"])), + ("Separation".to_owned(), names(&["All"])), + ("Separation".to_owned(), names(&["Gold"])), + ("Separation".to_owned(), names(&["None"])), + ("Separation".to_owned(), names(&["Teal"])), + ]; + expected.sort(); + assert_eq!(color_spaces(&pdf), expected, "process only is DeviceCMYK, which needs no resource"); + + let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap(); + let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect(); + assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold", "Teal"]); + let (gold, teal) = (p.inks.lookup("Gold").unwrap(), p.inks.lookup("Teal").unwrap()); + let at = |x: u32| -> Vec { p.inks.iter().map(|(id, _)| p.tint(id, x, 5).unwrap()).collect() }; + assert_eq!(at(5), [153, 0, 0, 102, 0, 0]); + assert_eq!(at(15), [0, 0, 0, 0, 128, 0]); + assert_eq!(at(25), [0, 0, 0, 128, 255, 0]); + assert_eq!(at(35), [0, 0, 0, 0, 0, 255]); + assert_eq!(at(45), [255; 6], "registration"); + assert_eq!(at(55), [0; 6]); + assert_eq!((p.tint(gold, 25, 5), p.tint(teal, 35, 5)), (Some(255), Some(255))); + + let unknown = InkSet::new(); + assert_eq!(to_pdf_color(&colors[1], &unknown, &cms), None, "an ink not in the set"); + assert_eq!(to_pdf_color(&InkColor::Tints(vec![]), &inks, &cms), None, "no ink: nothing to paint"); + let mut many = InkSet::new(); + let tints: Vec<(InkId, f32)> = (0..33) + .map(|i| (many.add(Ink::Spot(Spot { name: format!("S{i}"), alternate: Alternate::Cmyk([0.0; 4]), solidity: None })).unwrap(), 1.0)) + .collect(); + assert_eq!(to_pdf_color(&InkColor::Tints(tints), &many, &cms), None, "more than 32 colorants"); +} diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 1f5aa1a8..1e6a6350 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -27,11 +27,11 @@ winsafe = { version = "0.0.29", default-features = false, features = ["winspool" # The job file's private folder. tempfile = "3" # Reading the PrintTicket and PrintCapabilities the driver hands back. -roxmltree = "0.20" +roxmltree = "0.21" # Inflating the print-ready PDF's cross-reference and object streams (src/sheets.rs). -miniz_oxide = "0.8" +miniz_oxide = "0.9" [dev-dependencies] tempfile = "3" -roxmltree = "0.20" -miniz_oxide = "0.8" +roxmltree = "0.21" +miniz_oxide = "0.9" diff --git a/crates/print/README.md b/crates/print/README.md index 00b12689..22d39e43 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -81,10 +81,21 @@ their own fidelity checks. Native Windows PDF submission checks the driver's validated copy count and explicit simplex/duplex and grayscale requests before sending. Collation must be retained when both -the page count and copy count exceed one. A driver that drops or changes these options is -refused; select supported options or flatten grayscale into the print-ready page content. -Without an explicit duplex request, the queue default applies. Normal color output retains -the driver's color policy. Single-page or single-copy jobs have no collating order to check. +the page count and copy count exceed one. A driver that offers one of these options and drops +or changes it is refused; select supported options or flatten grayscale into the print-ready +page content. A driver without the feature, per its PrintCapabilities, is accepted when it can +only do what was asked: one-sided on a queue with no two-sided feature, grayscale on one with no +colour option, uncollated copies on one that can't collate. Two-sided on such a queue is +refused. Windows doesn't document collating for a driver that can't collate, so collated copies +on one are a single job of one copy that holds the document's pages once per copy; on two-sided +paper an odd page count gets a blank page after each copy but the last, so every copy starts on +a sheet of its own. Where one job would print the copies wrongly or can't hold them (pages with +annotations, an encrypted PDF, a very large job, a queue set to several pages per sheet, binding, +stapling or a two-sided option outside the Print Schema's), each copy is a job of its own. +Without an explicit duplex request, the queue default applies. Without a grayscale request the +ticket names no colour, so the colour in the user's printing preferences (Properties…, or +Windows Settings) applies, black and white included. Single-page or single-copy jobs have no +collating order to check. `cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal), printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 3c26974a..ecf97508 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -483,7 +483,9 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result { /// [`submit`] with Windows sheet-processing progress: `(processed, sheets)` before the first /// sheet and after each sheet is successfully added to the native print job. Counts refer to -/// source PDF sheets, regardless of copies or duplex. CUPS reports no progress. +/// source PDF sheets, regardless of copies or duplex, except for collated copies on a driver +/// that can't collate: every copy's sheets are sent, in one job or a job per copy, and counted +/// together. CUPS reports no progress. /// /// The callback runs synchronously on the thread calling this function, need not be `Send`, /// and should return promptly. It is never retained. All sheets processed is not terminal @@ -590,7 +592,7 @@ fn windows_preview(job: &Job) -> Vec { Scaling::ShrinkToFit => "shrink-to-fit", } ), - format!("color={}", if job.grayscale { "monochrome" } else { "color" }), + format!("color={}", if job.grayscale { "monochrome" } else { "(driver default)" }), ] } diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index bbb4c7c7..56303d2f 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -31,6 +31,8 @@ const MAX_TOKENS: usize = 16 << 20; const MAX_OBJECT_NODES: usize = 32_000; const MAX_OBJECT_TOKENS: usize = 1 << 20; const MAX_SECTIONS: usize = 256; +/// The most the page objects of repeated copies ([`Repeat`]) may take, in bytes. +const MAX_REPEATED: usize = 64 << 20; /// A page without a MediaBox (invalid, but readers accept it) is US Letter. const DEFAULT_MEDIA: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; /// Sheet sizes this close, in points (0.18 mm), are the same size. @@ -515,6 +517,53 @@ fn indirect<'a>(data: &'a [u8], offset: usize, budget: &mut Budget) -> Option), + /// The output would pass the limit. + Exhausted, + /// A damaged or truncated stream: only the bytes actually written before the damage. + Damaged(Vec), +} + +/// Inflate `raw` (zlib-wrapped or bare deflate) to at most `limit` bytes. Unlike the +/// `decompress_to_vec*` helpers it reports the bytes the decompressor really wrote, so a stream +/// rejected early yields an empty `Damaged`, never the zero-filled output buffer. +fn inflate(raw: &[u8], zlib: bool, limit: usize) -> Inflated { + use miniz_oxide::inflate::TINFLStatus; + use miniz_oxide::inflate::core::{DecompressorOxide, decompress, inflate_flags}; + let flags = inflate_flags::TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF | if zlib { inflate_flags::TINFL_FLAG_PARSE_ZLIB_HEADER } else { 0 }; + let mut out = vec![0u8; raw.len().saturating_mul(2).max(64).min(limit)]; + let mut state = Box::::default(); + let (mut input, mut written) = (raw, 0usize); + loop { + let (status, consumed, wrote) = decompress(&mut state, input, &mut out, written, flags); + written = written.saturating_add(wrote).min(out.len()); + match status { + TINFLStatus::Done => { + out.truncate(written); + return Inflated::Done(out); + } + TINFLStatus::HasMoreOutput => { + if out.len() >= limit { + return Inflated::Exhausted; + } + let Some(rest) = input.get(consumed..) else { + out.truncate(written); + return Inflated::Damaged(out); + }; + input = rest; + out.resize(out.len().saturating_mul(2).min(limit), 0); + } + _ => { + out.truncate(written); + return Inflated::Damaged(out); + } + } + } +} + /// A stream's data with its filters undone: FlateDecode (with PNG predictors), or none. fn decode(dict: &Dict, raw: &[u8], budget: &mut Budget) -> Option> { if budget.exhausted { @@ -545,20 +594,22 @@ fn decode(dict: &Dict, raw: &[u8], budget: &mut Budget) -> Option> { Some(raw.to_vec()) } [b"FlateDecode" | b"Fl"] => { - let inflated = match miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, limit) { - Ok(v) => v, - Err(e) if e.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput => { + let inflated = match inflate(raw, true, limit) { + Inflated::Done(v) => v, + Inflated::Exhausted => { budget.exhausted = true; return None; } // A damaged stream: what inflated before the damage, as other readers do. - Err(e) if !e.output.is_empty() => e.output, - Err(_) => match miniz_oxide::inflate::decompress_to_vec_with_limit(raw, limit) { - Ok(v) => v, - Err(e) => { - budget.exhausted |= e.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput; + Inflated::Damaged(v) if !v.is_empty() => v, + // Rejected before any output: retry as a bare deflate stream. + Inflated::Damaged(_) => match inflate(raw, false, limit) { + Inflated::Done(v) => v, + Inflated::Exhausted => { + budget.exhausted = true; return None; } + Inflated::Damaged(_) => return None, }, }; unpredict(&inflated, parms, limit) @@ -814,12 +865,18 @@ pub(crate) struct Sheet { /// The page object (number, generation); `None` for a page written inside its parent. obj: Option<(u32, u16)>, dict: Dict, + /// The MediaBox, inherited or not. + media: [f64; 4], /// The visible box (CropBox within MediaBox), in the page's own unrotated space. pub(crate) bounds: [f64; 4], + /// `/Resources` inherited from the page tree; `None` when the page has its own or none. + resources: Option>, /// `/Rotate`, 0, 90, 180 or 270. pub(crate) rotate: u32, /// Annotation flags match the renderer's visible printing behavior. print_annotations_safe: bool, + /// `/Annots` lists at least one annotation. + annotated: bool, } impl Sheet { @@ -835,6 +892,7 @@ pub(crate) struct Sheets<'a> { data: &'a [u8], pub(crate) sheets: Vec, trailer: Dict, + catalog: Dict, /// The newest cross-reference section is a stream (so is the update's). xref_stream: bool, startxref: usize, @@ -848,6 +906,7 @@ struct Inherited { media: Option>, crop: Option>, rotate: Option>, + resources: Option>, } /// Read the sheets of a print-ready PDF. @@ -905,10 +964,14 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { _ => None, }; let mut dict = reader.resolve(&node).and_then(|d| d.dict().cloned()).ok_or_else(|| unreadable("a page is missing"))?; + // A null value is the same as no entry (ISO 32000-1, 7.3.9), so it inherits. + let own = |key: &[u8]| get(&dict, key).filter(|v| **v != Obj::Null).cloned().map(Arc::new); + let parent_resources = inherited.resources.clone(); let here = Inherited { - media: get(&dict, b"MediaBox").cloned().map(Arc::new).or(inherited.media), - crop: get(&dict, b"CropBox").cloned().map(Arc::new).or(inherited.crop), - rotate: get(&dict, b"Rotate").cloned().map(Arc::new).or(inherited.rotate), + media: own(b"MediaBox").or(inherited.media), + crop: own(b"CropBox").or(inherited.crop), + rotate: own(b"Rotate").or(inherited.rotate), + resources: own(b"Resources").or(inherited.resources), }; let kids = get(&dict, b"Kids").cloned().and_then(|k| reader.resolve(&k)); let is_node = get(&dict, b"Type").and_then(Obj::name) == Some(b"Pages") || (get(&dict, b"Type").is_none() && kids.is_some()); @@ -955,20 +1018,31 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { let rotate = if rotate % 90 == 0 { u32::try_from(rotate).unwrap_or(0) } else { 0 }; // Windows renders viewer visibility, not annotation print visibility. Only a plain // visible printable annotation is safe; flatten other flags before native submission. - let print_annotations_safe = match get(&dict, b"Annots") { - None | Some(Obj::Null) => true, + let (print_annotations_safe, annotated) = match get(&dict, b"Annots") { + None | Some(Obj::Null) => (true, false), Some(annotations) => match reader.resolve(annotations) { - Some(Obj::Null) => true, - Some(Obj::Array(items)) => items.iter().all(|item| { - reader - .resolve(item) - .and_then(|a| a.dict().cloned()) - .is_some_and(|annotation| get(&annotation, b"F").and_then(|f| reader.resolve(f)).and_then(|f| f.int()) == Some(4)) - }), - _ => false, + Some(Obj::Null) => (true, false), + Some(Obj::Array(items)) => ( + items.iter().all(|item| { + reader + .resolve(item) + .and_then(|a| a.dict().cloned()) + .is_some_and(|annotation| get(&annotation, b"F").and_then(|f| reader.resolve(f)).and_then(|f| f.int()) == Some(4)) + }), + !items.is_empty(), + ), + _ => (false, true), }, }; - sheets.push(Sheet { obj, dict, bounds, rotate, print_annotations_safe }); + // A reference to a missing object is null too. Only the cross-reference table is + // looked at: resolving a shared Resources dictionary for every page would parse it again. + let own_resources = match get(&dict, b"Resources") { + None | Some(Obj::Null) => false, + Some(Obj::Ref(num, _)) => matches!(reader.entries.get(num), Some(Entry::At(_) | Entry::In(..))), + Some(_) => true, + }; + let resources = if own_resources { None } else { parent_resources }; + sheets.push(Sheet { obj, dict, media, bounds, resources, rotate, print_annotations_safe, annotated }); } if reader.budget.exhausted { return Err(unreadable("the parser memory budget was exceeded")); @@ -976,7 +1050,7 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { if sheets.is_empty() { return Err(Error::Spool("the print-ready PDF has no pages".into())); } - Ok(Sheets { data, sheets, trailer, xref_stream, startxref: start, size }) + Ok(Sheets { data, sheets, trailer, catalog, xref_stream, startxref: start, size }) } /// How sheets of another size than the paper are printed. @@ -1067,6 +1141,30 @@ pub(crate) fn plan(sheets: &[Sheet], paper: (f64, f64), fit: Fit) -> Result bool { + get(dict, b"Annots").is_some_and(|a| !matches!(a, Obj::Null) && !matches!(a, Obj::Array(items) if items.is_empty())) +} + +fn rect_obj(r: [f64; 4]) -> Obj { + Obj::Array(r.into_iter().map(num_obj).collect()) +} + +/// Starts object `num` in an update, recording where it is. +fn new_object(out: &mut Vec, num: u32, generation: u16, written: &mut Vec<(u32, u16, usize)>) { + written.push((num, generation, out.len())); + out.extend_from_slice(format!("{num} {generation} obj\n").as_bytes()); +} + /// Boxes a reframed page drops: they were in the old geometry. const OLD_GEOMETRY: [&[u8]; 6] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox", b"Rotate"]; @@ -1084,7 +1182,25 @@ impl Sheets<'_> { /// original bytes, then the new versions of the edited pages, the content streams a /// reframed page wraps its own in, and a cross-reference section of the same kind as the /// newest one, chained to it with `/Prev`. + /// pub(crate) fn update(&self, edits: &[Edit]) -> Result, Error> { + // Only a repeat can be turned down, so there is always a PDF. + Ok(self.write(edits, None)?.unwrap_or_else(|| self.data.to_vec())) + } + + /// [`update`](Self::update) with every copy of `repeat` in the one PDF. The edited pages + /// are not written in place: the update adds a page tree of its own, every copy's pages as + /// new page objects that share the original content and resources, and a new version of + /// the catalog pointing to it. Each new page carries the boxes, rotation and resources it + /// inherited, since its parent is the new tree. `None` when the copies can't be one PDF: + /// the PDF is encrypted (strings are encrypted with keys made from their object's number), + /// a page has annotations (an annotation belongs to one page; whether a renderer draws it on + /// others is unknown), or the job would be too large. + pub(crate) fn repeated(&self, edits: &[Edit], repeat: Repeat) -> Result>, Error> { + self.write(edits, Some(repeat)) + } + + fn write(&self, edits: &[Edit], repeat: Option) -> Result>, Error> { let refuse = |why: &str| Error::Spool(format!("the print-ready PDF can't be fitted to the paper: {why}")); let mut out = self.data.to_vec(); if !out.ends_with(b"\n") { @@ -1093,13 +1209,10 @@ impl Sheets<'_> { let mut next = self.size; // Each new object's number, generation and offset. let mut written: Vec<(u32, u16, usize)> = Vec::new(); - fn new_object(out: &mut Vec, num: u32, generation: u16, written: &mut Vec<(u32, u16, usize)>) { - written.push((num, generation, out.len())); - out.extend_from_slice(format!("{num} {generation} obj\n").as_bytes()); - } + let repeat = repeat.filter(|r| r.copies > 1); + let mut edited = HashMap::new(); for edit in edits { let sheet = self.sheets.get(edit.sheet).ok_or_else(|| refuse("no such sheet"))?; - let (num, generation) = sheet.obj.ok_or_else(|| refuse("a page is not an object of its own"))?; let mut dict = sheet.dict.clone(); dict.retain(|(k, _)| k != b"Rotate"); if let Some(frame) = &edit.frame { @@ -1109,10 +1222,7 @@ impl Sheets<'_> { // Annotation appearances live outside /Contents. Transforming only content // would leave them at the old coordinates; deleting them loses printed marks. // Require callers to flatten before fitting until appearances can be reframed. - if let Some(annotations) = get(&dict, b"Annots") - && !matches!(annotations, Obj::Null) - && !matches!(annotations, Obj::Array(items) if items.is_empty()) - { + if has_annotations(&dict) { return Err(refuse("flatten page annotations before using shrink-to-fit")); } let [a, b, c, d, e, f] = frame.matrix; @@ -1155,12 +1265,126 @@ impl Sheets<'_> { dict.push((b"Contents".to_vec(), Obj::Array(contents))); } dict.push((b"Rotate".to_vec(), Obj::Num(edit.rotate.to_string().into_bytes()))); - new_object(&mut out, num, generation, &mut written); - write_dict(&mut out, &dict); + if repeat.is_some() { + edited.insert(edit.sheet, dict); + } else { + let (num, generation) = sheet.obj.ok_or_else(|| refuse("a page is not an object of its own"))?; + new_object(&mut out, num, generation, &mut written); + write_dict(&mut out, &dict); + out.extend_from_slice(b"\nendobj\n"); + } + } + if let Some(Repeat { copies, two_sided }) = repeat { + let refuse = |why: &str| Error::Spool(format!("the copies can't be written: {why}")); + if get(&self.trailer, b"Encrypt").is_some() || self.sheets.iter().any(|s| s.annotated) { + return Ok(None); + } + let Some(&Obj::Ref(root, root_generation)) = get(&self.trailer, b"Root") else { + return Ok(None); + }; + let blank = two_sided && self.sheets.len() % 2 == 1; + let per_copy = self.sheets.len() + usize::from(blank); + let count = + usize::try_from(copies).ok().and_then(|c| per_copy.checked_mul(c)).map(|n| n - usize::from(blank)).filter(|&n| n <= MAX_PAGES); + let Some(count) = count else { return Ok(None) }; + let tree = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + let parent = (b"Parent".to_vec(), Obj::Ref(tree, 0)); + // Bytes written once, and bytes written once per copy (a page, its xref entry and kid). + let (mut once, mut each) = (0usize, 0usize); + let within = + |once: usize, each: usize| each.checked_mul(copies as usize).and_then(|n| n.checked_add(once)).is_some_and(|n| n <= MAX_REPEATED); + // Inherited inline resources are written once, as an object of their own, and every + // page that inherited them refers to it. + let mut shared: HashMap<*const Obj, Obj> = HashMap::new(); + let mut bodies: Vec> = Vec::with_capacity(per_copy); + let mut last = Dict::new(); + for (i, sheet) in self.sheets.iter().enumerate() { + let mut dict = edited.remove(&i).unwrap_or_else(|| sheet.dict.clone()); + dict.retain(|(k, _)| k != b"Parent"); + // The boxes as read, not the inherited objects: those can be anything. + for (key, value) in [(&b"MediaBox"[..], rect_obj(sheet.media)), (b"CropBox", rect_obj(sheet.bounds))] { + if get(&dict, key).is_none_or(|v| *v == Obj::Null) { + dict.retain(|(k, _)| k != key); + dict.push((key.to_vec(), value)); + } + } + if get(&dict, b"Rotate").is_none_or(|v| *v == Obj::Null) { + dict.retain(|(k, _)| k != b"Rotate"); + dict.push((b"Rotate".to_vec(), Obj::Num(sheet.rotate.to_string().into_bytes()))); + } + if let Some(resources) = &sheet.resources { + let value = match &**resources { + Obj::Ref(..) => Obj::clone(resources), + _ => match shared.get(&Arc::as_ptr(resources)) { + Some(r) => r.clone(), + None => { + let num = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + let at = out.len(); + new_object(&mut out, num, 0, &mut written); + write_obj(&mut out, resources); + out.extend_from_slice(b"\nendobj\n"); + once += out.len() - at; + if !within(once, each) { + return Ok(None); + } + shared.insert(Arc::as_ptr(resources), Obj::Ref(num, 0)); + Obj::Ref(num, 0) + } + }, + }; + dict.retain(|(k, _)| k != b"Resources"); + dict.push((b"Resources".to_vec(), value)); + } + dict.push(parent.clone()); + let mut body = Vec::new(); + write_dict(&mut body, &dict); + each += body.len() + 64; + if !within(once, each) { + return Ok(None); + } + bodies.push(body); + last = dict; + } + if blank { + let mut page = vec![(b"Type".to_vec(), Obj::Name(b"Page".to_vec())), parent.clone(), (b"Resources".to_vec(), Obj::Dict(Vec::new()))]; + for key in [&b"MediaBox"[..], b"CropBox", b"Rotate"] { + if let Some(value) = get(&last, key) { + page.push((key.to_vec(), value.clone())); + } + } + let mut body = Vec::new(); + write_dict(&mut body, &page); + bodies.push(body); + } + let mut kids = Vec::with_capacity(count); + for body in bodies.iter().cycle().take(count) { + let num = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + new_object(&mut out, num, 0, &mut written); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + kids.push(Obj::Ref(num, 0)); + } + new_object(&mut out, tree, 0, &mut written); + let node = vec![ + (b"Type".to_vec(), Obj::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Obj::Array(kids)), + (b"Count".to_vec(), Obj::Num(count.to_string().into_bytes())), + ]; + write_dict(&mut out, &node); + out.extend_from_slice(b"\nendobj\n"); + let mut catalog = self.catalog.clone(); + // Page labels and the open action name pages of the old tree. + catalog.retain(|(k, _)| k != b"Pages" && k != b"PageLabels" && k != b"OpenAction"); + catalog.push((b"Pages".to_vec(), Obj::Ref(tree, 0))); + new_object(&mut out, root, root_generation, &mut written); + write_dict(&mut out, &catalog); out.extend_from_slice(b"\nendobj\n"); } if written.is_empty() { - return Ok(self.data.to_vec()); + return Ok(Some(self.data.to_vec())); } written.sort_by_key(|(num, _, _)| *num); let mut trailer: Dict = Vec::new(); @@ -1207,7 +1431,7 @@ impl Sheets<'_> { write_dict(&mut out, &trailer); out.extend_from_slice(format!("\nstartxref\n{at}\n%%EOF\n").as_bytes()); } - Ok(out) + Ok(Some(out)) } } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index db03fb8a..e051cda6 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -309,7 +309,7 @@ fn command_previews_do_not_require_pdf_files() { } else { assert_eq!( preview, - ["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=color"] + ["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=(driver default)"] ); } } diff --git a/crates/print/src/tests/pdf_bounds.rs b/crates/print/src/tests/pdf_bounds.rs index 76b1409a..b9bc15a2 100644 --- a/crates/print/src/tests/pdf_bounds.rs +++ b/crates/print/src/tests/pdf_bounds.rs @@ -283,3 +283,196 @@ fn an_oversized_inherited_media_box_is_not_copied_for_every_page() { assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); assert_eq!(sheets.sheets.len(), pages); } + +/// A classic-xref PDF of these objects, numbered from 1. +fn classic(objects: &[String]) -> Vec { + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + pdf +} + +/// Three Letter pages that inherit their MediaBox and Resources; the third is turned by its own +/// `/Rotate 90`, and carries `extra` in its dictionary. +fn three_pages(extra: &str) -> Vec { + classic(&three_page_objects(extra)) +} + +fn three_page_objects(extra: &str) -> Vec { + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R /PageMode /UseNone >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << /ProcSet [/PDF] >> >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Rotate 90 {extra} >>"), + ]; + objects.extend((0..3).map(|_| "<< /Length 0 >>\nstream\n\nendstream".to_string())); + objects +} + +/// `pdf` with `copies` collated copies in one job, or `None` when they can't be. +fn repeat(pdf: &[u8], copies: u32, two_sided: bool) -> Option> { + read(pdf).unwrap().repeated(&[], Repeat { copies, two_sided }).unwrap() +} + +#[test] +fn collated_copies_repeat_the_pages_with_a_blank_back_between_odd_copies() { + let pdf = three_pages(""); + let sheets = read(&pdf).unwrap(); + // The turned page comes back upright in a portrait job, in every copy. + let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap(); + let content = |n: u32| Some(Obj::Ref(n, 0)); + for (two_sided, expected) in [ + (true, vec![content(6), content(7), content(8), None, content(6), content(7), content(8)]), + (false, vec![content(6), content(7), content(8), content(6), content(7), content(8)]), + ] { + let updated = sheets.repeated(&edits, Repeat { copies: 2, two_sided }).unwrap().unwrap(); + let repeated = read(&updated).unwrap(); + let contents: Vec> = repeated.sheets.iter().map(|s| get(&s.dict, b"Contents").cloned()).collect(); + assert_eq!(contents, expected); + for sheet in &repeated.sheets { + assert_eq!((sheet.size(), sheet.rotate % 180), ((612.0, 792.0), 0), "{sheet:?}"); + // What the old parent gave each page is the page's own now. + assert!(get(&sheet.dict, b"Resources").is_some()); + assert!(sheet.obj.is_some_and(|(num, _)| num >= sheets.size), "every copy is a new page object"); + } + assert_eq!(get(&repeated.catalog, b"PageMode"), get(&sheets.catalog, b"PageMode"), "the rest of the catalog is kept"); + // The inherited resources are written once and shared. + let resources: Vec<_> = repeated.sheets.iter().filter(|s| get(&s.dict, b"Contents").is_some()).map(|s| get(&s.dict, b"Resources")).collect(); + assert!(matches!(resources[0], Some(Obj::Ref(..))) && resources.iter().all(|r| *r == resources[0]), "{resources:?}"); + } + // Without the edits the last page stays turned, and so does the blank back after it. + let bytes = repeat(&pdf, 2, true).unwrap(); + let repeated = read(&bytes).unwrap(); + assert_eq!((repeated.sheets[3].size(), repeated.sheets[3].rotate), (repeated.sheets[2].size(), 90)); + // An even page count needs no blank; one copy is the document as it is. + let even = classic(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + assert_eq!(read(&repeat(&even, 3, true).unwrap()).unwrap().sheets.len(), 6); + assert_eq!(repeat(&pdf, 1, true).unwrap(), pdf); + // A cross-reference stream, with the catalog and the page in an object stream. + for compressed in [false, true] { + let bytes = repeat(&fixture("3 0 R", compressed, ""), 3, false).unwrap(); + let repeated = read(&bytes).unwrap(); + assert_eq!(repeated.sheets.len(), 3); + assert_eq!(repeated.xref_stream, compressed); + } +} + +#[test] +fn collated_copies_that_cannot_be_one_job_are_left_to_separate_jobs() { + assert!(repeat(&three_pages("/Annots [6 0 R]"), 2, false).is_none(), "annotations"); + let pdf = three_pages(""); + assert!(repeat(&pdf, 50_000, false).is_none(), "too many pages"); + let encrypted = Sheets { trailer: vec![(b"Encrypt".to_vec(), Obj::Ref(9, 0))], ..read(&pdf).unwrap() }; + assert!(encrypted.repeated(&[], Repeat { copies: 2, two_sided: false }).unwrap().is_none(), "encrypted"); +} + +#[test] +fn a_null_missing_or_free_entry_inherits_and_an_empty_annotation_list_is_none() { + // Object 9 is written, but its cross-reference entry is free. + let mut objects = three_page_objects("/Resources 9 0 R"); + objects.push("<< /ProcSet [/Text] >>".into()); + let mut free = classic(&objects); + let at = free.windows(9).rposition(|w| w == b"00000 n\r\n").unwrap(); + free[at + 6] = b'f'; + for (extra, pdf) in [ + ("/Resources null", three_pages("/Resources null")), + ("/Resources 99 0 R", three_pages("/Resources 99 0 R")), + ("/Resources 9 0 R (free)", free), + ("/Annots null", three_pages("/Annots null")), + ("/Annots []", three_pages("/Annots []")), + ] { + let bytes = repeat(&pdf, 2, false).unwrap(); + let repeated = read(&bytes).unwrap(); + let resources: Vec<_> = repeated.sheets.iter().map(|s| get(&s.dict, b"Resources")).collect(); + assert!(matches!(resources[0], Some(Obj::Ref(..))) && resources.iter().all(|r| *r == resources[0]), "{extra}: {resources:?}"); + } +} + +#[test] +fn large_inherited_resources_are_written_once_for_every_copy() { + let pages = 4_000; + let procset = vec!["/PDF"; 25_000].join(" "); + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", i + 3)).collect(); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 612 792] /Resources << /ProcSet [{procset}] >> >>", kids.join(" ")), + ]; + objects.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string())); + let pdf = classic(&objects); + let updated = repeat(&pdf, 3, false).unwrap(); + assert!(updated.len() < pdf.len() + (4 << 20), "{} bytes", updated.len()); + assert_eq!(read(&updated).unwrap().sheets.len(), 3 * pages); +} + +fn flate_dict() -> Dict { + vec![(b"Filter".to_vec(), Obj::Name(b"FlateDecode".to_vec()))] +} + +fn varied(len: usize) -> Vec { + (0..len).map(|i| u8::try_from(i * i % 251).unwrap_or(0)).collect() +} + +#[test] +fn a_valid_flate_stream_round_trips() { + let raw = varied(4000); + let packed = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6); + let mut budget = Budget::default(); + assert_eq!(decode(&flate_dict(), &packed, &mut budget), Some(raw.clone())); + assert_eq!(budget.decoded, raw.len()); + assert!(!budget.exhausted); +} + +#[test] +fn a_truncated_flate_stream_yields_only_the_bytes_it_inflated() { + let raw = varied(4000); + let packed = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6); + let cut = packed.get(..packed.len() * 3 / 4).unwrap_or_default(); + let mut budget = Budget::default(); + let got = decode(&flate_dict(), cut, &mut budget).unwrap_or_default(); + assert!(!got.is_empty() && got.len() < raw.len()); + assert!(raw.starts_with(&got), "a prefix of the data, with no zero padding"); + assert!(!budget.exhausted); +} + +#[test] +fn a_stream_rejected_before_any_output_is_none_not_zeros() { + // A zlib header, then a reserved deflate block type: nothing is ever inflated, and the + // bare-deflate retry rejects it too. + let bad = [0x78, 0x9c, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff]; + let mut budget = Budget::default(); + assert!(decode(&flate_dict(), &bad, &mut budget).is_none()); + assert_eq!(budget.decoded, 0); + assert!(!budget.exhausted); +} + +#[test] +fn a_bare_deflate_stream_still_decodes_through_the_retry() { + let raw = varied(500); + let packed = miniz_oxide::deflate::compress_to_vec(&raw, 6); + let mut budget = Budget::default(); + assert_eq!(decode(&flate_dict(), &packed, &mut budget), Some(raw)); +} + +#[test] +fn an_over_limit_flate_stream_sets_exhausted() { + let packed = miniz_oxide::deflate::compress_to_vec_zlib(&vec![7u8; 4096], 6); + let mut budget = Budget { decoded: MAX_DECODED - 100, ..Budget::default() }; + assert!(decode(&flate_dict(), &packed, &mut budget).is_none()); + assert!(budget.exhausted); + assert_eq!(budget.decoded, MAX_DECODED - 100); +} diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index c42bccc9..f25f0cfb 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -429,6 +429,9 @@ fn windows_jobs_reach_the_spooler() { } else { None }; + // The Print dialog's default, an explicit one-sided request, on a queue with no duplex unit. + let simplex_only = listed.as_ref().is_some_and(|p| p.two_sided == Some(false)); + let one_sided = simplex_only.then(|| spool::submit(&mixed, &Job { duplex: Some(Duplex::Off), title: "simplex".into(), ..job.clone() })); let tabloid = test_pdf(&[TABLOID, LETTER], false); let shrink = Job { printer: Some(name.into()), scaling: Scaling::ShrinkToFit, title: "shrink".into(), ..Job::default() }; let shrunk = spool::submit(&tabloid, &shrink); @@ -451,12 +454,16 @@ fn windows_jobs_reach_the_spooler() { if let Some(refused) = duplex_refused { assert!(matches!(refused, Err(Error::Spool(ref m)) if m.contains("simplex/duplex") && m.contains("nothing was sent")), "{refused:?}"); } + if let Some(one_sided) = one_sided { + assert_eq!(one_sided.as_deref(), Ok("spooled 3 sheet(s)"), "one-sided is what a simplex-only queue does: {one_sided:?}"); + } assert_eq!(*processed.borrow(), [(0, 3), (1, 3), (2, 3), (3, 3)]); assert!(duplex_progress.is_empty(), "driver refusal before submission has no progress"); assert_eq!(shrunk.as_deref(), Ok("spooled 2 sheet(s)"), "{shrunk:?}"); assert!(matches!(exact_refused, Err(Error::Spool(ref m)) if m.contains("one paper size")), "{exact_refused:?}"); assert!(refused_progress.is_empty(), "rejection before a job starts has no progress: {refused_progress:?}"); - assert_eq!(queued.as_deref().map(str::trim), Ok("mixed.pdf 3|shrink.pdf 2"), "one job each, all sheets, in the paused queue: {queued:?}"); + let expected = if simplex_only { "mixed.pdf 3|simplex.pdf 3|shrink.pdf 2" } else { "mixed.pdf 3|shrink.pdf 2" }; + assert_eq!(queued.as_deref().map(str::trim), Ok(expected), "one job each, all sheets, in the paused queue: {queued:?}"); assert!(cleaned.is_ok(), "the test printer is removed again: {cleaned:?}"); // Asking for a printer that isn't there is an error, never a job on another one. let missing = spool::submit(&mixed, &Job { printer: Some(name.into()), ..Job::default() }); diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 5f8c914b..ade33dc8 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -43,14 +43,15 @@ pub(crate) fn ticket_xml(job: &Job, landscape: bool) -> String { } }; let collate = if job.collate { "Collated" } else { "Uncollated" }; - let color = if job.grayscale { "Monochrome" } else { "Color" }; + // Only grayscale is asked for. Without it the ticket says nothing about colour, so a black and + // white chosen in the driver's own preferences (Properties…) still applies (storytold/pdfcraft#2422). + let color = if job.grayscale { "\n " } else { "" }; let copies = job.copies.clamp(1, 999); format!( r#" {copies} - {duplex} - + {duplex}{color} "# @@ -171,10 +172,97 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo Ok(size) } +/// What a driver's PrintCapabilities offer for the options [`verify_job_options`] checks. A +/// field is `None` when the capabilities couldn't be read; that option is then checked strictly. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Offered { + /// The two-sided feature (`JobDuplexAllDocumentsContiguously`) has any option. + pub duplex: Option, + /// The colour feature (`PageOutputColor`) has an option other than Monochrome and Grayscale. + pub color: Option, + /// The collation feature (`DocumentCollate`) has a Collated option. + pub collate: Option, +} + +/// What these PrintCapabilities offer ([`Offered`]); all unknown when the document is not +/// PrintCapabilities. +pub(crate) fn offered(capabilities_xml: &str) -> Offered { + let Ok(document) = roxmltree::Document::parse(capabilities_xml) else { return Offered::default() }; + let root = document.root_element(); + if !root.has_tag_name((NS_PSF, "PrintCapabilities")) { + return Offered::default(); + } + let options = |name: &'static str| { + root.children() + .filter(move |n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, name)) + .flat_map(|f| f.children().filter(|n| n.has_tag_name((NS_PSF, "Option")))) + }; + Offered { + // The schema's two duplex features are alternatives; a driver offers one or the other. + duplex: Some(options("JobDuplexAllDocumentsContiguously").chain(options("DocumentDuplex")).next().is_some()), + // A vendor's own option might be colour, so only the two schema keywords count as not. + color: Some(options("PageOutputColor").any(|n| !named(n, "Monochrome") && !named(n, "Grayscale"))), + collate: Some(options("DocumentCollate").any(|n| named(n, "Collated"))), + } +} + +/// Whether a `pages`-page job asks for collated copies. +pub(crate) fn collated_copies(job: &Job, pages: usize) -> bool { + job.collate && job.copies.clamp(1, 999) > 1 && pages > 1 +} + +/// Whether collated copies are made by repeating the document in one job with one copy on the +/// ticket: the job asks for them and the driver can't collate. +pub(crate) fn repeats_collated(job: &Job, pages: usize, offered: Offered) -> bool { + collated_copies(job, pages) && offered.collate == Some(false) +} + +/// Whether a repeated job's validated one-copy ticket ([`crate::sheets::Repeat`]) prints on +/// both sides; `None` when the copies can't share one job and are printed as a job each: a +/// duplex option other than the schema's (the blank backs would be guessed), several pages per +/// sheet, binding (booklets and offset stacking included) or stapling, which would treat every +/// copy as one document. +pub(crate) fn one_job_sides(xml: &str) -> Option { + let document = roxmltree::Document::parse(xml).ok()?; + let features = |names: &'static [&'static str]| { + document.root_element().children().filter(move |n| n.has_tag_name((NS_PSF, "Feature")) && names.iter().any(|name| named(*n, name))) + }; + fn option<'a, 'i>(feature: roxmltree::Node<'a, 'i>) -> Option> { + feature.children().find(|n| n.has_tag_name((NS_PSF, "Option"))) + } + let mut two_sided = false; + for feature in features(&["JobDuplexAllDocumentsContiguously", "DocumentDuplex"]) { + match option(feature) { + Some(o) if named(o, "OneSided") => {} + Some(o) if named(o, "TwoSidedLongEdge") || named(o, "TwoSidedShortEdge") => two_sided = true, + _ => return None, + } + } + if features(&["JobNUpAllDocumentsContiguously", "DocumentNUp"]).any(|f| integer(f, "ScoredProperty", "PagesPerSheet").is_some_and(|n| n > 1)) { + return None; + } + if features(&["JobBindAllDocuments", "DocumentBinding", "JobStapleAllDocuments", "DocumentStaple"]) + .any(|f| !option(f).is_some_and(|o| named(o, "None"))) + { + return None; + } + Some(two_sided) +} + /// Confirm explicit job options survived driver validation. Collation only changes output /// when both the document and copy count exceed one. Without a duplex request the queue's -/// default applies; without forced grayscale the driver's normal color policy applies. -pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<(), Error> { +/// default applies; without a grayscale request the driver's own colour setting applies. +/// +/// A driver that offers an option ([`Offered`]) and returned a ticket without the requested +/// value dropped it, and the job is refused. A driver without the feature is accepted where it +/// can only do what was asked: one-sided on a driver with no two-sided feature, grayscale on one +/// with no colour option, uncollated copies on one that can't collate. Two-sided on a simplex +/// driver is refused, and so are collated copies on a driver that can't collate: Windows doesn't +/// document collating for such a driver (the standard XPS filters make the copies, and leave +/// collated copies to the device only when its GPD or PPD has a Collate command). The job then +/// asks for one copy and repeats the document instead ([`repeats_collated`]), in one job or a +/// job per copy ([`one_job_sides`]). An unknown offer (`None`) is checked strictly. +pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Offered) -> Result<(), Error> { let document = roxmltree::Document::parse(xml).map_err(spool_error)?; let root = document.root_element(); let option = |name: &str| { @@ -188,7 +276,15 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<( return Err(refused("copy count")); } if copies > 1 && pages > 1 && !option("DocumentCollate").is_some_and(|n| named(n, if job.collate { "Collated" } else { "Uncollated" })) { - return Err(refused("collation")); + match (job.collate, offered.collate) { + (false, Some(false)) => {} + (true, Some(false)) => { + return Err(spool_error( + "the printer's driver can't collate copies; nothing was sent (turn collation off, or print one copy at a time)", + )); + } + _ => return Err(refused("collation")), + } } if let Some(duplex) = job.duplex { let wanted = match duplex { @@ -196,11 +292,13 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<( Duplex::LongEdge => "TwoSidedLongEdge", Duplex::ShortEdge => "TwoSidedShortEdge", }; - if !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) { + let one_sided_only = duplex == Duplex::Off && offered.duplex == Some(false); + if !one_sided_only && !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) { return Err(refused("simplex/duplex setting")); } } - if job.grayscale && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) { + let monochrome_only = offered.color == Some(false); + if job.grayscale && !monochrome_only && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) { return Err(refused("grayscale")); } Ok(()) @@ -256,10 +354,11 @@ mod tests { assert!(!xml.contains("<&>"), "the title never enters the XML"); assert!(roxmltree::Document::parse(&xml).is_ok()); let xml = ticket_xml(&Job { copies: 5000, ..Job::default() }, false); - for value in [">999<", "psk:Collated", "psk:Color", "psk:Portrait"] { + for value in [">999<", "psk:Collated", "psk:Portrait"] { assert!(xml.contains(value), "missing {value}"); } assert!(!xml.contains("JobDuplex"), "no duplex asked: the queue's default applies"); + assert!(!xml.contains("PageOutputColor"), "no grayscale asked: the driver's own colour setting applies"); assert!(ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false).contains("psk:OneSided")); assert!(ticket_xml(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, false).contains("psk:TwoSidedLongEdge")); assert!(!ticket_xml(&Job { printer: Some("Office \"east\" <1>".into()), ..Job::default() }, false).contains("east")); @@ -301,31 +400,159 @@ mod tests { fn a_driver_that_drops_explicit_job_options_is_refused() { let job = Job { copies: 2, duplex: Some(Duplex::LongEdge), grayscale: true, ..Job::default() }; let xml = ticket_xml(&job, false); - assert!(verify_job_options(&xml, &job, 3).is_ok()); + assert!(verify_job_options(&xml, &job, 3, Offered::default()).is_ok()); for (from, to) in [(">2<", ">1<"), ("psk:Collated", "psk:Uncollated"), ("psk:TwoSidedLongEdge", "psk:OneSided"), ("psk:Monochrome", "psk:Color")] { - assert!(verify_job_options(&xml.replace(from, to), &job, 3).is_err(), "{from}"); + assert!(verify_job_options(&xml.replace(from, to), &job, 3, Offered::default()).is_err(), "{from}"); } - assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3).is_err(), "namespace matters"); - assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3).is_ok()); + assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3, Offered::default()).is_err(), "namespace matters"); + assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3, Offered::default()).is_ok()); for feature in ["DocumentCollate", "JobDuplexAllDocumentsContiguously", "PageOutputColor"] { let start = xml.find(&format!("").unwrap() + "".len(); let mut dropped = xml.clone(); dropped.replace_range(start..end, ""); - assert!(verify_job_options(&dropped, &job, 3).is_err(), "{feature}"); + assert!(verify_job_options(&dropped, &job, 3, Offered::default()).is_err(), "{feature}"); if feature == "DocumentCollate" { - assert!(verify_job_options(&dropped, &job, 1).is_ok(), "one page has no collating order"); + assert!(verify_job_options(&dropped, &job, 1, Offered::default()).is_ok(), "one page has no collating order"); } } let default = Job::default(); let xml = ticket_xml(&default, false); - assert!(verify_job_options(&xml, &default, 3).is_ok()); + assert!(verify_job_options(&xml, &default, 3, Offered::default()).is_ok()); let simplex = Job { duplex: Some(Duplex::Off), ..Job::default() }; - assert!(verify_job_options(&ticket_xml(&simplex, false), &simplex, 3).is_ok()); - assert!(verify_job_options(&xml, &simplex, 3).is_err(), "explicit simplex must be confirmed"); - assert!(verify_job_options(" String { + let feature = |name: &str, options: &[&str]| { + if options.is_empty() { + return String::new(); + } + let options: String = options.iter().map(|o| format!(r#""#)).collect(); + format!(r#"{options}"#) + }; + let duplex = feature("JobDuplexAllDocumentsContiguously", if duplex { &["psk:OneSided", "psk:TwoSidedLongEdge"] } else { &[] }); + format!( + r#" +{}{duplex}{} +"#, + feature("DocumentCollate", collations), + feature("PageOutputColor", colors) + ) + } + + const COLOR: &[&str] = &["psk:Color", "psk:Monochrome"]; + const COLLATE: &[&str] = &["psk:Collated", "psk:Uncollated"]; + + /// The ticket without one of its features, as a driver that doesn't know it returns it. + fn without(xml: &str, feature: &str) -> String { + let start = xml.find(&format!("").unwrap() + "".len(); + let mut dropped = xml.to_owned(); + dropped.replace_range(start..end, ""); + dropped + } + + #[test] + fn a_driver_without_a_duplex_feature_prints_one_side_and_is_not_refused_for_it() { + let offered = |duplex| offered(&capabilities_xml(duplex, COLOR, COLLATE)); + assert_eq!(offered(true), Offered { duplex: Some(true), color: Some(true), collate: Some(true) }); + assert_eq!(offered(false).duplex, Some(false)); + assert_eq!(super::offered(""), Offered::default(), "another namespace is not capabilities"); + assert_eq!(super::offered(""# + ); + assert_eq!(super::offered(&empty).duplex, Some(false), "a feature with no option offers nothing"); + let document_duplex = format!( + r#""# + ); + assert_eq!(super::offered(&document_duplex).duplex, Some(true), "DocumentDuplex is a duplex feature too"); + + let one_sided = Job { duplex: Some(Duplex::Off), ..Job::default() }; + let two_sided = Job { duplex: Some(Duplex::LongEdge), ..Job::default() }; + let kept = ticket_xml(&one_sided, false); + // The ticket a driver with no duplex feature returns: the setting is gone. + let none_kept = ticket_xml(&Job::default(), false); + + // The Print dialog's defaults (duplex Off) on a simplex-only queue. + assert!(verify_job_options(&none_kept, &one_sided, 3, offered(false)).is_ok()); + // Offered and kept. + assert!(verify_job_options(&kept, &one_sided, 3, offered(true)).is_ok()); + // Offered but the ticket lost it: the driver dropped the choice. + assert!(verify_job_options(&none_kept, &one_sided, 3, offered(true)).is_err()); + // Two-sided never rests on the missing feature. + assert!(verify_job_options(&none_kept, &two_sided, 3, offered(false)).is_err()); + // Unknown capabilities stay strict. + assert!(verify_job_options(&none_kept, &one_sided, 3, Offered::default()).is_err()); + // The other options are still checked on a simplex-only queue. + let copies = Job { copies: 2, duplex: Some(Duplex::Off), ..Job::default() }; + assert!(verify_job_options(&none_kept, &copies, 3, offered(false)).is_err(), "copy count"); + let gray = Job { grayscale: true, duplex: Some(Duplex::Off), ..Job::default() }; + assert!(verify_job_options(&none_kept, &gray, 3, offered(false)).is_err(), "grayscale"); + } + + #[test] + fn a_driver_without_colour_prints_grayscale_and_is_not_refused_for_it() { + let colors = |colors: &[&str]| offered(&capabilities_xml(true, colors, COLLATE)).color; + assert_eq!(colors(COLOR), Some(true)); + assert_eq!(colors(&["psk:Monochrome"]), Some(false), "monochrome-only"); + assert_eq!(colors(&["psk:Grayscale", "psk:Monochrome"]), Some(false)); + assert_eq!(colors(&[]), Some(false), "no colour feature"); + assert_eq!(colors(&["psk:Monochrome", "ns0000:Vivid"]), Some(true), "a vendor's option may be colour"); + + let gray = Job { grayscale: true, ..Job::default() }; + let kept = ticket_xml(&gray, false); + // A driver without the feature returns a ticket without it. + let dropped = without(&kept, "PageOutputColor"); + let color = kept.replace("psk:Monochrome", "psk:Color"); + let caps = |colors: &[&str]| offered(&capabilities_xml(true, colors, COLLATE)); + + assert!(verify_job_options(&dropped, &gray, 3, caps(&["psk:Monochrome"])).is_ok()); + assert!(verify_job_options(&dropped, &gray, 3, caps(&[])).is_ok()); + assert!(verify_job_options(&kept, &gray, 3, caps(COLOR)).is_ok()); + // A colour driver that dropped or changed the request. + assert!(verify_job_options(&color, &gray, 3, caps(COLOR)).is_err()); + assert!(verify_job_options(&dropped, &gray, 3, caps(COLOR)).is_err()); + assert!(verify_job_options(&dropped, &gray, 3, Offered::default()).is_err(), "unknown stays strict"); + } + + #[test] + fn copies_on_a_driver_that_cannot_collate_are_accepted_only_uncollated() { + let caps = |collations: &[&str]| offered(&capabilities_xml(true, COLOR, collations)); + assert_eq!(caps(COLLATE).collate, Some(true)); + assert_eq!(caps(&["psk:Uncollated"]).collate, Some(false)); + assert_eq!(caps(&[]).collate, Some(false)); + + let collated = Job { copies: 2, collate: true, ..Job::default() }; + let uncollated = Job { copies: 2, collate: false, ..Job::default() }; + let dropped = |job: &Job| without(&ticket_xml(job, false), "DocumentCollate"); + + // Uncollated copies are what a driver without collation prints. + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, caps(&[])).is_ok()); + assert!(verify_job_options(&ticket_xml(&uncollated, false), &uncollated, 3, caps(&["psk:Uncollated"])).is_ok()); + // Collated copies are refused, saying why. + let refused = verify_job_options(&dropped(&collated), &collated, 3, caps(&[])).unwrap_err(); + assert!(refused.to_string().contains("can't collate"), "{refused}"); + // A driver that collates and dropped or changed the request. + assert!(verify_job_options(&dropped(&collated), &collated, 3, caps(COLLATE)).is_err()); + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, caps(COLLATE)).is_err()); + let changed = ticket_xml(&collated, false).replace("psk:Collated", "psk:Uncollated"); + assert!(verify_job_options(&changed, &collated, 3, caps(COLLATE)).is_err()); + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, Offered::default()).is_err(), "unknown stays strict"); + // The job repeats the document instead of asking for collated copies. + assert!(repeats_collated(&collated, 3, caps(&[]))); + assert!(!repeats_collated(&collated, 3, caps(COLLATE)) && !repeats_collated(&collated, 3, Offered::default())); + assert!(!repeats_collated(&uncollated, 3, caps(&[])) && !repeats_collated(&collated, 1, caps(&[]))); + assert!(!repeats_collated(&Job { copies: 1, ..collated.clone() }, 3, caps(&[]))); + // One page or one copy has no collating order. + assert!(verify_job_options(&dropped(&collated), &collated, 1, caps(&[])).is_ok()); } #[test] @@ -404,4 +631,27 @@ mod tests { assert_eq!(job_filename(""), "Print.pdf"); assert_eq!(job_filename("Отчёт"), "Отчёт.pdf"); } + + #[test] + fn a_repeated_job_reads_its_sides_and_leaves_layouts_that_mix_copies_to_separate_jobs() { + let xml = ticket_xml(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, false); + assert_eq!(one_job_sides(&xml), Some(true)); + assert_eq!(one_job_sides(&xml.replace("JobDuplexAllDocumentsContiguously", "DocumentDuplex")), Some(true)); + assert_eq!(one_job_sides(&ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false)), Some(false)); + assert_eq!(one_job_sides(&ticket_xml(&Job::default(), false)), Some(false), "no duplex feature"); + assert_eq!(one_job_sides(&xml.replace("psk:TwoSidedLongEdge", "vendor:Duplex")), None, "an option it can't read"); + let with = |feature: &str| xml.replace("", &format!("{feature}")); + let n_up = |n: u32| { + with(&format!( + r#"{n}"# + )) + }; + assert_eq!(one_job_sides(&n_up(1)), Some(true)); + assert_eq!(one_job_sides(&n_up(2)), None); + for feature in ["JobBindAllDocuments", "JobStapleAllDocuments", "DocumentStaple"] { + let finishing = |option: &str| with(&format!(r#""#)); + assert_eq!(one_job_sides(&finishing("None")), Some(true), "{feature}"); + assert_eq!(one_job_sides(&finishing("StapleTopLeft")), None, "{feature}"); + } + } } diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 45a23aa7..b99d1cf0 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -45,6 +45,15 @@ fn two_sided(device: &PrinterDevice) -> Option { Some(capabilities.options_for_feature(duplex).any(|o| o.name.as_ref().is_some_and(|n| n.local_name.starts_with("TwoSided")))) } +/// What the driver's PrintCapabilities offer for the job options [`ticket::verify_job_options`] +/// checks; all unknown when they can't be read. +fn driver_offers(device: &PrinterDevice) -> ticket::Offered { + PrintCapabilities::fetch_xml_for_ticket(device, None) + .ok() + .and_then(|xml| String::from_utf8(xml).ok()) + .map_or_else(ticket::Offered::default, |xml| ticket::offered(&xml)) +} + /// The queue the job goes to: the one asked for, else the default. Never another one: a job /// for a queue that has gone must not come out of a different printer. fn select_device(devices: Vec, requested: Option<&str>, default: Option<&str>) -> Result { @@ -142,9 +151,32 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM let read = &read; // The worker is joined before `dir` is dropped: the spooler reads the file. let result = crate::progress::run(progress, move |sender| { - let (ticket, paper) = driver_ticket(&device, job, first, landscape)?; + let (mut ticket, mut paper) = driver_ticket(&device, job, first, landscape)?; let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; - ticket::verify_job_options(xml, job, count)?; + let mut repeat = None; + // Jobs of one copy each, for collated copies that can't be one job. + let mut jobs = 1; + // The capabilities are read when the ticket lacks a requested value: they tell a driver + // that dropped it from one that never had the feature. Unreadable stays strict. A driver + // can also keep a collation it doesn't offer, so collated copies always read them. + let checked = ticket::verify_job_options(xml, job, count, ticket::Offered::default()); + if checked.is_err() || ticket::collated_copies(job, count) { + let offered = driver_offers(&device); + if ticket::repeats_collated(job, count, offered) { + let single = Job { copies: 1, ..job.clone() }; + (ticket, paper) = driver_ticket(&device, &single, first, landscape)?; + let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; + ticket::verify_job_options(xml, &single, count, offered)?; + // Two-sided as the driver will print it, the queue's default included. + let copies = job.copies.clamp(1, 999); + match ticket::one_job_sides(xml) { + Some(two_sided) => repeat = Some(sheets::Repeat { copies, two_sided }), + None => jobs = copies, + } + } else if checked.is_err() { + ticket::verify_job_options(xml, job, count, offered)?; + } + } let (fit, paper) = match job.scaling { Scaling::Exact => (Fit::Exact, first), Scaling::ShrinkToFit => { @@ -153,29 +185,50 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM } }; let edits = sheets::plan(&read.sheets, paper, fit)?; - let updated = if edits.is_empty() { None } else { Some(read.update(&edits)?) }; + let mut updated = None; + if let Some(r) = repeat { + updated = read.repeated(&edits, r)?; + if updated.is_none() { + (repeat, jobs) = (None, r.copies); + } + } + if updated.is_none() && !edits.is_empty() { + updated = Some(read.update(&edits)?); + } let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(written)?; file.write_all(updated.as_deref().unwrap_or(pdf)).map_err(written)?; drop(file); - WinPdfPrinter::new(device) - .print_with_progress(&path, ticket, |processed, total| { - // Page acceptance is distinct from terminal job success. The callback - // drains on the submitter's thread, so it can retain non-Send UI state. - let _ = sender.send((processed as usize, total as usize)); - }) - .map_err(|e| { - let mut message = e.to_string(); - let mut cause = std::error::Error::source(&e); - while let Some(source) = cause { - message.push_str(&format!(": {source}")); - cause = source.source(); - } - spool_error(message) - }) + let printer = WinPdfPrinter::new(device); + let jobs = jobs as usize; + for copy in 0..jobs { + printer + .print_with_progress(&path, ticket.clone(), |processed, total| { + // Page acceptance is distinct from terminal job success. The callback + // drains on the submitter's thread, so it can retain non-Send UI state. + let (processed, total) = (processed as usize, total as usize); + let _ = sender.send((copy * total + processed, jobs * total)); + }) + .map_err(|e| { + let mut message = e.to_string(); + let mut cause = std::error::Error::source(&e); + while let Some(source) = cause { + message.push_str(&format!(": {source}")); + cause = source.source(); + } + if copy > 0 { + message = format!("{copy} of {jobs} copies were sent, each as a job of its own, and the next was not: {message}"); + } + spool_error(message) + })?; + } + Ok((repeat, jobs)) }); drop(dir); - result?; - Ok(format!("spooled {count} sheet(s)")) + Ok(match result? { + (Some(repeat), _) => format!("spooled {count} sheet(s) {} times, collated", repeat.copies), + (None, jobs) if jobs > 1 => format!("spooled {count} sheet(s) as {jobs} jobs, one per collated copy"), + _ => format!("spooled {count} sheet(s)"), + }) } #[cfg(test)] @@ -199,7 +252,7 @@ mod tests { let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); let job = Job { copies: 2, ..Job::default() }; let (ticket, paper) = driver_ticket(&device, &job, (612.0, 792.0), false).unwrap(); - ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1).unwrap(); + ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1, ticket::Offered::default()).unwrap(); assert_eq!(paper, (215_900, 279_400)); eprintln!("unprintable margin: {} pt", imageable_margin(&device, &ticket)); if let Ok(path) = std::env::var("CRAFT_PRINT_TEST_TICKET_OUTPUT") { @@ -212,7 +265,7 @@ mod tests { ("DocumentCollate", "Collated", Job { copies: 2, ..Job::default() }), ] { let (ticket, _) = driver_ticket(&device, &requested, (612.0, 792.0), false).unwrap(); - let retained = ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &requested, 3); + let retained = ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &requested, 3, ticket::Offered::default()); eprintln!("{feature}={wanted}: {retained:?}"); let offered = capabilities .options_for_feature(OwnedName::qualified(feature, NS_PSK, Some("psk"))) @@ -242,4 +295,30 @@ mod tests { eprintln!("{sent:?}"); assert!(sent.is_ok()); } + + /// A colour chosen in the driver's own preferences (Properties…) reaches the job when the job + /// doesn't ask for grayscale: the validated ticket has the colour of the user's preferences, + /// whatever they are (storytold/pdfcraft#2422; it was always Color before). Set the queue to + /// black and white in its preferences to see the case that failed. Sends no job. + #[test] + #[ignore = "needs CRAFT_PRINT_TEST_PRINTER naming an installed printer; sends no job"] + fn the_preferences_colour_reaches_a_job_without_grayscale() { + fn colour(ticket: &PrintTicket) -> Option { + let xml = std::str::from_utf8(ticket.get_xml()).ok()?; + let document = roxmltree::Document::parse(xml).ok()?; + let feature = document + .root_element() + .children() + .find(|n| n.has_tag_name((ticket::NS_PSF, "Feature")) && n.attribute("name").is_some_and(|a| a.ends_with(":PageOutputColor")))?; + feature.children().find(|n| n.has_tag_name((ticket::NS_PSF, "Option")))?.attribute("name").map(str::to_owned) + } + let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap(); + let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); + let mut builder = PrintTicketBuilder::new(&device).unwrap(); + builder.merge(PrintTicket::from_xml(winprint::ticket::DEFAULT_PRINT_TICKET_XML.as_bytes().to_vec())).unwrap(); + let preferences = colour(&builder.build().unwrap()); + eprintln!("the preferences' colour: {preferences:?}"); + let (ticket, _) = driver_ticket(&device, &Job::default(), (595.28, 841.89), false).unwrap(); + assert_eq!(colour(&ticket), preferences, "a job without grayscale keeps the preferences' colour"); + } } diff --git a/crates/quit/src/lib.rs b/crates/quit/src/lib.rs index b57928c9..5151c47d 100644 --- a/crates/quit/src/lib.rs +++ b/crates/quit/src/lib.rs @@ -65,12 +65,16 @@ pub use close::{QUIT_MENU_ID, on_close_request, on_menu_event}; /// For apps without an app menu of their own. It replaces the whole menu and sets muda's /// process-wide menu-event handler, so an app that builds its own muda menu should not call it: /// give that menu's Quit item [`QUIT_MENU_ID`] and pass its events to [`on_menu_event`] instead. +/// muda keeps the first handler set, so this and `craft_menu::mac::install` are alternatives: an +/// app calls one of them. `craft_menu::mac::install_app_menu` builds the same menu on craft-menu's +/// menu event handler, which its full menu bar shares. /// /// On error winit's menu stays: the app still runs, but ⌘Q quits without asking. The app decides /// whether that is fatal; `?` passes it out of the eframe app creator. /// /// Other platforms: does nothing and returns `Ok`. #[cfg(feature = "menu")] +#[deprecated(note = "use craft_menu::mac::install_app_menu")] pub fn install_app_menu(ctx: &egui::Context, app_name: &str) -> Result<(), Box> { #[cfg(target_os = "macos")] menu::install(ctx, app_name)?; diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml new file mode 100644 index 00000000..ae32a115 --- /dev/null +++ b/crates/text/Cargo.toml @@ -0,0 +1,156 @@ +[package] +name = "craft-text" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Text processing below the Craft composer: hyphenation with a bundled US English dictionary and patterns, other languages through hypher" + +[lints] +workspace = true + +[dependencies] +hypher = { version = "0.1.8", default-features = false, features = ["alloc"], optional = true } +miniz_oxide = { version = "0.8", optional = true } +unicode-general-category = { version = "1.1", optional = true } +unicode-linebreak = { version = "0.1.5", optional = true } +unicode-segmentation = { version = "1.13", optional = true } +icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"], optional = true } +unicode-bidi = { version = "0.3.18", optional = true } +unicode-bidi-mirroring = { version = "0.4.0", optional = true } + +[dev-dependencies] +unicode-segmentation = "1.13" + +[[example]] +name = "hyphgen" +required-features = ["generator"] + +[features] +default = ["hyphen", "bidi", "segment", "linebreak"] +# Hyphenation points; English (bundled dictionary and patterns) needs no further feature. +hyphen = ["dep:hypher", "dep:miniz_oxide", "dep:unicode-general-category"] +# Bidirectional text: embedding levels, line reordering and mirroring (UAX #9). +bidi = ["dep:unicode-bidi", "dep:unicode-bidi-mirroring"] +# Line breaking on a box/glue/penalty model (Knuth–Plass, greedy, balanced); no dependencies. +linebreak = [] +# The dictionary writer (`hyphen::dict`) and pattern trainer (`hyphen::patgen`) used by +# examples/hyphgen.rs. +generator = ["hyphen"] +# Line-break opportunities (UAX #14 with kinsoku) and grapheme-cluster emergency breaks; Thai, Lao, +# Khmer and Burmese words need their language feature. +segment = ["dep:unicode-linebreak", "dep:unicode-segmentation"] +# With `thai`, `lao`, `khmer` or `burmese`: find their words with ICU4X's LSTM models instead of its +# dictionaries. Much smaller (for web builds), slower to run, and its words differ slightly. +lstm = ["segment", "icu_segmenter?/lstm"] +# Each feature below adds the data for one more language: for `hyphen`, hypher's patterns (see +# NOTICE for their licenses; Slovak is `slovak-gpl`, outside `all-languages`); for `segment`, ICU4X's word data for Thai, Lao, Khmer and Burmese +# (one shared data set, Unicode License v3). +all-languages = [ + "afrikaans", + "albanian", + "assamese", + "belarusian", + "bengali", + "bulgarian", + "burmese", + "catalan", + "croatian", + "czech", + "danish", + "dutch", + "estonian", + "finnish", + "french", + "galician", + "georgian", + "german", + "greek", + "gujarati", + "hindi", + "hungarian", + "icelandic", + "italian", + "kannada", + "khmer", + "kurmanji", + "lao", + "latin", + "lithuanian", + "malayalam", + "marathi", + "mongolian", + "norwegian", + "oriya", + "panjabi", + "polish", + "portuguese", + "russian", + "sanskrit", + "serbian", + "slovenian", + "spanish", + "swedish", + "tamil", + "telugu", + "thai", + "turkish", + "turkmen", + "ukrainian", +] +afrikaans = ["hyphen", "hypher/afrikaans"] +albanian = ["hyphen", "hypher/albanian"] +assamese = ["hyphen", "hypher/assamese"] +belarusian = ["hyphen", "hypher/belarusian"] +bengali = ["hyphen", "hypher/bengali"] +bulgarian = ["hyphen", "hypher/bulgarian"] +catalan = ["hyphen", "hypher/catalan"] +croatian = ["hyphen", "hypher/croatian"] +czech = ["hyphen", "hypher/czech"] +danish = ["hyphen", "hypher/danish"] +dutch = ["hyphen", "hypher/dutch"] +estonian = ["hyphen", "hypher/estonian"] +finnish = ["hyphen", "hypher/finnish"] +french = ["hyphen", "hypher/french"] +galician = ["hyphen", "hypher/galician"] +georgian = ["hyphen", "hypher/georgian"] +german = ["hyphen", "hypher/german"] +greek = ["hyphen", "hypher/greek"] +gujarati = ["hyphen", "hypher/gujarati"] +hindi = ["hyphen", "hypher/hindi"] +hungarian = ["hyphen", "hypher/hungarian"] +icelandic = ["hyphen", "hypher/icelandic"] +italian = ["hyphen", "hypher/italian"] +kannada = ["hyphen", "hypher/kannada"] +kurmanji = ["hyphen", "hypher/kurmanji"] +latin = ["hyphen", "hypher/latin"] +lithuanian = ["hyphen", "hypher/lithuanian"] +malayalam = ["hyphen", "hypher/malayalam"] +marathi = ["hyphen", "hypher/marathi"] +mongolian = ["hyphen", "hypher/mongolian"] +norwegian = ["hyphen", "hypher/norwegian"] +oriya = ["hyphen", "hypher/oriya"] +panjabi = ["hyphen", "hypher/panjabi"] +polish = ["hyphen", "hypher/polish"] +portuguese = ["hyphen", "hypher/portuguese"] +russian = ["hyphen", "hypher/russian"] +sanskrit = ["hyphen", "hypher/sanskrit"] +serbian = ["hyphen", "hypher/serbian"] +# Slovak patterns are under the GPL, version 2 or later (hyph-sk.tex), unlike every other language +# here, so `all-languages` leaves them out. A build that enables this ships GPL-licensed data. Any +# other dependency that enables `hypher/slovak` (hypher's defaults do) embeds them too, through +# feature unification. +slovak-gpl = ["hyphen", "hypher/slovak"] +slovenian = ["hyphen", "hypher/slovenian"] +spanish = ["hyphen", "hypher/spanish"] +swedish = ["hyphen", "hypher/swedish"] +tamil = ["hyphen", "hypher/tamil"] +telugu = ["hyphen", "hypher/telugu"] +turkish = ["hyphen", "hypher/turkish"] +turkmen = ["hyphen", "hypher/turkmen"] +ukrainian = ["hyphen", "hypher/ukrainian"] +burmese = ["segment", "dep:icu_segmenter"] +khmer = ["segment", "dep:icu_segmenter"] +lao = ["segment", "dep:icu_segmenter"] +thai = ["segment", "dep:icu_segmenter"] diff --git a/crates/text/README.md b/crates/text/README.md new file mode 100644 index 00000000..585945b7 --- /dev/null +++ b/crates/text/README.md @@ -0,0 +1,313 @@ +# Craft Text + +`craft-text` holds the text processing that sits below each app's composer: functions that take +plain text and a language and return what layout needs before it shapes and places glyphs. + +| Module | Feature | Provides | +| --- | --- | --- | +| `hyphen` | `hyphen` (default) | Hyphenation points | +| `segment` | `segment` (default) | Line-break opportunities with kinsoku; Thai, Lao, Khmer and Burmese words; grapheme-cluster emergency breaks | +| `bidi` | `bidi` (default) | Bidirectional text: embedding levels, line reordering, mirroring | +| `linebreak` | `linebreak` (default) | Line breaking on a box/glue/penalty model | + +Language features (`spanish`, `german`, …, `all-languages`) add the data for one language to every +module that uses it, and turn those modules on. Build with `default-features = false` to leave +out all modules and data. + +## Hyphenation + +`craft_text::hyphen` finds hyphenation points. It returns byte offsets into the word: a hyphen +may go before the char that starts at each offset. Line breaking, discretionary hyphens and +exception lists stay in each application. + +### English + +English is always available with `hyphen` and needs no language feature. Each word is looked up +in this order: + +1. the bundled dictionary (`Dictionary::en_us`): the Moby Hyphenator II word list, about 160,000 + words. Simple inflections (`'s`, `-s`, `-es`, `-ed`, `-d`) inherit the breaks of their stem; +2. the bundled Liang patterns (`assets/hyphen/en-us.pat`), trained from the same list by the crate's + patgen-style trainer; +3. a small syllable heuristic for words outside the pattern alphabet. + +Case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds are handled; no break is +placed next to an apostrophe or a non-letter. `Dictionary::en_us()` also serves as an English word +list, for example for spelling checks. + +### Other languages + +Other languages use the patterns compiled into [hypher](https://crates.io/crates/hypher) 0.1.8. +Each has a cargo feature named as in hypher (`spanish`, `german`, `norwegian`, …); `all-languages` +enables 46 of them. Patterns for languages whose feature is off are not compiled in. + +Slovak's patterns are under the GPL, version 2 or later; the patterns of every other language are +under a permissive license. craft-text asks for Slovak through the feature `slovak-gpl`, which +`all-languages` leaves out; a build that enables it ships GPL-licensed data. Cargo unifies features, +so another dependency that enables hypher's `slovak` feature (hypher's default features do) embeds +the same data. A build that must exclude it checks its dependency graph +(`cargo tree -e features -i hypher`). + +```rust +use craft_text::hyphen::{Lang, Limits, hyphen_points_in}; + +fn points(word: &str, language: &str) -> Vec { + match Lang::for_language(language) { + Some(lang) => hyphen_points_in(word, &Limits::default(), lang), + None => Vec::new(), // unknown language or feature off: don't hyphenate + } +} +``` + +`Lang::for_language` accepts English language names as InDesign and IDML write them (`Spanish`, +`German: 2006 Reform`, `$ID/Norwegian: Bokmal`) and locale codes (`es`, `es_ES`, `pt-BR`, +`de_DE_2006`). It returns `None` for unknown languages and for languages not compiled in. +`Lang` is `Copy + Eq + Hash`, so it can key a cache of computed points. Its `Patterns` variant +holds an opaque `PatternLang`; build one with `Lang::for_language`. + +### Limits + +`Limits` holds the paragraph's settings: minimum word length, letters before the first and after +the last hyphen, and whether capitalized words are hyphenated. Words in all capitals are never +hyphenated. Limit checks are linear in word length. + +### Regenerating the English data + +`examples/hyphgen.rs` rebuilds `assets/hyphen/en-us.dic` and `assets/hyphen/en-us.pat` from the +Moby list. It needs the `generator` feature, which exposes the dictionary writer (`hyphen::dict`) +and the pattern trainer (`hyphen::patgen`); applications don't need it. + +```sh +curl -LO https://www.gutenberg.org/files/3204/files/mhyph.txt +cargo run --release -p craft-text --features generator --example hyphgen -- mhyph.txt crates/text/assets/hyphen --validate +``` + +## Segmentation + +`craft_text::segment` finds where a line may break. It takes plain text and returns byte offsets; +measuring, choosing breaks and hanging punctuation stay in each application's composer. +Consolidated from vectorcraft, wordcraft, deckcraft and designcraft. + +```rust +use craft_text::segment::{Kinsoku, Options, line_breaks}; + +let options = Options { kinsoku: Kinsoku::Soft, ..Options::default() }; +for b in line_breaks("日本語のテキスト。", &options) { + // A line may end before byte b.offset; it must if b.mandatory. +} +``` + +`line_breaks` starts from UAX #14 (the `unicode-linebreak` crate) and adjusts it: + +- `Options::kinsoku` applies the Japanese and Chinese line-start and line-end rules (JLREQ, + CLREQ) at breaks next to an East Asian character. Text with no East Asian characters gets + UAX #14's breaks unchanged. + - `Hard` (default): UAX #14's breaks, minus those kinsoku forbids. + - `Soft`: as `Hard`, and 々, ー and small kana may start a line. + - `None`: UAX #14's breaks plus a break between any two East Asian characters. +- `Options::korean_words` sets Korean word by word: no break between two Hangul letters or Han + ideographs. Off, Korean breaks between syllables as UAX #14 does. +- Mandatory breaks (newlines and the end of the text) are always kept. + +`Options::for_language(language)` gives the options for a paragraph's BCP 47 language tag, or for +an unknown language (`None`): `korean_words` for Korean, the defaults otherwise. + +When no opportunity leaves a line short enough, `emergency_breaks(text, range)` gives the places +to break the piece `text[range]` anyway: byte offsets into `text` between extended grapheme +clusters (UAX #29, through `unicode-segmentation`). An emoji ZWJ sequence, a flag or a letter with +its marks is never split. + +`kinsoku_allows(before, after, mode)` is the same rule for composers that build their own +opportunity lists. + +### Each app's kinsoku as a `Kinsoku` value + +| App | App setting | `Kinsoku` | Rules the app keeps | +| --- | --- | --- | --- | +| vectorcraft | Kinsoku set None, Hard, Soft | `None`, `Hard`, `Soft` | none | +| wordcraft | `kinsoku` on, off | `Hard`, `None` | `word_wrap` off: Latin words break at any character | +| designcraft | no set (the breaker's own rules); Hard Kinsoku | `Hard` | custom document tables, inseparable pairs, bunri-kinshi, rensuuji, roman word break | +| designcraft | Soft, Simplified Chinese, Traditional Chinese, Korean Kinsoku | `Soft` | as above | +| designcraft | Kinsoku set None | `None` | as above | + +Push-in and push-out priority and hanging punctuation decide how a line fits, not where it may +break, and stay in the composer. deckcraft has no kinsoku of its own (see below). + +### Kinsoku sets for adopters + +The sets are vectorcraft's (`no_line_start`, `soft_line_start`, `no_line_end`), plus these from +the other apps: + +- Can't start a line: 〞 (closing double prime quote), ゛ ゜ ゙ ゚ (spacing sound marks), % ¢ + (postfixed abbreviations, JLREQ cl-13). From wordcraft. +- Can't end a line: $ £ ¥ (prefixed abbreviations, JLREQ cl-12). From wordcraft. + +What changes for each app: + +- vectorcraft: the additions above. Breaks come from UAX #14 rather than "after any CJK + character", so `Hard` and `Soft` also keep every break UAX #14 forbids. Kinsoku no longer + applies between two non-East-Asian characters (a space before an en dash in Latin text may + break). +- wordcraft: its kinsoku on is `Hard`, which also keeps the prolonged sound mark, small kana, + hyphens (‐ – 〜 ゠), ‼ ⁇ ⁈ ⁉ and the Latin closing marks next to East Asian text off the line + start, and « ‘ “ ( [ { off the line end. Its kinsoku off is `None`. Breaking Latin words + anywhere (`word_wrap` off) and the no-break-after-slash rule stay in wordcraft. +- designcraft: its breaker's set is `Hard` without the additions above and without ’ ” 〛 〟 ⦆ + 〜 ~ ゠ ‐ – ‼ ⁇ ⁈ ⁉ », ゕ ゖ, ㇰ–ㇿ and the half-width forms (can't start), « ‘ “ 〚 〝 ⦅ 「 + (can't end). Its Soft set also lets ゝ ゞ ヽ ヾ 〻 start a line; here they stay forbidden, as in + InDesign's Soft set. Document kinsoku tables (`Kinsoku` in `doc/cjk.rs`) stay in designcraft. + `korean_char_breaks: false` is `korean_words: true`. +- deckcraft: `Hard` is its UAX #14 breaks minus those kinsoku forbids. `None` adds breaks between + East Asian characters, so no mode returns plain UAX #14 for East Asian text. + +### Thai, Lao, Khmer and Burmese + +UAX #14 gives these scripts class SA and leaves breaking inside them to a dictionary. Without the +script's language feature, `line_breaks` treats a run of them as one word: it never breaks inside +the run, only at its edges where UAX #14 allows a break (after a space, for example). A long Thai +sentence with no spaces is one unbreakable piece, so the composer needs an emergency break for it. +The features `thai`, `lao`, `khmer` and `burmese` split runs of that +script into words with ICU4X's segmenter (`icu_segmenter`). Any one of them links the data for all +four; the default build has no ICU data. The data is under the Unicode License v3 (see NOTICE). + +The segmenter has two models, chosen by the `lstm` feature: + +| Model | Feature | Added to a wasm32 release build | Gzipped | +| --- | --- | --- | --- | +| Dictionaries (desktop default) | none | 1.86 MB | 0.89 MB | +| LSTM models (for web) | `lstm` | 0.42 MB | 0.33 MB | + +Measured on a minimal `line_breaks` example, against the same example with `segment` alone. The +LSTM models are slower to run and split a few words differently, mostly compounds broken between +syllables (Lao ລາດ|ຊະການ, Burmese မြန်|မာ), which are still valid line breaks. +## Bidi + +`craft_text::bidi` runs the Unicode Bidirectional Algorithm (UAX #9, through +[unicode-bidi](https://crates.io/crates/unicode-bidi)) on document text. It works on plain text and +needs no fonts; shaping, glyph mirroring and caret placement stay in each application. + +The pipeline is the one UAX #9 prescribes: + +1. `Paragraph::new(text, Direction::Rtl)` resolves embedding levels once for the logical + paragraph. `Direction::Auto` takes the direction from the first strong character. + `Paragraph::with_overrides` also forces byte ranges to one direction (as LRO/RLO would), + keeping offsets into the original text. A paragraph separator (`\n`, U+2029) in the text starts + a new paragraph with its own level: `paragraphs()` lists each one's byte range and level, and + `level()` is the first one's. +2. The application shapes and breaks lines in logical order. +3. Each line is reordered on its own: `Paragraph::visual_runs(line)` returns its runs of one level + (byte range and level) left to right as displayed, with rule L1 applied (trailing whitespace, + tabs and separators at the level of the paragraph they are in). Zero-width joiners and tag + characters keep the level of the character before them, so no run splits a grapheme cluster. + A run with an odd level displays its characters from end to start. Lines should not cross a + paragraph separator. + +Lines made of other units (clusters, glyphs, cells) use the free functions on level slices: +`line_levels(chars, levels, para_level)` for rule L1 and `visual_order(levels)` for rule L2. +`mirror(c)` returns the mirrored character for rule L4 (`(` for `)`), or `None` when the font has +to supply a mirrored glyph. + +Offsets are bytes into the paragraph's `&str`: `Paragraph::levels()` holds one level per byte, +as unicode-bidi resolves them, and line ranges and runs are byte ranges. This matches the shaper +cluster offsets and line ranges the apps already keep, so no char-index table is needed; +`char_levels()` gives one level per character for code that works on `char` slices. Levels are +plain `u8` (even left to right, odd right to left), so unicode-bidi's types don't leak into +callers. + +Text with nothing right to left in a left-to-right or `Auto` paragraph skips the algorithm. + +The module started from DeckCraft's `text::bidi` (levels, rule L1 and L2 over chars). The +per-byte levels, the run-level reordering test cases and the hostile-input checks come from +WordCraft's layout; overrides and mirroring follow DesignCraft's composer. +## Line breaking + +`craft_text::linebreak` breaks paragraphs into lines on Knuth and Plass's box/glue/penalty model. +It knows nothing about fonts: the composer turns shaped glyphs into `Item`s, calls a breaker and +places the glyphs of each returned `Line`. Widths are in the caller's units, and `width(k)` gives +the measure of line `k`, so first-line indents and shaped frames work. The core comes from +vectorcraft's every-line composer; tiered justification, emergency breaks, the hyphenation zone, +greedy and balanced lines come from designcraft's breaker. + +- **Items.** Boxes, glue and penalties. Glue has a natural width and stretch and shrink in three + tiers (word space, letter space, glyph scale) that are used in that order, as in InDesign's + justification settings. Boxes may carry letter and glyph tiers too. Penalties have a cost and a + flag (hyphens); ±10 000 forbids or forces the break. `Item::fill()` before the final + `Item::forced()` leaves the last line unjustified. Optional fields, set on top of a constructor + (`Item { hang, ..Item::glue(w, st, sh) }`): `hang` (optical margin), `edge` (an `Edge` of + stretch and shrink gained at a line end or start, such as mojikumi), `hyphenation`, + `emergency`. +- **Breakers.** `knuth_plass` runs one total-fit pass. `break_paragraph` runs passes at rising + tolerances, then falls back to `greedy`; the returned `Breaks` says in `method` which one + produced the lines. `greedy_with` resolves position-dependent widths (tabs). `balanced` evens + the lines (meant for ragged text) and sets `Breaks::balanced` when it did. `overlong` finds + words wider than the measure, where the composer adds `Item::emergency()` breaks. +- **Params.** Demerits, fitness classes, tolerance, hyphen limit, tier costs, `uniform_from`, + and `Rag` for ragged text: rag stretch, short-last-line rating and hyphenation zone. + `uniform_from` is the first line index from which `width(k)` stays the same. The default 0 is + right for a constant measure; a caller with a first-line indent, drop cap or shaped frame sets + it to the first line of the constant tail, or its breaks may not be the best ones. Negative or + NaN demerits count as 0, and a NaN measure as 0. +- **Lines.** Each `Line` gives its item range, natural width, adjustment ratio, the fraction of + each tier used and the width left over, so the composer can distribute the space. + +```rust +use craft_text::linebreak::{Item, Params, Rag, break_paragraph}; + +let space = Item::glue(0.5, 0.0, 0.0); +let items = [Item::boxed(3.0), space, Item::boxed(3.0), space, Item::boxed(5.0), Item::fill(), Item::forced()]; +let ragged = Params { rag: Some(Rag::default()), ..Params::default() }; +let lines = break_paragraph(&items, &|_line| 8.0, &ragged, &[1.0, f64::INFINITY]).lines; +``` + +### Adopting + +- **vectorcraft** `composer::compose`: a box per glyph (`hang` = `start_credit(i)` for `i > 0`), + glue per space (justified: stretch 0.5 and shrink 0.2 of its width; ragged: none), a penalty per + `Breakpoint` (width = its hyphen, flagged when it has one, cost 50 justified or 25 ragged), and + `fill` + `forced` at the end unless Justify All. `Params`: `first_fitness: None`, + `max_badness: 10_000`, `fitness_demerits` 10 000 or 1 000, `fitness` bounds + `[-0.5, 0.5, 1.0]` or `[0.15, 0.4, 0.75]`, `uniform_from` as `layout.rs` computes it (the + first index from which `widths` stay at the last width); ragged adds + `Rag { stretch: 0, width_fraction: 1/6, short_last: 1/3, short_last_unit: 1/3 }`. Its two + `compose` calls become two `knuth_plass` calls (tolerance 1, then 4 or ∞); `None` still falls + back to the single-line composer. +- **designcraft** `breaker::items` builds `linebreak::Item`s: `hang`, `edge` (mojikumi + end/start elasticity), `overflow` (`space_overflow`), `hyphenation` (`auto`), `emergency`. + Ragged text uses plain spaces and `Rag { stretch: ragged_stretch, width_fraction: 0, + hyphenation_zone: hyph_zone }` instead of glue/penalty/glue triples. `Spacing` maps to + `Params { hyphen_limit, overrun: FIT_EPS, uniform_from, .. }` with `uniform_from` the number + of drop-cap lines, else 1 with a first-line indent, else 0. Kinsoku priority 1 or 2 maps + to `stretch_weight` or `shrink_weight` 4; `TIER_COST` is `tier_cost`. `knuth_plass` becomes + `overlong` + emergency items + `break_paragraph(.., &[2.5, 12.0, f64::INFINITY])`; `greedy` + becomes `greedy_with` (tabs through `advance`; `Item::no_break()` where an emergency split is + not allowed); `balanced` becomes `balanced`. Mapping lines back to glyphs + (`breaks_from_positions`) stays in the app. +- **effectcraft** `every_line`: a box per segment between line-break opportunities, glue for its + trailing whitespace, `Rag { width_fraction: 1.0, .. }`, `uniform_from: 1` (the first line's + measure differs when it is indented) and `break_paragraph(.., &[f64::INFINITY])`. + +Differences from the seeds: + +- vectorcraft: a Justify All last line is accepted at any ratio, like every line that ends at a + forced break. +- designcraft: fill glue stretches infinitely instead of by 10 000 units. In ragged text a line + ending at a forced break keeps its neighbour's fitness class. Greedy counts every flagged break + toward the hyphen limit, and splits overlong words at the composer's emergency breaks instead + of scanning for kinsoku. Past 4096 active breaks a pass fails and `break_paragraph` falls back + to greedy, reported as `Method::Greedy`. Lines before `uniform_from` (first-line indent, drop + cap) are kept apart by line number, where designcraft treats every line alike. +- effectcraft: lines are rated by Knuth's cubed ratio, not squared slack. + +## Licenses + +The word list is public domain; the trained patterns are CC0-1.0. hypher's patterns come from +hyph-utf8 under their own permissive licenses (LPPL, MPL, MIT, BSD-3); see the repository's +[NOTICE](../../NOTICE) and [ATTRIBUTION.md](../../ATTRIBUTION.md). + +## Checks + +Some tests depend on language features, so run the suite with them on: + +```sh +cargo test -p craft-text --all-features +``` diff --git a/crates/text/assets/hyphen/en-us.dic b/crates/text/assets/hyphen/en-us.dic new file mode 100644 index 00000000..a0900914 Binary files /dev/null and b/crates/text/assets/hyphen/en-us.dic differ diff --git a/crates/text/assets/hyphen/en-us.pat b/crates/text/assets/hyphen/en-us.pat new file mode 100644 index 00000000..92dc61f3 --- /dev/null +++ b/crates/text/assets/hyphen/en-us.pat @@ -0,0 +1,1633 @@ +% DesignCraft US English hyphenation patterns (Liang/TeX notation). +% Generated by crates/compose/examples/hyphgen.rs: trained with DesignCraft's patgen-style trainer +% from the Moby Hyphenator II word list by Grady Ward (public domain, Project Gutenberg #3204). +% Released into the public domain (CC0-1.0) by the DesignCraft contributors. +'2c '4cl '4do 'emil4 'emili4 'exi4 '4fa '4fl 'ha4 'immor4 '4in 'l2 +4'll 4're 2's 4's. .a2 .a4ba. .a3bas .a4b3er .ab3h .a4bia. .a4bie .a4bil +.ab5ir .a4bl .ab3la .a4bo. .a4bos .a3b4u .a6cetyl. .a4cl .a5coe .ac3t .ac4te .ac4u +.a4cus .acu4ta .a4cy .ad4ded .ad4der. .ad6din .a4dh .a4di .ad3ia .ad3ip .a4don .ad3um +.a4dur .a4dy .ae4r3 .ae4ta .af1 .af3t .a4gan .a4g3a4t .ag4e .a3gen .agis4te .a3gl +.a4glet .a3gu .aile4 .ak2 .alar4 .ald4 .a4ley .al4fa. .ali4as. .al3ie .al3im .a5lin +.al4ist .alk4 .al5ky .al3l .alli4e .alm4 .a3lon .alth4 .amalgam5 .ama4li .ama4ra. .amato6ry +.amb4 .ame4l .a4ment .ami4r .a4mosi .amp4 .an1 .a4na. .a6nac .an3ar .a3nath .an3c +.anchis4 .an3g .a4n3i .an3k4 .an4nem .an3or .an3s .an4tal .an4tat .anti3a .anti5f .anti3s +.an3y .a3o .a4pal .a3par .ap3at .a6perie .a4phar .apho3 .aph3or .a4plan .ap4le .a5por4 +.appe3t .a3pr .aqui3 .arbo6rea .archa5i .ar4chan .ar3d4 .are4l .ar3g .ar2i .aria4 .ar4ia. +.ar4io .ar4iu .ar4mes .a3rou .ar4rie .ar3s .arsen6 .ar4sp .ar4sy. .ar3t .ar4tier .ar4tin +.ar4tl .ar4ty .as4a .as5ce .as4d .as4e .as3p4 .as4r .as1s .ass4e .as6teria .as4tom +.as6tron. .as4u .at5ar .a4t4e .ate4l .ath3a .ath4em .a4to4 .ato2m .atom3i .at4ty .au3b +.au4rou .aus3 .aus4a .a4val .a4vat .a4ven .a5veng .a3vi .a4vid .aw4r .ax3 .ax4en +.ay4l .a3yu .a4zi .b2 .ba4bel. .ba2bi .bach3 .ba4cl .ba4di .ba4ga .ba4la. .bal4lu +.ba2lu .banda4 .ba4ni4 .ba4r .ba4san .bas6tia .bate4 .b4b .b4d .be4a. .be2d .beef3 +.bel4lo. .be3ni .ber2a .ber4i .be3str .bev3 .b4h .bi2al .bi4e .bi4lan .bill4a .binar4 +.biz4 .bk4 .b4l4 .ble4 .bli4n6 .b4m .b4o .boan4 .bod2e .bo4ral .bor4t .b4r +.brea4 .bre4c .brel4 .bres4 .bre4t .bri4t .bro4s .b4s .b4t .bul4bar .bun4di .bunk4e +.bun4t .bu4ri .bz4 .c2 .c4a2 .ca4bel .ca4dil .ca4le .cal3o .ca4mas .candi4a .carbon5y +.ca4ro. .car3ol .ca4t .cat4as .cat3e .catter4 .c4b .ce4br .cec4 .ce4lan .ce4li4a. .cens4 +.cer2a .cer4i .ce3r4o .cess4 .c4h4 .che4m3 .chi4g .chiv3 .chol4i .cho3ro .chthon4 .church5 +.chy4p .c4i .ci4a .cic5e .cid4e .cin3 .ciner4 .ci4s .ci4t .cit3i .cit3r .cit3y +.c4l4 .cla4d .cla4r .cle4l .cli4nal .cli4t .clu4 .c4n .cnid4 .co3ed .co5et .co4let. +.co4lo. .co4mat .convin6 .cop2e .co4pl .copol5 .co4p3y .corn4in .corre5s .cotti4 .cotu4 .cout4 +.cow5a .cp4 .cret4 .cre4ti .cro4ma .cro4p .cro4se .cru4d .c4to .cu4mi .cun4 .cu4rati +.cur4b .cur4vy .cu2t .cutin3 .c4v .cya4ne .cyan4o .cy4n .cys6tic. .cy4th .cz4 .d2 +.d4a2 .da4ce .dacty4 .da4li .da4m .da4ni .dar4ia .dar4i4u .dar4te .da4to .da4vit. .dd4 +.d4e .de4an. .dea4t .de2bo .de4bra .de4cil .de4lis .de4mi. .de4moi .de4mons .de4mos. .dent4 +.de3r4a4 .dern4 .de3sce .de6sert. .de3t2 .de4vil .de4war .dez4 .di4a. .di6able. .di4al. .diar4y +.didy4 .di4f .di4le .di4os. .diot4 .diri6gi .dis3 .disar4m .d4m4 .d4na. .do2 .do4co +.dola4 .dol2o .dors4 .dous4 .do4v .dow4y .dr4 .dre4 .dro4 .dro2m5 .d4s4 .d4u +.du4ca .dul4la .dun4nit .du3r4a .du6rativ .dus4 .d4v .dy4f .dy4nas .dy4s .d4z .eac4 +.ea3g .eak4 .eal4i .ear4t .east3 .ea4sy .ea4te .e2b .eb3o4 .ech3o .e4cla .e2cle +.e4clo .e4col .e4con. .e2cos4 .e2cot .e4da. .e4deli .ed4h .ed4it. .e4do. .ed4t .ee4 +.eer4 .e4f .efflu6e .eg4ge .e2i .eid4 .el4a .elab3 .e6later. .e4lay .eld4i .e3le +.e4lea .e4lect. .el3e3g2 .el4egit .e4len .ele3p .el4f .el4i .eli4as .e4lie .e4lio .e4lip +.e3lis .eli4so .e2ly .e2man .em3ana .e4mer .e4met .em5igra .emil4 .emili4 .emi4r .e4mis +.e4mog .e4mor .e2na .en3act .enam3 .e3name .en3an .en3c .en4cy. .en3e .enervat6 .enes4 +.e4nosi .ent2 .eon4 .eo3si .e2p .eph4 .epho4 .ep3ic .epid4 .ep3in .e4pio .e4po +.e6quine .eq3ui5t .e6quiva .er2 .e1ra .er4e .erg4 .e4ri .eri4d .er3m .e1ro .ero6g +.er4rab .er4ri .e4s3 .es4op .eso3t .es4u .et4al .e3te .e3ther .eth4yl. .e4tia .e4tow +.etu4 .eun4 .eu4ra .e4va. .e4van. .evapor4 .e4var .e4vat .ever2 .evers4 .ever4t .eve3ry +.evo4c .e4wan .ex3 .exhaus4 .ex4or. .ex5u .ey4a .f2 .fanat3 .f4b .f4c .fe4me +.fen4d .fer4a .feri4a .fe4ta. .f4f .f4i2 .fi4do .figur5at .final4 .fi6nan .finea6 .fin3g +.fis4ti .f4l .fle4 .frin4 .f4s .fu4el. .fu3si .g2 .g4a2 .ga4da .ga4m .gam3i +.gam3o .ga4p .ga4ri .gar4th .ga4s3 .ga4th .g4b .g4c .g4d .ge2a .ge4li .gen4f +.geni4a .genom4 .gent4 .gen3tl .geo4me .ger4ent .ger4i .ger4o .ghi4 .gi2 .gian4 .gi4b +.gin5ge .g4l .glass5 .g6le .gle4a .gluti6na .g4n .gne4 .go4l .go4me .gon3o .go4r +.go4s5p .gou4t .g4r .gra4ce .graduat6 .gra4p .grip4pa .gro4g .gu4g .gu4ri .gu4t .gy4b +.h2 .ha2 .ha4c .hae2 .ha4g .hag3i .ha3la .hal3i .ham5ad .hand3i .han4g3 .hank4 +.ha4no .haran4 .has4k .has4t .hast6i .ha4v .he2 .he4a .hec3at .he4i .he4la. .hel3ic +.help5 .hem3i .hem4ic. .hemis6 .he3mot .hemo6tr .hem4p .hep3a .he3red .he5resi .her6in .hero4p +.he4s .hi3b .hi4c .hi4dr .hi4lary .hil5d4 .hiller4 .hil4li .hil4ly .hi4m .hin4der .hin4g +.hi4o .hi4s .hi4t .h4m .ho4a .ho4do .hoe4s .ho4i .ho4la .ho4le .hol3o .ho4ma +.ho4mon .ho4my .hoo4 .hoosi4 .ho4p .ho4rac .ho4sen .ho4th .hov5er .h4s .huff3 .hurs4 +.hy3g .hyo4 .ia2 .iae4 .i5am .i4as. .i4ast .ia4t .i4ata .iat3r .i4br .i4ca +.i4cel .ich3 .ich4i .i4da. .idel4 .i4des .i4d3i .i3d4l .ido2 .idol3 .i4e .if4u +.ig4f .i4gl .ig3n .i4h .i4j .ikar4 .ile4a .i4li .ili4s .ilk4 .il4lima .illo4 +.il4lus. .i4lo. .i2m .im3age .imi4d .im4in .im4it. .im3m .i4mo .im4ph .im5pin .i4n3 +.ina4m .inc4 .in5ce .ind2 .in4dic. .indi6cat .ine4a .in4gly .in4k .inow4 .in6suran .i1o +.i4oa .io4b .i4or .i4ou .i4pa .ipi4 .iq4 .i4ra. .i4ran. .ir2i .i3ric .ir3id +.iri4s .i4ro. .ir3r .i4si .i4so. .i4ta. .i4tac .ital3 .i4tal. .i3tem .i3ti .i4to. +.i4tu. .iv2 .ixo4 .j2 .jame4 .jh4 .joli4 .juni4 .ju1r .jus3 .k2 .ka4le +.kal4i .ka4li. .ka4te .ka4ti .k4c .ke4g .ke4p .ke4r .kh2 .ki2 .kian4 .ki4b +.kil1 .ki4l3o .kin3em .k4l .kle4s .k4n .kni4f .kold4 .kon4e .ko4ni4a .k4u .k4w +.l2 .l4a2 .la4br .lab5y .la4cy .la4ga .lalish4 .la3ma .la4me .la4na .lan4dr .laps4ib +.lar4g .lar4i .las4t .lat3er .la4th .la4ti .lati6na .latin5i .lau2d .laz3a .l4e2 .lea4 +.lear4 .le4b .lech3e .lecith4 .le4gat .le4m .lemo4 .len4g .le4om .lep3r .le4pu .le4va. +.lew3i .l4h .l4i2 .lid4i .lie4 .li4f .li4go .li4me .limo4 .linar4 .lin4eag .li3o4 +.lis6ter. .li4tu .l4l4 .lo2 .lo4chi. .lo4g .lo4i .lo4n .lo4p .lor4in .lot4i .lo4v +.l4s .l4u .lub4l .lu4e .l4y .ly4b .ly4l .m2 .ma4bi .ma2c .mack4er .maga3 +.ma4la. .mal3ad .ma2n .mand4 .man4is .mans4 .mar4ia. .mar3ti .ma4thi .ma4tin .may4er .m4b +.mc3 .mensu4 .mer4a .mer4ise .merit6e .me4s1 .met4al. .me6te6or. .meth6yl. .me4to .m4i .mia4 +.mi4a. .mi4d .mif4 .mi4na .mi4ne .mines4 .mi4n3i .mini4a .mi4s1 .mis4er. .mk4 .m4la. +.m4m .m4ne .mo2 .mod1 .mo4da .mol4o .mo4no. .moth3 .mou4 .mould5 .mo4v .m4p +.m4s4 .mul3 .mun4ga .muni4 .munis4 .mus5ke .mw4 .mye3 .my4i .my4t .n2 .naba4 +.na4bat .na4ch .nadi4 .na4i .nal4g .nan4i .na4ri .na4to .na2v .naw4 .n4c .n4d4 +.n4e2 .ne4bri .neb3u .ne4gro. .nem3at .neph3e .ner2 .ne4th3 .neva4 .ne4vi .n4g4 .n4i +.ni2c .ni4da. .ni4e .ni4ne .nish4 .nit4e .n4n .n4o2 .no3e .no4m .no4n .no4r +.nor4thi .no4s .noy4 .ns4 .nu4i .nu4n .nur4s .ny2 .o'4 .oax4 .obe3l .ob5elis +.o5ber .o4bit4 .o4bl .ob5la .ob5lo .oblon4 .o4br .o2c .o4ca .oca4l .o4cel .o4ch +.och3l .od4 .odel4 .o6donta .o4dr .o2dy .oe6co .oes3t .o4h .oil3 .ois4 .oit4 +.ok4 .o3le .ole4c .ol3ig .ol4la .o3ly .o4mad .on4as .ond4 .on4de .on4eros .oni4 +.onk4 .on3o .ood4 .o5og .o3o3ph .oos4 .o3pa4 .o4pec .ophe4 .o3pin .o2r .or3ato +.orbi4 .or3c .or3d .o5ron .ors4 .ory4 .os3 .os4a .o4sa. .o4sc .os4i .o4st +.os4tl .ote4 .ot4i .o4tr .ou4 .ous4 .out3 .ov2 .over3 .o4ver. .o4vu .ox3 +.oy4 .o4za .p2 .p4a2 .pa4b .paci4 .pa4k4 .pa4li .pan5at .par1 .par3at .par4ch +.pa4ri. .park4e .parti4a .part4ib .pe4a .pear2 .pearl3 .pe4ba .pec3t .pe4lik .pel4te .pend4 +.pe6nult. .per4a. .pe4r3ia .per4lie .peron4 .pe3ru .pet3a .pe4ti .p4f .p4g .ph4 .pha4c +.pha4n3 .phar3i .phil4ia .pho3n4 .phtha4 .p4i2 .pic5r .pi3ge .pi4mo .pin4dl .pin4er .pi4ol +.pi3ra .pi4t .pit3y .p4l .plas3t .plat3i .pli6catu .po4dite .po3e .po4et. .po4k .po4lis +.po4ni .po4p .pop5i .pop5l .port4e .post3a .post3h .post3o .p4r .pre3ad .pre4cat .pre4lat +.pre6sent .pri4c .pro3a .proc4i4 .prolo6gi .pro4phy .propos6a .p4s .psa4 .p4t .pter4on .ptot4 +.ptyal4 .p4u .pur4ri .put4ter .p4v .q2 .qi4 .q4t .qua4m .r2 .r4a2 .ra4be +.ra4bid .ra4ce .ra4cy .ra3d .ra4da. .ra4du .ra4f .ra4gi .rain4e .ra4pi .rap4pis .ra4to. +.rave4 .rawl4i .r4c4 .re1 .re4an. .recho4 .re4col .rec5oll .rec3re .red4ded .red4din .red5in +.redriv6 .reduc4e .re5end .re4go. .re4ist .re3ma .ren4ta .re4par .rep4i .re4rem .res4t .rest4i +.ret2 .re3te .re4trib .re3vi .r3ge .rgen3 .rhag4 .rhet4 .rhe4to .rhe4u .rhot4 .ri2 +.ri4ba. .rib4e .ri4bl .ri4co .ri4e .ri4i .rio4 .ri4sh .ri4ta. .riv3e .r4ma .r4n4 +.r4o .ro4ba .ro4bi .rob4o4 .ro4ch .ro4g .rol4lin .romani6a .ro4p .ro4q .ro4san .ro2sc +.ro4set. .ro4sin .ro4sy .ro4th .rough3 .ro4ze .rp4 .rs4 .r4sa .rt4 .rud4e .ru4in. +.ru4l .run4i .runt3 .ru3r .r4w .ry4c .s2 .sa2 .sa4do .sak4e .sali6na .sal4is +.sal4te .san4de .sani3 .sa4t3in .satur4a .sa4ve .s4c4 .sca4li .scar3a .sca6tol .schil5 .sci3e +.s4e2 .se4a .se4lag .sel4e .self5 .sel4i .sel4li .sem3 .se4pa .sepho4 .ser4ice .ses3 +.se4v .s4f .s4g .s4h4 .shan3t .shing4 .shov3 .s4i2 .si3al .si4b .si4de .sid4eri +.silk3 .si5lo .sin4ga .sin3g4l .sin3i .sis3 .s4k4 .s4l .sli4 .s4m .smal4 .smar2 +.smell3 .smok4in .s4n .sna4 .sne4 .so4a .soap3 .sodom4 .so4lute .so4me .som5er .son4a +.soni4 .sot4e .soth4 .s4p4 .spel4 .spen4 .sper4o .spi4c .spi3d .spi4e .spi6ral. .spi4t +.spo3ro .s4s4 .s4t4 .sta3li .stal4l .ste4n .ster4ic .stin4 .sto4le .sto4ria .stort4 .stot4i +.sto4v .sub5l .sug3a .s4w .sy2 .sy5ring .sz4 .t2 .ta2 .ta4b .tac3i .ta4gr +.ta4l .tal4ly. .ta4mi .tani4a .tanis4 .tanta3 .ta4p .tark4 .tas4si .tati4 .t4bil .t4c +.te4 .tecto4 .tel1 .tel3a .tel3et .tel4li .ten3d4 .ten4o .ten4ti .ten4ty .tep4 .ter4as +.ter4ent .ter4me .termin4 .ter4mo .teu3 .th4 .the3a .tho4m .tho6rax .thro4 .t4i2 .tia4 +.ti4f .ti3ge .til4lag .timet4 .ting4 .ti4p .tisa4 .tita4 .ti4ti .to4e .to4i .to4li +.to4mal .tona4 .to4p .tor4c .to4th .trache5 .tra4s .tra4v .tre2 .tre4a .trep3o3 .tristi4 +.tri4ti .tro4po .t4s4 .tsu3 .t4u .tu4r4i .tuy4 .t4v .t4w .twin4i .ty3ro .tz4 +.u2 .u3b .uc4 .uca4 .u4da. .u4di. .udo4me .ugar4 .umen4 .un3 .unch4 .und2 +.under5e .under5s .u4ne .un5er4 .une4s3 .un5et .ung4 .un5in3 .unk4 .u4no .un5p .un5s +.unsel4 .un5t4 .unta4 .u4p1 .u3ra .ura4l .u3re3 .ure4ti .u3ri .u4rial .u4rid .u3ro +.u3ru .u4ru. .u4sa .u4si .usur4 .uta4 .ut4e4 .u3ti .u3tr .ut4s .u3v .v2 +.va2 .vac3i .va4len .val4is .va3p .vapo4 .var5ic .va4s .vas3o3 .v4c .ve2b .vei4 +.vel4la .ve3l4o .ven4er. .ver4se .ver4sy .ve2s .ve4ti .vi3gn .vi4ne .vin4i .vi2t .vi5va +.vivipa6 .vi5zi .vo2l .vol5v .vo4r .v4r .v4z .w2 .wap5 .ward4er .war4e .wc4 +.whirl3 .wi4 .wk4 .wo4m .wr4 .x2 .xe3r .x4i4 .xn4 .x4t .x4v .xy3 +.xyl4 .y2 .ye4 .yes3t .yh4 .ym4 .y4o .yog4 .ys4e .y4u .yw4 .z2 +.za4c .zar4i .za4s .ze2 .zh4 .z4i .zi4b .zo4i .zo4n .zo4oty .zori4 .z4v +2aa2 aad4 aa4f aar4d aar4i ab2 4a4b. 4aba. 4abag 4abaj abal2 a4bala +a3ball abal4lo a3ban aba4ri 4abasi a4basine abas4si 4abas4tro a4batea a4batis a4batt 4a4b3b +abba4s ab3d abe2 4abee a4bef a3bel a4bela abel3i a4bell. 4aber a4berc ab3erd +a4bern a3bet ab3ho 4abhu 4abia4 ab3ica abi4d a4bidit a4bien a4biet ab3if 4abile +4abilia abilis6e a6bilism abiliz6e a4bily 4abin4 abini4 a4bino ab5irr a4bist 2abi2t abit3ab +ab3itat ab3j 1a3bl a4bla ab3lat ab4lin a5bling ab3lis a4blu ab3n 2a2bo 4abo. +4aboa 4a3bob 3abolic ab5o5lise ab5olism ab3o3liz ab3olo abon4 4abor ab3ora 3about a2br +4abra. a4brad 4abran abras4 4abria ab3ric ab3rog a4b3s4 4absh absin4 a2bu a4bu. +ab3ul a3b4un 4abur a3but 4aby abyl4 aby3r abys4s 4aca. a2cad acan4d a5cap +4acara acare4 ac3a5ro 4acas. acas4se ac3c4 4acch acci4a ac5edl ac1er ac4er4o acess4 +acet2 ac3eta 4acetam ac5eton 2ac4h 4ach. 4acha achal4 a3chan ache3a ache4t a3chian +a4chic ach5ic. 5achiev ach5inat a6chiop ach5ronis ach3ry a5chu 3achut a2ci acic3 ac3id. +ac3ido a3cien ac5ifie ac5ify ac5ile a3cili aci4nate 2a3cio a6ciology 2acit ac3ita a4city +aci4u ack4erel ac4l a4clav a3cly ac3m acoat4 a4coco acoid4 a4coli aco4pa aco4ph +acorpo4 a3cou 5acoust ac3ram 4acre ac1ri ac3rob ac3roc acros4c acrot4 ac4so 4actac +acte3a ac3ti actuar4y acu1 ac3ua a4cue ac3ul ac4ulifo a3c4um acun2 a4cuse a3cut +a3cy a4cy4l a2d 2a4d. 4ada. a4daea a4daeu ad5aman 3adap 4a3das adav5e a4dc +a4dd4 4addai ade4a. ade4mia 3ademic 6ademici a3denc ad3eno a3dent ad3er. ader4y adev4 +a4df adge4t a3dha 2adi a3dia adia4p adiaph5 a4d3ic. a4d3ica a4dice a3dii a4dina +a4ding a4dinit a3dio adio3t adi4ph ad3ipo adi4ra adis4c a4dish a3d4it a4ditiv a4dito +a3diu 3adju 4adk adm4 4admil 3admit a4dn 4ado. ado4ce ado4e a4doma a4dona +a4doph adop4te ador5ab a4dot a3dou 3adox adria4 ad2t a3d4uc a4d3ula ad3umb 3adv +adwom4 a4dyn 4adyr adys4 2a2e 4ae1a ae1di ae3g4 ae4gl ae3i ae2l ael3it +ae1m ae2ma aem3or ae1n aen4i aeo4logu ae2r aer4ar aer3o1 3aerob aer4on. aer4ono +ae4sal aes3t aes4ta ae3t4 ae4tat aeth4i ae1u ae4vu aey4 a2f 4af. a3fa +a4fae a4fag af4e 4afe. afem4 affi4c a4fia a4fie a3fil a4flat a3fo4 afor4 +afri3 a3fu af4y4 4ag. agan3d aganis4t agar4ic. a4gath a4gaz ag3azi ag4c 2agea +agean4 agel4i ag3ella age3na 4a3ge2o a4geol ager4y age2s ages3i 4aget 4ageur ageus4 +4aggi aggo4 4agh aght4 ag1i a3g4ia agi3an agiar4 ag3ic. 4agie agin2 ag4ines +agio2 ag2is ag2l agli4 a3gly 4aglyphy agmen4 agn4 4agna ag4nac agni4s agnoli6 +a2go ag3oc ag1on 4ago3n4i agor4i a5gos 3agou agout4 ag3p a3g4ra a4grag agra4p +a4grapha a3g4re a4grop agrue4 agth4 4agua 4ague a3gui agus4 a2gy ag4y. agy4r +a3ha aha4li aha4n a3he ahe4m a3h4i ahi4e ahil4 ahi4m ahli4 a3ho aho4g +a3hu a1hy 2ai2 4ai. ai4a ai3b a1ic ai3ca a3i3ci ai4d aig2 ai5ko +ail3ab ail3i ail4ia. aim3ab aim3er 4a4in ain3ag ai4ne ain3i ain3ou ain2t ai4o +a4ip4 aiq4 air3l air3m ais4e aish4e a3ism aiss4 a3isti aite4 aitor5 a4ity +aiu4 aiwa4 4aj. a3ja aja4m a4jer a4jl a4jn a3jo a4joi 2ak ak4al +aka4n a3kar ak1er ak4erli ak4et ak4ey ak1i a3ki. ak4ings ako4i aks4 aky4 +ak3y. al1 4ala. 4a5laa al3ab al4abr a3laci al3a3co ala4f a3lak a4lal. 4alam +al3ama al3amu al3an4c a3lang al4ar2 a4laro al3ata al4ate a4lath al4ati a4latt a4laty +ala4zi albur4y al3c 4alci 2ald4a alde4b ald4er alder4a 4aldi al4dis 4aldu 4ale. +ale3a a3lee. aleer4 alem4a a1len a3lene. a4lenti al5entin a3leo a3lep aler4a a4les +ale4so a5less. 4alet aleth4 a3lexi a4leya alfi4 al3g4 4ali. 2ali2a a3l4ia. al3iat +alib4 2alic a4l3ic. al3ida ali2e a4lifa a1l4ig ali4ge a4liger a4lika al5imen al3ing +a4linite a4lio ali4ol alipa4 al5ipe 6alipo alipu4 alir4i 4aliste alit4e al3iva alk3at +allad4e alla4l al4lena alle4t al4leye al3li 2allic 4alloce 4allop 4alloth 4allu. al3ly +al2mer al4mic al4min almon4d alm4s al2o aloblas4 a4locy al3ogiz a1lon4 a2lony a3loo +a3lopi al4ora al3ori a3los aloy4 alp4 4alpa als4i al4sie. 4alta. alth3i al4tin +al4tis 4alu. a3lu3b al3ue al3ui a5luk a1lum a3lumi a3lumn a1lus a5lus. a2lust +a3lute a3luti alv4 al3va aly2 4alye 4alyn a3lyp 4alysan 5alyse 4alysi a3lyst +1alyt 1alyz al4z 2am. a2ma 4ama. am3ab ama3c ama3d4 am3age am3agi amak4 +a4maka a4male ama4lia a3man a4mandu. ama4ne a4manit ama3ra amas4c a4mash a3matic am3atis +am4atis. am3atiz am3ato am3atu a3maze 4amba. ambar4 am3bi ambi4er 4ambo 4ambr am4da +4amele ameli4a a3men ame3na amen4d amendato6 ame4ne. ament4a a4mentab amenta5ry ameral4 am5eris +am3ero ame4si ames4o ami4b 2am1ic a3micr am4ien am3if ami4la am3ily ami4m am3ina +ami6nize amis4e am3ito a5miu am3iz am3l amme4d5 4ammel ammis4 amn4 a4moi am5ois +4amon a4mora a4mort a4mosit a4moy ampa4nu amp3ing ampi4r am4por ampu3t 2ams am3sh +am4sp 4a4mue a4mul 4amun amy4a amz4 a4n. a5naa an4abase ana4basi an4abr a3na4ch +ana4din an4adr an3aer an3age an4al. anal6ise anal6ize. an4all an4ame4 an4amm anan4i 4anao +an3arc ana4ri. anari4a anar4ia. an2as 4anasy a4nat. anath3 anat3ic ana4tr anau4 4anca +anchan4 an3chi 3anchol 4ancht anci6nate 4anco. 2and and3ag andes5cen andev4 an4dies. an2dis +andoo4 an4dow andown6e an4drac 4andre. 4androp and4t 4andu andu4a an4dun a3nea. a3nean. +a3nemi ane4mon a3nen4t ane4p 4aner an4erg a4n3est an3eta ane4to 2ang 4ang. angar4 +anger4y ang2l an4glan an3gli angol4 an4gou angov4 an4gres an4gru an3gu an1i 4ani. +an4ia 4aniac 4a3nial a4niar an3ic a4nic. an5ica an5icis an5ics a3nicul an4id a3nide +an3iel ani4er an3if ani4la an4ilit ani4lo 4an4imi 4animo a3nine a4n3ing 4anio a3nip +a4nipl an4ir an3is an5ish anis4tr an3ito ani4toi an3ity 4a3niu ani4y an3iz 4anke +ank5ly an3l ann4 4ann. annar4 an4nema an4nia a3noc4 an3od anogam6 a3nol. 4anoli +a1no4m a3noma anon4g an3onis a4nopo anora4 a3nosi anoti4 a4nour a3nov a4nove ano4vi +4anow4 an5ox an3q 4anr an2s ans5co anse4l an5sen an4sil ans3l ans3m an4soc +anso6phi ans3pa anspar3 ans3po an4sure an4sut an4tac antal2 an4talk antar4 4ante. an4ters +anth4 an4thor anti3e 3antio 4antis 4anto. anto4c an4try an2ty a4nu. an4ub an3uf +an3ul an5umi a3nut anx3 an3ya 2anz 2ao a4o. ao4a a4ob a4oh ao3li +aos4 ao2t aout4 a2p 2ap. 4apa. 4apab a3pac apach4 a3pal a4pala a3pann +apar2 a4parin ap3aro a3part ap4bl 4ape. 4aped apel4e ape4r 4aper. a6peries ap3ero +a4pete apeu3 a3pezi aph3ano a4phare a3phe a4phes a4phot 4aphrasi aph3ro3 a4pid a3pien +ap3illo ap3ing a4pio ap4is ap3ita a4plana ap4len ap3li apo4c a4poe ap3oge ap3ola +a3polo a4p3o3m4 apon4a a4ponin a4pon4ism a4ponit apop4 apo4ph ap3opl apo4re apor3iz apor4t +a4posp apos3t a4posto a4potac a3pou app4 appe3ti 4appi appi4a. ap4ri a4pric aprog4 +aproge4 a4prop apro4pi apro4s 3aptat 4apte. ap5ule a5pun 4a3pur a4puri a1py a3py. +apyret4 a4qa aquil4 aquin4 aqui4v aq4w 2ar. a1ra. ar2ab ar3abe arac4i a3rade +ara3di a3raj 4arak ar1al ar4alo ar4amb ara3me aramel3 ara4mu ar4an. a3rang aran4gu +ar4ani aran4tis a4rap ar1as4 ar2at ara3th a3ratio ara3v4 arawa4 arb4 arbal4 arbalis4 +arbe2 arbel3i ar3bi arbor5i arbu3r arbut4 4arca ar4cant arce3n ar4chang archiv4 arcion4 +ar4cla 2ar2d ard3er. ard3is ard3ous a4rea ar3ean 2ared a1ree areli4 4aren ar3ent. +ar5eou 4a4r3er ar4ero a3res4s a4rev ar3gic ar4gre ar3gu 4arguar ar1h a2r1i a3ri. +4ar3ia aria4d ar4iali a4ric ar3ic. ar4i4cl ari4dea ar3ies a3rieta ari6ette arif4f a4rify +arimi4 a3rina. ar3inat a3rine. 4a4r3ing a3rini ar4int ar3io 4arion ari4oso ar2is ar3ish. +ariso4 ar3ison a5riss ar4it. ari3ta ar5ity ar3iu a3riv 2ariz ar3ko a4rl arla3t +ar4lite armag4 4arman arm3er. ar4mes. armis4 armon6g armo5n4is armo5niz 4armos 2ar1n ar3na +arnel4 arnet4t ar2o a3ro. ar4ob 4aroc a3roch aro4i aro4mat. ar5omatic arone4 a5roo +a3rop a3ros4e a1rou aroun4 2arov ar3ox ar4per arp5ing arpos4 arr4 arri4an. arris4t +4arrit arrol4 ar4sl ar4sn ar4sph arta4n ar3ter 4arth. 4arthe ar4thor ar4tom a1ru +4aru. a4ruc a3rus 4arv arv4e arves4 ar3w ar1y arya2 a4rz 2asa asag4 +4asal asal4a a6salt. asan4i a4sanit as3an4t a4sav as3c4 ascar4 asca4t as4ch as4cop +as4di a4sec as3ect aseli4 a4sent 4as4h ashal4 ash3er. ashi2 ashim4 ash4o a4shout +ash3y asi2 as4ia asi5al a3sib 4asic asi4d a4sidim a4sif a4sila a4silin a4silis +a4sin4 as4ings asionar4 a4sir 1a3sit ask4e asl4 as4ly as4met as1mi 2aso a2soc +aso3li a4som a4sore a4sori as4pas as3pe as3ph as5pira aspi4ran as4pr 4asse. assem4i +as2sh assis4 associ5 asso4l assor4 as4sp 4astad astat6in astau4 as4tele as3tell as3ten. +as3teri as6teria. as3tero astho4 2as3ti astil4 as4tin as4titic astna4 as3t4o aston3 as3tr +as4tray as3tu a2s1u 4asu. asu3a asu3i as4un 4asur a3sus 4asv a4syli 2at. +a2ta a4tab at3abi atag4 ata5la atalo4 atani4a ata3ra atas4 a3tax at4ch atchet4t +a2te a4teb at3e3ch at3ego at3en. at3ena atenar4y aten4te a3teri at4erin ater3n a3terna +a5ternit at5erniz a6tersi a4terso ate4v a2th 4ath. ath3em a4th3e3te a4this. 3athist 4athoc +athomor4 a3thy. 3a3thyr 4ati. at1ic atica4 at3icis 4atico a4ticoe a4ticu a4tida at3ies +a2t1if 4atig at3iga ati2l 6a6tilise. 6a6tilize. a4tilo 4atim a4time at3inat 4at3inis 6atinize +4atino atir2 a4tire at3iris a3tise a5t4iti at3itu ati4us ativ3i a3tiz at4ja 2atl +at5li atmos5 a3to 4ato. 4atoa a4tob a4tocu a4togl a4togr a4toh a4tois a4tola +a4toli a4tom a5toma a5tome at3omis at3omiz a5tona ato3nis 4atook a4topa a6tophi atopho4r +4atora a4tosp a4totr at2r a4tra atra4p a3t4re 4atri. a4triate atri4cle 4a4trico at5rics +a3tricu 4a4trid a2t3ril a2t3rim 3atriz a4troc atro4n3 a5tron. 4a4t5rophy 3atrou a3try 4ats +ats4k at4sp at4su a4tt at3te atter6ie at4tho a2tu atu4an atuar4 at3uat at3ue +a4t1ul a3tum atu3ra at3ural a4turat 4aturis 6aturiza aturn4 4atus. at4wai a2ty 4au. +aub2 auc4e a4ud au4dato aud2i au3dib audo4 audu3 aud3ul au3e au4fi aug4h +augi4 3augur 4auh au3i aul2 auld4 auli4e aulo4m aume4 aumer4 aumi4 aumon4 +4aun4 au5na aun5de aun5dr auni4 au4nit aupa4 au1r auran4 auri4o auriti4 au4rous +4aus. au4sa aus5ativ 4ause au3sib auspi4ce auta4m 3au1th au3ti auti4e au4to. autu4 +4av1ag ava3li a4vanc avar4y a2vas av5asa a3vat a4vata a2ve 4ave. av4ed ave2l +a4velo ave4no av3erag aver4an aver5i ave4sa 4avi a3v4ia av4ic a4vidl avi4et a4v3ig +avi2o avi4o4le av3ish a4viz av4o2 a4voir 4avon avo4r avor3i a3vou a3vu 4aw. +a3wa 3away 4awb a2wh aw3i aw4k awl4e awl5er aw4ly awm4 a3wo aw4pe +a2w3r aws4e aw3ye 4axa axa4l ax4eni ax3i axi4b 4axim axim4e 4axo ax4yl +2ay 4ay. ayac4 ay2an4 a4yar4 a3yat ay3l aymo4 ayn4 ays4t ay3t2 ayth4 +ayy4 az2 a2za az3ard aze4d a5zell a1zi 4azi. a4zic a3zie a3zine az3ing +azi4r4 a4zl. azo4la a4zom a3zy. a4zygou 1b 2b' 2b. 3b4a. baa4 ba3ali +4bab babel3 b3abil ba6bilis ba4br bach3e ba4cla bac3t bac4ulif b4a3da badar4 b4adic +ba4din baen4 ba4gat 3b4ah bah4n 3bai bair4 ba4je bak4erl bak4l bal3an4 ba4lata +bali4o balis4te ba4lit ballas4 balle4 bal4lo. bal4lup bal4mi 4balu ba4m b2an 3ban. +ba3na4 banda4n ban4der. ban4e ban3i ba4ni4a ban4il 4bano banse4 bant4 ba4p bapos4 +bapt4 3bar. 4baran bar4bl bar4cl bar2i bar3ic ba4rin ba3riz bar4my bar3og barris4 +bart4 bas4a ba4sani bas4es. b4asi ba4sil. 2basis bas3k bas4king bas4sis bas4tron 3b2a2t +4batae 4batean bate4s bathe4 bati4e 4batis. bat4s batter6i 4batto 4bauchee baw2d baxillar6 +ba3ya ba4za 4bazit 2bb2 4b4b. b3b4a b5bab bbal2 bba4si 4bbe b4bed bbe4s +b4besi b4bev 4b3bi bbi4a bbi4na bbi4t bbit4i 4b3b4l b5bli b3bo 4bbu 4b3by +2b1c bc4i4 bcres4 bcun4 2b1d2 4b4d. bde4m b3di bdisti4 b3do b3du b2e +2b4e. bea4h be2a4n3 3bear beas4 beat4r bea3v be4bi be4br be4ce 2b4ed 4bed. +be3da bede4v 3bedie be2dr 3bee bee4f 4befa befal4 bego4 begoni4 be3gu be4han +3bei2 be3ia 4belai be4lan 4belar 4beld4 4beles 4belet be4lia. 3bell belle4 bellig3 +4bellik 4beme bemi4 4benda ben3ed be3nev3 ben4i be3nig be4nis 4beno 3benz b1era +b3eral 3berc 4bercr berdi4 b4ere be3rea b4erg4 bergi4 3b4eri be4ri. ber4im beri4o +be4rios berlin4 3bern b4ero 4berose b4ert. ber3ti ber4tie ber4tit b4eru 4b4es. 4besias +be4sier 4bes4n be3sp be3stow bet1i be4ton be3tr be3tw be4v bev3e bev4er. 4bevi +bevil4 be3w 3bey beyo4 be4ze 2bf bfoun4 2b1g bga4 bgen4 2bh bhav4 +b3hor bhyoi4 b2i 3b4i. 3bia 4biae bial6iza bia4n bi4anc bi4arr bia4t bi4b3 +bib4a bib4it 2bic b4ich b4ici 3bicu 3bidd 3bidi bid3in 3b4ie biel4 4bieta +2bif biform4 bi2g bi4gl bigo4 big5ot 4biki 3bil1 4bila bi4land 4bilen bi4les +bil2g bi3lin bi3lise bi5lisi 4bi3l2o 4bilyn 4bin' 3bina bin2d bin4e bine4t bin4ga +bini4e 4binog 4bino4s bi4nou 4binso 3bi1o bio5e bioe4c bi4ol. 4biotr bi3p bipa4ro +3biq bir2 bir4c bir4l birm4 b4is bis4e bish3 bis4p b3ited 3b4itio b3itiv +b3itor bi4tram b4i2tu 3bitua 3biu biv4i biz4e 2bj b4j. b3je b3ju 2bk2 +bk4c b2l2 4b4l. blag4 4blah 3b4las blas5p blas4ty 4blat 4blau b4laz 3b4l4e1 +ble2a ble2m 4blem. blem3at ble4nit ble4p bler4 ble4sk 4bless 4blet 4bl4i b3lic +b4lica. b3liga bli4gato 2blik 5blin6d b4ling bli4ni b3lish 4bloi 4blol blou4 blri4 +blu4e 4blum blun3d 4blut 3b4ly 2b1m bman4i b3m4e bmer2 bmi2 2b1n 4b3ne +4bni b3no bnubi4 bo2 3bo. 3boa4 boan4e 4boant boas4 bo4b 3bod1 bod2h +bod3ie b4oed boeti4 b4oh 3boi bois4 bok4s bo3la bol4e bo4ley 3bolic 3bolish +b3o3lit bo3lo bo3lu bomba4b bom4be bombo4 3bomi b2on2 2bonai bonar4 bonav4 bon3i +bonse4 bo3nu 3boo 3bor. bo3ra bo4rab borac4 bor4al b3orat borbi4 bor3d bor4ic +4borin. bor3o. bor3ou borre4 3bort bor4ty 3bo4s bos3ci bos4si bos3w b4ot botan4 +bot4e bo4th 3bou boun4c boun5t bou4r bous4t bo3va boxyl3 3boy 2b1p bpe4t +bpro4b 2bq b2r2 4br. 3bra 4braca bra4ce bra4che 4brado brail4 bra4ne brank4 +brant4i bra4r bras2 bras4e bras4si brau4n 4brawo 4brd bre2 brea4t bre4co brel4o +bre4m 4brep bres4c bre4th bre4to 4brev. bri4b b4rici 3bri2d brid3i 3brie 4brifa +4brifuge bri4g brig3an 6brillato brind4 4brio. 4brioi 4brion 4brios 4briu bro4a bro3g +b3roga broge4 broid4 broi5de brok4 brom2 bro3mi bromi4n 4bron. bro4ph bro4sy broth3 +4brua brun4 bru3ne bry1 4bryc 2b1s2 4b4s. b4sb b3s4c bscon4 b3se 4bsh +bsi4 bsin4t bsis4 bsist3 4b3so bsor4 bsorb5en 4b3s4t bstra4 bsur4 2b1t2 bt3ab +bte2 bter4m bti4 b4u. bu4be bubin4 bub4o bu4chi buco4l budge4 bu4gou buil4 +build3 4buj 2bul bullar4y 3bullie bul4lo bulu4 bume3ni b4un4a 3b2und bun4dis bu4ne +bun4ger bun4t4i 3bur bu5ra bu1re bu4ria buri4at bur5ie bu3ris bu3riz bur1y 3b4us +busi4e buss4 bust2 bus5tib bus3y 4butan b3utar3 3butio b3utiv b3uto but4s 2b1v +2b1w 3b4y. by4b b4ye 2byl4o 2byr by3ri b5yrin byr4l bys4e bys4sa bys4t +byte4 by4z 2bz bze4 bz4i 4c'd 4c't 2c. 1c2a 3ca. 4caa. 4caaa +cab3a3li ca4bell cab3ine cab4o 3cac cacci4 cach3i ca3ci ca4cit ca4co cac3o5d caco4p +2cad4a cadav5 2cade cade4mi ca3den cael4 caeni4 c4ag cagel4 cag2o ca3gon. 2cah4 +cahi4 3cai 3cal. ca3lad calde4 cal4di ca4len ca4li. cali4fa cali4g calit4 cal4lin +cal4mi 4calog cal3or cal4pi 4calus ca4ly. ca4mak 4cambo 4camen cam3er 4cam4et cami4e +ca3nar canari4 can3c4 can3d4 4canedi can3k4 ca5noe can5ol cano5p4 4canor cant4a can4tic. +can4u 4caoni ca3pa cap1i 4capl 4capod. capon3i cap4r car1 car4an carbor4 ca3ree +car5eo ca3res4 care4u cari4sa carmi4n car3oli car4ter. car4tes car4tho carti4e car4v ca2s +cas4a cas4ser casso4 cas3tel cas5tle 4c3asty casty6la casu3 2c3asy cat1a catani4 cat3e3c +cat3eg cat4er. cat4eri ca4th cath3e3t ca4ti. 5catio 4catiz cat4j ca5to 4caton 4catoo +4cator' catri4cl catter4y ca3t4u4 ca4turas cau4s cav5er2 ca4v3i c2b2 c4bi 2c1c2 c4c. +ccar4y c3ce ccel3 ccentric6 ccep2 ccept5an ccessar4 ccha1 ccha3r4 c3ci cc4l c4cles. +cco3l ccompa3 ccoun4 ccout4 ccun4 ccu4san 2cd cdi4 cdo2 cdo4t cdou4 cd4y4 +2ce2 4ce. ce5a. 4ceab ce3ani 4ceanog. 3ceas ce3b ce4bri cec4i cecili4 4ced. +ce3da 5ceded 3cedi 3cee ce3f ceg4 ceger4 3cei2 ceip4 3cel. 4celain. 4celeu +3cell ce3lo celo4n 4celot 5cels cel4t 4cely ce3m 4cem. ce4met 3cen 5cenc +4cend cend3a cend5ent. cen4e 4cened 4cenes 4cenet cen4ic 4cenin4 ce3no cenogra6 ce4not +censur5a cen4tag 4centar 4centati cen3ter cen6tic 3ceo 4ceor 3cep 4cepe ceph1 cephe4 +4cepl 4cepo 4cepr 4c1er. cera4m 4ceran c2erb 3c4erc 3cere cer3em 4ceri4n 3c4er2n +cer4ne cer2o 4ceros cer3ou 3c4ert 4certil certin4 4certina c4e3r4u cervi4e 3ces 4cesa +ces4co 4cesh 4ceso 4cessa 4cesse ces3si 4cest. 4cesu 3cet. ce5tab c3etal ce3tat +cet2i 3cetic ce4til ce4toi cetu4 3cety 4cetylc 4cetylic 4cetylmo 4cetyls 3ceu ce3w +4cfo cge4 cgre4 ch'4 4c4h. c4ha 3cha. 4chab ch3abi 4chaelm 6chaeoc 4chago +3chai cha3l4a 4chalo 3cha4m chan4cy chan3d 3chan4g 4changel 3chanic 4chanici cha4p char4g +char4ti 3chas 3chat chatu4 chaza4 4ch3b4 chcom4 2ch3d4 2c4he2 4che. 3chea4 cheap5 +3check. 4ched ched4di chei4on che5k che3li 4che3ma 4cheme 3chemi che4m3o ch4en. 4chenbe +4chener 3che4no che4p cher4t 4ches. che4te 5chette chet4tin 4chf 2chg 2chh ch2i +3chi. 3chial 3chiat chi4cle chi2co 3chid 4chies 3chiev chi4ge chi4l5i chilo4m 4chilu +4ch3ily 3chim 4chin 5chin. chind4 ch5iness 2ch3ing 3chio chi4oe chirp3 chir4ri 3chise +3chit 4chitar chiv3a chiz5oph 4chk 2chl chle3a 3chlo 4chloi 4chly 4chm chma4 +4chn4 c4ho 4chocy 4choe 4chogy 3chon 4chono 4chony 3ch4or chor4i cho4ry 4chosph +cho2t chot3o 3chow 2chp chr4 4chroi. chro5niz ch3rym 2chs 4cht4 ch3te ch3th +chu4l chur4 chura4 chu4t 4chw4 ch4we. ch3y3d 3chym chy4pr chys4 4chyto 1ci2 +3cia cia4l ci4alite cia4n ci3ar 4ciare ci6ata 4ciatic 2ci3b cic3at ci4chl 4cici +3cicu cic4ulus 4cidas cid3i 3cidif 3cidit 3cidu 4cidur 3cies. ci3est cie3t cif1e +ci4fic 3cific. 5cifical c3ifis ciform4i cig4e4 4cigr cil3ab 4cilat cil3i 5cili3a cil4ie +4cil4is 3cilit4 4cilite 4cilitr 3cilli 5cillu ci3ly 2cim 2cin 3cin. ci3na ci4nac +c4inatio 3cine. 4c3ing 3cini cin4quef 5cinth cioe4 4ci4ol. 3ciou 2cipa 4cipe ci3ph +ci4p3i 4cipic cip4is cip4it. c4i3p4l cip3ro 4ciproci cip4ule 4cish ciss4 4cista 4ciste +2cit cit3ab 3c4ite. ci4thi cit3i3z citur4 3ciz 2c2k1 4ck. c4k3a ckas4 4cke2 +ckel3i ck3er cke4t ck3i ck3l ck5le 4ckm ck3o cko4h 1c2l2 4cl. cla2 +4cla. 4cla4do4 claf4 4clame clam3o 4clamp clan4g 4clarati clari4no cla4ro 4clasi clas4p +class4es clat4u4 c4l4e 3cle. cle2an 2c5lect cle4la cle4m cler4a cli4f4 4clik clim4 +5clinab clin4g5 cli4ni clin4is 3clip cliq2 cli4to 4clium 4cliva 3cl4o 4clogi 4clogu +4clomy 4clonu clo3p clos4t clothi4 clow4 cl4u2 clu4b 4cluc clu4d 4cluse 2cl4y2 +c4lym cly4p 2cm2 4cma 4cme 2cn c1ne cn4i 1co2 3co. co3ad coar4 +c3obit coble4 3coc co4cal cocar4p 4coche 4cocot cod4ic coenter4 co3er coffi4 4cog. +cog1i 2cogn co3h cohol5 3coi co3la co4lan colas4 col4e co4ley co3lit col3l4 +colli4e col2n 5cology co5lom 5colon col5oniz 5color color3i 2colou 3com co3ma co4mato +com4be comed4 com3er com3pi com4t 3con. 2cona con2al 2conci concy4 con4dr conger4 +3conic 4co5nid con4if con4is co5nit co3niu conni2 6connoitr 3cono conspir5a cont2 contan4 +con5ti coo3e cooker4y coo4la coo4p cooper4y co3or3 co3pa co4pag 4cope co4phy cop1i +4copic cop3ie 3co3pis co3po copol5y co4pr 3copy3 co3py. co3q 2cora co4rac cor3al +co4ram co3rat 4corb c4ord cord3an cordel4 cordi4 4cordl core4la cor4ic 4corism cork4e +corn4e 2coro coro3n4 4corp. 3corr cor4rel. 3cor3t cos3tal costi4 costu4 co4su co3ta +cot4er c4otia co4tic co3tin cotin3i co4tit 4coto4s c4ott cotti4e cotu4l cou5ple cour3a +6coustici cous4y cout4e couter4 3cov1 co3v2a co4ve4 cov4el cov3er covet5 covi4 co4w +4cowa cow5h 4coz2 cp4s c1q 4cq. c4quem c4queri c4ques. cr2 cra2 c4rack +2c3racy crad4 cra3n4i 4crano cra4p crap3i crap5u cras5t 3crat. 4c3ra3tes 3cratic 4c3ratiz +crawl3 3cre. cread4 2crean cre3at 3cred1 credit5ab cre6mas cre4natu crene4 cre4p crep3i +2creta cre4te creti4 cre4vi cre4w 4crg cri2 c4ria cri4b 4criba crib3er c4ridi +cri5f c4rin 5crine 4crinou 4crip 4cris cris4e c4risi crit1 5c2rit3i cro3ch cro4dr +cro4f cro4mar c4ron. c4ropi cro4pl cro4po c4rops cros4ci cro4set crosi4 crot4e c4ru +4cru. cru3c4 cruf4 crum4b 5crus crus4e cru3t cruti3 4cry. cry4ly 5crys 2cs2 +4cs. 4csi c4so. 4csp c3sta 2c1t 4c4t. 4cta c4tab ctag4i cta4r3 ctar4y +4c2te c4ted c3t4er c4ter. cter3is cter4o ctex4 c4tg cth4 4cti c4t1ib c3tic +cti2m3i c3tina c4ting cti4nos c4tish cti4vi ctiv6ise. ctiv6isti ct3l 4c3to cto1m ctomor6 +ctori4e ctro3c ctrop4 ctro3t c4ts 4ctu ctur3a ctyl3i cty4log cty4loi ct4z c2u +cuad4 cub2 cu4be cu3b4i 3cue 4cuee c4uf 3c4ui cui4a cuis4 cu1l c1ula +c3ula. cu4lam c4ulare c4ule 3culia cullo4 1cult cul5ta cul4te 4cultie cul4tis 3cultu +1c4um. cu2ma 4cuman c4umb 4cume cumen4is 3cumi cu4mo cum1s 5cumu c4un cun2a +cund4 4cundin cu5n4e cun4h 3cu3n4i cun5n cu4ph 4cupl 1cur curan4 cura3r cu3rat +cu4rativ cur4bl cur4d cur5dl cur4f c4u1ri cu2rin cu2ris cur4l 6currency cu4rum cur4vey +3c4us 4cusan 4cusec cuss4 cus5sin cus3t cu4tanc cutch4 cu2te 3cuted cuti4e 3cutin +cutin3i 3cutio c3utri cuy4 c2v 4cx. 1cy 3cy. cy1a cy4bel 4cyc. cy4cla +4cyl. 4cyla cyme4 cyn3i cy3o cy2p cy3ph 4cypod. cy4s cy4tiu c2z cz4e +4d's 2d. 1da 3d2a. 2d1ab d4aba d2abe d4a4br 3dac da4ces 4dach4 da4di +3dae dae4ni 2dag d3age. da4gr da3gu da4hi dah4l 4dahy 3dai daik4 dail4 +4dair dak4e da3la da4le 4dalid dal4if 4dalir4 da2m2 dam3ag dama4n d5amant damas4 +da4mo damp4ie 3dan. 3dana dand4 3dane d4ang dan3ge 4dani. dani4l 5dantic dan4tr +d2ap 4dapho 3daptab 5dap4te 3dapti dar2 3dar. 2dard d3ardi da4ri 4daris dar4i4us +darmer4 d4art das4tu 3dat dat4c da4til datilo4 da4tol da3tu dau4b daugh3 dau4r +dau4t 3dav dav5er da3vi daw4t 4daxi 3day 2db dblind4 2d1c dcas4 4dcl +dcol4 2d1d2 d4d. d3de d3dh d3di d4dib 4d3d4l ddys4 4d4e. 1dea deaf5e +deal4a de3am de1an de4as dea4th 4deaw de2bit de4bra. de4but 1de1c 3deca deca3d +de2cag de4cane 4decar de4cast de4cat de3ce 4deces de4cile de4cili de4clo decol3 dec3o3ro +de3c4r 2decto 2d1ed deda4 3ded3i 4dedl de3du 1dee deem3 def4 4def. de4fini +def5lag de4flor deform6i de1g4 3de1i 4deico de4im de4k dek4e de5ki 3del. de4la. +de4lano del4at del3eg dele4m de4li. de4lia deli4a. delib3 deli4e de2l3iq de4lis. de4lit +de3lo 3delp del4st de2lug 1d4em dem3ago deman4 dema3t de4mero 3demic dem3ic. 4demicia +de4mo. demon3o demor5 1den d4ena 3dena. de4nar4 d4enb 2denc d4enci 4denes deni4e +de6nol d4ens. den3sa d2ent 2dent. 3dental dentar4 d4ente4 3d4ent4i d4env de3o d3eog +3deol 1d4ep de3pa depau6 de3pic dep6ilato de2pu d3eq 3d4ere der5el de3rid 4derie +der3k4 derle4 3derm der2n d4erne dern3i der4ob de5rogato d4er4ol d4erop der4or. d6er3os +3derou derpi4e d5ership der3t derto4 4dertot 1des d4es. desal4 desar4 des4c descar4 +3desce des3ec de4sei des3ic des5igna 4desn de2so des3o3l de3spon des3ti 3destri 4desy +3de1t de3ta de3te de4ti de4to det2r de4tri 3deu de1v dev3as3 de3ve devel3 +de4ver dev3il de3v4o de4war. 3dex 4deye dez4i 2df dfas4 4dfo4 2d2g dg5ab +d4ge dgel4i dge2o dg1er dge4ta dg5ete dg1i d3gr d3gu dg3y 2dh 4dh. +d1h4e d4hel d4hen 3dhi 4dhib d3hou dhun4 dhy2 3d4i. 1di3a 3d4iac 4diad +3diae di4aln d4iam di4am. dia4n dias6tro 3diat dia4z 2di3b di4bat d1ibl d4ibo +di4bu 3dib3ul di4cam d3icap di3cea dic4ed di1ch d4icin4 d5icine 3dicit 4dic4l diclin4 +3d4ict dic4te 3diculo 3did didy4n d4ie. 2died 3di1en di4erl die3v 5d4iff d3ifi +d4ific. d4ifu 3dig 4digal di2g1i 4digr 4digy di3i di4lem dil3et di4l3i 3d4ill4 +4dillu d2im2 di3mi4 dimin3 di4mis di4mou 2din 3d4in. 3dina 4dinag dina4l di4narc +din4e 3dine. din4gu 3dini 4dinite di3niz din4kl di3o 3dioa di4oc. 4dioco dio4de +3diog dion4 di4or. diora4 d4iori dio3te diother6m d4iph dipha4 di4pla dipo4c dir4 +di4ral 3di1re di4ref di4rel di4ren di4res 4d5iro dirt3 d2i4s1 3d4is. dis4ant dises3 +dish4a dis3i dis5pi diss4 dis3t2 dis5ti d1ita 4ditat dith4 3diti 6ditione. d3ity +3d4ius 1di1v2 dives4 div4i 4divid. 4diviv di3vo 2d2iz 4dja djective6 d4jes d4ji +d4jt 2dk d1l2 2dla d4lac 3dle. 3dled dle4i 3dlel dles4 d4lest 3dlew +2dli 2dlo dlor4 2dlu4 2dly 2d1m 4dm4e dmin3 dmin4d dmis3 4dmo dm4s +2d1n 4d4n. 4d3na dney4l dni4 4dno 1d2o 3do. do1a doac4e 3do3b dob4l +dochei4 do3cr doc3um do3de doder6mi 4dody 3doe dof4 4doff 3dog 4dogr 3doi +4doit do2la dol4g dol3ic do4l3is 2dolit dol4li do3lom dol2or do4lou dol4u 3d4om +domen4 do3mes do4mete dom1i dom3ic 4domid do4mine do4miz 4domou 3don 4donai 4donij +do5nisti 4donit do2nou do4ny 3d4oo doo3d4 3dop dop4a dope4 dophi4 4dopto 3d4or2 +4dorat 4dor4ic dor3if dork4 dor3m dor3ou dort4e 3dos do4sag doss4e doster4 3do2t +do3th 4dotr 4dotu do4u dou4c dous4t 4dout do2v 4dovary 4dowa dow4ie 4downe +4downi 3do4x doz3i 2d1p2 4dqu 1dr2 4dr. dra2 dra4b 4drace. drag3on dra4gr +4drail drala4 drav4 dra4w dr4e 5dre. dre4i dres4se 4drh 3dria dria4n dri4b +4drik d4ril driple6 4droe. droga4 drogen3 3droi 4droll d4rom 4dromal drom3ic 4d5romou +4dromy 4droot drophobi6 drop4o dross3 4drow dru3i dru3p 4drut dry4e 2ds2 ds3b +d3sc 4d1s4e d1sh d4shu 4dsl ds3m ds4mi dso4 dsor4 d3s4p d4spo d1s4t4 +dsth4 d4stin dswor4 d4sy 2dt d4ta. d4tes d2th d4tia d4tis 1du 2d1u1a +dual6iza d4uar du3bi dubi5t du2c d3uca 3duce 3duci 3duct duct4in 3due du3el +du4gou duik4 2dul d3ulen d4ul4i 3dult 5dum. dum4bl d3umbr dume4 3dun dunc4 +4dung du4ni dun4nite 4dup. du4pa du4pe du4pin dup4l 3dupli 3dur 4durol 3d4us +dus4i dus4ta d1v dvan4c dvert3e 4dvi dvi4se 2d1w dwar4 dw4e d2wel4 dwif4 +dwifer4 1dy dya4n 4dydi dy4fe 4dyl. 4dyll. d4ym 3dy1n 4dyno dyo4 d4ys1 +dys5e dy4su dyte4 dz2 4dza4 dzo4 2e. 2ea2 e1a. 4e3a3b e4aba e4abe +eabi4 each1 each3i ea3co4 eac4s e3act e2ad1 eadapt6 eaddress6 4eade ea4d3i e3adj +e3admi e3ador e3ae eaf5en ea4fi ea4fy e4a3ger ea3gl ea4hi eak1 e4ak. eal3a +eal4at eal3er eal3i ea4lis eal3l e2alm eal3o ealth3 ea3ma e4am3er eam3i eam3y +e1an. e3and e4ane ean3er ean3i e3anic eano4r e4ans ean4tr e4apa e4ape e4api +ea4py ear3a ear4at ear4c e2are e4argua ear3in e4armo ear2n ear2t eart3e e4ar4th3 +e2arw ear2y easan4 eas4l eas4t e4ast. east3e easty4la e3ate. eat3en ea4t3er e4ath +eathe4n eath3er ea4thy eat3ie e3at4if e3atio e3ativ ea4to. eator4i e1atr eat3ri e4a3t4u4 +4e2au eaus4 eav3en 4ea4vi ea3w eb2 4eb. e3ba2 e4bara ebarba4r ebe4c e4bel. +e4bend 4eber 4ebic e4bis4 eb4l e4boan ebok4 e2bona eb4or e3br e4braw e4brio +e4bris. ebro4p e4bry ebs4 eb4t3 eb3uli eb3ulo 4ebu4r ebusi4 e4buta 4eby 2ec +e4c' 4e4c. 4eca e2cad eca3de ecag2 e2cago ec5agon. ec3ah e4cali e4calo ecami4 +e4can. e4cana e4cano e4care ecas4t ec3c4 eccar4 ec4clu ecd4 3ecdo4 e1ce e3cel3 +ecenar4 e3cer ecer4n e4chea 3e3chis 3e3chiz ech5ua ech4we eci3a. eci6able e4ciar eci6ativ +ec5ificat ec3ifie ec3ify e4cig e4cilit ec1i3m4 e3cio ecip3r e4cist e3cite ec3i3th ec4k +e4clam e2c5lec e4clina ecli6nato6 e4clino e4clog e4clom e3clu e4co. e2cog e4col. e4colle +ecolor3 e4comme ec5ommend ecom3p ecompari6 e6compen ecompos6e ec3onci e4condi e3co4p e2cor ec3o3ra +e4cord. ec3o3rou e2cosp ecos4s e4cow e1cr e3cra e2crea e4crem ecrep3 e4creto ec4ri +ecrit3 ec3rom ec3st ec4te ecti4no ectiv4iz ecu4bitu ec1ul e4c3ula e3culi e4cuma e3cun +e3cur e4cury e4cus4a e3cute 2eda ed1ag e4dah e4dalu edan4te e3dar eda4tis e4dativ +e4dato 2edd4 ed4dite 4e1de edek4 ede4mo ed2en edentar5 ede3p e2der ed3era e3des. +ed3es3ta e3destr ede2v e4dey 4edgel4 e4dh 2edi e3dia ed1ib e2dica ed3icab ed3icat +edi1e ed6ifica e4dik ed3ime e3dio ed1it e4dita e4ditiv e3diu ediv4 ed4j e2dl +1edn e2dole edo2no e4dony edriv6en e4droe 4edt ed4ta 2e2du ed4ua 5educa 2ee2 +4e4e. e4e3b4 ee4b. ee4c eech3i e2ed1 eed3in e3ed3u ee3h ee3i4 eek1 e4el. +eel3ab ee3lan e4eler e4em. eemo4 e4en. e6encoun e4ened e4ener. een3in ee4no eensbor4 +ee3nu eep1 eep3i ee3ru eesep4 e2est. eet3i ee3va e4eve e4evin ee3w e1ex3 +eexa4 eex4plos 2ef 4e4f. e3fa efal4l e4fana efe4 e2fer ef5ere efic4 3eficia +efici6ary e4fila e4finit ef5i5nite ef4l e4flag ef5lagr e4flora e4flu e3fo eform6it e2frag +efrig3 ef4t eft4il 4e3fu efu3e e2fug efun4 efu4t e4futa 4ega. egaci4 4egal +egam4e eg4an 3eganc e4gano eg4are e4garin egas4 e3gel egel4e ege4m egen1 e2g3end3 +eger4a eger4e eg4gr e3ghe e2gi2 e3gi4a eg3ib eg2ic e3gil eg3ime e3gio eg1is +e3git1 e2gle eg3li eg4my 4egn eg4no e3go e4god ego3i e4goli e4gor. ego3ris +4egos ego3ti e4grad6a egrammat6 4egraphy e4gri. e2gu e3gui e4guin. eg1ul e5gur 2e1h2 +e3ha e3he ehear4s e4hen. e3hi eh3me e3ho ehr4 e3hu ehy2 ehydro3 2ei +4ei. ei4a e3ian e4ib e1ic. ei4ce ei3d4e 4eif eig2 eil4e eil3ing e3illu +e4imar e4imo e4ims e1i2n e4inag ein3au ein4e e5ing. e4inho ein3i e4ins. 4e4i1o +ei4ov eip4i eip4t eir4d eire4 eir4i e4isa e4is4e eiss4l ei3su e3itera eiterat6 +ei2v eiv3er e3ja e4jel e2jud ej3udi ejun4 ejus3 2ek ekam4 eka3t ekate5 +e5kater e3kee ek4n ek4s e4l. e1la el3abl e4lacy e4lae e4laf e3lagi e4lagin +e3lai 4elan el3anch e3land elan4i el3ano e4lano. e4lapi elas3 5elast e4lates elat3in +e4latin. elati5niz e4l3ativ e4latori e4latory e4latur elav4 e4layl el4di el4do. e4le. elea4 +e4lean e3leas e4leat el3eb e3lec ele4ch e4lee e4l1eg el3ega e4lem el3eme 3elemen +e4lena e3lene elen4i e4leno elen4t e4leo eleono4 e4l3ep ele3ph elepi4 el3e4r 4eles +e4lese e3let. el3eto eleton5 e4leu e4lev el3eva e5le4v3e el2f 4elf. elgi4 e1li +e3l4ia e4liar elib3e e4libi e4libl el3ic. el3ica eli4ca. el4iced e3lici el4id. e4lidl +el3igib e4lign 4elik e4lim. e2lin e3lina e3l4ine el3ing eli3on e3lios e4liph elir3 +e4lira e2lis eli4sa eli4sor e4list e3liu eliv3 e2liz el3ki el4labl 4ellan. elle4r +el3li 4ellif ellif5l 4ellit 4ello 4ellu ellu5r 4elm. elme4 e1lon elo4na e3loni +e4lono el3op. el3opa el3oq e3lor 4elot 4e3lov e3low elr4 el3si el4tan el4ti +el4try e1lu elui4 elus6te e3ly 4ely. ely4a 3elyt el3ytr e4lyu e4m. em3ab +emagnet5 e2mago em4al e3man. e2mana em3anat e3manc eman4d e4mane eman4n e3m4ar e4mare +emargi5 e3mas e2mat e3mate em3atoc em5atolo em3atop e6matoriu e6mature. e4mav e4m3b4 ember6m +em5bl emblem3 3embo embod3 5embry e1m2e e4meab e4medie eme4diev e4mel emeli4 eme4lia +e3m4en ementa5r e4m3era e4mere e4merol e4mery e4m4es 4emet4 e4mete emica4 em3id4 4emie +e4mige e4migran emili4a em3im emin4 em3inen e4mingt e2mini e3miou e4mi3p emi5pa emi1r +emi4rus emis4c em3i3s4p em4ite. em4iti emi3v emm4 5emniz emod3u e3mog e4molit emon3e3 +e4mone. emon3is e4moniti emon3ol emo4nu e3moph em4or. emor4a e4mora. e4m3orr e4mory e4mosc +e4moso e4mosp e4mosu emo4te emp2 em3pe emper4e em3p4i 3empir empo3 e4ms e4mu. +em3ula em3ulo emun4 e4mv em4y emy3tho 4en't e4n. e1na 4ena. e2nac e3nade +enadi4 e3nae en5age. e3n4a4l ena2m e2nan en5ancy e4nar4d e3nari 4enary 4enbu 4enc. +en4ces 4encia 4encio 2en2d end6aged en3dam en4d3an en4dato en4ded en3dic 4endre en4dry +end4wr endwrit4 e5neal e3nei 4e4nel enem4 4enen e3n4eo enep4 en3er enervat6i 4enes +e3nes. e4nese en3esi e4nesse enes3t en4eti e3neu eneu4ri e3nev3o ene4w en4fer eng2 +en4gel en5gine en4gle eng3li 4en3gu engue4 4enhairt 4enia e3nia. en4ian e4nias. en3ic +eni4dea e3nign e3nil en3ing enion4 e4nip eni4r en3ish e4niso en3ita en3itu en6izer +4en3k enka4 4enna ennar4 enne3a 4en4nim 4enny e1no 4eno. eno4e 4enol e4nold4 +e4nom. enom4ic enon4i 4enopho 4enor en3orr e4nors 4e4nosin eno3th e3n4ou e2nov 4e3now +enro4 en2se ens4es ensi4n 4ensk ensur4a en4swe e4nt en4tage en5tar 4ente. 4entem +enth4 3enthet en4thou enti6pe enti4r ent4is 4entiv en2tiz en4tox entre3p 4ents en3twi +4e4nu. en4ual en4uf 4enul e3num e1nun en3ure e3nus e4nuses en3v e3ny. enyl3 +enz4 4enzi 2e1o 4e3o. eob3l eob4u e3oc eo3ch eoder4 eo3e eoe6c eo4es +e3og e3ol eo3la 4eo1li eo4logus e5ome e4omin eon4as e4opar eo3po eora4 eor3d +eo3re e2org eo6rient. eo4r1o e3os eo3sin eo3sta eostra4 e3ot eo4t4e eo3th eoti4 +eo2to eox3 eoxi3 4e2pa e3pac4 e3pai e4pana epar2 e4para ep3a3rab epari4 e3pas +epa4tia epa4tic epau6p e3pav e3pay e4pech e4peh epend3 epen4t e4pert e4pet e2pha +epha3la ephe2d ephem3 ephe4u e3phi e4phia 4e4phonis 4ephony e4phos 4ephr ephtha4 ephy4 +ep1i ep3ica ep3ice ep3id ep3igl ep3i3l 4epin. e4pion ep3iso epi3t ep3ita ep3ith +epix4 ep3iz eple4r e2pli ep3lic e4plorat e2poch epol4 ep3o3nem e4positi e4potis e4pp +ep4pes eppli4 epra4n e2pre ep3reca ep3red ep3reh epre4m ep3rese e2prob ep5robat epro4c +epro4l eprom5i ep3si ept2 ep5tib epub3 ep3urat e2put ep3uta e1q e4q. e4qq +eq3uab e4quer e4quie e6quine. e2q3ui3s e2quit er1 e4r' er3ab era3ch e5racio er4ah +e3rai e4raim erak4 1eral 3erali 4eralia era4lo era4ma e5raphica era3pi er4aps e4ra4r +era4s 4er4asi er4ass. er6ators 4eraz er4bage. er4bie er4bly 2er3c 4erca 4erd. er5da +erder4o erdi4e erdit4 erdo4s 2ere 4ere. 4erea e3real er3eb ere2c ere4co eredi4a +er5edu er5ee. ere6forma 3er3eig er3emb e4remou er3en 4erene 6erens eren4tis 4ereo er3er +eres4i er3ess er3est. er3este er3ett er3ex ere4z 4er3ges 4ergia erg3in er4git ergi4u +erha4 2eri 4eri. 4e1ria e3ria. 4er4ib eribe4 4eric er5icu 4erides erid4l eri4dop +e3rien 4erii er4imet 4erinc er3ine 4e1rio er4ion e4rioni e3rior erio3ra erior3i e4rioso +eriot4i e3riou 4erip e5ript eripter6a eri4q er3ir 4erity 4eriu e5rivativ e3rive 4erk +er3l erlan4d erm2 4er3me er4mel. ermen4 er4mer. er3m4i ermon3i er4mor. erna4c ernali4a +ernau4 4erne. er4nen er2ni ern4ie 4ernit 4e1r4o. e3roci erodacty6 e4rogeni e3roic eroid4 +4eroli 4eroll e4rometr e4ron. eronau4 eron4is 4e3room ero4tol 4eroun 4erow 4e4rox erp4 +er3pe 4erpen erpi4er erport4 2err4 erre4fo error3 2e4rs ersar4ia 4erse erse4h erse4p +ers4es ersh4i ers3ib ersi4e ersi4u er3sk 4ersl 4ertas erte4n ert3er. ert4h er4thag +4ertic er3tin er4tish er4tite 4ertiu er4tl er3tw 4eru. e3ruc eru4e 4er3ul 4erum +4erun e3rup er3ur 2erv 4erva. 4erve erv3er. 4ervo 4erwal 4e4rx er4yo erys4 +2es. es4a2 e2sag esalin4 e4salp esal4t e4sam esar4g esar4io 4esb4 es3ca es4cara +es5chea es4cia e4scienc 4esco e4scot e1s4cr e2scu ese2 es3ecr e4seil e3sel esel4l +es3enc e4sentee e4sentl e1s4er e4serva es4es 4es4h e1shi esi2 4esia 4e2sic es3icc +esiccat6e esic3u e2sid eside4 es3iden e3sidi esido4 e2sig es5ignat esil3i esil4l e2sim +es3ima e4sin e4sip esir3o esis4 esis5tiv 4esit es3ita esk4 e4skil e3skin 4esm +e4sn esocepha6 e2sol eso3la eso3li es3olut es4ome e4son4 e5son. es3o3na es4oph e4sorc +e4so3te esp4 es3per e4spi es5pirat espond3 espon4ti ess4abi es2sib ess3ing es4sis es4site +4essn es4sy 1est. 4esta. es5tabli es5tame e4stant e4stau e4sted 4estep e4ster. es3th4 +6esthetiz es4tho e2sti estic4i es3tif es3tig es5tina es4tip es3tit4 2estl 4esto es4tor +e5strai e5strict e4stur es2u 4esuc e4sue esui4 e4sult e2surr es3urre es4us e1sy +esychas4 e4syl. esystem4 e4sz e2t e4t. 4eta. e4tab e5tabul etae3 e3tai e3tak +et5allis et3alo 4et1am etan4 e4tant et1ap eta3ph e3t4ar3 etas4 e4tat. e3tate4 eta4th +e3tati etax4 etch3er e3tea e3tec e4ted etel2 e3tele e3tel4l e4tely e4tem e3tenc +e4tene e3tent 2eteo 4eterm e3tern eter3ni eterog6e e3tes e3tet ethar3 e4thea 4e4thel +e3there e3theri e4thic 5ethn ethod3 e4th1y 4etia e4tiam eti4b et1ic et4ica. et3icis +e4ticl e4ticule et4id e3tim etin4 et3ina eti4p e3tir et2is et5ish e5titio et3itiv +4etiu eti4us e4tm etma4 e4tn e3to 4eto. e4toes e4toid e4toma et3ona 4etone +e4tony e4tope e4topl etor4ic. e4towa e5tox et4raci et4rag e3tran e3trat e3tre4 etrea4 +e4trel et1ri et4rid et4ril e4t3rim et4rio etro5f4 et3rog e3tropi etro3s e3tru e4trum +1e3try et4st et4su e4tt 3ettan et4ten et4tes et4tine. 4ettl 4etto et3ua e3tud +etu4i et3ula e3tum e3tun e3tu4r 4etv ety3le 4e4tz 2eu1 4eu. e4u3b2 euc4e +eu3ci euck4 eu4cy eu2i eul2 e1um. eu4mi eun4e eun4o eunu4 eupa4 euphe3 +4eur eu3ra eu4ram eu4ras eury4l 4eus e1us. eus4p eut4e2 eu3ti e3util eu3to +euton3 eu4zl e2vale ev3ane e2vans evap3 evardi4 e4vart evass4 evas3t 3evate 3evati +e4vatt e3vea e4vei e4v3el. ev5elatio ev3ell evel3o e4vena even4i even6ta e2ven4u ev3er. +e4verat e2vere ev3eren ev4ers ever4to e4ver2y e3vet e4vev eviar4y e3viat e4v3i3d evi4e4 +e2vil evil3i e3vinc 4eving evit4er e3viti eviv3a e2vo e3vo. ev3ocab evol3a ev3ole +e4v3olu e4von e5voro e3vot ev3r evv4 ev3y 2ew e4w. ewal4l e3wee e4wic +ew3ie e3wif ew3ish e4witt ew3l e3wo e2x 4ex. ex3a exac5e exae4 exas4t +3ex3c exci4t ex3e 3exh ex3i exi4an. 4exm exon4a 3ex3p ex5ul e2y e4y. +ey4al 4e5yar ey3de 3eye. eye3l eyk4 eyl4 ey4na eyne4 eyo4g ey3or eyp4 +eys4 ey3se ey3t 2ez 4ez. e4zant. ez2e e4zel ezi4a e3zo e3zu e4z3z +1f 4f's 2f. f2a 3fa. fa2b fab4b fa4b3u 3fa2c fa4ce fa3cie fac5u +4fael faer4 4fage fain4 3fal fald4e falder4 fal4l3e fal4lin falo4n fal4sie falut4 +4famati fa4min 3fan 4fana. fanat3i fa4ne 4fanie fani4l 4fans 3far far3ad far4m3 +fa4ro. 4fars far4sp farth4 fash3 fas3ten fa2t fa3ta fatar4 fa3the fa4tiz 4fato +fat3ua fault3 faus4 fau4v faw4 faya4 fay4al. 2fb 2fc fca4 fcas4 2fd +fe2 2fe. feath3 f4eb fe4bris 2feca fec4ta fed1 feel3 4fees 3fei2 fek4 +fe3li fem3 fem4a fe4mer femi3 femor4 fen2 4fen. 3fend 3fens feof4 f2er. +2fera fer3e fer4es feri4a. feri4as 4f1ero fer4ob fe3roc fer4op 3ferr 4ferse 3fert +4fertit fer3v 3f2es 4fesa fes4sa fessing4 fes3ta 3feu fe3v4 2ff2 4f4f. 4ffa +4ffe f4fed f3fen ffer3 ffer4o f3fet 4ffi f3fic ffich4 f4fie ff4ie. ff3ily +ff5iness ffin3i f4fis 4ffl f3fle f5fle. 4ff3n ffor3e f4fr. f4frai 4ffs f3fu +ffus3ib f4fy 2fg fgh4 fgha4 2fh f4i. 3fi3a 5fiab 4fian. fi3b fi4bo +fi4bu 4f1ic. 4fica. fi4cab 4f3ical 4f3ican 4ficate 6ficatin 5ficatio 6ficato 2fic4e 4f3icen +4fices fich4e 3fici fi3cie 4ficis 4ficit fi4cl 4fics 4ficulti 3fid fi3de4l fid3i +4fido fi4do. fi3du 3fie 5fied 5fiel4 fier4 4fies fi4f fift4 3fi2g fight3 +fig1u2 4fik fi5lad fil3am fi4latu fil3i fil2l fil4m fin2 3fin. fin3ab fi3nal +find5 4fing. fin3ge fin4ial. fin3is fin4nic. fin4nis fi4noi fi2r2 fires4 fish3 3fishe +f3ishly f3ishn 5fist fis4tia fi2t f3it3a fi4te f4iter fi4u 3fix fiz2 f2j +2fk f2l2 4fl. 3fla fla4c fla4ge flag3el 4flagr fla4my fla4s fla3v f4le +3fle. fle4a flen4 fler4 4f5less fleu4ro 4fley 4fli. flich4 4flik flin4 flint3 +4flis 3flo flo1r 4florat flor4i flo4rie flo4rin. floy4 flu1e flu3i flu4t 2fm +2fn f3ne f2o 3foc fo4e 3fol 4fola fo1l4i foli5a fomo4 fon4a foni4 +fonni4 fonta4 fon4tal 3foo fool3 3for2 fo6rab fo4ram. fo4ras fo3rat6 for3b for4c +fo4rea for3ei fore5r 4for4mate for3n4 fos3 fote4 3fou fough4 foun4dr fouri6er. fout4 +fow4 2fp fper4 fr2 4fraf frag4o f4rai fran3c fras4 fra4tr fre2d frel4 +f4re2s fri2 4frid fri4ga frig3e fri3k fril4 fro2 4frob fro4ni4 frost3 frot4ta +f4ru 4fru. frui4 frus3 2fs2 fsa4 fsur4 2f2t 4f4t. 4fte 4ft1i ft3l +f4tm 4fto f5ton 3f4u. fuck4i fue4gi fu3el 4fug fu4gu 3ful 4fulo fu2m +fu3ma 3fun fun4a fu3n4e 3fur fu4rat fu3ri fu6riate. 3fus fu3sil fus2s3 fuss5i +fu4s3t fu2ta 4futab fuz2 fuzz3 4fve 2fw fy1 3fy. 3fyi 5fyin fy4k +f4yo 2g. 1g2a 3g4a. gaar4 ga4ba 4gabo ga3br gac4 4gach gadge4 gad4h +gadi4 gadis6 ga4dit ga5dr 3g4ae 4gaeu gagam4 gag4n gah4 3ga4i gais4 3gal. +gal4a 4galay ga3len g4ali gal4ib 3galis gal5ler gal4lin galma4 ga1lo galo4pi 2gam +gam4a 5gamb gambi4e games4 3gamic g3a3mis g3amou g3amy 3gan 4ganap gan3gl gan4gla +gan3i gan4im 4ganip gan6iser ganis4ti 4gann 4ganof ga4nome gans4e 2gapo ga4py gar3den +g4area gar4it garn4 ga3r4o 5gart gar4ti ga2s ga4se gask4 gas4me ga4so gas4per. +g4ass4 3gas3t 3gate 4gatee 4gatew 4gatha gath5er. gath4i 3gatin 3gatio g3ativi gativ6ism +ga4tiz 4gatorin 4gatu 3gau2 gaus4 gauta4 gauti4 gav4a ga4ve gav3el gay4a 2gaz +2gb 2gc g4c. g3ch g4cy 2g1d gd4i g4dy gdyni4 4g4e. ge1a 4geab +ge2ar 2ged 4ged. ge4dal 3gee 4gef geiss4 gel4a 2gele gel3ic ge4lid ge4lig +4gelik gel4in g4ella. g3ellat g4elli 2gem 4gema gemar4 gem3i ge4mo gemon4is 1ge2n +ge3nar 4genary 3genc 3gene gen3es 4genes. 4genet. gen4fe gen1i geni4al 3genic geni2o +2genis gen3it gen4ite geni4u 2geno g4enoi genom4i 4g3enou gens4e 3gent gen3tle ge4nuse +4g1e3ny 1geo ge4of 3geog ge4ol. 4geole ge2on ge2or ge2ou ger4an 2gere 3geria +germa4no 2gero 1g2es 4gese 3gesi gesi5a ges3tat get4a ge4tar get1i 4geto ge4try +3gett 3geu geus4t 2gf g4fet 2g1g2 ggar4t gge4d g5gedl g3gedn g3ger g3gi +ggi4a gg4l gglom3 gglu3 g3go ggo4t ggran3 gg4s 2gh2 4gh. g3ham ghar4i +ghas4 gh3b gh4es. gh3f gh3h ghi4b gh3in ghi4o gh3l 4ghn g5horn g3hous +ght1 gh4ten 3g4i. 1gi2a gi3al giale4 gian4t giar2 giar4c gia3ri gi4at gia4to +1gib gib4e g1ic g4ici gi4cl 3gid3i 3gien gi1es gi4et gi3ga gigam4 g4ih +gi4le g4iliu 2gim g3imen g4in. gin2a gi3nab g4ind 3ginee giner4 g4ines. 2ging +gin5ger gin4h gini4a g4int 1gio 3gion 4giose giotro4 3giou gipa4 gip5s 3gir2 +4giriti girl3 1gis g1isl giss4 g2ist. gis3te g1i3ta gita4l gi4tall g4ite 1giti +4gitim. gitin4 gius4 3giv 1giz 4gji 2gk g3k4i4 4gko gl2 4gl. gla2 +gla3b gla4d glam3 4gland. g4lar glas2 glas4s glazi4 1g4le 3gle. gle4an 3gled +4glen. 2gles g5less 4gley 2glia gli4b 4glie. g3lig 4glik g4lim 3glion g3lish +4glisti 1glo globi4 4glod 4glog glom3e glo4n glo1r glou4 glo4v 4glu. glu4me +glu3ti 3glyc g4lym 3gly4p 4glyphy glyr4 2g1m 4gma2 gma6tos gmentar5 2g1n2 g4n. +g4na. g4nac. g4nace gna4g g3nal gnar4 g4nard gna4s gnat4a g3nati gnet5ise gnet4t +3gneu gneu4ry g4ni. gni4an gn4ic g3ni4f gnif4ic. gn3ing g4nio4 gnis4 g3nit gniz2a +gn3l gn3m gn4o g4no. g2nol gnom4 gnon4 gno3ra g2ny 1go 3go. 3goa +go4bl go3c g3ocy 4godo goer4 3g2o2g 4gogr 4goh 3goi goid4 go3is gold4i +4golit gol4le go4mer 2gon2 g2on. go1na4 g4onar g4one. 3gonia 3g4onic go3nid 4goni4e +gon4io gon4ism 4gonist 3goniu go3nom gon3op 4gonu 4gony g3ony. 3goo4 gor3at gor3ou +4gosa 4goso goster4 g4ot 3go4th 3gou 4gour. 4gout. go2v1 gover4 govin4 gowa4 +2gp gper4 gpi2 1gr2 g2ra2 gra4ces 4grag. 3gram grama4 3gran granar4 grang5er +grani4t gran4te 2grap gra4pe 3graph. 4grapha. 6grapher 3graphi 5graphic 4graphis gra4py grar3 +gras4 gra4tin gra4tis 4gray. 3g4ree greg4 gre2l 4gressis gre4t 4greth gre4v g2ri +2gric g3ricu 4grif grif5fi grigen4 gri2m grin4d 4grity gri4z g4ro2 groc4 grol4l +4grope groph4 4gropo gro3sc grue4l gruf4 gruff3 grum4p gry4p 2gs2 g4s. g3sp +g4spo 4g1s4t gstar4 2g1t2 4gta g4th gth3e gting4 1gu 3gu. 3gu2a gua3d2 +guar3a guay4 gu4dr 2g4u4e 4gue. 4gued 4guef guel4 gue4la 4gues. gu4gl guia4 +3guid 3guil gu2in gui3na 2gu1l gul4lab gul4p gum4a gu4mis 3gu4n 2guo 4g3uou +4gup 2gur gurd4 g1ure gu4rio 3gus gu4si gus3tat 4guta gu4th gut4s guya4 +4gva 4gve 2g1w 4gwa g4wane 4gwe 1gy 2gy2a gy4bi 4gygu 4gyh 2gyn +gy4ne gyn4i g3yny 4gypt. gy1r gy5ra gy4t 4gze gz4w h'4i 2h. h2a. +4habad ha4baz habi2 ha4bile h3abl ha4col h2ad ha4dar ha4deu had4j had3ow haf4 +ha4gan hag2i hag3ic hag3io h3agis hag4on h3a3gy h4ah ha4han haid4 hail4i 4hairt +ha4it ha4ki hal2 ha3lami hal4an ha6lange. ha4lar h2ali hal3ic h3aliti hal5mi ha1lo +ha4loc ha4lone ham3it hamma4 hamme4 ha4my h2an2 ha2nat handel4 han3er h4ang han4ge +hang3er han4i han5ic hani4l h3anis ha5nisti h3a3niz han4k han3n ha4no. h5anol h3anous +ha4nov 4hansh hao2 ha3ot hap3er ha4p3l h4apu h2ar har3ac ha3ran hard5en. har3d4i +haren4 hare4u har4f har2g har4ga har4ia har4iz har2mi ha3ros4 har2p har4tis hary4 +hass4 h4a2t ha4tiz hauc4 4haud haugh3 hau4l hauli4 5hause ha2v ha4ve havel4 +havi2 hay4a ha2z haz3ar 2hb h3bo h3b4r 2h1c hcas4 4hcl hcom4b 4hcr +2h1d hdra4 hd4u 4h4e. 3h4e4a4d he3an hea4p hear2 3heart heart3i h3east3 heathe4 +hea5then heat3r hea4v heav3i hebe4i hebra3 he4bri he4bro he4cl hec3t hectom4 h4ed +4hed. hed4dit hede4 he4done hed3u hee4n hega4 he4gi he4ia he4il he2in hein4i +he1is he4j 4hek hekho4 hel4ep hel3ico heli4e he3li3o hel4lie hel4lis hel4ly h3elor +helo4t hel2p hely4 he2m he4matei h4eme hemer3 hemilu6 hem4ise. hem4ith he4mon he4m3or +hem4pe he2na he3nes he4nias heni4e hen3is hen3iz henom3 4henu he4o. heo3ri he4os. +h3eosi heos4t he2p hepta4v he2q her4ali her4bal her4by her4d here3a he3redi heres4 +heret6ic. here4v he4ri. h3eries herit3a he3riti herlan4 her4mel hermi4t hernali4 her4ome her2op +her4or hers4e hert4e h3erw h3es. hesan4 he4sho hes3it hess4e hes4sit h2est hes3ter +hes3tr het1 hete4 het4ed h3e3teri he3tis he3t2iz het4tine heun4 4h4ev he3vis hex3 +hexas4 h4ey. 2hf2 4hfu 4hg. h3go h1h h4i. hia4n hian4g hi4ap hi4au +h5iave hi3be hi4bo hibra4 h1ic hi4c4e h3ici h4id hid4it hi4do hi4dy hien4 +h3ier. h3i1es hi3est hif4fer hi4fl h3ify hi4get high3 hight4 h4ih hi4lad hi4li. +hilib4 hi4lin h3iliz hillar4 hiller4y hil4loc hilo4mo h3i3lou h1ily hi4lyr h2im hima4 +hi4mac4 hi4mar him4it h4ina hin4der. hin4derm hin4ea hine4al h3ing. 4hi3no h4inom 4hinu +4hi1o hio4d hi4oed h4iol hi2on hiop4i hi4or hi2p hiprot4 hiq4 hir2 hirel4 +hirst3 his4a hi4se his3el hi4sor hist2 his5tam his3tl his4try h4i2t 4hite hith3er +hi4top hi2v hiv3al hiv4al. hi2z h1k 4hki 4hko 2hl hlag4 h3lan h3lea +hle4b hlep4 hle4t h1li hl4o1 hlor2i hlo5ric hloro3 hlo6rou hls4 2h1m2 4hm. +hma2 hma4g h3man hmel4 h3men hmet6ic. hmi4r 2h1n2 4hn. h4nen hn4i hni4e +h4nol. hnos4 h2o ho4ag4 ho3bo ho4co h4oda h3od3is h5odiz hoen4 ho2g hog3o +h4oi4c h4ois ho4it ho4lam ho4lap ho4l3ar holat6 3hold1 hol5er holes3t ho4ley hol3ic +ho4mas ho4me3s ho4mogr ho4mopho homo3t homy4 ho4my. hon4a ho4nas ho4nau hond4 ho4ne. +ho3nem ho5net hon2g hon4is ho3niz hon4oc ho3nog ho4o 3hood hoo4k hoo4p hooper4 +hoot3e hopes4 hophyl6lo ho4pl h3opoe hor2 h4or. 4ho3ra ho4rab horar4 ho3reo ho3reti +horian4 hor3ic horo4b hor4on hor4s 3horse hors3i ho4ry. hos4e hosi4 hos3p ho4tho +hoto1 h4oug 3houn houp4 3house ho2v hove4 hov3el ho4via 2h1p 4hpa hpem4 +hpri4 2h2r 4hra hral4li hra4sa h4r4e hrea4 hreat3 hren4e hre4no hre4s hr4i +h5rig hri4s hroat3 hro3mic hro4mis hro3n4is h4rops hr4y h3ryma hs2 4hs. hs4e +h3sh h4shu h4si2 hsin4 hsink4 h1s4t h4stag hsti4 h2t h4t. htab4 ht1e4 +h4ten. hten3i h4t3eo ht2er h1th4 htha4la hthe4us h4this h3thy hthyla4 ht3l 4hto +h4ts htu4 h2u hu2a huat4 huf4 huff3i hu4ge hugh4e hu4mis hum4p hump4y +hun4e hunk4e hun4t hur2 hura4d hu4rat hur4c hu1ri hu6rise. hur4l hurs4i hur4t +hus4s hus3t4 hu2t huta4 huz4i h1v 4hv. h4vh 2h1w2 4h2wa hwar4 h3ward4 +hwat4 h3way hw4e hy1 hy4' hya4c hya5li hy4bi hyen4 hy3gi hygro3 hyi4 +h4yli hy2m 4hymi hy3o hy4pre h4yr hy4rh hyr4i hyro4g hyroi2 hy4s2 hys3e3a +hys3i3a 4hyt hyx3 2i. 2i1a i3ab i4abel iabi4 i3ac. 4iacal iace3t ia4chi +iaci4 4iaco iac5o5na i3ad ia3de i3ae iaf4 4iag i2age i4agu 4ia4h i4ai +i4ald iale4u i3alit i2am ia4mi i4amo ia2n ia4na 4ianao ian3at ianchi4 i4anco +4ianh ian3i 4ianii ian4ite 4ianne. 4iano i4ano. ia4nop ianop4s 4iansk iaon4 ia3pa +ia4pl i4app i3arc i2ari i3arie i4arri iar3t 4i3as ias4c ia4sic iass4 ias6trop +i3at ia4tic ia4toa i4atoo ia2tr iat5ric. i4att ia3tu i4aus ia3v iave4 5iavel +i4avo 4iax3 ia3zi i2b i4b. 2iba ib4as i4bata i4bb 4ibbo 4ibe. i3b4el +ibe4la 2iber ib3era iber4o i4b4es ibe4t i4bia i3bi4d ibid3i ib3ila i4b3in i4bio +ib3it. ibi4ta i4b3ite ib3ito i4bium i3bl 4ibla i4bn i3bo 4ibo. 4i4bol 4ibon +i3br ibra4c 4ibran 2ibri i4bria i4b5rio i4bs i4bul ib3ula ib2un ib1ut ib3uta +ib3ute i4by i4c. 2ica. i4caa ica4ba ica3c 4icaca i3cal i4cald 4icam icap3i +3icapp i3cari i4cast. 4icata. 4icch iccu4 2ice 4icef ice3ro i4cest i3cet 2ich +4ich. i1cha i4chag ichar4 4iche. i3che4n ichen4e ichen5o 4ichi ich3ic ich4i4e ichi4l +ich3in ichi3ni i1ch4o2 i4choi i2chom i4chore. 4ichot i1chr 4icht i3chy 2ici 6icia +4icic i4cic. i3cid4 i3cien i2cif i4cila icillar4 4ic3in4a i2c3ip i5cipi i3cis 4ic1it +2ic4k 4ick. 4icke icker4y ick5l 4i4clo. ic3ly ic1n4 4ico. 4icog. i2cola 4i2c5oli +ico4mat icom4e i6como 4icope ico3ti 4icou 4icoy 2i1cr icre3 4i4cri icro1 2ict +4icta icti2m3 ic4tis ictu2 ic2t3ua 2icu1 4ic1ul ic3ula i3cule ic3ulo i3cult ic3ulu +ic4um ic3uo 3icur 4icylat 2id i4d. idacty4 ida4d i4dai i4dak ida4l i4dam +i4dani i4danu 4idasa idau4 4id3d4 ide1a i2dect id5ed idel4e ideli4a i3den ide4na +id3er. id3era 3idesc ide4se i4detr idewa4 id4g idgel4 idger4 i2d2i i4d3ia i4d3ic. +id3ica idi4d id4ie id3if id4il i3d4im i3dine i4d3ing id3ino i4d3io i4d3i3t id3iu +4id4j id3n 4idni i4dobl ido2l idol3a i4domi i4dones i4dophy id3ow i4dri i4drop +4ids i2du1 i3duc id1ul idu4n id3uo idur4 idw4 i4dyd i2dym ie2 2i4e. +4iebr 4iec. 4ie4ce i3ect 2i2ed i4ed. ie4fe ief5l iego3 4ieh 2iel ie3la +iel4d ieman4 iemp4 2ien ie4na 3ien3at ien4e ien4g ie4n3i i4eno 4ieno. ient4a +ien4tag i3enti4 4iento ien4tr 4ie3p iepra4 i1er i4er' ier4c i4erd 4ier4e i4erg +2ieri i4erly i4ern 4iero 4i4ers 4iert i4erwa i4es. ie3se i1est i3est. 4i3et +ie3ta i4eter i4eton i4et4r ie3ty i4eu 4iev iev3er 2iew ie4we i1ex i3ezo +i2f 2i4f. i3fa ifar3 4ifat 2ife i4f1er ifer4e 4iferi i4ff 4iffe if2fi +if4fia 4iffu i3fi i4fi. i5fia 4ificant i4fice i5fie 4ifil i4fing 4i3fl i3fo +i4fo. iform3i i4ft1 i3fu 4ifug if3uga i5fuge i3fy i5fy. i5fyi 2ig 4ig. +igam4e i4gand 3igarc i4gatori i1ge2 igens4 igen4to i2ger ig3era ig3eren ig3ero i4geta +ig4fe igg4 igh4t ight3e ight3i i2gia ig3i5at i3gib ig1il ig1in 4i3gio i4gios +i2g3it4 ig2l igli4 i3gly ig2n 4ign. ign3ab ig3nat 3ignate ign3er ign3in 4ignm +igno3m i5gnon. i2go2 3i3g4oc i5gon. igon4is ig3o3no i3gop igor3a i4gri i2gu i4guat +i3gu2i ig3ul igulat4 i4g3uo ig1u2r 4i2gy i1h iha4n iheret6 ihil3i 4ihin i4hk +i4hs ihun4 i4hz 2i1i2 4ii. i3id i4is. 2ij ijan4 4ije i4jl i4jn +2ik 4ik. i1ka i3kan 4ike ikin4 i5kin. ik4l ik4w 2il. i1la i4labe +i4lade i4laf i4lana ila4ne ila4p3 ilar2 ila3ra i4laru ila4s i3lat i4lato i5lator. +i4latur 4ild il4du i4le. ilea4 il4ed i3leg 4il3er iles4 i4lest ile4tr i5lev +ilev4er ilh4 il1i i4l3ia ili4al. ili2an il5ias ili6ativ il2ib ili6ca. 4ilie il3ien +i4liki i3line il3ing. il3io ilion4 iliora4 il2is 4il4is. 2ilit il3ita il4ite ili4tur +il3iu 4ilius iliv4 i1l2iz iliza6bi i4l4k 4ilk. 2ill ill3abl ill4e 4ille. ille4i +illi2a illin4 4illio illo4c il4lock il4lust. il4mi 4ilni 4ilob ilo4e 4i4logi ilo4mon +4ilop il1oq il5oquis i3lo2t 4ilp ilpa4 il2ti i1l4u il1v il4y ily4a i4lyra +4im. i4maa i4mace 4imach i4machu i2mag 4imak iman4i i4mano 4imaru i5mat 4imato. +4imav 2ime 4ime. ime3l i2mene imenta5ry i5meriz im5etry imf4 imfer4 4imh im1i +4imi. i4m3ia im4ico im3ida imi5la 4im5ing imin3i imi4nu i6minutio imi5ny imi4on im4i4p +i1mis i4miss im3iti 4i4mity imma3t 4imn4 i3mo imo4ch i4moi i4mola imo4nian imo3no +i4moth i4mout impa4l im4pers. im4pha imput3a 4imr imro4 i2mu 4imu. im3ul 4imw +2in. i1na 4i5na. inab4 4inace i4nach 4inad ina4la inal4iz i4nam ina4mo in4ann +i4narch i4nare i3nari inar4is inar4it in3aug inav4 ina4z 2inb incar3 incel4 4inche +in4cor. in4cr. 4inda 4indee inde4fi inde4l in4dermo 4indl indor4se 6indromis ind4wi 2ine +4ine. in1e3a in3eal in3ear in3eat in5ega i4neit 4inel i3nell ine3m in3emat i1nen +i2n3eo inep4 3ine4ph3 in3er iner4s in4er4v i4nese i2nest i4nesw i1n4et i4neth ine4tr +in3ew in3f4 infat3 in4fin. 1i4ng 4inga ing5ab 4ingbu 4ingd 4inge ing3en. 5ingenu +4inghe 2ingi in2gl 4ingla 2ingle 4ingo ingov4 4ingr 4ingtow 4ingu 4ingy inhag4 +4inhar 4inhol 2i2n1i 4ini. in3ia 4inian 4in3ic i4nic. in4ica. in3in i4ning in3io +iniona4 5i5nisc in3ish in3ist in3itiv i3nitr 4i4n3ity i4n3iu 2ink in4ke4 in4ki in3kl +in4ky 2in3l inlan4 in4nag inname4 4inni inno4m i1no 4ino. i2no4c i4nodon i2nog +i4noge in5ogen. inoi4s inoki4 i4noli i3nom 4inomi i3non4 inos4t 4inot. i4nota i4noti +4inour inow4e in4ri. insati4 in4siani insis4 insist3 4insk 3insu insur5a i4n1t 4int. +4inta in4tee in4ter. inter3e intes3 intest4 4inthi in4thod in4tin in4tres intro3 2inu +inud4 4i3nus 3inv4 4invil i2ny 4i3ny4l3 2io 4io. i1o1a i1o3b i1oc io4cel +4iocu i1od io4dep 4iodic io5dize io5do i1oe ioe4co i1og ioga4 6iographa i3oi +i3ol 4iol. io5late. iol5a5ter io4list 4iolou i1om iomer4y io2n ion3a ion4ac ion4ag +ionar4i4a i2o4ne 4ione. ion3i 4ioni. i3onic ion4it iono4my i3op ior2a iora4bi iora4m +io3rati i4ord io4rit i4orm i3os 4iosi 4ioso io3sp io3sta 4ios4to i1o2t io3ta4 +io3tel iotempo4 io3th iother6my iot1i iot4s io3v iovi4 i3ox3 2ip i3pa 4ipala +ipa4n i4par ip3aro i5part ipat4edn i4pathi i2pe ipe3da iper4ac i4phat 4i1phe 5ipheno +i3pho i4phora iphori4 i4phyo 4ip1i i4pia ip4ic ip3i1e ip3ing i5pinn ipis4t ip3it +i2pl i4plac i3ply ipo4ce 4ipoda i4pode i4pody ipol4i i4pom ipor4 i4poro 4ipot +ipow4 4i4pp ip4pel ipri4e i4pry ip4sal 4i4pt2 ip4tal i2pu ip1ul i3pur 2iq +i1qua iq3uef iquet4 iq1ui 2ir 4ir. i1ra 4i3ra. i4rado i3ral 4iram i4rama +iran4 irassi4 ir4at i3ratio ir4cal ir4ch ir2d ir4do ir3dr ire5ar i1rec irec6trix +i4refu irel4i i4rely i3ren iren4t i4r3er iret4 i4rety irev3o ir3g irhi4 ir1i +i3ri. i4ria ir3ica ir3i3de ir3ido iriga4 iri6gibl ir4im i4rin ir3ing i1r2is ir4ites +ir4ius 5i5r2iz ir2l ir2m ir4mand irm5ativ irm6it ir4ni i1ro i5rog i3ron iro4ny +iro4po irov4 irre4f irre4la irri3 5irrit irru4 4ir2s ir4ty. ir3u3l i5run i3rus +i4russi irv4 ir1y i2s 2i4s. 4isa. 2isab isa4me 4isan i4sandr i3s4ant. isar4i +isas3 4isau isa3ve 2is3c iscar4y is4ces is4cio 4iscot iscov3 4isd i3sec 4i3sep +is3er iser3a is3es ises3t iset4i i3seu 4isex ish1 4ishan 4ishc ish4ch i5shev3 +2ishi isho4 ishon3 is1i 4isib 4is2ic is5ido 4isi4e is3il is4iny 4isio i4sior +isi4p i4sise i4sisi is3i4ta is3itiv is3ito 2isiv 2isk is1l is4la. 4islao 4ismi +ismis6sib 4ismo ismolo6 ismome4 4ismu i3so iso5b is4od is4ol iso3la 4isor is3p +ispan4 is4pec ispen6si is4ph i3sphe is4pi i4spie 4is1q isro4 2is1s 4issa2 4isse +is3s4i 4ista is3tak is4tat 4iste. istem4i is3ten. ist3enc ist4er is3teri i4sth is3t2i +ist3ib 4isticha is4tide. is4tif 4istiu is4tiv ist2l 4is3to istoi4 is4tolic is4tom is3tr +3istry is3tu istur2 is1u3a isub4u i5sul is3w is4we is4y i5syl i3sym i4t. +it2a 4ita. itab4 4itac i4taco i4tag 4itai 4i4tak4 ita4l3i i4taly 4i4tam ita3mi4 +ita4ne i3tani ita4n3o 4itante ita4p i3tar ita3riz itar3y 4i3t4as4 i4tasi i3tat 4itato. +i2te i3tell i3tem3i i3tent i4t3eo 2it1er 4itero it4erp 2i2th 3ithel i4them 4ithi +i4this 4ithmi ith3r 2i2ti 4itia. i4t1ic it3ica it3ici 4itics it3ig i4t3il it1im +iti4n it3ish it3ism i4tisti i4tiu i4tiva itiv3i i4tly i3to 4ito. i4tok 4itol +i6tology i4tom i4toph i4tora itori4d 4itoso itpro4 itr4 i4trame 4itre i4trem i4trep +i2tri 4itric it4ro. 4itropy i4try 4its it4sa it4sy i4t3t4 4itt. 4itta. 4ittae +4itte4 ittel4e 4it5ti itti4t ittu4 i4t1ua i3tud itu4de itu3di3 4itu3e 4itui i4tui. +it1ul itulat6 i3tum i4t3uo 2itur itur4a i4turgy itur4n 1i3ty it4y. 4itya i4tyi +i4tyl i4tyo i4tyr i4tys 4itz it4za 2i1u iuli4e i3um iu2mi 4iu4o 4iupo +4iur i4ush i4usi ius4t 2iv 4iv. 4iva. 4ive i4vel iv3ell iv3en. i4ver. +iv3eri ives4t ivest4i iv1i iv3ia i4vid. ivil2 iv5il. ivi3lis i3vine iv3io ivi4p +4i4vis. iv3ise i2vo 4ivor i4v3oro i4vot iv3v i1w iwan4 i4ww 2ix 4ixe +ix3i ixiv3 ixo2 ixo4d ix4yl 2i1y i4y. i3y4a i4ys i4yy 4iza. 2izab +4izal 4izar iz3ard i4zato 4izd i4zel izi2 iz4ie iz4it izz4 iz5zin 1j +2j. ja2 3ja. jac3u ja4de ja4g jama4 ja4mas jame4s 4jang ja4pon4is jargo4 +jargon3 jas4c jas3p 4jav javar4 jazz3 2jd je2 jeal3 je4b 3jec jectiv6is +jeel4 je3j 4jel. jella4 jel4lo 4jem. jemap4 jem4i jen4u jeop3 2jep jepu4 +4jer. jer4a jer4om jeron4 j4es 4jes. 4jesty jes3u3 jeun4 jew1 jh4v jh4w +j2i2 jil4 jim4p jing4 jink4 4jis 2jj j4je 2jk 4jl. 4jli 2jm +4jn. 4jna jo2 jo4b jo4ce jo4i2 4joie join4tr joli4o jon4a jo4p jo3ra +jos4e jo5sep joss4 jo4su jour4no jo3vi jow4 2jp 2jr 2js 4jt. 4jta +ju4a ju5bi 3judg jui4 4jul ju4le ju4lia. ju4lian. ju4li4u jum2 jump3 3jun +jun4a jun4e jun4gi juni4u jun3k4e jun4u 3jur jurid3 jus4r jus4ter ju4ti juve3 +4jvo 2k. 3k4a. 4k1ab kaha4 3kai kain4i kais4 kak4 3kal2 k4ala 4kalah +ka4ley ka1li ka4lia kal4id ka4lis. kalit4 4kalm kam4e kam4i 1kan ka4na kana4k +kang4w kanon4 kan4ti ka3o kao3l kap4i ka4pl kar4s ka4sa kash4e 5kateri 4kath +ka4thy 4kati ka4tol ka4trine ka5za 4kbe kbin4 4kbl k1c k4cy k1d ke4an +keat4 4k4ed keda4 ke4dr 1kee keel4e 3keep3 ke4ge kegel4 keiz4 kem4i kena4 +ke4n3i ken4ia ken4ti k4enz keper4 ke4pl keple4 k1er. k4era 4kerm ker4ni k4err +ker4si k4esi 3keta keth4 k4e4tr k4evi ke5ya 2kf 4kfe 2kh 4kh. kha4k +khani4 khano4 khay4 khe4da khet4 5khov kho4vi khuz4 k4i. kian4g ki4ato ki4bl +ki4d k1ie 4kier 4ki1es k4iew kil1o3 kilom4 4kil4s k1ily k4im k4in' k4ind +3kind. kin3ema 3kinesi k3iness kin2g king5l king4s k4ingt 3kin4i kin4kly k4inl k4inn +kir2 kis2 k1ish k4ist ki2t ki4ty k4iz k1k2 k1l2 klavi4 kld4 4kles4 +kle4sh 4kley k4lid 4klik k4lio k1m kn2 kne2 kn4i kni4fi 3k2no4 knol4 +knot3 k4nu knur4 1ko ko4cytot ko4hi 3koi4 ko4it kold4i 4kolos kone4 koon4 +koop4 ko3pe kopen4 kop4f ko3ra kor4t kov3s k1p2 kpa4t 4kpo kram4 kre4 +kroo4 krot4 2ks2 k4s. k4sar ks3b k4sco k4shea ksnif4 ks4p 4ks4t ks3v +k1t kt4a kth4 ktom4 1ku ku4a kuch4 ku4dr k4um kun4z ku4o k3up +2k1w kwar4 4kwe 4kwo 2ky kydi4 ky4e 3kylo ky4mi kyp4 ky4pr ky4ri +2l' 4l'o l4's 2l. l2a 3l4a. 5laam la3be 4labeg lab3ic 2labl la5bore +la3bori l4a4bri lab5yr la2c lac3ab 3laceo la4ces 5la3chia lach3r l4aci lack4y 4laco +laco4p lac3t 6lacteric 3lactic 4ladel ladi4e ladi4o. lad5is lado4c 4ladol la4dop la3dy +4laeo laes3 lae4v laf4f la4fi 4lafo 4laga4 la4gan lage4 lag3ell la4geo 4lagine +la4gist 4lag4o 3lah. la4hab laim3a lai4n 4laira laish4 4lakl la3lia 4lalist 4lalt +la3mar lam4bi lam4d la5melli la4met 4lamid lam3or3 4lampu l4an. lan4a 4lanac la5natio +l3ancho l4an2d 3land. 4landin 4laned lanel4 4lanes lan4ic lani4e lan4if lanne4 lano4ch +lans4i lanta4g lan4tr lao4 la5od5 lap3ar 4lapax 4laph la4pl lapla4 laps3i lapu4r +1la2r1 3lar. lar2a la4ran 4larbor 4larct lard4er lar2g lar4ge lar3i la4rine 3l4arm +la4ro. 4larop 4larsh 4larus larynge6 la4sa las4e lask4 lasmi4 las5ph las3ty 1la2t +l3a3tal 4latat la4te 4lateli 4lateriu 4latesh lath3er 4lathi 4latho la4thy 3latia l3atil +latil3i la4tim 4latine l5atine. lat3ini 4latinit 2latis lat3it 4l3ativi 4latom laton4 4l1a3tr +l3atro l3atry lat4ur la4ty 4latya lau4dat laudi4o laun5d launt5 lau3si 2lava la4vato +laver4a lavi4e lav3is 4lawa lax3 4layl. lays4 la2z 4laze lazi4e la4zin 2lb +l4b. l3ba lban4 l3be lber4d lbin4 l3bo l6bous lbun4 2l1c2 l4c. l3ca +lcali4 lcas4 l3ce l3ci lcia4 lci4nab lci6nato lci3tr l4cm l3co lco4n lco3ra +l4cose l4cou l4cs l4ct lcu1 2ld l4d. 4lde lde4ba ldens4 lder4al ld4er4o +l4des ldh4 ldi2 l4dia l4d3ish l4dist l4dit ldiv4 ldivi4 ld3l l4dox l4dry +ld4ti l4dup l2dy 2l2e l4e. le4a4d lea4f 4leano4 le2ar lea4s leas3a least4e +leath3 4leati leator4 4leaz le4bel le4bit l4e4bri 1lec 4lec. 4leca le4che lech3er +l4e4chi lech4w 3lecit 3lect lect4a 3lecula 6lecy 4l4ed. l3edi 4ledl 4ledn le2e +lee4l 4leem l5efact le4gad 3le3gal l3egan le1gi 3lego lego5rize 4legy le4ia lei4c +le4ina leit4e lek4 3lel. 4lelik lem3ati l4emb 4lem4e le4met 3lemic lem5ish 3lemma +lemper4 4lenar. 3lenc lend4w len4e le3nec 4leneu 3leng 4lenga len4gl le5ni5e len3is +le4nite 4lenor 3lent 4lentin 4lentu lentur4 4lenty le3o leob4 leo4ch 3leog le4omi +4leph le3pha le4pi. l4epin lepi4p le4pus l3era l4era. ler4al ler4e leres4 lern4 +l4e1r2o 3lero. le4ron 3lert 3l4es 4les. 4lesb 5lescenc 4lesd les4e 4lesee 4lesg +4lesh 4lesk 4lesl 4lesm 4leso le4sow 4lesr lessar4 les4sen less4i 5lessn 4lestin +4lestit 4lesto 4lesv le3ta 4letag le3thal leth4i 3letic 4leti4e le4tom le4try 3lets +4leui 4lev. 4leva l3evat lev3el le4ven 4levil levis4 levit4e lex3 lexi4an 4lexini +3ley l4ey. 4leyar 4leye. 4leys 4leyvo 2lf l4f' 4lf. l3fa lfani4 l4fant +lfa4ti lf4fia lf3h lfi4e lfilar4 l2fl l4fla l3fo l3fu lfu4ra l4fy 2l1g2 +l4g. l3ga2 lga4m lgan4 lgar3 lgar4y l4gea l4gev lgi2 l2gin lg3ing lgi4u +l3go lg4r l3gu 2l1h 4lha 4l3he lh4j l3ho l4ho. 1l4i. l4iae lian4it +li2ar liar3is libe4l lib3er lib4er. 5libera li3b4i4 2lic l4icata 3licen l4i3che l3icho +li4cic li4cid. 3licio liclin4 l4icn lico4t li4cr lic4te 5lic3ul l3idar l4idas l3idat +li4dea lid3i lidi4as 3lidif 4li4dil 3lidit li4dom l2ie l4ie4b lief4 4liem lie4n +l3ien3a li4ery li1e4s 4lifat li4fe lif4f li4fl lig3am 3ligat lig3ere 2ligib 5ligino +3l4ign lig3nan li3g4o l3igop lig2u l4ihi 4lihu 3l4ik 4likar 4liko lil4i lilit4 +3liloq 4limani. lim4bl limenta5r l4i4mi lim4ic limit5ab limo4g lim4pl l4imu l2in 1lin. +3lina. li4nall li4nant 4linar 4linas li4natu 5lindric 3lin3ea li4nen 4linet 2ling 3lingt +lingto4 3lingu li4nid lin3iz linoi4 linos4 li4nosi 3linq l4ins lin4sh lin4t lint3i +linu4 3liog 4liog. li4ol. lio3r liorat6i 3liot l4ip lipa4t li3pe li4pec lipe4d +4lipha 4lip4i li4pol liptic4 lipu4r li4pus 4liqua 6lique. lir4a 4lirat li5re lir3i +lir4ty 3lis. li4sa. li3sad lisar4 li4sato l4isb 4liseu 2lish 4lishl 4lishn 4lis4p +l4iste lis3ten lis3ter 4listine l4it. 2lita l3itan l3itar lit4c 1l2ite l4iter li3t4er. +4lites 1li4th lith3op 1liti 3litica 3litio 4litiv li4tor l4itr l4itu li4tui 5liturg +2l3ity li4ve liv4ed live3l liv3er lix3 l4iy 4lizin. l2j 2l2k 4lk. l3ka +lka1l l4kat l4kaw lk1er lket4 4lki lk3y. 2l1l2 l4l. llab3o llad4er lladi4o +llai4 lland4 llan4i l4lao llap2 llapu4 llas4ti l3lat ll3b ll3d 4lle l4le. +l3lea l3lec l4led l3l4eg lle4in l3le2l llel3i l3l4em l3len lle4no ll4er lle5r4a +l3lero l5lers. l4lese l3let l4leta l4lete. lle3v ll3f ll3g l4l3h l3lia l3lic +llif5lu l3lig llil4 llim4e llinar4 llin4iz lli4nos l5lio lli4ol llipe4 ll4it l4l3m +l4l3n l3lo l4locke llogu4 llo4mi llopa4 llo4pe l4lov llow4el ll3p l4lr4 l4ls +ll3t l3lu llu3c llumin4 l4lv ll3w l3ly. lly4e lly3g 2l1m l4m. l3ma +l4mab lma4to lm4e l4mes lme4t lm3ie l4mil l4ming l2mis 4l3mo lmo5na lmon4dy +lm4sh lmu2d l2my 2l1n2 4ln4e lnia4 1l4o. lo2a 3load l2ob lo4bal lobi4n +l6o3bl 4lock' 4locky 4locya l2od 3lodic 5lodio l3odr l4odro l2oe loes4 loeti4 +l2of log3a3r l4ogen l3oget 3logia 3logic log4n 1logu logu3i 4logy lohu4 loid4 +loi4t loiz4 4lole lol4i l2om 3lom. 5lomatic lom3er lo3mi lo4mic 3lomit 4lomitic +lo4mona lomul4 lomulti4 l2on2 1lon. lon4e 4loned 1long longer4 lo1n4i 3lonia loni4e +l3onis 4lonoi 2lonyl lood3 4lool looz4 l2op lop3in lopi4t l3opm 4loq. l4ora. +lora4d 4lorc l4orch lor4dy l4orem lore4t lo2rh l4oric lori4e lori4n l3oriz loro3e +loro3m lorometh5 loro3p l2o2s lo4se los3in l5osophic 5losophis 5losophiz l4osophy l2ot lo5tha +loth4i4e lotin4 lo2to 4loton lo2tr l4ott l2ou lou4c lou4de lou3i 4lour. lour4d +4lout lout4r lo2v lo4ve l2ow1 loy4d 4loys 2l1p2 lpa4c l4per l4pes. lpe4t +lp3f 4lph4 l4phant l4phar lpha4t lphe4n lphi6a. lphi4d l4phor lp4i l4pier l4pies +lpi4tal l2ple l3po lp3t lpu4 l4py. 2l1r lre4 lr4i lri4o 4lro 2ls2 +4l4s. lsa4my ls3b l4s4c 4lse lsem4 l4sew lshe3v l4sib lsin4 ls4it ls4k +4l3s4p 4l1s4t4 lstaf4 lsth4 l4stin l4stu 2lt l4t. lta4na l4tans l4tant l4tativ +l4taz l3ten lte4riu l2th l3tha lthy4 lth3y. lti3a lt4ib l3tic l2tie l4tily +ltin4 lti3p ltira6c ltis4 l4tish l4tism l4tist. ltiv2 lt4j lt3l ltm4 l3to +l4tol l4tose ltra4bl ltra3m ltu2r ltur3a ltur3i l2u l4u. 4l3u1a lual4 l4uar +2lub lubi4 l3ubl lub4li lu3br 3luce 4lucel lu3ci luct4 luc5ta 4lucti lu2d +lud5ib 2lue lu1en luf4 lu1i lui4a 4lu3id3 lu1l4 l4um. lum4bl 2lume 4luminic +4luminiu 3lumni 4lumnis 4lumny lun2 lu3na lun3de lun4e 2lunt l3unta luntar3 lu3n4u +lu4ny 4luo luop4 4lur. lu3ra lur4b lur4c 3lu3ro lu3so luss4 lus3t lus4t5i +lutan4 5lutato luth4 lu2tis 4lutiz lutton3 4lu4va 4luyl 2lv 4lva l4vable. l3vag +lvan4 lvar4y l3ven l3vet l5vin. l5vini lv4v 2l1w lw4e lx4a 1l2y l4y. +4lya 3lybd 4ly4bi 2lyc ly4cid ly4cina ly4cra l4y4dom 4lyer l4y2g lygen4 l4yk +4lylo 4lyme ly4mene 4lymo 3lymp4 4lymu lyn2 lyn4c l4yny 4lyo 2lyp l3ypl +ly4poi ly3ra 4lyra. lyr3i lyr4m 2lys 3lyse 4lysi lys4t 3ly4ti ly4tom 4lytr +4lyus l4yx 4lyzate 4lyzato 3lyze l4zg l4zi lzin4 lzo4 l4zy 2m. 1m2a +3m4a. maar4 4maas 4maaz 2m1ab mabe4 ma4bin m4abo mace4t 2mach mache4 ma5chine +mack4ere ma2cl mac1r mac3t mac3u m4ad mad3ic mado4 3mae mae4n 4maep ma2g +magal4 mag3a3z mager4 3magg maggluti6 2mag3i 3magn ma3go 2magog magor4 4magr 4maha +maho4 3m4ai4 maid3e maini4 main4ie 3mak 4maka. 4maku 3mal m6al. mal3ap ma4lat +m4ale 4malek 4malge mal4ib ma3l4in mal5le 4malley malli4 4malloy mal4m ma5lou 4mal2t +mal4ti 4malu 4maly 2mamb man3ag manar4 mana4s 2manat manc2 3manci 3mand ma3nen +ma4nes 4manes. m4ang 5mania man5if mani4l man3it 3mann ma4noi mans4l m4ant ma2nu +maph3r map4pe ma3q m4ar2a marbel3 4mares mar3g mar2i mar4ial 4marif mari4o. mar3it +ma3riz ma5rol mar4shi mar4st 4marty. 4maru mar3v4 mar2y. m4as 3mas. masal4 mas4ce +mas4e ma4sic ma4sor 3mas3t mas4th mas4tl ma3s2u 4masv 3mata 4matal mat4c 3ma3ter +ma4ter. m3ateu ma4this 4mathy ma4tik. m4atin mati4nal 3matio ma3tis 4matod ma2tog 4matogl +ma6toph matopho4 4matopod ma6tosc 4matose 2matou ma2tri ma3tu ma4tura maug4 maus4e max3 +maz4a ma4zl 2mb m4b. 4mba mbal4i mbardi4 mba4t 4mbe mbel3 m4bem m3ber +m4berie mbia4 mbian4 m3bic mbid4 mbi3en m4bies m4bik m2b3ing mbis4 mbi4v mb4l +mblem3a m5blin mbm4 mb4o mbo5na 4mbr mb4sa m3bu mbus3t mbus5ti mbut4 mbys4 +2mc mca4 mc4h mck4 mcma4 mc4o m4da. 4mdo me2 4m4e. meal3 meam4 +me3b 3mech me3co 2m4ed 4med. me4dan 3me3des 3medi med3ic me3die 4medied 4medil +4medis med4it. m5e5dy me3f meg2 3meg3a1 megam4 3m4ei meion4 mel3anc 3melia me5lie +4melik 3melio melli4a mel2t memo3 memor4 1m2en men2a me3nab men3ac men4ag 3mend +me3ne 4meness mene4u 4menh me3nia meno4m menos4 mens4 men4sc mensu3 mensur4 men4sw +3m4ent 4mentac men4tate men2te ment3er me3nu4 me3o meog4 4meon 3meop 4meosi 3meo3t +me3p me4pa meral4i 3merc merd4 merdi4 3mere 4merel mer3et 5mer2g 3meria 3meric +4merici m4eride mer4im mer6ob meroblas6 mer4og mer4ol 4merol. m3eron 4merso m4eru mer5us +4meryv 4mesb 4mesd mes3en 4mes4i mes4n me4s1o mest4 4mesta 4meste 3mesti mestic4 +4mestr 1met1a me4ta. met4abl me4tai 3metal 4me4tar met2as m1e3te me4ten 3meteo m4etes +1meth 4meth. 3metha meth4i 4methr meth6ylam 3metic 4metin me4toe me4ton 4metop 5metre +3m4etri 4metrid 4metris me4tru mew4 me3wa mex4ia 2mf mf4e mfer4o mfoun4 2mg +2mh 3m4i. 1mi3a 3m4ia. mia4m mian3a mianop4 4miar 3mias mi3b m1ica mi5chel +mi4chen4 m3icil micker4 3micro m4id. mid2a m5idal m3idat mi4da4z mid3ic m4idn mi4done +2mie4 m4ient mi1es mif4f 3miga 4miges m4igra mi4la. mi4lag mil4de mi3let mil4h +1m2ili mili4a. mili4al 4milie 4milis m4ilk 3mill milli4e mil4te 4mily mimed4 mime4t +mi4mi. mim5ic mi4mis min2 m4in. mi3na m4ind 3mine. mi3nee m3ineer mi4nei mines4t +m4inia mini4fo 3minio m4i4nit mi3niz min5ne5 mi3no mi1o m4iolo mi4ona 4mioni m4iot +m3ip mi3pe 4mipl miq4 3mir. mir2a mi4ram mi4ri mi4russ m2is 3m4is. mis1a +m4isco mi4see 4mises 2mish mi4si mis5le 1miss 4misso mis4sy. mis3ta mister4 mi4tal +mit2er 4mith m4itic m3itiv mit3iz 1m4itt m4iu m3iv4 mivi4 m2iz 4mko mk4s +2m1l2 m3le m3li mli4n 2m1m2 m3ma mma4da mman2 m4mandab mmar2 mmari4o mma6top +m4md mmeas6ure mme4da mmeli4 mmem3 mme4n m3m4i mmi6nato mmi4p mmis4a mmi4t m4mk +mmoni4a. mmu1 m3mul mmu4ne 2m1n2 m4n' m3na mnar4y mnav3 4mne m4nem m4ner +m2n3in mnon4 mnosper4 1m2o m4o. mo2a 4mochore mo4choro mo4chron mocli4 mo4coo 2mocr +5mocrac 5mocratis 5mocratiz 3mocy 3mo2d mo4d1e mod3ul 4mody. moer4 mo4gena mo4g5e5niz mo4ges +mo4i 4moi. moid4 moig4 moi4t 4moix 4moki mok4s mo3la 4mola. mo3lin4 4moliti +4mollit 4molog. 4mologue mol4or 5moly moma6 4momi 3mon mon2a 4monas. 4monb mon4dia +mo4ne 4monen mon3e3t 4monf mo4niani 4monid. 4monl monni4 mo3noe mon3olo mon3os 4monto +mon4tro mon3u mo4num 4monym moo4s 4moot 4mopau 3mophi 4mophoni mo2r m3or4ab mor3al +mo4rar 4morat mo4rax morbi4 4moredn moreli4 m4org 3moria mo4ries 4morin morit4 m3oriz +mor6iza 4moro mo3ros 5morp m3orrh 3mos 4mosac mos4e mosil4 4mosite 4mosm 4mosor +mo4sp m4ost mo3sta 4mosur mo4sy mo4tes 4mothy 4motis moto2 motor5i m6otropis mot4tle. +moun4te 3mou4s 4mousine mous4se 4mout. mo2v mo4vi movi4e 4moyi 2m1p m4pab mpac4 +mpa4i mpais4 mpan4d mpan3e mpani4a mpa5no m4pantl mpa4nus mpa3rab mpar4te mpa4s m5pa1t +mpa4tr m4paz mpe4dit mpe4l m4pen. m2per. mper6ato mpe3ria m3pet mpe3tr mph2 mphan3g +mphi4m mph4y m4phy. m2pi mp4ic mp3ie mp4ie. m3pig m3pile mpin4 m3pir mp3is +mp4le mp5les m4plev mpoti4 m4pous m4pout mpo4v5 mpress6in mpri4 mprin4 mpro3m mps4es +4mpt mp4ter mp5tu mptuar4 mp4u2 mpug4 mput3ab m4pv m2py mp3y. 2m1r mro4d +2ms2 4m4s. 4m1s4e m4spo m1s4t4 2mt2 m4tis mt4s 1mu muci4 mu4cl 4muel +2mul m4ulc mul4i mul4ler mul4lit 3muls m2ul3t4 multi3 3m4um 3mu1n2 mund4 mun4e +mun4ga. muni4f mun4is m4ur mu3ra mur3d4 mu4re murmu4 3m4us mus3t 3muta muth4i +mu4tis 2m1v 4mvi 2mw mwal4 mw4e 1my 2mya4 myal4i my4an my3c my4cer +my4col. 4myda my4do my1e my4log my4los 4myne my1o 4myr my4ri myr3ia myro4 +mys3 mys4i mys4u my4ti mz4e m4zl n2' 2n. 3n4a. 2nab naba4l n4abase. +4nacen 3n4aceo 4nachl na4cho 4nacif nac4r 2nact 2nad nad4de 3nadia nadi4e nadi4r +3nado. 3naea 4naec 3naem n3aero naes3 naeth4 2nag n4agena n3ager na5go n4agu +n4ah 5nah. 4naigret na4is n4aj nak2 nal4go na3li3a nal3ie 4nalin na4lit 4nalla +4n2alo n5alogic 5nal5ogis 4nalt 3nalysi 4nalysin name4ne na4met n5a3mid n4amme4 4namy. n2an +nanach5 3nance na4nes nan4ic nank4 n4anna nanom4 nan4tes 4nantiz nan4tr 2nap n4aph5o +napi4 na4pos n4apu nar1 3nar. n4ara n2ar3c n5archis nar4di 4naret nar3i 3naria +nar4iac na4rin nar4ite nar4l 4narou narri4 nar3t n4as. n4asa na4sa. nas5c na4sh +3nasi na4sit n4asm. nass4 n2as3t 4nasth 3nastic 1n2at na3ta nat4ab nata4k nata4l +nat4en 3nathem na3tho nat3ici 4natina nati4u n5atomic 5nato5mis 5natomiz na4tro 3natu natu4a +nat3ura nat4u4ran nat4u4rat 2nau 3nau. naud2 nau3di n3augu 3nauti 4nauto nautog4 nav3i +naxa4 3nay n4az 2nb2 n4b. nbak4 n4bd n3be nbeat4 nbes4 nb4l n5bli +n3bu 2n1c n4c. nca2 ncaco4 ncampho4 ncapsul4 ncar3c n4caria n5ceri nc4es n4ces. +n4cev n2ch n4chate nch3er n3chia nchi4ni n3chi4s nchis4es n3cho ncho5a ncho4lin n3chu +n3cib n3cil ncil3a n3cina n3cip n4ck nc4l nclat4 ncle4 n3c4o ncorre4 ncov4er. +nc4r ncre4v n4cs n4ct nc4tad nct4e ncu4ra n4cx n3cy ncy4cli 2n1d n4d. +nd3age n3dal ndama4 nda4na. n2da4r nd3ard n3dea n3d4ec n4ded. ndeform6 n3del ndel4li +nd3enc nd4ens n3denta nde4ra n3deri n3dero ndes5cent nde3t nd3g nd3ho n3d4ia nd5ibi +ndi6cativ n3dice ndic4to n3dic3u ndi4en n3d4if nd3ing n3dio nd4it nd4iu n4div. n4d4j +n2dl n3dler n3do n4dof ndo4mi ndoo4r ndop4 n4dov n4dq n3d4r n4drat n4ds +nd4sia nd4te n3d4u ndu4a. n4duc. nduct4i n4dunc ndus3 n4d4v nd4win nd4y n4dz +n2e 4ne' 2n4e. ne1a n4ea. 5nealo ne4ap n3eari ne4art ne2as ne4ata n3eate +n3eati 3neau ne2b n4ebe nebro4 ne2c n3ecd 4ne2d n4ed. n3edic ned3u neep4 +5neficen n3efici n3efit ne4fr 4negade neg5ativ ne3gr n3egy 1n4ei neid4 3neir4 neis4 +nek4e 4nela ne4let neli4a ne2lig nel3in n4elv n4ely 4nemari nem4ati nemer4y nem4i +n4emia 3ne3mic ne4mog ne4mone n4en. ne2na 4nene 3nent. ne4ny 1ne3o neo4t4 3neph +4nephore ne5phriti n3eq n3era ne3rad nera4l3 nera4m ne3r4as n4erd n4ere ne3rea n3er3g +n4eri 3neric ner5ist ne3rod n4er4ol ner4ri n4ersv ner4vi n4erw 1nes 2n4es. 4nesbo +nes4co 4nesd 4nesec 4nesen 4nesh 4nesm ne4sot 4nesp 2nesq 3n4ess nes3ti nes3tr +4nesv 4neswe n4et. ne3ta ne4tag n3etar netel4 4neter 3net1i ne4ti. 5n4etic 4netie +n4etn ne4toi ne4top n3e3t4r ne4try net4s n4ett 1neu 4neui 3neuro neu4ry. 3neut4 +neva4d nev4e nevers4 4nevi nevo4c nev3ol newal4 news3 n3ex3 nexami4 nexci4 n4ey. +n4eya n4ez 2nf2 n4f. nfalo4 n4fers nfi2 nf4l n5fla nfu3sib 2ng 4n4g. +n2gab ngal4le ngal4li ng4am ngan4i ngar4i ngar4y n3gat ng3c ngd4 nge4a. n3geal +n4ged n1gel n2gen. ngen4er. ngeni2 n3gere n3gero nger4on nge4t ngh4 n3gi3a n3gib4 +ng2ic ngin4 ng3ing n3giu n2g3iz n4gj n4gk4 4ngl n3gli3o n4gm ngn4 ngna4 +ngol4l n4gour n3gr n4gr. n4gs n4gt ngth3 n4gue n3gul ngu4s n4gv n4gw +n2gy n4gz 2n1h2 nha2 n4ha. nhab3 nhabit5an nhag4i nhas4 nhil4l 4nho nhs4 +nhu4me nhun4 n2i 3n4i. 1nia n4iac nia4g 4ni4ap 4niard ni3ba 2nibl n3ic. +n4icc n4ice n4ici n5icil n4ick n4i2co nic3o3t n3ics n4icu nic3ul 3nicula n4icy +nid2 3n4id. n4ida 3nide. n5idine n4idir n4i4do. ni4dob n4idu nie4l ni4et n4ifer +nif2f nif4fi ni4fin n4ifo nift2 ni5fy nig4i n5igmatic n4ii n4ij nik4 ni3ki +n4ila ni4la. ni4lam 3nile n4i3lin n4ility n4illi n4ilo 4nilw n3im nim3ag nima4te +n4ime 4nimea nimi3ta ni3miz n4in. n4ina nindi6c n4ine 3nine. nin4erti ni4net ninvo4c +nio2 n4ioc 2n3ion niona4t nio4r n4ios 1ni1ou n4i2p nipa4 4niple nipo3 nipre4 +3nipu 3nique. n1is n4is. 2n3ism nis4ma. n4ison nis4p nissari4 n3is3te n3istr n4it. +nit3ab n3ital nitar3 nitar4iu nith4 4nith. ni4thi ni3tho 3nition 3nitis. 1ni3tr 3nitro +nit4s ni4ur ni4use 1n4iv 4niv. 3niva ni2van nive4l 4njo nju4ra 2n2k n4k. +4nka n4kal nka4v n4kb 4nke n3kee nk3en. nker4s nk4e4t n4kf n4kh nk3ho +n4kie nk3ines nk2l n4kp n4ks n3ku n4kw n4ky. n3kyl 2n1l2 nlan2 nlan4d5 +nlar2 nlas4t nlau4 4n3le2 nlev3 n3li2 nlis4 nlo2 n3lu nlu3b n3ly 2n1m2 +n3ma n3me nmemo4ra nmensu4 nmercu4 nmin3i nmo2 nmon2 n3mu 2n1n2 n4n. n3na2 +nnaba4 nnait4 nname4n nnan4 nnar4y nna4ti. nnat3u3 nn4e2 n4ne. nne4ce n4necy n5nel +nne4le nnel3i nner4o n3net n4nh n3n4i n4nian nni4cu n4nime n5nin n4nism nnit4 +nni4v nni2va n4niza n4nk n4nl n3no nno4mi n4ns nn4u n3ny nnym4 n2o +3n4o. 4noam no3be nobli4 n4oce no4cer noco4 n4ocon n4ocr 2noc3u n3ocul no2dal +4nodrome 4noem n4oet 2nof no1g 4noga nogam6e nogen4is no2gr 1noi 5noint noi4sa +4nok no2la 4nold4e 4nolei no1li 4no3lit n3olith 4nomac no5mad3 3nom3al 6nomal. 4noman +no5mer. 4nometa no5me6tri 3nomic nom5ina no3mis n3omou 4n3omy. nomyce4 6nomycet no2n3 non4age +non4ar4y noncu4 no4ne 5none. none4a none4s non5f nong4 3nonic 4nonicit non4it n3oniz +4nono nonos5 nonta4 noo4t 2nop n4op. n5ophob n5opht no4phys nop4l 3nop3o3l 4nopolyl +no4pre nopter4o no4rab no4rary norator4 norm4 nor3ma nor4thin no4ry 4nosce nos4ed no4sic +no1sp 4nosph nos3t n6ostr 4notap 4notau 5no4te n3othei no3tice noti4e no3tis noti4t +no3tiz no2to 1nou 4nouk 4nouro 4nout 3nov. nove2 nov3e4l nov4ell 4nover no4vis +2now nowl3 now3s nox3 2n1p2 np4a2 n5par npat3en npa3tr n3pe npecu4li npe4d +nper3 nphenol4 nphi2 nphil3 npi2 npi4t np4l npo3e npoin4 npom4 npre4fa npri4 +npross4 n3ps npub3 n1q n3qu nquil4la n1r2 n4r. n3ra nra4k nras4 nrav3e +n3re1 nre2co nre4com nre3d nre2e nre4lat nres4t nret2 nri4de 4nro nro4b n4r4r +n4r4w 2n1s2 n4s. nsa4na nsan3i 4n4s3b ns4c nsco4 n3scr n4scry ns3d nsde4 +nsec5utiv ns4ed nse2l nse4la ns3elem n5sen. nse4na nsep3 n4ses ns3f n4s3g n3sh +n3si nsi4d nsist3e ns4i3t n4sit. nsi4ta n4site ns4k n2sl ns4lau ns4lay ns1mi +nsmo4 n3so n4soce nso2l nsol3ab nso3na n4sorb nspar3e n3spi2 nspir5ato nspiri4 nspor6ti +nspro4 ns3r ns3s4 ns4t2 n3ste n3su nsu3a nsubmers6 nsu5li nsur4ab n4suret n4sute +ns3v nsw4 n3swe n4sz 2nt 4n4t. 4nta nta4be nt3abi n4tage. nta4gen ntagi4u +ntag4n ntago3 n3tai nta4le n4talka ntam3i nta4na nt3anc ntan4go ntan4i4 n3tar n4tarc +ntar4ia. ntar4is n3tas n5tatio n6tativ n1te n3tee nte4l3 nt4en n4ten. n3tena nt2er +n2ter. n3tera nter5c nter3es nt4eri n6terman. ntero2 n3tes. nte4t ntev4 nt3g n1th2 +nth4e n4thel n3thi n4this n4thodo n3ti ntiar4 nti3di n4tie. nti3eg nti2er n4ties +nti4fi n4tily nti4nal n4tines n4ting nt4ingt nti4nou ntin3ue nti3p nti4pate ntip4s nti1r +ntiradi6c ntis4 nti3sc n4tish n4tism nt4ist n4tite nti4us n4tiz n3to nto4cr ntomi4 +n5ton ntoni4e nton4is nt4r ntrac5ta ntrar2 ntrion4 n4tro. ntro3s n4trose ntro3v n3try +nt4sa nt4si nt4so n3twin nt4z n1u1a n4ua. nuan4 nu6atel 1n4uc nu4ch nuch3i +3nucl nu4de nud4i nudo4 nu4e nuf4 n3ufa nu1i n3uin nu4is4 3nuk null4 +nul4lar n3ulo 1num n4um. num4bly 4numen 2numi nu4mis. nu4na 1nunc nuniform6 n1uo +nu4p 4nup. nur2 n4ura nur3b nur4l 4nury 1n2us 3nus. nus4c nu4th nuti4l +2n1v2 n4v. n3va nva5les nvar3 n3ve2 nvel6ope. nvermi4 nvers6a 4n3v4i nvict4u nvig1 +n4vt 2n1w nwi4 n3wo n2x nx3i n2y 1ny. 4ny2a n4y2c 3nych3i nyer4 +3nyin n4yl nyla6m ny4li 3nym. 3nym3ic 4n3ymo nyth4 2n1z2 nza4l nzi4a n4zit +n4zk o'2 o'4c o'4d o'4f 2o. oa2 2o1a. o3ab oab6o o3ace oac4er +oach5er o5act oa4d1 o3ag1 oag4r o4ag4y o3a5les oalesc6 o1an oan4er oan3i 4oann +oa6no oa4p o3app oar2 oar3i oar4s oas4t oat5er o2b 4o4b. o3ba 4oba. +ob3abi o4bad. obadil4 obah4 oba4li o4balo 4o4bb4 ob4bes o3bedi obe3li4 obe4lia o3ben +o3bi obil2 obiliar4 o4bing 3obin3i obi4no o4bins obit4e 3o4bj objectiv6 o3b4la o4blem +oble4s obor4 o3bot o3br o4bre o4bs4 ob1ul o4c. o3ca o4cag oca4la o4calu +o4cat. o4catel o4cativ oc4cas. occul4 o3ce 4ocer o4c3er3o o4cess. o1ch ochei4o o3chem3 +o3chet o4chic ochine4 ochle3 o4chop o4chorou o4chosp o4chrois o5chrom ochron4 och3t oci4ab +ocid4 oci4l o4cile 2ocio ocio4n o4cip oc4k oc4l4 o4clam o3co o4cod ocoe4 +ocolor4 ocommuni6 4oconi oco4py oco4q o4cosa oc3rac ocran4 oc5ratis ocre3a oc4ter oc4tu. +oc3ul oc3ume oc3uo o2cura oc3urat ocurato6 ocus5si o3cuti o3cy 4o4d. od2a oda4g +o2dal. o4dam o3dan o4dant ode2 o3dec odec3a odel4s ode4mat o4den. o4depe oder4i +odern3 o4des. o4desis o3dia o4d1ic od4ico odidac4 odi6dacti od1if odif4i o4diga o3dio +odio4d o3d4iu od4j o4doa odoac4 o4doe o2doli o4dolo o4dom odom4it o2domo o4dop +odor3i odor3o o4dosc 3o3dou o4dov o4droma o4dromo odro4s odu2 o4duct. od3ula odwil4 +o4e1a oe1i2 oe5ia oe1l o1e3le o3emp o3enc oendo6c oe3n4i o4eno o3enz oeod4 +o1er o4er4a o3erc oer4i oe5rotic o4er4u o4eso o4ess4 o2et oetas5 o3ethy 4o3et1i +oeti4u oet4s o1ev oev3e o3ex o2f2 o3fa ofa4n o3fe of3fe of3fi offi4e +of4fl of4fo 4offr off3s ofi2 o3fic ofi3ci of3it3 o4fle o3fo o3fu o4ful +o4fy 4og. o2ga o4gal o3game og3amo o4gano og3a3ri o3gas 2ogat og5atori og5atory +o1ge 4oge. oge4l o5gen. ogen4ism o4g3eno o3geo2 o4ges. 3ogeti o4gh 4ogic 1ogis +o3gist og1it 1ogiz o1g2l o2gli o4glie o3glo ogn4 og4nac og4ne o2go o4go. +o4gog o5gol o3goni o4gonis og3ony 3ogous 4ographa o6gress. ogrol4 2o2gu 1ogy 4ogyn4 +2ogyr4 o1h2 o3ha2 o4ham. o3he ohe4a o4hg o4hin o4hm o4hn o3ho oho4l1 +4ohs 4o3hu o4hv ohypo4 2oi o4i. oian4 o1ic. oi4ce oi3ch o4ico o4id. +oi2d3ec oid4er 4oides oid4h oid4ic oid5is oi4do oi3es oig2 oi3ki oi4l oil3a +oile4t oil3i oi2n o3inc oino4 oin4ti o4ir o4is. ois4c o4isi 4o5ism o5isti +oit2 o4it. o3ite. oit4i o4i4ty o4jv 2ok ok4am o1ken ok4es ok4ev o3kina +o3kine o4kn 5okous ok4sa ok4u okur4ti oky4m ol4' o4l. o1la ola4be ola4br +o4lac olack4 o4lalis o4lami o3lan olan4i o4lapa o3lar3 o4larc olar6is. o4larl o4lars +o3las3 olas4c olass4 o2lat ol3atr 4olc olch4 o4l2d ol4d1e o4le. ole1a oleast4 +ole4cr ol4e4d o3lei o3len ole4nod o4l3er o3let o3leu o4l2f ol4fe olf4fi 4olg +o3li. o1lia o5liat ol1ic o3lice ol3ich ol5ick ol3ida o3lidi oli4e oli4f ol3igo +4olik o3lilo o3line ol3ing. o3lini o3li3o o4lip 4ol3ish o3lism o1lit ol3ita o3lit4e +o3lith oliv4e 4olk o4ll olla4gi 4oll4e ol3l4i ol4lingl ollin4i olli4no 4olln ollo4p +ol3ly olmak4e o3lo. ol6ob o5log. ol3oge o4logist ol4ogr o2l3ogy o5lomb o3lomi o3lon. +ol1or olor3is olo4ry 1o3lou o4lour o4lp o4l1s4 ol2t olt5is o1lu ol3ub o4luce +4o2lun3 ol3unt o3lus o4luy 4ol2v ol4va olv3er. olx4 o2ly ol3y3a ol3yc ol3yh +4o4lyl ol3yp o4l1ys4 2om. o4mab o2mac om3achy o2mada o4madh omad3i o4mah o2mal +omal2i o4malle o4malo o4many o3mas oma4tik o4maton ombil4 om4bl ombo4r om4bry ombus3 +o2me 4omely om3ena omene4 omen4i ome3no o3meo om3er5a ome4ri om3eriz o3mest o3me4ta +o4meter o3meth om5e5tris o4metry ome3w o3mia om1ic 4omic. 4omica om3ici o3mid o3mim +omi4mi om1in om5ina. om3inen omin4ic. 4omins omi4se o4mish o4missa o3mist o3miu o1miz +om3l omma6to ommut5ab omni4f omno3 o3m4o omo4geni o6mometr o4monas o4monid o4mony o4mopa +o4mophon omo3th o4mp om3pil omp4l ompress6 ompro3 omp4te om4sh om4ti omy3 omy4t +omz4 on1 o4n. 4ona. 2onab onabor4 on4ace onactu6 on3age on4agen ona4nat o3nanc +ona4ne ona4po onapos6 o5narchia o5narchic 4onard onar4i4a. on4asm o3nasti on3b onback4 on3c +oncer4 on4chat 3oncila 3oncile oncu4m ond4d ond3ent ondur4 ondu5ra o2ne one4ag 4o3neal +one4at one4gat o3nei o3nemat o3neo on3er 2o4nes o5nesi on3est o4net2 o3neur o4nev +on3ey on5fl 4o4ng onge2 onger4o on2gi ong5is onglu3 on3go ongrel3 on3h o3n4ia +o4n3ic on3if on4ife on3ing o1ni1o o3nis. o4n3ish on4iti on3ito o1n4iu on3key onk4i +onk4s 2on3l on3m onma4tu onmerce4 onn4 onni4e onni2v on3oc ono4ce on4ocu on5oh +ono2l o4nol. onoma4t on3omis on3omo o4n3omy ono4phy onopo3 onopter4 6onora onos4 o4nost +onosti4 on3osy on3p4 onpart4i onpre4p onpre6se on3r 4onra onre4fo onre4la ons4 onsa4n +onsel4 onse4n onsid3 4onsk onspir5at ontag4 onta4l ontam3 ontana4 ont2i onti2e onti4f +4ontin onti4s ontortu4 on4tros on2u o3nuc o4nume onuti4 o4n3v onva5le on3w 4onymu +o3nys onza4 2oo2 ood1 ood3i ooe4l oo3ey oo4f oof3i oo3i oo4id ook1 +ook3ie o4ol. oo4lan ooly4 4oom1 oo4n3i oo4pe oop4er' oop3i oo4pl oo4ri. oos3in +oo3sp oos4t o4ot. ootch4i oot3er oo4ti ooz3i o2p o4p. o3pac 4opaeu op3ag4 +o4pagu o3pai o3pa4l o3pan opar6o o3patho op3athy 3o3pau o3pe3a o3pec o4pec. ope3di +opeg4 o3pei o3pell o3pen o4peng op1er 3opera oper4a. 4operag opes4t o3pex op4fr +o1ph oph4e ophe4l opheli4 o3phen o5pher. o5phia o3phie oph5ilis oph3ora o3phori oph3oro +o3phr 4ophrasi o4piab op3ic o4p3ing o3pini o1pis o3pit opi4th o3piu o3piz o3pl +3oplec o4p3li4 o4plo 3opme op3oda o4pody opog4e o3poi 4opol. opo4lat op3olis opoly3 +opo5ly. opol5ym o3pom o3pon o4ponis o4pophy o4popy o3por o4posit o4posp o3pou oppe4 +oppress4 o3pr opra4 opress4 4opresso oprin4 o4prod opro6f op3rol ops4 4ops. opse4 +op3si opsi3a o4pt opter4oi opu1 op1ul o1py. o4pylo o1q oqu4 o5qua 4oque. +o4r. o2ra o3ra. 4orac o4race. o4raceo o3rad ora4da orade4 o4rades o3rae o4rag +or3age 4orah 4o3rai 4o4rak4 ora4l o4ralist o5r4alit o3ram ora4ma or3ange o3rar o4rar. +orargy4 o3ras o4rasm orati4 ora4tis ora4tivi o5rav oravi4a. o3rax orber4 or3bi orbi4e +orbi4f o4rc orca4 or3ches orch4i4er 4orchu 4ord. or4dal ordeli4 or4dit o3re1a orea4l +o4rear o3rece or3eig ore4lat oreli4a o3rell oremon6 o4rence o3rens o4rent o4renz o3resc +o3resis or3est. ores4tr o4reth o3retic ore4v 4org. orga6na. 2orge 2orgi or4gis o3rhi +o2rhy o3ri. 4o3r4ia orib2 o2r1ic 4oric. or4ico o4rid. o4rida ori4dea ori3di o4ridl +o3rie ori4en. ori4f 4orifa ori5ge o3rigi o1ril o5rill o4r3ing o4rinu o1rio o4rip +o2ris or3ish orit4e o4rity o3riu or2m or4mater orm4e ormen4 ormini4 orm3is 4ormu +or4ner orni4a or4n3ie 4orno oro3a o3roc oro3et o4rogl o3roi or4on. oro3n4e oro3ph +2or4os o3rota orote4 oro4ti or3oug orphy5 orpo3 or3r4 orrob3 o4r2s orsa4l or4sen. +ors3in orst4 or3ste o4rt ort5ab or5tent ort3er. orth3e or3th4i or4tier. or4tres or4ty. +o1ru o4r3ul o4rv or3w o1ry ory4da ory4o os2 o4s. os3ab o4sace. os4ad +os4a4g o4salin o4sam os4an o4sanne os4ar osari4o o3sau2 os4ce o4schi os4ciat o1sco +o2s3cul os3e3cu 4osek osem4 os4f osgen4 4os4h o5sho os4ie osi4g osil4l o3sim +osina4 o4si4nan o3sine os3ing os3itiv os3ito o4situ osk4e os3m o3so. o4sod os5om. +o2sop4 os5ophis os3ophy os4or o4soth 4osov os4pa o3spec osper4mi os3pero ospirit4 os3sa +os4sai oss3c ossem4 ossil3 ossin4 oss3t osta4l os3tate os4tax oste3a o5stem os4tend. +os4thy osti4b os3tile ost3m os4tome 4ostomo os4tor os3tu ostu4m os4tut o1s4ty o3su +o4sue osulphu6 osur3g os3we o1sy4 osymme4 o2syne osyphi4 o4t. ot2a 4otag otam4e +o2tan otan4i ot5any ota4p o3tar ota5riz o4tasi o3tat o3tax ote2 o1tei o3teli2 +o3ten o4tenl o4tenoi otepa4 o2ter ot3er. ot4er4a oter6ism o3tes o4testa otes4tiv otet4 +oth5ecat oth3er. o4theral o4thou o2ti ot1ic o4tic. ot5icis o3tif o3til o4ting. o3tini +oti4po o3tist o3tit otiv4is o4t3l oto5f4 o4tog. otoion4 o2tom o5toma ot3o3mis ot3omiz +ot3omy o5tone otone4g oton4g o4tonus o4topy otor5is o4toro o3tra o2tri o6trophe o5trophi +o4trophy o4tropo otru4d o4try ot5ryo o4tt 4otte ottra4 otu3b oturi4e o4ty. ot1yl +oty3le o2u2 4oua oua4c oub4 ou5bl oubt3 ou4ce ouch3i ou4den ou3ette 4ouga +4ouge oug4h ough4t o4ui ou3is 4oul oul4d ou3m 4oun2 oun4cy ound3i oun4dry +ounk4 oun4sl oun3ter oun4ti ou4p oupha4 2our our3ag our4an our4c our4de our1i +our3is our4no. our3ous our4th o4us1 ou4se 4ousia ou4so ous4p ous4to ous4tr out1 +outch2 outre4 4ou3v ou4verne ouw4 o4ux o1v o5vak o3var ovar4y ov4ella o2ven +ove5na oven4i over3e overgent6 over3i o3v4ers ove4t ovet5o o2vi o3via o3vip o3vis +o4visa ov4it 3o3viz o4vk 4o3v4o ovol4t o4vr ov4y o2w 2ow. ow5al owa4n +o3way ow3di owd3l o4w1e owe4l ow3er owg4 ow3i o5witz. 4owo owof4 o4w3r +ows4a ow3sh ow3y ox3a oxam4 oxa4n oxcom4 oxe3m ox3i 4oxidas ox5im ox4on. +o4xu ox3y oxyl3a 3oxys 2oy oy1a oy4ad oycot4 oy4da oysi4 o4zal o4zene +oz2i o3zo1 oz4s 4p's 2p. 1p2a pa4ba pabys4 pa2c pa4ca pa4ce pach4e +pac3it 4pac4r pac4u 3pae pagan3 page4 pag4n p4agulu pagy4 pah4 p4ai pain2 +pain4t pais4t paja4 pa4k4i pa2l pal4ab pal5an pald4 p4ale palet4 3p4ali pa4lik +pal3in p4all p4aloi pan4a panc4 pan3el pan4gler pa4nia pa4nic pan5ici pani4o pan4is +panni4e pan5op pan4ti paot4 3pap pa3pa pa3pe papel4 pa4pet 4paph papi4 3paq +p4ar. par1a 5paras p5aratio par4chi par4din p4a4re 3paren pa3riet 4parina par3iso 5parit +4paro pa3roc pa3rol parot4ic p3arou 5parox 4partee 4pasm pas3qui 3pass pat4edl pa5tel +pa4ters 3path. 3pathic 3pathol 4pa3thy 3p4atic pa4tin pa4toi 3patr patro4 pau2l4 3paus +3pave pavi4e pa4z 2pb pbel4 p4ble 4pbr 2p1c pcervi4 2pd 1pe2 4pe. +4peab pe4ak 3pean pear4l pe4ba. pecas4 4pecha pe3cie pecti4v 2p4ed 4ped. pe4dar +peda4t pe4date 3pede pedes3t 3pedi pe4di. ped3im pe4dio pe5dol 4pee4d pe3f pefoun4 +peg4i 4peha pei2 3pe3ia peip4 peir4 pek4 peki4 4pelet 4pelik pelli4e pe4lo +pel4ter pel4tr pe4nan p4enc4 pend3e pe3nia pen4ic pen4is pen3i3t p4enn 5penny pens4e +pentar4i pen4tho pe3nu 3pep pe4pe pep4i pep3s per3a p4era. 4peralta 4perand percen4 +pere4t 3per3g 3peri pe3rial per3ic per3ig per3il per3im per3in per3ios3 periot4 per3it +4perl. perlu6n p4erm per4mism per3n pernatu6 6persedel 4persedy persh4 pe3rus per3v p2es +pes4car pes4th pes3ti p4et 3pet. 3petalo pe3ten pe4tio pet5iola pe3tra pe4tra. pet4rog. +pe4try 3peu peu3t 4pev 2pex3 3pexi 2pf pfal4 2pg 4ph. pha4co phag2 +3phage. 3phagia phak4 5phalia 3phalic ph5a5lis ph3alit 3phant 4phantu 3p4har phare4 4phareu +phar3is 4phars 3phas 3phat 4phe. 4phea 4phed phe2dr 4phelio 3phem 4phep 4pheret +ph4es 4phese 3phet 4phet. 4phets 4phial phi4b phi4di 3phil. 3phile 3philia 3philic +phi3lis ph5ilism phi4ma phi4n ph5iol 3phis 4phiste 4phistop 4phis4tr phi3th 3phiz phl4 +phle4 4phli 5phloe 1pho 3phob 4phobl 4phobo ph5omo 3phon phon4e 4pho3n4o ph5onou +4phony 3phore 4phori. 3phoria 3phoric 4phorn 3phos 3pho3t4 4photy 4phox 3phra 4phrasi +3phre ph3ro3d phros4 ph3t phu3i phu4ra 3phus 3phy phyc4 4phyg phy3la phy3le +phyl4i 4phyly phyr4 phyro4 4physa 4physis 4physit 4phytou p2i 3pia pi4ag pia4n4 +4pianu p3ic. pi6cable pi4ceo p4ici pi4cie pi4cil p4icl picom4 p4i4cr pi4cy. pid4e +pi3der pid4io 3p4idit p4idol p4idur 3p4ie4c pi1en p4ieno pier4 pi1es pi4ete pi3ez +pif4 pi4fl pi3geo p3iglo pigni4 p3igram p4ii p4ik p6ilatory pi4lik pil3ing pi3lo +pi4mol pinc4 pin3d4 pin4ery pi4net p4ini 3pinio pink3 pi3no p4int pin4ta. 3pi3o +4pioc pi4ole pion4a 4piony p4i2p pi4pag pi4ped pi4perin pi4phor pip3i pipis4 piq4 +p4ir pir3ic piri4ti pir4y 2pis p4is. 3pisc piscar4 4piscope pis4p pistem4 pis3th +3pisti pis4tr pi5tab pi4te pit2er pi4tl pi4to 3pit3om 4pitomic pi5tou pit4r pittel4 +3pitu pi4ty pi4v piza4 p3izo 2p1k 1p2l2 2pl. 3p4la2 pla4co 4placu 4plad +pla3g planc4e 4pland plan5et 4planos 4plap plar4 plas3te 4plasy plat3in plau3s 5ple. +ple4a4 p4lec 3pled plee4 3ple3g2 ple4ga plenar4 4plene. p4lent pleo4c ple4ri 4ples +plev3 4plev. 3plex 4pley pl4i 3p4lie 4plig 4plik pli4nan 4plit 4plj plo4d +4plof 4plog 4plol plum4be plu2mi plur4 ply4m 2p1m 2p1n p2neu 4pno 1p2o2 +poad4 po4cer pochlo6 4pocri pocrin6 3pod. 4po3da4 4podes po3di 3podia 5podic. 4podis +5podiu 4po3do po4dos po4dot po3dy 4pody. podyna4 3poe po4e. poe5i poendo6 po3e4t1 +poet3i po3ge poge4ny po3gl pohepa4 po4i 3poi3e poi3k poin4t po3ken pok4ier 3p4ol. +po3la po4lam pold4 po4les 4poli. polian4 4polis polit2 3polita p3o3liz poll4 pollin4 +4pologa 4pologia 4pologu po5loni po4l3y poly3s 3pomo po6mology pomp4 pon4ac pond3en po3nen +pon4g 3poni po4nia 4ponin. 4ponist 4ponite ponti4 4pony 4popau po4phy 4pophyt pop4l +popla4 p3ople poploi4 pop1u 4popy. po3r4a po4rade 4poree po4res p4ori po4rif po5rife +por4it por5ten 4porto por4tre por4ty 3pos pos3a pos5edl 4posem pos3in 4posio pos3it. +4posph pos3s pos5tasi pos4th 4postol pos4tom 3pota 4potace po3te poth5eca 3potheo poth5es +3poti poti4e po4tor potri4 poun4d3 3pour 4pousl po4ve po4w3 pow4n pox4a 2p1p2 +p4p' 4pp. 4ppa ppal4 ppara4t ppar3e 4ppe ppeli4 ppend6ant ppe4s p4pes. ppet4i +ppe4tr pph4 4p3p4i 4pp4l 4ppo ppo4d ppoin4 ppo5site 4ppr ppro4c 4ppu p4pv +1pr2 4pr. prac3 4prae prai4 pral4 pra4ni praorbi4 4prava 4praw pre1 prec4 +3preci pre5co pre3e pre4ene pre4fac pre3i pre2j prel4 pre3le prem4 pre4mis pre3p +pre4p. preph4 pre4posi pres4o 3press pres4sag pres4u pre3t4r p4ri. pri4b 4prico pri4cy +pri2e pri4es 2prima p5riman4 prin2 pring3 prin6kler pri4nod prin4t pri4s pris3o pri4val +4privat pri4z pro4bl prob5le p4roci 4prodo pro3e pro3i pro6ject. pro3l2 prola4m prolo2 +prolo6gis promp4 pro5no pro4pis propu4 pro1r pro4sp pross4i pro5sto pro1t prov5al provo4 +4prox. pru4c prun4 pru3r 2ps2 4p4s. ps3ab p4sal4m psa4m p4sb ps4est p1sh +p2sib psi4f psi2n ps3ing 4p1s4t p4sv p1sy. 3psyc 2p1t 4p4t. p2tan pt3ana +ptas4 p5tat 4pte pt3ed pt2er pter6ol pter4om p3t2i p4tib pti4f pti1m p4tin +pt3ing pt4is ptiv6ism 4ptl pto4mato p4tru p3tu ptur3a pt4y 1pu pu3a 4pub. +pu3be pub3l pue4 pueri4 puf4 pu4ga pu3gi pugli4 pu4gr pu5iss p1ula pulei4 +p4uli. 4puma pu2mi p4un2 pu4n3is 5punt pupi4 puppe4 pu1ra pu4ram puran4 pur3c +pu1ri 4purim pu4rism pu4rist. puri3t puru3 3pus pu4sio pus3t 3put. 3pute pu4to +4pv. p4va 4pvi 2p1w py4al p4ye py3el pyg4ia. 4pyla 4pylit py1o py4s +pyxi4 2q. 4qaz qi4b 4qq. q4to qu2 2quab q3uabl 4qua3m 3quatic qua3v +4que. 4quelic 3queo 4querr 3ques ques4s 3quet. qu4h qui3a 4quiem q4uin 3quin. +quinar4y 3quip 4quipa 6quipoise q2uis qui4sa 2quita quiv1 4quiv. qui4ve 4quivok 4quivoq +qu4o2 q4wa r2' 4r's 2r. r2a. 2rab raba4r rab4be. ra4bidi ra4bis ra3b4u +r4ac. ra4ca. r4acar racas4s ra3che ra3chi ra4chide 5rachn r3achu r2aci rac3ic 4racid. +4racki 4racl 4racot r4act2a rac4tan r2ad ra4dan radapt4 ra1di r4adic ra3dis r4adism +rado4 4rador ra3dr ra4dul 4rady3 r4afe r4agino 4ragon ra3g4r ragra4 ra4gro r4ah. +4rainer ra3ist r4ak ra5k4l 2ral rala4z r4alite r4allu ra4l3og r4a4lu r4alys r4amba +ram5bl ramel3i 4ramer r4a4mete r2ami 5ramida r3a3midi 6ramini 4ramis ra4mit r4amu ran2 +ranar4y r4and ran3di ran4dies ran4din 4randr ran4ge rang5eri ran4gui r4a3nia ran4if ra3nio +rani4to ran3n 3rannic r3anniz ranopi4 rans3el ran4sur ran4tr ran3ty ran1u ra4ny 3raphe +4raphed 4raphes 3raphis 3r4aphy ra3pis r4apn 4rapody r4apsi 4rapte rap5ul ra3py r4ara +4rarc rar4chat rar6chist rar3ef 4rarg r4ariu 4rarl r2as. ra4sal rasca4 r4ased ra3seo +ra3si 4r4asia r4asin ras4p ras4sie 6rasticho r4as4u rat4en4 rat4er3 ra4tid ra4tin. ra4tivis +r4atiz 2rato ra4toc 4ratom r4atus raud3u raugh4 raus4 raux4 rave2 rav3el ra4ver +rav3er. raver4sa ravet4 rav3it rawa4k rax3 r4axe 4raxel ra5zen razi4 4razzi rb2 +r4b. 4rba rb3ab rbal3a rbar2 rbarba4 r3bea rbe2l rbel3iz rbe4ri rber4n r4bes +r3bic r4bicide rbicy4 r3bid rbi4fi r2bing rb3ing. rbit4 r4bles rb5less. r4bn r3b4o +rbo3liz rbo4rac rbo6real. rbrat4 r3bu rbulk4y rbu3ri rbut4t 2r1c2 r4c. r3ca rca4b +r4canto rcap3i rcat6as r4cea r4ced r5cede r3cel rcelo4 r4cem rcen4ta rcep4te r3cera +rcer4eb r4cest r4cf 4r2ch2 r3cha r4ch3er. rcher4o r3chest r3chet r3chi rchid4 rch3m +r3cho r3ci r5cib rci3c r4ck rcle4 r3co r4cog rcollec4 rcor4i rcor4n4 r4cq +r4c3t rc4te r3c4u rcu2m3 rcuti4 2rd r4d' r4d. r4dal. r5dam r2dan rd4ancy +rde2c rdec3o3 r3del rdeli4a rdel4li rde4mo rdeni4 rdepend6a rder4or r1di r3dia rdi4en +r3dio rd3ise r4d3ism rdit4e r3diu rdi4vo rd3iz rdli4l r4dm r4dn r4dop rdo4sa +rdo5ta r4ds r3d4u r2e 4r4e. 4reab re3add re3adm 4re3ado re1al 3reali re1an +4rearm reas3i re4ast reast6e reas5y rea4th r6ebrate. rebu4 recas4 re3co4r re2cre rec3rea +1rect 4rectc 4rectd 4rectex rec6trix. re4cus4 2r4ed 4red. red2e rede4m re2dic red3ica +re4dit 4reditam re2dol redoni4 4reem re5endo ref4 ref5era ref5f refu2 4reful re6fuse. +4reg. reg3im regis3 reg4l reg5n 4rego re4gol re4gory re1i2 rein3a re4ist. reit4er. +rek4 re4lati re5latin re5latio re3lea re5len4 4relet re4ley rel3ish re4lude re6ludin 4rely. +re3mat r4emen re4mene re5mic remi4e re4mif 4remous r3emp r4en' r4en. 3rena. 4renai +re4nar renatu4 r4enb r4ene ren4eq 4renes r4eni re4nif r4eno ren3ov r4ens ren4tag +r4ente 5renthes ren4tist 4renz. re3o reo4e 2reot reo4tr re3pa repen4 rep3it 4replei +reposito6 rep5roba re3quit rer4 r3er. rerat4e r4ere rerec3o reren4 r4eri r4eroo res5ag +resar4i resel4 4resh r4esi res4im resin3 res4m re3spe res4see ress3ib res6so. res4to +re5stric res4try re3ta re5tabu retal3 3retar re3tel re5tem re3ten ret4er re4ters re3th +4rethy re4tic. ret2r re3tra ret3ro re3tu re3u2p 4reux re1v rev3ale revic2 rex3 +rexa4 r3exe r3ext re4za 2rf2 r4fab r4fer. rfer4ent r4fie rfi4l rflo4 rfo3ra +rfu2 r4fv r4fy 2r1g2 4r4g. r3g4a r4gab r4gag4 rgans4 rga4s r2ge2 rge3m +r3gen r3geo r3gery r3get rgia4 rg4im r2gi2n rg3ing rgini4 rg4l rglar4y r4gn +r3go rgon3is rgon3iz rgo4p rgo4t r4gret r4gual rguer4 r4gues rgu4mi 4rh. rha4b +rhag4o rhas4 rhe3al rhel4 rhen4ic rhepa4 rhe4um rh4i 4rhil rhi3z rhod4 rhon4i +rhu4me rhumer4 rhy2 rhya4 2rhyd r4hym 3rhyt 1r4i. ria4ch 4riad ria4da ri2ag +3riah 1rial 4rialisa 4rialla ri3a4m riam4e ri3an rian4it ri4ano ri3ar 4riat. 2riatr +2riba rib3al ri4bel ribe4r rib3er. rib3i r4ibo ribra4 r4ib2u r4icc ricente4 ri4chi +2rici ricid5a 4riciu ri4cles r4i4clo ri4coc r4icol ri6com r4icop ri4cy. 4ridae ri4dean +r3i3dec 3r4idic 3ridio 4r4idiu ri4doph r4i4ed rie4f ri3e4l ri1en 3rienc 4rientee ri3er +ries4 3rietal 2riev 4r4ifican ri5fy ri2g rigen4t rig3er righ4 rigi4l 3rigin 4rigin. +ri3gl ri3i ri3ka ril2 4ril. ri1li riliter6 riliza6b ril6let. rilli2 rilli4u 4rillo4 +ri4lo. ri3loc r4ima. 5riman4d r4imin rim6inan rim3it r4in. r2i3na r4inch rindi4a rin4dy +rin4e rine4p ring5hal 4ringn rin4io rin6kler. r4in4o ri4nodo ri4nou rint5er. r4inth rin4ti +4rinus ri3o2 r4iola. r6iolate r4ion. rion4is r4ior 4riosov rio4t1 riot3o ri2p ripho4 +riphthon6 ri4phy riple6g rip5lica ri4pol4 ri4pur ri3q r3irr ris4c 4risch ris5en 4risiu +5risiv riso4p ris4p 4risp. r4iss r2ist rister4 risti4ch ri2t ri3tal 4rites ri5tes. +ri4tiat rit3ic ri4tist r4itos rit4s ritur4 rit4y r4iup r4i2v ri3val riv5ativ riv3el +riven4 riv3er riv3et 4rix riy4 2riz ri4ze riz4zer r4ji 2r2k r4k. rka4n +r5kat rk1er r3ket r3kin. r5kins 4rkp rkpa4 rkt4 r1l2 r4l. rlan4di rlan4t +rlas4 r3le r4le. r4led r4ler r4lest r4leto rle4tr r4levi r4leye r4leyv 4rli +rl4in rl3ish r4lk4 r3lo rlunar4 rlus4 r1m r4m. 4rma rm3ab rmace4 r4maes +r4mald r4malg r4mallo r3man. rmaph3 r4mater. rma6toc rma4tog rma4top rm5atory rme3at rme3c +rm5edl rme4la r3men r2mer r4mest rm4et rm3h 4rmi r3m2ic r3mid2 r2ming rmini4f +rm4i4p rmi4tia rm4iv rm3l r4mn r3mo rmoe3 rmo4rie r3mu r5myl rmyster4 r4n. +r1na r4nabl rna4cl r3nad rna4l rnal4li r4nanc r4naro rnar6y rnat3u r3nau rnau4t +4rne r3nea r4nee rne4g r3ne4l rneli4 r2ner r4n3er. r4nesb r3nest r3net r3ney +4rnh r1ni r3nia r2n3ib r3nic r4nies r2n3in rn4io rn3ist r3n4it r4nite r2niz +r4nk r1no rnop4 r4nove rnt4 r1n2u r4nup r2ny ro3al r4oan ro4an. roar3 +rob3ab r4obe. ro4bile ro4bin rob5ing 4robiu ro3bl r6obla ro4ble rob5lem rob3or roc4e +roce4l ro4chos r2oci rocio4 4rocit ro1cr rocre3 r2od ro4dan roden4 r4odi ro3dis +3rodit r4oed roe3l 6roetha rof3f ro5fil roge4a rogen3i ro4geu rog4i 4roglu r4ogy +ro1ic 3roica roi5der rok4e rola4mi 4roles ro1li4 rollo4 ro3ly ro5lyse r4ome. ro3mel +rom2i romi4na romonta4 rom4p romp4t ron2 ro1na ro3nau rone4g ron4em ron4er r3one4t3 +ron4gr r2oni ron4ism ro3nist ron4ten r4onti r4oo rook4s 3room. roo4n ro4o4t 3root. +r4opae ro5par roparie4 ro3pel 4ropia ro5pin. ro3pis ro3po 5ropolita ro4posi 3ropous r4ops +4ropsi ro3py 4roq ro5rati rorato4 rore4b 4rors r2os ros4a ro4san. ro4sann ro1sc +ro5shi rosi4e ro4sot ros3per ros4s ros4te ro4sy. r2ot rotam4 rote4l rot4er4 5roteri +4rotesi rotes4ti ro4thes ro4tic rot3id4 4ro3tis ro4tonu rot3s rot4tag roturi4 rot4y4 ro4u +rou3b r4ousi 4rouss. rous4t ro2v rov5al. rov3el rov5en. ro4vo4c rox3 4rox. 4roxi +ro3zen rozi4 2r1p2 rpass6i r3pedo rpentar4 r4per. rpe4ti rph2 rphy5r rp4i r2p3ing +rp5ing. rp4l rpla4s rploi4 r4pn rpo3r rpo4ri rpo4s rpos4t rpre4te rpri4e rpro4m +rp4s rp3t2 rpu4ris r1q rqu4 rques4 2r1r2 r4r. 4r3r4a rr4e rre4co rrec4tit +rre4for rre4fu rre4i r2reo rre4pa rresol6 rre4th rrev3o rrh4 rrhe3a rrhon4 r3ri +rri2a rric3u rri4en rri4o rris4te rritual6 r4rk r4rm r4rn r3ro rrob3o rro4da +rrog4 rro4l rrol4l rron4 rror3i r4rs r3ru rrubi4 rru3g rru4p rrur4 r4rv +r3ry 2r1s2 4r4s. rs3ab r3sal rsal4t rsar4ia. rs3b rs4c rs3d 4r2se r3se4a +r3sec rse3cu rsel4 r3sen rse4no rse4ph rser4 rs4es. rse4u r3se3v rsever6at r3sey +r3sh r4shie r4shy r2sib r3sic4 rsica4 rs3ing rsis4 rsist6 r3s4it r2sk4 r3ski +r3so2 rson3at rspo4 4rs4t rst4e r3su rsu5an rsuggest6 rs3v 2rt 4r4t. 4rta +rt5abi r4tage. r3tar rtar3i r4tase r3tat 4r2te r3tea r3teb r4ted r3tee r3te4l +r3tem r3tere r3teri r3terl r3tes4 r3tex r4tf rth4a r4thei rth4erly rth2i rth4in +rtho4gr r4thors rthusi4 r3thy rt1ib r3tic r4ties r3tif rtil2 rti3lis r4tily r4tines +r4ting r3tins rt4inv r4tipl r4tism r4tist. r4tistr r3tit rt4j r4tles rt5let r4tly +r3to r4toma r4topl rto4te r4tress r4troo r4ts rt4sho r3tu rtub4 rtur4be r3twi +rtyr3i r2u2 ru3a ruant4 ru3be ru3bi ru3ci ru4cin ru3e2l rue4li ru3en r4ug +ru3ga ru3id ru3in r4uk ru3ma rum4bi rum5bl ru3me ru3mi 4rumn rum4ph run4e +run4s run4t ru3o rup2 3rupt ru3ra rur4e ru3ri r4us rushing6 3rusp ru3ta +rut5is 4rutz r1v r4v. rva4d rvato6ry r2ve rv4el r3ven rven3i r3ves rves4t +r3vet r3vey 4rvi r5vi5en r4vies r3vil r3vin. rvi4p rvisi4 rviso6 rvis4u 4rvy +r1w r4w. 4r3wa rwet4l r3wi 4rxe rx4y ry1a rya4t ry4di r2y2g ryg3i3 +4rylas ryllo4 ry4loc ry4lyl ry3ne 5rynga r5ynx ry1o ryopi4 ry2p ry4ph rys4e +rys4i ry4so rys4ton ry3te rytel4 ryth4i 3rythro 6rythrops rz2 4rzu 2s. 1sa +3s4a. 2s1ab sa4bat sab4o 3sacch sa3che sa4cl s4acr sa3cral sacros4 s3act s4adda +s4ade sa4dom s4age sa4gi sag4n s4ah4 saha4 3sai sail3 4saim 4saise 3s4ak +sa4lar sal4at s4a4le sal4id sa3lie s4alin sali6na. 4salind sa3line sal4ish sal3iv 4salmi +sal4mon. salti4e sal4ty sa3ma sambi4 sa4men 4sami sa4mo sa4my. 3s4an. sa4n5at 3sanc +3sand san4di san4dy san4gl sanhe4 san4in san3it 4sanite san4tr sa4n4u s4aph sap4i +sa3pie s4ar. sar4a sar4ce sard4 4sarea. sar4gu sar4iae sar4ios 4sarm sa3ro sar2y +s4as. sa4si sas3s4 3s2at sa3ta sa4tir 4satl satra4 sat1u 3saur s4ava sava4g +sa4vel sa4v3i2 sa3vo4 4savu 5saw. 3say 2sb s4b. s3ba sban4 s3be sbehav6 +sb4j s3bo sbor3o 4s3bu s2c2 4s4c. sc4a scag4 scal4d sca5len sca4lig sca4ly +scan4t sca4p sca4r scar3ab scar4c scar2f scar4p scas4 s1ce s4ced s4cene s4cen4i +scerp5 scet3 sch4 2sch. s4cha. s4chal s4che s5cheat schil5l s4cho s3cib scic4ulu +s4cio. s4cion sci3p4 s4cist scit4 sci5ta sc4l4 scle1 scler4 4scly 4sco. scocar4 +scof4 4scog sco4le 4scom 1scop 3scope 3scopic 4scopis 4sco3py s4coto scov3e sc4r +scra4 scro4 4scrys 2scue 2s3cula scu4riu scu4ru 2s1d s4d. s3da s2e 4s4e. +3se2a. sead4 seak4 sear4 seas4 3seat se4aw secer4 se3cl se2co secon4 3sect +sec3ul se4cy 2se2d s4ed. sed3en sedil4 sed3im s3edn 3s4ee 2sef4 sefa4 s4e2g +seg4n seg1r 2seh se4hen 3se2i 4seik sek4 3sel. se4la. se4lagi s3eleme selemen4 +selen4 4seles se5leu 3s4elf sel4ic seli4e 4seline 1sell 5selle. 4sellen 2sely4 sem1 +sem4a semain4 semal4 se3man se5mati sem5b 3sembl 2sem4e semer4 3sem3i3 se4mi. sem4ic. +semi5p semis4 semit4is sem4m sem4or 1sen senar4y sen5ato 2senc 4sene sen4h 3s4eni +sen6ic. 3seno se4nol se4nou 3sens sen4ti. 4sentis 4sents s4e2p sep3a se4pal sepa3r +se4pho sepho4r 3sept se3q ser2 2s3er. sera4d se5raphic ser3at s4erd ser4e 3s4eri +se3ria ser3ic 3s4erl sermon3 se3rol 4ser3p s4err s4ers 1sert 4sertico 4sertl s4e5ru4 +3serv 5servab 4servas serv3er 4ser3vo 2ses s3es. se4sa s4esc ses4o ses3q 3sess +s3est. 3s4et 4seta 4seterr seti4 4seto 4se4tr s4eu 3seur 3seus 3seve sev3en +sevi4 4sewo 3s4ex 4sexp 1sey 2sf2 s4f. s3fe sfea3 s3fi2 s3fo 2s1g2 +4sga s3gr s2h 4s4h. 3sha. 3shad shad5i shad3o sha4ga sha4ko sh4al sha4man. +sh4ap shar2 sharmo3 sharmon4 sharp3 4s4hc sh4chi shdraw4 shea4 4shead s4hed sheel4 +4shei s5heim shel4li 4shen shep3 4sh3es 5shev3e she3vi s4hf s4hi 3shi. shic4 +4sh3ier 4shies shim4i 4sh3ines sh4ino sh3i2o 3shi4p 1shir s4hk 4sh1l 4shm shmi4 +4shn4 1sho2 sh4od 4shol shoot3 sho4p sho4r 4shors short3 s4hp s4hr shriv4er +4s4ht 5shug 4shum 4s4hw s4hy shys4 1si 3s4i. 3si2a2 s4ia. si3ac s4iaca +4siad s4ian3 si3b siber4 si3ca 2sicc s4ich sicil4 sicili4 sico4t 3sicu sic3ul +si2d 3side. si4dec sidel4 side4n s3ident sid3er 3sidera s2ie 3s4ie. sie4n si4era +2si1es 4sies. siev4e si3f si4fe siga4 3sigh 3signal 3signed 5signi 4sih siha4 +s4ij sik4i 3sil. si3la 4silar sil4e siler4 si2li sili4ciu sil3i4e 4silin. 4silisc +4silisk sil3iz 4sillu sil4ty 2si5ly s2im 2sima s4imac s3imal sime4ne sim4ply 2s1in +3sin. 3sina sina4m 4si4nant 4sind 3sine. 4sing s3ing. sin4gan s4ingh sin3gle s3ingly +s4ingst s4ingt s4ini sin3if sin4in sini4s sin4it s4inn si4nou 4sins sin4th 4sinth. +sinus5 3sio2 4siod sio3p 4sioru 3sip si4pr s4i2r sir3ou 3s2is sis4a 4sish +sis4p sist3en 4sisth 2s2it si3ta si4tab 3sitio si5to sito4rie sit4s 3situ 4situs +s3ity s4iu 3siv siv3i si2z s2k 2s4k. s4kala ske4 4skee skel3e sk4et +s4kil ski4r s4kl sk4v sky3 sl2 s4l. sla2 slam3 slan4t s4laug 3s2lav +slav3er s4le. slen3 4s3let 2sley sli2 3s4lip sli4v sl4l 2slo 2slu2 s3luc +2sm2 s4m. sma2 sma3g s3man sman4n smel4 s3mer s1mic smi4no s3mis smis4sa +3smith smo2 s4mo4k smold4 s4moo smo4t smoth3 smu2 4smui smut3ab s4my sn2 +4s4n. s4nal sna4p snatu4 s1ne sne4f snel4 s4ni4 snif2 snive4 snoo4 s4nou +1so soa4 so3b 4soc. 4socc 4socea 2so2ch so3ci soci3o 4soct 4socu sodro4 +3soe soer4 soev3 s2o2g sogam6e 3soh 4sois sol2 3sol. solal4 so3lat 6solativ +so4lato sold4 sol3di so2li so3lic sol5id. so3lil 4soliti s4olo s4o6lutiv so4luto 3solv +3so2m so3ma s4ome. s4omel s4omen som4i 3s2on so3nan son4ar sond4a soni4e 4sonm +sonni4 so2no soo4t so4ped soper4 s4opha so4phe 3sophic so6phism s3ophiz sop4i so4po +sor2 sor4b sor3c 4sorci sor3d 4soret 4sorg4 so1ri so6riate 4sorit s4orn sor4ta +3sory 3sos sosi4 4sosm 4so3t4er soter6is soth4i sot4s sout4 south3 4sova so2ve +sover3 so3vi 4sowen 2sox s2p 4s4p. spal4l spa4ni span5ic spar3en spato4 spavi4 +s4pb sp4e 3s4pea s4pec3u s4ped s4pee4 spel4l spen4g spens4 4speo spe4r 3s4perm +sper4min sper4mis sper4on s3perou s6persede sph4 s3pha s4phe s5phem sphen4 spher4o s3pho +s4pi. sp4ic spi4cy 4spid spi3de s4pie 4spiec 4spier spi4f s4pil2 spi4li s4pin +sp3ing spion4 s4pis s4pli s4ply s4pn s4pom s4pon spon2d 3spons spoo4 1spor +4spora. 3spori 4sporou spor4tie spro4c spu2 s4pum sput3ab s4py s4q. s3quer squin4 +squit4 2s1r s4r. s4ram sre4c sre2p s4ri. s4ro. sro4b sr4t 2ss s4s. +s4sab s4sais s3sal s3san ssap4 ssar4t s4sc ssect4ib s1sel s3semb ssem4ic s1ses +ssev3 s2s3ho ss2i ssi4a s2sibl ssico4 ss4id s2sie ss3ies ssil3i ss3ingl s3sini +ssir4 s3s4it ssi4us s4s3l ssman4 s4s1n s3so sso4le sso3na ssoni4 sso6riat ssor4t +sso4ru s4sov sspel4 s4spo4 s3su ss3w ssy4r5 s2t 4s4t. st2a2 s4tad staffi4 +4stag. stame4 s4tan. stand3 s4tani sta4p staph3 s4tas. s4tasia 3s4tat. 3s4tatic stat3u +4staur s4taxi st3c ste2 4ste. 3stead ste3at s4ted s4te4g s4teles stel4ic st3ence +sten3i s3tenin ste4no sten3ot ster4na. stern5iz s4terr 4stet. stew5a 2s4tf 2s1th s4tha +s3the s4thea s4them s4thex sth4i sth4l stho4l stholi4 s4thou s4thyo stia4 s4tib +s3tic s5tica 3stick 2s3tid s4tie stif4 s3tifi s3tify sti4g3i 5stigmi s4tih s3tima +3s4timu sti3na s4ting st3ing. s3tini s4tipu 3s4tir s3tis s4tisp 4stl s3tled s4tli +4s4tm stmen4t s4tn4 4sto. stoba4 4stoe s4tof s4togn stoi4a s4tok stol4e stom3ar +3s4tome. 4stomo s3tomy st4on 4ston. 3stone 4stonp sto4pi sto2r s5tor. sto3ral 4storat +s4torb storbi4 s4tord s4tore s4torg s4torm s4toss s4tota stot4in 2stow s4tp st6pra +st2r 4str. stra4l5ia strep3 s4tride s4trol. s4trop. 4stro5phe strot4 stroth4 3stroy 3struc +st1s4 stti4 s4tu2d s3tul s3tuo s4tup s4tur. stur2b 4sture 4s4tw 4stya 3s4tyl +styl4ic s4tyr 1su2 s4u. su3ab su3al su5ant su4b3 succi4na 3sud sudo4 suf3 +sug3ar su5ic su3in sui4t3 3s4ul sul4te 4sulto 3sum sumer4 sun4a sunam4 suni4t +4suno s4u3o su3p su4per. supra3 4surani su3rat sur4d 4sureth sur4gy sur3v s2us3 +3s4us. sus4a 4sush sus4i sus4y 4suter sut4s 4suy su3z 2s1v s4v. s4var +svel4 4s3vi svil4 s4viz s4vp s2w2 s4w. swam4p sw4e sweat3 s4wee swel4 +swell3i 3swer 4s5wic swiv3 2swo sy2b syb5a5 sy4bi 3s4yl syl3l syl4p 1sym +sym4i 3symm sy4mond 1sy2n3 4syn. syph3i syr2 sy4ro 3sy4s3 s2z 4sz. s4z4a +sz4i 4szk 4t's 2t. 1ta 3t4a. 4taa 2tab t4abas ta4ber tabi4a tab3li +5tab5olis tab3u 3taceo tachi4 tach3yp 4tacid ta5cis tac3it ta3cl 4tacol 4tacon t4ad +ta4dl 3tae1 4taeb tae3r2 6taerist taf4fer taf4fr ta2g tage2 t4agn tago3n4 t4ah +3tah. 3tai. 3tail tai5lor 4tainers 4tainf talem4 ta4len ta4lic 4talie ta4ling. ta3lini +taliv4 tal4k 4talkal tal6lan t2alli t4alloc t4allot talo4g t4alp 4taly. t4ameris tam4ie +ta4mis4 4tamm t2an t3anal tanan4 ta4nat 2tanc tan4ce tan2d tand3a ta4nes t4ang +tan4gui 4tani. 3tan5ic tani4e ta4nin tan4iz ta4nom ta4nou 4tansc 4tansi tan4tan. 2tap +ta4pa t4ape t4aph. ta4pi t4app t2a2r2 t4ar. ta4ran taran4ti 4tarct 3t4ar4d tar3g +targu4 3tari tar3ie tar4is. tar4ism tar4ius tark4i tar3n tar4te 4tarth tarvel4 3tary +4taryt 3t4as. tas4c 3tasm tassem4 tas4sie tas4t t2asta 3tastas 4tasth ta3sy ta5tar +t4ath ta4the t4ati 4tatica 3tatiz ta4toc ta4tol ta4tos 4tatrop tat4ta ta4tu 4tatun +4tauc tau4m tavan4 tave4s tawa4 taw5n4 tax3 taxim4 tazo4l 2tb2 4tb. t4bili +tb4l t3bu 2tc tcau4 t3ce 4tch1 tche4 tch3eri tchet4ti tch3i t4chl t4cho +tc4i tci4t t1cr 2t1d2 tdev4 4t4e. 3teac teach3 3te1an 4teat te3ato 3teau +3tebr 1tec te4can te3ce te3chi 3techn t3e3chu 4te4col 3tect 2t1ed tee4l tee3pl +t4eer. tefi4 teg2 te3gi 3tegic teg4n t3egor te4gri teg3um teher4 te2i tei4n +te4k t2el 3tel. 3teleg tel3eo 3telep teler4 4teless tel3ev teli4e tel4lab 3telle +4tello. tel4o 4tely. te2m t4ema 4temen 3tem3iz temp4 tempt3i 4tenac. 3tenan ten3ant +t3enat t4enci 3tend ten4d3a 3tene. 4tenes te4ni te5nia ten3in te4no. te4n4or 3tens4 +4tens. 5tensi ten4tag ten4tee 3tenti ten4tie t4enu ten3ur 4tenus 4teny 3te3o teob4 +4teocr teo4r1 teorbi4 tep4i te4pl 1ter ter5an 3terb 3terc terci4 t4e4re ter5ec +3tered ter4enti ter3est tere4v 3ter5f 4teri. 3te3ria 5terie. ter5ine t4ering 3te3rio ter4is. +ter3ist 3te3riu 4terj. 4terjum t2erl 3t4erm ter4mor 5ternity ter3o teroge6no ter4oma tero2s +ter4os. 3terp t4erpe 3terr 3ters ter3t terte4 3ter3v tervar4i 1t4es 3tesc te2si +tes3im tes4o 3tesq 4tess. test3ab tes5tam 4testan tes3tat 4testr tes5tu 4tesv te4th +3teti 4teto tet1r 3teu2 teuc4 teur3i teu3t te3ve tev4i tewom4 3tex. texam4 +texami4 2tf2 tfal4 4t3fu 2t1g2 4tg. 4t3ga 4t3ge2 tgen3 tgeni4a t2h 4th. +5thaemi tha4lat thal5m tha4lo tha3no thar3g 5thargic 5thart thatch4e 4thau thaz4 4thead +th3eas theat4e 3theci 4theg 3the1i 4theid 4thelb the3m 4the4mo 1then 3theni the4ol. +4thep 3thera 5there. 3therea 4thereg th3erf 3theria th3erin 3therm th3ern th3ery th4es +the4sh thes3p 3thet 4theta thet4e 4thetec th3e3ter 4theth 4theto 4thet4y 3theu 4thexa +th4ey 2thf 4thh 3thiaz 4thica 4thina thin2g 3think 3thi3o thion4 thiop4 3this +4thisc 3thiz 2th3l 4thm thn4 1tho 3thodic 4thof tho4la 4thole tholi4a thol4ic. +5tholica 5tholicis 5tholiciz 5thologis 5thology thon2 4t3hoo th3opo 4thore thor3o 4thorse tho2t +4thour 4thouse 4thov 1thr thral4l thre4 thren4 4throd. thro4n thron3i 4ths 1thu +4thum4 3thus thy4b th1yl3 3thyo 3thyro th2ys 1ti 3ti. 3t4i2a 4tiam. tia4r +ti3ari 2ti3b tib5ia ti4bit ti4biu 4t1ibl 2tic t4icac ti4cas tich3i 3ticia ti4cil +3ticit 4tick 3ticl 3tico 4ticoen 4tict 3tic3u 4ticuf 4ticut tid2 4tidae ti2dal +3tide. ti3ego 3tien tier4i ti1es 4ties. ti3f 3tif. 3ti4fe tif4f tight4y 3t4i2gi +tig3i5a 4tigm t4igr 3tigu ti4j 3tik tik4e 4tilb 3tilen 3til2i 4tilik til6ise. +tilit4 tili4tu til6ize. t4illary til4lit 4tillu t4iln ti3lob tim2 ti3ma 3timab tim3b +3time timet4a ti4mete 3timo timon4i tim3u 4timul 2tin 3t4in. 5ti3nad 3tine. 3tined +t4ingb ting4e tin4gi 4tingl 4tingn tin4gr 5tingu 3tinic t4init tin4j ti4nose 4tinsk +t4in4t 5tinus t4invi 3tio2 4tio3la 4tiolu 4tipas. ti4pater ti3pe ti6peda ti3ph ti4pho +ti3pl ti4pod ti4pot tipri4 tip4sa 4tipul t4iq 4tir. 3tire tiren4 ti4ret tir2i +5tir3ic tir3in 4tiris 4tiriz 3tis. tisa4m ti3san tis4c 4tisec tiser4 2t1isf tish4c +4tishm tisi4 tis4l tiso4p ti3s4p 4tispu tisso4 t2ist tistal4 3tisti 4tistici tis4tr +ti3su ti3sy t2it ti3ta tita4n 4titc tit4e t4itia 3tition tit4is ti3tl tit4li +ti4tol ti3tr 3t4iu 3tiv ti3v2a ti4v3is ti4voc t3i3zen t4jan tj4e tju4 t1k +tk4i4 4tky 2t1l2 4t4l. 4tla t4lal tlan2 tland3 tl4e 3t4le. 3tled. 4tlee +3tlem 4tless 4tley 4tlie 4tlik tlil4 2t1m 4tma 4tm4e 4tmo tmos5p 2t1n +4tn' 4tn4a tna4si 4tne tnton4 1t2o 3to. toas4 toat4 3to3b toba4l to4be. +3toca 3tocr to3crat 3tode 4todu toe4a to4ed 4toest tof4i 3tog 4togly 4togne +4togo 4toh. 4tohm 3toid toi4di toile4 2tok tok4l 4toko t5okou 4to3ky tol3em +tolici4 tolo4n 3tom. 4tomal to4mall tomar3 to4matis to4matiz to4matop 3tome 4tomen tome4r +2tomi 3t4o4mic 5tomic. 5tomica t3omist tomoto4 4tomou tom4s tomu4 2tomy ton2 t4on. +5tona4l to5nalit toneast4 tone4ga to5net 3tonic ton4io 6tonish. 6tonishm tonni4 tonsi4 to2ny +tooe4 4toor to3pa 4topau 4tope. 3toph 4tophel 4topoe to5py 4topy. 3tor 5to5ratio +torbi4t 4tordi to4rea 4torer 4torga to1ri tori4de tori4en tor3iz 4torm. torpo4 torth4 +to3ry 3tos tos4e to3sp 4tosph tos4s tostel4 3tot 4tota. to3tem to3the to2to +totro4 totu4 4toua 4tound. tour4a tovar4 4tovis 4towah 3tow4n 5town. tows4 3tox3 +tox5i 2t1p2 tpeli4 tpelli4 tph4 tpi4 tport4 4tpr t6pran tpre4 1tr2 4t4r. +4tra' 3tra. tracas4 tra3ch t4racid 4tradd tra2ga 4tragalu t4rago 4tragu 3tral t4rame +4tramen tra3mi t4ranc t4rand 3trans 4trans. trans3a t4rapt t4rar 4trarch. trar6chis tras4 +tra5ven 4travi4o4 t4razz t2r4e 3tre. tre4ad 4treal treci4 t4reg tre4i t4re2m tre4ma +2treo tre2p 4treph trep3o3n 5tresc t4ri. 3t2ri3a 4triate. 3t2rib 4trib. trib3a tri4be +3t4rice t5richa 3tricia t4rid. 3t2rie 4trij t4rill trilli4 4trime 2trimo t4rin trinar4 +trion4a t4rios tri4ph tri4pu t2ris trisylla6 t2rit trith4 tri4tia trit3ur t4riu 3trix +triza4b trob4 tro3e trof4i t4rogeny trogo4 t6rograp tro4i tron4e tron4g tron3i 4trony +4troom t4roph 4trophy. 3tropic 4tropis tropos4 4t3ropou 2t3ropy t4ror 4tros. tro3sp 3trot +troth4e trous4e t4rox 4truc tru4de tru4e 4trug tru3i tru5si t4ruv 3try. 2ts2 +4t4s. t4sa. tsan4 4ts3b ts4c t4schl ts4e tsel4 4tsf t1sh t4shor ts4i +t3sky ts3m tsot4 t1s4p 4t1s4t4 tsta4 t4sug4 t4suk t4sus tsut4 ts3v 4tsy +t4sy. t4syn4 2t1t2 t4t' 4t4t. 4t3ta ttak4 ttar4iu t4tb t3ted t4teme ttend3 +tte4r t4tes. t4teso t4tg 4t3ti tti5a tti2d tti4la t5tingl t4tins t4tite tt4j +t4tk t3t4l t4tm4 t4tn tto2n ttori4 ttra4m ttrib3 t4try t4ts ttu4o ttu4p +ttur3 1tu tu1a tuag4 t6ualiser t6ualizer tub2 tu3b4e tu4bel tu3bu 4tuc. tuca4 +tu4des tu2di tud3ie tu3di3n tud3y 2tue t3u3en t4uf4 3tu3i3 tuil4 tu4is tuit4 +2tu1l 4tuln 3tum. 4tumb 3tumi tump3 tun4 tund4 tu4ne tu3nic tun5in tunk4 +tuo2 4tup. tu6rabl tura4m tu6rativ 3turb tur4ber 3ture 4turet4 tur4f 5turgic 3turi +tu3rie tur3is tur3iz tur3o turre4 4turt turti4 3t4us tuss4 tu4ta tu4val tuy4e +2t1v 2tw2 4twa t4wain twee4n 3twine twir4 twis4 twist5 4two twor6thil 4twy +txi4 1ty2 4tya 4tye tyers4 4tyf ty3i t3y3lat tyle4u t3ymo ty4mol. 4tyoc +3typ typo4t 4typu t4ys 4tysc 4tyu 4tyw 2t2z tz4e tzg4 tz4il 4t4zl +tzo4 tzs4 2u. ua2 ua3b 4uac uad1 uadi4 uaf4 uag4i uain2 uaint3 +u1al ual4a ua3ma ua4mas ua4mu ua3na uanan4 u4ane u4a3p uar3an 4uar4d u4are +uarri4an uar3te u1at uat4edn ua4tr 4uau uay4a u2b1 ub2a ubb4 ub5bl ub3c4 +ub3d ubde2 u4be. u4bele u3b4er u5beri ubi4as u3bic u4bin4g 4u4bis u3b4it ubi5ta +ubl4 ub3li ub3m ubor3 ubor4a ub3p u3bri u4bro 4ubs4 u4b2u ub4ul u5bulat +ub4y 2uc u3ca 4ucah ucar4 3ucat ucate4 ucay4 uc3c uccum4 4uce u4cep +u3chu4 uc1ib u3cif ucig4 uci4p u3c2it u4c4k3 4uc4l ucle3 u3co uco3p u1cr +uc4rac uc6ratize uct4a uc4te u1cu ucuro4 ucy4 u4cy. uda3i u4dam u3dan u4dax +ud3d4 u3den u4dene u4dep ud3er. ud4er4a uder4s udev4 udg3eo udge4t u1di u2dian +ud2ib udic4e 4udiciab u2die ud3ied u3dim udi3ni u3dino u3dio u2dis4 ud3ish ud4it +udi4u u3do3 udop4 u4dru u2du ud3ule u2dy 4udz ue4a ueb4 uebe4 u4ed +ue4gia uehon4 ue2li ue4lia u4elo uel4p uen4ce u4ene u4eni u4eno4 uere4 ueri4l +uer2r uer4y u4es2 ues4th ues3tr uet2 ue4tr 2u2f2 4uf. u3fa 4ufa. 3ufac +4uff uff3ab uff3ing uf3fl uf5fle uf4i u3f4l u4flo uf4r u3f4u 2ug u3gal +4ugao ugar4i u3gat u4gh ug4ha ugh4es ugh3i ught2 ugh3te 4ugi ugi4e ugli4a +ugmen4 ug4ne 4u3go u4gout ugre4 u3gu u4gul ugz4 u3ha uhe3 uh4s ui2 +4ui. ui4a. ui4ab ui3al ui3a4n ui4at uiber4 ui4bu u2ic ui4de uid3i 4uidit +ui3e ui4er uie4ti uig2 u2il ui3la uil4d u4ile uil3iz uil4lo. uil2t3 ui3nar +uine4a uin4ty uin4u4 ui3o 4uip ui3po 4uiq 4uir uirel4 uir4ite uirre4 uis2 +u4is. ui4sat uiset4 uis3in u5issa uis4tr ui4tab ui4tr uitz4 u4iv ujer4 u4jt +2uk u1ka 4uke ukem4 u3kh 4ul. u3l4a ula4ca. u4lai 6ularis. 4ulb ul4bl +ul3c 4ulca 4ulchra ul4e 4u3lea 4u5lei uleit4 ulei4u u3len 4uleo u4les u3let +4uleu u4ley 4ulf ulfa4t ul2ger ul2gi ulg3in ul4gy u1li 4uli4a ulian4i u3lic +ul4ie4 uli4m u4lina ul3ing ulin3i ul5ish u3l4it ul4iu 4ull ul4labi ul4larb ul4lard +ul4ler. ull4i ullian4 ul4lite ul4loc ullo4d ul4low uln4 u3l4o 4ulok ulo4mi ulon4 +uloni4 4ulp ulpha4 ul4pie ulpi4ta ul4py 4uls 4ult ult5ant ulti3l u1l4u 4ulu. +ulu4n ul3v ulw4 4uly4 u5ly. u4m. um3ab u5mac u4mag u2mam uma5ro uma4s +u3mat uma6top 4umba um4bie um2bin u1m2e 3umell u3men umen4s u4mer. u6mer6ic. u1mi +u2mic umid3 u3mig u2ming umi3ni umin4is u4mism. um4nis u3mo umoni4 umo4nit umon4o +umon4t u5mor5o um2p umpe4 um4phy ump3i ump3y u4ms4 umt4 u3mu1 umu4le u2my +un1 4una. un5abl un4alas una4mo un4ani un4ary unat4ic. una4tr u4naut unb4 un5bl +2unc uncer4a unde6mon und2i un5dit un4dro. un4dry. un3e une2a uneb4 unek4 u4nep +un4er un5ero uner4r u4nes une4so un4et unf4 unfi4bro unflo4 unful4l ung4a unga4m +un4ger. un4gia ung4l un5gu un4ia4 un4ic u3nic. u3n4if uniflo4 unig3 uni4j uni2m +u4nin un5ing u2n3io u3nip un4iq u4n3ish un4ism u1n4it uni4us u3ni1v u4nj un3l +un3m unmatu4 4unn4 unne4c unob3l unof3 unon4e uno4pr un3p4 u4n3r4 unrav3 unre4co +unremis6 unrep4u un3s4 unsca4 unscru6ta unsepa4 unta4l 3untar untar3y untel4l un3ten un4ter. +un4terj unti4t unti4u un3tr unu4c unuch3 un3v unvict4 unvo2 uny2 unzi4 u1o +4uo. uob4 uodio4 uolan4 u4om4 u2on4 uop4e uopi4 u2or u3o3re uor3idi uo2s +uo2t u3ou u4ov 2u1p u4p. u4pab upat4 u3p4e u4ped uper3 u4peralt upercivi6 +uperlu6 up4ha upha4n uphe3m uphi4 u4p3ing upi4p upl4 u3ple u4plet u4pp uppe4t +uppor4 upre4 up1s u4pt upt3ib u3p4u uqui4 4ur. 4u3ra. u3rac 4urad ura4da +4urae ur3agi 4urale u4ram. ura4ma u4r3anc ura5no ur4ant u3rar2 u3r4as u4rasi u1r4at +u4rb urbabili6 ur4ber. ur2bin urb3ing ur2c4 urd4a urdis4 ur4dish urd4w u1rea 3urea. +u5reo ur3er ures4 u1ret ure4tic ur4fer ur4f3i ur4fr u4rg urg3in u3ria uri4ac +uri4at. u1r4ic uric3u u3rid 4ur2ie u3rien u2ring u3rio uri4ol ur2it uri3ta uriti4u +ur4lies. urmoun4 urmu4r u4rn 4urni urni4e urn4is ur4nov 4uro u3r4o. uroce4 u3roi +u4rol. uro3p uro2t urovi4 urox4 urpo4 urpre4 urr4 4urren urre4t ur4rit ur2s +ursu5a urten4 ur4tho ur4ty u1ru ur4vie ury4d ury4lo u2s u4s. u5sad u3s4an +us4ap us4as us3c us4ch us4cr use3a use4o usep4 us3es u4set us4h1 4ush2a +4usi. us4ia usi3an u3sic us4ie us5ies usil4i4 u3sill u4sin us4it us1n us4o +u3sor 4us3p us4pa usper4 us4po us4ri 2uss2 ussel4 us4seli uss4i ust3ab us4th +ustha4 us4tia us4tivel us4tiven us4tly ustmen4 us3to2 ustom3a us4top us3tr us3tu us4ty +u3s4u usur4e usur2p usyn4 2ut 4u4t. ut2a uta4h u3tal uta4ma u3t4an u4tance +utan4i 3utar3y u3tatio utb4 u4tc4 utca4 utci4 u4te4co u3te3i u3tell ut4em ut4en +ute4or ute4r u2t3er. uter5in uter4me uter4op u2tes ut3g uth4ic uthor3 u4thr utiabili6 +u3tic u3til2 u4tily ut4im u3tina uti6nate. u3tini u3tino 4utio u4tish utj4 utk4 +u4t3l4 uto4mata u3ton utre4m ut4rop ut1s4 ut4sy ut3t uttel4 ut4tit utto2 u3tu +u4tup utura4 ut1w utyr5 u1v u4valu u4ved uvel4 uve3n u4verneu u3vi uvii4 +u4vis u3vu uw4e u2x 4ux. ux3i uxili4 uxle4 uxm4 uxt4 uxu1r uya4n +uyk4 uz2e uz4is uz4ze 2v. 1va 3va. 2v1ab v4aba v4abh 3v4ac va3ca +vac3il vac3u 2vag va4gen vager4 v4agg v4a4gi 5vagina vaha4 3vai vail3a v2al +val4a va1le val4er va3leri valer4y va5lesc vali2 val3ia va3lie val4ise 3vall val4len +valli4 val3or valor3i val3u 4valu. vam4ph vampi4 va4na van4ce van4ch 4vanco 2vane +van1g 4vang. vange4 van4i vani4e van5it va3niz va3no van4u va4pi vapor3i va3ra +vard4 vardi4e va3riet var6iola var4is varle4 vars4 4varts va4ru 2vasa v5asat 5vasc +va4se vas3ec vass4i 2vast v3as3ta vastat6i 4vatar 3vate 4vatina 3vatio vats4 4vatt. +v4ce 2vd 4v4e. 2vea 3veal ve4b ve2bl 2ved v4ed. 4veda v3edl veg1e3 +vehe3 ve2i vei4o 2vel v3eled v3eler ve2li v4elin. v3el3is veli4u v4ella. vel3ler +3velo vel4od vel3op 4veloy vel4t vel3v v4ely4 2vem ve5nan veng3i ven3ie veno4 +ve2n3o2m vens4e 3vent ven4tag ven6tat 4venth ven4tis ven4ua 4vep v4era. ver4an. 4verati +verc4 5vere. ver3ea ver3ei ver3em 2veren 4vereu ve2ri ver4ia 4verie veri3ta ver3m4 +verma4n vermi6no 4verneur 1vers v4ers. 3verte vert3er 3verti ver4tit ver3u 6verwear. v2es +4ves. ve4sac 3vesc ve2si ves3p ves3ti vet3e vet5ou 4veve vey3o 4vh. 3v4i. +v4ian vi4ano 4viar vi4ary vias4 vi4ata vi3b vi4be vi4bi vi4bri vi2car vi4ce +4viced 4vicen 3vich. 5vict victi2 vic2t3u 3v4icu vi2d 4v3iden 3vid3i 4vid4i4v v4ie. +vi4en vie4r vi4erw 3viet viet5i vi4eto vie4w vi3f vi4fe 4v3iga v4ign v4ig1o +vigor3 vi4gou vii4u 3vik vik4i v4ile. vil2i v2ili2a vil3if 3ville 4villed 2vin +v2ina 3vina. vi4nato 3vinci 3vine. vi4net vin4ic vini4f vi4nom v4i3ny4 vi1o vio3l +vio4lis v3i2or5 viot4 vi4pa vi3pe 3viru v2is vis5ag 4visat 3visc v3isec 3visi +vi4sis vis3i4t 3viso vis1u3 vi3ta v4ital vita3m v3itat vitato4 v4ite vitori4 vi2t1r +vi4tra vit4ro vit4ry vit4u vi2v viv3al 4vivis v4iza v4ized 4vizz 2vk 4vki +vli4 2vn 1vo vo4cato voet4 3vog 3voi2 voi4c void3 4voird vo4j 3vok +vo2la vol3at 3volt 4vol4to vol3un 3volv vom3i von4a 4vonar 4vonp vont4 v4oq +vo2r 3vor4c vo4ri 5vorot v3orou 4vos vo2t 4vot. 3vote 4votee 3voti 3vour +vour3i 3vout vo3vi 3vow vowe4 4vowi 3voy 4vp. vra4 v4reu vro4n 2vs +v3ski v3sky 2vt 4vt. 4vty 1vu 6vulgati 3vur 2vv v4va vv4i v3vy +vy4e v4zi 2w. 1wa 4w1ab w4abs wa2g wagi4 3wak wa4kan wak3en wal4k +wallad4 wal4ley wam4pl wa4ne 4wane. wa4ny waq4 war2 war4d warde4 war4er warmon6 +warri4 war4t was4 wash3 wasp3 was5s4 waster4 w4at 3wa3te wat4i wat4ta wat4th +wau4f wavel4 3way. wc4t wd4i wdo4 wdus4 we2 wear2 wea3r4ie wear4ine wea3ry +wea4t weath3 we4b w2ed weep3 4weet3 weev4 weil4 weir4 wel3i wel2l wel4ta +we4ly 4w1er w3eri wern4 w4err wer4s wes4e west1 western5 w4et 4wett. w4ey +wf2 wg4r wgra4 1wh2 4wh. wha4 whan4 whar4 whel2 whi4 who4 why4 +w2i2 w4i. w4ia w4i4c w4i4d wid3o w4iec wield3 wien4 wi3er wi3est wi4f +3wife wi4g wign4 will3in wil4lo. wilm4 4wim wimp4 w4in. win2d win4dr win2g +win3i w4inn w4ins wir4l 1w4ise wis4p5 w4i4t with3 4witt. w4kn w4l. wl3ed +wl4ey wl3ing wl4ings w1m wm4e w2n1 wn4de w4n3er w3nes wnfal4 wn3i wn4ia +wn4ingt wnpa4 1wo2 wok4 wol2 wolf4f wol3l 3wom3 wo4ma won4e won4g won4t +woo4 3wood woozi4er wor4c wor2d wor4sen 3wort wo3ven wows4 wp2 1wr2 wr4e +3wri2 w2s ws4ab ws4er w3s4k wsma4 w3so ws4pe ws4t 2wt wtu4 wuh4 +wum4 wur2 wur4l wut4 wuz4 4ww. 2wy2 wyc5l wydi4 wyom4 w2z x1 +2x. xac4 x4aca xac5er x3ae xa2g xal2 xamer6a xa4m3i xami4nab xa4na x4andro +xap4 xar4 x4ario xas3p xas4ti xato4 x4au x4c. x3ce xchan4 x3ci xci4t3a +xcom4b xcu4 xcub4 2xe2 x4e. x3ec xe3cr xec5utiv xec3uto x4ed x5edl x5edn +xe3g xel4 xe3mi xer2 xe4ra xer4gu xe3ro x3es xhi2 xhil3 xhila3 xhu2 +2xi x3ia xi3b xibi4 xica4l x3id2 xi3di xi4dic xi4din x3ie xi3f x3ig +xig4r x3il xili4a xiliar4 xi4mis x3in x5ine xi4no x3io xi4p x3is xist5ent. +xis4ti x3iv xiv3i x4ix x3le xle4i xm4e xn4t x3o. xochro4 xo4di xo2g +xogen4 xon4ar xop4 xo5r2a xospo4 xo2t x4p. xpan4d x3pe xpec2 xpect6ed. xpecto3 +xpe3d2 xpen4c xper2 xpres4sa x4pt xpun4 x4r. xs2 xt2 x4t. x3ti xt4r +xtra5ve xtre4me xtro3v xu1a xu3b xu4l2 xulian4 xulli4 x5ult xuper4 4xus xy1 +xyb4 xyl3at x4yle xy4s x3y3t y4'l y4'r y2a. y1ab y4abe ya4co yac4t +yaen4 y4ageu 4y4ai ya4j y1al y4ala ya5lin y2am 5yam4a y1an 3yang ya3nid +y4ank yan4ode 1y2ar y4ard 4yaro ya4s yaw4p y4az yb4a yba5r 4ybd y4bele +y4bia 4y4biu yb4o ybrid3 y1c y5cem 3ycep yc3er3 y4ceri4 yce4te ych2 ychasi4 +ychas4ti ych3n y5cho ychor4 y3ci y4cida y4cif y4cinal y4cliu yclos4 yc3n4 ycog4 +yco3p yco3s ycosta4 ycot4t 4ycra ycran4 y4crat yctag4 y1d y4d' y4dad y4dali +y4dam yd4di y2de y3den y3der 4y2d5ia y4dic ydi4o ydi4v yd4l 4y4domo 4y3dr +y4driat ydroc4 ydro3s ydro3t 4y4e. ye4a yeas4 yeat4 y4ed ye3le ye3lo yel4p +ye4m yen2 ye3n4i ye4o y1e4r y4era. yer4e y2erf y4erl yers4e yes3te yet2 +ye4u4 yeus4 3yev y4fed yfire4 4yga y2gap yge2 ygen3 2y2gi yg3i3a y3gie +4ygo ygro3s 4ygy y1h yhisto6 yh4v yh4w y1i y3i4a y4ie yk4e yk4j +ykn4 y2l y4l. y5lae yl3am y3lar4 y3lax 4yld y4le. y3ledo2 4ylem ylen4 +y4lest yl1ic 4ylid4 yl3ism y3lit 4yll yl3la yllar4 yl3li yllo4m yllo4n 4ylna +y3lo y4loch yl4on yl2p yl4ph ylphe4 ylphi4 4ylsu 4yl3t y3lum y3lus yl3v +y3ly 2ym 4ym. 4yma ymbo4 ymbol3 ym4c y3men 4ymer 4y2mi 5y5miz y3mo +ymo4b 4ymol. y4monds ympa3 ymphan3 ym4phe ymp4i ymple4 2yn y1na y4nae yna3mit +y4namy y4n4ar ynar4chi y4nast. yn3c ynde4 y2ne yn4ep yn4et yn1g yng4a 5yngec +6yngologi y2ni ynia4 yn2n yno4e ynos4 3ynou yn1t yntro4 3yny. 4ynya yo3de +yog4i y3oi 5yoke yoky4 yom4i yon2 y3one4 y1op yopi4t 2yp y2pa y4paes +yp4e yper3 yperpu4 y4pes ypeta4 y4peu 4yphag yphe2 yph3il y3pho 4yphon ypho4t +4yp3i yp4ico 3yplo ypo3a ypo3c y4pocr y3pogr ypo4lite ypo3n ypos5tas ypos4to y4poth. +y4pou y4pre. y4pri yp3s yp2ta ypt3an yp3y y3ra. y3rag y2re y5rene. y4rhi +y4ria yriam4 y4r1ic yr4if 5yrint yr4le y4rm yr4ma yrme3 y4rn y1ro y4rod +yro3st yrro4 y2s ysal2 ysca4 yscrin4 ys3e3al ys4el ys4es y3sh ys2i y4siac. +ys3i3al y4sias ys3ic y3sid ys3io 3y5sis y5sit 4ysk ysme4 ys4o ysom4 yss4 +ys4sal ysse4 ys3t ystem3 ystem4l ysthe4u ys4tl ys4toi ys4tone ysto4p ystri6 y3su +y4sur ys4us 3y3sy 2y2t y4t. ytan4 yte4l ytel4l 3y3ten yte4r y4teu y3them +y4th3i ytho4gr yth3op yth3ro yt1i y4til y4tio ytish4 y4tium y3to 4ytom y4tomo +y4tomy ytos4t y3tr ytt4 y3tu yub4 y4uc yu4go. yu4le y1v y1w yw4c +ywon4 2y2x 4yx3e yx3i yxi4a. yxi4d 4yxo 4yy4a 2yz y2za y4zan y4zygy +2z. 1za1 3za. 2z1ab za4ch 4zaf za4g 3zal za4la 4zald zale4 3zan +4zank za4nu za4q 2zar2 z4ar. za2rat zar4ia za4ry zas4 za4st 3zat 4zath +za4u 2zb 2ze. zeal3 ze4bra. ze4da zeg4 3zel. zel4le ze4n zen4e 4zener +zenti4 ze4p z1er zer4a zer4o z4ert zevi4 zg4e 4zgi zh4d z1i 3zi. +3zia. zi4at zib4 zi4be4 4zic. 3z4ie. zi4ery zi1es z4if zi4k 4zilin z4ilo +4z4im 2zin2 3zin. z4in4c zinc3i zind4 zin4e 3zine. z3ing. 4ziot zir4ca zi4r4i +4zirit 4zky z5ky. 1z2l2 4zla 3zle 4zlo 1zo zo1a 4zoam zocer4 zo1i +zoker4 zo4lam zolan4 4zolin 4zolo 3zo2n zonar4 zo4ne zon4if zo1o zoo4i zook4 +2zop 3zor zori4l zos4e 2zot zo3ti zo4tic z3ra zs4c z4so4 zuid4 zu4o +4zwi 3zyg zy2ga zym4 z1z2 z3za z4zed 4zzi z3zl z3zo zzu4 z4zw diff --git a/crates/text/examples/hyphgen.rs b/crates/text/examples/hyphgen.rs new file mode 100644 index 00000000..520cee91 --- /dev/null +++ b/crates/text/examples/hyphgen.rs @@ -0,0 +1,174 @@ +//! Regenerates the bundled English hyphenation data from the Moby Hyphenator II word list. +//! +//! ```text +//! curl -LO https://www.gutenberg.org/files/3204/files/mhyph.txt # Project Gutenberg #3204, public domain +//! cargo run --release -p craft-text --features generator --example hyphgen -- mhyph.txt crates/text/assets/hyphen [--validate] +//! ``` +//! +//! Writes `en-us.dic` (the word list, front-coded and deflated; see `craft_text::hyphen::dict`) +//! and `en-us.pat` (Liang patterns trained from the list by `craft_text::hyphen::patgen`). +//! `--validate` first trains on 90% of the words and reports accuracy on the held-out 10%. + +use std::collections::HashMap; +use std::path::PathBuf; + +use craft_text::hyphen::dict::{Dictionary, hyphenated}; +use craft_text::hyphen::patgen::{self, DEFAULT_LEVELS}; + +/// Mac OS Roman, 0x80..=0xFF (the list's encoding; 0xA5 `•` marks breaks). +const MAC_ROMAN_HIGH: &str = + "ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø¿¡¬√ƒ≈∆«»…\u{A0}ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄€‹›fifl‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔ\u{F8FF}ÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ"; + +fn mac_roman(bytes: &[u8]) -> String { + let high: Vec = MAC_ROMAN_HIGH.chars().collect(); + assert_eq!(high.len(), 128); + bytes.iter().map(|&b| if b < 0x80 { b as char } else { high[b as usize - 0x80] }).collect() +} + +const BREAK: char = '•'; + +fn is_word_char(c: char) -> bool { + c.is_alphabetic() || c == '\'' || c == BREAK +} + +/// `(lowercase word, mask)` of one token like `Aa•ron's`, or None if it isn't a plain word. +fn token(t: &str) -> Option<(String, u64)> { + let t = t.trim_matches('\''); + if t.is_empty() || !t.chars().all(is_word_char) || t.starts_with(BREAK) || t.ends_with(BREAK) { + return None; + } + let mut w = String::new(); + let mut m = 0u64; + let mut n = 0usize; + for c in t.chars() { + if c == BREAK { + if n < 64 { + m |= 1 << n; + } + } else { + w.extend(c.to_lowercase()); + n += 1; + } + } + ((1..64).contains(&n) && w.chars().count() == n).then_some((w, m)) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let validate = args.iter().any(|a| a == "--validate"); + let pos: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect(); + let Some(src) = pos.first() else { + eprintln!("usage: hyphgen [out-dir] [--validate]"); + std::process::exit(2); + }; + let out_dir = PathBuf::from(pos.get(1).map_or("crates/text/assets/hyphen", |s| s.as_str())); + let text = mac_roman(&std::fs::read(src).expect("read word list")); + + // key → (lowercase-origin masks, other masks); compound parts only fill gaps. + let mut primary: HashMap, Vec)> = HashMap::new(); + let mut parts: HashMap = HashMap::new(); + let mut entries = 0usize; + for line in text.lines() { + let line = line.trim_end_matches('\r').trim(); + if line.is_empty() { + continue; + } + entries += 1; + if let Some((w, m)) = token(line) { + let lower = !line.chars().any(char::is_uppercase); + let e = primary.entry(w).or_default(); + if lower { e.0.push(m) } else { e.1.push(m) } + } else { + for t in line.split([' ', '-', '/']) { + if let Some((w, m)) = token(t) { + parts.entry(w).or_insert(m); + } + } + } + } + let mut list: Vec<(String, u64)> = primary + .into_iter() + .map(|(w, (lower, other))| { + // Prefer lowercase entries; where spellings disagree (re•cord / rec•ord) keep only the + // breaks they share. + let v = if lower.is_empty() { other } else { lower }; + (w, v.iter().fold(u64::MAX, |a, b| a & b)) + }) + .collect(); + let have: std::collections::HashSet = list.iter().map(|x| x.0.clone()).collect(); + let extra: Vec<(String, u64)> = parts.into_iter().filter(|(w, _)| !have.contains(w)).collect(); + println!("{entries} entries → {} words (+{} from compounds)", list.len(), extra.len()); + list.extend(extra); + let dict = Dictionary::from_entries(list); + let bytes = dict.to_bytes(); + std::fs::create_dir_all(&out_dir).expect("mkdir"); + std::fs::write(out_dir.join("en-us.dic"), &bytes).expect("write en-us.dic"); + let back = Dictionary::from_bytes(&bytes).expect("roundtrip"); + assert_eq!(back.len(), dict.len()); + println!("en-us.dic: {} words, {} bytes", dict.len(), bytes.len()); + + // Training data: plain lowercase ASCII words (+ apostrophes). + let train: Vec<(Vec, Vec)> = dict + .iter() + .filter(|(w, _)| w.chars().all(|c| c.is_ascii_lowercase() || c == '\'')) + .map(|(w, m)| (w.chars().collect(), (1..w.len().min(64)).filter(|&i| m & (1 << i) != 0).collect())) + .collect(); + println!("training on {} words", train.len()); + let mut log = |s: String| println!(" {s}"); + // Optional override for tuning: HYPHGEN_LEVELS="1,4,1,2,20;2,5,2,1,8;…" (min,max,good,bad,threshold). + let custom: Option> = std::env::var("HYPHGEN_LEVELS").ok().map(|s| { + s.split(';') + .map(|l| { + let v: Vec = l.split(',').map(|x| x.trim().parse().expect("number")).collect(); + patgen::Level { min_len: v[0] as usize, max_len: v[1] as usize, good_weight: v[2], bad_weight: v[3], threshold: v[4] } + }) + .collect() + }); + let levels = custom.as_deref().unwrap_or(DEFAULT_LEVELS); + if validate { + let (fit, hold): (Vec<_>, Vec<_>) = train.iter().cloned().enumerate().partition(|(i, _)| i % 10 != 0); + let fit: Vec<_> = fit.into_iter().map(|x| x.1).collect(); + let hold: Vec<_> = hold.into_iter().map(|x| x.1).collect(); + let p = patgen::train(&fit, levels, &mut log); + let s = patgen::score(&p, &hold); + println!( + "held-out: {} words, {} breaks: found {} ({:.1}%), missed {}, wrong {} ({:.2}% of reported)", + s.words, + s.breaks, + s.found, + 100.0 * s.found as f64 / s.breaks.max(1) as f64, + s.missed(), + s.wrong, + 100.0 * s.wrong as f64 / (s.found + s.wrong).max(1) as f64 + ); + } + if std::env::var_os("HYPHGEN_VALIDATE_ONLY").is_some() { + return; + } + let pats = patgen::train(&train, levels, &mut log); + let s = patgen::score(&pats, &train); + println!( + "training set: found {}/{} ({:.1}%), wrong {} — {} patterns", + s.found, + s.breaks, + 100.0 * s.found as f64 / s.breaks.max(1) as f64, + s.wrong, + pats.len() + ); + let mut out = String::new(); + out.push_str("% Craft US English hyphenation patterns (Liang/TeX notation).\n"); + out.push_str("% Generated by crates/text/examples/hyphgen.rs: trained with craft-text's patgen-style trainer\n"); + out.push_str("% from the Moby Hyphenator II word list by Grady Ward (public domain, Project Gutenberg #3204).\n"); + out.push_str("% Released into the public domain (CC0-1.0) by the DesignCraft and craft-libs contributors.\n"); + for line in pats.to_tex().chunks(12) { + out.push_str(&line.join(" ")); + out.push('\n'); + } + std::fs::write(out_dir.join("en-us.pat"), &out).expect("write en-us.pat"); + println!("en-us.pat: {} patterns, {} bytes", pats.len(), out.len()); + for w in ["hyphenation", "typography", "composition", "photographer", "unquestionably", "localization", "blockchain", "smartphone"] { + let c: Vec = w.chars().collect(); + let m = pats.points(&c).unwrap_or_default().iter().fold(0u64, |m, &i| m | 1 << i); + println!(" patterns: {}", hyphenated(w, m)); + } +} diff --git a/crates/text/src/bidi/mod.rs b/crates/text/src/bidi/mod.rs new file mode 100644 index 00000000..39fef7d7 --- /dev/null +++ b/crates/text/src/bidi/mod.rs @@ -0,0 +1,367 @@ +//! Bidirectional text (UAX #9) for document layout, on plain text and independent of fonts. +//! +//! The pipeline is the one the algorithm prescribes: [`Paragraph::new`] resolves embedding levels +//! once for the whole logical paragraph (or for each paragraph, in text with paragraph +//! separators); shaping and line breaking happen in logical order; then each line is reordered on +//! its own. [`Paragraph::line_levels`] applies rule L1 and +//! [`Paragraph::visual_runs`] rule L2 to a byte range of the paragraph. For lines built from other +//! units (clusters, glyphs, cells), [`line_levels`] and [`visual_order`] apply the same rules to +//! slices of levels. [`mirror`] gives the character for rule L4; drawing it, or a font's mirrored +//! glyph, is left to the shaper. +//! +//! Levels are `u8` embedding levels: even runs left to right, odd runs right to left. + +use std::ops::Range; + +use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS}; +use unicode_bidi::{BidiInfo, Level, bidi_class}; + +/// A paragraph's base direction (rules P2–P3, or the higher-level protocol HL1 when set). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Direction { + /// Left to right: paragraph level 0. + Ltr, + /// Right to left: paragraph level 1. + Rtl, + /// From the first strong character, left to right when there is none. + Auto, +} + +/// A directional override (LRO or RLO … PDF) over a byte range of a paragraph: every character in +/// it is treated as strongly left to right or right to left. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Override { + pub range: Range, + pub rtl: bool, +} + +/// A maximal run of one embedding level in a line, as [`Paragraph::visual_runs`] returns it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub struct Run { + /// Byte range in the paragraph text, in logical order. + pub range: Range, + pub level: u8, +} + +impl Run { + /// Is the run displayed right to left (its characters from the end of `range` to its start)? + pub fn is_rtl(&self) -> bool { + self.level % 2 == 1 + } +} + +/// Resolved embedding levels (rules up to I2) for a paragraph's text, one per byte. +/// +/// A paragraph separator (`\n`, U+2029 and the other class B characters) inside the text ends a +/// paragraph and belongs to it; the next one starts after it with its own paragraph level, which +/// `Direction::Auto` resolves from that paragraph's own first strong character. Rule L1 uses the +/// level of the paragraph each byte is in. +#[derive(Clone, Debug)] +pub struct Paragraph<'t> { + text: &'t str, + levels: Vec, + /// Each paragraph's byte range and level, in order and covering the text; never empty. + paragraphs: Vec<(Range, u8)>, +} + +impl<'t> Paragraph<'t> { + /// Resolve the levels of `text`. Text with nothing right to left in a left-to-right or `Auto` + /// paragraph skips the algorithm. + pub fn new(text: &'t str, base: Direction) -> Self { + if base != Direction::Rtl && !needs_bidi(text) { + let paragraphs = split_paragraphs(text).into_iter().map(|r| (r, 0)).collect(); + return Self { text, levels: vec![0; text.len()], paragraphs }; + } + let (levels, paragraphs) = resolve(text, base); + Self { text, levels, paragraphs } + } + + /// Like [`new`](Self::new), with directional overrides. Overrides that are empty, out of + /// range, off char boundaries or overlapping an earlier one are ignored. An override that + /// spans a paragraph separator continues in the next paragraph; the separator keeps its + /// paragraph's level. + pub fn with_overrides(text: &'t str, base: Direction, overrides: &[Override]) -> Self { + let mut sorted: Vec<&Override> = overrides.iter().filter(|o| o.range.start < o.range.end && text.get(o.range.clone()).is_some()).collect(); + sorted.sort_by_key(|o| o.range.start); + // The text with LRO/RLO … PDF around each override, and where each source char went. + let mut wrapped = String::with_capacity(text.len() + sorted.len() * 6); + let mut at: Vec = Vec::with_capacity(text.len()); + let mut copy = |wrapped: &mut String, piece: Range| { + if let Some(s) = text.get(piece) { + for (_, c) in s.char_indices() { + at.extend(std::iter::repeat_n(wrapped.len(), c.len_utf8())); + wrapped.push(c); + } + } + }; + let mut pos = 0; + for o in sorted { + if o.range.start < pos { + continue; + } + copy(&mut wrapped, pos..o.range.start); + // A paragraph separator ends every embedding (rule X8), so the override is closed + // before each separator and opened again after it. + let mut start = o.range.start; + while start < o.range.end { + let rest = text.get(start..o.range.end).unwrap_or_default(); + let (end, next) = rest + .char_indices() + .find(|&(_, c)| bidi_class(c) == B) + .map_or((o.range.end, o.range.end), |(i, c)| (start + i, start + i + c.len_utf8())); + if start < end { + wrapped.push(if o.rtl { '\u{202E}' } else { '\u{202D}' }); + copy(&mut wrapped, start..end); + wrapped.push('\u{202C}'); + } + copy(&mut wrapped, end..next); + start = next; + } + pos = o.range.end; + } + if pos == 0 { + return Self::new(text, base); + } + copy(&mut wrapped, pos..text.len()); + let (wrapped_levels, wrapped_paragraphs) = resolve(&wrapped, base); + // The inserted controls are not separators, so the paragraphs are the same ones; their + // ranges map back through `at`, which is ascending. + let back = |w: usize| at.partition_point(|&a| a < w); + let paragraphs: Vec<(Range, u8)> = wrapped_paragraphs.iter().map(|(r, l)| (back(r.start)..back(r.end), *l)).collect(); + let levels = at.iter().enumerate().map(|(b, &w)| wrapped_levels.get(w).copied().unwrap_or_else(|| level_in(¶graphs, b))).collect(); + Self { text, levels, paragraphs } + } + + pub fn text(&self) -> &'t str { + self.text + } + + /// The first paragraph's embedding level: 0 or 1. Text with paragraph separators has one + /// level per paragraph: see [`paragraphs`](Self::paragraphs). + pub fn level(&self) -> u8 { + self.paragraphs.first().map_or(0, |p| p.1) + } + + /// Is the first paragraph right to left? + pub fn is_rtl(&self) -> bool { + self.level() % 2 == 1 + } + + /// Each paragraph's byte range (its separator included) and embedding level, in order. There + /// is always at least one; empty text has one empty paragraph. + pub fn paragraphs(&self) -> &[(Range, u8)] { + &self.paragraphs + } + + /// The embedding level of the paragraph containing byte `byte` (the last one's past the end). + pub fn paragraph_level_at(&self, byte: usize) -> u8 { + level_in(&self.paragraphs, byte) + } + + /// Does any line need reordering (is any level above 0)? + pub fn needs_reordering(&self) -> bool { + self.levels.iter().any(|l| *l > 0) + } + + /// The level of each byte of the text. + pub fn levels(&self) -> &[u8] { + &self.levels + } + + pub fn into_levels(self) -> Vec { + self.levels + } + + /// The level of the character at byte `byte` (the last paragraph's level past the end). + pub fn level_at(&self, byte: usize) -> u8 { + self.levels.get(byte).copied().unwrap_or_else(|| self.paragraph_level_at(byte)) + } + + /// The level of each character, in order. + pub fn char_levels(&self) -> Vec { + self.text.char_indices().map(|(b, _)| self.level_at(b)).collect() + } + + /// Rule L1 for the line `line` (a byte range of the text): the level of each of its bytes, + /// with segment and paragraph separators, whitespace before them and whitespace at the end of + /// the line at their paragraph's level; zero-width joiners and tag characters keep the level of + /// the char before them. Empty when `line` is not a char-boundary range of the text. A line + /// should lie within one paragraph; one that crosses a separator takes each byte's own + /// paragraph level here and is reordered as a whole by [`visual_runs`](Self::visual_runs). + pub fn line_levels(&self, line: Range) -> Vec { + let Some(s) = self.text.get(line.clone()) else { return Vec::new() }; + let mut out: Vec = line.clone().map(|b| self.level_at(b)).collect(); + let mut trailing = true; + for (b, c) in s.char_indices().rev() { + if l1_reset(bidi_class(c), &mut trailing) + && let Some(bytes) = out.get_mut(b..b + c.len_utf8()) + { + bytes.fill(self.paragraph_level_at(line.start + b)); + } + } + let mut prev = None; + for (b, c) in s.char_indices() { + if joins_previous(c) + && let Some(l) = prev + && let Some(bytes) = out.get_mut(b..b + c.len_utf8()) + { + bytes.fill(l); + } + prev = out.get(b).copied(); + } + out + } + + /// Rules L1 and L2 for the line `line`: its runs of one level, left to right as displayed. + /// Empty when `line` is not a char-boundary range of the text. + pub fn visual_runs(&self, line: Range) -> Vec { + let levels = self.line_levels(line.clone()); + let Some(s) = self.text.get(line.clone()) else { return Vec::new() }; + let mut runs: Vec = Vec::new(); + for (b, c) in s.char_indices() { + let level = levels.get(b).copied().unwrap_or_else(|| self.paragraph_level_at(line.start + b)); + let end = line.start + b + c.len_utf8(); + match runs.last_mut() { + Some(run) if run.level == level => run.range.end = end, + _ => runs.push(Run { range: line.start + b..end, level }), + } + } + let run_levels: Vec = runs.iter().map(|r| r.level).collect(); + visual_order(&run_levels).into_iter().filter_map(|i| runs.get(i).cloned()).collect() + } +} + +/// Rule L1 over a line's characters `chars` and their resolved `levels`: segment and paragraph +/// separators, any whitespace before them and whitespace at the end of the line take `para_level`; +/// zero-width joiners and tag characters keep the level of the char before them. Missing levels are +/// taken as `para_level`. +pub fn line_levels(chars: &[char], levels: &[u8], para_level: u8) -> Vec { + let mut out = levels.to_vec(); + out.resize(chars.len(), para_level); + let mut trailing = true; + for (i, c) in chars.iter().enumerate().rev() { + if l1_reset(bidi_class(*c), &mut trailing) + && let Some(l) = out.get_mut(i) + { + *l = para_level; + } + } + for i in 1..out.len() { + if chars.get(i).is_some_and(|&c| joins_previous(c)) + && let Some(&l) = out.get(i - 1) + && let Some(cur) = out.get_mut(i) + { + *cur = l; + } + } + out +} + +/// Zero-width joiners and tag characters extend the grapheme cluster of the char before them +/// (UAX #29), so they take its level. Rule L1 counts them as whitespace and would otherwise give a +/// trailing one the paragraph level, splitting its cluster across two runs. +fn joins_previous(c: char) -> bool { + matches!(c, '\u{200C}' | '\u{200D}' | '\u{E0020}'..='\u{E007F}') +} + +/// Rule L2: the display order, left to right, of items with these levels (indices into `levels`). +/// From the highest level down to the lowest odd one, every maximal run at that level or higher is +/// reversed. +pub fn visual_order(levels: &[u8]) -> Vec { + let mut order: Vec = (0..levels.len()).collect(); + let Some(min_odd) = levels.iter().copied().filter(|l| l % 2 == 1).min() else { return order }; + let max = levels.iter().copied().max().unwrap_or(0); + let level_at = |order: &[usize], i: usize| order.get(i).and_then(|&o| levels.get(o)).copied().unwrap_or(0); + for lvl in (min_odd..=max).rev() { + let mut i = 0; + while i < order.len() { + if level_at(&order, i) < lvl { + i += 1; + continue; + } + let mut j = i; + while j < order.len() && level_at(&order, j) >= lvl { + j += 1; + } + if let Some(run) = order.get_mut(i..j) { + run.reverse(); + } + i = j; + } + } + order +} + +/// The mirrored counterpart of `c` (Bidi_Mirroring_Glyph), for a character at an odd level (rule +/// L4): `(` for `)`, `»` for `«`. `None` when it has none; some Bidi_Mirrored characters (`∑`) +/// need a mirrored glyph from the font instead. +pub fn mirror(c: char) -> Option { + unicode_bidi_mirroring::get_mirrored(c) +} + +/// Does anything in the text need the full algorithm (strong right-to-left characters, Arabic +/// numbers or explicit embeddings)? +fn needs_bidi(text: &str) -> bool { + text.chars().any(|c| matches!(bidi_class(c), R | AL | AN | LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) +} + +/// Levels per byte of `text`, and each paragraph's byte range and level. +fn resolve(text: &str, base: Direction) -> (Vec, Vec<(Range, u8)>) { + let default = match base { + Direction::Ltr => Some(Level::ltr()), + Direction::Rtl => Some(Level::rtl()), + Direction::Auto => None, + }; + let info = BidiInfo::new(text, default); + let mut paragraphs: Vec<(Range, u8)> = info.paragraphs.iter().map(|p| (p.range.clone(), p.level.number())).collect(); + if paragraphs.is_empty() { + paragraphs.push((0..text.len(), u8::from(base == Direction::Rtl))); + } + let levels = (0..text.len()).map(|b| info.levels.get(b).map_or_else(|| level_in(¶graphs, b), |l| l.number())).collect(); + (levels, paragraphs) +} + +/// The byte ranges of the paragraphs in `text` as rule P1 (and unicode-bidi) splits them: each +/// ends after a paragraph separator, and there is always at least one. +fn split_paragraphs(text: &str) -> Vec> { + let mut out = Vec::new(); + let mut start = 0; + for (b, c) in text.char_indices() { + if bidi_class(c) == B { + out.push(start..b + c.len_utf8()); + start = b + c.len_utf8(); + } + } + if start < text.len() || out.is_empty() { + out.push(start..text.len()); + } + out +} + +/// The level of the paragraph containing byte `byte`; the last paragraph's past the end. +fn level_in(paragraphs: &[(Range, u8)], byte: usize) -> u8 { + let i = paragraphs.partition_point(|(r, _)| r.end <= byte); + paragraphs.get(i).or(paragraphs.last()).map_or(0, |p| p.1) +} + +/// Rule L1 walking a line backwards: whether the character of class `class` takes the paragraph +/// level. `trailing` is true while everything after it up to the line end or a separator is +/// whitespace. +fn l1_reset(class: BidiClass, trailing: &mut bool) -> bool { + match class { + S | B => { + *trailing = true; + true + } + WS | FSI | LRI | RLI | PDI | BN | LRE | RLE | LRO | RLO | PDF => *trailing, + _ => { + *trailing = false; + false + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/text/src/bidi/tests.rs b/crates/text/src/bidi/tests.rs new file mode 100644 index 00000000..58a450fb --- /dev/null +++ b/crates/text/src/bidi/tests.rs @@ -0,0 +1,254 @@ +use super::*; + +fn rev(s: &str) -> String { + s.chars().rev().collect() +} + +/// Byte offset of char `n` of `s`. +fn at(s: &str, n: usize) -> usize { + s.char_indices().nth(n).map_or(s.len(), |(i, _)| i) +} + +/// The line as displayed, left to right, built from its visual runs. +fn shown(p: &Paragraph<'_>, line: Range) -> String { + p.visual_runs(line).iter().map(|r| if r.is_rtl() { rev(&p.text()[r.range.clone()]) } else { p.text()[r.range.clone()].to_string() }).collect() +} + +fn shown_all(text: &str, base: Direction) -> String { + shown(&Paragraph::new(text, base), 0..text.len()) +} + +#[test] +fn mixed_arabic_latin_numbers_and_brackets() { + let s = "سنة 2024 (Retrait) فقط"; + let p = Paragraph::new(s, Direction::Rtl); + let l = p.char_levels(); + let chars: Vec = s.chars().collect(); + assert_eq!(l.len(), chars.len()); + // Arabic letters at the paragraph level 1; European digits and Latin at 2. + assert_eq!(l[0], 1); + assert_eq!(&l[4..8], &[2, 2, 2, 2]); + assert_eq!(&l[10..17], &[2; 7]); + assert_eq!(l[19], 1); + // Brackets around Latin inside a right-to-left paragraph take the embedding direction (N0). + assert_eq!(l[9] % 2, 1, "( is right to left"); + assert_eq!(l[17] % 2, 1, ") is right to left"); + // The Arabic words reversed, Latin and digits kept, the brackets swapped (the shaper mirrors + // them back, rule L4). Char-level reordering and byte runs agree. + let order = visual_order(&line_levels(&chars, &l, p.level())); + let vis: String = order.iter().map(|&i| chars[i]).collect(); + assert_eq!(vis, "طقف )Retrait( 2024 ةنس"); + assert_eq!(shown(&p, 0..s.len()), vis); +} + +#[test] +fn levels_for_persian_with_latin_and_numbers() { + let p = Paragraph::new("plain English", Direction::Ltr); + assert!(!p.needs_reordering(), "pure left-to-right paragraphs skip the algorithm"); + assert_eq!(p.levels(), &[0; 13]); + let s = "سلام Word ۱۴۰۳"; + let p = Paragraph::new(s, Direction::Rtl); + assert_eq!(p.levels().len(), s.len()); + assert!(p.needs_reordering()); + assert_eq!(p.level_at(0), 1, "Persian letters are right to left"); + assert_eq!(p.level_at(at(s, 5)), 2, "Latin inside a right-to-left paragraph is embedded at level 2"); + assert_eq!(p.level_at(at(s, 10)), 2, "Persian digits are numbers: left to right inside right-to-left text"); + assert_eq!(p.level_at(s.len()), 1, "past the end: the paragraph level"); + // In a left-to-right paragraph Persian is embedded at level 1. + let s = "Hello سلام"; + let p = Paragraph::new(s, Direction::Ltr); + assert_eq!((p.level_at(0), p.level_at(at(s, 6))), (0, 1)); + // A right-to-left paragraph of Latin text keeps it left to right inside it. + assert_eq!(Paragraph::new("ab", Direction::Rtl).char_levels(), vec![2, 2]); +} + +#[test] +fn auto_direction_follows_the_first_strong_character() { + assert!(Paragraph::new("سلام Word", Direction::Auto).is_rtl()); + assert!(!Paragraph::new("Word سلام", Direction::Auto).is_rtl()); + assert!(Paragraph::new("123 سلام", Direction::Auto).is_rtl(), "digits are weak"); + assert_eq!(Paragraph::new("123", Direction::Auto).level(), 0); + assert_eq!(Paragraph::new("", Direction::Auto).level(), 0); + assert_eq!(Paragraph::new("", Direction::Rtl).level(), 1); + assert_eq!(Paragraph::new("Word", Direction::Rtl).level(), 1, "an explicit direction wins"); +} + +#[test] +fn whole_lines_read_in_display_order() { + let s = "سلام دنیا"; + assert_eq!(shown_all(s, Direction::Rtl), rev(s)); + assert_eq!(shown_all("Hello سلام world", Direction::Ltr), "Hello مالس world"); + // "Price 250 toman" in Persian, with a Latin product name, Persian punctuation and digits: + // numbers and Latin keep their order, the words around them read right to left, and the final + // full stop is at the paragraph's end (the far left). + let s = "قیمت 250 تومان برای Word، امسال ۱۴۰۳."; + let want = format!(".۱۴۰۳ {} ،Word {} {} 250 {}", rev("امسال"), rev("برای"), rev("تومان"), rev("قیمت")); + assert_eq!(shown_all(s, Direction::Rtl), want); +} + +#[test] +fn each_line_is_reordered_on_its_own() { + // A right-to-left paragraph of Latin words broken after "ab ": the trailing space takes the + // paragraph level (rule L1), so it sits at the line's end edge, on the left. + let p = Paragraph::new("ab cd", Direction::Rtl); + assert_eq!(p.visual_runs(0..3), vec![Run { range: 2..3, level: 1 }, Run { range: 0..2, level: 2 }]); + assert_eq!(p.visual_runs(3..5), vec![Run { range: 3..5, level: 2 }]); + // Line ranges that aren't char-boundary ranges of the text give nothing. + let p = Paragraph::new("سلام", Direction::Rtl); + assert!(p.visual_runs(1..2).is_empty()); + assert!(p.line_levels(0..99).is_empty()); + #[allow(clippy::reversed_empty_ranges)] + let backwards = 4..2; + assert!(p.visual_runs(backwards).is_empty()); + assert!(p.visual_runs(2..2).is_empty()); +} + +#[test] +fn l1_resets_trailing_whitespace_and_tabs() { + let chars: Vec = "ab\tcd ".chars().collect(); + assert_eq!(line_levels(&chars, &[2; 7], 1), vec![2, 2, 1, 2, 2, 1, 1]); + assert_eq!(Paragraph::new("ab\tcd ", Direction::Rtl).line_levels(0..7), vec![2, 2, 1, 2, 2, 1, 1]); + // Short level slices never panic. + assert_eq!(line_levels(&['a', 'b'], &[], 1), vec![1, 1]); +} + +#[test] +fn visual_order_follows_rule_l2() { + // The example from UAX #9 / unicode-bidi. + assert_eq!(visual_order(&[0, 0, 0, 1, 1, 1, 2, 2]), vec![0, 1, 2, 6, 7, 5, 4, 3]); + assert!(visual_order(&[]).is_empty()); + assert_eq!(visual_order(&[0, 0]), vec![0, 1]); + assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]); + // Numbers (level 2) inside right-to-left text keep their order. + assert_eq!(visual_order(&[1, 2, 2, 1]), vec![3, 1, 2, 0]); + assert_eq!(visual_order(&[125, 125]), vec![1, 0]); + assert_eq!(visual_order(&[255, 0, 255]), vec![0, 1, 2]); +} + +#[test] +fn overrides_force_a_direction() { + let p = Paragraph::with_overrides("abc def", Direction::Ltr, &[Override { range: 0..3, rtl: true }]); + assert_eq!(p.levels().len(), 7); + assert_eq!(shown(&p, 0..7), "cba def"); + let s = "سلام دنیا"; + let p = Paragraph::with_overrides(s, Direction::Rtl, &[Override { range: 0..at(s, 4), rtl: false }]); + assert_eq!(shown(&p, 0..s.len()), format!("{} سلام", rev("دنیا"))); + // Overrides off char boundaries, empty, out of range or overlapping an earlier one are ignored. + let bad = [Override { range: 1..2, rtl: false }, Override { range: 3..3, rtl: false }, Override { range: 0..99, rtl: false }]; + assert_eq!(Paragraph::with_overrides(s, Direction::Rtl, &bad).levels(), Paragraph::new(s, Direction::Rtl).levels()); + let overlapping = [Override { range: 2..5, rtl: true }, Override { range: 0..3, rtl: true }]; + assert_eq!(shown(&Paragraph::with_overrides("abcdef", Direction::Ltr, &overlapping), 0..6), "cbadef"); +} + +#[test] +fn each_paragraph_has_its_own_level() { + // The second paragraph resolves right to left on its own, so rule L1 puts its trailing space at + // level 1: at the line's end edge, on the left. + let s = "a\nאב "; + let p = Paragraph::new(s, Direction::Auto); + assert_eq!(p.paragraphs(), &[(0..2, 0), (2..7, 1)]); + assert_eq!((p.level(), p.is_rtl()), (0, false), "level() is the first paragraph's"); + assert_eq!((p.paragraph_level_at(1), p.paragraph_level_at(2), p.level_at(s.len())), (0, 1, 1)); + assert_eq!(p.line_levels(2..7), vec![1; 5]); + assert_eq!(p.visual_runs(2..7), vec![Run { range: 2..7, level: 1 }]); + assert_eq!(p.line_levels(0..2), vec![0, 0]); + // Left-to-right text skips the algorithm but still splits paragraphs. + assert_eq!(Paragraph::new("ab\u{2029}c", Direction::Ltr).paragraphs(), &[(0..5, 0), (5..6, 0)]); + assert_eq!(Paragraph::new("", Direction::Auto).paragraphs(), &[(0..0, 0)]); +} + +#[test] +fn overrides_continue_across_paragraph_separators() { + let s = "ab\ncd"; + let p = Paragraph::with_overrides(s, Direction::Ltr, &[Override { range: 0..5, rtl: true }]); + assert_eq!(p.paragraphs(), &[(0..3, 0), (3..5, 0)]); + assert_eq!(p.line_levels(0..3), vec![1, 1, 0], "the separator keeps the paragraph level"); + assert_eq!(shown(&p, 0..3), "ba\n"); + assert_eq!(shown(&p, 3..5), "dc"); + // Each paragraph still resolves its own direction. + let s = "a\nאב "; + let p = Paragraph::with_overrides(s, Direction::Auto, &[Override { range: 0..1, rtl: false }]); + assert_eq!(p.paragraphs(), &[(0..2, 0), (2..7, 1)]); + assert_eq!(p.visual_runs(2..7), vec![Run { range: 2..7, level: 1 }]); +} + +#[test] +fn mirrored_characters() { + assert_eq!(mirror('('), Some(')')); + assert_eq!(mirror(']'), Some('[')); + assert_eq!(mirror('«'), Some('»')); + assert_eq!(mirror('a'), None); + assert_eq!(mirror('∑'), None, "mirrored, but only by glyph"); +} + +#[test] +fn hostile_text_never_panics() { + // Explicit embeddings, isolates, unmatched pops, marks with no base, deep nesting. + let rlo = "\u{202E}".repeat(200); + let junk = [ + "\u{202B}\u{202B}\u{202C}\u{202C}\u{202C}abc\u{2067}سلام\u{2069}\u{2069}", + "\u{064E}\u{064E}\u{200C}\u{200D}", + "\u{200F}\u{200E}\t\u{200F}\n\u{200E}", + "(((((((((سلام]]]]]]]", + "a\u{2029}سلام\u{2029}b", + &rlo, + ]; + for s in junk { + for base in [Direction::Ltr, Direction::Rtl, Direction::Auto] { + let overrides = [Override { range: 0..s.len() / 2, rtl: true }, Override { range: s.len() / 2..s.len(), rtl: false }]; + for p in [Paragraph::new(s, base), Paragraph::with_overrides(s, base, &overrides)] { + assert_eq!(p.levels().len(), s.len()); + let paras = p.paragraphs(); + assert_eq!((paras.first().map(|p| p.0.start), paras.last().map(|p| p.0.end)), (Some(0), Some(s.len())), "{s:?}"); + assert!(paras.windows(2).all(|w| w[0].0.end == w[1].0.start), "{s:?}"); + let bounds: Vec = (0..=s.len()).filter(|&b| s.is_char_boundary(b)).collect(); + for &b in &bounds { + for line in [0..b, b..s.len()] { + let runs = p.visual_runs(line.clone()); + assert_eq!(runs.iter().map(|r| r.range.len()).sum::(), line.len(), "{s:?} {line:?}"); + assert!(runs.iter().all(|r| !r.range.is_empty() && line.start <= r.range.start && r.range.end <= line.end)); + } + } + } + } + } +} + +#[test] +fn runs_never_split_a_grapheme_cluster() { + use unicode_segmentation::UnicodeSegmentation; + let cases = [ + // Arabic with harakat, among Latin and digits. + ("كَتَبَ abc مُحَمَّدٌ 123 بِسْمِ", Direction::Rtl), + ("see كَتَبَ, مُحَمَّدٌ here", Direction::Ltr), + // Hebrew with niqqud (and a dagesh-shin-dot stack). + ("שָׁלוֹם hello עוֹלָם", Direction::Rtl), + ("hello שָׁלוֹם world", Direction::Ltr), + // A Devanagari conjunct (virama, and an explicit half form with ZWJ) in right-to-left text. + ("سلام क्षत्रिय क्\u{200D}ष سلام", Direction::Rtl), + ("سلام क्\u{200D} ", Direction::Rtl), + // A combining mark after a Latin letter in a right-to-left paragraph, and after a Hebrew + // letter in a left-to-right one, each at a run edge. + ("שלום e\u{0301}te\u{0301} עולם", Direction::Rtl), + ("שלום cafe\u{0301}", Direction::Rtl), + ("café ש\u{05B8}\u{05BC} next", Direction::Ltr), + ("abc ש\u{05B8}", Direction::Ltr), + ("a\u{0301}\u{0302}ש", Direction::Auto), + ]; + for (s, base) in cases { + let p = Paragraph::new(s, base); + let bounds: Vec = s.grapheme_indices(true).map(|(i, _)| i).chain([s.len()]).collect(); + for &a in &bounds { + for &b in bounds.iter().filter(|&&b| b > a) { + for r in p.visual_runs(a..b) { + assert!(bounds.contains(&r.range.start) && bounds.contains(&r.range.end), "{s:?} line {a}..{b}: run {r:?}"); + } + } + } + } + // The char-level L1 keeps a trailing joiner with its conjunct too. + let s = "سلام क्\u{200D} "; + let chars: Vec = s.chars().collect(); + let l = line_levels(&chars, &Paragraph::new(s, Direction::Rtl).char_levels(), 1); + assert_eq!((l[6], l[7], l[8]), (2, 2, 1)); +} diff --git a/crates/text/src/hyphen/dict.rs b/crates/text/src/hyphen/dict.rs new file mode 100644 index 00000000..28b71a77 --- /dev/null +++ b/crates/text/src/hyphen/dict.rs @@ -0,0 +1,159 @@ +//! Hyphenation exception dictionary: lowercase words with their break points. +//! +//! On-disk format (`assets/en-us.dic`): raw-deflate of +//! `"DCHYPH1\n"` followed by one line per word, sorted by the plain word's bytes. Each line is +//! front-coded against the previous one: a byte with the shared prefix length (in bytes of the +//! hyphenated form), then the rest of the hyphenated form (`-` at every break), then `\n`. + +/// Sorted word list with a break bitmask per word (bit `i` = hyphen before char `i`). +#[derive(Clone, Debug, Default)] +pub struct Dictionary { + words: String, + offs: Vec, + masks: Vec, +} + +const MAGIC: &[u8] = b"DCHYPH1\n"; + +impl Dictionary { + pub fn len(&self) -> usize { + self.masks.len() + } + + pub fn is_empty(&self) -> bool { + self.masks.is_empty() + } + + fn word(&self, i: usize) -> &str { + &self.words[self.offs[i] as usize..self.offs[i + 1] as usize] + } + + /// Break mask of an exact (already lowercased) word. + pub fn get(&self, word: &str) -> Option { + let (mut lo, mut hi) = (0usize, self.len()); + while lo < hi { + let mid = (lo + hi) / 2; + match self.word(mid).as_bytes().cmp(word.as_bytes()) { + std::cmp::Ordering::Less => lo = mid + 1, + std::cmp::Ordering::Greater => hi = mid, + std::cmp::Ordering::Equal => return Some(self.masks[mid]), + } + } + None + } + + /// Iterate `(word, mask)`. + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.len()).map(|i| (self.word(i), self.masks[i])) + } + + /// Deserialize data written by `Dictionary::to_bytes`. + pub(crate) fn decode(data: &[u8]) -> Result { + let raw = miniz_oxide::inflate::decompress_to_vec(data).map_err(|e| format!("hyphenation dictionary: inflate failed: {e:?}"))?; + let body = raw.strip_prefix(MAGIC).ok_or("hyphenation dictionary: bad header")?; + let mut d = Dictionary { words: String::with_capacity(body.len()), offs: vec![0], masks: Vec::new() }; + let mut cur: Vec = Vec::new(); + let mut i = 0; + while i < body.len() { + let p = body[i] as usize; + let end = body[i + 1..].iter().position(|&b| b == b'\n').map(|e| i + 1 + e).ok_or("hyphenation dictionary: truncated")?; + if p > cur.len() { + return Err("hyphenation dictionary: bad prefix".into()); + } + cur.truncate(p); + cur.extend_from_slice(&body[i + 1..end]); + i = end + 1; + let s = std::str::from_utf8(&cur).map_err(|_| "hyphenation dictionary: bad utf-8")?; + let (w, m) = parse_hyphenated(s); + d.words.push_str(&w); + d.offs.push(d.words.len() as u32); + d.masks.push(m); + } + Ok(d) + } +} + +#[cfg(any(test, feature = "generator"))] +impl Dictionary { + /// Build from `(lowercase word, break mask)` pairs (sorted and de-duplicated here; the first + /// entry of a duplicate wins). + pub fn from_entries(mut entries: Vec<(String, u64)>) -> Dictionary { + entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + entries.dedup_by(|b, a| a.0 == b.0); + let mut d = Dictionary { words: String::new(), offs: vec![0], masks: Vec::with_capacity(entries.len()) }; + for (w, m) in entries { + d.words.push_str(&w); + d.offs.push(d.words.len() as u32); + d.masks.push(m); + } + d + } + + /// Serialize (deflate-compressed). + pub fn to_bytes(&self) -> Vec { + let mut raw = MAGIC.to_vec(); + let mut prev: Vec = Vec::new(); + for (w, m) in self.iter() { + let h = hyphenated(w, m).into_bytes(); + let p = prev.iter().zip(&h).take_while(|(a, b)| a == b).count().min(255); + raw.push(p as u8); + raw.extend_from_slice(&h[p..]); + raw.push(b'\n'); + prev = h; + } + miniz_oxide::deflate::compress_to_vec(&raw, 10) + } + + /// Deserialize data written by [`Dictionary::to_bytes`]. + pub fn from_bytes(data: &[u8]) -> Result { + Dictionary::decode(data) + } +} + +/// `word` with `-` before every char whose bit is set in `mask`. +#[cfg(any(test, feature = "generator"))] +pub fn hyphenated(word: &str, mask: u64) -> String { + let mut s = String::with_capacity(word.len() + 8); + for (i, c) in word.chars().enumerate() { + if i > 0 && i < 64 && mask & (1 << i) != 0 { + s.push('-'); + } + s.push(c); + } + s +} + +/// A word written with `-` at its breaks, as `(word, break mask)`. +pub fn parse_hyphenated(s: &str) -> (String, u64) { + let mut w = String::with_capacity(s.len()); + let mut m = 0u64; + let mut n = 0; + for c in s.chars() { + if c == '-' { + if n > 0 && n < 64 { + m |= 1 << n; + } + } else { + w.push(c); + n += 1; + } + } + (w, m) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let e: Vec<(String, u64)> = ["ty-pog-ra-phy", "cat", "hap-pen", "o'clock", "naïve-té", "naïve"].iter().map(|s| parse_hyphenated(s)).collect(); + let d = Dictionary::from_entries(e); + let back = Dictionary::from_bytes(&d.to_bytes()).unwrap(); + assert_eq!(back.len(), 6); + assert_eq!(back.get("typography").map(|m| hyphenated("typography", m)), Some("ty-pog-ra-phy".to_string())); + assert_eq!(back.get("cat"), Some(0)); + assert_eq!(back.get("naïveté").map(|m| hyphenated("naïveté", m)), Some("naïve-té".to_string())); + assert!(back.get("dog").is_none()); + } +} diff --git a/crates/text/src/hyphen/mod.rs b/crates/text/src/hyphen/mod.rs new file mode 100644 index 00000000..76d1416f --- /dev/null +++ b/crates/text/src/hyphen/mod.rs @@ -0,0 +1,509 @@ +//! Hyphenation points for text layout. English uses the bundled dictionary and patterns below. +//! Other languages use the Liang patterns compiled into [`hypher`], one cargo feature per +//! language (`spanish`, `german`, …, or `all-languages`); their licenses are listed in NOTICE. +//! +//! English lookup order for each word: +//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list +//! by Grady Ward (~160k words), stored compactly in `assets/hyphen/en-us.dic`; simple +//! inflections (`'s`, `-s`, `-es`, `-ed`, `-d`) inherit the breaks of their stem; +//! 2. **Liang patterns** (`assets/hyphen/en-us.pat`) for words not in the dictionary — our own +//! pattern set, trained from the same list by the patgen-style trainer behind the `generator` +//! feature (`examples/hyphgen.rs` regenerates both files); +//! 3. a small syllable **heuristic** for words outside the pattern alphabet. +//! +//! Then the paragraph's limits ([`Limits`]) are applied. + +#[cfg(feature = "generator")] +pub mod dict; +#[cfg(not(feature = "generator"))] +mod dict; +#[cfg(feature = "generator")] +pub mod patgen; +mod patterns; + +use std::sync::OnceLock; + +pub use dict::Dictionary; +#[cfg(feature = "generator")] +pub use patterns::Patterns; +#[cfg(not(feature = "generator"))] +use patterns::Patterns; + +static EN_US_DIC: &[u8] = include_bytes!("../../assets/hyphen/en-us.dic"); +static EN_US_PAT: &str = include_str!("../../assets/hyphen/en-us.pat"); + +impl Dictionary { + /// The bundled US English dictionary (decoded on first use; empty if the data is corrupt). + pub fn en_us() -> &'static Dictionary { + static D: OnceLock = OnceLock::new(); + D.get_or_init(|| Dictionary::decode(EN_US_DIC).unwrap_or_default()) + } +} + +impl Patterns { + /// The bundled US English patterns (parsed on first use). + pub fn en_us() -> &'static Patterns { + static P: OnceLock = OnceLock::new(); + P.get_or_init(|| Patterns::parse(EN_US_PAT)) + } +} + +/// Hyphenation limits from the paragraph's Hyphenation settings. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub min_word: usize, + pub after_first: usize, + pub before_last: usize, + pub capitalized: bool, +} + +impl Default for Limits { + fn default() -> Self { + Limits { min_word: 5, after_first: 2, before_last: 2, capitalized: true } + } +} + +/// The hyphenator for a run of text. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Lang { + /// The bundled dictionary, patterns and heuristic. + English, + /// Patterns compiled into [`hypher`] (one cargo feature per language). + Patterns(PatternLang), +} + +/// A language whose patterns are compiled into [`hypher`]; obtained from [`Lang::for_language`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PatternLang(hypher::Lang); + +/// English names of every language hypher can hyphenate, with their ISO 639-1 codes. Languages +/// whose cargo feature is off are not compiled into hypher, so `from_iso` returns None for them. +const LANGUAGES: &[(&str, [u8; 2])] = &[ + ("afrikaans", *b"af"), + ("albanian", *b"sq"), + ("assamese", *b"as"), + ("belarusian", *b"be"), + ("bengali", *b"bn"), + ("bangla", *b"bn"), + ("bulgarian", *b"bg"), + ("catalan", *b"ca"), + ("croatian", *b"hr"), + ("czech", *b"cs"), + ("danish", *b"da"), + ("dutch", *b"nl"), + ("estonian", *b"et"), + ("finnish", *b"fi"), + ("french", *b"fr"), + ("galician", *b"gl"), + ("georgian", *b"ka"), + ("german", *b"de"), + ("greek", *b"el"), + ("gujarati", *b"gu"), + ("hindi", *b"hi"), + ("hungarian", *b"hu"), + ("icelandic", *b"is"), + ("italian", *b"it"), + ("kannada", *b"kn"), + ("kurmanji", *b"ku"), + ("latin", *b"la"), + ("lithuanian", *b"lt"), + ("malayalam", *b"ml"), + ("marathi", *b"mr"), + ("mongolian", *b"mn"), + ("norwegian", *b"no"), + ("bokmål", *b"nb"), + ("bokmal", *b"nb"), + ("nynorsk", *b"nn"), + ("oriya", *b"or"), + ("odia", *b"or"), + ("panjabi", *b"pa"), + ("punjabi", *b"pa"), + ("polish", *b"pl"), + ("portuguese", *b"pt"), + ("russian", *b"ru"), + ("sanskrit", *b"sa"), + ("serbian", *b"sr"), + ("slovak", *b"sk"), + ("slovenian", *b"sl"), + ("slovene", *b"sl"), + ("spanish", *b"es"), + ("castilian", *b"es"), + ("swedish", *b"sv"), + ("tamil", *b"ta"), + ("telugu", *b"te"), + ("turkish", *b"tr"), + ("turkmen", *b"tk"), + ("ukrainian", *b"uk"), +]; + +impl Lang { + /// The hyphenator for a language name or locale code: an English name as InDesign and IDML + /// write it ("Spanish", "German: 2006 Reform", "$ID/Norwegian: Bokmal"), or a locale code + /// (`es`, `es_ES`, `pt-BR`, `de_DE_2006`). Case and punctuation don't matter. None when the + /// language is unknown or its cargo feature is off. + pub fn for_language(language: &str) -> Option { + if language.starts_with("English") { + return Some(Lang::English); + } + let language = language.trim(); + let language = language.strip_prefix("$ID/").unwrap_or(language); + let l = language.to_lowercase(); + let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + let first = *words.first()?; + let code = match locale_language(&words) { + Some((code, script)) => { + let code = <[u8; 2]>::try_from(code.as_bytes()).ok()?; + // Patterns for one script find nothing in another: `sr-Latn` has none. + if script.is_some_and(|script| script != pattern_script(code)) { + return None; + } + if code == *b"en" { + return Some(Lang::English); + } + code + } + None if first == "english" => return Some(Lang::English), + None => LANGUAGES.iter().find(|(name, _)| *name == first)?.1, + }; + hypher::Lang::from_iso(code).map(|l| Lang::Patterns(PatternLang(l))) + } +} + +/// The script of hypher's patterns for the language `code`, lowercase, as its documentation +/// lists them. +fn pattern_script(code: [u8; 2]) -> &'static str { + match &code { + b"as" | b"bn" => "beng", + b"be" | b"bg" | b"mn" | b"ru" | b"sr" | b"uk" => "cyrl", + b"hi" | b"mr" | b"sa" => "deva", + b"ka" => "geor", + b"el" => "grek", + b"gu" => "gujr", + b"pa" => "guru", + b"kn" => "knda", + b"ml" => "mlym", + b"or" => "orya", + b"ta" => "taml", + b"te" => "telu", + _ => "latn", + } +} + +/// The language and script of a locale code split into lowercase `words`: a language (2 or 3 +/// letters), then optionally a script (4 letters), a region (2 letters or 3 digits) and a reform +/// year (4 digits), in that order. +fn locale_language<'a>(words: &[&'a str]) -> Option<(&'a str, Option<&'a str>)> { + fn letters(w: &str, n: std::ops::RangeInclusive) -> bool { + n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase()) + } + fn digits(w: &str, n: usize) -> bool { + w.len() == n && w.bytes().all(|b| b.is_ascii_digit()) + } + let (&lang, mut rest) = words.split_first()?; + if !letters(lang, 2..=3) { + return None; + } + let script = rest.first().copied().filter(|w| letters(w, 4..=4)); + if script.is_some() { + rest = &rest[1..]; + } + let region = |w: &str| letters(w, 2..=2) || digits(w, 3); + let year = |w: &str| digits(w, 4); + for subtag in [region as fn(&str) -> bool, year] { + if let Some((&w, tail)) = rest.split_first() + && subtag(w) + { + rest = tail; + } + } + rest.is_empty().then_some((lang, script)) +} + +/// Where a word's break points came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Source { + Dictionary, + Patterns, + Heuristic, + None, +} + +fn is_apostrophe(c: char) -> bool { + matches!(c, '\'' | '’' | 'ʼ') +} + +/// Letters, apostrophes, and the combining marks and joiners inside a word: a virama or a +/// decomposed accent doesn't split it. +fn is_word_char(c: char) -> bool { + c.is_alphabetic() || is_apostrophe(c) || is_mark(c) || is_joiner(c) +} + +/// A combining mark (general category M): it belongs to the letter before it. +fn is_mark(c: char) -> bool { + use unicode_general_category::{GeneralCategory, get_general_category}; + matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark) +} + +/// Zero-width joiner and non-joiner, which shape the letters on both sides. +fn is_joiner(c: char) -> bool { + matches!(c, '\u{200C}' | '\u{200D}') +} + +/// Unrestricted break points of one lowercase core (letters and `'` only, no leading/trailing `'`). +fn core_breaks(core: &[char], lang: Lang) -> (Vec, Source) { + let n = core.len(); + if n < 2 { + return (vec![], Source::None); + } + if let Lang::Patterns(PatternLang(l)) = lang { + // The whole core, apostrophes included: French, Italian and Catalan patterns encode + // elisions (`l'or-ga-ni-sa-tion`, `del-l'am-mi-ni-stra-zio-ne`). + let s: String = core.iter().collect(); + let mut out = Vec::new(); + let mut at = 0; + let syl: Vec<&str> = hypher::hyphenate_bounded(&s, l, 1, 1).collect(); + if let Some((_, init)) = syl.split_last() { + for part in init { + at += part.chars().count(); + out.push(at); + } + } + return (out, Source::Patterns); + } + let dict = Dictionary::en_us(); + let s: String = core.iter().collect(); + let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::>(); + if let Some(m) = dict.get(&s) { + return (from_mask(m, n), Source::Dictionary); + } + // Inflections inherit the stem's breaks (the suffix adds no break of its own). + for suf in ["'s", "s", "es", "ed", "d"] { + if let Some(stem) = s.strip_suffix(suf) + && stem.chars().count() >= 2 + && let Some(m) = dict.get(stem) + { + return (from_mask(m, stem.chars().count()), Source::Dictionary); + } + } + let pats = Patterns::en_us(); + if !pats.is_empty() + && let Some(p) = pats.points(core) + { + return (p, Source::Patterns); + } + (heuristic_points(core), Source::Heuristic) +} + +/// Unrestricted break points of `word` as byte offsets (a hyphen goes before the char that starts +/// there), handling case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each +/// letter run is hyphenated separately). No break is ever placed next to an apostrophe or +/// non-letter, before a combining mark or next to a joiner; marks and joiners don't end a run. +pub fn word_breaks(word: &str) -> (Vec, Source) { + word_breaks_in(word, Lang::English) +} + +/// [`word_breaks`] for a language. +pub fn word_breaks_in(word: &str, lang: Lang) -> (Vec, Source) { + let (pts, src) = char_breaks(word, lang); + (byte_offsets(word, pts), src) +} + +/// Char indices into `word` as byte offsets. +fn byte_offsets(word: &str, pts: Vec) -> Vec { + let starts: Vec = word.char_indices().map(|(b, _)| b).collect(); + pts.into_iter().filter_map(|p| starts.get(p).copied()).collect() +} + +/// [`word_breaks_in`] as char indices. +fn char_breaks(word: &str, lang: Lang) -> (Vec, Source) { + let chars: Vec = word.chars().collect(); + let mut out = Vec::new(); + let mut src = Source::None; + let mut i = 0; + while i < chars.len() { + if !is_word_char(chars[i]) { + i += 1; + continue; + } + let mut j = i; + while j < chars.len() && is_word_char(chars[j]) { + j += 1; + } + let (mut a, mut b) = (i, j); + while a < b && is_apostrophe(chars[a]) { + a += 1; + } + while b > a && is_apostrophe(chars[b - 1]) { + b -= 1; + } + let core: Vec = chars[a..b].iter().map(|&c| if is_apostrophe(c) { '\'' } else { c.to_lowercase().next().unwrap_or(c) }).collect(); + let (pts, s) = core_breaks(&core, lang); + if src == Source::None { + src = s; + } + // Never before a mark or next to a joiner: that would split a shaping cluster. + let allowed = |p: usize| match (core.get(p.wrapping_sub(1)), core.get(p)) { + (Some(&x), Some(&y)) => x != '\'' && y != '\'' && !is_mark(y) && !is_joiner(x) && !is_joiner(y), + _ => false, + }; + out.extend(pts.into_iter().filter(|&p| p > 0 && allowed(p)).map(|p| a + p)); + i = j; + } + (out, src) +} + +/// Allowed hyphenation points in `word` as byte offsets (a hyphen goes before the char that +/// starts there), with the paragraph limits applied to each letter run. +pub fn hyphen_points(word: &str, lim: &Limits) -> Vec { + hyphen_points_in(word, lim, Lang::English) +} + +/// [`hyphen_points`] for a language. +pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { + byte_offsets(word, char_points(word, lim, lang)) +} + +/// [`hyphen_points_in`] as char indices. +fn char_points(word: &str, lim: &Limits, lang: Lang) -> Vec { + let chars: Vec = word.chars().collect(); + let (pts, _) = char_breaks(word, lang); + if pts.is_empty() { + return pts; + } + let min_before = lim.after_first.max(1); + let min_after = lim.before_last.max(1); + // Counted once, so each point's checks take constant time however long the word: letters and + // uppercase letters before each char, and the letter run (trimmed of apostrophes) of each char. + let (mut letters, mut upper) = (Vec::with_capacity(chars.len() + 1), Vec::with_capacity(chars.len() + 1)); + let (mut l, mut u) = (0usize, 0usize); + for c in &chars { + letters.push(l); + upper.push(u); + if c.is_alphabetic() { + l += 1; + u += usize::from(c.is_uppercase()); + } + } + letters.push(l); + upper.push(u); + let mut run_of: Vec<(usize, usize)> = vec![(0, 0); chars.len()]; + let mut i = 0; + while i < chars.len() { + let mut j = i; + while chars.get(j).is_some_and(|c| is_word_char(*c)) { + j += 1; + } + let (mut a, mut b) = (i, j); + while a < b && chars.get(a).is_some_and(|c| is_apostrophe(*c)) { + a += 1; + } + while b > a && chars.get(b - 1).is_some_and(|c| is_apostrophe(*c)) { + b -= 1; + } + for r in run_of.get_mut(i..j).into_iter().flatten() { + *r = (a, b); + } + i = j.max(i + 1); + } + let count = |v: &[usize], a: usize, b: usize| v.get(b).zip(v.get(a)).map_or(0, |(y, x)| y.saturating_sub(*x)); + pts.into_iter() + .filter(|&p| { + // A point lies between two letters of one run; the char after it gives the run. + let Some(&(a, b)) = run_of.get(p) else { return false }; + let n = count(&letters, a, b); + if n < lim.min_word.max(2) { + return false; + } + if n > 1 && count(&upper, a, b) == n { + return false; + } + if !lim.capitalized && chars.get(a).is_some_and(|c| c.is_uppercase()) { + return false; + } + count(&letters, a, p) >= min_before && count(&letters, p, b) >= min_after + }) + .collect() +} + +/// `word` with `-` at every hyphenation point (tests and diagnostics). +pub fn hyphenate_word(word: &str, lim: &Limits) -> String { + let pts = hyphen_points(word, lim); + let mut s = String::with_capacity(word.len() + pts.len()); + for (i, ch) in word.char_indices() { + if pts.contains(&i) { + s.push('-'); + } + s.push(ch); + } + s +} + +// ---------- heuristic fallback (words outside the pattern alphabet) ---------- + +fn is_vowel(c: char) -> bool { + matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii()) +} + +fn onset_pair(a: char, b: char) -> bool { + let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase()); + matches!( + (a, b), + ('c' | 's' | 't' | 'p' | 'w' | 'g', 'h') + | ('b' | 'c' | 'd' | 'f' | 'g' | 'k' | 'p' | 't', 'r') + | ('b' | 'c' | 'f' | 'g' | 'k' | 'p' | 's', 'l') + | ('q', 'u') + | ('s', 't' | 'p' | 'c' | 'k') + ) +} + +/// Syllable heuristic: V-CV ("ty-po"), VC-CV ("hap-pen"), keeping onset clusters ("gra-phy"). +fn heuristic_points(c: &[char]) -> Vec { + let n = c.len(); + if n < 4 || !c.iter().all(|c| c.is_alphabetic()) { + return vec![]; + } + let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + // Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count. The part + // after `i` is `c[i..end]`, `end` before that ending; computed once, so each point is O(1). + let end = |i: usize| { + if n - i >= 2 && (c.ends_with(&['e', 'd']) || c.ends_with(&['e', 's'])) { + n - 2 + } else if c.ends_with(&['e']) { + n - 1 + } else { + n + } + }; + // UTF-8 bytes before each char, and the first vowel at or after each char. + let mut bytes = Vec::with_capacity(n + 1); + let mut acc = 0usize; + for ch in c { + bytes.push(acc); + acc += ch.len_utf8(); + } + bytes.push(acc); + let mut next_vowel = vec![n; n + 1]; + for i in (0..n).rev() { + next_vowel[i] = if v[i] { i } else { next_vowel[i + 1] }; + } + let mut out = vec![]; + let mut vowel_before = false; + for i in 1..n { + vowel_before |= v[i - 1]; + let ok = if v[i - 1] && !v[i] { + (i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2]) + } else if !v[i - 1] && !v[i] { + i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] || (c[i - 1] == c[i] && i >= 2 && v[i - 2]) + } else { + false + }; + let e = end(i); + if ok && next_vowel[i] < e && bytes[e] - bytes[i] > 1 && vowel_before { + out.push(i); + } + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/text/src/hyphen/patgen.rs b/crates/text/src/hyphen/patgen.rs new file mode 100644 index 00000000..eea88b2a --- /dev/null +++ b/crates/text/src/hyphen/patgen.rs @@ -0,0 +1,251 @@ +//! A small patgen-style trainer (ours): learns Liang hyphenation patterns from a hyphenated word +//! list, level by level. Odd levels add hyphenating patterns, even levels add inhibiting ones. +//! +//! At level `L`, for every pattern length, each inter-letter position whose current value is +//! below `L` is classified as *good* (applying value `L` would fix it) or *bad* (it would break a +//! position that is currently right). A candidate (letters + value position) is selected when +//! `good·good_weight − bad·bad_weight ≥ threshold`; selected patterns are applied to every word +//! before the next length is considered. Used offline by `examples/hyphgen.rs`. + +use super::patterns::{Alphabet, FxMap, MAX_PATTERN_LEN, Patterns, apply, pack}; + +/// Parameters of one level. +#[derive(Clone, Copy, Debug)] +pub struct Level { + pub min_len: usize, + pub max_len: usize, + pub good_weight: u32, + pub bad_weight: u32, + pub threshold: u32, +} + +/// Defaults tuned on the Moby list (see `examples/hyphgen.rs`). +pub const DEFAULT_LEVELS: &[Level] = &[ + Level { min_len: 1, max_len: 4, good_weight: 1, bad_weight: 2, threshold: 20 }, + Level { min_len: 2, max_len: 5, good_weight: 2, bad_weight: 1, threshold: 8 }, + Level { min_len: 3, max_len: 6, good_weight: 1, bad_weight: 4, threshold: 7 }, + Level { min_len: 3, max_len: 7, good_weight: 3, bad_weight: 2, threshold: 2 }, + Level { min_len: 4, max_len: 8, good_weight: 1, bad_weight: 20, threshold: 6 }, + Level { min_len: 4, max_len: 8, good_weight: 3, bad_weight: 1, threshold: 2 }, +]; + +struct Word { + /// Padded codes: boundary, letters…, boundary. + codes: Vec, + /// Correct hyphen before padded char `p`. + hyph: Vec, + vals: Vec, +} + +/// Accuracy of a pattern set over a word list. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Score { + pub words: usize, + pub breaks: usize, + pub found: usize, + pub wrong: usize, +} + +impl Score { + pub fn missed(&self) -> usize { + self.breaks.saturating_sub(self.found) + } +} + +/// Train patterns from `(lowercase word, break char indices)` pairs. `log` receives progress lines. +pub fn train(words: &[(Vec, Vec)], levels: &[Level], log: &mut dyn FnMut(String)) -> Patterns { + let mut alpha = Alphabet::default(); + let mut data: Vec = Vec::with_capacity(words.len()); + 'w: for (w, br) in words { + let mut codes = Vec::with_capacity(w.len() + 2); + codes.push(1); + for &c in w { + let Some(k) = alpha.intern(c) else { continue 'w }; + codes.push(k); + } + codes.push(1); + let mut hyph = vec![false; codes.len() + 1]; + for &b in br { + if b > 0 && b < w.len() { + hyph[b + 1] = true; + } + } + let vals = vec![0; codes.len() + 1]; + data.push(Word { codes, hyph, vals }); + } + let mut pats: FxMap> = FxMap::default(); + let mut max_len = 0usize; + for (li, lv) in levels.iter().enumerate() { + let level = (li + 1) as u8; + let hyphenating = level % 2 == 1; + for len in lv.min_len..=lv.max_len.min(MAX_PATTERN_LEN) { + // (packed letters | dot) → (good, bad) + let mut counts: FxMap = FxMap::default(); + for w in &data { + let n = w.codes.len(); + // Positions strictly between the word's letters. + for p in 2..n - 1 { + let v = w.vals[p]; + if v >= level { + continue; + } + let (good, bad) = if hyphenating { + (w.hyph[p] && v % 2 == 0, !w.hyph[p] && v % 2 == 0) + } else { + (!w.hyph[p] && v % 2 == 1, w.hyph[p] && v % 2 == 1) + }; + if !good && !bad { + continue; + } + for dot in 0..=len { + if p < dot || p - dot + len > n { + continue; + } + let s = p - dot; + let key = pack(&w.codes[s..s + len]) | ((dot as u64) << 54); + let e = counts.entry(key).or_default(); + if good { + e.0 += 1; + } else { + e.1 += 1; + } + } + } + } + // Select. + let mut new: FxMap> = FxMap::default(); + let mut selected = 0usize; + for (key, (g, b)) in counts { + // Widened first: u32 × u32 fills a u64, and the difference needs a sign. + let score = i128::from(g) * i128::from(lv.good_weight) - i128::from(b) * i128::from(lv.bad_weight); + if g > 0 && score >= i128::from(lv.threshold) { + let dot = ((key >> 54) & 15) as usize; + let letters = key & !(15u64 << 54); + let e = new.entry(letters).or_insert_with(|| vec![0u8; len + 1].into_boxed_slice()); + e[dot] = level; + selected += 1; + } + } + if selected == 0 { + continue; + } + for w in &mut data { + apply(&new, len, &w.codes, &mut w.vals); + } + for (k, v) in new { + let e = pats.entry(k).or_insert_with(|| vec![0u8; len + 1].into_boxed_slice()); + for (a, b) in e.iter_mut().zip(v.iter()) { + *a = (*a).max(*b); + } + } + max_len = max_len.max(len); + let sc = score_vals(&data); + log(format!("level {level} len {len}: +{selected} patterns ({} total); found {}/{} wrong {}", pats.len(), sc.found, sc.breaks, sc.wrong)); + } + } + // Re-express through the public pattern type (TeX strings) so the alphabet matches at runtime. + let tmp = Patterns::from_parts(alpha, pats, max_len); + let mut out = Patterns::default(); + out.add_letters(words.iter().flat_map(|w| w.0.iter().copied())); + for t in tmp.to_tex() { + out.insert(&t); + } + out +} + +fn score_vals(data: &[Word]) -> Score { + let mut s = Score { words: data.len(), ..Default::default() }; + for w in data { + for p in 2..w.codes.len() - 1 { + let on = w.vals[p] % 2 == 1; + if w.hyph[p] { + s.breaks += 1; + if on { + s.found += 1; + } + } else if on { + s.wrong += 1; + } + } + } + s +} + +/// Score `pats` against a word list. +pub fn score(pats: &Patterns, words: &[(Vec, Vec)]) -> Score { + let mut s = Score::default(); + for (w, br) in words { + let Some(pts) = pats.points(w) else { continue }; + s.words += 1; + s.breaks += br.len(); + for p in &pts { + if br.contains(p) { + s.found += 1; + } else { + s.wrong += 1; + } + } + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn w(s: &str) -> (Vec, Vec) { + let mut chars = Vec::new(); + let mut br = Vec::new(); + for c in s.chars() { + if c == '-' { + br.push(chars.len()); + } else { + chars.push(c); + } + } + (chars, br) + } + + /// Weights near `u32::MAX` select the pattern instead of overflowing. + #[test] + fn large_weights_do_not_overflow() { + let words = vec![(vec!['a', 'b'], vec![1]); 2]; + let levels = [Level { min_len: 1, max_len: 1, good_weight: 1 << 31, bad_weight: 1, threshold: 1 }]; + let patterns = train(&words, &levels, &mut |_| {}); + assert_eq!(patterns.points(&['a', 'b']), Some(vec![1])); + } + + #[test] + fn learns_a_tiny_list() { + let list: Vec<_> = [ + "hap-pen", + "hap-pens", + "hap-pened", + "pen-cil", + "pen-cils", + "rab-bit", + "rab-bits", + "kit-ten", + "kit-tens", + "but-ter", + "bet-ter", + "let-ter", + "lit-ter", + "sit-ter", + ] + .iter() + .map(|s| w(s)) + .collect(); + let levels = [ + Level { min_len: 1, max_len: 4, good_weight: 1, bad_weight: 1, threshold: 2 }, + Level { min_len: 1, max_len: 5, good_weight: 1, bad_weight: 1, threshold: 1 }, + ]; + let p = train(&list, &levels, &mut |_| {}); + let sc = score(&p, &list); + assert_eq!(sc.breaks, list.len()); + assert!(sc.found >= sc.breaks - 1, "{sc:?}"); + assert!(sc.wrong <= 1, "{sc:?}"); + // Generalises to an unseen word with the same shape. + assert_eq!(p.points(&"bitter".chars().collect::>()).unwrap(), vec![3]); + } +} diff --git a/crates/text/src/hyphen/patterns.rs b/crates/text/src/hyphen/patterns.rs new file mode 100644 index 00000000..a5dff954 --- /dev/null +++ b/crates/text/src/hyphen/patterns.rs @@ -0,0 +1,241 @@ +//! Liang hyphenation patterns (TeX notation, e.g. `.ab1c`, `4t1ion`): parsing and application. +//! +//! The bundled English set is *ours*: trained from the public-domain Moby Hyphenator word list by +//! the crate's patgen-style trainer (see `examples/hyphgen.rs`). Patterns are stored in a hash map keyed by the +//! packed letter string, so applying them to a word costs `O(len × max_pattern_len)` lookups. + +use std::collections::HashMap; +use std::hash::{BuildHasherDefault, Hasher}; + +/// Word-boundary marker in patterns. +pub(crate) const DOT: char = '.'; +/// Longest pattern the packed key supports. +pub(crate) const MAX_PATTERN_LEN: usize = 9; + +/// Small, fast multiplicative hasher for packed `u64` keys (FxHash-style). +#[derive(Default, Clone, Copy)] +pub(crate) struct FxHasher(u64); + +impl Hasher for FxHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.write_u64(b as u64); + } + } + fn write_u64(&mut self, i: u64) { + self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); + } +} + +pub(crate) type FxMap = HashMap>; + +/// Maps characters to 6-bit codes (1 = word boundary, 2.. = letters); 0 = not in the alphabet. +#[derive(Clone, Debug)] +pub(crate) struct Alphabet { + ascii: [u8; 128], + other: Vec<(char, u8)>, + next: u8, +} + +impl Default for Alphabet { + fn default() -> Self { + let mut a = Alphabet { ascii: [0; 128], other: Vec::new(), next: 2 }; + a.ascii[DOT as usize] = 1; + a + } +} + +impl Alphabet { + pub(crate) fn code(&self, c: char) -> u8 { + if (c as u32) < 128 { self.ascii[c as usize] } else { self.other.iter().find(|x| x.0 == c).map_or(0, |x| x.1) } + } + /// Code for `c`, adding it if new (None when the 6-bit alphabet is full). + pub(crate) fn intern(&mut self, c: char) -> Option { + let k = self.code(c); + if k != 0 { + return Some(k); + } + if self.next >= 63 { + return None; + } + let k = self.next; + self.next += 1; + if (c as u32) < 128 { + self.ascii[c as usize] = k; + } else { + self.other.push((c, k)); + } + Some(k) + } +} + +/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes). +#[inline] +pub(crate) fn pack(codes: &[u8]) -> u64 { + let mut k = 0u64; + for &c in codes { + k = (k << 6) | c as u64; + } + k | ((codes.len() as u64) << 58) +} + +/// A compiled pattern set. +#[derive(Clone, Debug, Default)] +pub struct Patterns { + alpha: Alphabet, + /// Packed letters → inter-letter values (`len + 1` entries). + map: FxMap>, + max_len: usize, +} + +impl Patterns { + /// Parse TeX-style patterns: whitespace-separated, `%`/`#` start a comment line. + pub fn parse(text: &str) -> Patterns { + let mut p = Patterns::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('%') || line.starts_with('#') { + continue; + } + for tok in line.split_whitespace() { + p.insert(tok); + } + } + p + } + + /// Add one pattern like `a1b`; returns false if it can't be represented. + pub(crate) fn insert(&mut self, pat: &str) -> bool { + let mut codes = Vec::with_capacity(pat.len()); + let mut vals = vec![0u8]; + for c in pat.chars() { + if let Some(d) = c.to_digit(10) { + *vals.last_mut().unwrap_or(&mut 0) = d as u8; + } else { + let Some(k) = self.alpha.intern(c) else { return false }; + codes.push(k); + vals.push(0); + } + } + if codes.is_empty() || codes.len() > MAX_PATTERN_LEN { + return false; + } + self.max_len = self.max_len.max(codes.len()); + self.map.insert(pack(&codes), vals.into_boxed_slice()); + true + } + + #[cfg(feature = "generator")] + pub(crate) fn from_parts(alpha: Alphabet, map: FxMap>, max_len: usize) -> Patterns { + Patterns { alpha, map, max_len } + } + + /// Make `letters` part of the alphabet even if no pattern mentions them. + #[cfg(feature = "generator")] + pub(crate) fn add_letters(&mut self, letters: impl IntoIterator) { + for c in letters { + let _ = self.alpha.intern(c); + } + } + + #[cfg(any(test, feature = "generator"))] + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Inter-letter values for a lowercase word: `out[i]` is the value *before* char `i` + /// (`out.len() == word.len() + 1`). None if a char is outside the pattern alphabet. + pub(crate) fn values(&self, word: &[char]) -> Option> { + let mut codes = Vec::with_capacity(word.len() + 2); + codes.push(1); + for &c in word { + let k = self.alpha.code(c); + if k == 0 { + return None; + } + codes.push(k); + } + codes.push(1); + let mut vals = vec![0u8; codes.len() + 1]; + apply(&self.map, self.max_len, &codes, &mut vals); + // Padded position p sits before padded char p; word char i is padded char i + 1. + Some(vals[1..codes.len()].to_vec()) + } + + /// Break points (char indices, a hyphen goes before that char) of a lowercase word. + pub fn points(&self, word: &[char]) -> Option> { + let v = self.values(word)?; + Some((1..word.len()).filter(|&i| v[i] % 2 == 1).collect()) + } + + /// The patterns in TeX notation (sorted by letters), e.g. for writing a pattern file. + #[cfg(any(test, feature = "generator"))] + pub fn to_tex(&self) -> Vec { + let mut rev: Vec<(u8, char)> = + (0u8..128).filter(|&c| self.alpha.ascii[c as usize] != 0).map(|c| (self.alpha.ascii[c as usize], c as char)).collect(); + rev.extend(self.alpha.other.iter().map(|&(c, k)| (k, c))); + let ch = |k: u8| rev.iter().find(|x| x.0 == k).map_or('?', |x| x.1); + let mut out: Vec<(String, String)> = self + .map + .iter() + .map(|(&key, vals)| { + let n = (key >> 58) as usize; + let letters: Vec = (0..n).map(|i| ch(((key >> (6 * (n - 1 - i))) & 63) as u8)).collect(); + let mut s = String::new(); + for (i, l) in letters.iter().enumerate() { + if vals[i] > 0 { + s.push(char::from(b'0' + vals[i])); + } + s.push(*l); + } + if vals[n] > 0 { + s.push(char::from(b'0' + vals[n])); + } + (letters.into_iter().collect(), s) + }) + .collect(); + out.sort(); + out.into_iter().map(|x| x.1).collect() + } +} + +/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1). +#[inline] +pub(crate) fn apply(map: &FxMap>, max_len: usize, codes: &[u8], vals: &mut [u8]) { + let n = codes.len(); + for s in 0..n { + let mut k = 0u64; + for len in 1..=max_len.min(n - s) { + k = (k << 6) | codes[s + len - 1] as u64; + if let Some(v) = map.get(&(k | ((len as u64) << 58))) { + for (slot, &x) in vals.iter_mut().skip(s).zip(v.iter()) { + *slot = (*slot).max(x); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn liang_example() { + // The classic example from Liang's thesis: "hy-phen-ation". + let p = Patterns::parse("hy3ph he2n hena4 hen5at 1na n2at 1tio 2io o2n"); + let w: Vec = "hyphenation".chars().collect(); + assert_eq!(p.points(&w).unwrap(), vec![2, 6]); + let mut tex = p.to_tex(); + tex.sort(); + assert!(tex.contains(&"hen5at".to_string())); + assert!(p.values(&['h', 'é']).is_none()); + } +} diff --git a/crates/text/src/hyphen/tests.rs b/crates/text/src/hyphen/tests.rs new file mode 100644 index 00000000..f786a5e3 --- /dev/null +++ b/crates/text/src/hyphen/tests.rs @@ -0,0 +1,364 @@ +use super::*; + +fn h(w: &str) -> String { + hyphenate_word(w, &Limits::default()) +} + +#[test] +fn bundled_data_loads() { + assert!(Dictionary::en_us().len() > 150_000, "{}", Dictionary::en_us().len()); + assert!(Patterns::en_us().len() > 1000, "{}", Patterns::en_us().len()); +} + +#[test] +fn dictionary_words() { + assert_eq!(h("happen"), "hap-pen"); + assert_eq!(h("typography"), "ty-pog-ra-phy"); + assert_eq!(h("composition"), "com-po-si-tion"); + assert_eq!(h("hyphenation"), "hy-phen-a-tion"); + assert_eq!(h("international"), "in-ter-na-tion-al"); + assert_eq!(h("cat"), "cat"); + assert!(!h("changed").contains('-')); + assert_eq!(h("NASA"), "NASA"); + assert_eq!(word_breaks("publication").1, Source::Dictionary); +} + +#[test] +fn case_apostrophes_and_punctuation() { + assert_eq!(h("Typography"), "Ty-pog-ra-phy"); + assert_eq!(h("TYPOGRAPHY"), "TYPOGRAPHY"); + assert_eq!(h("typography,"), "ty-pog-ra-phy,"); + assert_eq!(h("“typography”"), "“ty-pog-ra-phy”"); + assert_eq!(h("typography's"), "ty-pog-ra-phy's"); + assert_eq!(h("typography’s"), "ty-pog-ra-phy’s"); + assert_eq!(h("'typography'"), "'ty-pog-ra-phy'"); + // Compounds: each part separately, never next to the hyphen. + assert_eq!(h("typography-composition"), "ty-pog-ra-phy-com-po-si-tion"); + // Points are byte offsets: the opening quote takes three bytes. + assert_eq!(hyphen_points("“typography”", &Limits::default()), vec![5, 8, 10]); +} + +#[test] +fn inflections_inherit_the_stem() { + assert_eq!(h("ripples"), "rip-ples"); + assert_eq!(h("gradients"), "gra-di-ents"); + assert_eq!(h("restrained"), "re-strained"); +} + +#[test] +fn patterns_cover_unknown_words() { + // Not in the word list: the trained patterns decide. + let (pts, src) = word_breaks("hyperlocalization"); + assert_eq!(src, Source::Patterns); + assert!(pts.len() >= 4, "{}", h("hyperlocalization")); + let (_, src) = word_breaks("zzyzx"); + assert_ne!(src, Source::Dictionary); +} + +#[test] +fn limits_are_respected() { + let lim = Limits { min_word: 5, after_first: 3, before_last: 3, capitalized: false }; + for w in ["typography", "hyphenation", "justification", "Paragraph", "beautiful"] { + for p in hyphen_points(w, &lim) { + assert!(p >= 3 && p <= w.chars().count() - 3, "{w} {p}"); + } + } + assert!(hyphen_points("Paragraph", &lim).is_empty()); + let lim = Limits { min_word: 12, ..Limits::default() }; + assert!(hyphen_points("typography", &lim).is_empty()); +} + +#[test] +fn language_names_and_codes() { + for name in ["English", "English: USA", "english", "$ID/English: UK", "en", "en_US", "en-GB", "EN", "en-Latn-US"] { + assert_eq!(Lang::for_language(name), Some(Lang::English), "{name}"); + } + assert_eq!(Lang::for_language("en-Cyrl"), None); + // Each language resolves by name and by code exactly when its feature is on. + for &(name, code) in LANGUAGES { + let want = hypher::Lang::from_iso(code).map(|l| Lang::Patterns(PatternLang(l))); + let code = std::str::from_utf8(&code).unwrap(); + let mut title = name.chars(); + let title: String = title.next().map(|c| c.to_uppercase().chain(title).collect()).unwrap_or_default(); + let upper = code.to_uppercase(); + let script = pattern_script(code.as_bytes().try_into().unwrap()); + for spelling in [ + name.to_string(), + format!("{title}: Reform"), + format!("$ID/{title}"), + code.to_string(), + format!("{code}_{upper}"), + format!("{code}-419"), + format!("{code}_{upper}_2006"), + format!("{code}-{script}"), + format!("{code}-{script}-{upper}"), + format!("{code}-1996"), + format!("{code}-{script}-1996"), + ] { + assert_eq!(Lang::for_language(&spelling), want, "{spelling}"); + } + let other = if script == "latn" { "Cyrl" } else { "Latn" }; + assert_eq!(Lang::for_language(&format!("{code}-{other}-{upper}")), None, "{code}: patterns of another script"); + assert_eq!(Lang::for_language(&format!("{code}_{upper}_x")), None, "{code}: not a locale code"); + } + for unknown in ["", "[No Language]", "Klingon", "zh_CN", "Chinese: Simplified", "tlh"] { + assert_eq!(Lang::for_language(unknown), None, "{unknown}"); + } +} + +#[cfg(feature = "all-languages")] +#[test] +fn every_language_is_available() { + for &(name, code) in LANGUAGES { + if code == *b"sk" && !cfg!(feature = "slovak-gpl") { + continue; + } + assert!(hypher::Lang::from_iso(code).is_some(), "{name}"); + assert!(Lang::for_language(name).is_some(), "{name}"); + } +} + +/// A virama doesn't split a word: the limits count the whole word, and no point falls before a +/// mark or next to a joiner. +#[cfg(feature = "hindi")] +#[test] +fn indic_word_limits_span_viramas() { + let hi = Lang::for_language("hi").unwrap(); + // न म स ् क ा र: unrestricted points before म, स and र; only स has two letters on each side. + assert_eq!(word_breaks_in("नमस्कार", hi).0, vec![3, 6, 18]); + assert_eq!(hyphen_points_in("नमस्कार", &Limits::default(), hi), vec![6]); + for w in ["नमस्कार", "हिन्दुस्तानी", "प्रौद्योगिकी", "विश्वविद्यालय", "क्ष\u{200D}त्रिय"] + { + for p in word_breaks_in(w, hi).0 { + let (before, after) = w.split_at(p); + let (Some(x), Some(y)) = (before.chars().next_back(), after.chars().next()) else { panic!("{w} at {p}") }; + assert!(!is_mark(y) && !is_joiner(x) && !is_joiner(y), "{w} at {p}"); + } + } +} + +/// Slovak's patterns are GPL-2.0-or-later: only their own feature turns them on, never +/// `all-languages`. +#[test] +fn all_languages_leaves_out_the_gpl_slovak_patterns() { + let manifest = include_str!("../../Cargo.toml"); + let all = manifest.split("all-languages = [").nth(1).and_then(|s| s.split(']').next()).unwrap_or_default(); + assert!(all.contains("\"spanish\""), "all-languages list not found"); + assert!(!all.contains("slovak"), "{all}"); + assert!(manifest.contains("\nslovak-gpl = [\"hyphen\", \"hypher/slovak\"]")); +} + +#[cfg(feature = "spanish")] +#[test] +fn spanish_uses_the_bezos_patterns() { + let es = Lang::for_language("Spanish").unwrap(); + assert_eq!(es, Lang::for_language("es_ES").unwrap()); + let h = |w: &str| { + let pts = hyphen_points_in(w, &Limits::default(), es); + assert!(pts.iter().all(|&p| w.is_char_boundary(p)), "{w} {pts:?}"); + w.char_indices().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::() + }; + // Offsets are bytes: `ú` and `ó` take two each. + assert_eq!(hyphen_points_in("música", &Limits::default(), es), vec![3, 5]); + assert_eq!(hyphen_points_in("ejército", &Limits::default(), es), vec![5, 7]); + assert_eq!(h("transición"), "tran-si-ción"); + assert_eq!(h("pingüino"), "pin-güino"); + assert_eq!(h("Construcción"), "Cons-truc-ción"); + assert_eq!(h("desesperación"), "de-ses-pe-ra-ción"); + assert_eq!(h("teatro"), "tea-tro", "a vowel run stays whole"); + assert_eq!(h("calle"), "ca-lle", "ll stays whole"); + #[cfg(not(feature = "french"))] + assert!(Lang::for_language("French").is_none(), "only languages built in"); +} + +/// An elided article or preposition keeps the breaks of the word after it, and no break falls +/// next to the apostrophe. +#[cfg(feature = "french")] +#[test] +fn french_elisions_keep_their_breaks() { + let fr = Lang::for_language("French").unwrap(); + let h = |w: &str| { + let pts = hyphen_points_in(w, &Limits::default(), fr); + w.char_indices().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::() + }; + assert_eq!(h("organisation"), "or-ga-ni-sa-tion"); + assert_eq!(h("l'organisation"), "l'or-ga-ni-sa-tion"); + assert_eq!(h("L’organisation"), "L’or-ga-ni-sa-tion"); + assert_eq!(h("d'information"), "d'in-for-ma-tion"); + assert_eq!(h("aujourd'hui"), "au-jour-d'hui"); +} + +/// The fastest of three timings of `f`, after a warm-up. +fn fastest(f: impl Fn()) -> f64 { + f(); + (0..3) + .map(|_| { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() + }) + .fold(f64::INFINITY, f64::min) +} + +/// `hyphen_points`' limit checks as first written (a rescan of the letter run per point): the +/// reference for the linear version. +fn hyphen_points_ref(word: &str, lim: &Limits) -> Vec { + let chars: Vec = word.chars().collect(); + let (pts, _) = char_breaks(word, Lang::English); + let min_before = lim.after_first.max(1); + let min_after = lim.before_last.max(1); + let run_of = |p: usize| { + let mut a = p; + while a > 0 && is_word_char(chars[a - 1]) { + a -= 1; + } + let mut b = p; + while b < chars.len() && is_word_char(chars[b]) { + b += 1; + } + while a < b && is_apostrophe(chars[a]) { + a += 1; + } + while b > a && is_apostrophe(chars[b - 1]) { + b -= 1; + } + (a, b) + }; + pts.into_iter() + .filter(|&p| { + let (a, b) = run_of(p); + let run = &chars[a..b]; + let letters = run.iter().filter(|c| c.is_alphabetic()).count(); + if letters < lim.min_word.max(2) { + return false; + } + if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) { + return false; + } + if !lim.capitalized && run[0].is_uppercase() { + return false; + } + let before = chars[a..p].iter().filter(|c| c.is_alphabetic()).count(); + let after = chars[p..b].iter().filter(|c| c.is_alphabetic()).count(); + before >= min_before && after >= min_after + }) + .collect() +} + +/// `heuristic_points` as first written (the right part rebuilt per point): the reference. +fn heuristic_ref(c: &[char]) -> Vec { + let n = c.len(); + if n < 4 || !c.iter().all(|c| c.is_alphabetic()) { + return vec![]; + } + let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + let mut out = vec![]; + for i in 1..n { + let ok = if v[i - 1] && !v[i] { + (i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2]) + } else if !v[i - 1] && !v[i] { + i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] || (c[i - 1] == c[i] && i >= 2 && v[i - 2]) + } else { + false + }; + let right: String = c[i..].iter().collect(); + let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right); + if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) { + out.push(i); + } + } + out +} + +#[test] +fn linear_limits_and_heuristic_match_the_reference() { + let long = "hyphenation".repeat(40); + let words = [ + "", + "x", + "''", + "a'b'c", + "cat", + "happen", + "hyphenation", + "Typography's", + "TYPOGRAPHY", + "Typography", + "typography’s", + "'typography'", + "“typography”,", + "typography-composition", + "don't", + "rock'n'roll", + "naïveté", + "crème-brûlée", + "Ünïcödé", + "ÉTUDE", + "strengths", + "ee", + "bed", + "hopped", + "rhythmed", + "aeiouy", + "xxyyzz", + "bookkeeper", + "ʼtwas", + "mid-1990s", + "twenty-one", + "école", + "Ålesund", + "smørrebrød", + "cafe\u{301}-cre\u{300}me", + "e\u{301}tude", + "नमस्कार", + "hopes", + "hoped", + "taed", + "teeed", + &long, + ]; + for w in words { + for min_word in [2, 5, 7] { + for (after_first, before_last) in [(1, 1), (2, 2), (3, 2), (2, 4)] { + for capitalized in [true, false] { + let lim = Limits { min_word, after_first, before_last, capitalized }; + assert_eq!(hyphen_points(w, &lim), byte_offsets(w, hyphen_points_ref(w, &lim)), "{w:?} {lim:?}"); + } + } + } + let c: Vec = w.to_lowercase().chars().collect(); + assert_eq!(heuristic_points(&c), heuristic_ref(&c), "{w:?}"); + } +} + +#[test] +#[ignore = "wall-clock timing; run with --ignored"] +fn hyphenating_a_long_word_is_linear() { + // A word of n letters against one of 4n: linear ~4x the time, the old rescans ~16x. One in + // the pattern alphabet, one outside it (the syllable heuristic). + let pattern = |k: usize| "hyphenation".repeat(k); + let heuristic = |k: usize| "smørrebrød".repeat(k); + assert_eq!(word_breaks(&heuristic(2)).1, Source::Heuristic); + // Each timing repeats the call so the shorter one is milliseconds, well above timer noise. + let ratios: Vec<(String, f64)> = [(pattern as fn(usize) -> String, 20), (heuristic, 40)] + .into_iter() + .map(|(word, repeat)| { + let (small, big) = (word(400), word(1600)); + let time = |w: &str| fastest(|| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default())))); + let (t1, t4) = (time(&small), time(&big)); + let ratio = t4 / t1.max(1e-9); + let what = format!("{}…: t(n) {t1:.4} s, t(4n) {t4:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::()); + eprintln!("{what}"); + (what, ratio) + }) + .collect(); + for (what, ratio) in ratios { + assert!(ratio < 8.0, "4x the letters, not linear: {what}"); + } +} + +#[test] +fn heuristic_fallback() { + let c: Vec = "happen".chars().collect(); + assert_eq!(heuristic_points(&c), vec![3]); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs new file mode 100644 index 00000000..07795c31 --- /dev/null +++ b/crates/text/src/lib.rs @@ -0,0 +1,25 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Shared text processing for the Craft apps. It sits below the composer: layout code calls it on +//! plain text and a language before it shapes and places glyphs. +//! +//! Modules, each behind the cargo feature of the same name: +//! - [`hyphen`] (default): hyphenation points, with bundled US English data and optional +//! hypher patterns for 47 more languages (Slovak, under the GPL, only with `slovak-gpl`). +//! - [`segment`] (default): line-break opportunities (UAX #14 with kinsoku), and word breaks for +//! Thai, Lao, Khmer and Burmese with their language features. +//! - [`bidi`] (default): bidirectional text (UAX #9): embedding levels, line reordering and +//! mirroring. +//! - [`linebreak`] (default): line breaking on a font-independent box/glue/penalty model. +//! +//! Language features (`spanish`, `german`, …, `all-languages`) add data for that language and +//! turn on the modules that use it. + +#[cfg(feature = "bidi")] +pub mod bidi; +#[cfg(feature = "hyphen")] +pub mod hyphen; +#[cfg(feature = "linebreak")] +pub mod linebreak; +#[cfg(feature = "segment")] +pub mod segment; diff --git a/crates/text/src/linebreak/greedy.rs b/crates/text/src/linebreak/greedy.rs new file mode 100644 index 00000000..b6dcb606 --- /dev/null +++ b/crates/text/src/linebreak/greedy.rs @@ -0,0 +1,108 @@ +//! Greedy first fit: each line takes as much as fits. + +use super::{INFINITE_PENALTY, Item, Kind, Line, Params, Sums, clamp_measure}; + +/// Break `items` first fit: each line ends at the last break where it still fits its measure +/// `width(k)` with its shrink. A line with no such break ends at the last [`Item::emergency`] +/// break that fits, else before the first box that overflows (never after an +/// [`Item::no_break`]). +pub fn greedy(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params) -> Vec { + greedy_with(items, width, p, &|_, _, k| items.get(k).map_or(0.0, |i| i.width)) +} + +/// [`greedy`] with box widths that depend on position: `advance(line, x, k)` is the width of box +/// `k` when it starts `x` from the start of line `line` (a tab reaching its stop). It returns +/// `items[k].width` for other boxes. +pub fn greedy_with(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params, advance: &dyn Fn(usize, f64, usize) -> f64) -> Vec { + let p = &p.sanitized(); + let width = |k: usize| clamp_measure(width(k)); + let s = Sums::new(items); + let m = items.len(); + // Japanese "push out first": break where the line fits without shrinking when there is one. + let avoid_shrink = p.shrink_weight > p.stretch_weight; + let zone = p.rag.map_or(0.0, |r| r.hyphenation_zone); + let zoned = |b: usize| zone > 0.0 && items.get(b).is_some_and(|i| i.is_flagged() && i.hyphenation); + let no_break_before = |k: usize| k > 0 && items.get(k - 1).is_some_and(|i| i.kind == Kind::Penalty && i.cost >= INFINITE_PENALTY); + let mut out = Vec::new(); + let mut start = 0; + let mut line = 0; + let mut hyphens = 0; + loop { + let measure = width(line); + // extra[k - start]: width the boxes before item `k` gained from `advance`. + let mut extra = Vec::new(); + let mut ext = 0.0; + let mut last_ok = None; + let mut last_natural = None; + let mut last_emergency = None; + // (break, line width there) + let mut last_space: Option<(usize, f64)> = None; + let mut k = start; + let (end, next) = loop { + extra.push(ext); + let Some(it) = items.get(k) else { break (m, m) }; + if s.is_break(k) { + if it.is_forced() { + break (k, s.next_start(k)); + } + let blocked = it.is_flagged() && p.hyphen_limit > 0 && hyphens >= p.hyphen_limit; + let f = s.fit(start, k, Some(it), false, measure, p, ext); + if !blocked && f.feasible { + if it.emergency { + last_emergency = Some(k); + } else { + last_ok = Some(k); + if f.width <= measure + p.overrun { + last_natural = Some(k); + } + } + } + if it.kind == Kind::Glue { + last_space = Some((k, f.width)); + } + } else if it.kind == Kind::Box { + let x = s.fit(start, k, None, false, measure, p, ext).width; + ext += advance(line, x, k) - it.width; + if k > start { + // Would a line ending right after this box overflow? (The following break's + // hang and edge elasticity apply; a hyphen is checked at its own break.) + let after = items.get(k + 1).filter(|i| i.kind != Kind::Box).map(|i| Item { width: 0.0, emergency: false, ..*i }); + let f = s.fit(start, k + 1, after.as_ref(), false, measure, p, ext); + if avoid_shrink + && f.width > measure + p.overrun + && let Some(b) = last_natural + { + break (b, s.next_start(b)); + } + if !f.feasible { + match (last_ok, last_emergency) { + (Some(b), _) => { + // Hyphenation zone: break before the word if that leaves little space. + if zoned(b) + && let Some((sp, xs)) = last_space + && measure - xs < zone + { + break (sp, s.next_start(sp)); + } + break (b, s.next_start(b)); + } + (None, Some(b)) => break (b, s.next_start(b)), + (None, None) if !no_break_before(k) => break (k, k), + (None, None) => {} + } + } + } + } + k += 1; + }; + hyphens = if items.get(end).is_some_and(Item::is_flagged) { hyphens + 1 } else { 0 }; + let ext_end = extra.get(end.saturating_sub(start)).copied().unwrap_or(ext); + out.push(s.line(start, end, next, measure, p, ext_end)); + if next >= m { + break; + } + start = next; + line += 1; + } + out +} diff --git a/crates/text/src/linebreak/knuth_plass.rs b/crates/text/src/linebreak/knuth_plass.rs new file mode 100644 index 00000000..71f63c2d --- /dev/null +++ b/crates/text/src/linebreak/knuth_plass.rs @@ -0,0 +1,223 @@ +//! Knuth–Plass total fit: the breaks that minimise the paragraph's summed demerits. + +use super::{Item, Kind, Line, Params, Sums, clamp_measure, fitness_class}; + +/// Give up (return `None`) beyond this many active breaks. +const MAX_ACTIVE: usize = 4096; +/// Ratios beyond this are rated as this (an unstretchable line is infinitely loose). +const MAX_RATIO: f64 = 10_000.0; +/// Demerits of an overfull line accepted because nothing else reaches a break. +const OVERFULL_DEMERITS: f64 = 1e8; + +#[derive(Clone, Copy, Debug)] +struct Node { + /// The break this node ends a line at. + pos: usize, + /// First item of the next line. + start: usize, + /// Number of the next line. + line: usize, + fitness: Option, + demerits: f64, + /// Consecutive lines ending at flagged breaks, up to here. + hyphens: usize, + flagged: bool, + prev: Option, +} + +/// The best way found to reach the current break for one (line class, fitness, hyphen state). +/// Line classes are line numbers below [`Params::uniform_from`], then one class for the rest. +struct Candidate { + line: usize, + fitness: u8, + hyphens: usize, + demerits: f64, + from: usize, +} + +/// One total-fit pass over `items`. `width(k)` is the measure of line `k`; set +/// [`Params::uniform_from`] when it varies. Returns `None` when no set of breaks keeps every line +/// within [`Params::tolerance`] (unless [`Params::overfull`]), or past the active-break limit. +pub fn knuth_plass(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params) -> Option> { + let p = &p.sanitized(); + let width = |k: usize| clamp_measure(width(k)); + let s = Sums::new(items); + let m = items.len(); + let limit = p.hyphen_limit; + // Hyphen states 0..=limit; unlimited still tells a flagged line (1) from others (0). + let max_hyphens = if limit == 0 { 1 } else { limit }; + let zone = p.rag.map_or(0.0, |r| r.hyphenation_zone); + let mut nodes = vec![Node { pos: 0, start: 0, line: 0, fitness: p.first_fitness, demerits: 0.0, hyphens: 0, flagged: false, prev: None }]; + let mut active: Vec = vec![0]; + let mut keep: Vec = Vec::new(); + let mut best: Vec = Vec::new(); + // The last break at a space (glue), for the hyphenation zone. + let mut last_space: Option = None; + for b in 0..=m { + if !s.is_break(b) { + continue; + } + let item = items.get(b); + let forced = s.forced(b); + let flagged = item.is_some_and(Item::is_flagged); + let cost = item.filter(|i| i.kind == Kind::Penalty).map_or(0.0, |i| i.cost); + best.clear(); + keep.clear(); + let mut last_removed = None; + for &a in &active { + let Some(n) = nodes.get(a).copied() else { continue }; + // The node's next line starts after `b` (the penalties between were discarded): no + // line ends here, but one may end at a later break. + if b < n.start { + keep.push(a); + continue; + } + let measure = width(n.line); + let f = s.measure(n.start, b, measure, p); + // An overfull node is dropped even at a penalty with width (a hyphen), as in TeX, + // although a later break without that width might still fit. + if f.feasible && !forced { + keep.push(a); + } else { + last_removed = Some(a); + } + if !f.feasible || (f.ratio > p.tolerance && !forced) { + continue; + } + if flagged { + if limit > 0 && n.hyphens >= limit { + continue; + } + // Hyphenation zone: break at the space before the word instead when that leaves + // less than the zone at the line end. + if zone > 0.0 + && item.is_some_and(|i| i.hyphenation) + && let Some(sp) = last_space.filter(|&sp| sp >= n.start) + && measure - s.measure(n.start, sp, measure, p).width < zone + { + continue; + } + } + let weight = if f.ratio > 0.0 { + p.stretch_weight + } else if f.ratio < 0.0 { + p.shrink_weight + } else { + 1.0 + }; + let badness = (weight * 100.0 * f.ratio.abs().min(MAX_RATIO).powi(3)).min(p.max_badness); + let lp = p.line_penalty + badness; + let mut d = if forced { + lp * lp + } else if cost >= 0.0 { + lp * lp + cost * cost + } else { + lp * lp - cost * cost + }; + if flagged && n.flagged { + d += p.flagged_demerits; + } + // A ragged line ending at a forced break says nothing about the rag: it keeps its + // neighbour's class. + let class = fitness_class(f.ratio, &p.fitness); + let fitness = if forced && p.rag.is_some() { n.fitness.unwrap_or(class) } else { class }; + if n.fitness.is_some_and(|nf| fitness.abs_diff(nf) > 1) { + d += p.fitness_demerits; + } + let hyphens = if flagged { (n.hyphens + 1).min(max_hyphens) } else { 0 }; + let line = (n.line + 1).min(p.uniform_from); + let demerits = n.demerits + d; + match best.iter_mut().find(|c| c.line == line && c.fitness == fitness && c.hyphens == hyphens) { + Some(c) if c.demerits <= demerits => {} + Some(c) => { + c.demerits = demerits; + c.from = a; + } + None => best.push(Candidate { line, fitness, hyphens, demerits, from: a }), + } + } + if p.overfull + && keep.is_empty() + && best.is_empty() + && let Some(a) = last_removed + && let Some(n) = nodes.get(a) + { + // An overfull line ending here would leave the next line empty up to a forced break: + // end it at that forced break instead. Nor may it pass the hyphen limit: it ends at a + // later break. + let empty_next = !forced && s.items.get(s.next_start(b)).is_some_and(Item::is_forced); + let too_many_hyphens = flagged && limit > 0 && n.hyphens >= limit; + if empty_next || too_many_hyphens { + keep.push(a); + } else { + best.push(Candidate { + line: (n.line + 1).min(p.uniform_from), + fitness: 0, + hyphens: 0, + demerits: n.demerits + OVERFULL_DEMERITS, + from: a, + }); + } + } + std::mem::swap(&mut active, &mut keep); + if item.is_some_and(|i| i.kind == Kind::Glue) { + last_space = Some(b); + } else if forced { + last_space = None; + } + if best.is_empty() { + if active.is_empty() { + return None; + } + continue; + } + let start = s.next_start(b); + // Knuth's pruning: within a line class and hyphen state, a node more than the fitness + // demerits behind the best can never win. + for c in &best { + let min = best.iter().filter(|o| o.line == c.line && o.hyphens == c.hyphens).map(|o| o.demerits).fold(f64::INFINITY, f64::min); + if c.demerits > min + p.fitness_demerits { + continue; + } + let Some(prev) = nodes.get(c.from).copied() else { continue }; + nodes.push(Node { + pos: b, + start, + line: prev.line + 1, + fitness: Some(c.fitness), + demerits: c.demerits, + hyphens: if flagged { prev.hyphens + 1 } else { 0 }, + flagged, + prev: Some(c.from), + }); + active.push(nodes.len() - 1); + } + if active.len() > MAX_ACTIVE { + return None; + } + } + let end = if s.ends_forced() { m - 1 } else { m }; + let last = active + .iter() + .copied() + .filter(|&a| nodes.get(a).is_some_and(|n| n.pos == end && n.prev.is_some())) + .min_by(|&a, &b| nodes[a].demerits.total_cmp(&nodes[b].demerits))?; + let mut chain = Vec::new(); + let mut cur = Some(last); + while let Some(i) = cur { + let n = nodes.get(i)?; + if n.prev.is_some() { + chain.push(n.pos); + } + cur = n.prev; + } + chain.reverse(); + let mut start = 0; + let mut lines = Vec::with_capacity(chain.len()); + for (k, &b) in chain.iter().enumerate() { + let next = s.next_start(b); + lines.push(s.line(start, b, next, width(k), p, 0.0)); + start = next; + } + Some(lines) +} diff --git a/crates/text/src/linebreak/mod.rs b/crates/text/src/linebreak/mod.rs new file mode 100644 index 00000000..d34d50b2 --- /dev/null +++ b/crates/text/src/linebreak/mod.rs @@ -0,0 +1,637 @@ +//! Line breaking on Knuth and Plass's box/glue/penalty model, independent of fonts. +//! +//! A composer turns a paragraph's shaped glyphs into [`Item`]s (boxes for glyphs or words, glue +//! for spaces, penalties for other break opportunities), calls a breaker, and places the glyphs of +//! each returned [`Line`]. Widths are in the caller's units. +//! +//! - [`knuth_plass`]: one total-fit pass at [`Params::tolerance`]. +//! - [`break_paragraph`]: Knuth–Plass passes at rising tolerances, the last accepting overfull +//! lines, then [`greedy`] if no pass succeeds. [`Breaks::method`] says which produced the lines. +//! - [`greedy`] / [`greedy_with`]: first fit, each line takes as much as fits. +//! - [`balanced`]: the line count of [`break_paragraph`] with lines as even as possible. +//! - [`overlong`]: runs of boxes wider than the measure, where a composer may add +//! [`Item::emergency`] breaks. +//! +//! Elasticity comes in [`TIERS`] tiers, used in order: tier 0 (word spaces), then tier 1 (letter +//! spacing), then tier 2 (glyph scaling). Each tier is charged [`Params::tier_cost`] per unit of +//! its ratio, so a breaker prefers word-space changes, then letter spacing, then glyph scaling. +//! +//! Breakers take the measure of line `k` from `width(k)`. They clamp each measure to +//! `0.0..=f64::MAX` (NaN counts as 0) and treat lines from [`Params::uniform_from`] on as having +//! the same measure. + +mod greedy; +mod knuth_plass; +#[cfg(test)] +mod tests; + +use std::ops::Range; + +pub use greedy::{greedy, greedy_with}; +pub use knuth_plass::knuth_plass; + +/// Number of elasticity tiers: word space, letter space, glyph scale. +pub const TIERS: usize = 3; + +/// Stretch or shrink per tier. +pub type Tiers = [f64; TIERS]; + +/// A penalty at least this high forbids a break; one at most its negative forces a break. +pub const INFINITE_PENALTY: f64 = 10_000.0; + +/// Cost of an [`Item::emergency`] break: high, so a paragraph takes no more than it must, but legal. +pub const EMERGENCY_PENALTY: f64 = 5_000.0; + +/// What an [`Item`] is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Kind { + /// Fixed material (a glyph, a word). Never a break. + Box, + /// Elastic space. A break when it follows a box; discarded at the start of a line. + Glue, + /// A break opportunity with a cost; adds its width when a line ends there. + Penalty, +} + +/// Tier-0 stretch and shrink that a line gains at one of its ends (see [`Item::edge`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Edge { + pub stretch: f64, + pub shrink: f64, +} + +/// One item of a paragraph. +/// +/// Build items from a constructor ([`Item::boxed`], [`Item::glue`], [`Item::penalty`], …) and +/// override the optional fields with struct update syntax, such as +/// `Item { hang: 2.0, ..Item::glue(3.0, 1.5, 0.6) }`. Fields added later get neutral values in the +/// constructors, so code written this way keeps compiling. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Item { + pub kind: Kind, + /// Natural width (boxes, glue), or the width a line broken here gains (penalties: a hyphen). + pub width: f64, + /// Stretch per tier. Boxes may carry letter-spacing and glyph-scaling stretch. + pub stretch: Tiers, + /// Shrink per tier. + pub shrink: Tiers, + /// Glue: infinite stretch. A line holding fill glue gives it all its leftover space. + pub fill: bool, + /// Glue: the unit in which stretch past every tier is rated. Lines whose glue has none rate it + /// against their total stretch. + pub overflow: f64, + /// Penalty cost: [`INFINITE_PENALTY`] forbids the break, its negative forces it. + pub cost: f64, + /// Penalty: a flagged (hyphen) break. Consecutive flagged lines cost + /// [`Params::flagged_demerits`] and count toward [`Params::hyphen_limit`]. + pub flagged: bool, + /// Breaks: width a line ending here gives back (punctuation hanging into the margin). + /// Boxes: width a line starting with this box gives back. + pub hang: f64, + /// Tier-0 stretch and shrink that a line ending at this break (glue, penalties) or starting + /// with this box gains; negative values remove elasticity. The line's tier-0 totals are + /// clamped at zero. + pub edge: Edge, + /// Penalty: an automatic or discretionary hyphenation point, subject to + /// [`Rag::hyphenation_zone`]. + pub hyphenation: bool, + /// Penalty: a break inside a word wider than the measure. A justified line ending here that + /// must stretch is rated by its leftover space as a fraction of the measure, so the fullest + /// piece wins. + pub emergency: bool, +} + +const BLANK: Item = Item { + kind: Kind::Box, + width: 0.0, + stretch: [0.0; TIERS], + shrink: [0.0; TIERS], + fill: false, + overflow: 0.0, + cost: 0.0, + flagged: false, + hang: 0.0, + edge: Edge { stretch: 0.0, shrink: 0.0 }, + hyphenation: false, + emergency: false, +}; + +impl Item { + /// A box of `width`. + pub const fn boxed(width: f64) -> Item { + Item { width, ..BLANK } + } + /// Glue of natural `width` with tier-0 `stretch` and `shrink`. + pub const fn glue(width: f64, stretch: f64, shrink: f64) -> Item { + Item { kind: Kind::Glue, width, stretch: [stretch, 0.0, 0.0], shrink: [shrink, 0.0, 0.0], ..BLANK } + } + /// Glue of zero width and infinite stretch: ends a paragraph (before [`Item::forced`]) whose + /// last line is not justified. + pub const fn fill() -> Item { + Item { kind: Kind::Glue, fill: true, ..BLANK } + } + /// A break opportunity costing `cost`, adding `width` when taken. + pub const fn penalty(width: f64, cost: f64, flagged: bool) -> Item { + Item { kind: Kind::Penalty, width, cost, flagged, ..BLANK } + } + /// A forced break. + pub const fn forced() -> Item { + Item::penalty(0.0, -INFINITE_PENALTY, false) + } + /// A break that is never taken (keeps [`greedy`] from splitting two boxes). + pub const fn no_break() -> Item { + Item::penalty(0.0, INFINITE_PENALTY, false) + } + /// An emergency break inside an overlong word (see [`overlong`]). + pub const fn emergency() -> Item { + Item { emergency: true, ..Item::penalty(0.0, EMERGENCY_PENALTY, false) } + } + fn is_forced(&self) -> bool { + self.kind == Kind::Penalty && self.cost <= -INFINITE_PENALTY + } + fn is_flagged(&self) -> bool { + self.kind == Kind::Penalty && self.flagged + } +} + +/// Ragged (unjustified) setting. Spaces keep their width; a line is rated by its leftover space +/// against a rag stretch, as if every line ended with glue of that stretch. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rag { + /// Rag stretch of every line, in width units. + pub stretch: f64, + /// Rag stretch of every line, as a fraction of its measure (added to `stretch`). + pub width_fraction: f64, + /// A line ending at a forced break that is shorter than this fraction of its measure is rated + /// by the shortfall instead of its leftover space (0 = off). Such lines otherwise cost nothing. + pub short_last: f64, + /// The unit of that shortfall, as a fraction of the measure. + pub short_last_unit: f64, + /// Hyphenation zone: a [`Item::hyphenation`] break is not taken when breaking at the space + /// before its word would leave less than this much space at the end of the line (0 = off). + pub hyphenation_zone: f64, +} + +impl Default for Rag { + fn default() -> Self { + Rag { stretch: 0.0, width_fraction: 1.0 / 6.0, short_last: 0.0, short_last_unit: 1.0 / 3.0, hyphenation_zone: 0.0 } + } +} + +/// How lines are rated and chosen. +/// +/// Breakers clamp `flagged_demerits` and `fitness_demerits` to at least 0, with NaN counting as 0 +/// (negative demerits would let pruning drop the best candidate). Other fields are used as given. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Params { + /// Largest adjustment ratio of a line not ending at a forced break ([`knuth_plass`]). + pub tolerance: f64, + /// When no line reaches a break, accept the overfull line from the last node that could + /// ([`knuth_plass`]), so the pass always succeeds. + pub overfull: bool, + /// Added to every line's badness before squaring. + pub line_penalty: f64, + /// Added when two consecutive lines end at flagged breaks. + pub flagged_demerits: f64, + /// Added when neighbouring lines' fitness classes differ by more than one. + pub fitness_demerits: f64, + /// Upper ratio bounds of fitness classes 0 (tight, `r <` bound), 1 and 2 (`r <=` bound); + /// class 3 (very loose) is above. + pub fitness: [f64; 3], + /// Fitness class assumed before the first line; `None`: the first line pays no fitness demerits. + pub first_fitness: Option, + /// Most consecutive lines ending at flagged breaks (0 = unlimited). A hard constraint: a + /// Knuth–Plass pass that cannot meet it fails, and [`greedy`] breaks before the word instead. + pub hyphen_limit: usize, + /// Ratio charged per unit of each tier. + pub tier_cost: Tiers, + /// Badness cap (badness is `100·|r|³`, times the weight below). + pub max_badness: f64, + /// Badness multiplier of stretched lines. + pub stretch_weight: f64, + /// Badness multiplier of shrunk lines. [`greedy`] avoids shrinking when this exceeds + /// `stretch_weight`. + pub shrink_weight: f64, + /// A line longer than its measure by at most this fits exactly (rounded widths). + pub overrun: f64, + /// First line index from which `width(k)` no longer changes. [`knuth_plass`] keeps the ways + /// to reach a break apart by line number only for earlier lines; from here on they compete + /// directly, which keeps the number of active breaks small. + /// + /// The default 0 suits a constant measure. A caller whose measures vary (a first-line indent, + /// a drop cap, a shaped frame) must set it to the first index of the constant tail, such as 1 + /// for a first-line indent; with a smaller value the lines still get their own measures, but + /// the breaks may not be the best ones. Large values make long paragraphs slow and can exceed + /// the active-break limit, so that [`break_paragraph`] falls back to [`greedy`]. + pub uniform_from: usize, + /// Ragged setting; `None` justifies. + pub rag: Option, +} + +impl Default for Params { + fn default() -> Self { + Params { + tolerance: 2.5, + overfull: false, + line_penalty: 10.0, + flagged_demerits: 3000.0, + fitness_demerits: 3000.0, + fitness: [-0.5, 0.5, 1.0], + first_fitness: Some(1), + hyphen_limit: 0, + tier_cost: [1.0, 0.75, 1.0], + max_badness: f64::INFINITY, + stretch_weight: 1.0, + shrink_weight: 1.0, + overrun: 1e-6, + uniform_from: 0, + rag: None, + } + } +} + +impl Params { + /// These parameters with the clamps documented on [`Params`] applied. + fn sanitized(&self) -> Params { + let non_negative = |x: f64| if x.is_nan() { 0.0 } else { x.max(0.0) }; + Params { flagged_demerits: non_negative(self.flagged_demerits), fitness_demerits: non_negative(self.fitness_demerits), ..*self } + } +} + +/// A measure from a caller's `width`, clamped to `0.0..=f64::MAX` (NaN counts as 0). +fn clamp_measure(m: f64) -> f64 { + if m.is_nan() { 0.0 } else { m.clamp(0.0, f64::MAX) } +} + +/// One line chosen by a breaker. +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub struct Line { + /// First item on the line. + pub start: usize, + /// The break: items `start..end` are on the line. `items.len()` for a paragraph that does not + /// end with a forced break; a box's index where [`greedy`] split a word with no break in it. + pub end: usize, + /// First item of the next line (glue and penalties after the break are discarded). + pub next: usize, + /// Natural width: boxes and glue on the line, plus the break's penalty width, minus hangs. + pub width: f64, + /// The line's measure. + pub measure: f64, + /// Adjustment ratio as rated: positive stretches, negative shrinks. Each tier adds + /// [`Params::tier_cost`] per unit used, so a line that fits may shrink below −1; an overfull + /// line has a negative [`excess`](Self::excess) instead. + pub ratio: f64, + /// Fraction of each tier's stretch (positive) or shrink (negative) the line uses. + pub tiers: Tiers, + /// Width the tiers leave: taken by fill glue or the rag, stretch past every tier when + /// justified (spread over the glue's `overflow`, else its stretch), or the overfull amount + /// when negative. + pub excess: f64, + /// Ended at a flagged penalty. + pub flagged: bool, + /// Ended at a forced break or the paragraph end. + pub forced: bool, +} + +/// Runs of boxes, as item ranges, whose width fully shrunk exceeds `measure`: words no line can +/// hold. A composer adds [`Item::emergency`] breaks inside them where its script allows. +pub fn overlong(items: &[Item], measure: f64) -> Vec> { + let mut out = Vec::new(); + // (first box, last box, width) + let mut run: Option<(usize, usize, f64)> = None; + let mut close = |run: &mut Option<(usize, usize, f64)>| { + if let Some((first, last, w)) = run.take() + && w > measure + { + out.push(first..last + 1); + } + }; + for (k, it) in items.iter().enumerate() { + match it.kind { + Kind::Box => { + let w = it.width - it.shrink.iter().sum::(); + run = Some(run.map_or((k, k, w), |(first, _, acc)| (first, k, acc + w))); + } + Kind::Penalty if it.cost >= INFINITE_PENALTY => {} + Kind::Glue | Kind::Penalty => close(&mut run), + } + } + close(&mut run); + out +} + +/// Which breaker produced a paragraph's lines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Method { + /// The [`knuth_plass`] pass at `tolerances[pass]`. + KnuthPlass { pass: usize }, + /// [`greedy`], because every Knuth–Plass pass failed or there were none. + Greedy, +} + +/// Lines from [`break_paragraph`] or [`balanced`]. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct Breaks { + pub lines: Vec, + /// The breaker that produced `lines`. + pub method: Method, + /// [`balanced`] evened the lines. Always false from [`break_paragraph`]. + pub balanced: bool, +} + +/// [`knuth_plass`] at each of `tolerances` in turn, the last pass also accepting overfull lines; +/// [`greedy`] if every pass fails or `tolerances` is empty. The last pass fails only past the +/// active-break limit (see [`Params::uniform_from`]). +pub fn break_paragraph(items: &[Item], width: &dyn Fn(usize) -> f64, params: &Params, tolerances: &[f64]) -> Breaks { + for (pass, &tolerance) in tolerances.iter().enumerate() { + let overfull = params.overfull || pass + 1 == tolerances.len(); + if let Some(lines) = knuth_plass(items, width, &Params { tolerance, overfull, ..*params }) { + return Breaks { lines, method: Method::KnuthPlass { pass }, balanced: false }; + } + } + Breaks { lines: greedy(items, width, params), method: Method::Greedy, balanced: false } +} + +/// Paragraphs with more items than this are not balanced (each balancing step breaks the whole +/// paragraph again). +pub const MAX_BALANCED_ITEMS: usize = 6000; + +/// Balance lines: keep [`break_paragraph`]'s line count but make the lines as even as possible, +/// by breaking against the narrowest scaled measure that still gives that many lines. The lines +/// are measured against the real `width`. Meant for ragged text; justified text is balanced the +/// same way, so its lines stretch more. +/// +/// Returns [`break_paragraph`]'s lines with [`Breaks::balanced`] false when the paragraph has +/// fewer than two lines, a forced break before its last line, more than [`MAX_BALANCED_ITEMS`] +/// items, or when no narrower measure keeps the line count. +pub fn balanced(items: &[Item], width: &dyn Fn(usize) -> f64, params: &Params, tolerances: &[f64]) -> Breaks { + let base = break_paragraph(items, width, params, tolerances); + let n = base.lines.len(); + if n < 2 || items.len() > MAX_BALANCED_ITEMS || base.lines[..n - 1].iter().any(|l| l.forced) { + return base; + } + let attempt = |f: f64| -> Option { + let w = |j: usize| clamp_measure(width(j)) * f; + let b = break_paragraph(items, &w, params, tolerances); + (b.lines.len() == n && b.lines.iter().enumerate().all(|(j, l)| l.width <= w(j) + params.overrun)).then_some(b) + }; + let (mut lo, mut hi) = (0.3, 1.0); + let mut best = None; + for _ in 0..14 { + let mid = (lo + hi) / 2.0; + match attempt(mid) { + Some(lines) => { + best = Some(lines); + hi = mid; + } + None => lo = mid, + } + } + let Some(best) = best else { return base }; + let sums = Sums::new(items); + let p = params.sanitized(); + let lines = best.lines.iter().enumerate().map(|(j, l)| sums.line(l.start, l.end, l.next, clamp_measure(width(j)), &p, 0.0)).collect(); + Breaks { lines, method: best.method, balanced: true } +} + +/// How a line fits its measure. +#[derive(Clone, Copy, Debug)] +struct Fit { + width: f64, + ratio: f64, + tiers: Tiers, + excess: f64, + /// Fits with the shrink available. + feasible: bool, +} + +/// Prefix sums of a paragraph's items, for measuring any line in constant time. +struct Sums<'a> { + items: &'a [Item], + /// Before item `k`: width of boxes and glue, their stretch and shrink, glue overflow and the + /// number of fill glues. + w: Vec, + y: Vec, + z: Vec, + over: Vec, + fill: Vec, +} + +impl<'a> Sums<'a> { + fn new(items: &'a [Item]) -> Self { + let m = items.len(); + let (mut w, mut y, mut z, mut over, mut fill) = + (Vec::with_capacity(m + 1), Vec::with_capacity(m + 1), Vec::with_capacity(m + 1), Vec::with_capacity(m + 1), Vec::with_capacity(m + 1)); + let (mut sw, mut sy, mut sz, mut so, mut sf) = (0.0, [0.0; TIERS], [0.0; TIERS], 0.0, 0); + for it in items { + w.push(sw); + y.push(sy); + z.push(sz); + over.push(so); + fill.push(sf); + if it.kind != Kind::Penalty { + sw += it.width; + for t in 0..TIERS { + sy[t] += it.stretch[t]; + sz[t] += it.shrink[t]; + } + } + if it.kind == Kind::Glue { + so += it.overflow; + sf += u32::from(it.fill); + } + } + w.push(sw); + y.push(sy); + z.push(sz); + over.push(so); + fill.push(sf); + Sums { items, w, y, z, over, fill } + } + + fn len(&self) -> usize { + self.items.len() + } + + /// The paragraph ends with a forced penalty (else its end is an implicit forced break). + fn ends_forced(&self) -> bool { + self.items.last().is_some_and(Item::is_forced) + } + + /// Is `b` a legal break? `len()` is one unless the items end with a forced penalty. + fn is_break(&self, b: usize) -> bool { + match self.items.get(b) { + None => b == self.len() && !self.ends_forced(), + Some(it) => match it.kind { + Kind::Box => false, + Kind::Penalty => it.cost < INFINITE_PENALTY, + Kind::Glue => b > 0 && self.items.get(b - 1).is_some_and(|p| p.kind == Kind::Box), + }, + } + } + + fn forced(&self, b: usize) -> bool { + self.items.get(b).map_or(b >= self.len(), Item::is_forced) + } + + /// First item of the line after a break at `b`: glue and penalties are discarded up to the next + /// box or forced break. + fn next_start(&self, b: usize) -> usize { + let mut i = b + 1; + while let Some(it) = self.items.get(i) { + if it.kind == Kind::Box || it.is_forced() { + break; + } + i += 1; + } + i.min(self.len()) + } + + fn at(&self, k: usize) -> (f64, Tiers, Tiers, f64, u32) { + let k = k.min(self.len()); + ( + self.w.get(k).copied().unwrap_or(0.0), + self.y.get(k).copied().unwrap_or_default(), + self.z.get(k).copied().unwrap_or_default(), + self.over.get(k).copied().unwrap_or(0.0), + self.fill.get(k).copied().unwrap_or(0), + ) + } + + /// How items `start..end` fit `measure`, ending at `brk` (a glue or penalty whose hang, edge + /// elasticity and penalty width apply; `None` at the paragraph end or a split between boxes). + /// `extra` is added to the natural width (boxes whose width depends on position). + #[allow(clippy::too_many_arguments)] + fn fit(&self, start: usize, end: usize, brk: Option<&Item>, forced: bool, measure: f64, p: &Params, extra: f64) -> Fit { + let (w0, y0, z0, o0, f0) = self.at(start); + let (w1, y1, z1, o1, f1) = self.at(end); + let (start_hang, start_edge) = + self.items.get(start).filter(|i| i.kind == Kind::Box && end > start).map_or((0.0, Edge::default()), |b| (b.hang, b.edge)); + let (pw, end_hang, end_edge, emergency) = brk + .filter(|i| i.kind != Kind::Box) + .map_or((0.0, 0.0, Edge::default(), false), |i| (if i.kind == Kind::Penalty { i.width } else { 0.0 }, i.hang, i.edge, i.emergency)); + let width = w1 - w0 + pw - start_hang - end_hang + extra; + let mut y = [0.0; TIERS]; + let mut z = [0.0; TIERS]; + for t in 0..TIERS { + y[t] = y1[t] - y0[t]; + z[t] = z1[t] - z0[t]; + } + y[0] = (y[0] + start_edge.stretch + end_edge.stretch).max(0.0); + z[0] = (z[0] + start_edge.shrink + end_edge.shrink).max(0.0); + let mut d = measure - width; + if d < 0.0 && d > -p.overrun { + d = 0.0; + } + let free = |ratio: f64| Fit { width, ratio, tiers: [0.0; TIERS], excess: d, feasible: true }; + if d < 0.0 { + let (ratio, tiers, excess, feasible) = tiered(d, &z, 0.0, &p.tier_cost); + return Fit { width, ratio, tiers, excess, feasible }; + } + if let Some(rag) = p.rag { + if forced && rag.short_last > 0.0 { + let short = measure * rag.short_last - width; + let unit = measure * rag.short_last_unit; + return free(if short <= 0.0 { + 0.0 + } else if unit > 0.0 { + short / unit + } else { + f64::INFINITY + }); + } + if f1 > f0 { + return free(0.0); + } + let s = rag.stretch + rag.width_fraction * measure; + return free(if d <= 0.0 { + 0.0 + } else if s > 0.0 { + d / s + } else { + f64::INFINITY + }); + } + if f1 > f0 { + return free(0.0); + } + let (mut ratio, tiers, excess, feasible) = tiered(d, &y, o1 - o0, &p.tier_cost); + if emergency && ratio > 0.0 && measure > 0.0 { + ratio = d / measure; + } + Fit { width, ratio, tiers, excess, feasible } + } + + /// Measure the break at `b` for a line starting at `start` (`b == len()`: the paragraph end). + fn measure(&self, start: usize, b: usize, measure: f64, p: &Params) -> Fit { + self.fit(start, b, self.items.get(b), self.forced(b), measure, p, 0.0) + } + + fn line(&self, start: usize, end: usize, next: usize, measure: f64, p: &Params, extra: f64) -> Line { + let brk = self.items.get(end).filter(|i| i.kind != Kind::Box); + let forced = brk.map_or(end >= self.len(), Item::is_forced); + let f = self.fit(start, end, brk, forced, measure, p, extra); + Line { + start, + end, + next, + width: f.width, + measure, + ratio: f.ratio, + tiers: f.tiers, + excess: f.excess, + flagged: brk.is_some_and(Item::is_flagged), + forced, + } + } +} + +/// Adjustment ratio for `d` units of stretch (`d > 0`) or shrink (`d < 0`) from tier capacities +/// `cap`, used in tier order and charged `cost` per unit of each tier's ratio. Returns the ratio, +/// the fraction of each tier used (negative when shrinking), the width left over and whether the +/// line is feasible: shrinking past every tier is not. Stretch past every tier is rated in units +/// of `over`, or of the total stretch when `over` is zero. +fn tiered(d: f64, cap: &Tiers, over: f64, cost: &Tiers) -> (f64, Tiers, f64, bool) { + const EPS: f64 = 1e-9; + let mut used = [0.0; TIERS]; + if d.abs() < EPS { + return (0.0, used, d, true); + } + let sign = d.signum(); + let mut rem = d.abs(); + let mut r = 0.0; + for t in 0..TIERS { + if cap[t] > EPS && rem > EPS { + let take = rem.min(cap[t]); + r += cost[t] * take / cap[t]; + used[t] = sign * take / cap[t]; + rem -= take; + } + } + let excess = sign * rem; + if d < 0.0 { + return (-r, used, excess, rem <= 1e-6); + } + if rem > 1e-6 { + let unit = if over > EPS { over } else { cap.iter().sum::() }; + r = if unit > EPS { r + rem / unit } else { f64::INFINITY }; + } + (r, used, excess, true) +} + +/// Fitness class 0 (tight) … 3 (very loose) of ratio `r`. +fn fitness_class(r: f64, bounds: &[f64; 3]) -> u8 { + if r < bounds[0] { + 0 + } else if r <= bounds[1] { + 1 + } else if r <= bounds[2] { + 2 + } else { + 3 + } +} diff --git a/crates/text/src/linebreak/tests.rs b/crates/text/src/linebreak/tests.rs new file mode 100644 index 00000000..a9cc150b --- /dev/null +++ b/crates/text/src/linebreak/tests.rs @@ -0,0 +1,476 @@ +//! Breaker tests on a monospaced model: letters are 6 units wide, spaces 3, hyphens 4. Parameter +//! sets mirror vectorcraft's and designcraft's composers. + +use super::*; + +const CHAR: f64 = 6.0; +const SPACE: f64 = 3.0; +const HYPHEN: f64 = 4.0; + +const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \ +The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing and letter spacing, and adjusting the space \ +between pairs of letters."; + +/// A paragraph's items and the text each item stands for. +struct Para { + items: Vec, + text: Vec, +} + +impl Para { + /// A box per word and glue per space (elastic unless ragged); with `hyphen_cost`, a + /// hyphenation point every two letters inside words of six letters or more. Ends with fill and + /// a forced break. + fn new(text: &str, p: &Params, hyphen_cost: Option) -> Para { + let mut para = Para { items: Vec::new(), text: Vec::new() }; + for (i, word) in text.split(' ').enumerate() { + if i > 0 { + let glue = if p.rag.is_some() { Item::glue(SPACE, 0.0, 0.0) } else { Item::glue(SPACE, SPACE / 2.0, SPACE / 5.0) }; + para.push(glue, " "); + } + let chars: Vec = word.chars().collect(); + let points: Vec = match hyphen_cost { + Some(_) if chars.len() >= 6 => (2..chars.len() - 2).step_by(2).collect(), + _ => Vec::new(), + }; + let mut from = 0; + for &at in points.iter().chain([chars.len()].iter()) { + let piece: String = chars[from..at].iter().collect(); + para.push(Item::boxed(width_of(&piece)), &piece); + if at < chars.len() { + para.push(Item { hyphenation: true, ..Item::penalty(HYPHEN, hyphen_cost.unwrap_or(0.0), true) }, ""); + } + from = at; + } + } + para.push(Item::fill(), ""); + para.push(Item::forced(), ""); + para + } + + fn push(&mut self, item: Item, text: &str) { + self.items.push(item); + self.text.push(text.to_string()); + } + + fn lines(&self, lines: &[Line]) -> Vec { + lines + .iter() + .map(|l| { + let s: String = self.text[l.start..l.end.min(self.text.len())].concat(); + let s = s.trim_end().to_string(); + if l.flagged { s + "-" } else { s } + }) + .collect() + } +} + +fn width_of(s: &str) -> f64 { + s.chars().map(|c| if c == ' ' { SPACE } else { CHAR }).sum() +} + +fn vectorcraft(ragged: bool) -> Params { + Params { + fitness_demerits: if ragged { 1000.0 } else { 10_000.0 }, + fitness: if ragged { [0.15, 0.4, 0.75] } else { [-0.5, 0.5, 1.0] }, + first_fitness: None, + max_badness: 10_000.0, + rag: ragged.then_some(Rag { + stretch: 0.0, + width_fraction: 1.0 / 6.0, + short_last: 1.0 / 3.0, + short_last_unit: 1.0 / 3.0, + hyphenation_zone: 0.0, + }), + ..Params::default() + } +} + +/// vectorcraft's every-line composer: a strict pass, then a loose one, then single-line. +fn every_line(items: &[Item], width: f64, ragged: bool) -> Vec { + let p = vectorcraft(ragged); + let w = |_| width; + let loose = if ragged { f64::INFINITY } else { 4.0 }; + knuth_plass(items, &w, &Params { tolerance: 1.0, ..p }) + .or_else(|| knuth_plass(items, &w, &Params { tolerance: loose, ..p })) + .unwrap_or_else(|| greedy(items, &w, &p)) +} + +fn designcraft(ragged: bool) -> Params { + Params { + hyphen_limit: 3, + overrun: 0.01, + uniform_from: 0, + rag: ragged.then_some(Rag { stretch: 24.0, width_fraction: 0.0, ..Rag::default() }), + ..Params::default() + } +} + +const PASSES: [f64; 3] = [2.5, 12.0, f64::INFINITY]; + +/// Lines are contiguous and cover every item. +fn assert_covers(items: &[Item], lines: &[Line]) { + assert_eq!(lines.first().map(|l| l.start), Some(0)); + for pair in lines.windows(2) { + assert_eq!(pair[0].next, pair[1].start, "{lines:?}"); + } + assert_eq!(lines.last().map(|l| l.next), Some(items.len())); +} + +#[test] +fn tiers_are_used_in_order() { + let cost = Params::default().tier_cost; + // Word stretch alone. + let (r, used, _, ok) = tiered(5.0, &[10.0, 10.0, 10.0], 10.0, &cost); + assert!(ok && (r - 0.5).abs() < 1e-9 && used == [0.5, 0.0, 0.0]); + // Word saturated, half the letter tier. + let (r, used, _, _) = tiered(15.0, &[10.0, 10.0, 10.0], 10.0, &cost); + assert!((r - (1.0 + 0.5 * cost[1])).abs() < 1e-9 && used == [1.0, 0.5, 0.0]); + // Beyond every tier: unbounded word spacing. + let (r, _, excess, _) = tiered(40.0, &[10.0, 10.0, 10.0], 10.0, &cost); + assert!(r > 2.0 && (excess - 10.0).abs() < 1e-9); + // Shrink beyond the tiers is infeasible. + assert!(!tiered(-25.0, &[10.0, 10.0, 0.0], 0.0, &cost).3); + assert!(tiered(-15.0, &[10.0, 10.0, 0.0], 0.0, &cost).3); +} + +#[test] +fn lines_fit_and_cover_the_paragraph() { + let text = [COPY; 2].join(" "); + for ragged in [false, true] { + for hyphen in [None, Some(50.0)] { + let p = vectorcraft(ragged); + let para = Para::new(&text, &p, hyphen); + for (name, lines) in [("every-line", every_line(¶.items, 180.0, ragged)), ("greedy", greedy(¶.items, &|_| 180.0, &p))] { + assert!(lines.len() > 10, "{name}"); + assert_covers(¶.items, &lines); + for l in &lines { + assert!(l.excess >= -p.overrun, "{name} ragged={ragged} {l:?}"); + if ragged { + assert!(l.width <= 180.0 + 1e-6, "{name} {l:?}"); + } + } + assert!(lines.last().is_some_and(|l| l.forced)); + } + } + } +} + +/// Every-line breaking spreads the spacing more evenly than greedy breaking. +#[test] +fn every_line_is_more_even_than_greedy() { + let text = [COPY; 2].join(" "); + let p = vectorcraft(false); + let para = Para::new(&text, &p, None); + for width in [150.0, 190.0, 240.0] { + let worst = |lines: &[Line]| lines[..lines.len() - 1].iter().map(|l| l.ratio).fold(0.0, f64::max); + let (kp, gr) = (worst(&every_line(¶.items, width, false)), worst(&greedy(¶.items, &|_| width, &p))); + assert!(kp <= gr + 1e-9, "{width}: every-line worst ratio {kp} vs greedy {gr}"); + } +} + +/// Ragged: every-line breaks before a word that would fit, so the last line is not a lone short +/// word; greedy keeps filling the first line. +#[test] +fn every_line_balances_ragged_lines() { + const S: &str = "Destroy all creatures with toughness X or less."; + let width = width_of("Destroy all creatures with toughness X or") + 2.0; + let p = vectorcraft(true); + let para = Para::new(S, &p, None); + assert_eq!(para.lines(&greedy(¶.items, &|_| width, &p)), ["Destroy all creatures with toughness X or", "less."]); + assert_eq!(para.lines(&every_line(¶.items, width, true)), ["Destroy all creatures with toughness X", "or less."]); +} + +/// Every-line evens the rag of a long ragged paragraph: the leftover space is no worse than greedy +/// breaking, with at most one line more. Hyphenated, it trades some rag for fewer hyphens. +#[test] +fn every_line_evens_the_rag() { + let text = [COPY; 2].join(" "); + let p = vectorcraft(true); + for (width, hyphen) in [(150.0, None), (190.0, None), (240.0, Some(25.0)), (310.0, None)] { + let para = Para::new(&text, &p, hyphen); + let rag = |lines: &[Line]| { + for l in lines { + assert!(l.width <= width + 1e-6, "{width}: {l:?}"); + } + let slack = lines[..lines.len() - 1].iter().map(|l| (width - l.width).powi(2)).sum::(); + (slack, lines.len(), lines.iter().filter(|l| l.flagged).count()) + }; + let ((kp, kp_n, kp_hy), (gr, gr_n, gr_hy)) = (rag(&every_line(¶.items, width, true)), rag(&greedy(¶.items, &|_| width, &p))); + if hyphen.is_some() { + assert!(kp_hy < gr_hy, "{width}: {kp_hy} hyphens vs greedy {gr_hy}"); + } else { + assert!(kp <= gr + 1e-6, "{width}: rag {kp} vs greedy {gr}"); + } + assert!(kp_n <= gr_n + 1, "{width}: {kp_n} lines vs {gr_n}"); + } +} + +/// Greedy: each line takes as many words as fit. +#[test] +fn greedy_fills_each_line() { + let width = 190.0; + let p = vectorcraft(true); + let para = Para::new(COPY, &p, None); + let lines = para.lines(&greedy(¶.items, &|_| width, &p)); + for pair in lines.windows(2) { + let next_word = pair[1].split(' ').next().unwrap_or_default(); + assert!(width_of(&format!("{} {next_word}", pair[0])) > width, "{pair:?}"); + } +} + +#[test] +fn hyphen_limit_is_a_hard_constraint() { + let text = [COPY; 4].join(" "); + for limit in [1, 2, 9] { + let p = Params { hyphen_limit: limit, ..designcraft(false) }; + let para = Para::new(&text, &p, Some(0.0)); + for lines in [break_paragraph(¶.items, &|_| 130.0, &p, &PASSES).lines, greedy(¶.items, &|_| 130.0, &p)] { + let mut run = 0; + for l in &lines { + run = if l.flagged { run + 1 } else { 0 }; + assert!(run <= limit, "{run} consecutive hyphens with limit {limit}"); + } + assert!(lines.iter().any(|l| l.flagged)); + } + } +} + +#[test] +fn hyphenation_zone_limits_ragged_hyphens() { + let text = [COPY; 4].join(" "); + let measure = 110.0; + let count = |zone: f64, every_line: bool| { + let base = designcraft(true); + let p = Params { rag: base.rag.map(|r| Rag { hyphenation_zone: zone, ..r }), ..base }; + let para = Para::new(&text, &p, Some(0.0)); + let lines = if every_line { break_paragraph(¶.items, &|_| measure, &p, &PASSES).lines } else { greedy(¶.items, &|_| measure, &p) }; + // A hyphenated line leaves at least the zone after the last space before its last word. + for l in lines.iter().filter(|l| l.flagged) { + if let Some(space) = (l.start..l.end).rfind(|&k| para.items[k].kind == Kind::Glue) { + let before: f64 = para.items[l.start..space].iter().filter(|i| i.kind != Kind::Penalty).map(|i| i.width).sum(); + assert!(measure - before >= zone, "every_line={every_line} zone {zone}: {:?}", para.lines(&[*l])); + } + } + lines.iter().filter(|l| l.flagged).count() + }; + for every_line in [true, false] { + let (free, zoned, huge) = (count(0.0, every_line), count(36.0, every_line), count(1000.0, every_line)); + // A huge zone leaves only words that start a line hyphenable. + assert!(free > 0 && zoned > 0 && huge * 4 <= free, "every_line={every_line}: {free} {zoned} {huge}"); + } +} + +#[test] +fn balanced_lines_even_a_headline() { + let p = Params { rag: Some(Rag { stretch: 24.0, width_fraction: 0.0, ..Rag::default() }), ..designcraft(true) }; + let para = Para::new("Balanced headlines read better than a stub", &p, None); + let w = |_| 150.0; + let plain = break_paragraph(¶.items, &w, &p, &PASSES).lines; + let bal = balanced(¶.items, &w, &p, &PASSES); + assert!(bal.balanced); + let bal = bal.lines; + assert!(plain.len() >= 2 && plain.len() == bal.len(), "{:?} vs {:?}", para.lines(&plain), para.lines(&bal)); + let spread = |lines: &[Line]| lines.iter().map(|l| l.width).fold(f64::MIN, f64::max) - lines.iter().map(|l| l.width).fold(f64::MAX, f64::min); + assert!(spread(&bal) < spread(&plain), "{:?} vs {:?}", para.lines(&bal), para.lines(&plain)); + assert!(bal.iter().all(|l| l.measure == 150.0)); +} + +/// A word wider than the measure breaks at its emergency points, or, for greedy without them, +/// before the box that overflows. +#[test] +fn an_overlong_word_breaks_at_the_measure() { + let build = |emergency: bool, ragged: bool| { + let p = designcraft(ragged); + let glue = if ragged { Item::glue(SPACE, 0.0, 0.0) } else { Item::glue(SPACE, SPACE / 2.0, SPACE / 5.0) }; + let mut items = vec![Item::boxed(4.0 * CHAR), glue]; + for i in 0..60 { + if i > 0 && emergency { + items.push(Item::emergency()); + } + items.push(Item::boxed(CHAR)); + } + items.extend([glue, Item::boxed(3.0 * CHAR), glue, Item::boxed(2.0 * CHAR), Item::fill(), Item::forced()]); + (items, p) + }; + let (plain, _) = build(false, false); + assert_eq!(overlong(&plain, 72.0), vec![2..62]); + for ragged in [false, true] { + let (items, p) = build(true, ragged); + let (no_points, _) = build(false, ragged); + for (name, lines) in [ + ("every-line", break_paragraph(&items, &|_| 72.0, &p, &PASSES).lines), + ("greedy", greedy(&items, &|_| 72.0, &p)), + ("greedy, no emergency points", greedy(&no_points, &|_| 72.0, &p)), + ] { + assert!(lines.len() >= 6, "{name} ragged={ragged}: {lines:?}"); + for l in &lines { + assert!(l.width <= 72.0 + 0.01, "{name} ragged={ragged}: {l:?}"); + } + } + } +} + +#[test] +fn forced_breaks_end_lines_and_the_paragraph_end_is_one() { + let p = designcraft(false); + // No forced penalty at the end: the paragraph end is the last break. + let items = [Item::boxed(30.0), Item::glue(SPACE, 1.5, 0.6), Item::boxed(30.0), Item::fill(), Item::forced(), Item::boxed(30.0)]; + for lines in [break_paragraph(&items, &|_| 100.0, &p, &PASSES).lines, greedy(&items, &|_| 100.0, &p)] { + assert_eq!(lines.iter().map(|l| (l.start, l.end, l.forced)).collect::>(), [(0, 4, true), (5, 6, true)]); + // The last line is justified: it has no fill and nothing to stretch. + assert!(lines[1].ratio.is_infinite()); + } + // Empty paragraph: one empty line. + for lines in [break_paragraph(&[], &|_| 100.0, &p, &PASSES).lines, greedy(&[], &|_| 100.0, &p)] { + assert_eq!(lines.iter().map(|l| (l.start, l.end, l.next)).collect::>(), [(0, 0, 0)]); + } +} + +/// The measure is per line: a first-line indent narrows line 0 only. +#[test] +fn each_line_gets_its_own_measure() { + let p = Params { uniform_from: 1, ..vectorcraft(true) }; + let para = Para::new(COPY, &p, None); + let w = |k: usize| if k == 0 { 120.0 } else { 200.0 }; + for lines in [break_paragraph(¶.items, &w, &p, &[1.0, f64::INFINITY]).lines, greedy(¶.items, &w, &p)] { + for (k, l) in lines.iter().enumerate() { + assert_eq!(l.measure, w(k)); + assert!(l.width <= w(k) + 1e-6, "{k}: {l:?}"); + } + assert!(lines[1].width > 120.0); + } +} + +/// Punctuation hanging into the margin gives its width back: the line fits with the word after it. +#[test] +fn a_hanging_break_gives_its_width_back() { + let p = designcraft(true); + let items = |hang: f64| { + [ + Item::boxed(60.0), + Item::glue(5.0, 0.0, 0.0), + Item::boxed(40.0), + Item { hang, ..Item::glue(5.0, 0.0, 0.0) }, + Item::boxed(30.0), + Item::fill(), + Item::forced(), + ] + }; + for (hang, end) in [(0.0, 1), (5.0, 3)] { + let items = items(hang); + for lines in [break_paragraph(&items, &|_| 100.0, &p, &PASSES).lines, greedy(&items, &|_| 100.0, &p)] { + assert_eq!(lines[0].end, end, "hang {hang}: {lines:?}"); + } + } +} + +/// Justification uses word spacing first, then letter spacing. +#[test] +fn justification_uses_tiers_in_order() { + let p = designcraft(false); + let letters = Item { stretch: [0.0, 10.0, 0.0], ..Item::boxed(40.0) }; + let items = [letters, Item::glue(10.0, 6.0, 0.0), letters, Item::forced()]; + let lines = break_paragraph(&items, &|_| 100.0, &p, &PASSES).lines; + let l = lines[0]; + assert_eq!(l.tiers, [1.0, 0.2, 0.0]); + assert!((l.ratio - (1.0 + 0.2 * p.tier_cost[1])).abs() < 1e-9, "{l:?}"); +} + +/// Letter spacing and glyph scaling absorb what word spacing alone cannot in a narrow column. +#[test] +fn letter_and_glyph_tiers_ease_a_narrow_column() { + let worst = |box_stretch: f64| { + let p = designcraft(false); + let mut para = Para::new(COPY, &p, Some(275.0)); + for it in para.items.iter_mut().filter(|i| i.kind == Kind::Box) { + it.stretch = [0.0, it.width * box_stretch, it.width * box_stretch]; + it.shrink = [0.0, it.width * box_stretch / 2.0, it.width * box_stretch / 2.0]; + } + let lines = break_paragraph(¶.items, &|_| 100.0, &p, &PASSES).lines; + lines[..lines.len() - 1].iter().map(|l| l.ratio).fold(0.0, f64::max) + }; + let (words, all) = (worst(0.0), worst(0.03)); + assert!(all < words, "{all} vs {words}"); +} + +/// A long ragged paragraph with a constant measure breaks by Knuth–Plass under the default +/// parameters and effectcraft's infinitely tolerant rag, without reaching the active-break limit. +#[test] +fn a_long_ragged_paragraph_stays_on_knuth_plass() { + let mut seed = 1u32; + let mut items = Vec::new(); + for i in 0..5000 { + if i > 0 { + items.push(Item::glue(1.0, 0.0, 0.0)); + } + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + items.push(Item::boxed(f64::from(3 + (seed >> 16) % 5))); + } + items.extend([Item::fill(), Item::forced()]); + let default = Params { rag: Some(Rag::default()), ..Params::default() }; + let effectcraft = Params { rag: Some(Rag { width_fraction: 1.0, ..Rag::default() }), ..Params::default() }; + for (p, tolerances) in [(default, &[2.5][..]), (effectcraft, &[f64::INFINITY][..])] { + let b = break_paragraph(&items, &|_| 60.0, &p, tolerances); + assert_eq!(b.method, Method::KnuthPlass { pass: 0 }, "{p:?}"); + assert_covers(&items, &b.lines); + } +} + +/// A NaN measure counts as 0 and negative fitness demerits as 0. +#[test] +fn invalid_params_are_clamped() { + let p = designcraft(false); + let para = Para::new(COPY, &p, None); + let lines = break_paragraph(¶.items, &|_| f64::NAN, &p, &PASSES).lines; + assert_eq!(lines, break_paragraph(¶.items, &|_| 0.0, &p, &PASSES).lines); + assert!(lines.iter().all(|l| l.measure == 0.0 && !l.excess.is_nan())); + let negative = Params { fitness_demerits: -5000.0, ..p }; + let zero = Params { fitness_demerits: 0.0, ..p }; + assert_eq!(break_paragraph(¶.items, &|_| 150.0, &negative, &PASSES), break_paragraph(¶.items, &|_| 150.0, &zero, &PASSES)); +} + +/// Two consecutive breaks: the line after the first starts past the second, which must not end a +/// line `4..3` from there. +#[test] +fn lines_never_end_before_they_start() { + let items = [ + Item::boxed(10.0), + Item::glue(1.0, 0.0, 0.0), + Item::penalty(0.0, -1000.0, false), + Item::penalty(0.0, -1000.0, false), + Item::boxed(10.0), + Item::fill(), + Item::forced(), + ]; + let p = Params { rag: Some(Rag { width_fraction: 1.0, ..Rag::default() }), ..Params::default() }; + let lines = break_paragraph(&items, &|_| 11.0, &p, &[2.5, f64::INFINITY]).lines; + assert!(lines.iter().all(|l| l.start <= l.end && l.end <= l.next), "{lines:?}"); + assert_covers(&items, &lines); +} + +fn spans(lines: &[Line]) -> Vec<(usize, usize)> { + lines.iter().map(|l| (l.start, l.end)).collect() +} + +/// A final word wider than the measure is one overfull line: the fill before the forced break +/// doesn't end it and leave an empty line. Consecutive forced breaks still make an empty line. +#[test] +fn an_overlong_last_word_adds_no_empty_line() { + let fill = [Item::boxed(20.0), Item::fill(), Item::forced()]; + assert_eq!(spans(&break_paragraph(&fill, &|_| 11.0, &Params::default(), &[2.5, f64::INFINITY]).lines), [(0, 2)]); + let forced = [Item::boxed(20.0), Item::forced(), Item::forced()]; + assert_eq!(spans(&break_paragraph(&forced, &|_| 11.0, &Params::default(), &[2.5, f64::INFINITY]).lines), [(0, 1), (2, 2)]); +} + +/// Overfull lines keep the hyphen limit too: with a limit of 1, no two consecutive lines end at a +/// hyphen even when every line is overfull. +#[test] +fn overfull_lines_keep_the_hyphen_limit() { + let items = + [Item::boxed(10.0), Item::penalty(1.0, 0.0, true), Item::boxed(10.0), Item::penalty(1.0, 0.0, true), Item::boxed(10.0), Item::forced()]; + let p = Params { hyphen_limit: 1, ..Params::default() }; + let lines = break_paragraph(&items, &|_| 5.0, &p, &[2.5, f64::INFINITY]).lines; + assert!(lines.windows(2).all(|w| !(w[0].flagged && w[1].flagged)), "{lines:?}"); + assert_eq!(spans(&lines), [(0, 1), (2, 5)]); +} diff --git a/crates/text/src/segment/complex.rs b/crates/text/src/segment/complex.rs new file mode 100644 index 00000000..57a625a2 --- /dev/null +++ b/crates/text/src/segment/complex.rs @@ -0,0 +1,50 @@ +//! Word breaks inside Thai, Lao, Khmer and Burmese runs. UAX #14 gives these scripts class SA +//! ("complex context") and leaves breaking inside them to a dictionary; ICU4X's segmenter supplies +//! it, with its dictionaries or, with the `lstm` feature, its LSTM models. Only runs of the scripts +//! whose language feature is on are segmented. + +use icu_segmenter::options::LineBreakOptions; +use icu_segmenter::{LineSegmenter, LineSegmenterBorrowed}; +use unicode_linebreak::{BreakClass, break_property}; + +/// Builds ICU4X's line segmenter with one model for the complex scripts. The models are static +/// data; building the segmenter only looks them up. +pub(super) type Model = fn(LineBreakOptions<'static>) -> LineSegmenterBorrowed<'static>; + +/// The model `line_breaks` uses: the LSTM models with the `lstm` feature, else the dictionaries. +#[cfg(feature = "lstm")] +pub(super) const MODEL: Model = LineSegmenter::new_lstm; +#[cfg(not(feature = "lstm"))] +pub(super) const MODEL: Model = LineSegmenter::new_dictionary; + +/// Byte offsets inside `text` where a run of an enabled script may break between words, in order. +/// Run starts and ends are left to UAX #14. +pub(super) fn word_breaks(text: &str, model: Model) -> Vec { + let mut out = Vec::new(); + let mut segmenter = None; + let mut run_start = None; + let ends = text.char_indices().map(|(i, c)| (i, segmented(c))).chain(std::iter::once((text.len(), false))); + for (i, inside) in ends { + match (run_start, inside) { + (None, true) => run_start = Some(i), + (Some(start), false) => { + run_start = None; + let Some(run) = text.get(start..i) else { continue }; + let seg = *segmenter.get_or_insert_with(|| model(LineBreakOptions::default())); + out.extend(seg.segment_str(run).filter(|&o| o > 0 && o < run.len()).map(|o| start + o)); + } + _ => {} + } + } + out +} + +/// A character of an enabled script that UAX #14 leaves to the word segmenter. +fn segmented(c: char) -> bool { + let u = c as u32; + let enabled = (cfg!(feature = "thai") && matches!(u, 0x0E00..=0x0E7F)) + || (cfg!(feature = "lao") && matches!(u, 0x0E80..=0x0EFF)) + || (cfg!(feature = "khmer") && matches!(u, 0x1780..=0x17FF | 0x19E0..=0x19FF)) + || (cfg!(feature = "burmese") && matches!(u, 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F)); + enabled && break_property(u) == BreakClass::ComplexContext +} diff --git a/crates/text/src/segment/kinsoku.rs b/crates/text/src/segment/kinsoku.rs new file mode 100644 index 00000000..c4d52ba0 --- /dev/null +++ b/crates/text/src/segment/kinsoku.rs @@ -0,0 +1,119 @@ +//! Kinsoku character sets (JLREQ 3.1.7–3.1.8, CLREQ 3.1.7). The sets are VectorCraft's, with the +//! additions listed in the crate README. + +use super::Kinsoku; + +/// Whether kinsoku lets a line break between `before` and `after` (`None`: the end of the +/// paragraph). `Hard` keeps every character of the set off the line start and end; `Soft` lets +/// 々, ー and small kana start a line; `None` allows every break. Breaks with no East Asian +/// character on either side are always allowed, so Latin text keeps its own rules. +pub fn kinsoku_allows(before: char, after: Option, mode: Kinsoku) -> bool { + if mode == Kinsoku::None || (!is_east_asian(before) && !after.is_some_and(is_east_asian)) { + return true; + } + let start_ok = |c: char| !no_line_start(c) || (mode == Kinsoku::Soft && soft_line_start(c)); + !no_line_end(before) && after.is_none_or(start_ok) +} + +/// East Asian text for kinsoku: Han, kana, bopomofo, Hangul syllables and compatibility jamo, CJK +/// punctuation and symbols, full-width and half-width forms. Not the ideographic space, conjoining +/// jamo or the (combining and spacing) kana sound marks, which never take a break before them. +pub(crate) fn is_east_asian(c: char) -> bool { + matches!(c as u32, + 0x2E80..=0x2FFF // radicals, ideographic description + | 0x3001..=0x3096 // CJK symbols and punctuation, hiragana + | 0x309D..=0x30FF // hiragana iteration marks, katakana + | 0x3100..=0x312F // bopomofo + | 0x3131..=0x318F // Hangul compatibility jamo + | 0x3190..=0x33FF // kanbun, strokes, small katakana, enclosed and compatibility forms + | 0x3400..=0x4DBF // CJK extension A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xAC00..=0xD7A3 // Hangul syllables + | 0xF900..=0xFAFF // compatibility ideographs + | 0xFE30..=0xFE4F // CJK compatibility forms + | 0xFF01..=0xFF9D // full-width forms, half-width katakana + | 0xFFA0..=0xFFEF // half-width Hangul, full-width signs + | 0x20000..=0x3FFFF) // supplementary ideographs +} + +/// A character that can't start a line: closing brackets and quotes, hyphens, dividing +/// punctuation, middle dots, colons, full stops and commas, iteration marks, the prolonged sound +/// mark, small kana, kana sound marks and postfixed abbreviations (JLREQ cl-02 to cl-07, cl-09 to +/// cl-11, cl-13). +pub(crate) fn no_line_start(c: char) -> bool { + matches!(c, + ')' | ']' | '}' | ',' | '.' | ':' | ';' | '!' | '?' | '»' | '’' | '”' | '‐' | '–' | '‼' | '⁇' | '⁈' | '⁉' + | '、' | '。' | '〉' | '》' | '」' | '』' | '】' | '〕' | '〗' | '〙' | '〛' | '〞' | '〟' | '〜' | '゠' | '・' | '⦆' + | 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '々' | '〻' | '゛' | '゜' + | 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ' + | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' | 'ㇰ'..='ㇿ' + | '!' | '%' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ' + | '゙' | '゚' | '¢') +} + +/// A character of [`no_line_start`] that may start a line under [`Kinsoku::Soft`]: 々, the +/// prolonged sound mark ー (JLREQ cl-10) and small kana (cl-11), as JLREQ's level 3 rules allow +/// (Appendix C.3, ). Half-width forms aren't in those +/// classes. +pub(crate) fn soft_line_start(c: char) -> bool { + matches!( + c, + '々' | 'ー' + | 'ぁ' + | 'ぃ' + | 'ぅ' + | 'ぇ' + | 'ぉ' + | 'っ' + | 'ゃ' + | 'ゅ' + | 'ょ' + | 'ゎ' + | 'ゕ' + | 'ゖ' + | 'ァ' + | 'ィ' + | 'ゥ' + | 'ェ' + | 'ォ' + | 'ッ' + | 'ャ' + | 'ュ' + | 'ョ' + | 'ヮ' + | 'ヵ' + | 'ヶ' + | 'ㇰ'..='ㇿ' + ) +} + +/// A character that can't end a line: opening brackets and quotes, and prefixed abbreviations +/// (JLREQ cl-01, cl-12). +pub(crate) fn no_line_end(c: char) -> bool { + matches!( + c, + '(' | '[' + | '{' + | '«' + | '‘' + | '“' + | '〈' + | '《' + | '「' + | '『' + | '【' + | '〔' + | '〖' + | '〘' + | '〚' + | '〝' + | '(' + | '[' + | '{' + | '⦅' + | '「' + | '$' + | '£' + | '¥' + ) +} diff --git a/crates/text/src/segment/mod.rs b/crates/text/src/segment/mod.rs new file mode 100644 index 00000000..ebfbd420 --- /dev/null +++ b/crates/text/src/segment/mod.rs @@ -0,0 +1,164 @@ +//! Line-break opportunities for text layout: where a composer may end a line, as byte offsets into +//! the paragraph. Font-independent; measuring and choosing among the breaks stays in the composer. +//! +//! [`line_breaks`] starts from the Unicode line-breaking algorithm (UAX #14, through +//! [`unicode_linebreak`]) and adjusts it: +//! - **Kinsoku** ([`Kinsoku`]): the Japanese and Chinese line-start and line-end rules (JLREQ, +//! CLREQ). Closing brackets, commas, full stops, small kana and the like don't start a line; +//! opening brackets don't end one. The rules apply only next to East Asian characters, so text +//! without them gets UAX #14's breaks unchanged. +//! - **Korean words** ([`Options::korean_words`]): Korean set word by word, breaking at spaces. +//! - **Thai, Lao, Khmer and Burmese**: UAX #14 leaves these scripts (class SA) to a dictionary and +//! never breaks inside a run of them. With the language feature (`thai`, `lao`, `khmer`, +//! `burmese`) the run is split into words by ICU4X's segmenter: its dictionaries by default, its +//! much smaller LSTM models with the `lstm` feature. +//! +//! [`Options::for_language`] picks the options for a paragraph's language. When no opportunity +//! leaves a line short enough, [`emergency_breaks`] gives the places to break a word anyway: +//! between grapheme clusters, never inside one. +//! +//! Composers that build their own opportunity lists can filter them with [`kinsoku_allows`]. + +#[cfg(any(feature = "thai", feature = "lao", feature = "khmer", feature = "burmese"))] +mod complex; +mod kinsoku; +#[cfg(test)] +mod tests; + +use std::ops::Range; + +use unicode_linebreak::{BreakClass, BreakOpportunity, break_property}; + +pub use kinsoku::kinsoku_allows; +use kinsoku::{is_east_asian, soft_line_start}; +use unicode_segmentation::UnicodeSegmentation; + +/// Which kinsoku (line-start and line-end) rules apply. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Kinsoku { + /// No kinsoku: a line may also break between any two East Asian characters, punctuation + /// included. + None, + /// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small kana + /// and the like don't start a line; opening brackets don't end one. + #[default] + Hard, + /// As `Hard`, except that 々, the prolonged sound mark ー and small kana may start a line + /// (JLREQ's relaxed rules, Appendix C.3). + Soft, +} + +/// How [`line_breaks`] adjusts UAX #14. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Options { + pub kinsoku: Kinsoku, + /// Set Korean word by word (KLREQ): no break between two Hangul letters or Han ideographs + /// (hanja), only at spaces and punctuation. For Korean-language text only, since Han + /// ideographs count as Korean letters. Off, Korean breaks between syllables as UAX #14 does. + pub korean_words: bool, +} + +impl Options { + /// The options for text in `language`, a BCP 47 tag such as `ja` or `ko-KR` (`None`: unknown). + /// Korean is set word by word; every other language, and an unknown one, gets the defaults. + /// Thai, Lao, Khmer and Burmese words are found by script, whatever the language. + pub fn for_language(language: Option<&str>) -> Self { + let primary = language.and_then(|tag| tag.split(['-', '_']).next()).unwrap_or_default(); + Self { korean_words: primary.eq_ignore_ascii_case("ko"), ..Self::default() } + } +} + +/// A place where a line may end: the next line starts at byte `offset`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub struct Break { + pub offset: usize, + /// The line must end here (after a newline or another hard break, and at the end of the text). + pub mandatory: bool, +} + +/// Line-break opportunities in `text`, in order. The last one is the end of the text (mandatory); +/// empty text has none. +pub fn line_breaks(text: &str, options: &Options) -> Vec { + #[cfg(any(feature = "thai", feature = "lao", feature = "khmer", feature = "burmese"))] + let words = complex::word_breaks(text, complex::MODEL); + #[cfg(not(any(feature = "thai", feature = "lao", feature = "khmer", feature = "burmese")))] + let words = Vec::new(); + breaks_with_words(text, options, words) +} + +/// [`line_breaks`], given the word breaks inside Thai, Lao, Khmer and Burmese runs (sorted). +fn breaks_with_words(text: &str, options: &Options, words: Vec) -> Vec { + if text.is_empty() { + return Vec::new(); + } + let mut uax = unicode_linebreak::linebreaks(text).peekable(); + let mut words = words.into_iter().peekable(); + let mut out = Vec::new(); + // The last character before the boundary that isn't a combining mark: kinsoku looks at the + // character a mark belongs to. + let mut base: Option = None; + let mut after_mark = false; + for (i, c) in text.char_indices() { + if i > 0 { + // Both lists are sorted by offset: take the entry for `i` from each, if it has one. + let uax_here = uax.next_if(|&(o, _)| o == i).map(|(_, op)| op); + let word = words.next_if(|&o| o == i).is_some(); + let allowed = uax_here.is_some() || word; + let brk = if uax_here == Some(BreakOpportunity::Mandatory) { + Some(true) + } else { + adjust(allowed, base, after_mark, c, options).then_some(false) + }; + if let Some(mandatory) = brk { + out.push(Break { offset: i, mandatory }); + } + } + after_mark = is_mark(c); + if !after_mark { + base = Some(c); + } + } + out.push(Break { offset: text.len(), mandatory: true }); + out +} + +/// Emergency breaks for `text[range]`, a piece between two [`line_breaks`] that is too long for a +/// line: byte offsets into `text`, strictly inside `range` and in order, between extended grapheme +/// clusters (UAX #29). An emoji ZWJ sequence, a flag or a letter with its marks stays whole. Empty +/// if `range` isn't on char boundaries of `text`. +pub fn emergency_breaks(text: &str, range: Range) -> Vec { + let Some(piece) = text.get(range.clone()) else { return Vec::new() }; + piece.grapheme_indices(true).skip(1).map(|(o, _)| range.start + o).collect() +} + +/// Whether a line may break between `before` and `after`, given whether UAX #14 (or the word +/// segmenter) allows it. `after_mark`: the character right before the boundary is a combining +/// mark, joiner or variation selector that belongs to `before`. +fn adjust(allowed: bool, before: Option, after_mark: bool, after: char, options: &Options) -> bool { + let Some(before) = before else { return allowed }; + // The East Asian breaks UAX #14 lacks go only between two base characters: never inside a + // cluster, so not after a joiner or before a mark. + let east_asian_pair = !after_mark && is_east_asian(before) && is_east_asian(after) && !is_mark(after); + let allowed = match options.kinsoku { + Kinsoku::None => allowed || east_asian_pair, + // UAX #14 keeps small kana, ー and 々 (classes CJ and NS) off the line start. + Kinsoku::Soft => (allowed || (east_asian_pair && soft_line_start(after))) && kinsoku_allows(before, Some(after), Kinsoku::Soft), + Kinsoku::Hard => allowed && kinsoku_allows(before, Some(after), Kinsoku::Hard), + }; + allowed && !(options.korean_words && is_korean_letter(before) && is_korean_letter(after)) +} + +/// A combining mark, variation selector or joiner (UAX #14 classes CM and ZWJ): it belongs to the +/// character before it. +fn is_mark(c: char) -> bool { + matches!(break_property(c as u32), BreakClass::CombiningMark | BreakClass::ZeroWidthJoiner) +} + +/// Hangul in every form, and Han ideographs (read as hanja). +fn is_korean_letter(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11FF | 0x3131..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC + | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3FFFF) +} diff --git a/crates/text/src/segment/tests.rs b/crates/text/src/segment/tests.rs new file mode 100644 index 00000000..d1d2a0f4 --- /dev/null +++ b/crates/text/src/segment/tests.rs @@ -0,0 +1,242 @@ +use super::kinsoku::{no_line_end, no_line_start}; +use super::*; + +fn opts(kinsoku: Kinsoku) -> Options { + Options { kinsoku, ..Options::default() } +} + +/// Byte offsets of the breaks before the end of the text. +fn offsets(text: &str, options: &Options) -> Vec { + line_breaks(text, options).iter().map(|b| b.offset).filter(|&o| o < text.len()).collect() +} + +/// The text from each break before the end to the next break. +fn pieces(text: &str, options: &Options) -> Vec { + let mut from = 0; + let mut out = Vec::new(); + for b in line_breaks(text, options) { + out.push(text.get(from..b.offset).unwrap_or_default().to_string()); + from = b.offset; + } + out +} + +/// The character each line after the first could start with. +fn starts(text: &str, options: &Options) -> Vec { + offsets(text, options).iter().filter_map(|&o| text.get(o..).and_then(|t| t.chars().next())).collect() +} + +#[test] +fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() { + for (a, b) in + [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅'), ('字', '〞'), ('1', '%'), ('$', '1')] + { + assert!(!kinsoku_allows(a, Some(b), Kinsoku::Hard), "{a}{b}"); + } + for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] { + assert!(kinsoku_allows(a, Some(b), Kinsoku::Hard), "{a}{b}"); + } + assert!(kinsoku_allows('。', None, Kinsoku::Hard)); + // No East Asian character on either side: Latin rules apply. + assert!(kinsoku_allows(' ', Some('–'), Kinsoku::Hard)); + assert!(!kinsoku_allows('字', Some(')'), Kinsoku::Hard)); +} + +/// Soft kinsoku lets 々, ー and small kana (JLREQ cl-09's 々, cl-10, cl-11) start a line and keeps +/// the rest of the set (half-width forms aren't in those classes); None allows any break. +#[test] +fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() { + for (a, b) in [('ャ', 'ー'), ('カ', 'ッ'), ('時', '々'), ('ト', 'ㇰ'), ('か', 'ゃ')] { + assert!(!kinsoku_allows(a, Some(b), Kinsoku::Hard), "{a}{b}"); + assert!(kinsoku_allows(a, Some(b), Kinsoku::Soft), "{a}{b}"); + } + for (a, b) in [('字', '。'), ('弧', '」'), ('「', 'か'), ('カ', 'ッ'), ('カ', 'ー'), ('字', 'ゝ')] { + assert!(!kinsoku_allows(a, Some(b), Kinsoku::Soft), "{a}{b}"); + assert!(kinsoku_allows(a, Some(b), Kinsoku::None), "{a}{b}"); + } +} + +#[test] +fn no_break_puts_a_closing_mark_at_a_line_start_or_an_opening_one_at_a_line_end() { + let text = "あいうえお。かきくけ「こさしすせそ」"; + let breaks = offsets(text, &Options::default()); + assert!(breaks.len() > 10, "{breaks:?}"); + for o in breaks { + let (head, tail) = text.split_at(o); + let (Some(last), Some(first)) = (head.chars().next_back(), tail.chars().next()) else { continue }; + assert!(!no_line_start(first) && !no_line_end(last), "break at {o}: {last}|{first}"); + } +} + +/// あカッ breaks before ッ with Soft and None (a small kana may start a line) and not with Hard; a +/// full stop never starts a line, Soft or not. +#[test] +fn kinsoku_mode_decides_whether_a_small_kana_may_start_a_line() { + assert_eq!(starts("あカッ", &opts(Kinsoku::Hard)), ['カ']); + assert_eq!(starts("あカッ", &opts(Kinsoku::Soft)), ['カ', 'ッ']); + assert_eq!(starts("あカッ", &opts(Kinsoku::None)), ['カ', 'ッ']); + assert_eq!(starts("あカ。", &opts(Kinsoku::Soft)), ['カ']); + assert_eq!(starts("あカ。", &opts(Kinsoku::None)), ['カ', '。']); +} + +#[test] +fn kinsoku_adjusts_uax14_next_to_east_asian_text_only() { + let ja = "日本語、「漢字」。テスト"; + let on = starts(ja, &opts(Kinsoku::Hard)); + for c in ['、', '」', '。', '漢'] { + assert!(!on.contains(&c), "kinsoku: no line starts with {c}: {on:?}"); + } + assert!(on.contains(&'「') && on.contains(&'テ'), "{on:?}"); + let off = starts(ja, &opts(Kinsoku::None)); + for c in ['、', '」', '。', '漢'] { + assert!(off.contains(&c), "no kinsoku: a line may start with {c}: {off:?}"); + } + let en = "Hello wonderful (world), 50%."; + for k in [Kinsoku::None, Kinsoku::Soft] { + assert_eq!(starts(en, &opts(k)), starts(en, &opts(Kinsoku::Hard))); + } + assert_eq!(starts(en, &Options::default()), ['w', '(', '5']); +} + +#[test] +fn breaks_are_byte_offsets_and_newlines_are_mandatory() { + let b = line_breaks("a b\nc", &Options::default()); + let got: Vec<(usize, bool)> = b.iter().map(|b| (b.offset, b.mandatory)).collect(); + assert_eq!(got, [(2, false), (4, true), (5, true)]); + assert!(line_breaks("", &Options::default()).is_empty()); + // A variation selector belongs to its ideograph: no break before it, kinsoku reads the base. + assert_eq!(pieces("字\u{FE00}字\u{FE00}。", &Options::default()), ["字\u{FE00}", "字\u{FE00}。"]); +} + +/// The East Asian breaks that None and Soft add to UAX #14 never split a cluster: not after a +/// joiner, not before a combining mark or variation selector. +#[test] +fn no_kinsoku_mode_breaks_inside_a_cluster() { + for k in [Kinsoku::None, Kinsoku::Soft, Kinsoku::Hard] { + let o = opts(k); + assert_eq!(pieces("日本\u{200D}語", &o), ["日", "本\u{200D}語"], "{k:?}"); + assert_eq!(pieces("あカ\u{200D}ッ", &o), ["あ", "カ\u{200D}ッ"], "{k:?}"); + assert_eq!(pieces("葛\u{E0100}城", &o), ["葛\u{E0100}", "城"], "{k:?}"); + assert_eq!(pieces("字\u{3099}\u{FE00}字", &o), ["字\u{3099}\u{FE00}", "字"], "{k:?}"); + } +} + +#[test] +fn korean_words_break_at_spaces_only() { + let text = "한국어 문장(文章)입니다"; + assert_eq!(pieces(text, &Options::default()), ["한", "국", "어 ", "문", "장", "(文", "章)", "입", "니", "다"]); + let words = Options { korean_words: true, ..Options::default() }; + assert_eq!(pieces(text, &words), ["한국어 ", "문장", "(文章)", "입니다"]); +} + +#[cfg(not(feature = "thai"))] +#[test] +fn thai_without_its_feature_breaks_as_uax14() { + assert_eq!(pieces("ภาษาไทยภาษาไทย ไทย", &Options::default()), ["ภาษาไทยภาษาไทย ", "ไทย"]); +} + +/// Each model available in this build, for the complex-script tests. +#[cfg(any(feature = "thai", feature = "lao", feature = "khmer", feature = "burmese"))] +fn models() -> Vec<(&'static str, complex::Model)> { + use icu_segmenter::LineSegmenter; + #[allow(unused_mut)] + let mut models: Vec<(&str, complex::Model)> = vec![("dictionary", LineSegmenter::new_dictionary)]; + #[cfg(feature = "lstm")] + models.push(("lstm", LineSegmenter::new_lstm)); + models +} + +/// Breaks every model puts in `words.concat()` fall between two of `words`, and there are at least +/// `min` of them. `words` splits compounds where a model may break them. +#[cfg(any(feature = "thai", feature = "lao", feature = "khmer", feature = "burmese"))] +fn assert_word_breaks(words: &[&str], min: usize) { + let text = words.concat(); + let bounds: Vec = words + .iter() + .scan(0, |end, w| { + *end += w.len(); + Some(*end) + }) + .collect(); + for (name, model) in models() { + let breaks = breaks_with_words(&text, &Options::default(), complex::word_breaks(&text, model)); + let inside: Vec = breaks.iter().map(|b| b.offset).filter(|&o| o < text.len()).collect(); + let shown: Vec<&str> = std::iter::once(0) + .chain(inside.iter().copied()) + .zip(inside.iter().copied().chain([text.len()])) + .filter_map(|(a, b)| text.get(a..b)) + .collect(); + assert!(inside.iter().all(|o| bounds.contains(o)), "{name}: {shown:?}"); + assert!(inside.len() >= min, "{name}: {shown:?}"); + } +} + +#[cfg(feature = "thai")] +#[test] +fn thai_breaks_between_words() { + assert_word_breaks(&["Thai: ", "ภาษา", "ไทย", "เป็น", "ภาษา", "ราชการ", "ของ", "ประเทศ", "ไทย."], 6); +} + +#[cfg(feature = "lao")] +#[test] +fn lao_breaks_between_words() { + assert_word_breaks(&["ພາສາ", "ລາວ", "ເປັນ", "ພາສາ", "ລາດ", "ຊະ", "ການ", "ຂອງ", "ປະເທດ", "ລາວ"], 5); +} + +#[cfg(feature = "khmer")] +#[test] +fn khmer_breaks_between_words() { + assert_word_breaks(&["ភាសា", "ខ្មែរ", "ជា", "ភាសា", "ផ្លូវ", "ការ", "នៃ", "ប្រទេស", "កម្ពុជា"], 5); +} + +/// Burmese may break between any two syllables, and the LSTM model splits some words there +/// (မြန်|မာ): the expected breaks are the syllable boundaries. +#[cfg(feature = "burmese")] +#[test] +fn burmese_breaks_between_syllables() { + let syllables = ["မြန်", "မာ", "ဘာ", "သာ", "စ", "ကား", "သည်", "မြန်", "မာ", "နိုင်", "ငံ", "၏", "ရုံး", "သုံး", "ဘာ", "သာ", "စ", "ကား", "ဖြစ်", "သည်"]; + assert_word_breaks(&syllables, 5); +} + +/// Chinese full-width punctuation: no line starts with a closing mark, none ends with an opening +/// one. +#[test] +fn chinese_kinsoku_keeps_punctuation_with_its_text() { + let text = "他说:“《红楼梦》是一部小说,(很长)。”"; + let o = Options::for_language(Some("zh-Hans")); + let breaks = offsets(text, &o); + assert!(breaks.len() > 8, "{:?}", pieces(text, &o)); + for b in breaks { + let (head, tail) = text.split_at(b); + let (Some(last), Some(first)) = (head.chars().next_back(), tail.chars().next()) else { continue }; + assert!(!":”》,)。".contains(first), "line starts with {first}: {:?}", pieces(text, &o)); + assert!(!"“《(".contains(last), "line ends with {last}: {:?}", pieces(text, &o)); + } +} + +#[test] +fn language_picks_korean_words() { + for tag in ["ko", "ko-KR", "KO_kr"] { + assert!(Options::for_language(Some(tag)).korean_words, "{tag}"); + } + for tag in [Some("ja"), Some("kok"), Some("en-KO"), None] { + assert_eq!(Options::for_language(tag), Options::default(), "{tag:?}"); + } +} + +/// Emergency breaks fall between grapheme clusters: never inside an emoji ZWJ sequence, a flag, a +/// skin-tone modifier or a letter with its combining marks. +#[test] +fn emergency_breaks_keep_grapheme_clusters_whole() { + let family = "👨\u{200D}👩\u{200D}👧\u{200D}👦"; + let text = format!("ab{family}e\u{301}🇯🇵👍🏽"); + let breaks = emergency_breaks(&text, 0..text.len()); + let a = family.len() + 2; + let b = a + "e\u{301}".len(); + let c = b + "🇯🇵".len(); + assert_eq!(breaks, [1, 2, a, b, c]); + // Offsets are into `text`; a range off a char boundary has none. + assert_eq!(emergency_breaks(&text, 1..b), [2, a]); + assert!(emergency_breaks(&text, 3..b).is_empty()); + assert!(emergency_breaks("", 0..0).is_empty()); +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index b30fb177..e7df43f4 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -11,15 +11,73 @@ description = "Reusable egui controls and icon catalogs for Craft frontends" workspace = true [dependencies] +craft-fonts-build = { workspace = true } +craft-i18n = { workspace = true } egui = { workspace = true } +epaint = { version = "0.37", default-features = false } +log = "0.4" serde = { version = "1", features = ["derive"], optional = true } +# Same version as epaint's, for reading a face's character map. +skrifa = { version = "0.44", default-features = false, features = ["std"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Screenshot and input tests of an app's screens (the screen-testing feature). +egui_kittest = { version = "0.37", features = ["snapshot", "wgpu"], optional = true } +# The versions egui_system_font_provider 0.37 uses. +fontique = { version = "0.11.1", default-features = false, features = ["system", "fontconfig-dlopen"], optional = true } +unicode-script = { version = "0.5.8", optional = true } + +# Fetching served fonts (`fonts::UiFonts::served_faces`); already in every eframe web build. +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "RequestInit", "Response", "console"] } + +# 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", "NSEvent", "NSResponder", "NSScreen", "NSWindow"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSString"] } [features] +default = [] serde = ["dep:serde"] +# Floating windows may move to their own OS windows (desktop egui backends with viewports). +native-windows = [] +# Draw colour glyphs (COLR, sbix, CBDT), e.g. colour emoji. +color_fonts = ["epaint/color_fonts"] +# Find installed fonts for characters the app's faces lack (desktop only). +system_fonts = ["dep:fontique", "dep:unicode-script"] +# `docking::testing`: a headless driver for application docking tests. +testing = [] +# craft_ui::testing: per-language screenshot tests and IME smoke tests with egui_kittest +# (desktop only). +screen-testing = ["dep:egui_kittest"] [dev-dependencies] -egui = { workspace = true, features = ["default_fonts"] } +# serde: the IME tests read events that only the suite's egui fork has from JSON. +egui = { workspace = true, features = ["default_fonts", "serde"] } serde_json = "1" egui_extras = { version = "0.37", default-features = false, features = ["svg"] } -egui_kittest = { version = "0.37", features = ["wgpu"] } image = { version = "0.25", default-features = false, features = ["png"] } + +[[example]] +name = "docking_native" +required-features = ["native-windows"] + +[[test]] +name = "screens" +required-features = ["screen-testing"] + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +egui_kittest = { version = "0.37", features = ["wgpu"] } +eframe = { version = "0.37", default-features = false, features = ["wgpu", "x11", "default_fonts"] } + +# The browser test (tests/web_fonts.rs). +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +wasm-bindgen-test = "0.3" +web-sys = { version = "0.3", features = ["Window", "Response"] } diff --git a/crates/ui/README.md b/crates/ui/README.md index f093a549..3906429e 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -5,9 +5,11 @@ embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling. `docking` supplies transactional panel moves and an optional docking surface. `printing` supplies printer/job controls and asynchronous feedback used by WordCraft and GridCraft. +`fonts` installs the UI font stack, the same fallback order in every app. +`localized` supplies number fields that write and read the user's region's numbers. The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance) -records their scope and acceptance limits. Assets, complete themes, localization and command handling -remain in each application. No icons, fonts or application engines are bundled. +records their scope and acceptance limits. Assets (font files included), complete themes, localization +and command handling remain in each application. No icons, fonts or application engines are bundled. ## SVG catalogs @@ -107,12 +109,19 @@ 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: ```rust fn tab(ui: &mut egui::Ui, rect: egui::Rect, selected: bool) -> egui::Response { - craft_ui::tabs::Tab::new(ui.id().with("layers-tab"), "Layers", selected) + craft_ui::tabs::Tab::new(ui.scope_id().with("layers-tab"), "Layers", selected) .sense(egui::Sense::click_and_drag()) .show_at(ui, rect, |ui, response| { let fill = if response.hovered() { egui::Color32::GRAY } else { egui::Color32::DARK_GRAY }; @@ -207,10 +216,249 @@ fn style_widgets(visuals: &mut egui::Visuals, palette: WidgetPalette, text: Colo } ``` -This is a mechanism for coloring widgets, not a shared theme schema. Fonts, complete palettes, +This is a mechanism for coloring widgets, not a shared theme schema. Complete palettes, selection colors, shadows, spacing, context storage and preference serialization remain in each -application. See the [eight-app comparison](../../docs/theme-infrastructure.md) for the exact -common operation and the differences preserved by the adapters. +application; fonts are installed by `fonts`, below. See the +[eight-app comparison](../../docs/theme-infrastructure.md) for the exact common operation and the +differences preserved by the adapters. + +## UI fonts + +egui draws a character only if one of its installed faces has it, so an app's labels, layer names +and file names show boxes for any script its faces miss. `fonts::UiFonts` builds every app's UI +font stack in the same order: + +1. the app's own faces for the family (UI face, semibold, mono), or egui's default faces for a + family the app has none for; +2. craft-fonts faces for the UI language; +3. craft-fonts faces for every other script the app ships, so a Japanese interface still draws + Arabic file names; +4. installed system fonts (see below), set to regular weight so variable fonts don't draw Thin; +5. emoji faces (system emoji first, then the colour, web and monochrome faces the app embeds) and, + behind the app's own faces, egui's default faces. + +When the faces of step 1 don't cover the UI language's main script (Arabic, Thai, Georgian), that +script's face goes first. egui takes a row's height from a family's first face, so the language's +own face sets the row height and its marks aren't clipped. Han, kana and Hangul sit inside the em +square and fit a Latin row, so Chinese, Japanese and Korean UIs keep the Latin face first: Latin +text draws in the app's face in every language, and CJK characters fall back to the language's +face. Rows and widgets keep the same height in every one of these languages as in a Latin UI: +CJK faces sit on the first face's baseline inside its row, scaled down only if a glyph would +otherwise leave the row (Noto Sans CJK and BIZ UDPGothic at the same size don't need it), with +crates.io egui and the suite's patched egui alike. `UiFonts::cjk_rows(CjkRows::Grow)` opts into +the CJK face's taller line instead. The app's theme sets the minimum widget height for rows of its +own face; when the UI language's script face leads the stack and has taller rows, `install` adds +the difference to the theme's height, and takes it off again when a later install needs less. The +theme's height and padding stay the app's: a theme applied after `install` is fitted at the start +of the next pass, so the result doesn't depend on which comes first. On the suite's patched egui +(`FontTweak::set_baseline_fallback`, which the other craft faces and installed text faces turn +on), other fallback glyphs sit on the row's baseline and rows grow to fit them by themselves; emoji +and symbol faces (colour glyphs, or "Emoji" or "Symbol" in the name) stay centred. With crates.io +egui those fallbacks are centred. Monospace keeps the app's or egui's mono face first. Han characters take the regional face the language needs: `zh-TW` gets the +Traditional (TC) face, then Hong Kong, then Simplified; `zh-HK` the Hong Kong face first; `ja` the +Japanese face; `ko` the Korean one. + +The app keeps its font files: its `build.rs` embeds the craft-fonts faces it wants from +`fonts/manifest.txt` with `craft-fonts-build`, and craft-ui never depends on craft-fonts. A crate +below the UI (an app's engine or font crate, without egui) holds the table as +`craft_fonts_build::EmbeddedFace` values (storytold/craft-libs#213), and the UI converts each into +a `fonts::CraftFace` with `From`. Apps embed with `Embed::subset_to(catalogs)`, which takes the +CJK faces as craft-fonts' interface faces (`Noto Sans TC UI`, BIZ UDPGothic Regular) and cuts them +at build time to the characters the translation catalogs use (about 3 MB for all five with +FilmCraft's catalogs). A character a cut face lacks, such as one in a file name, comes from +another region's interface face that has it, then from the installed system fonts; the build +warns when a catalog has characters no embedded face covers. Preferences name the full family +(`Noto Sans TC`) and also apply to its interface face (`Noto Sans TC UI`). + +The app configures its own faces, a preferred face per script, extra named families (a semibold +family, a family led by Arabic for the runs it shapes itself), emoji faces, and whether system +fonts come before or after the craft faces for other scripts. `UiFonts::stack` returns a `Stack` +whose chains the app can still edit (`insert`, `replace`, `remove`) before `Stack::install`. +`Stack::after_system` (a `FaceList`) holds the faces asked after the system fonts, readable with +`get` and `faces`; `Stack::flat_definitions` appends them to each chain, for a context that takes +its fonts only through `set_fonts`. + +`fonts::I18nScripts` reads each language's scripts and Han forms from craft-i18n; an app with its +own table implements `fonts::LanguageScripts` instead. + +```rust +use craft_fonts_build::EmbeddedFace; +use craft_ui::fonts::{CraftFace, Family, I18nScripts, UiFonts}; +use egui::{FontData, FontFamily}; + +pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); + +fn install_fonts(ctx: &egui::Context, ui_language: &str) { + let craft_faces: Vec = CRAFT_FONTS.iter().copied().map(CraftFace::from).collect(); + UiFonts::new(&I18nScripts) + .app_face("ui", FontData::from_static(UI_FONT), &[FontFamily::Proportional]) + .app_face("ui-semibold", FontData::from_static(UI_SEMIBOLD), &[FontFamily::Name("semibold".into())]) + .app_face("mono", FontData::from_static(MONO_FONT), &[FontFamily::Monospace]) + .family(Family::named("semibold").weight(600)) + .craft_faces(&craft_faces) + .languages(OFFERED_LANGUAGES) + .ui_language(ui_language) + .install(ctx); +} +``` + +Call `install` again when the UI language changes. + +### Web builds + +A web build serves the craft-fonts faces beside its wasm instead of embedding them, so the wasm +stays small and an English interface never downloads Chinese. `craft-fonts-build`'s +`Embed::serve` copies the faces into a directory and writes a table of `fonts::ServedFace`s (URL, +size, Subresource Integrity hash and the characters the face maps, no bytes); +`UiFonts::served_faces` takes it in place of `craft_faces`. Each face's characters are its +`cmap`'s code point ranges, packed as LEB128 gaps and lengths: about 3 KB for a CJK interface face +cut to an app's catalogs, a few hundred bytes for Noto Sans, the symbol faces or an emoji face. The +stack orders served faces exactly as embedded ones, and on the web `Stack::install` fetches them: + +- the UI language's faces at once (`Stack::ui_downloads`: the best face for each of its scripts + the app's faces lack, per family, so a semibold family brings the bold face); +- for a character no installed face has, the one face that draws it (`Stack::downloads_for`): + the first face in the family's order whose file maps the character and whose scripts fit it. + Noto Sans draws Latin, Greek and Cyrillic letters the app's faces lack (Vietnamese ẫ), the + symbol faces (Noto Sans Symbols, Symbols 2, Math) keyboard keys, arrows and other text symbols + (⌘ ⌥ ⇧ ⎋ ⏎), the emoji faces emoji. A Han character takes the interface face of the UI + language's region first (Simplified, Traditional, Hong Kong, Japanese or Korean), then the + other offered languages' and the rest in manifest order; +- a served emoji face (`Emoji::served`) the first time text needs an emoji. + +A character no served face maps, such as a rare Han character none of the cut interface faces +has, fetches nothing: egui's web glyph rasterizer draws it with the browser's fonts, or it shows +as a missing glyph. A file that +fails to download leaves its characters to the next face that draws them and is fetched again +after 2 seconds, then after twice as long with each further failure, up to 10 minutes. Each face is +added when its file arrives, and the context repaints; text falls back to the faces after it until +then. Fetched files stay in memory for later installs, such as a language switch, and the +browser's HTTP cache keeps them across page loads. `fonts::loading(ctx)` is true while the UI +language's files are on their way, for an app that would rather draw nothing than Latin fallbacks +in its first frames. + +`build.rs`: + +```rust +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + // CJK faces as craft-fonts' interface faces, cut to the app's catalogs (`catalogs` as in + // craft-fonts-build's README; `subset` feature on the build-dependency). + let fonts = craft_fonts_build::Embed::new().subset_to(catalogs("myapp")); + let written = if craft_fonts_build::target_is_wasm() { + // Served by the page as `fonts/`; see Trunk.toml. + fonts.serve("web/fonts", "fonts/").write("craft_fonts.rs") + } else { + fonts.write("craft_fonts.rs") + }; + if let Err(e) = written { + println!("cargo::error={e}"); + } +} +``` + +The app: + +```rust +// The embedded table is written as `EmbeddedFace` literals, the served one as `ServedFace`s. +use craft_ui::fonts::{CraftFace as EmbeddedFace, I18nScripts, ServedFace, UiFonts}; + +#[cfg(not(target_arch = "wasm32"))] +static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +#[cfg(target_arch = "wasm32")] +static CRAFT_FONTS: &[ServedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); + +fn install_fonts(ctx: &egui::Context, ui_language: &str) { + let fonts = UiFonts::new(&I18nScripts).app_face("ui", FontData::from_static(UI_FONT), &[FontFamily::Proportional]); + #[cfg(not(target_arch = "wasm32"))] + let fonts = fonts.craft_faces(CRAFT_FONTS); + #[cfg(target_arch = "wasm32")] + let fonts = fonts.served_faces(CRAFT_FONTS); + fonts.languages(OFFERED_LANGUAGES).ui_language(ui_language).install(ctx); +} + +impl eframe::App for App { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + if craft_ui::fonts::loading(ui.ctx()) { + return; // The fetch repaints when it arrives. + } + // … + } +} +``` + +Call `install_fonts` from the `eframe::App` creator, so the fetches start before the first frame. +The served directory has to reach the site next to the wasm; with Trunk, copy it in a hook +(`Trunk.toml`): + +```toml +[[hooks]] +stage = "post_build" +command = "sh" +command_arguments = ["-c", "cp -R web/fonts \"$TRUNK_STAGING_DIR/fonts\""] +``` + +A relative `serve` directory is under the package whose `build.rs` runs it; a crate deeper in the +workspace can pass `concat!(env!("CARGO_MANIFEST_DIR"), "/../../web/fonts")` instead. Keep the +directory out of version control. A build filters faces for the web as for the desktop (`scripts`, +`filter`), e.g. to leave out bold faces. + +Two features, both off by default, change what every craft-ui user draws, so the app turns them on: + +- `system_fonts` (desktop): the stack asks the installed fonts, at regular weight, and `install` + replaces the context's font providers, including the one eframe adds. One index of the + installed fonts serves every UI language, so a language switch doesn't scan them again. + `install` starts building it on a background thread; until it is ready, text draws with the + stack's own faces, and the context repaints when it arrives. Without it, + `install` keeps the providers the context has (eframe's `system_fonts`, for one) and asks the + stack's own faces after them. `UiFonts::system_provider` supplies a provider either way, and + `SystemFonts::Off` drops system fonts. +- `color_fonts`: colour glyphs (COLR, sbix, CBDT), for colour emoji. + +egui 0.37's default faces have only a few monochrome emoji and lack symbols such as ⊞ ☰ 🔍. On +the web there are no system fonts, so emoji draw as boxes unless the app sets an `Emoji` face. +`UiFonts::egui_face` puts one of egui's own faces into a family, e.g. Hack in the proportional +family for ⇧ and ▸. + +Each app tests its coverage with `fonts::assert_covers`: it checks every shipped translation and a +sample of content scripts against the stack without system fonts, and fails naming the language, +message key and character that has no glyph. Build the stack with `.system_fonts(SystemFonts::Off)` +in that test, and run it with `CRAFT_FONTS_DIR` set so it sees the faces a release embeds. For +languages the app leaves to installed fonts, `fonts::assert_covers_with_system` asks the stack's +system provider too; `fonts::system_font_provider(locale)` gives egui's provider (with +`system_fonts`), shared per locale, for `UiFonts::system_provider` or such a check. At run time, +`Stack::covers_language(language, with_system)` says whether the stack draws a language's scripts, +for leaving a language out of the app's menu on a machine that can't show it. + +## Testing screens in each language + +The `screen-testing` feature adds `testing::Screens`, for an app's screenshot tests of its key screens in +Arabic, Japanese, Traditional Chinese (Taiwan), Korean, Thai and Vietnamese (`testing::LANGUAGES`). +Each language gets an egui_kittest harness with the app's font stack installed for that UI language +and system fonts off, so snapshots match on every machine and a character no face draws fails the +test. Right-to-left languages lay the screen out from the right. The app's UI closure receives the +`Locale` (language tag and direction) and picks its strings from it. + +```rust,ignore +use craft_ui::testing::{KOREAN, Screens, compose}; + +let screens = Screens::new("rename").size([320.0, 150.0]).fonts(|fonts| fonts.craft_faces(FACES)); +// Compares tests/snapshots/rename-ar.png, rename-ja.png, …; UPDATE_SNAPSHOTS=1 writes them. +screens.snapshots(|ui, locale| rename_dialog(ui, locale)); + +// IME smoke test: a scripted 2-Set Korean composition into the focused field. +let mut harness = screens.harness_state("ko", |ui, locale, state: &mut Dialog| state.ui(ui, locale), Dialog::default()); +harness.get_by_role(egui::accesskit::Role::TextInput).focus(); +compose(&mut harness, &KOREAN); +assert_eq!(harness.state().name, "한"); +``` + +`compose` sends the preedit and commit events an input method sends (`KOREAN`, `JAPANESE`, +`PINYIN`, `TELEX`), which egui's `TextEdit` and canvases reading `Event::Ime` both take. The module +documentation has the recipe for a shortcut smoke test on a non-Latin layout through craft-keymap. +`tests/screens.rs` is a complete example. egui's default proportional face has no Vietnamese +letters such as ổ, so an app relying on it fails the `vi` screens until its own face covers them. The per-release manual checks are in +[language-testing.md](../../docs/language-testing.md). ## Print settings and feedback @@ -232,10 +480,120 @@ represented by `None`, independently of explicit one-sided, long-edge or short-e Receipts and backend errors remain unmodified data for the app to display. These controls work on WASM too; browser PDF printing remains an app-owned service. +## Localized numbers + +`localized::LocaleNumbers` writes numbers in the region's digits, decimal mark and grouping and +reads back any digits. Build it from the app's `I18n` with `.with_catalog(&i18n, lang)`, so the +error tooltips come from the catalog rows keyed by `localized::MESSAGES` (English where a row is +missing). `LocalizedDragValue` puts it on an `egui::DragValue`. + +`NumberField` is the panel and dialog field. It reads text on commit and keeps text it can't +read, outlined, with the reason in a tooltip. + +```rust +let label = ui.label(i18n.t(lang, "Width")); +let frame = Recessed { fill: t.input, stroke: Stroke::new(1.0, t.input_border), ..Recessed::from_style(ui.style()) }; +let r = ui.add(NumberField::new(&mut width, &numbers).range(0.0..=1e4).step(1.0).unit(Unit::translated(&i18n, lang, "{n} px")) + .recessed(frame).scrub_label(&label).mixed(widths_differ)); +``` + +- `.recessed(Recessed)` draws the sunken box: fill, border, focus border, radius, height, margin. +- `.scrub_label(&label)` names the field and scrubs it when the label is dragged: a step every two + points. A click on the label focuses the field, and Escape puts the value back. The response + carries the label's drag flags, so the app can make the drag one undo step. +- Arrow keys, the wheel over the focused field and scrubbing step to the next multiple of `step` + (5.1 up to 6, down to 5); Shift steps ten, Ctrl/Cmd a tenth where the decimals show it. +- `.mixed(true)` with `.placeholder(text)` shows a blank field for values that differ between + objects. The value stays until the user enters a number, which is `changed()` even when equal. +- `localized::focus_first_field(ctx, true)` when a dialog opens: the first enabled field drawn + takes the focus with its text selected. + +## Screenshot completion + +`capture::CaptureSlot` is a one-result slot for egui 0.37's one-shot screenshot callback. It is not a +capture scheduler: request IDs, output paths, deadlines, settling frames, geometry epochs and the +aggregate admission reservation stay in the application. The slot owns strong `Context` and +`ViewportId` handles only in the live request; the callback holds a weak reference, so closing the +request cannot leave a callback-owned context cycle. Keep the slot in the application's request +record, outside egui-owned storage: a slot stored in `egui::Memory::data` or a registered plugin +would form a `Context` → slot → `Context` cycle. + +```rust +use craft_ui::capture::{CaptureError, CaptureSlot}; +use egui::{Context, ViewportCommand, ViewportId}; + +fn issue_capture(ctx: &Context, viewport: ViewportId) -> CaptureSlot { + // A per-image bound, not the application's aggregate admission budget. + let (slot, callback) = CaptureSlot::new(ctx, viewport, 128 * 1024 * 1024); + // A pending screenshot command requests the paint that hidden windows need. + ctx.send_viewport_cmd_to(viewport, ViewportCommand::Screenshot(callback)); + slot +} + +fn report_capture(slot: &CaptureSlot) { + if let Some(result) = slot.take() { + match result { + Ok(image) => { + // Validate the app-owned request/geometry epoch, then encode and reply. + let _ = image; + } + Err(error) => { + // Report the completion error through the app's request record. + let _: CaptureError = error; + } + } + } +} +``` + +`take` returns the image or completion error at most once; `None` means pending, cancelled, already +taken or dropped at that moment. The slot cannot distinguish those closed states, so the +application's request record owns the pending/closed distinction and the associated timeout or +error reply. + +Completion checks a non-zero width and height, `width * height * size_of::()` with checked +arithmetic, a pixel buffer that matches the reported dimensions, and the per-image byte bound. A +zero width or height is `MalformedImage`; an overflowing pixel or byte count is +`DimensionsOverflow`. `CaptureError` is `#[non_exhaustive]`, so match the known variants with a +wildcard arm. The slot releases its mutex before requesting a repaint, and `cancel`/`Drop` replace +the state and drop any retained image only after unlocking, so a large image is not freed while +another thread is completing. +Both accepted images and rejection errors wake the owning viewport so the application can take +the result and reply promptly. + +Every retry needs a fresh `CaptureSlot` and a fresh callback from `CaptureSlot::new`. A callback is +one-shot and a slot never accepts a second completion, even after `take` removed the first result. +Issue an explicit screenshot command only once per slot, after the application has settled the +frame it wants. The first delivered frame wins; later frames delivered to the same callback are +discarded. A completion is published before the wake: a completion racing `cancel` or `Drop` +may already have published its result, so it can still deliver one harmless extra repaint after +cancellation. If the cancellation won, the image is released and a later `take` returns `None`; if +`take` won first, the caller already owns the result. A completion that observes the cancelled or +taken state stores no image and does not wake. Cancellation and wakeup are therefore not one atomic +step, and the helper never blocks a completing thread under its lock. + +`complete` calls `Context::request_repaint_of`, which takes egui's context lock. Completing from an +ordinary `Ui` closure, an egui 0.37 plugin hook or a renderer/worker thread is fine: these run outside +the context lock. Repaint observers and request-repaint callbacks run under that lock, so completing +there would deadlock. An observer can take or cancel a slot because those operations only use the +slot lock, but further `Context` access must happen outside the observer. + +Correlation is based on callback ownership. The helper cannot detect a callback sent to the wrong +viewport if the returned image otherwise fits its bound. The application must route each callback +to its recorded viewport and validate its request and geometry; native multi-window tests must +verify the actual pixels as well as callback routing. + +## Text reading areas + +`text::align_galley_rows` positions already shaped rows inside a fixed-width reading area without changing glyphs or logical text. Supply a per-row alignment, for example the paragraph direction from the application shaper. The helper uses stock egui APIs and keeps cached glyph meshes shared. Its logical rectangle is the requested width; ink may overflow, so clip the painter when drawing cards. Use it for read-only painting, keeping the original galley for editable hit testing. + ## Compatibility and checks The crate uses egui 0.37 and Rust 1.95 or later. Its production dependency disables egui's default -features so fonts and rendering features remain the application's choice. See the +features, so egui's bundled fonts and rendering features remain the application's choice. It has +no default features; see [UI fonts](#ui-fonts) for `color_fonts` and `system_fonts`, and +[Testing screens in each language](#testing-screens-in-each-language) for `screen-testing`; +`testing` adds the headless docking driver `docking::testing`. See the [suite audit](../../docs/ui-components-audit.md) for candidates, policies and adoption order. Validation: diff --git a/crates/ui/examples/docking_native.rs b/crates/ui/examples/docking_native.rs new file mode 100644 index 00000000..753a4d57 --- /dev/null +++ b/crates/ui/examples/docking_native.rs @@ -0,0 +1,64 @@ +//! 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}")); + } + + /// Return the example content's logical and accessibility label, `Panel {panel}`. + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } +} + +struct App { + layout: Layout, +} + +impl eframe::App for App { + /// Render the dock, apply every returned action with default permissions, print floating + /// window places, and forward residual native-window events to the host. + 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 new file mode 100644 index 00000000..9deb6fa5 --- /dev/null +++ b/crates/ui/examples/ui_hotpaths.rs @@ -0,0 +1,784 @@ +//! Test-only headless performance harness for craft-ui hot paths. +//! +//! Run through the server task workflow, e.g. `cargo run -p craft-ui --example ui_hotpaths --locked`. +//! Set `CRAFT_UI_PERF_OUT` to write the raw JSON report (otherwise stdout); `CRAFT_UI_PERF_LABEL` +//! is copied into the report. Frames are `Context::run_ui` plus `Context::tessellate`; texture +//! deltas are counted but never uploaded, and no GPU, window, compositor or native input path is +//! exercised. Fonts are egui's bundled `default_fonts` unless `CRAFT_UI_PERF_FONT` points at a +//! pinned fixture file (recorded by the parent with its hash). Bodies, geometry, interactions and +//! the mesh fingerprint are collected on the final measured frame; the hover probe is a separate +//! unmeasured frame and never contributes to timings or counters. + +use std::{ + collections::{BTreeMap, BTreeSet}, + env, + error::Error, + hint::black_box, + sync::Arc, + time::{Duration, Instant}, +}; + +use craft_ui::{ + docking::{DockArea, DockError, DockStyle, Floating, Layout, Node, Permissions}, + layout::{SplitAxis, SplitSize, fit_toolbar, split_rect}, + tabs, +}; +use egui::{Context, Id, Rect, pos2, vec2}; +use serde_json::{Value, json}; + +const WARMUPS: usize = 50; +const MIN_WARMUP: Duration = Duration::from_secs(3); +const MICRO_SAMPLES: usize = 50; +const MICRO_BATCH: usize = 16; +const FRAME_SAMPLES: usize = 1000; +const EPS: f32 = 0.1; + +#[derive(Default)] +struct Frames { + times_ns: Vec, + warmups: usize, + warmup_elapsed_ns: u64, + next_frame: usize, + mesh_hash: u64, + bodies: BTreeMap, + shapes: usize, + vertices: usize, + indices: usize, + set_images: usize, + set_bytes: usize, + free_images: usize, + repaint_frames: usize, + passes: Vec, + galleys_after_warmup: usize, +} + +/// Run all micro and dock/integration cases at 1× and 2×, gate each case, and emit the schema-3 +/// JSON report to `CRAFT_UI_PERF_OUT` (or stdout). Fails when no bundled or fixture font is +/// available. +fn main() -> Result<(), Box> { + let (fonts, font_names) = font_definitions()?; + if font_names.is_empty() { + return Err("the example needs egui's bundled default_fonts or CRAFT_UI_PERF_FONT".into()); + } + let mut micro = Vec::new(); + for scale in [1.0f32, 2.0] { + for count in [1usize, 8, 64, 4096] { + let natural: Vec<_> = tab_widths(count).into_iter().map(|width| width * scale).collect(); + let selected = count / 2; + for available in [96.0f32, 640.0, 1920.0] { + let (available, minimum, overflow) = (available * scale, 48.0 * scale, 64.0 * scale); + check_tabs(&natural, selected, available, minimum, overflow)?; + micro.push(micro_entry( + "tabs_fit", + json!({"tabs": count, "available": available, "selected": selected, "minimum_width": minimum, + "overflow_width": overflow, "size_scale": scale}), + || { + let natural = black_box(&natural); + black_box(tabs::fit(natural, black_box(selected), black_box(available), black_box(minimum), black_box(overflow))); + }, + )); + } + } + for (shape, rect, axis) in [ + ("normal", Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0 * scale, 600.0 * scale)), SplitAxis::Horizontal), + ("tiny", Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0 * scale, 80.0 * scale)), SplitAxis::Vertical), + ] { + let (gap, minimum) = (4.0 * scale, 24.0 * scale); + let span = match axis { + SplitAxis::Horizontal => rect.width(), + SplitAxis::Vertical => rect.height(), + }; + // Dock layout uses `SplitSize::FixedFirst` (`docking/surface.rs`), so both fixed modes are + // measured next to the ratio case for every shape and scale. + for size in [SplitSize::Ratio(0.31), SplitSize::FixedFirst(span * 0.3), SplitSize::FixedSecond(span * 0.3)] { + let layout = check_split(rect, axis, size, gap, minimum)?; + micro.push(micro_entry( + "split_rect", + json!({"shape": shape, "axis": format!("{axis:?}"), "size": format!("{size:?}"), "gap": gap, "minimum": minimum, + "size_scale": scale}), + || { + black_box(split_rect(black_box(rect), black_box(axis), black_box(size), black_box(gap), black_box(minimum))); + }, + )); + micro.push(micro_entry( + "split_resized", + json!({"shape": shape, "axis": format!("{axis:?}"), "size": format!("{size:?}"), "delta": 12.0 * scale, + "minimum": minimum, "size_scale": scale}), + || { + black_box(black_box(layout).resized(black_box(size), black_box(12.0 * scale), black_box(minimum))); + }, + )); + } + } + for groups in [8usize, 4096] { + let natural: Vec<_> = toolbar_widths(groups).into_iter().map(|width| width * scale).collect(); + let available = 1920.0 * scale; + check_toolbar(&natural, available, 6.0 * scale, 40.0 * scale)?; + micro.push(micro_entry( + "fit_toolbar", + json!({"groups": groups, "available": available, "spacing": 6.0 * scale, "overflow_width": 40.0 * scale, + "size_scale": scale}), + || { + let natural = black_box(&natural); + black_box(fit_toolbar(natural, black_box(available), black_box(6.0 * scale), black_box(40.0 * scale))); + }, + )); + } + } + let mut dock = Vec::new(); + for (tag, panels, floating) in [("dock4", 4u32, true), ("dock32", 32u32, false)] { + for viewport in [[800.0f32, 600.0], [1440.0, 900.0]] { + for scale in [1.0f32, 2.0] { + dock.push(run_case(Case { label: tag, kind: "dock", panels, floating, side_panel: false, viewport, scale }, &fonts)?); + } + } + } + let mut integration = Vec::new(); + for viewport in [[800.0f32, 600.0], [1440.0, 900.0]] { + for scale in [1.0f32, 2.0] { + integration.push(run_case( + Case { label: "panel_integration", kind: "panel_integration", panels: 6, floating: false, side_panel: true, viewport, scale }, + &fonts, + )?); + } + } + let case_counts = json!({"micro": micro.len(), "dock": dock.len(), "panel_integration": integration.len()}); + let report = json!({ + "schema": 3, "harness": "craft-ui/examples/ui_hotpaths", + "label": env::var("CRAFT_UI_PERF_LABEL").unwrap_or_default(), + "os": env::consts::OS, "arch": env::consts::ARCH, + "package_version": env!("CARGO_PKG_VERSION"), "debug_assertions": cfg!(debug_assertions), + "build_profile": if cfg!(debug_assertions) { "debug" } else { "release" }, + "theme": "dark", + "render_config": {"visuals": "dark", "animation_time": 0.0, "text_cursor_blink": false, "max_texture_side": 4096, + "window_focused": true, "clock": "frame index / 60 Hz", "pixels_per_point": [1.0, 2.0]}, + "notes": [ + "CPU-only headless measurement; no GPU renderer, native window, compositor or input.", + "Dock frame scope is Context::run_ui + Context::tessellate; texture deltas are counted, not uploaded.", + "Fonts are egui default_fonts or the CRAFT_UI_PERF_FONT fixture; no installed system fonts.", + "Micro samples are mean-of-batch nanoseconds; frame samples are individual frame nanoseconds.", + "Every micro operation black-boxes its inputs and result so loop-invariant hoisting cannot erase the measured work.", + "Individual micro latencies are diagnostic only: below about one microsecond the single-call timer overhead dominates.", + "Bodies, geometry, interactions and the mesh fingerprint come from the final measured frame; the hover probe is a separate unmeasured frame.", + ], + "minimum_warmups": WARMUPS, "minimum_warmup_ns": MIN_WARMUP.as_nanos(), "micro_samples": MICRO_SAMPLES, "micro_batch": MICRO_BATCH, "frame_samples": FRAME_SAMPLES, + "fonts": {"source": font_source(), "font_file": env::var_os("CRAFT_UI_PERF_FONT").map(|path| path.to_string_lossy().into_owned()), "font_names": font_names, + "families": fonts.families.iter().map(|(family, names)| (format!("{family:?}"), names)).collect::>()}, + "micro": micro, "dock": dock, "panel_integration": integration, + "case_counts": case_counts, + }); + let text = serde_json::to_string_pretty(&report)?; + if let Ok(path) = env::var("CRAFT_UI_PERF_OUT") { + std::fs::write(&path, &text)?; + eprintln!("ui_hotpaths: wrote {path} ({} bytes)", text.len()); + } else { + println!("{text}"); + } + Ok(()) +} + +/// Synthetic tab widths from 40 to 119 pt, cycling with `(i * 17) % 80`. +fn tab_widths(count: usize) -> Vec { + (0..count).map(|i| 40.0 + ((i * 17) % 80) as f32).collect() +} + +/// Synthetic toolbar widths from 24 to 71 pt, cycling with `(i * 13) % 48`. +fn toolbar_widths(count: usize) -> Vec { + (0..count).map(|i| 24.0 + ((i * 13) % 48) as f32).collect() +} + +/// Time `op` after at least 50 warmup calls and 3 seconds, record 50 batch-mean samples (2000 +/// operations per batch for split cases, 16 otherwise), and also record diagnostic single-call +/// latencies. Returns the counts, samples and summaries without p95/p99 where noted. +fn micro_entry(name: &str, params: Value, mut op: impl FnMut()) -> Value { + let warmup_start = Instant::now(); + let mut warmups = 0; + while warmups < WARMUPS || warmup_start.elapsed() < MIN_WARMUP { + op(); + warmups += 1; + } + let warmup_elapsed_ns = warmup_start.elapsed().as_nanos() as u64; + let mut samples = Vec::with_capacity(MICRO_SAMPLES); + let batch = if name.starts_with("split_") { 2000 } else { MICRO_BATCH }; + for _ in 0..MICRO_SAMPLES { + let start = Instant::now(); + for _ in 0..batch { + op(); + } + samples.push((start.elapsed().as_nanos() as u64) / batch as u64); + } + let mut summary = summarize(&samples); + if let Some(stats) = summary.as_object_mut() { + stats.remove("p95_ns"); + stats.remove("p99_ns"); + } + let mut individual = Vec::with_capacity(MICRO_SAMPLES); + for _ in 0..MICRO_SAMPLES { + let start = Instant::now(); + op(); + individual.push(start.elapsed().as_nanos() as u64); + } + let mut latency = summarize(&individual); + if let Some(stats) = latency.as_object_mut() { + stats.remove("p99_ns"); + } + let (sample_count, individual_count) = (samples.len(), individual.len()); + json!({"name": name, "params": params, "sample_kind": "mean_of_batch_ns", "iterations_per_sample": batch, + "operations": warmups + MICRO_SAMPLES * batch + MICRO_SAMPLES, + "warmups": warmups, "warmup_elapsed_ns": warmup_elapsed_ns, "samples": sample_count, "samples_ns": samples, + "stats": summary, "individual_samples": individual_count, "individual_latency_ns": individual, + "individual_latency_stats": latency, + "individual_latency_note": "diagnostic only; timer overhead dominates single sub-microsecond calls"}) +} + +/// Sort a copy of `samples` and return count, median, p95, p99, min and max in nanoseconds. +fn summarize(samples: &[u64]) -> Value { + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + json!({"count": sorted.len(), "median_ns": percentile(&sorted, 50), "p95_ns": percentile(&sorted, 95), + "p99_ns": percentile(&sorted, 99), "min_ns": sorted.first().copied().unwrap_or(0), + "max_ns": sorted.last().copied().unwrap_or(0)}) +} + +/// Nearest-rank percentile of an ascending slice. Empty input returns 0; the index is clamped to +/// the last element. +fn percentile(sorted: &[u64], percent: usize) -> u64 { + if sorted.is_empty() { + return 0; + } + sorted[(sorted.len() * percent).div_ceil(100).saturating_sub(1).min(sorted.len() - 1)] +} + +/// Use egui's bundled fonts, or an empty definition set with the `CRAFT_UI_PERF_FONT` file inserted +/// first in both proportional and monospace families. Returns the used font names. +fn font_definitions() -> Result<(egui::FontDefinitions, Vec), Box> { + let mut fonts = egui::FontDefinitions::default(); + if let Some(path) = env::var_os("CRAFT_UI_PERF_FONT") { + fonts = egui::FontDefinitions::empty(); + let name = "perf-fixture".to_owned(); + fonts.font_data.insert(name.clone(), Arc::new(egui::FontData::from_owned(std::fs::read(&path)?))); + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + fonts.families.entry(family).or_default().insert(0, name.clone()); + } + } + let names = fonts.font_data.keys().cloned().collect(); + Ok((fonts, names)) +} + +/// Describe the report's font source: the pinned `CRAFT_UI_PERF_FONT` file when +/// set, otherwise egui's bundled default fonts. +fn font_source() -> &'static str { + if env::var_os("CRAFT_UI_PERF_FONT").is_some() { "CRAFT_UI_PERF_FONT (pinned fixture file)" } else { "egui default_fonts (bundled)" } +} + +/// A headless context at `scale` with the given fonts, dark visuals, zero animation time and no +/// cursor blinking. +fn context(scale: f32, fonts: &egui::FontDefinitions) -> Context { + let ctx = Context::default(); + ctx.set_pixels_per_point(scale); + ctx.set_fonts(fonts.clone()); + ctx.set_visuals(egui::Visuals::dark()); + ctx.all_styles_mut(|style| { + style.animation_time = 0.0; + style.visuals.text_cursor.blink = false; + }); + ctx +} + +/// A headless screen rectangle from `(0, 0)` to `viewport[0] × viewport[1]`. +fn screen_rect(viewport: [f32; 2]) -> Rect { + Rect::from_min_size(pos2(0.0, 0.0), vec2(viewport[0], viewport[1])) +} + +/// A focused 60 Hz input for `viewport` with a bounded texture side for headless frames. +fn raw_input(viewport: [f32; 2], frame: usize) -> egui::RawInput { + egui::RawInput { + screen_rect: Some(screen_rect(viewport)), + time: Some(frame as f64 / 60.0), + focused: true, + max_texture_side: Some(4096), + ..Default::default() + } +} + +/// Check that tab fitting partitions every index exactly once, uses finite nonnegative widths, +/// stays within `available` (plus epsilon) including the overflow control, and keeps the selected +/// tab (clamped to the last tab). +fn check_tabs(natural: &[f32], selected: usize, available: f32, minimum: f32, overflow: f32) -> Result<(), Box> { + let fit = tabs::fit(natural, selected, available, minimum, overflow); + let mut seen = vec![false; natural.len()]; + let mut sum = 0.0f32; + for (index, width) in &fit.shown { + if *index >= natural.len() || seen[*index] || !width.is_finite() || *width < 0.0 { + return Err(format!("tabs_fit invalid shown entry ({index}, {width})").into()); + } + seen[*index] = true; + sum += *width; + } + for index in &fit.overflow { + if *index >= natural.len() || seen[*index] { + return Err(format!("tabs_fit invalid overflow index {index}").into()); + } + seen[*index] = true; + } + if seen.iter().any(|shown| !*shown) || sum + fit.overflow_width > available + EPS { + return Err(format!("tabs_fit did not partition {available}: sum={sum} overflow={}", fit.overflow_width).into()); + } + let selected = selected.min(natural.len().saturating_sub(1)); + if !natural.is_empty() && !fit.shown.iter().any(|(index, _)| *index == selected) { + return Err("tabs_fit dropped the selected tab".into()); + } + Ok(()) +} + +/// Check a split layout: both panes are positive and sum to the span minus the gap, do not overlap +/// or escape, `resized` stays finite, and an inverted rect is rejected. Returns the layout. +fn check_split(rect: Rect, axis: SplitAxis, size: SplitSize, gap: f32, minimum: f32) -> Result> { + let layout = split_rect(rect, axis, size, gap, minimum).ok_or("split_rect returned None")?; + let (span, first, second) = match axis { + SplitAxis::Horizontal => (rect.width(), layout.first.width(), layout.second.width()), + SplitAxis::Vertical => (rect.height(), layout.first.height(), layout.second.height()), + }; + let (divider_end, second_start) = match axis { + SplitAxis::Horizontal => (layout.divider.max.x, layout.second.min.x), + SplitAxis::Vertical => (layout.divider.max.y, layout.second.min.y), + }; + if !(first > 0.0 && second > 0.0) || (first + second + gap.min(span) - span).abs() > EPS { + return Err(format!("split_rect geometry: first={first} second={second} span={span}").into()); + } + if divider_end > second_start + EPS || !rect.expand(EPS).contains_rect(layout.first) || !rect.expand(EPS).contains_rect(layout.second) { + return Err("split_rect panes overlap or escape their rect".into()); + } + let next = layout.resized(size, 12.0, minimum).ok_or("split_resized returned None")?; + let finite = match next { + SplitSize::Ratio(v) | SplitSize::FixedFirst(v) | SplitSize::FixedSecond(v) => v.is_finite(), + }; + if !finite { + return Err("split_resized returned a non-finite size".into()); + } + if split_rect(Rect::from_min_max(pos2(10.0, 10.0), pos2(0.0, 0.0)), axis, size, gap, minimum).is_some() { + return Err("split_rect accepted an inverted rect".into()); + } + Ok(layout) +} + +/// Check that toolbar fitting is an exact prefix partition: shown widths match the natural widths, +/// overflow is the remaining suffix, a nonempty overflow reserves a positive control, and a +/// zero-width group is rejected. +fn check_toolbar(natural: &[f32], available: f32, spacing: f32, overflow: f32) -> Result<(), Box> { + let fit = fit_toolbar(natural, available, spacing, overflow).ok_or("fit_toolbar returned None")?; + let mut expected = 0; + for (index, width) in &fit.shown { + if *index >= natural.len() || *index != expected || (*width - natural[*index]).abs() > EPS { + return Err(format!("fit_toolbar broken prefix at {index}").into()); + } + expected += 1; + } + if fit.overflow.iter().any(|index| *index < expected) || expected + fit.overflow.len() != natural.len() { + return Err("fit_toolbar did not partition the groups".into()); + } + if !fit.overflow.is_empty() && fit.overflow_width <= 0.0 { + return Err("fit_toolbar overflowed without reserving a control".into()); + } + if fit_toolbar(&[0.0], available, spacing, overflow).is_some() { + return Err("fit_toolbar accepted a zero-width group".into()); + } + Ok(()) +} + +/// A one-panel tabs node with the panel active. +fn leaf(panel: u32) -> Node { + Node::Tabs { panels: vec![panel], active: 0 } +} + +/// A balanced recursive split fixture over `panels`, alternating the axis with `depth`. +fn tree(panels: &[u32], depth: usize) -> Node { + if panels.len() == 1 { + return leaf(panels[0]); + } + let mid = panels.len() / 2; + Node::Split { + axis: if depth.is_multiple_of(2) { SplitAxis::Horizontal } else { SplitAxis::Vertical }, + size: SplitSize::Ratio(0.5), + first: Box::new(tree(&panels[..mid], depth + 1)), + second: Box::new(tree(&panels[mid..], depth + 1)), + } +} + +/// The benchmark layout: with `with_floating`, a fixed 240 pt second column beside a vertical split +/// plus floating window id 1; otherwise a balanced tree over `panels`. +fn dock_layout(panels: u32, with_floating: bool) -> Layout { + if with_floating { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(240.0), + first: Box::new(Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::Ratio(0.55), + first: Box::new(leaf(0)), + second: Box::new(leaf(1)), + }), + second: Box::new(leaf(2)), + }), + 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(); + Layout { root: Some(tree(&all, 0)), floating: Vec::new() } + } +} + +/// Return the benchmark content's logical label, `Panel {panel}`. +fn panel_label(panel: &u32) -> String { + format!("Panel {panel}") +} + +/// Count texture-set images, their color bytes, and freed images in `delta`. +fn texture_counts(delta: &egui::TexturesDelta) -> (usize, usize, usize) { + let mut images = 0; + let mut bytes = 0; + for deltas in delta.set.values() { + for image in deltas { + images += 1; + let egui::ImageData::Color(data) = &image.image; + bytes += data.as_raw().len(); + } + } + (images, bytes, delta.free.len()) +} + +/// Sum mesh vertices and indices, rejecting renderer callbacks because benchmark fixtures must not +/// produce them. +fn count_mesh(primitives: &[egui::epaint::ClippedPrimitive]) -> Result<(usize, usize), Box> { + let (mut vertices, mut indices) = (0, 0); + for clipped in primitives { + match &clipped.primitive { + egui::epaint::Primitive::Mesh(mesh) => { + vertices += mesh.vertices.len(); + indices += mesh.indices.len(); + } + egui::epaint::Primitive::Callback(_) => return Err("the benchmark fixtures must not produce renderer callbacks".into()), + } + } + Ok((vertices, indices)) +} + +// Fingerprint of one frame's tessellated primitives, beyond equal vertex/index counts. Texture IDs and +// clip bounds are included. This is a 64-bit FNV-1a checksum, not an exact mesh identity. Timings stop +// before this evidence collection. +/// A 64-bit FNV-1a fingerprint over primitive count, clip rects, texture ids, vertex data and +/// indices. This is a diagnostic checksum, not exact mesh identity; callbacks are rejected. +fn mesh_hash(primitives: &[egui::epaint::ClippedPrimitive]) -> Result> { + let mut hash = 0xcbf29ce484222325u64; + let mut feed = |bytes: &[u8]| { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + }; + feed(&(primitives.len() as u64).to_le_bytes()); + for clipped in primitives { + for value in [clipped.clip_rect.min.x, clipped.clip_rect.min.y, clipped.clip_rect.max.x, clipped.clip_rect.max.y] { + feed(&value.to_bits().to_le_bytes()); + } + match &clipped.primitive { + egui::epaint::Primitive::Mesh(mesh) => { + feed(&[0]); + match mesh.texture_id { + egui::TextureId::Managed(id) => { + feed(&[0]); + feed(&id.to_le_bytes()); + } + egui::TextureId::User(id) => { + feed(&[1]); + feed(&id.to_le_bytes()); + } + } + feed(&(mesh.vertices.len() as u64).to_le_bytes()); + feed(&(mesh.indices.len() as u64).to_le_bytes()); + for vertex in &mesh.vertices { + for value in [vertex.pos.x, vertex.pos.y, vertex.uv.x, vertex.uv.y] { + feed(&value.to_bits().to_le_bytes()); + } + feed(&vertex.color.to_array()); + } + for index in &mesh.indices { + feed(&index.to_le_bytes()); + } + } + egui::epaint::Primitive::Callback(_) => return Err("the benchmark fixtures must not produce renderer callbacks".into()), + } + } + Ok(hash) +} + +/// Run `FRAME_SAMPLES` measured frames after at least 50 warmup frames and 3 seconds. Times and +/// completed passes are recorded for every measured frame; the final measured frame also collects +/// bodies and the shapes, mesh counts and hash reported in the result. Texture and repaint counters +/// aggregate over measured frames, and texture deltas are cleared each frame. +fn measure_frames( + ctx: &Context, + viewport: [f32; 2], + paint: &mut impl FnMut(&mut egui::Ui, &mut BTreeMap, bool), +) -> Result> { + let mut result = Frames::default(); + result.times_ns.reserve(FRAME_SAMPLES); + result.passes.reserve(FRAME_SAMPLES); + let warmup_start = Instant::now(); + let mut frame = 0; + while result.times_ns.len() < FRAME_SAMPLES { + let warming = frame < WARMUPS || warmup_start.elapsed() < MIN_WARMUP; + if !warming && result.times_ns.is_empty() { + result.galleys_after_warmup = ctx.fonts(|fonts| fonts.num_galleys_in_cache()); + result.warmup_elapsed_ns = warmup_start.elapsed().as_nanos() as u64; + } + // The last measured frame also collects panel bodies, so bodies, geometry and mesh evidence all + // describe the same rendered frame as the reported timings. + let collect = !warming && result.times_ns.len() + 1 == FRAME_SAMPLES; + result.bodies.clear(); + let start = Instant::now(); + let mut output = ctx.run_ui(raw_input(viewport, frame), |ui| paint(ui, &mut result.bodies, collect)); + let ppp = output.pixels_per_point; + let shapes = output.shapes.len(); + let primitives = ctx.tessellate(output.shapes, ppp); + if !warming { + result.times_ns.push(start.elapsed().as_nanos() as u64); + result.passes.push(output.platform_output.num_completed_passes); + result.shapes = shapes; + (result.vertices, result.indices) = count_mesh(&primitives)?; + result.mesh_hash = mesh_hash(&primitives)?; + let (sets, bytes, frees) = texture_counts(&output.textures_delta); + result.set_images += sets; + result.set_bytes += bytes; + result.free_images += frees; + if output.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|output| output.repaint_delay != std::time::Duration::MAX) { + result.repaint_frames += 1; + } + } else { + result.warmups += 1; + } + output.textures_delta.clear(); + frame += 1; + } + result.next_frame = frame; + Ok(result) +} + +/// Check every recorded body is positive, finite and inside the viewport; non-floating bodies may +/// not overlap by more than 0.5 pt. Returns the body count. +fn check_bodies(screen: Rect, bodies: &BTreeMap, floating: &BTreeSet) -> Result> { + for (panel, rect) in bodies { + if !rect.is_positive() || !rect.min.is_finite() || !rect.max.is_finite() { + return Err(format!("panel {panel} has empty or invalid body geometry {rect:?}").into()); + } + if !screen.expand(EPS * 4.0).contains_rect(*rect) { + return Err(format!("panel {panel} body {rect:?} escapes the viewport {screen:?}").into()); + } + } + let static_rects: Vec<(u32, Rect)> = bodies.iter().filter(|(panel, _)| !floating.contains(*panel)).map(|(panel, rect)| (*panel, *rect)).collect(); + for (i, (a, first)) in static_rects.iter().enumerate() { + for (b, second) in static_rects.iter().skip(i + 1) { + let overlap = first.intersect(*second); + if overlap.width() > 0.5 && overlap.height() > 0.5 { + return Err(format!("static panels {a} and {b} overlap: {overlap:?}").into()); + } + } + } + Ok(bodies.len()) +} + +/// Check every interaction owned by the dock has a response with a positive hit rect inside the +/// viewport. Returns the interaction count. +fn check_interactions(ctx: &Context, dock_id: Id, screen: Rect) -> Result> { + let owned = ctx.data(|data| data.get_temp::>(dock_id.with("owned-interactions"))).unwrap_or_default(); + if owned.is_empty() { + return Err("dock rendered no owned interactions".into()); + } + for id in &owned { + let response = ctx.read_response(*id).ok_or_else(|| format!("missing response for dock interaction {id:?}"))?; + if !response.interact_rect.is_positive() { + return Err(format!("dock interaction {id:?} has empty hit rect {:?}", response.interact_rect).into()); + } + if !screen.expand(EPS * 4.0).contains_rect(response.interact_rect) { + return Err(format!("dock interaction {id:?} hit rect {:?} escapes the viewport", response.interact_rect).into()); + } + } + Ok(owned.len()) +} + +/// Move the pointer to the first body's tab center in a separate unmeasured frame and require the +/// tab to hover; returns the probe JSON. The probe is excluded from timings and counters. +fn probe_tab_hover( + ctx: &Context, + viewport: [f32; 2], + dock_id: Id, + bodies: &BTreeMap, + frame: usize, + paint: &mut impl FnMut(&mut egui::Ui, &mut BTreeMap, bool), +) -> Result> { + let panel = bodies.keys().next().copied().ok_or("no panel body for hover probe")?; + let tab_id = dock_id.with(("tab", panel)); + let rect = ctx.read_response(tab_id).ok_or("no tab response for hover probe")?.rect; + let mut input = raw_input(viewport, frame); + input.events.push(egui::Event::PointerMoved(rect.center())); + let mut probe_bodies = BTreeMap::new(); + ctx.run_ui(input, |ui| paint(ui, &mut probe_bodies, true)).textures_delta.clear(); + if !ctx.read_response(tab_id).is_some_and(|response| response.hovered()) { + return Err(format!("pointer at {panel}'s tab center did not hover its tab").into()); + } + Ok(json!({"panel": panel, "tab_center": [rect.center().x, rect.center().y], "hovered": true, + "scope": "separate unmeasured frame after the samples; excluded from timings and counters"})) +} + +struct Case<'a> { + label: &'a str, + kind: &'a str, + panels: u32, + floating: bool, + side_panel: bool, + viewport: [f32; 2], + scale: f32, +} + +/// Render the fixture dock through `DockArea`, optionally recording each panel body's rect on the +/// collection frame, and return the dock error. +fn render_dock( + ui: &mut egui::Ui, + dock_id: Id, + layout: &Layout, + bodies: &mut BTreeMap, + style: &DockStyle, + collect: bool, +) -> Option { + DockArea::new(dock_id) + .show( + ui, + layout, + style, + panel_label, + |_| Permissions::default(), + |ui, panel| { + if collect { + bodies.insert(*panel, ui.max_rect()); + } + }, + ) + .error +} + +struct Scene { + dock_id: Id, + side_panel: bool, + style: Option, + error: Option, + dock_rect: Rect, + left_rect: Option, + central_rect: Option, +} + +impl Scene { + /// Render the dock inside an optional 180 pt left panel and central panel, capturing panel geometry + /// and preserving the first dock error; otherwise render at the available rect. + fn render(&mut self, layout: &Layout, ui: &mut egui::Ui, bodies: &mut BTreeMap, collect: bool) { + if self.style.is_none() { + self.style = Some(DockStyle::from_ui(ui)); + } + let Self { dock_id, side_panel, style, error, dock_rect, left_rect, central_rect } = self; + let Some(style) = style.as_ref() else { return }; + if *side_panel { + let _ = egui::Panel::left("ui-perf-left").resizable(false).exact_size(180.0).show(ui, |ui| { + *left_rect = Some(ui.max_rect()); + ui.label("Left panel"); + }); + let _ = egui::CentralPanel::default().show(ui, |ui| { + *central_rect = Some(ui.max_rect()); + let next = render_dock(ui, *dock_id, layout, bodies, style, collect); + if error.is_none() { + *error = next; + } + }); + } else { + *dock_rect = ui.available_rect_before_wrap(); + let next = render_dock(ui, *dock_id, layout, bodies, style, collect); + if error.is_none() { + *error = next; + } + } + } +} + +/// Run one case end to end: validate the layout, measure frames, gate sample count, warmup, one +/// completed pass per frame and dock error, check body count, interactions and geometry, run the +/// hover probe, and return the case JSON. +fn run_case(case: Case<'_>, fonts: &egui::FontDefinitions) -> Result> { + let ctx = context(case.scale, fonts); + let dock_id = Id::unique(("ui-perf", case.label)); + let layout = dock_layout(case.panels, case.floating); + layout.validate()?; + 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 = { + let mut paint = |ui: &mut egui::Ui, bodies: &mut BTreeMap, collect: bool| scene.render(&layout, ui, bodies, collect); + measure_frames(&ctx, case.viewport, &mut paint)? + }; + if frames.times_ns.len() != FRAME_SAMPLES { + return Err(format!("{} recorded {} of {FRAME_SAMPLES} frame samples", case.label, frames.times_ns.len()).into()); + } + if frames.warmups < WARMUPS || frames.warmup_elapsed_ns < MIN_WARMUP.as_nanos() as u64 { + return Err(format!("{} failed its warmup gate: warmups={} elapsed_ns={}", case.label, frames.warmups, frames.warmup_elapsed_ns).into()); + } + if frames.passes.len() != frames.times_ns.len() || frames.passes.iter().any(|passes| *passes != 1) { + return Err(format!("{} has measured frames without exactly one completed pass: {:?}", case.label, frames.passes).into()); + } + if let Some(error) = scene.error { + return Err(format!("{} reported {error}", case.label).into()); + } + let screen = screen_rect(case.viewport); + let body_count = check_bodies(screen, &frames.bodies, &floating)?; + let interaction_count = check_interactions(&ctx, dock_id, screen)?; + if body_count != case.panels as usize { + return Err(format!("{} rendered {body_count} of {} panel bodies", case.label, case.panels).into()); + } + let geometry = if case.side_panel { + let left = scene.left_rect.ok_or("left panel did not render")?; + let central = scene.central_rect.ok_or("central panel did not render")?; + if !left.is_positive() || !central.is_positive() || left.right() > central.left() + EPS { + return Err(format!("left/central panel geometry is invalid: left={left:?} central={central:?}").into()); + } + json!({"left_rect": [left.min.x, left.min.y, left.width(), left.height()], + "central_rect": [central.min.x, central.min.y, central.width(), central.height()]}) + } else { + let dock_rect = scene.dock_rect; + if !dock_rect.is_positive() { + return Err(format!("{} had an empty available rect", case.label).into()); + } + json!({"dock_rect": [dock_rect.min.x, dock_rect.min.y, dock_rect.width(), dock_rect.height()]}) + }; + let hover_probe = { + let mut probe = |ui: &mut egui::Ui, bodies: &mut BTreeMap, collect: bool| scene.render(&layout, ui, bodies, collect); + probe_tab_hover(&ctx, case.viewport, dock_id, &frames.bodies, frames.next_frame, &mut probe)? + }; + let frame_stats = summarize(&frames.times_ns); + Ok(json!({ + "case": case.label, "kind": case.kind, "panels": case.panels, "floating": case.floating, + "viewport_points": case.viewport, "viewport_physical": [case.viewport[0] * case.scale, case.viewport[1] * case.scale], + "pixels_per_point": case.scale, "frames": frames.times_ns.len(), "warmups": frames.warmups, + "warmup_elapsed_ns": frames.warmup_elapsed_ns, + "last_measured_mesh_fnv64": format!("{:016x}", frames.mesh_hash), + "last_measured_mesh_scope": "64-bit FNV-1a fingerprint of the final measured frame's primitives; not an exact identity", + "frame_scope": "Context::run_ui + Context::tessellate", + "frame_times_ns": frames.times_ns, "frame_stats": frame_stats, + "passes_per_frame": frames.passes, + "counter_scope": "shapes, vertices, indices and the mesh fingerprint describe the final measured frame, which also collects bodies and geometry; texture and repaint counters are summed over all measured frames", + "checks_scope": "final measured frame", + "galleys_after_warmup": frames.galleys_after_warmup, + "shapes": frames.shapes, "vertices": frames.vertices, "indices": frames.indices, + "texture_set_images": frames.set_images, "texture_set_bytes": frames.set_bytes, + "texture_free_images": frames.free_images, "repaint_frames": frames.repaint_frames, + "galleys": ctx.fonts(|fonts| fonts.num_galleys_in_cache()), + "hover_probe": hover_probe, + "checks": {"bodies": body_count, "interactions": interaction_count, "geometry": geometry}, + })) +} diff --git a/crates/ui/src/capture.rs b/crates/ui/src/capture.rs new file mode 100644 index 00000000..848469ff --- /dev/null +++ b/crates/ui/src/capture.rs @@ -0,0 +1,251 @@ +//! One-shot completion slots for screenshot requests. +//! +//! Applications retain request IDs, output paths, deadlines, settling policy, geometry epochs and +//! aggregate admission budgets. This module only correlates one capture with one slot, checks the +//! retained image against a per-image byte bound, and wakes the owning viewport. +//! +//! [`CaptureSlot::new`] does not issue a screenshot command. Send the returned [`egui::ScreenshotCallback`] +//! with [`egui::ViewportCommand::Screenshot`] when the application decides the capture is due, or +//! complete it directly from a headless renderer. The callback holds only a weak reference to the +//! slot, so dropping or cancelling the request releases its image and makes late results inert. +//! +//! Every retry needs a fresh slot and a fresh callback from [`CaptureSlot::new`]; a callback is +//! one-shot, and a slot ignores completions after its result was taken, cancelled or dropped. The +//! slot does not track whether a request is still open: keep that pending/closed distinction in the +//! application's request record. +//! +//! A completion that publishes its result just before a concurrent [`CaptureSlot::cancel`] or drop +//! can still deliver its single wakeup afterwards. That extra repaint is harmless: if the +//! cancellation won, the image is released and a later [`CaptureSlot::take`] returns `None`; if +//! `take` won first, the caller already owns the result and nothing else changes. A completion that +//! observes a cancelled or taken slot stores no image and does not wake. Cancellation and completion +//! are therefore not atomic with respect to the wakeup, and this module never waits for another +//! thread while holding the slot lock. +//! +//! [`CaptureSlot`] holds a strong [`egui::Context`]. Keep it in the application's request record; +//! storing it in `Context`-owned data (`egui::Memory::data` or a registered plugin) forms a +//! `Context` → slot → `Context` cycle that keeps both alive. + +use std::mem::size_of; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; + +use egui::{Color32, ColorImage, Context, ScreenshotCallback, ViewportId}; + +/// Why a completed screenshot was rejected and released. +/// +/// The enum is `#[non_exhaustive]`: consumers should match the known variants and use a wildcard +/// arm for a future rejection reason. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaptureError { + /// The checked `width * height` pixel count, or its `width * height * size_of::()` + /// byte count, overflowed `usize`. + DimensionsOverflow { width: usize, height: usize }, + /// The pixel buffer does not match the reported image dimensions, or the image has zero width + /// or height. + MalformedImage { width: usize, height: usize, pixels: usize }, + /// The checked image size exceeds the slot's per-image byte bound. + ImageTooLarge { bytes: usize, max_bytes: usize }, +} + +impl core::fmt::Display for CaptureError { + /// Format a human-readable rejection reason. The wording is informative only and is not an + /// API contract; the variants keep checked-overflow, malformed-image and size-cap cases + /// distinguishable. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::DimensionsOverflow { width, height } => { + write!(f, "screenshot dimensions {width}×{height} overflow the checked pixel or byte count") + } + Self::MalformedImage { width, height, pixels } => write!(f, "screenshot has {pixels} pixels for its {width}×{height} dimensions"), + Self::ImageTooLarge { bytes, max_bytes } => write!(f, "screenshot uses {bytes} bytes, over the {max_bytes}-byte per-image limit"), + } + } +} + +impl std::error::Error for CaptureError {} + +enum SlotState { + Pending, + Ready(Result, CaptureError>), + Taken, + Cancelled, +} + +struct Shared { + state: Mutex, + /// The live request owns the strong context; the callback only upgrades it briefly to wake. + context: Context, + viewport: ViewportId, + max_image_bytes: usize, +} + +/// The live request side of a capture. Keep it alive until the result is taken or the request is +/// cancelled; dropping it cancels the completion and releases any retained image. +/// +/// This type is `Send + Sync`; the application may hold it in its request record and poll +/// [`Self::take`] from its logic pass. Do not store it in `Context`-owned storage: the slot holds a +/// strong [`egui::Context`], so `egui::Memory::data` or a registered plugin would create a cycle. +#[must_use = "the slot must be kept alive to receive and take the screenshot"] +pub struct CaptureSlot { + shared: Arc, +} + +impl CaptureSlot { + /// Create a completion slot and its one-shot callback. + /// + /// `max_image_bytes` bounds the single retained image only. The application must separately + /// reserve its aggregate in-flight/completed budget before issuing the request. + /// + /// This function does not send a viewport command. If the request is issued in a UI pass, send + /// the callback with [`egui::ViewportCommand::Screenshot`]; a pending screenshot command itself + /// requests the paint needed while the window is hidden. Issue that command at most once per + /// slot, after the application has settled the frame it wants; every retry needs a fresh slot + /// and callback. + pub fn new(context: &Context, viewport: ViewportId, max_image_bytes: usize) -> (Self, ScreenshotCallback) { + let shared = Arc::new(Shared { state: Mutex::new(SlotState::Pending), context: context.clone(), viewport, max_image_bytes }); + let weak = Arc::downgrade(&shared); + let callback = ScreenshotCallback::new(move |image| complete(&weak, image)); + (Self { shared }, callback) + } + + /// Take the completed result, at most once. + /// + /// Returns `None` while the capture is pending, after it was cancelled or dropped, and after a + /// previous call already took the image or error. The slot cannot distinguish those states; + /// the application's request record owns the pending/closed distinction. Invalid images are + /// returned as an error so the application can report completion failure with its own request + /// identity. + pub fn take(&self) -> Option, CaptureError>> { + let mut state = lock(&self.shared.state); + if !matches!(&*state, SlotState::Ready(_)) { + return None; + } + match core::mem::replace(&mut *state, SlotState::Taken) { + SlotState::Ready(result) => Some(result), + _ => None, + } + } + + /// Cancel the request and release any completed image. + /// + /// A completion that published its result before this call may still deliver one harmless + /// repaint afterwards; the slot retains no image and a later [`Self::take`] returns `None`. A + /// completion that observes the cancellation stores no image and does not wake. Calling `cancel` + /// after [`Self::take`] is harmless. Any retained image is dropped after the slot mutex is + /// unlocked. + pub fn cancel(&self) { + close(&self.shared); + } + + #[cfg(test)] + /// Nonblocking probe used by the wakeup test to prove that the slot mutex is free while the + /// repaint observer runs. + fn lock_is_free(&self) -> bool { + self.shared.state.try_lock().is_ok() + } + + #[cfg(test)] + /// Count strong references to the shared slot state, proving callbacks retain none. + fn state_strong_count(&self) -> usize { + Arc::strong_count(&self.shared) + } + + #[cfg(test)] + /// A weak handle to the shared state, used to inject a second completion directly when egui has + /// already consumed the public callback. + fn state_weak(&self) -> Weak { + Arc::downgrade(&self.shared) + } +} + +impl Drop for CaptureSlot { + /// Cancel the request as the slot goes away. A later completion upgrades no live state and + /// releases its image, a retained ready image is dropped, and no repaint is requested. Results + /// already taken by the caller are unaffected. + fn drop(&mut self) { + close(&self.shared); + } +} + +impl core::fmt::Debug for CaptureSlot { + /// Format only the viewport and omit the remaining fields. The retained image and error payload + /// are deliberately hidden so logging cannot hold or dump screenshot contents. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CaptureSlot").field("viewport", &self.shared.viewport).finish_non_exhaustive() + } +} + +/// Lock the slot state, recovering from poisoning instead of panicking. The guard must be +/// released before entering the egui context or a repaint observer; completion and close do so +/// before waking the viewport. +fn lock(state: &Mutex) -> MutexGuard<'_, SlotState> { + state.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Mark the slot closed and drop any retained image after releasing the mutex. +fn close(shared: &Shared) { + let previous = { + let mut state = lock(&shared.state); + core::mem::replace(&mut *state, SlotState::Cancelled) + }; + drop(previous); +} + +/// Validate a completed image, store it, and wake the owning viewport. +/// +/// The slot lock is released before calling into the context, so a repaint observer may call +/// [`CaptureSlot::take`] or [`CaptureSlot::cancel`] re-entrantly. Inside an observer, only consume or +/// release that value; any further `Context` access must happen outside the observer. +/// [`egui::Context::request_repaint_of`] takes egui's context lock. Ordinary `Ui` closures and egui +/// 0.37 plugin hooks run outside that lock, as do renderer/worker callbacks. Repaint observers and +/// request-repaint callbacks run under it: completing from either would deadlock when requesting +/// the repaint. This function never waits under the slot lock. +fn complete(weak: &Weak, image: Arc) { + let Some(shared) = weak.upgrade() else { + // The request was dropped; the image is released here without a reply. + return; + }; + let result = checked_image(image, shared.max_image_bytes); + let should_wake = { + let mut state = lock(&shared.state); + if matches!(&*state, SlotState::Pending) { + *state = SlotState::Ready(result); + true + } else { + // Cancelled, already taken, or (defensively) completed twice: drop the image. + false + } + }; + // The slot lock is released before calling into the context, so a repaint observer may take + // the result or cancel the request re-entrantly without deadlocking. + if should_wake { + shared.context.request_repaint_of(shared.viewport); + } +} + +/// Validate `image` against the slot's per-image byte bound before publishing it. +/// +/// Zero width or height is malformed before any arithmetic; the pixel count and its byte size use +/// checked multiplication; the buffer length must equal `width * height`; and only then is the +/// byte count compared with `max_image_bytes`, rejecting a count greater than it (equality is +/// accepted). `max_image_bytes` bounds this one image, not an aggregate request budget. Returns the +/// same `Arc` without waking the viewport or requesting a repaint. +fn checked_image(image: Arc, max_image_bytes: usize) -> Result, CaptureError> { + let [width, height] = image.size; + if width == 0 || height == 0 { + return Err(CaptureError::MalformedImage { width, height, pixels: image.pixels.len() }); + } + let pixels = width.checked_mul(height).ok_or(CaptureError::DimensionsOverflow { width, height })?; + let bytes = pixels.checked_mul(size_of::()).ok_or(CaptureError::DimensionsOverflow { width, height })?; + if image.pixels.len() != pixels { + return Err(CaptureError::MalformedImage { width, height, pixels: image.pixels.len() }); + } + if bytes > max_image_bytes { + return Err(CaptureError::ImageTooLarge { bytes, max_bytes: max_image_bytes }); + } + Ok(image) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui/src/capture/tests.rs b/crates/ui/src/capture/tests.rs new file mode 100644 index 00000000..dd666d71 --- /dev/null +++ b/crates/ui/src/capture/tests.rs @@ -0,0 +1,491 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Barrier}; + +use super::*; + +/// Allocate a filled `Arc` fixture of `size` with every pixel set to `color`. +fn image(size: [usize; 2], color: egui::Color32) -> Arc { + Arc::new(ColorImage::filled(size, color)) +} + +/// Install a repaint observer and return the viewport requests it records. +fn observe_repaints(ctx: &Context) -> Arc>> { + let requests = Arc::new(Mutex::new(Vec::new())); + let observed = requests.clone(); + ctx.set_repaint_observer(move |info| observed.lock().unwrap().push(info.viewport_id)); + requests +} + +#[test] +/// Compile-time assertion that `CaptureSlot` and `ScreenshotCallback` are `Send + Sync`. +fn slot_is_send_and_sync() { + /// Compile-time helper requiring `T: Send + Sync`; the enclosing test uses it to + /// assert the public capture types are thread-safe. + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); +} + +#[test] +/// Two independent slots for different viewports may complete in reverse order; each `take` +/// returns exactly its own image and dimensions. +fn distinct_requests_complete_in_reverse_order() { + let ctx = Context::default(); + let first_viewport = ViewportId::from_hash_of("first-window"); + let second_viewport = ViewportId::from_hash_of("second-window"); + let (first, first_callback) = CaptureSlot::new(&ctx, first_viewport, 1024); + let (second, second_callback) = CaptureSlot::new(&ctx, second_viewport, 1024); + let first_image = image([2, 1], egui::Color32::RED); + let second_image = image([1, 3], egui::Color32::BLUE); + + // The renderer may answer the later request first. + second_callback.complete(second_image.clone()); + first_callback.complete(first_image.clone()); + + let taken_first = first.take().unwrap().unwrap(); + let taken_second = second.take().unwrap().unwrap(); + assert!(Arc::ptr_eq(&taken_first, &first_image)); + assert!(Arc::ptr_eq(&taken_second, &second_image)); + assert_eq!(taken_first.size, [2, 1]); + assert_eq!(taken_second.size, [1, 3]); +} + +#[test] +/// Slots keep separate results and byte caps: a 2×2 image over the first slot's 4-byte cap is +/// rejected as too large while the second stays pending, then the second accepts its own image and +/// a taken slot remains empty. +fn slots_do_not_share_results_or_byte_bounds() { + let ctx = Context::default(); + let first_viewport = ViewportId::from_hash_of("first-slot"); + let second_viewport = ViewportId::from_hash_of("second-slot"); + let (first, first_callback) = CaptureSlot::new(&ctx, first_viewport, 4); + let (second, second_callback) = CaptureSlot::new(&ctx, second_viewport, 16); + + let rejected_for_first = image([2, 2], egui::Color32::RED); + first_callback.complete(rejected_for_first); + + assert!(second.take().is_none(), "completing one slot leaves the other pending"); + assert!(matches!(first.take(), Some(Err(CaptureError::ImageTooLarge { bytes: 16, max_bytes: 4 })))); + + let accepted_for_second = image([2, 2], egui::Color32::BLUE); + second_callback.complete(accepted_for_second.clone()); + assert!(Arc::ptr_eq(&second.take().unwrap().unwrap(), &accepted_for_second)); + assert!(first.take().is_none(), "a taken slot stays empty"); +} + +#[test] +/// Cloned callbacks share one completion: the first result wins, the losing image is released, and +/// `take` yields the result only once. +fn callback_clones_share_one_completion_and_take_is_single_use() { + let ctx = Context::default(); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + let duplicate = callback.clone(); + let first_image = image([1, 1], egui::Color32::RED); + let second_image = image([1, 1], egui::Color32::BLUE); + let weak_loser = Arc::downgrade(&second_image); + + callback.complete(first_image.clone()); + duplicate.complete(second_image); + assert!(weak_loser.upgrade().is_none(), "the losing completion releases its image"); + + let taken = slot.take().unwrap().unwrap(); + assert!(Arc::ptr_eq(&taken, &first_image), "the first completion wins"); + assert!(slot.take().is_none(), "the result is taken once"); +} + +#[test] +/// After `take`, both a duplicate callback and a direct completion release their images without +/// storing a result or requesting a repaint. +fn duplicate_completion_after_take_neither_stores_nor_wakes() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + let duplicate_callback = callback.clone(); + let first_image = image([1, 1], egui::Color32::RED); + callback.complete(first_image.clone()); + assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &first_image)); + assert_eq!(requests.lock().unwrap().len(), 1, "the first completion wakes"); + + let duplicate_image = image([1, 1], egui::Color32::BLUE); + let weak_duplicate = Arc::downgrade(&duplicate_image); + duplicate_callback.complete(duplicate_image); + + assert!(weak_duplicate.upgrade().is_none(), "a duplicate after take is released"); + assert!(slot.take().is_none()); + assert_eq!(requests.lock().unwrap().len(), 1, "a duplicate after take does not wake"); + + // Deliver a second completion straight to the slot; egui already consumed its callback. + let direct_image = image([1, 1], egui::Color32::GREEN); + let weak_direct = Arc::downgrade(&direct_image); + complete(&slot.state_weak(), direct_image); + assert!(weak_direct.upgrade().is_none(), "a direct second completion after take is released"); + assert!(slot.take().is_none()); + assert_eq!(requests.lock().unwrap().len(), 1, "a direct second completion after take does not wake"); +} + +#[test] +/// An early `take` returns `None` without closing the request; a later completion still delivers. +fn take_before_completion_is_empty_and_keeps_the_request_open() { + let ctx = Context::default(); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + assert!(slot.take().is_none()); + let expected = image([1, 1], egui::Color32::RED); + callback.complete(expected.clone()); + assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &expected)); +} + +#[test] +/// Cancelling before completion releases a late image, leaves `take` empty, and suppresses the +/// repaint wakeup. +fn cancel_releases_late_images_without_waking() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let viewport = ViewportId::from_hash_of("cancelled-window"); + let (slot, callback) = CaptureSlot::new(&ctx, viewport, 1024); + slot.cancel(); + + let late_image = image([2, 2], egui::Color32::RED); + let weak_image = Arc::downgrade(&late_image); + callback.complete(late_image.clone()); + drop(late_image); + + assert!(weak_image.upgrade().is_none(), "a cancelled slot releases the late image"); + assert!(slot.take().is_none()); + assert!(requests.lock().unwrap().is_empty(), "cancellation suppresses the wakeup"); +} + +#[test] +/// A ready slot retains its image and has woken once; `cancel` releases the image and does not wake +/// again. +fn cancel_after_ready_releases_the_image() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + let completed = image([2, 2], egui::Color32::RED); + let weak_image = Arc::downgrade(&completed); + callback.complete(completed.clone()); + drop(completed); + assert!(weak_image.upgrade().is_some(), "the ready slot retains the image"); + assert_eq!(requests.lock().unwrap().len(), 1, "the completion woke the viewport"); + + slot.cancel(); + + assert!(weak_image.upgrade().is_none(), "cancel releases a ready image"); + assert!(slot.take().is_none()); + assert_eq!(requests.lock().unwrap().len(), 1, "cancellation itself does not wake"); +} + +#[test] +/// Dropping the slot releases an image that is only retained by the completed slot. +fn dropping_the_slot_releases_a_completed_image() { + let ctx = Context::default(); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + let completed = image([2, 2], egui::Color32::RED); + callback.complete(completed.clone()); + let weak_image = Arc::downgrade(&completed); + drop(completed); + assert!(weak_image.upgrade().is_some(), "the slot retains the completed image"); + + drop(slot); + assert!(weak_image.upgrade().is_none(), "dropping the slot releases its image"); +} + +#[test] +/// A completion after the slot is dropped releases the image and does not wake the viewport. +fn late_completion_after_drop_is_inert() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let viewport = ViewportId::ROOT; + let (slot, callback) = CaptureSlot::new(&ctx, viewport, 1024); + drop(slot); + + let late_image = image([1, 1], egui::Color32::RED); + let weak_image = Arc::downgrade(&late_image); + callback.complete(late_image.clone()); + drop(late_image); + + assert!(weak_image.upgrade().is_none()); + assert!(requests.lock().unwrap().is_empty(), "a late result does not wake the viewport"); +} + +#[test] +/// Callbacks hold only a weak reference: the slot state has one strong owner, dropping the slot +/// releases the context and state, and a later completion is inert. +fn callback_retains_only_a_weak_reference_to_live_state() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + let callback_clone = callback.clone(); + assert_eq!(slot.state_strong_count(), 1, "the callback must not own the slot state"); + + let weak_state = slot.state_weak(); + drop(slot); + assert!(weak_state.upgrade().is_none(), "dropping the slot releases its context and state"); + + let late_image = image([1, 1], egui::Color32::RED); + let weak_image = Arc::downgrade(&late_image); + callback_clone.complete(late_image.clone()); + drop(late_image); + assert!(weak_image.upgrade().is_none()); + assert!(requests.lock().unwrap().is_empty()); +} + +#[test] +/// A completion distinguishes the per-image size cap, a malformed pixel buffer, and overflow of +/// the pixel or byte count; a returned error is taken at most once. +fn rejects_oversized_malformed_and_overflowing_images() { + let ctx = Context::default(); + + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 16); + callback.complete(image([3, 3], egui::Color32::RED)); + assert!(matches!(slot.take(), Some(Err(CaptureError::ImageTooLarge { bytes: 36, max_bytes: 16 })))); + assert!(slot.take().is_none(), "a completion error is reported once"); + + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + callback.complete(Arc::new(ColorImage { size: [2, 2], source_size: egui::Vec2::ZERO, pixels: vec![egui::Color32::RED; 3] })); + assert!(matches!(slot.take(), Some(Err(CaptureError::MalformedImage { width: 2, height: 2, pixels: 3 })))); + + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + callback.complete(Arc::new(ColorImage { size: [usize::MAX, 2], source_size: egui::Vec2::ZERO, pixels: Vec::new() })); + assert!(matches!(slot.take(), Some(Err(CaptureError::DimensionsOverflow { width, height })) if width == usize::MAX && height == 2)); + + // The pixel count fits but the byte count does not. + let wide = usize::MAX / 2; + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + callback.complete(Arc::new(ColorImage { size: [wide, 1], source_size: egui::Vec2::ZERO, pixels: Vec::new() })); + assert!(matches!(slot.take(), Some(Err(CaptureError::DimensionsOverflow { width, height })) if width == wide && height == 1)); +} + +#[test] +/// Oversized and malformed images are dropped immediately and each rejection still wakes the error +/// consumer exactly once before `take` reports it. +fn rejected_oversized_and_malformed_images_are_released_before_take() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 16); + let oversized = image([3, 3], egui::Color32::RED); + let weak_oversized = Arc::downgrade(&oversized); + callback.complete(oversized); + assert!(weak_oversized.upgrade().is_none(), "an oversized image is released"); + assert_eq!(*requests.lock().unwrap(), [ViewportId::ROOT], "rejection wakes the error consumer"); + assert!(matches!(slot.take(), Some(Err(CaptureError::ImageTooLarge { bytes: 36, max_bytes: 16 })))); + + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + let malformed = Arc::new(ColorImage { size: [2, 2], source_size: egui::Vec2::ZERO, pixels: vec![egui::Color32::RED; 3] }); + let weak_malformed = Arc::downgrade(&malformed); + callback.complete(malformed); + assert!(weak_malformed.upgrade().is_none(), "a malformed image is released"); + assert_eq!(*requests.lock().unwrap(), [ViewportId::ROOT, ViewportId::ROOT]); + assert!(matches!(slot.take(), Some(Err(CaptureError::MalformedImage { width: 2, height: 2, pixels: 3 })))); +} + +#[test] +/// Any zero width or height is rejected as malformed before overflow or size-cap decisions, even +/// for extreme extents such as `usize::MAX × 0`. +fn zero_area_images_are_rejected_even_with_extreme_extents() { + let ctx = Context::default(); + + for size in [[0, 4], [4, 0], [0, 0], [usize::MAX, 0], [0, usize::MAX]] { + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + callback.complete(Arc::new(ColorImage { size, source_size: egui::Vec2::ZERO, pixels: Vec::new() })); + let rejected = slot.take(); + assert!( + matches!(rejected, Some(Err(CaptureError::MalformedImage { width, height, pixels })) + if width == size[0] && height == size[1] && pixels == 0), + "zero-area image {size:?} must be rejected" + ); + } +} + +#[test] +/// A 2×2 image at exactly the 16-byte cap is accepted: the bound is inclusive. +fn accepts_an_image_exactly_at_the_byte_bound() { + let ctx = Context::default(); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 16); + let exact = image([2, 2], egui::Color32::GREEN); + callback.complete(exact.clone()); + assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &exact)); +} + +#[test] +/// A worker thread completes a child-window slot; every recorded wake belongs to that slot's +/// viewport and the expected image is delivered. +fn another_thread_completes_and_wakes_only_the_owning_viewport() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let viewport = ViewportId::from_hash_of("child-window"); + let (slot, callback) = CaptureSlot::new(&ctx, viewport, 1024); + let expected = image([1, 1], egui::Color32::RED); + let sent = expected.clone(); + + let handle = std::thread::spawn(move || callback.complete(sent)); + handle.join().unwrap(); + + let requested = requests.lock().unwrap(); + assert!(!requested.is_empty(), "completion wakes the owning viewport"); + assert!(requested.iter().all(|requested_viewport| *requested_viewport == viewport), "completion wakes only the owning viewport: {requested:?}"); + drop(requested); + assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &expected)); +} + +#[test] +/// The repaint observer can `take` the image re-entrantly: `try_lock` succeeds during the wakeup, +/// proving the slot lock is released before `request_repaint_of`. +fn wakeup_runs_after_the_slot_lock_is_released() { + let ctx = Context::default(); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024); + let slot = Arc::new(slot); + let weak_slot = Arc::downgrade(&slot); + let observed = Arc::new(AtomicBool::new(false)); + let lock_free = Arc::new(AtomicBool::new(true)); + let taken = Arc::new(AtomicBool::new(false)); + let observer_observed = observed.clone(); + let observer_lock_free = lock_free.clone(); + let observer_taken = taken.clone(); + ctx.set_repaint_observer(move |info| { + if info.viewport_id == ViewportId::ROOT { + observer_observed.store(true, Ordering::SeqCst); + if let Some(slot) = weak_slot.upgrade() { + if slot.lock_is_free() { + observer_taken.store(slot.take().is_some(), Ordering::SeqCst); + } else { + observer_lock_free.store(false, Ordering::SeqCst); + } + } + } + }); + + callback.complete(image([1, 1], egui::Color32::RED)); + + assert!(observed.load(Ordering::SeqCst), "completion wakes the owning viewport"); + assert!(lock_free.load(Ordering::SeqCst), "the slot lock is released before the wakeup"); + assert!(taken.load(Ordering::SeqCst), "the observer can consume the image reentrantly"); + assert!(slot.take().is_none()); +} + +/// Complete, cancel and take all racing on one slot. Every interleaving must leave at most one +/// wake and at most one taken result, and every image must be released once the slot and result +/// are dropped. +#[test] +fn complete_cancel_and_take_race_without_retaining_or_double_waking() { + const RACE_TRIALS: usize = 100; + + for trial in 0..RACE_TRIALS { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let viewport = ViewportId::from_hash_of("race-window"); + let (slot, callback) = CaptureSlot::new(&ctx, viewport, 4096); + let slot = Arc::new(slot); + let completed = image([1, 1], egui::Color32::RED); + let weak_image = Arc::downgrade(&completed); + let barrier = Arc::new(Barrier::new(3)); + + let taken = std::thread::scope(|scope| { + let completion_barrier = barrier.clone(); + let completion_image = completed.clone(); + scope.spawn(move || { + completion_barrier.wait(); + callback.complete(completion_image); + }); + + let cancel_barrier = barrier.clone(); + let cancel_slot = slot.clone(); + scope.spawn(move || { + cancel_barrier.wait(); + cancel_slot.cancel(); + }); + + barrier.wait(); + slot.take() + }); + + let wake_count = requests.lock().unwrap().len(); + assert!(wake_count <= 1, "trial {trial}: one completion wakes at most once, got {wake_count}"); + if taken.is_some() { + assert_eq!(wake_count, 1, "trial {trial}: a published result wakes exactly once"); + } + if let Some(result) = &taken { + match result { + Ok(taken_image) => assert!(Arc::ptr_eq(taken_image, &completed), "trial {trial}: only the completed image is returned"), + Err(error) => panic!("trial {trial}: a valid image was rejected: {error:?}"), + } + } + + drop(taken); + drop(slot); + drop(completed); + assert!(weak_image.upgrade().is_none(), "trial {trial}: the slot and any taken result release the image"); + } +} + +#[test] +/// A zero byte budget rejects any nonempty image as too large and wakes once. +fn zero_budget_rejects_a_nonempty_image_and_wakes() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 0); + callback.complete(image([1, 1], egui::Color32::GREEN)); + assert!(matches!(slot.take(), Some(Err(CaptureError::ImageTooLarge { bytes: 4, max_bytes: 0 })))); + assert_eq!(*requests.lock().unwrap(), [ViewportId::ROOT]); +} + +#[test] +/// Two threads racing on `take` produce exactly one result, and a subsequent take is empty. +fn concurrent_takers_receive_the_result_exactly_once() { + let ctx = Context::default(); + let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 4); + let expected = image([1, 1], egui::Color32::GREEN); + callback.complete(expected.clone()); + let barrier = Barrier::new(3); + let results = std::thread::scope(|scope| { + let first = scope.spawn(|| { + barrier.wait(); + slot.take() + }); + let second = scope.spawn(|| { + barrier.wait(); + slot.take() + }); + barrier.wait(); + [first.join().unwrap(), second.join().unwrap()] + }); + assert_eq!(results.iter().filter(|result| result.is_some()).count(), 1); + for result in results.into_iter().flatten() { + assert!(Arc::ptr_eq(&result.unwrap(), &expected)); + } + assert!(slot.take().is_none()); +} + +#[test] +/// A renderer thread can complete a capture while a UI closure is blocked; the image is delivered +/// and the owning child viewport is woken exactly once. +fn renderer_completion_during_a_live_ui_pass_can_wake_and_deliver() { + let ctx = Context::default(); + let requests = observe_repaints(&ctx); + let viewport = ViewportId::from_hash_of("render-readback"); + let (slot, callback) = CaptureSlot::new(&ctx, viewport, 4); + let expected = image([1, 1], egui::Color32::GREEN); + let sent = expected.clone(); + let (start_tx, start_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + std::thread::scope(|scope| { + scope.spawn(move || { + start_rx.recv().unwrap(); + callback.complete(sent); + done_tx.send(()).unwrap(); + }); + let mut start_tx = Some(start_tx); + let mut output = ctx.run_ui(Default::default(), |ui| { + ui.label("A live frame while readback completes"); + if let Some(tx) = start_tx.take() { + tx.send(()).unwrap(); + done_rx.recv_timeout(std::time::Duration::from_secs(3)).expect("readback must complete while the UI closure is active"); + } + }); + // This synchronization test has no renderer to apply the atlas upload. + output.textures_delta.clear(); + }); + assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &expected)); + assert_eq!(requests.lock().unwrap().iter().filter(|id| **id == viewport).count(), 1); +} diff --git a/crates/ui/src/direction/mod.rs b/crates/ui/src/direction/mod.rs new file mode 100644 index 00000000..37079b83 --- /dev/null +++ b/crates/ui/src/direction/mod.rs @@ -0,0 +1,429 @@ +//! Interface direction for right-to-left languages (Arabic, Hebrew, Persian, Urdu, …). +//! +//! In a right-to-left interface the chrome mirrors, as in right-to-left macOS and Windows apps: +//! the panel that sits at the left moves to the right, rows and menu bars start at the right, +//! menus open under their button's right edge, and back arrows point right. Content keeps its +//! geometry: [`Unmirrored`] is the suite-wide list of what never mirrors. +//! +//! egui has no direction for a whole context. A `Ui` runs left to right unless its layout says +//! otherwise, and panels, popups and hand-painted positions name a side. Call sites name the +//! reading start or end through these helpers instead of left or right. Rows nested in a [`row`] +//! follow it on their own: egui starts a `ui.horizontal*` row from the right when its parent +//! layout runs right to left. +//! +//! # Setting the direction +//! +//! [`resolve`] picks the direction for the UI language: the `CRAFT_UI_DIRECTION` environment +//! variable first (`rtl` or `ltr`, for checking a mirrored layout before a right-to-left catalog +//! exists), then the app's [`Setting`], then the language's script ([`craft_i18n::direction`]). +//! [`apply`] stores it on the context; call it at startup and whenever the language changes. +//! Every helper reads it from a [`HasDirection`]: a `Context`, a `Ui`, or a [`Direction`] itself. +//! Tests force a direction by calling [`apply`] on their own context. +//! +//! # egui's own chrome +//! +//! The suite's egui fork adds `Style::right_to_left`, which mirrors submenus, scroll bars, +//! collapsing-header triangles and window title bars. When the app patches that fork in, [`apply`] +//! sets it on every theme's style; with crates.io egui, egui's own chrome stays left to right. +//! Installing a whole new [`egui::Style`] resets the field, so call [`apply`] after changing theme. + +use egui::{Align, Align2, AsIdSalt, Context, Id, InnerResponse, Key, Layout, Panel, Rect, RectAlign, Ui, pos2, vec2}; + +pub use craft_i18n::Direction; + +/// The environment variable that forces a direction whatever the language and setting. +pub const ENV_VAR: &str = "CRAFT_UI_DIRECTION"; + +/// Where the interface direction comes from: an app setting, usually hidden in a debug section. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "lowercase"))] +pub enum Setting { + /// Follow the UI language. + #[default] + Language, + /// Left to right whatever the language. + Ltr, + /// Right to left whatever the language. + Rtl, +} + +impl Setting { + /// The direction this setting gives for a UI language tag. + pub fn direction(self, ui_language: &str) -> Direction { + match self { + Setting::Language => craft_i18n::direction(ui_language), + Setting::Ltr => Direction::Ltr, + Setting::Rtl => Direction::Rtl, + } + } + + /// `language`, `ltr` or `rtl`, any case, as [`ENV_VAR`] and settings files spell them. + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "language" => Some(Setting::Language), + "ltr" => Some(Setting::Ltr), + "rtl" => Some(Setting::Rtl), + _ => None, + } + } +} + +/// The interface direction for a UI language tag: [`ENV_VAR`] when set to `rtl` or `ltr`, then +/// `setting`, then the language's script. +pub fn resolve(ui_language: &str, setting: Setting) -> Direction { + env_setting().filter(|s| *s != Setting::Language).unwrap_or(setting).direction(ui_language) +} + +fn env_setting() -> Option { + static ENV: std::sync::OnceLock> = std::sync::OnceLock::new(); + *ENV.get_or_init(|| Setting::parse(&std::env::var(ENV_VAR).ok()?)) +} + +fn key() -> Id { + Id::unique("craft_ui::direction") +} + +/// Draw the interface on `ctx` in `dir` from the next layout on. +pub fn apply(ctx: &Context, dir: Direction) { + ctx.data_mut(|d| d.insert_temp(key(), dir)); + ctx.all_styles_mut(|style| style.set_right_to_left(dir == Direction::Rtl)); +} + +/// The suite's egui fork has an inherent `Style::set_right_to_left`; crates.io egui has neither +/// the method nor the field. A method call picks an inherent method over a trait method of the +/// same name, so [`apply`] reaches egui's method on the fork and this no-op everywhere else. The +/// same source thus builds against both without a feature. +#[allow(dead_code, reason = "unused when egui has the inherent method")] +trait SetRightToLeft { + fn set_right_to_left(&mut self, _right_to_left: bool) {} +} + +impl SetRightToLeft for egui::Style {} + +/// Anything the interface direction can be read from. +pub trait HasDirection { + fn direction(&self) -> Direction; +} + +impl HasDirection for Direction { + fn direction(&self) -> Direction { + *self + } +} + +impl HasDirection for Context { + /// The direction last [`apply`]-ed to this context; left to right before that. + fn direction(&self) -> Direction { + self.data(|d| d.get_temp(key())).unwrap_or(Direction::Ltr) + } +} + +impl HasDirection for Ui { + fn direction(&self) -> Direction { + self.ctx().direction() + } +} + +/// Is the interface drawn right to left? +pub fn is_rtl(d: &impl HasDirection) -> bool { + d.direction() == Direction::Rtl +} + +// Panels and layouts. + +/// A side panel at the reading start: left, or right in a right-to-left interface. +pub fn start_panel(d: &impl HasDirection, id_salt: impl AsIdSalt) -> Panel { + if is_rtl(d) { Panel::right(id_salt) } else { Panel::left(id_salt) } +} + +/// A side panel at the reading end: right, or left in a right-to-left interface. +pub fn end_panel(d: &impl HasDirection, id_salt: impl AsIdSalt) -> Panel { + if is_rtl(d) { Panel::left(id_salt) } else { Panel::right(id_salt) } +} + +/// A vertically centred row in reading order. +pub fn row(d: &impl HasDirection) -> Layout { + if is_rtl(d) { Layout::right_to_left(Align::Center) } else { Layout::left_to_right(Align::Center) } +} + +/// A vertically centred row that starts from the reading end, for groups pinned to the far end +/// of a bar: a dialog's buttons, a title bar's controls, a status bar's zoom field. +pub fn trailing(d: &impl HasDirection) -> Layout { + if is_rtl(d) { Layout::left_to_right(Align::Center) } else { Layout::right_to_left(Align::Center) } +} + +/// A top-down column with its items against the reading start. +pub fn column(d: &impl HasDirection) -> Layout { + Layout::top_down(start_align(d)) +} + +/// [`Ui::horizontal`] in reading order. Use it where the parent runs top down (a panel, a +/// column); inside a [`row`], `ui.horizontal` already follows the parent. +pub fn horizontal(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + let size = vec2(ui.available_size_before_wrap().x, ui.spacing().interact_size.y); + let layout = row(ui); + ui.allocate_ui_with_layout(size, layout, add) +} + +/// A left-to-right row whatever the interface direction, for content on the [`Unmirrored`] list +/// placed inside a mirrored row: a transport bar, a slider with its value, a timecode. +pub fn unmirrored_row(ui: &mut Ui, _what: Unmirrored, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + let size = vec2(ui.available_size_before_wrap().x, ui.spacing().interact_size.y); + ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), add) +} + +// Hand-painted positions. + +/// The horizontal alignment of the reading start: [`Align::Min`], or [`Align::Max`] right to left. +pub fn start_align(d: &impl HasDirection) -> Align { + if is_rtl(d) { Align::Max } else { Align::Min } +} + +/// The x of `rect`'s reading-start edge. +pub fn start_x(d: &impl HasDirection, rect: Rect) -> f32 { + if is_rtl(d) { rect.right() } else { rect.left() } +} + +/// The x of `rect`'s reading-end edge. +pub fn end_x(d: &impl HasDirection, rect: Rect) -> f32 { + if is_rtl(d) { rect.left() } else { rect.right() } +} + +/// `1.0`, or `-1.0` right to left: multiply an x offset written for left to right by it, as in +/// `start_x(ui, rect) + forward(ui) * padding`. +pub fn forward(d: &impl HasDirection) -> f32 { + if is_rtl(d) { -1.0 } else { 1.0 } +} + +/// An anchor written for left to right, mirrored in a right-to-left interface. +pub fn align2(d: &impl HasDirection, ltr: Align2) -> Align2 { + if is_rtl(d) { ltr.flipped_x() } else { ltr } +} + +// Popups. + +/// A popup alignment written for left to right, mirrored in a right-to-left interface. +pub fn popup(d: &impl HasDirection, ltr: RectAlign) -> RectAlign { + if is_rtl(d) { ltr.flipped_x() } else { ltr } +} + +/// A menu below its button, under the button's reading-start edge. +pub fn below(d: &impl HasDirection) -> RectAlign { + popup(d, RectAlign::BOTTOM_START) +} + +/// A menu above its button, over the button's reading-start edge. +pub fn above(d: &impl HasDirection) -> RectAlign { + popup(d, RectAlign::TOP_START) +} + +/// A flyout beside its item, towards the reading end, top edges level: a tool's flyout, a +/// hand-made submenu. +pub fn beside(d: &impl HasDirection) -> RectAlign { + popup(d, RectAlign::RIGHT_START) +} + +// Arrow keys. + +/// A move along a row or list in reading order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Step { + Previous, + Next, +} + +/// What the left and right arrow keys mean in a mirrored row (tabs, a toolbar, a segmented +/// control, a breadcrumb): `ArrowRight` moves to the previous item right to left. `None` for any +/// other key. Over [`Unmirrored`] content (nudging on a canvas, stepping a timeline or a slider) +/// read the keys as they are. +pub fn step(d: &impl HasDirection, key: Key) -> Option { + let (previous, next) = if is_rtl(d) { (Key::ArrowRight, Key::ArrowLeft) } else { (Key::ArrowLeft, Key::ArrowRight) }; + if key == previous { + Some(Step::Previous) + } else if key == next { + Some(Step::Next) + } else { + None + } +} + +/// The arrow key that makes `step`, as a shortcut label shows it. +pub fn step_key(d: &impl HasDirection, step: Step) -> Key { + match (step, is_rtl(d)) { + (Step::Previous, false) | (Step::Next, true) => Key::ArrowLeft, + (Step::Next, false) | (Step::Previous, true) => Key::ArrowRight, + } +} + +/// The step pressed this frame on the arrow keys, if any; the keys stay in the input. +pub fn pressed_step(ctx: &Context) -> Option { + ctx.input(|i| [Key::ArrowLeft, Key::ArrowRight].into_iter().find(|k| i.key_pressed(*k))).and_then(|k| step(ctx, k)) +} + +// Icons. + +/// Icon pairs that are each other's mirror image: right to left, each name draws as the other. +/// Names follow Lucide, which the suite's icon sets use; an app with other names maps them first. +/// +/// They point along the reading direction: back and forward, previous and next, collapse and +/// expand a side panel, a reply and a forward. +pub const SWAPPED_ICONS: &[(&str, &str)] = &[ + ("arrow-left", "arrow-right"), + ("arrow-big-left", "arrow-big-right"), + ("arrow-left-to-line", "arrow-right-to-line"), + ("arrow-left-from-line", "arrow-right-from-line"), + ("chevron-left", "chevron-right"), + ("chevrons-left", "chevrons-right"), + ("chevron-first", "chevron-last"), + ("circle-arrow-left", "circle-arrow-right"), + ("circle-chevron-left", "circle-chevron-right"), + ("square-chevron-left", "square-chevron-right"), + ("move-left", "move-right"), + ("panel-left", "panel-right"), + ("panel-left-close", "panel-right-close"), + ("panel-left-open", "panel-right-open"), + ("reply", "forward"), +]; + +/// Icons with no mirrored counterpart that are drawn flipped right to left: what they show starts +/// at the reading start. Indent and outdent (nesting in an outline, a list or a layer tree), +/// bulleted lists, signing in and out, sending. +pub const FLIPPED_ICONS: &[&str] = + &["indent-increase", "indent-decrease", "indent", "outdent", "list", "list-checks", "list-tree", "log-in", "log-out", "send", "send-horizontal"]; + +/// Icons that look directional but keep their drawing right to left. [`icon`] returns every name +/// not in [`SWAPPED_ICONS`] or [`FLIPPED_ICONS`] unchanged; this list records the decisions. +/// +/// - Undo and redo: history runs in time, which the suite draws left to right (timelines, media +/// transport). The buttons still sit in reading order, since their row mirrors. +/// - Rotate, refresh and flip: they act on the canvas, which doesn't mirror, so counterclockwise +/// stays counterclockwise. +/// - Media transport: see [`Unmirrored::MediaTransport`]. +/// - Paragraph alignment (`align-left`, `text-align-start`, …): document text has its own +/// direction. Pass the paragraph's [`Direction`] to [`icon`] where an icon means its start or end. +/// - Object alignment and distribution: they name edges of the canvas. +/// - Numbered lists: the digits would mirror. +pub const KEPT_ICONS: &[&str] = &[ + "undo", + "redo", + "undo-2", + "redo-2", + "undo-dot", + "redo-dot", + "rotate-ccw", + "rotate-cw", + "refresh-ccw", + "refresh-cw", + "flip-horizontal", + "flip-horizontal-2", + "flip-vertical", + "flip-vertical-2", + "play", + "pause", + "skip-back", + "skip-forward", + "step-back", + "step-forward", + "rewind", + "fast-forward", + "align-left", + "align-right", + "text-align-start", + "text-align-end", + "align-start-vertical", + "align-end-vertical", + "align-horizontal-justify-start", + "align-horizontal-justify-end", + "align-horizontal-distribute-start", + "align-horizontal-distribute-end", + "list-ordered", +]; + +/// An icon to draw for the interface direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Icon<'a> { + /// The icon to look up in the app's set. + pub name: &'a str, + /// Draw it mirrored, through [`Icon::uv`]. + pub flip: bool, +} + +impl Icon<'_> { + /// The texture coordinates to draw the icon with, for [`egui::Image::uv`] or + /// [`egui::Painter::image`]: the whole texture, mirrored when [`Icon::flip`] is set. + pub fn uv(&self) -> Rect { + if self.flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) } + } +} + +/// The icon to draw for `name`: its mirror-image counterpart or a flipped drawing right to left +/// ([`SWAPPED_ICONS`], [`FLIPPED_ICONS`]), and `name` as it is otherwise. +pub fn icon<'a>(d: &impl HasDirection, name: &'a str) -> Icon<'a> { + let plain = Icon { name, flip: false }; + if !is_rtl(d) { + return plain; + } + for &(a, b) in SWAPPED_ICONS { + if name == a { + return Icon { name: b, flip: false }; + } + if name == b { + return Icon { name: a, flip: false }; + } + } + Icon { name, flip: FLIPPED_ICONS.contains(&name) } +} + +// What never mirrors. + +/// The suite-wide list of what keeps its left-to-right geometry in a right-to-left interface. +/// These show the content itself, or follow a convention that holds in right-to-left locales too. +/// Their surroundings (the panel, the toolbar, the labels) still mirror. Lay such content out +/// with [`unmirrored_row`] or a fixed [`Layout::left_to_right`], never with [`row`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Unmirrored { + /// Document canvases and viewports, with everything drawn over them: selections, crop and + /// mask overlays, guides, handles and transform gizmos. Arrow keys nudge in screen space. + Canvas, + /// Photos, previews and thumbnail contents, with marks pinned to their corners (flags, + /// ratings, badges, Before and After labels). A grid of thumbnails follows reading order. + Image, + /// Rulers: units count from the document's origin. + Ruler, + /// Timelines, tracks, waveforms, keyframe lanes and playheads: time runs left to right. + Timeline, + /// Tone curves, histograms, graphs and plots: their axes are mathematical. + Graph, + /// Gradients, colour ramps, hue strips, colour wheels and two-dimensional colour pickers. + ColourRamp, + /// Sliders: the value increases to the right, and the value field sits on the same side. + SliderValue, + /// Play, pause, step, skip and rewind: their order and arrows, which match the timeline. + MediaTransport, + /// Code, expressions, file paths and URLs: left-to-right text, aligned left. + Code, + /// Number fields with their units, coordinates and sizes: digits run left to right, and x + /// comes before y. + Number, + /// Timecodes, durations and frame counters. + Timecode, +} + +impl Unmirrored { + pub const ALL: [Unmirrored; 11] = [ + Unmirrored::Canvas, + Unmirrored::Image, + Unmirrored::Ruler, + Unmirrored::Timeline, + Unmirrored::Graph, + Unmirrored::ColourRamp, + Unmirrored::SliderValue, + Unmirrored::MediaTransport, + Unmirrored::Code, + Unmirrored::Number, + Unmirrored::Timecode, + ]; +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui/src/direction/tests.rs b/crates/ui/src/direction/tests.rs new file mode 100644 index 00000000..5f0887d1 --- /dev/null +++ b/crates/ui/src/direction/tests.rs @@ -0,0 +1,207 @@ +use super::*; +use egui::{Pos2, RawInput, Response}; + +const WIDTH: f32 = 600.0; + +fn context(dir: Direction) -> Context { + let ctx = Context::default(); + apply(&ctx, dir); + ctx +} + +fn frame(ctx: &Context, add: impl FnMut(&mut Ui)) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(WIDTH, 400.0))), ..Default::default() }; + ctx.run_ui(raw, add).textures_delta.clear(); +} + +/// Centre x of a panel shown with `panel` and of a label in the space left. +fn panel_and_rest(dir: Direction, panel: fn(&Context, &'static str) -> Panel) -> (f32, f32) { + let ctx = context(dir); + let (mut side, mut rest) = (0.0, 0.0); + for _ in 0..2 { + frame(&ctx, |ui| { + side = panel(ui.ctx(), "side").show(ui, |ui| ui.label("side")).response.rect.center().x; + rest = ui.label("rest").rect.center().x; + }); + } + (side, rest) +} + +/// Centre x of two labels added in order with `layout`. +fn two_in(dir: Direction, layout: fn(&Ui) -> Layout) -> (f32, f32) { + let ctx = context(dir); + let mut xs = (0.0, 0.0); + frame(&ctx, |ui| { + let layout = layout(ui); + ui.with_layout(layout, |ui| { + let first: Response = ui.label("first"); + let second = ui.label("second"); + xs = (first.rect.center().x, second.rect.center().x); + }); + }); + xs +} + +#[test] +fn direction_is_per_context_and_left_to_right_until_applied() { + let ctx = Context::default(); + assert_eq!(ctx.direction(), Direction::Ltr); + apply(&ctx, Direction::Rtl); + assert!(is_rtl(&ctx)); + assert_eq!(Context::default().direction(), Direction::Ltr); + apply(&ctx, Direction::Ltr); + assert!(!is_rtl(&ctx)); +} + +#[test] +fn setting_overrides_the_language() { + assert_eq!(Setting::Language.direction("ar"), Direction::Rtl); + assert_eq!(Setting::Language.direction("he-IL"), Direction::Rtl); + assert_eq!(Setting::Language.direction("en"), Direction::Ltr); + assert_eq!(Setting::Rtl.direction("en"), Direction::Rtl); + assert_eq!(Setting::Ltr.direction("fa"), Direction::Ltr); + assert_eq!(Setting::parse(" RTL "), Some(Setting::Rtl)); + assert_eq!(Setting::parse("ltr"), Some(Setting::Ltr)); + assert_eq!(Setting::parse("language"), Some(Setting::Language)); + assert_eq!(Setting::parse("ar"), None); + assert_eq!(Setting::parse(""), None); +} + +#[test] +fn start_panel_lands_at_the_right_in_rtl() { + let (side, rest) = panel_and_rest(Direction::Ltr, start_panel); + assert!(side < WIDTH / 2.0 && side < rest, "{side} {rest}"); + let (side, rest) = panel_and_rest(Direction::Rtl, start_panel); + assert!(side > WIDTH / 2.0 && side > rest, "{side} {rest}"); + let (side, _) = panel_and_rest(Direction::Rtl, end_panel); + assert!(side < WIDTH / 2.0, "{side}"); +} + +#[test] +fn rows_run_in_reading_order() { + let (first, second) = two_in(Direction::Ltr, row); + assert!(first < second); + let (first, second) = two_in(Direction::Rtl, row); + assert!(first > second); + assert!(first > WIDTH / 2.0, "a right-to-left row starts at the right: {first}"); + let (first, _) = two_in(Direction::Rtl, trailing); + assert!(first < WIDTH / 2.0, "a trailing group starts at the reading end: {first}"); + let (first, _) = two_in(Direction::Ltr, trailing); + assert!(first > WIDTH / 2.0, "{first}"); +} + +#[test] +fn nested_rows_follow_and_unmirrored_rows_do_not() { + let ctx = context(Direction::Rtl); + let (mut nested, mut fixed) = ((0.0, 0.0), (0.0, 0.0)); + frame(&ctx, |ui| { + horizontal(ui, |ui| { + ui.horizontal(|ui| nested = (ui.label("a").rect.center().x, ui.label("b").rect.center().x)); + }); + horizontal(ui, |ui| { + unmirrored_row(ui, Unmirrored::MediaTransport, |ui| fixed = (ui.label("a").rect.center().x, ui.label("b").rect.center().x)); + }); + }); + assert!(nested.0 > nested.1, "{nested:?}"); + assert!(fixed.0 < fixed.1, "{fixed:?}"); +} + +#[test] +fn positions_and_popups_mirror() { + let r = Rect::from_min_max(pos2(10.0, 0.0), pos2(50.0, 10.0)); + let (ltr, rtl) = (Direction::Ltr, Direction::Rtl); + assert_eq!((start_x(<r, r), end_x(<r, r), forward(<r)), (10.0, 50.0, 1.0)); + assert_eq!((start_x(&rtl, r), end_x(&rtl, r), forward(&rtl)), (50.0, 10.0, -1.0)); + assert_eq!(start_align(&rtl), Align::Max); + assert_eq!(column(&rtl).horizontal_align(), Align::Max); + assert_eq!(align2(&rtl, Align2::LEFT_TOP), Align2::RIGHT_TOP); + assert_eq!(below(<r), RectAlign::BOTTOM_START); + assert_eq!(below(&rtl), RectAlign::BOTTOM_END); + assert_eq!(above(&rtl), RectAlign::TOP_END); + assert_eq!(beside(<r), RectAlign::RIGHT_START); + assert_eq!(beside(&rtl), RectAlign::LEFT_START); +} + +#[test] +fn arrow_keys_follow_the_visual_direction() { + let (ltr, rtl) = (Direction::Ltr, Direction::Rtl); + assert_eq!(step(<r, Key::ArrowLeft), Some(Step::Previous)); + assert_eq!(step(<r, Key::ArrowRight), Some(Step::Next)); + assert_eq!(step(&rtl, Key::ArrowLeft), Some(Step::Next)); + assert_eq!(step(&rtl, Key::ArrowRight), Some(Step::Previous)); + assert_eq!(step(&rtl, Key::ArrowUp), None); + for d in [ltr, rtl] { + for s in [Step::Previous, Step::Next] { + assert_eq!(step(&d, step_key(&d, s)), Some(s)); + } + } + + let ctx = context(Direction::Rtl); + let mut pressed = None; + let press = egui::Event::Key { key: Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let raw = RawInput { events: vec![press], ..Default::default() }; + ctx.run_ui(raw, |ui| pressed = pressed_step(ui.ctx())).textures_delta.clear(); + assert_eq!(pressed, Some(Step::Previous)); +} + +#[test] +fn directional_icons_flip_from_one_list() { + let (ltr, rtl) = (Direction::Ltr, Direction::Rtl); + assert_eq!(icon(<r, "chevron-left"), Icon { name: "chevron-left", flip: false }); + assert_eq!(icon(&rtl, "chevron-left"), Icon { name: "chevron-right", flip: false }); + assert_eq!(icon(&rtl, "panel-right-close"), Icon { name: "panel-left-close", flip: false }); + assert_eq!(icon(&rtl, "indent-increase"), Icon { name: "indent-increase", flip: true }); + assert_eq!(icon(<r, "indent-increase").uv(), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))); + assert_eq!(icon(&rtl, "indent-increase").uv().left(), 1.0); + for name in KEPT_ICONS.iter().chain(["brush", "layers"].iter()) { + assert_eq!(icon(&rtl, name), Icon { name, flip: false }, "{name}"); + } + + let mut names: Vec<&str> = + SWAPPED_ICONS.iter().flat_map(|&(a, b)| [a, b]).chain(FLIPPED_ICONS.iter().copied()).chain(KEPT_ICONS.iter().copied()).collect(); + let count = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), count, "an icon is on one list only"); +} + +#[test] +fn every_unmirrored_kind_is_listed_once() { + for (i, kind) in Unmirrored::ALL.into_iter().enumerate() { + // A new kind fails to compile here until it is placed in `ALL`. + let position = match kind { + Unmirrored::Canvas => 0, + Unmirrored::Image => 1, + Unmirrored::Ruler => 2, + Unmirrored::Timeline => 3, + Unmirrored::Graph => 4, + Unmirrored::ColourRamp => 5, + Unmirrored::SliderValue => 6, + Unmirrored::MediaTransport => 7, + Unmirrored::Code => 8, + Unmirrored::Number => 9, + Unmirrored::Timecode => 10, + }; + assert_eq!(position, i, "{kind:?}"); + } +} + +/// `Style::right_to_left` as egui's `Debug` output shows it: `None` on crates.io egui, which has +/// no such field, so the test builds against both. +fn style_rtl(ctx: &Context, theme: egui::Theme) -> Option { + let debug = format!("{:?}", ctx.style_of(theme)); + debug.contains("right_to_left").then(|| debug.contains("right_to_left: true")) +} + +#[test] +fn apply_mirrors_egui_chrome_where_egui_can() { + let ctx = context(Direction::Rtl); + let patched = style_rtl(&ctx, egui::Theme::Dark).is_some(); + for theme in [egui::Theme::Dark, egui::Theme::Light] { + assert_eq!(style_rtl(&ctx, theme), patched.then_some(true), "{theme:?}"); + } + apply(&ctx, Direction::Ltr); + for theme in [egui::Theme::Dark, egui::Theme::Light] { + assert_eq!(style_rtl(&ctx, theme), patched.then_some(false), "{theme:?}"); + } +} diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs new file mode 100644 index 00000000..9e3aa2bc --- /dev/null +++ b/crates/ui/src/docking/chrome.rs @@ -0,0 +1,488 @@ +//! 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, + /// Fill of a selected or hovered control, such as an icon rail's open item. + pub active_background: 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_visuals(ui.visuals()) + } + + /// The colors [`Self::from_dock`] takes from [`DockStyle::from_visuals`], without building + /// the dock style's labels. + pub fn from_visuals(visuals: &egui::Visuals) -> Self { + Self { + background: visuals.window_fill, + stroke: visuals.window_stroke, + title_background: visuals.extreme_bg_color, + text: visuals.text_color(), + weak_text: visuals.weak_text_color(), + accent: visuals.selection.stroke.color, + active_background: visuals.widgets.active.bg_fill, + font: FontId::proportional(12.0), + right_to_left: false, + } + } + + 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, + active_background: style.active_background, + 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, + opacity: f32, +} + +/// 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", opacity: 1.0 } + } + + /// 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 + } + + /// Opacity of everything drawn in the frame, contents included (default 1.0). + pub fn opacity(mut self, opacity: f32) -> Self { + self.opacity = opacity; + 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| { + ui.multiply_opacity(self.opacity); + // 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 + } + + /// Draw the resize handles around `rect` and return `(resized, started, responses)`. + /// + /// `resized` is set only for an enabled, uncancelled handle whose clamped next rect differs + /// from the current one. `started` names the handle whose drag began, which can remain set after + /// Escape or focus loss cancels that drag. Keyboard arrow steps move an edge by 8 pt; pointer + /// drags derive the next rect from the cursor relative to the press origin. Limits clamp the + /// proposal. Stored pointer anchors are removed on cancellation or once the primary button is + /// released. Handles are accessibility buttons; the diagonal one is painted while hovered, + /// dragged or focused. + 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 draws its drag ghosts with it, and with +/// [`crate::docking::DockStyle::side_edge_zones`] the region a drop takes; the pane host draws +/// every destination 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 + } + + /// Draw the title bar into `rect` and return its responses. + /// + /// `close_at` rects are in logical points, already mirrored in a right-to-left layout; without it + /// the × is a square the bar's height centered 10 pt in from the trailing edge. Menu and close + /// buttons reserve space on the trailing side, the draggable bar excludes them, and a title is + /// truncated to the bar's remaining width. The bar senses clicks and drags, while double-click + /// behavior (for example minimizing or closing) is the caller's. A right-to-left style mirrors + /// the layout; `move_label` remains the accessible name of the drag surface. The bar's own id + /// is used, with `"menu"` and `"close"` child ids for the buttons. + 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..7db19eb4 --- /dev/null +++ b/crates/ui/src/docking/command.rs @@ -0,0 +1,608 @@ +//! 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. + /// + /// An outer edge dock wraps the part between the docks. [`Self::to_actions_with`] also + /// knows which panels are protected, so it wraps the canvas column where a drag would. + pub fn to_actions(&self, workspace: &Workspace

, right_to_left: bool, space: &FloatSpace

) -> Result>, DockError> { + self.to_actions_with(workspace, right_to_left, space, |_| Permissions::default()) + } + + /// [`Self::to_actions`] with the `permissions` the actions are applied with. An outer edge + /// dock wraps what a drag to that edge would: the canvas column where the area narrows its + /// edge band to it, else the part between the docks. `space` says whether the area draws + /// with [`super::DockStyle::side_edge_zones`], which never narrows. + pub fn to_actions_with( + &self, + workspace: &Workspace

, + right_to_left: bool, + space: &FloatSpace

, + permissions: impl Fn(&P) -> Permissions, + ) -> 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); + } + // The model narrows the edge on a protected anchor, as `Workspace::apply` checks it. + let protected = |p: &P| { + let policy = if workspace.locked { permissions(p).locked() } else { permissions(p) }; + policy.is_protected() + }; + let narrowed = !space.side_edge_zones && layout.edge_narrows(zone, &protected); + let anchor = layout.edge_anchor(narrowed, &protected, &|p| members.contains(p)).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_with`]. + 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_with(&shown, right_to_left, space, &permissions)?, &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_with(workspace, right_to_left, space, &permissions)?; + 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..c6ac3f1d --- /dev/null +++ b/crates/ui/src/docking/command_tests.rs @@ -0,0 +1,481 @@ +//! `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_with(&workspace(), false, &space(), permissions).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_with(&workspace(), rtl, &space(), permissions) { + 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_with(&workspace(), false, &space, permissions).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_with( + &workspace(), + false, + &small, + permissions, + ); + 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_with(&workspace(), false, &seeded, permissions); + 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_with(&workspace(), false, space, permissions).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); +} + +/// The frame beside `panel`'s own: what an edge split with `panel` wrapped. +fn wrapped(layout: &Layout

, panel: &str) -> Option> { + let Some(dock_model::Place::Node(path)) = layout.place(&s(panel)) else { return None }; + let (last, parent) = path.split_last()?; + layout.root.as_ref().and_then(|root| dock_model::node_at(root, &[parent, &[!last]].concat())).cloned() +} + +#[test] +fn an_outer_edge_dock_wraps_the_canvas_column_whatever_the_tree_order() { + let tabs = |panel: &str| Node::Tabs { panels: vec![s(panel)], active: 0 }; + let side = + |first, second| Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; + let canvas_first = side(tabs("canvas"), side(tabs("layers"), tabs("history"))); + let canvas_later = side(side(tabs("layers"), tabs("canvas")), tabs("history")); + for root in [canvas_first, canvas_later] { + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![s("color")], 0, [300.0, 200.0, 200.0, 150.0])] }; + let command = parse(json!({"command": "window.panel.dock", "panel": "color", "zone": "top"})).unwrap(); + let mut workspace = Workspace::new(layout.clone()); + command.apply(&mut workspace, false, &space(), permissions).unwrap(); + assert_eq!(wrapped(&workspace.layout, "color"), Some(tabs("canvas")), "{:?}", workspace.layout); + // An area drawn with side edge zones never narrows its edge to the canvas column. + let mut workspace = Workspace::new(layout.clone()); + command.apply(&mut workspace, false, &space().with_side_edge_zones(true), permissions).unwrap(); + let mut whole = layout; + whole.floating.clear(); + assert_eq!(wrapped(&workspace.layout, "color"), whole.root, "{:?}", workspace.layout); + } +} + +#[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..12e8468d --- /dev/null +++ b/crates/ui/src/docking/dock_model.rs @@ -0,0 +1,998 @@ +//! 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 +} + +/// The depth along the frame `path` of the split whose size a [`Placement::SplitSized`] on +/// `zone` replaces: the nearest enclosing split along the zone's axis (`on_axis` at a depth) +/// whose divider runs along the frame's `zone` side, else, for a frame at the edge of its +/// area on that side, the nearest one along its opposite side. +pub(super) fn sized_split_depth(path: &[bool], zone: Zone, on_axis: impl Fn(usize) -> bool) -> Option { + // A divider on the frame's start side (left or top) belongs to a split holding the frame + // in its second child. + let start = matches!(zone, Zone::Left | Zone::Top); + let nearest = |second: bool| (0..path.len()).rev().find(|&depth| path.get(depth) == Some(&second) && on_axis(depth)); + nearest(start).or_else(|| nearest(!start)) +} + +/// [`split_frame`] with the sizes of [`Placement::SplitSized`]: the new split takes `size`, and +/// the split [`sized_split_depth`] finds takes `outer`. False when there is no such split, or a +/// dock is beyond it. +pub(super) fn split_frame_sized

(root: &mut Node

, path: &[bool], new: Node

, zone: Zone, size: SplitSize, outer: SplitSize) -> bool { + let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let enclosing = + sized_split_depth(path, zone, |depth| matches!(node_at(root, &path[..depth]), Some(Node::Split { axis: own, .. }) if *own == axis)); + let Some(depth) = enclosing else { return false }; + let Some(Node::Split { size: own, first, second, .. }) = node_at_mut(root, &path[..depth]) else { return false }; + if zone == Zone::Center || matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }) { + return false; + } + *own = outer; + if !split_frame(root, path, new, zone) { + return false; + } + match node_at_mut(root, path) { + Some(Node::Split { size: own, .. }) => { + *own = size; + true + } + _ => false, + } +} + +/// 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

{ + /// The layout without its sizes and rectangles: which panels are where, and their state. + /// A run of splits along one axis nests one way (`a | (b | c)`), since nesting it the + /// other way only moves sizes between its members. + pub(super) fn shape(&self) -> Layout

{ + fn run<'a, P>(node: &'a Node

, axis: SplitAxis, out: &mut Vec<&'a Node

>) { + match node { + Node::Split { axis: own, first, second, .. } if *own == axis => { + run(first, axis, out); + run(second, axis, out); + } + node => out.push(node), + } + } + fn node(n: &Node

) -> Node

{ + match n { + Node::Split { axis, .. } => { + let mut members = Vec::new(); + run(n, *axis, &mut members); + let mut shaped = members.into_iter().rev().map(node); + let last = shaped.next().unwrap_or(Node::Tabs { panels: Vec::new(), active: 0 }); + shaped.fold(last, |second, first| Node::Split { + axis: *axis, + size: SplitSize::Ratio(0.5), + first: Box::new(first), + second: Box::new(second), + }) + } + Node::Tabs { panels, active } => Node::Tabs { panels: panels.clone(), active: *active }, + Node::Stack { entries } => Node::Stack { entries: entries.iter().map(|e| super::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: self.root.as_ref().map(node), + floating: self + .floating + .iter() + .map(|w| Floating { root: node(&w.root), rect: [0.0; 4], native: None, monitor: None, monitor_size: None, ..w.clone() }) + .collect(), + } + } + + /// Whether `other` arranges the panels as this layout does: equal apart from split sizes, + /// column widths, group heights and window rectangles. A drop that leads from one to the + /// other changes nothing the user would see as a move. + pub(super) fn same_arrangement(&self, other: &Self) -> bool { + self.shape() == other.shape() + } + + 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 at `zone` wraps: the root, or the part between the docks. Where + /// neither side of a side-by-side split is a dock and only one holds a `canvas` panel, + /// that side: the canvas column, without the side frames. A top (bottom) edge also + /// descends a split of rows into its first (last) row when only that row holds a `canvas` + /// panel, so a full-width row below (above) the canvas keeps its edge. + pub(in crate::docking) fn edge_path(&self, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> Vec { + let holds = |node: &Node

| tree_panels(node).into_iter().any(canvas); + let mut path = Vec::new(); + let mut node = self.root.as_ref(); + while let Some(Node::Split { axis, first, second, .. }) = node { + if path.len() > MAX_DEPTH { + break; + } + let second_side = match (axis, &**first, &**second) { + (SplitAxis::Horizontal, Node::Dock { .. }, _) => true, + (SplitAxis::Horizontal, _, Node::Dock { .. }) => false, + (SplitAxis::Vertical, ..) => match (zone, holds(first), holds(second)) { + (Zone::Top, true, false) => false, + (Zone::Bottom, false, true) => true, + _ => break, + }, + _ => match (holds(first), holds(second)) { + (true, false) => false, + (false, true) => true, + _ => break, + }, + }; + path.push(second_side); + node = Some(if second_side { second } else { first }); + } + path + } + + /// Whether an edge drop at `zone` that narrows by `canvas` wraps less than one that does + /// not: the edge area is narrower than the part between the docks. + pub(in crate::docking) fn edge_narrows(&self, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> bool { + self.edge_path(zone, canvas) != self.edge_path(zone, &|_| false) + } + + /// The docked panel, other than `skip`, that a [`Placement::Edge`] anchors on so the drop + /// wraps the edge area it shows: a `protected` one where that area is narrowed to the + /// canvas column (`narrowed`), else one that is not protected, since a protected anchor + /// narrows the model's edge. Any docked panel when every one is protected. + pub(in crate::docking) fn edge_anchor(&self, narrowed: bool, protected: &dyn Fn(&P) -> bool, skip: &dyn Fn(&P) -> bool) -> Option

{ + let docked: Vec<&P> = + self.panels().into_iter().filter(|p| !skip(p) && matches!(self.place(p), Some(Place::Dock { .. } | Place::Node(_)))).collect(); + docked.iter().find(|p| protected(p) == narrowed).or(docked.first()).map(|p| (*p).clone()) + } + + 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). + /// `canvas` is the protected-panel test an outer edge drop narrows by ([`Self::edge_path`]). + pub(super) fn insert_moving(&mut self, moving: Moving

, to: Target

, canvas: &dyn Fn(&P) -> bool) -> 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) } + } + Placement::SplitSized { zone, size, outer } => { + 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_sized(root, path, new, zone, size, outer) { Ok(()) } else { Err(DockError::InvalidPlacement) } + } + // 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) }, canvas) + } + 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); + } + // A protected anchor narrows the edge to the canvas column. + let path = if canvas(&anchor) { self.edge_path(zone, canvas) } else { self.edge_path(zone, &|_| false) }; + 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..a984f696 --- /dev/null +++ b/crates/ui/src/docking/dock_tests.rs @@ -0,0 +1,1061 @@ +//! [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, + }) + } + + /// Run three empty frames so floating areas finish their sizing pass, then + /// return the driver. + fn start(mut driver: Self) -> Self { + // Floating areas settle after their sizing pass. + for _ in 0..3 { + driver.step(vec![]); + } + driver + } + + /// One 800×600 frame with injected events and the driver's focus/right-to-left settings. + /// Renders the dock in a 600×600 scope, clears texture deltas, records shapes, and returns + /// `DockOutput`. + 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 + } + + /// Read the response rectangle for the dock widget named `name` and scoped to + /// `panel`. + fn rect(&self, name: &str, panel: u32) -> Rect { + self.ctx.read_response(Id::unique(AREA).with((name, Some(&panel)))).unwrap().rect + } + + /// Read the response rectangle for `panel`'s tab. + 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 result preview of `rect`: a 2 pt outline with a light + /// fill, inset 1 pt. + fn previewed(&self, rect: Rect) -> bool { + let rect = rect.shrink(1.0); + let shapes = || { + self.shapes.iter().filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.rect == rect => Some(r), + _ => None, + }) + }; + shapes() + .filter(|r| r.fill == egui::Color32::TRANSPARENT && r.stroke.width == 2.0) + .any(|outline| shapes().any(|r| r.stroke.width == 0.0 && r.fill == outline.stroke.color.gamma_multiply(0.08))) + } + + /// The rect of the group whose strip starts with `panel`'s tab, in the column whose bar + /// names `column`: down to the next group's tab, `next`, or the window's bottom. + fn group_rect(&self, panel: u32, next: Option, column: u32) -> Rect { + let tab = self.tab(panel); + let bottom = next.map_or(600.0, |next| self.tab(next).top()); + Rect::from_x_y_ranges(self.column_of(column).x_range(), tab.top()..=bottom) + } + + /// The rect of the dock column whose bar names `panel`. + fn column_of(&self, panel: u32) -> Rect { + let bar = self.rect("column-bar", panel).union(self.rect("column-collapse", panel)); + Rect::from_x_y_ranges(bar.x_range(), bar.top()..=600.0) + } + + /// 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_previews_the_rect_it_takes() { + 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); + let held = d.shapes.clone(); + let out = d.step(vec![button(seam, false)]); + d.settle(); + let rect = d.group_rect(5, Some(3), 2); + d.shapes = held; + assert!(d.previewed(rect), "group 5's rect between groups 2 and 3: {rect:?}"); + 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 a_dock_groups_top_and_bottom_thirds_stack_its_sides_make_columns_and_its_middle_joins() { + // Group 3's body is 0..240 across and 333..600 down. Each drop beside previews the rect + // group 5 then takes: in the column, below the group `Some` names, or a whole column. + for (at, expected, result) in [ + (pos2(120.0, 360.0), vec![vec![vec![2], vec![5], vec![3]]], Some(Some(3))), + (pos2(120.0, 580.0), vec![vec![vec![2], vec![3], vec![5]]], Some(None)), + (pos2(30.0, 466.0), vec![vec![vec![5]], vec![vec![2], vec![3]]], None), + (pos2(200.0, 466.0), vec![vec![vec![2], vec![3]], vec![vec![5]]], None), + (pos2(120.0, 466.0), vec![vec![vec![2], vec![3, 5]]], None), + ] { + 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.hold(bar(&d, 5), at); + let held = d.shapes.clone(); + d.step(vec![button(at, false)]); + assert_eq!(columns(&d.layout, Side::Start), expected, "{at:?}"); + d.settle(); + let rect = match result { + Some(next) => d.group_rect(5, next, 2), + None if expected.len() == 2 => d.column_of(5), + None => continue, + }; + d.shapes = held; + assert!(d.previewed(rect), "{at:?}: the rect group 5 takes, {rect:?}"); + } +} + +#[test] +fn with_side_edge_zones_a_dock_groups_side_zones_are_40_pt_deep() { + // Group 3's body is 0..240 across and 333..600 down. 60 pt in from its left edge and 57 + // below its top are in its outer thirds but past 40 pt: the middle, which joins it. + for (at, expected) in [ + (pos2(30.0, 466.0), vec![vec![vec![5]], vec![vec![2], vec![3]]]), + (pos2(120.0, 360.0), vec![vec![vec![2], vec![5], vec![3]]]), + (pos2(60.0, 466.0), vec![vec![vec![2], vec![3, 5]]]), + (pos2(120.0, 390.0), vec![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 = testing::DockDriver::new(layout, TestContent::default()) + .with_area(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 600.0))) + .with_style(|s| s.side_edge_zones = true); + let tab = d.hit(&Hit::Tab(5)).unwrap_or_else(|| panic!("tab 5")).center(); + d.drag(tab, at); + assert_eq!(columns(&d.layout, Side::Start), expected, "{at:?}"); + } +} + +#[test] +fn a_group_edge_scrolled_out_of_view_previews_its_line_at_the_views_edge() { + // Groups 306 pt high from y = 14: group 3 spans 320..626 and its body's bottom third starts + // at 533, above the window's bottom. + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3], &[4]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::with(layout, TestContent { min_height: 280.0 }, false); + let at = pos2(120.0, 580.0); + d.hold(bar(&d, 5), at); + assert!(d.painted(hline(598.0, 0.0..=240.0)), "the line at the view's bottom"); + d.step(vec![button(at, false)]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3], vec![5], vec![4]]]); +} + +#[test] +fn in_a_scrolled_column_a_stack_previews_the_rect_at_the_scroll_offset() { + // Groups 306 pt high from y = 14: group 3 spans 320..626 before scrolling. + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3], &[4]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::with(layout, TestContent { min_height: 280.0 }, false); + let over = d.tab(2).center(); + d.step(vec![ + Event::PointerMoved(over), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -200.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }, + ]); + d.wait(); + let group = d.tab(3); + assert!(group.top() < 250.0, "scrolled up: {group:?}"); + // Group 3's top third stacks 5 above it, where 3 is now. + let at = pos2(120.0, group.bottom() + 40.0); + d.hold(bar(&d, 5), at); + let held = d.shapes.clone(); + d.step(vec![button(at, false)]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![3], vec![4]]]); + d.settle(); + let rect = d.group_rect(5, Some(3), 2); + d.shapes = held; + assert!(d.previewed(rect), "the rect group 5 takes, {rect:?}"); +} + +#[test] +fn a_stripless_dock_groups_top_third_stacks_above_it() { + // Canvas 9 in the middle; protected panel 0 alone in the lower group draws no tab strip, so + // its body is 307..600. + let mut layout = with_floating(docked(Side::Start, &[&[&[2], &[0]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + if let Some(Node::Split { second, .. }) = &mut layout.root { + **second = Node::Tabs { panels: vec![9], active: 0 }; + } + let mut d = Driver::new(layout); + d.drag(bar(&d, 5), pos2(120.0, 330.0)); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![0]]]); +} + +#[test] +fn dock_bands_are_28_pt_wide() { + let window = || with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let target = |layout: Layout, at: Pos2| { + let mut d = Driver::new(layout); + let out = d.drag(bar(&d, 5), at); + moved(&out.actions).map(|(_, to)| to.clone()) + }; + let column_after = Target::At { anchor: 2, placement: Placement::Column { after: true } }; + // 14 pt either side of the dock's inner edge at 240; past it, the protected canvas. + assert_eq!(target(window(), pos2(253.0, 300.0)), Some(column_after.clone())); + assert_ne!(target(window(), pos2(255.0, 300.0)), Some(column_after)); + // 28 pt along the window edge without a dock. + assert_eq!(target(window(), pos2(573.0, 300.0)), Some(Target::NewDock { side: Side::End })); + assert_ne!(target(window(), pos2(571.0, 300.0)), Some(Target::NewDock { side: Side::End })); + // 28 pt along the bottom of the part between the docks, which splits at the root. + assert!(matches!(target(window(), pos2(420.0, 573.0)), Some(Target::At { placement: Placement::Edge(Zone::Bottom), .. }))); + assert!(!matches!(target(window(), pos2(420.0, 571.0)), Some(Target::At { placement: Placement::Edge(_), .. }))); + // 14 pt either side of a seam. In a 400 pt column, 16 pt in from the window edge and 13 + // above the seam at 307 is relatively nearer group 2's left than its bottom, but the seam + // takes it; 15 above, the left third makes a column. + let wide = || { + let mut layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [450.0, 300.0, 100.0, 150.0])]); + if let Some(Node::Split { size, first, .. }) = &mut layout.root { + *size = SplitSize::FixedFirst(400.0); + if let Node::Dock { columns, .. } = &mut **first { + columns.iter_mut().for_each(|c| c.width = 400.0); + } + } + layout + }; + assert_eq!(target(wide(), pos2(16.0, 294.0)), Some(Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } })); + assert_eq!(target(wide(), pos2(16.0, 292.0)), Some(Target::At { anchor: 2, placement: Placement::Column { after: false } })); +} + +#[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); + let held = d.shapes.clone(); + let out = d.step(vec![button(edge, false)]); + d.settle(); + let rect = d.column_of(5); + assert_eq!((rect.left(), rect.top(), rect.bottom()), (240.0, 0.0, 600.0)); + let shapes = std::mem::replace(&mut d.shapes, held); + assert!(d.previewed(rect), "the new column's rect, the full height of the dock"); + d.shapes = shapes; + 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 held = d.shapes.clone(); + let out = d.step(vec![button(at, false)]); + d.settle(); + let rect = d.column_of(5); + let shapes = std::mem::replace(&mut d.shapes, held); + assert!(!d.previewed(rect), "{new:?}: no rect of the new dock's column"); + d.shapes = shapes; + 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] +/// A group dropped on a column's tab strip joins as a tab, on a group body +/// appends as its last tab, and in the empty space below starts a new group. +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] +/// A column of five groups with a 160 pt minimum is taller than the window; scrolling up over its +/// tab strip moves the last tab more than 100 pt into view. +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] +/// Dragging a column bar to the window edge moves the column first; dragging it +/// away from the dock floats every group in the column and shows a follow ghost. +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] +/// Escape, focus loss, pointer exit and `cancel_drag` each end a held column drag without emitting +/// actions or a group drop; the release changes nothing and the layout equals the original. +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] +/// Dropping a column's first tab just past its own edge anchors it after the +/// other column's panel; a panel alone in its column stays in place. +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] +/// A driver that never reported window focus can still drag tab 3 to a column edge (moving it after +/// panel 4). When focus arrives and is then lost mid-drag, releasing emits no action and restores the +/// original layout. +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]); +} + +/// Dock columns left and right of protected canvas 0, as in a creative app: group [1, 7] above +/// [2] at the left, [3] above [4] at the right, each column 200 wide, and a floating window +/// holding group [5, 6]. +fn creative() -> testing::DockDriver { + let dock = |side, groups: &[&[u32]]| Node::Dock { side, columns: vec![Column::new(groups.iter().map(|g| group(g)).collect(), 200.0)] }; + let canvas = Node::Tabs { panels: vec![0], active: 0 }; + let inner = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(200.0), + first: Box::new(canvas), + second: Box::new(dock(Side::End, &[&[3], &[4]])), + }; + let root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(200.0), + first: Box::new(dock(Side::Start, &[&[1, 7], &[2]])), + second: Box::new(inner), + }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![5, 6], 0, [300.0, 200.0, 200.0, 160.0])] }; + testing::DockDriver::new(layout, TestContent::default()).with_style(|s| { + s.side_edge_zones = false; + s.new_dock_zones = true; + s.float_on_empty_drop = true; + s.title_bar_docks = true; + }) +} + +/// The rect of the result preview the last frame painted: a 2 pt accent outline with a light +/// fill. +fn result_preview>(d: &testing::DockDriver) -> Option { + let fill = DockStyle::default().accent.gamma_multiply(0.08); + d.shapes() + .iter() + .find_map(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) if shape.fill == fill && shape.stroke.width == 0.0 => Some(shape.rect), + _ => None, + }) + .filter(|rect| d.outlined(*rect)) +} + +#[test] +fn a_group_dropped_on_the_window_edge_makes_a_column_there_and_previews_the_edge_line() { + // Group [1, 7] heads the left column, so the column's window edge is named by panel 1: the + // drop names the column by panel 2 instead and moves the whole group out. + let mut d = creative(); + let at = pos2(3.0, 300.0); + d.hold(d.hit(&Hit::DragBar(1)).unwrap().center(), at); + assert!(d.filled(Rect::from_x_y_ranges(0.0..=4.0, 0.0..=600.0)), "the line along the window edge"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: 1, to: Target::At { anchor: 2, placement: Placement::Column { after: false } } }]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![1, 7]], vec![vec![2]]]); + + // A floating window's group at the right window edge. + let mut d = creative(); + let at = pos2(797.0, 300.0); + d.hold(d.hit(&Hit::DragBar(5)).unwrap().center(), at); + assert!(d.filled(Rect::from_x_y_ranges(796.0..=800.0, 0.0..=600.0)), "the line along the window edge"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: 5, to: Target::At { anchor: 3, placement: Placement::Column { after: false } } }]); + assert_eq!(columns(&d.layout, Side::End), vec![vec![vec![5, 6]], vec![vec![3], vec![4]]]); + + // The bottom edge band between the docks: the line along the canvas's bottom. + let mut d = creative(); + let at = pos2(400.0, 597.0); + d.hold(d.hit(&Hit::DragBar(5)).unwrap().center(), at); + assert!(d.filled(Rect::from_x_y_ranges(204.0..=596.0, 596.0..=600.0)), "the line along the canvas's bottom edge"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + assert!( + matches!(&out.actions[..], [Action::MoveGroup { panel: 5, to: Target::At { placement: Placement::Edge(Zone::Bottom), .. } }]), + "{:?}", + out.actions + ); +} + +#[test] +fn a_tab_dropped_beside_a_dock_group_previews_the_rect_it_takes() { + // The bottom third of group [2], the left column's last group: a new group below it. + let mut d = creative(); + let at = pos2(100.0, 560.0); + d.hold(d.hit(&Hit::Tab(6)).unwrap().center(), at); + let preview = result_preview(&d); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: 6, anchor: 2, placement: Placement::Group { below: true, height: None } }]); + d.settle(); + let rect = Rect::from_x_y_ranges(0.0..=200.0, d.hit(&Hit::Tab(6)).unwrap().top()..=600.0); + assert_eq!(preview, Some(rect.shrink(1.0)), "group 6's rect below group 2"); +} diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs new file mode 100644 index 00000000..b70ec5c2 --- /dev/null +++ b/crates/ui/src/docking/drop_tests.rs @@ -0,0 +1,587 @@ +//! Drop targets of the default profile over frames: a group body's thirds, the tab outline, +//! the fallback where a side has no room, and the root edge band. + +use std::collections::HashMap; + +use super::testing::DockDriver; +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Color32, Pos2, Rect, pos2, vec2}; + +/// Panel bodies by their rects; `rules` are the permissions of panels that differ from the +/// default, `min_width` every panel's least width. +#[derive(Default)] +struct Bodies { + bodies: HashMap, + rules: HashMap, + min_width: f32, +} + +impl DockContent for Bodies { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + self.bodies.insert(*panel, ui.max_rect()); + } + fn permissions(&self, panel: &u32) -> Permissions { + self.rules.get(panel).copied().unwrap_or_default() + } + fn limits(&self, _: &u32) -> PanelLimits { + PanelLimits { min: vec2(self.min_width, 0.0), ..PanelLimits::default() } + } +} + +type Driver = DockDriver; + +/// Frames `[1]` and `[2, 3]` side by side, each half of the area. +fn frames() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(Node::Tabs { panels: vec![1], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![2, 3], active: 1 }), + }), + floating: Vec::new(), + } +} + +fn driver(rules: &[(u32, Permissions)]) -> Driver { + DockDriver::new(frames(), Bodies { rules: rules.iter().copied().collect(), ..Bodies::default() }) +} + +/// The body of `panel` and its group's rect, the 26 pt strip included. +fn group(d: &Driver, panel: u32) -> (Rect, Rect) { + let body = d.content.bodies.get(&panel).copied().unwrap_or_else(|| panic!("panel {panel} not drawn")); + (body, 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() +} + +/// The fill of the last frame's rect shape at `rect` that has no stroke. +fn fill_at(d: &Driver, rect: Rect) -> Option { + d.shapes().iter().find_map(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) if shape.rect == rect && shape.stroke.width == 0.0 && shape.fill != Color32::TRANSPARENT => Some(shape.fill), + _ => None, + }) +} + +/// The 4 pt line along `rect`'s edge on `zone`'s side, inside it. +fn line(rect: Rect, zone: Zone) -> Rect { + match zone { + Zone::Left => Rect::from_x_y_ranges(rect.left()..=(rect.left() + 4.0), rect.y_range()), + Zone::Right => Rect::from_x_y_ranges((rect.right() - 4.0)..=rect.right(), rect.y_range()), + Zone::Top => Rect::from_x_y_ranges(rect.x_range(), rect.top()..=(rect.top() + 4.0)), + Zone::Bottom | Zone::Center => Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - 4.0)..=rect.bottom()), + } +} + +fn accent() -> Color32 { + DockStyle::default().accent +} + +#[test] +fn a_drop_on_a_groups_outer_third_docks_beside_it_with_its_rect_and_its_middle_joins_with_an_outline() { + let d = driver(&[]); + let (body, rect) = group(&d, 1); + let (w, h) = (body.width(), body.height()); + let line = |zone| line(rect, zone); + for (zone, at) in [ + (Zone::Left, pos2(body.left() + w * 0.3, body.center().y)), + (Zone::Right, pos2(body.right() - w * 0.3, body.center().y)), + (Zone::Top, pos2(body.center().x, body.top() + h * 0.3)), + (Zone::Bottom, pos2(body.center().x, body.bottom() - h * 0.3)), + // In the top left corner, the top is relatively nearer. + (Zone::Top, pos2(body.left() + w * 0.25, body.top() + h * 0.2)), + ] { + let mut d = driver(&[]); + d.hold(tab(&d, 3), at); + assert!(!d.outlined(rect.shrink(1.0)), "{zone:?}"); + let out = previews_result(driver(&[]), tab(&d, 3), at, |d| group(d, 3).1); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); + } + // The middle and the tab strip join: the group's outline with a light fill, no line. + for at in [body.center(), pos2(rect.center().x, rect.top() + 13.0)] { + let mut d = driver(&[]); + d.hold(tab(&d, 3), at); + assert!(d.outlined(rect.shrink(1.0)), "{at:?}"); + assert_eq!(fill_at(&d, rect.shrink(1.0)), Some(accent().gamma_multiply(0.08)), "{at:?}: a light fill"); + assert!([Zone::Left, Zone::Right, Zone::Top, Zone::Bottom].into_iter().all(|z| !d.filled(line(z))), "{at:?}"); + let out = d.release(at); + assert!( + matches!(&out.actions[..], [Action::Move { panel: 3, anchor: 1, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) }]), + "{:?}", + out.actions + ); + } + // A group's drag bar previews with the same painter. + let mut d = driver(&[]); + let bar = d.hit(&Hit::DragBar(2)).unwrap().center(); + d.hold(bar, body.center()); + assert!(d.outlined(rect.shrink(1.0))); +} + +#[test] +fn without_new_dock_zones_a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { + let canvas = Permissions { accepts_tabs: false, ..Permissions::default() }; + let start = || driver(&[(1, canvas)]).with_style(|style| style.new_dock_zones = false); + let d = start(); + let (body, rect) = group(&d, 1); + for (zone, at) in [(Zone::Left, body.center() - vec2(10.0, 0.0)), (Zone::Top, body.center() - vec2(0.0, 10.0))] { + let out = previews_result(start(), tab(&d, 3), at, |d| group(d, 3).1); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); + } + // Its strip joins nothing. + let mut d = start(); + let out = d.drag(tab(&d, 3), pos2(rect.center().x, rect.top() + 13.0)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { anchor: 1, placement: Placement::Tab { .. }, .. })), "{:?}", out.actions); +} + +#[test] +fn a_side_without_room_joins_as_a_tab_or_offers_nothing() { + // Two 400 pt frames of panels at least 300 wide: neither has room for two halves side by + // side, but both have room for two halves stacked. + let narrow = |rules: &[(u32, Permissions)]| { + DockDriver::new(frames(), Bodies { rules: rules.iter().copied().collect(), min_width: 300.0, ..Bodies::default() }) + }; + let d = narrow(&[]); + let (body, rect) = group(&d, 1); + let left = pos2(body.left() + body.width() * 0.25, body.center().y); + let mut d = narrow(&[]); + d.hold(tab(&d, 3), left); + assert!(d.outlined(rect.shrink(1.0)), "the tab outline"); + let out = d.release(left); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Center) }]); + let top = pos2(body.center().x, body.top() + body.height() * 0.2); + let mut d = narrow(&[]); + let out = d.drag(tab(&d, 3), top); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Top) }]); + // A group that takes no tabs offers nothing there. + let mut d = narrow(&[(1, Permissions { accepts_tabs: false, ..Permissions::default() })]); + d.hold(tab(&d, 3), left); + assert!(!d.outlined(rect.shrink(1.0))); + let out = d.release(left); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { anchor: 1, .. })), "{:?}", out.actions); +} + +#[test] +fn the_root_edge_band_is_28_pt_and_previews_a_line_along_the_layouts_edge() { + let d = driver(&[]); + let (body, _) = group(&d, 1); + let x = body.center().x; + let window = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let out = previews_line(driver(&[]), tab(&d, 3), pos2(x, 573.0), line(window, Zone::Bottom)); + assert!(matches!(&out[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{out:?}"); + let mut d = driver(&[]); + d.drag(tab(&d, 3), pos2(x, 573.0)); + d.settle(); + let (_, rect) = group(&d, 3); + assert_eq!((rect.left(), rect.right(), rect.bottom()), (0.0, 800.0, 600.0), "along the layout's whole bottom edge"); + // Past the band, the group's own bottom third. + let mut d = driver(&[]); + let out = d.drag(tab(&d, 3), pos2(x, 571.0)); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Bottom) }]); + // The band reaches only 4 pt past its line into a tab strip, which joins the group. + let mut d = driver(&[]); + let out = d.drag(tab(&d, 3), pos2(x, 12.0)); + assert!(matches!(&out.actions[..], [Action::Move { panel: 3, anchor: 1, placement: Placement::Tab { .. } }]), "{:?}", out.actions); + let mut d = driver(&[]); + let out = d.drag(tab(&d, 3), pos2(x, 7.0)); + assert!(matches!(&out.actions[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Top), .. }]), "{:?}", out.actions); +} + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { + Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } +} + +/// Side frame `[1]`, canvas 0 and side frame `[2, 3]` side by side, 200, 400 and 200 pt wide. +fn canvas_columns() -> Layout { + let sides = split(SplitAxis::Horizontal, SplitSize::FixedSecond(200.0), tabs(&[0]), Node::Tabs { panels: vec![2, 3], active: 1 }); + Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(200.0), tabs(&[1]), sides)), floating: Vec::new() } +} + +fn canvas_driver(canvas: Permissions, style: impl Fn(&mut DockStyle) + 'static) -> Driver { + DockDriver::new(canvas_columns(), Bodies { rules: [(0, canvas)].into(), ..Bodies::default() }).with_style(style) +} + +fn moves(out: &DockOutput) -> bool { + out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::MovePanel { .. })) +} + +#[test] +fn between_side_frames_the_outer_edge_band_covers_only_the_canvas_column() { + let d = canvas_driver(Permissions::PROTECTED, |_| {}); + let (canvas, _) = group(&d, 0); + let (side, _) = group(&d, 1); + let at = pos2(canvas.center().x, 590.0); + let mut d = canvas_driver(Permissions::PROTECTED, |_| {}); + // The line along the canvas column's bottom. + let out = previews_line(canvas_driver(Permissions::PROTECTED, |_| {}), tab(&d, 3), at, line(canvas, Zone::Bottom)); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Bottom) }]); + d.drag(tab(&d, 3), at); + // The side frames keep their full height. + let column = split(SplitAxis::Vertical, SplitSize::Ratio(0.75), tabs(&[0]), tabs(&[3])); + let sides = split(SplitAxis::Horizontal, SplitSize::FixedSecond(200.0), column, tabs(&[2])); + assert_eq!(d.layout.root, Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(200.0), tabs(&[1]), sides))); + // Under a side frame, its own bottom third. + let mut d = canvas_driver(Permissions::PROTECTED, |_| {}); + let out = d.drag(tab(&d, 3), pos2(side.center().x, 590.0)); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Bottom) }]); +} + +#[test] +fn between_docks_the_outer_edge_band_covers_the_part_between_them() { + let dock = |side, panels: &[u32]| Node::Dock { side, columns: vec![Column::new(vec![Group::new(panels.to_vec(), 0)], 200.0)] }; + let middle = split(SplitAxis::Vertical, SplitSize::Ratio(0.7), tabs(&[0]), tabs(&[4])); + let inner = split(SplitAxis::Horizontal, SplitSize::FixedSecond(200.0), middle.clone(), dock(Side::End, &[2, 3])); + let layout = + Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(200.0), dock(Side::Start, &[1]), inner)), floating: Vec::new() }; + let driver = || DockDriver::new(layout.clone(), Bodies { rules: [(0, Permissions::PROTECTED)].into(), ..Bodies::default() }); + let d = driver(); + let (canvas, _) = group(&d, 0); + let (timeline, _) = group(&d, 4); + let area = Rect::from_x_y_ranges(canvas.x_range(), (canvas.top() - 26.0)..=timeline.bottom()); + let at = pos2(canvas.center().x, 590.0); + // The line along the bottom between the docks. + let out = previews_line(driver(), tab(&d, 3), at, line(area, Zone::Bottom)); + assert!(matches!(&out[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{out:?}"); + let mut d = driver(); + d.drag(tab(&d, 3), at); + let Some(Node::Split { second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; + let Node::Split { first, .. } = &**second else { panic!("{:?}", d.layout) }; + assert_eq!(**first, split(SplitAxis::Vertical, SplitSize::Ratio(0.75), middle, tabs(&[3]))); +} + +/// The frame beside `panel`'s own: what an edge split with `panel` wrapped. +fn wrapped(layout: &Layout, panel: u32) -> Option> { + let Some(dock_model::Place::Node(path)) = layout.place(&panel) else { return None }; + let (last, parent) = path.split_last()?; + layout.root.as_ref().and_then(|root| dock_model::node_at(root, &[parent, &[!last]].concat())).cloned() +} + +#[test] +fn over_a_full_width_row_the_top_edge_band_covers_only_the_canvas_column_above_it() { + // SoundCraft's Edit window: side frame 1, canvas 0 and side frame [2, 3] over a full-width + // editor 4. + let Some(upper) = canvas_columns().root else { panic!() }; + let root = split(SplitAxis::Vertical, SplitSize::Ratio(0.7), upper.clone(), tabs(&[4])); + let layout = Layout { root: Some(root), floating: Vec::new() }; + let driver = || DockDriver::new(layout.clone(), Bodies { rules: [(0, Permissions::PROTECTED)].into(), ..Bodies::default() }); + let d = driver(); + let (canvas, _) = group(&d, 0); + let at = pos2(canvas.center().x, 5.0); + // The line along the canvas column's top, not the window's. + let out = previews_line(driver(), tab(&d, 3), at, line(canvas, Zone::Top)); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Top) }]); + let mut d = driver(); + d.drag(tab(&d, 3), at); + assert_eq!(wrapped(&d.layout, 3), Some(tabs(&[0])), "{:?}", d.layout); +} + +#[test] +fn an_outer_edge_drop_wraps_the_area_it_previews_when_the_app_applies_other_permissions() { + // Frame [2, 3] and canvas 0 beside frame 1, which is protected only while drawing (PDFCraft's + // bare tools mode): drawn, the edge band is not narrowed to the canvas. + let inner = split(SplitAxis::Horizontal, SplitSize::Ratio(0.5), Node::Tabs { panels: vec![2, 3], active: 1 }, tabs(&[0])); + let layout = Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::Ratio(0.7), inner, tabs(&[1]))), floating: Vec::new() }; + let drawn = [(0, Permissions::PROTECTED), (1, Permissions::PROTECTED)]; + let driver = || DockDriver::new(layout.clone(), Bodies { rules: drawn.into(), ..Bodies::default() }); + let d = driver(); + let (canvas, _) = group(&d, 0); + let at = pos2(canvas.center().x, 5.0); + let mut d = driver(); + d.hold(tab(&d, 3), at); + assert!(d.filled(line(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), Zone::Top)), "the line along the window's top"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + let mut applied = layout.clone(); + let apply = |p: &u32| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() }; + applied.apply_all(out.actions.clone(), apply).unwrap_or_else(|e| panic!("{e}: {:?}", out.actions)); + let mut rest = layout; + rest.apply(Action::Close { panel: 3 }).unwrap(); + assert_eq!(wrapped(&applied, 3), rest.root, "{:?}", out.actions); +} + +#[test] +fn drop_edges_limit_the_outer_edges_and_the_canvas_sides() { + let left_right = |style: &mut DockStyle| style.drop_edges = Sides::LEFT_RIGHT; + let d = canvas_driver(Permissions::PROTECTED, left_right); + // The canvas has no tab strip: its body is its frame. + let (canvas, _) = group(&d, 0); + let (side, _) = group(&d, 1); + // Without top and bottom, no outer edge takes a drop: left and right are docks here. + for at in [pos2(canvas.center().x, 590.0), pos2(canvas.left() + 5.0, 300.0), pos2(canvas.right() - 5.0, 300.0)] { + let mut d = canvas_driver(Permissions::PROTECTED, left_right); + d.hold(tab(&d, 3), at); + assert!(!d.filled(line(canvas, Zone::Bottom)), "{at:?}"); + let out = d.release(at); + assert!(!moves(&out), "{at:?}: {:?}", out.actions); + } + // A side frame keeps all four sides. + for (zone, y) in [(Zone::Bottom, 590.0), (Zone::Top, side.top() + side.height() * 0.2)] { + let mut d = canvas_driver(Permissions::PROTECTED, left_right); + let out = d.drag(tab(&d, 3), pos2(side.center().x, y)); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); + } + // No drop edges: no outer edge takes a drop. + for at in [pos2(canvas.center().x, 590.0), pos2(canvas.center().x, 5.0), pos2(canvas.left() + 5.0, 300.0), pos2(canvas.right() - 5.0, 300.0)] { + let mut d = canvas_driver(Permissions::PROTECTED, |style| style.drop_edges = Sides::NONE); + let out = d.drag(tab(&d, 3), at); + assert!(!moves(&out), "{at:?}: {:?}", out.actions); + } +} + +#[test] +fn without_new_dock_zones_a_canvas_that_takes_splits_takes_them_only_on_its_drop_edges() { + let beside = Permissions { accepts_splits: true, ..Permissions::PROTECTED }; + let d = canvas_driver(beside, |_| {}); + let (body, _) = group(&d, 0); + // Nearer the bottom than the left, and right of center nearer the right than the top. + let low = pos2(body.left() + body.width() * 0.3, body.bottom() - 40.0); + let high = pos2(body.right() - body.width() * 0.3, body.top() + 40.0); + for (edges, zones) in [ + (DockStyle::default().drop_edges, [Some(Zone::Bottom), Some(Zone::Top)]), + (Sides::LEFT_RIGHT, [Some(Zone::Left), Some(Zone::Right)]), + (Sides::NONE, [None, None]), + ] { + for (at, zone) in [low, high].into_iter().zip(zones) { + let split_frames = move |style: &mut DockStyle| { + style.new_dock_zones = false; + style.drop_edges = edges; + }; + let mut d = canvas_driver(beside, split_frames); + let out = d.drag(tab(&d, 3), at); + match zone { + Some(zone) => assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Split(zone) }], "{edges:?}"), + None => assert!(!moves(&out), "{edges:?}: {:?}", out.actions), + } + // With new dock zones the canvas body takes no drop on any side. + let mut d = canvas_driver(beside, move |style| style.drop_edges = edges); + let out = d.drag(tab(&d, 3), at); + assert!(!moves(&out), "{edges:?}: {:?}", out.actions); + } + } +} + +/// Whether the last frame painted a drop preview: an accent line, outline or fill. +fn previews(d: &Driver) -> bool { + let accent = accent(); + d.shapes().iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.fill == accent || shape.fill == accent.gamma_multiply(0.08) || shape.stroke.color == accent, + egui::Shape::Path(path) => path.fill != Color32::TRANSPARENT && path.fill == accent.gamma_multiply(0.3), + _ => false, + }) +} + +fn ratio_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) } +} + +#[test] +fn a_drop_that_changes_only_sizes_shows_nothing_and_does_nothing() { + // CADCraft's sidebar: Layers (2) alone above Properties (3) and a third group (4), beside + // the canvas (1); and the same with the column as one two-group split. + let nested = ratio_split(SplitAxis::Vertical, 0.3, tabs(&[2]), ratio_split(SplitAxis::Vertical, 0.5, tabs(&[3]), tabs(&[4]))); + let flat = ratio_split(SplitAxis::Vertical, 0.3, tabs(&[2]), tabs(&[3])); + for column in [nested, flat] { + let layout = Layout { root: Some(ratio_split(SplitAxis::Horizontal, 0.6, tabs(&[1]), column)), floating: Vec::new() }; + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, _) = group(&d, 3); + let top = pos2(body.center().x, body.top() + body.height() * 0.2); + // Properties' bottom third moves Layers, with the rect it takes. + let below = pos2(body.center().x, body.bottom() - body.height() * 0.2); + previews_result(start(), tab(&d, 2), below, |d| group(d, 2).1); + // Its top third would put Layers back where it is. + let mut d = start(); + d.hold(tab(&d, 2), top); + assert!(!previews(&d), "no outline or fill"); + let out = d.release(top); + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert_eq!(d.layout, layout); + } + // A lone frame's tab on the layout edge its frame already holds. + let layout = Layout { root: Some(ratio_split(SplitAxis::Vertical, 0.75, tabs(&[1]), tabs(&[4]))), floating: Vec::new() }; + let mut d = DockDriver::new(layout.clone(), Bodies::default()); + let at = pos2(400.0, 590.0); + d.hold(tab(&d, 4), at); + assert!(!previews(&d)); + let out = d.release(at); + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert_eq!(d.layout, layout); +} + +#[test] +fn a_dock_drop_that_changes_only_heights_shows_nothing_and_does_nothing() { + // A dock column of a sized lone group (2) above another (3), beside the canvas (1). + let column = Column::new(vec![Group { height: Some(150.0), ..Group::new(vec![2], 0) }, Group::new(vec![3], 0)], 240.0); + let dock = Node::Dock { side: Side::End, columns: vec![column] }; + let root = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedSecond(240.0), first: Box::new(tabs(&[1])), second: Box::new(dock) }; + let layout = Layout { root: Some(root), floating: Vec::new() }; + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, rect) = group(&d, 3); + let (upper, _) = group(&d, 2); + // Below the lower group moves the upper one, with the rect it takes. + let mut d = start(); + d.hold(tab(&d, 2), pos2(body.center().x, body.bottom() - body.height() * 0.2)); + assert!(previews(&d)); + // The lower group's top third and the seam between them put the group back where it is. + for at in [pos2(body.center().x, body.top() + body.height() * 0.2), pos2(body.center().x, (upper.bottom() + rect.top()) * 0.5)] { + let mut d = start(); + d.hold(tab(&d, 2), at); + assert!(!previews(&d), "{at:?}: no preview"); + let out = d.release(at); + assert!(out.actions.is_empty(), "{at:?}: {:?}", out.actions); + assert_eq!(d.layout, layout); + } +} + +#[test] +fn a_header_drag_of_a_windows_only_group_looks_through_its_window() { + // Group 4 floats alone over frame 1's left part. + let mut layout = frames(); + layout.floating.push(Floating::single(vec![4], 0, [40.0, 150.0, 300.0, 260.0])); + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, rect) = group(&d, 1); + let window = Rect::from_min_size(pos2(40.0, 150.0), vec2(300.0, 260.0)); + let at = pos2(body.left() + body.width() * 0.25, body.center().y); + assert!(window.contains(at)); + let bar = d.hit(&Hit::DragBar(4)).unwrap().center(); + // Frame 1's left third, behind the window. + let out = previews_result(start(), bar, at, |d| { + let (_, group) = group(d, 4); + assert_eq!(group.left(), rect.left()); + group + }); + assert_eq!(out, vec![Action::MoveGroup { panel: 4, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }]); +} + +#[test] +fn a_header_drag_in_a_window_of_two_groups_still_offers_the_other_group() { + let mut layout = frames(); + let tree = ratio_split(SplitAxis::Vertical, 0.5, tabs(&[4]), tabs(&[5])); + layout.floating.push(Floating::new(tree, [40.0, 100.0, 300.0, 400.0])); + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, rect) = group(&d, 5); + let mut d = start(); + let bar = d.hit(&Hit::DragBar(4)).unwrap().center(); + d.hold(bar, body.center()); + assert!(d.outlined(rect.shrink(1.0)), "group 5 joins"); + let out = d.release(body.center()); + assert!( + matches!( + &out.actions[..], + [Action::MoveGroup { panel: 4, to: Target::At { anchor: 5, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) } }] + ), + "{:?}", + out.actions + ); +} + +#[test] +fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() { + // Frame 2 is 60 pt wide beside frame 1; group 4 floats over frame 1. + let root = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedSecond(60.0), first: Box::new(tabs(&[1])), second: Box::new(tabs(&[2])) }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![4], 0, [100.0, 100.0, 200.0, 200.0])] }; + let start = || DockDriver::new(layout.clone(), Bodies::default()); + let d = start(); + let (body, rect) = group(&d, 2); + let at = pos2(body.left() + 8.0, body.center().y); + // The preview is the rect the group takes left of frame 2. + let out = previews_result(start(), tab(&d, 4), at, |d| group(d, 4).1); + assert!(matches!(&out[..], [Action::Move { panel: 4, anchor: 2, placement: Placement::SplitSized { zone: Zone::Left, .. } }]), "{out:?}"); + let mut d = start(); + d.drag(tab(&d, 4), at); + d.settle(); + assert!((group(&d, 2).1.width() - rect.width()).abs() < 1.0, "frame 2 keeps its width"); + assert!(group(&d, 4).1.width() >= 40.0); +} + +/// The rect of the result preview the last frame painted: a 2 pt accent outline with a light +/// fill. +fn result_preview(d: &Driver) -> Option { + let fill = accent().gamma_multiply(0.08); + d.shapes() + .iter() + .find_map(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) if shape.fill == fill && shape.stroke.width == 0.0 => Some(shape.rect), + _ => None, + }) + .filter(|rect| d.outlined(*rect)) +} + +/// Drags `from` to `at` and releases: the preview while held is the drop line `line`, with no +/// result rect. Returns the drop's actions. +fn previews_line(mut d: Driver, from: Pos2, at: Pos2, line: Rect) -> Vec> { + d.hold(from, at); + assert!(d.filled(line), "the line {line:?}"); + assert_eq!(result_preview(&d), None); + let out = d.release(at); + d.settle(); + out.actions +} + +/// Drags `from` to `at` and back to rest: the preview while held is the rect `rect_of` reads +/// once the drop is applied, inset by its 1 pt margin. Returns the drop's actions. +fn previews_result(mut d: Driver, from: Pos2, at: Pos2, rect_of: impl Fn(&Driver) -> Rect) -> Vec> { + d.hold(from, at); + let preview = result_preview(&d); + let out = d.release(at); + d.settle(); + assert_eq!(preview, Some(rect_of(&d).shrink(1.0)), "{:?}", out.actions); + out.actions +} + +#[test] +fn a_drop_beside_a_frame_previews_the_rect_the_group_takes() { + let d = driver(&[]); + let (body, _) = group(&d, 1); + let at = pos2(body.left() + body.width() * 0.2, body.center().y); + let out = previews_result(driver(&[]), tab(&d, 3), at, |d| group(d, 3).1); + assert_eq!(out, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Left) }]); + // A whole group the same. + let bar = d.hit(&Hit::DragBar(2)).unwrap().center(); + // Panel 3 is the group's selected tab, the one it draws. + let out = previews_result(driver(&[]), bar, at, |d| group(d, 3).1); + assert_eq!(out, vec![Action::MoveGroup { panel: 2, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }]); +} + +/// A 240 pt dock column at the start, group `[2]` above `[3, 4]`, beside frame 1. +fn dock_driver() -> Driver { + let column = Column::new(vec![Group::new(vec![2], 0), Group::new(vec![3, 4], 0)], 240.0); + let dock = Node::Dock { side: Side::Start, columns: vec![column] }; + let root = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(tabs(&[1])) }; + DockDriver::new(Layout { root: Some(root), floating: Vec::new() }, Bodies::default()) +} + +/// The rect of the dock column of `panel`'s group, its bar included, when that group fills it. +fn column_of(d: &Driver, panel: u32) -> Rect { + let bar = d.hit(&Hit::ColumnBar(panel)).unwrap_or_else(|| panic!("column bar {panel}")); + let (body, _) = group(d, panel); + Rect::from_x_y_ranges(body.x_range(), bar.top()..=body.bottom()) +} + +#[test] +fn a_dock_drop_previews_the_rect_the_group_or_column_takes() { + let d = dock_driver(); + let (upper, _) = group(&d, 2); + // Above group 2: a new group at the column's top. + let at = pos2(upper.center().x, upper.top() + 10.0); + let out = previews_result(dock_driver(), tab(&d, 4), at, |d| group(d, 4).1); + assert_eq!(out, vec![Action::Move { panel: 4, anchor: 2, placement: Placement::Group { below: false, height: None } }]); + // The column's inner edge: a new column after it, the full height. + let out = previews_result(dock_driver(), tab(&d, 4), pos2(upper.right() + 6.0, 200.0), |d| column_of(d, 4)); + assert_eq!(out, vec![Action::Move { panel: 4, anchor: 2, placement: Placement::Column { after: true } }]); + // The window's right edge: a new dock, previewed by the line along that edge. + let window = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let out = previews_line(dock_driver(), tab(&d, 4), pos2(795.0, 200.0), line(window, Zone::Right)); + assert_eq!(out, vec![Action::MovePanel { panel: 4, to: Target::NewDock { side: Side::End } }]); +} diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs new file mode 100644 index 00000000..34d1a4b2 --- /dev/null +++ b/crates/ui/src/docking/floating_tests.rs @@ -0,0 +1,847 @@ +//! 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, + /// A preview the host draws for every drag, from `resolve_drag`. + host_preview: Option, +} + +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 + } + fn resolve_drag(&mut self, _: &Context, _: &DockStyle, _: &DropRequest<'_, u32>) -> Option { + self.host_preview.map(|rect| DropFeedback { preview: Some(rect), insertion: None }) + } +} + +#[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, + /// What the last frame painted. + shapes: Vec, +} + +impl Driver { + /// Create a floating test driver and run two empty frames to settle sizing. + 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, + shapes: Vec::new(), + }; + driver.step(vec![]); + driver.step(vec![]); + driver + } + + /// One 800×600 frame with an inserted modifier event, optional split-tree style/native + /// windows/maximized frame/hidden panels. Clears texture deltas and returns `DockOutput`. + 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(); + self.shapes = full.shapes; + 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) + } + + /// Read the response rectangle of the move bar whose first panel is `first`. + fn title(&self, first: u32) -> Rect { + self.response(Id::unique(AREA).with(("move-group", Some(&first)))).unwrap() + } + + /// Read `panel`'s tab response rectangle, or `None` when it is not rendered. + 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)]) + } + + /// Opacity of the floating window that the last frame drew at `rect`: the alpha of its + /// opaque background fill. + fn window_opacity(&self, rect: [f32; 4]) -> Option { + let rect = Rect::from_min_size(pos2(rect[0], rect[1]), vec2(rect[2], rect[3])); + fn find(shape: &egui::Shape, rect: Rect) -> Option { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| find(s, rect)), + egui::Shape::Rect(r) if r.rect.min.distance(rect.min) < 0.5 && r.rect.max.distance(rect.max) < 0.5 && r.fill.a() > 0 => { + Some(f32::from(r.fill.a()) / 255.0) + } + _ => None, + } + } + self.shapes.iter().find_map(|s| find(&s.shape, rect)) + } + + 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] +/// Releasing a floating window's title bar on a dock seam stacks its group +/// between the target groups; releasing on the edge band makes an end dock. +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] +/// A group dragged to another floating window's top band splits that window like a frame; the gap +/// between groups is not a target, and the window divider resizes both sides. +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] +/// Double-clicking the window title toggles minimization of the whole window; the close box sits +/// 10 pt in from the trailing edge and emits `CloseWindow`, removing every panel in the window. +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] +/// With split-tree chrome, a drop 7 pt inside the outer edge targets the edge +/// dock, while a drop at 10 pt targets frame [1]'s split zone. +fn the_split_tree_outer_edge_band_is_8_pt() { + let mut d = Driver::new(frames(), true); + let tab = d.tab(3).unwrap().center(); + let out = d.drag(tab, pos2(7.0, 300.0)); + assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions); + // Past it, frame [1]'s left side zone. + let mut d = Driver::new(frames(), true); + let out = d.drag(tab, pos2(10.0, 300.0)); + assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Left) }), "{:?}", out.actions); +} + +#[test] +/// Maximizing a frame makes it start at the area edge; another frame's tab is not rendered and the +/// hidden floating window's move-group response is absent, so dragging its title emits no move. A +/// floating panel cannot be maximized. +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] +/// The tab context menu exposes the expected group actions without Minimize; Close Other emits +/// `CloseOthers` leaving only the chosen tab, and Maximize Frame emits `ToggleMaximize`. +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] +/// Hiding every panel draws no tabs and leaves the layout unchanged; the +/// `Except` set keeps only its panels, with a retained window below the resize band. +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] +/// A protected canvas that accepts splits is still kept when every panel is hidden, and draws +/// neither a tab nor a drag bar. +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] +/// When viewports are embedded, a floating window marked native draws in the +/// main window at its usual rect without emitting actions. +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] +/// With real viewports, a close event from a native window's backend produces `CloseWindow` and +/// removes all of that window's panels. +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] +/// `FloatingFrame` and `TitleBar` work without a dock area: close and menu clicks register, the bar +/// drag reports its delta, and an edge drag resizes the frame. +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] +/// Hidden panels give up their split space; the remaining split hit is named by its layout path and +/// dragging it emits `ResizeSplit` for exactly that path while the nested ratio changes. +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] +/// Hiding floating window 5 removes its coverage of the dock seam, so dragging +/// window 8 there stacks its group between panels 3 and 4. +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] +/// Each top resize edge sits directly above the title bar and keeps the opposite edges while +/// dragging; the title bar still moves the window, and a focused top edge responds to arrow keys. +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] +/// Undock Panel Group from one group of a stacked floating window emits `MoveGroup` to a float and +/// separates exactly that group; a window with only one group has no undock entry. +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); +} + +#[test] +fn a_title_bar_drag_fades_over_a_preview_the_host_draws() { + let mut d = Driver::new(workspace(), false); + d.content.host_preview = Some(Rect::from_min_size(pos2(20.0, 20.0), vec2(100.0, 100.0))); + let title = d.title(8).center(); + d.step(vec![Event::PointerMoved(title), button(title, true)]); + d.step(vec![Event::PointerMoved(title + vec2(8.0, 6.0))]); + d.step(vec![Event::PointerMoved(pos2(650.0, 150.0))]); + let rect = window_of(&d.layout, 8).unwrap().rect; + d.step(vec![]); + let shown = d.window_opacity(rect).unwrap(); + assert!((shown - 0.75).abs() < 0.01, "{shown}"); +} + +#[test] +fn an_in_window_floating_window_fades_only_while_its_title_bar_drag_highlights_a_drop_target() { + let mut d = Driver::new(workspace(), false); + let strip = d.tab(4).unwrap().center(); + let empty = pos2(650.0, 150.0); + // Opacity of window W2 (dragged) and W1 once the pointer rests at `at`: the window draws + // before the preview, so it fades a frame after the target lights up. + let over = |d: &mut Driver, at: egui::Pos2| { + d.step(vec![Event::PointerMoved(at)]); + let (dragged, other) = (window_of(&d.layout, 8).unwrap().rect, window_of(&d.layout, 5).unwrap().rect); + d.step(vec![]); + (d.window_opacity(dragged).unwrap(), d.window_opacity(other).unwrap()) + }; + let shown = |d: &Driver| d.window_opacity(window_of(&d.layout, 8).unwrap().rect).unwrap(); + let grab = |d: &mut Driver| { + let title = d.title(8).center(); + d.step(vec![Event::PointerMoved(title), button(title, true)]); + d.step(vec![Event::PointerMoved(title + vec2(8.0, 6.0))]); + }; + assert_eq!(shown(&d), 1.0); + grab(&mut d); + assert_eq!(over(&mut d, empty), (1.0, 1.0), "over empty space"); + let (dragged, other) = over(&mut d, strip); + assert!((dragged - 0.75).abs() < 0.01 && other == 1.0, "over a drop target: {dragged}, {other}"); + assert_eq!(over(&mut d, empty).0, 1.0, "after leaving the target"); + assert!((over(&mut d, strip).0 - 0.75).abs() < 0.01); + // Released off the target the window stays floating, opaque. + d.step(vec![Event::PointerMoved(empty), button(empty, false)]); + d.step(vec![]); + assert_eq!(shown(&d), 1.0, "after the release"); + assert!(window_of(&d.layout, 8).is_some()); + + // Escape over a target puts the window back, opaque. + grab(&mut d); + assert!((over(&mut d, strip).0 - 0.75).abs() < 0.01); + d.step(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + d.step(vec![]); + assert_eq!(shown(&d), 1.0, "after Escape"); + d.step(vec![button(strip, false)]); + assert!(window_of(&d.layout, 8).is_some()); +} diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs new file mode 100644 index 00000000..d6b28adc --- /dev/null +++ b/crates/ui/src/docking/group_tests.rs @@ -0,0 +1,844 @@ +//! 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, + /// Width the extra tabs ask for, and the rect they were last drawn in. + extra: f32, + extra_rect: Option, + 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 { + /// Test body that fills its rect when `fill` is set, otherwise takes the + /// configured height or draws a one-line `Body` label. + 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}") + } + /// Mark panels in `protected` as protected and leave the rest with default + /// permissions. + 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; + } + } + fn group_extra_tabs_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + self.extra + } + fn group_extra_tabs(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { + self.extra_rect = Some(ui.max_rect()); + } +} + +/// 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))) + } + + /// Create a group test driver for `layout`, `content` and `area`, then settle + /// two frames. + 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 + } + + /// One 800×600 frame with inserted modifier events and optional transparent/tweaked style or + /// custom area. Captures shapes, clears texture deltas, and returns `DockOutput`. + 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 + } + + /// Run one frame, assert it reports no error, and apply each returned action + /// with the content's permissions. + 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 + } + + /// Read the response rectangle for the dock widget named `name` and scoped to + /// `panel`. + fn rect(&self, name: &str, panel: u32) -> Rect { + self.ctx.read_response(Id::unique(AREA).with((name, Some(&panel)))).unwrap().rect + } + + /// Read the response rectangle for `panel`'s tab. + 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] +/// A group bar dropped on another strip emits one `MoveGroup` and keeps panel +/// order and the active panel; dropped on empty space it floats at its docked +/// width and 600 pt height; inside a column it joins the group below. +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] +/// In a 300 pt strip with eight tabs and a 24 pt header, the drag bar keeps at least 40 pt and ends +/// at the header; tabs shrink and overflow before it is narrowed, and dragging it floats all eight +/// panels as one group. +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); +} + +#[test] +/// The extra tabs follow the last panel tab, before the drag bar and the header. In a narrow row +/// they shrink to the minimum tab width while the panel tabs overflow, and the bar keeps its +/// width; a tab dropped on them goes after the last panel tab. +fn extra_tabs_follow_the_panel_tabs_and_overflow_with_them() { + let content = TestContent { header: 24.0, extra: 100.0, ..Default::default() }; + let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() }; + let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0))); + let (extra, bar) = (d.content.extra_rect.expect("extra tabs drawn"), d.rect("drag-bar", 1)); + assert_eq!((extra.left(), extra.width()), (d.tab(2).right(), 100.0)); + assert_eq!((bar.left(), bar.right()), (extra.right(), 600.0 - 24.0), "the bar and the header follow"); + d.drag(d.tab(1).center(), extra.center()); + assert_eq!(d.layout.root, Some(tabs(&[2, 1], 1)), "a drop on the extra tabs appends"); + + let content = TestContent { header: 24.0, extra: 100.0, ..Default::default() }; + let layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: Vec::new() }; + let d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 400.0))); + let overflow = d.ctx.read_response(Id::unique(AREA).with(("overflow", &1u32))).expect("the panel tabs overflow").rect; + let (extra, bar) = (d.content.extra_rect.expect("extra tabs drawn"), d.rect("drag-bar", 1)); + assert_eq!(overflow.left(), d.tab(1).right()); + assert_eq!(extra.left(), overflow.right(), "the extra tabs follow the overflow button"); + let min = DockStyle::default().min_tab_width; + assert!(extra.width() >= min - 0.5 && extra.width() < 100.0, "{extra:?}"); + assert_eq!((bar.left(), bar.right()), (extra.right(), 200.0 - 24.0)); + assert!(bar.width() >= 40.0, "{bar:?}"); +} + +/// 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(40.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(40.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] +/// Holding Ctrl or Command at release turns either a group-bar drop or a tab +/// drop into a float instead of a dock. +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] +/// Escape, focus loss, pointer exit and `cancel_drag` each end a held group drag with no actions or +/// drop; releasing changes nothing and the layout equals the original. +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"] { + // Nothing docked: a header drag of a window's only group looks through the window. + let layout = Layout { root: None, 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] +/// Moving a floating window by eight fractional pointer steps lands within one +/// point of the accumulated 80.3 × 60.7 pt offset, without step-rounding drift. +fn floating_border_movement_does_not_accumulate_fractional_step_rounding() { + let layout = Layout { root: None, 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] +/// With host-owned headers, dragging either the border or the bar reaches +/// `whole_group`, emits no action or group drop, and leaves the layout unchanged. +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] +/// A lone protected panel renders neither a tab nor a drag bar, while another group still has its +/// bar. +fn a_lone_protected_panel_draws_no_strip_and_no_bar() { + struct Bare; + impl DockContent for Bare { + /// A bare content body that draws nothing. + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} + /// Mark panel 1 protected and leave every other panel with default permissions. + 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] +/// The drag bar's context menu contains Close Tab Group, Minimize, Float Tab Group and Move Group +/// To; clicking Close emits `CloseGroup`, and Enter on the focused bar opens the menu. +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] +/// Dragging one group out of a two-group floating stack floats only that group +/// at the stack width and its recorded height in the stack. +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] +/// A docked panel floats at the title bar plus strip plus recorded content height, with a 120 pt +/// minimum and the 320 pt docked cap; a host floating size supplies the width and caps the height, +/// and a group uses its active panel's content height. +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] +/// A fill-height panel floats at the 320 pt docked cap without a host size +/// and at the host's height when one is supplied. +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] +/// Keyboard focus on a tab is kept across a user drag, but a layout reset that moves the tab clears +/// it and reports no new focus; closing the panel also gives focus up. +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] +/// Wheel selection is off by default. With the floating frame style, wheel up selects the previous +/// tab and stops at the first, trackpad points accumulate into a notch, huge deltas clamp at both +/// ends, and middle-click closes the tab. +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..0a353f2c --- /dev/null +++ b/crates/ui/src/docking/iconic_tests.rs @@ -0,0 +1,435 @@ +//! [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] +/// An icon click activates its panel and opens a canvas-side flyout; another +/// icon switches it, and the same icon closes it even with auto-collapse off. Escape +/// and outside presses close it only with auto-collapse on; a programmatic raise opens it. +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] +/// Choosing Auto-Collapse Iconic Panels from the iconic column's menu emits a +/// `DockEvent::ToggleAutoCollapse` event rather than mutating the layout directly. +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); +} + +/// A group dropped on the window edge beside an iconic column at that edge docks in a new column +/// there: the preview is the edge line alone, without the column's outline. +#[test] +fn an_edge_drop_next_to_an_iconic_column_highlights_only_the_edge() { + 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 target = pos2(2.0, 300.0); + d.hold(bar, target); + let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0)); + assert!(d.filled(Rect::from_x_y_ranges(0.0..=4.0, 0.0..=600.0)), "the edge line"); + assert!(!d.outlined(column.shrink(1.0)), "the iconic column is not outlined"); + d.release(target); + let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) }; + let Node::Dock { columns, .. } = first.as_ref() else { panic!("{first:?}") }; + let panels: Vec> = columns.iter().map(|c| c.groups.iter().flat_map(|g| g.panels.clone()).collect()).collect(); + assert_eq!(panels, vec![vec![5, 6], vec![1, 2]]); + assert!(!columns[0].iconic && columns[1].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/ime_tests.rs b/crates/ui/src/docking/ime_tests.rs new file mode 100644 index 00000000..d16c653d --- /dev/null +++ b/crates/ui/src/docking/ime_tests.rs @@ -0,0 +1,113 @@ +//! IME descriptors of editors in native floating windows (feature `native-windows`). + +use egui::{Event, Id, ImeEvent, Rect, ViewportId, output::IMEOutput, pos2, vec2}; + +use super::testing::DockDriver; +use super::{DockContent, Floating, Layout, Node}; +use crate::ime::{Composer, Edit}; +use crate::layout::{SplitAxis, SplitSize}; + +/// Desktop position of the main window's content. +const ORIGIN: egui::Pos2 = pos2(100.0, 50.0); + +struct Content { + /// The panel whose canvas takes text through `composer`. + editor: u32, + composer: Composer, + typed: String, + /// A panel whose body clears the pass's IME descriptor, as an app's own editor that + /// publishes after each body does when it has nothing to show. + clears: Option, + /// The bodies drawn in the last frame, with the viewport each was drawn in. + drawn: Vec<(u32, ViewportId)>, +} + +impl Content { + fn new(editor: u32) -> Self { + Self { editor, composer: Composer::new().with_owner(Id::unique("editor")), typed: String::new(), clears: None, drawn: Vec::new() } + } +} + +impl DockContent for Content { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + self.drawn.push((*panel, ui.ctx().viewport_id())); + if *panel == self.editor { + for edit in self.composer.update(ui.ctx(), true) { + if let Edit::Insert(text) = edit { + self.typed.push_str(&text); + } + } + self.composer.set_caret(ui.ctx(), Rect::from_min_size(ui.max_rect().min + vec2(8.0, 8.0), vec2(1.0, 16.0))); + } + if self.clears == Some(*panel) { + ui.ctx().output_mut(|o| o.ime = None); + } + } +} + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +/// Frame `[1, 2]`, and a native window with panel 5 above panel 6. +fn layout() -> Layout { + let split = Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[5])), second: Box::new(tabs(&[6])) }; + Layout { root: Some(tabs(&[1, 2])), floating: vec![Floating::new(split, [300.0, 80.0, 240.0, 300.0])] } +} + +fn driver(content: Content) -> DockDriver { + DockDriver::new(layout(), content).with_native_host(ORIGIN) +} + +fn preedit(text: &str) -> Event { + Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None }) +} + +/// The window's id and the descriptor its OS window published last. +fn window_ime(d: &DockDriver) -> (u64, Option) { + let id = d.layout.floating[0].id; + let viewport = d.viewport(id); + (id, d.host().windows.get(&viewport).and_then(|w| w.ime.clone())) +} + +#[test] +#[allow(clippy::expect_used)] +fn a_composition_in_a_native_window_publishes_on_that_window() { + let mut d = driver(Content::new(5)); + let (id, _) = window_ime(&d); + d.window_step(id, vec![preedit("に")]); + assert_eq!(d.content.composer.preedit().map(|p| p.text), Some("に")); + let (_, ime) = window_ime(&d); + let ime = ime.expect("the window publishes the editor's descriptor"); + assert_eq!(ime.owner, Id::unique("editor")); + // At the caret, in the window's own coordinates. + let caret = ime.cursor_rect; + assert!(caret.min.x < 240.0 && caret.min.y < 300.0, "{caret:?}"); + assert!(d.ime().is_none(), "the main window publishes nothing"); + + // Docked, the editor publishes on the main window. + let mut d = driver(Content::new(1)); + d.step(vec![preedit("に")]); + assert_eq!(d.ime().map(|ime| ime.owner), Some(Id::unique("editor"))); + assert!(window_ime(&d).1.is_none()); +} + +#[test] +#[allow(clippy::expect_used)] +fn a_body_after_the_editors_keeps_the_window_composing() { + let mut d = driver(Content { clears: Some(6), ..Content::new(5) }); + let (id, _) = window_ime(&d); + d.content.drawn.clear(); + d.window_step(id, vec![preedit("に")]); + let viewport = d.viewport(id); + assert!(d.content.drawn.ends_with(&[(5, viewport), (6, viewport)]), "{:?}", d.content.drawn); + let first = window_ime(&d).1.expect("the editor publishes"); + for _ in 0..2 { + d.window_step(id, Vec::new()); + let ime = window_ime(&d).1.expect("the window keeps publishing"); + assert_eq!((ime.owner, ime.context_generation), (first.owner, first.context_generation), "the same IME context"); + assert!(!ime.should_interrupt_composition); + } + d.window_step(id, vec![Event::Ime(ImeEvent::Commit("日".into()))]); + assert_eq!(d.content.typed, "日"); +} 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..b7985b82 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -5,13 +5,62 @@ //! 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, route_app_drag, send_keys, typing_window, +}; +pub use targets::{ + DropProposal, DropTarget, SIDE_EDGE_ZONE_DEPTH, Sides, ZoneRule, body_zone, drop_zone, resolve_drop, resolve_drop_by, 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 drop_tests; +#[cfg(test)] +mod floating_tests; +#[cfg(test)] +mod group_tests; +#[cfg(test)] +mod iconic_tests; +#[cfg(all(test, feature = "native-windows"))] +mod ime_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..0c90c140 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -1,11 +1,15 @@ 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, split_frame_sized, + 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 +24,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 +40,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 +57,238 @@ 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. Every window writes its `id`; a window read without one gets a +/// fresh one. +/// +/// 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`] draws a fresh id, [`Self::with_id`] sets one, + /// loading keeps the saved id (or draws one when it is missing or taken), 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> { + id: u64, + panels: &'a [P], + active: usize, + rect: [f32; 4], + } + + #[derive(Serialize)] + struct Tree<'a, P> { + id: u64, + 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 { id: self.id, panels, active: *active, rect: self.rect }.serialize(serializer) + } + root => Tree { + id: 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 +298,42 @@ 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 side [`Placement::Split`] that gives the new frame its own room instead of half the + /// anchor's frame: the new split takes `size`, and `outer` replaces the size of the nearest + /// enclosing split whose divider runs along the anchor frame's `zone` side, so the frames + /// beyond that divider give the room. An anchor at the edge of its area on that side takes + /// the nearest such split along its opposite side instead. The renderer offers it beside a + /// frame too small to halve. `Center`, and an anchor without either divider or with a dock + /// beyond it, are invalid. + SplitSized { zone: Zone, size: SplitSize, outer: SplitSize }, + /// 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. A protected anchor ([`Permissions::is_protected`], applied through + /// [`Layout::apply_with_permissions`]) narrows the part to the canvas column: where neither + /// side of a side-by-side split is a dock and only one holds a protected panel, that side; + /// for `Top` (`Bottom`), also the first (last) row of a split of rows when only that row + /// holds one. `Center` is invalid. + Edge(Zone), } /// A closed panel's former placement. Applications may retain this beside visibility flags. @@ -92,10 +352,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 +379,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 +394,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 +404,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 +419,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 +544,8 @@ pub enum DockError { InvalidGeometry, InvalidPlacement, NotAllowed, + /// Saved by a newer version of the docking format. + UnsupportedVersion, } impl fmt::Display for DockError { @@ -185,6 +559,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 +570,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 +587,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 +596,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 +605,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 +696,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 +712,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 +808,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) }, &|_| false)?; + } 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() }, &|_| false)?; + } 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 }, &|_| false)?, } } 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 +866,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 }, &|_| false)?; + 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 +886,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,44 +900,139 @@ 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) }, &|_| false)?; + 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.apply_inner(action, &|p| permissions(p).is_protected())?; proposed.validate()?; *self = proposed; - Ok(()) + Ok(closed) } - fn apply_inner(&mut self, action: Action

) -> Result<(), DockError> { + /// 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(), + }) + } + + /// `canvas` is the protected-panel test an outer edge drop narrows by. + fn apply_inner(&mut self, action: Action

, canvas: &dyn Fn(&P) -> bool) -> Result<(), DockError> { match action { Action::Move { panel, anchor, placement } => { if panel == anchor { @@ -425,36 +1051,45 @@ impl Layout

{ } } let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; - self.insert(entry, &anchor, placement) + self.insert(entry, &anchor, placement, canvas) } Action::Float { panel, rect } => { 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) } Action::Open { panel, anchor } => { if self.contains(&panel) { - return self.apply_inner(Action::Activate { panel }); + return self.apply_inner(Action::Activate { panel }, canvas); } let entry = StackEntry { panel, open: true, height: None }; if let Some(anchor) = anchor { - self.insert(entry, &anchor, Placement::Tab { before: None }) + self.insert(entry, &anchor, Placement::Tab { before: None }, canvas) } else if self.root.is_none() { self.root = Some(Node::Tabs { panels: vec![entry.panel], active: 0 }); Ok(()) @@ -464,21 +1099,21 @@ impl Layout

{ } Action::OpenAt { panel, anchor, placement } => { if self.contains(&panel) { - return self.apply_inner(Action::Move { panel, anchor, placement }); + return self.apply_inner(Action::Move { panel, anchor, placement }, canvas); } if let Placement::Tab { before: Some(before) } = &placement && !self.same_group(before, &anchor) { return Err(DockError::MissingTarget); } - self.insert(StackEntry { panel, open: true, height: None }, &anchor, placement) + self.insert(StackEntry { panel, open: true, height: None }, &anchor, placement, canvas) } Action::MoveFloating { panel, rect } => { 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 +1123,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 +1144,205 @@ 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(), canvas) + } + Action::MovePanel { panel, to } => match to { + Target::At { anchor, placement } => self.apply_inner(Action::Move { panel, anchor, placement }, canvas), + to => { + let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; + self.insert_moving(Moving::group(Group::new(vec![entry.panel], 0)), to, canvas) + } + }, + 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 } - 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(()); + /// 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 })); } - if let Some(root) = self.root.as_mut() - && insert_node(root, &entry, anchor, &placement) + // A canvas anchor would narrow the edge to the canvas column. + let edge = docked.iter().find(|(p, _)| !permissions(p).is_protected()).or(docked.first()); + edge.map(|(panel, _)| ((*panel).clone(), Placement::Edge(Zone::Right))) + } + + fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

, canvas: &dyn Fn(&P) -> bool) -> Result<(), DockError> { + 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 }, canvas); + }; + 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 let Placement::SplitSized { zone, size, outer } = placement { + let new = Node::Tabs { panels: vec![entry.panel], active: 0 }; + return if split_frame_sized(root, &path, new, zone, size, outer) { Ok(()) } else { Err(DockError::InvalidPlacement) }; + } + 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(_) | Placement::SplitSized { .. } => 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 +1356,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 +1434,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 +1480,7 @@ fn activate(node: &mut Node

, panel: &P) -> bool { } } Node::Split { first, second, .. } => activate(first, panel) || activate(second, panel), + Node::Dock { .. } => false, } } @@ -643,7 +1488,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 +1497,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 +1514,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 +1527,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 +1535,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 +1572,7 @@ fn node_location(node: &Node

, panel: &P) -> Option } Some(location) } + Node::Dock { .. } => None, } } @@ -773,6 +1613,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..4df96d21 --- /dev/null +++ b/crates/ui/src/docking/model_tests.rs @@ -0,0 +1,800 @@ +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!({"id": plain.id, "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!(value["id"], json!(window.id), "{value}"); + assert_eq!(serde_json::from_value::>(value).unwrap(), window); + } + assert!(serde_json::from_value::>(json!({"rect": rect})).is_err()); + } + + #[test] + fn in_window_windows_keep_their_ids_through_a_save() { + let rect = [1.0, 2.0, 300.0, 200.0]; + let layout = Layout { + root: None, + floating: vec![Floating::single(vec![4u32], 0, rect).with_id(7), Floating::stack(vec![g(&[5], 0), g(&[6], 0)], rect).unwrap().with_id(8)], + }; + let saved = serde_json::to_string(&layout).unwrap(); + let loaded: Layout = serde_json::from_str(&saved).unwrap(); + assert_eq!(loaded, layout); + assert_eq!(loaded.floating.iter().map(|w| w.id).collect::>(), [7, 8]); + } + + #[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, plus the + // fresh id of each floating window. + let mut written = serde_json::to_value(&layout).unwrap(); + for (window, value) in layout.floating.iter().zip(written["floating"].as_array_mut().unwrap()) { + assert_eq!(value.as_object_mut().unwrap().remove("id"), Some(json!(window.id)), "{app}{pointer}"); + } + assert_eq!(written, 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); +} + +#[test] +fn a_sized_split_sets_the_new_split_and_the_enclosing_one_beside_the_anchor() { + // 1 | (2 | 3): 4 goes left of 3, and the split between 2 and 3 gives the room. + let layout = Layout { + root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), hsplit(SplitSize::FixedSecond(60.0), tabs(&[2], 0), tabs(&[3], 0)))), + floating: vec![Floating::single(vec![4], 0, [0.0, 0.0, 100.0, 100.0])], + }; + let sized = |zone| Placement::SplitSized { zone, size: SplitSize::FixedFirst(100.0), outer: SplitSize::FixedSecond(164.0) }; + let mut moved = layout.clone(); + moved.apply(Action::Move { panel: 4, anchor: 3, placement: sized(Zone::Left) }).unwrap(); + let inner = hsplit(SplitSize::FixedSecond(164.0), tabs(&[2], 0), hsplit(SplitSize::FixedFirst(100.0), tabs(&[4], 0), tabs(&[3], 0))); + assert_eq!(moved.root, Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), inner))); + // 1 has no divider on its left: the split on its right gives the room. + let mut moved = layout.clone(); + moved.apply(Action::Move { panel: 4, anchor: 1, placement: sized(Zone::Left) }).unwrap(); + let rest = hsplit(SplitSize::FixedSecond(60.0), tabs(&[2], 0), tabs(&[3], 0)); + assert_eq!(moved.root, Some(hsplit(SplitSize::FixedSecond(164.0), hsplit(SplitSize::FixedFirst(100.0), tabs(&[4], 0), tabs(&[1], 0)), rest))); + // 1 has no divider above or below it, and a center is no side. + for (anchor, zone) in [(1, Zone::Top), (3, Zone::Center)] { + let mut moved = layout.clone(); + assert_eq!(moved.apply(Action::Move { panel: 4, anchor, placement: sized(zone) }), Err(DockError::InvalidPlacement), "{anchor} {zone:?}"); + assert_eq!(moved, layout); + } +} diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs new file mode 100644 index 00000000..f872ddae --- /dev/null +++ b/crates/ui/src/docking/native_tests.rs @@ -0,0 +1,2767 @@ +//! 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, INACTIVE_PALETTE, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, drag_opacity, 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, + /// The protected canvas that takes splits beside it, as PhotoCraft's does. + canvas: Option, + /// 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 text field gives up keyboard focus on the next frame. + blur_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, + /// The panel with an app drag source, and where it was drawn. + source: Option, + /// How the source marks its drag as one that carries something. + marks: Marks, + source_rect: Option, + /// The source's drag so far, and how often its drag stopped. + source_delta: egui::Vec2, + source_stopped: usize, + /// How often the source's body saw a primary-button release in its input. + source_releases: usize, + /// The source asks egui to run its window's pass again, once, the next time it draws. + discard_next: bool, + /// The source asks for that many more passes, drawn or not, before the frame completes. + discard_passes: usize, + /// A drop site asks for its window's pass again when it reads a release. + discard_drop: bool, + /// Panels whose bodies are app drop sites: where each saw the pointer, and where a release + /// came with the pointer inside. + drop_sites: Vec, + hovers: Vec<(u32, Pos2)>, + drops: Vec<(u32, Pos2)>, + /// Drop sites whose release found egui's drag-and-drop payload. + payload_drops: Vec, + /// Key and clipboard events panel 1 saw in the main window, and the text field's panel in + /// its own window. + main_keys: Vec, + field_keys: Vec, +} + +/// How a test's app drag source marks its drag. +#[derive(Default)] +enum Marks { + /// It does not: a plain egui drag, such as a slider's. + #[default] + Nothing, + /// [`super::route_app_drag`] for this area, each frame while it drags. + Route(egui::Id), + /// An egui drag-and-drop payload, its panel. + Payload, +} + +impl DockContent for Content { + /// Test body that probes native input: records events/raw events, text focus and typing, button + /// focus, a click counter, drag source state, drop sites and payloads, file hover/drop, and + /// primary/secondary presses inside the body. + 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 std::mem::take(&mut self.blur_text) { + field.surrender_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); + } + if self.source == Some(*panel) { + let response = ui.allocate_response(vec2(120.0, 60.0), egui::Sense::drag()); + self.source_rect = Some(response.rect); + self.source_delta += response.drag_delta(); + self.source_stopped += usize::from(response.drag_stopped()); + self.source_releases += usize::from(ui.input(|i| i.pointer.primary_released())); + let discard = std::mem::take(&mut self.discard_next); + let more = self.discard_passes > 0; + if more { + self.discard_passes -= 1; + } + if discard || more { + ui.ctx().request_discard("the source's pass runs twice"); + } + match self.marks { + Marks::Route(area) if response.dragged() => super::route_app_drag(ui.ctx(), area), + Marks::Payload => response.dnd_set_drag_payload(*panel), + _ => {} + } + } + // As apps' drop sites do: the pointer over the body, and a release there. + if self.drop_sites.contains(panel) + && let Some(pos) = ui.ctx().pointer_hover_pos().filter(|p| ui.max_rect().contains(*p)) + { + self.hovers.push((*panel, pos)); + if ui.input(|i| i.pointer.any_released()) { + self.drops.push((*panel, pos)); + if egui::DragAndDrop::has_payload_of_type::(ui.ctx()) { + self.payload_drops.push(*panel); + } + if std::mem::take(&mut self.discard_drop) { + ui.ctx().request_discard("a drop site's pass runs twice"); + } + } + } + self.saw_files |= ui.input(|i| !i.raw.hovered_files.is_empty() || !i.raw.dropped_files.is_empty()); + let main = ui.ctx().viewport_id() == egui::ViewportId::ROOT; + if (main && *panel == 1) || (!main && self.text == Some(*panel)) { + let keys = ui.input(|i| { + i.events.iter().filter(|e| matches!(e, Event::Key { .. } | Event::Copy | Event::Cut | Event::Paste(_))).cloned().collect::>() + }); + (if main { &mut self.main_keys } else { &mut self.field_keys }).extend(keys); + } + 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 { + if self.canvas == Some(*panel) { + return Permissions { accepts_splits: true, ..Permissions::PROTECTED }; + } + 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")); +} + +/// The last `InnerSize`, `MinInnerSize` and `MaxInnerSize` sent to `viewport`. +fn size_commands(d: &DockDriver, viewport: egui::ViewportId) -> [Option; 3] { + let mut sizes = [None; 3]; + for (_, c) in d.host().commands.iter().filter(|(v, _)| *v == viewport) { + match c { + ViewportCommand::InnerSize(s) => sizes[0] = Some(*s), + ViewportCommand::MinInnerSize(s) => sizes[1] = Some(*s), + ViewportCommand::MaxInnerSize(s) => sizes[2] = Some(*s), + _ => {} + } + } + sizes +} + +#[test] +fn a_minimized_native_window_shrinks_to_its_bar_and_keeps_its_expanded_size() { + let mut d = driver(layout(true)); + let viewport = d.viewport(window(&d, 5).id); + let open = os_window(&d, 5).unwrap().0; + let (native, rect) = (window(&d, 5).native, window(&d, 5).rect); + assert_eq!(native, Some([open.left(), open.top(), 240.0, 300.0])); + + // Its bar and its group's tab strip, at the same width and top-left corner; only the width + // resizes. + let collapsed = BAR + super::DockStyle::default().tab_height; + d.layout.apply(Action::SetMinimized { panel: 5, minimized: true }).unwrap(); + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0, Rect::from_min_size(open.min, vec2(240.0, collapsed))); + let [_, min, max] = size_commands(&d, viewport); + assert_eq!((min.map(|s| s.y), max.map(|s| s.y)), (Some(collapsed), Some(collapsed))); + + // The layout keeps the expanded size after the window reports the collapsed one, past the + // wait for commanded moves, and takes a new width. + for _ in 0..40 { + d.step(vec![]); + } + assert_eq!((window(&d, 5).native, window(&d, 5).rect), (native, rect)); + d.host().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(open.min, vec2(300.0, collapsed)); + for _ in 0..40 { + d.step(vec![]); + } + assert_eq!(window(&d, 5).native, Some([open.left(), open.top(), 300.0, 300.0])); + assert_eq!(window(&d, 5).rect[2..], [300.0, 300.0]); + + // Expanded: the saved height comes back, and the height resizes again. + d.layout.apply(Action::SetMinimized { panel: 5, minimized: false }).unwrap(); + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0, Rect::from_min_size(open.min, vec2(300.0, 300.0))); + assert_eq!(size_commands(&d, viewport), [Some(vec2(300.0, 300.0)), Some(vec2(160.0, 100.0)), Some(egui::Vec2::INFINITY)]); + // The limits change before the size: the OS clamps a new size to the limits it has. + let commands: Vec<_> = d.host().commands.iter().filter(|(v, _)| *v == viewport).map(|(_, c)| c.clone()).collect(); + let last = |f: fn(&ViewportCommand) -> bool| commands.iter().rposition(f); + assert!(last(|c| matches!(c, ViewportCommand::MaxInnerSize(_))) < last(|c| matches!(c, ViewportCommand::InnerSize(_))), "{commands:?}"); + assert_eq!(window(&d, 5).native, Some([open.left(), open.top(), 300.0, 300.0])); +} + +#[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 palettes_stay_shown_at_the_normal_level_while_the_app_is_inactive() { + use egui::{ViewportId, WindowLevel}; + let mut d = driver(layout(true)); + let palette = d.viewport(window(&d, 5).id); + let focus = |d: &mut DockDriver, window: Option, frames: usize| { + d.host().focus = window; + for _ in 0..frames { + d.step(vec![]); + } + }; + let level = |d: &DockDriver| d.host().windows[&palette].level; + let told = |d: &DockDriver| d.host().commands.iter().any(|(v, c)| *v == palette && matches!(c, ViewportCommand::Visible(_))); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Focus moving to the palette, with a frame of none between, keeps it on top. + focus(&mut d, None, 1); + focus(&mut d, Some(palette), 30); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Another application: the palette drops behind it after the delay, and shows on, so + // files dragged from that application can reach it. + focus(&mut d, None, 10); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop), "not yet"); + focus(&mut d, None, 10); + assert_eq!(level(&d), Some(INACTIVE_PALETTE)); + // Back: on top again. + focus(&mut d, Some(ViewportId::ROOT), 1); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Blocked by a modal, it stays at the normal level, away and back. + d.input_blocked = true; + focus(&mut d, None, 20); + assert_eq!(level(&d), Some(WindowLevel::Normal)); + focus(&mut d, Some(ViewportId::ROOT), 5); + assert_eq!(level(&d), Some(WindowLevel::Normal)); + assert!(d.host().windows[&palette].visible); + assert!(!told(&d), "{:?}", d.host().commands); +} + +#[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: the palette drops behind it; the viewers stay at the normal level. + d.host().focus = None; + for _ in 0..20 { + d.step(vec![]); + } + assert_eq!( + [viewer, palette, mixed].map(|v| level(&d, v)), + [Some(egui::WindowLevel::Normal), Some(INACTIVE_PALETTE), Some(egui::WindowLevel::Normal)] + ); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + assert_eq!(level(&d, palette), Some(egui::WindowLevel::AlwaysOnTop)); + assert_eq!(level(&d, viewer), Some(egui::WindowLevel::Normal)); + assert!(!d.host().commands.iter().any(|(_, c)| matches!(c, ViewportCommand::Visible(_))), "no window hides"); +} + +#[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 goes back on top. + 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 its id too. + let in_window = Floating::single(vec![1u32], 0, [0.0, 0.0, 9.0, 9.0]).with_id(3); + assert!(serde_json::to_string(&in_window).unwrap().contains(r#""id":3"#)); +} + +/// 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], 40 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(760.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 over_a_canvas_body_a_window_stays_floating_and_a_tab_floats_while_the_edge_bands_dock() { + // PhotoCraft: a dock column of [2] above [3, 4] beside canvas 0, which takes splits, and + // window [5, 6] floating. + let accent = Color32::from_rgb(250, 10, 200); + 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, 4], 0)], 240.0)], + }; + let root = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(tabs(&[0])) }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![5, 6], 0, [300.0, 80.0, 240.0, 200.0])] }; + let start = || { + DockDriver::new(layout.clone(), Content { canvas: Some(0), ..Content::default() }) + .with_style(move |s| { + s.accent = accent; + s.float_hint = false; + }) + .with_native_host(ORIGIN) + }; + // The canvas spans x = 244 to 800 and the whole height: no tab strip. + for at in [pos2(520.0, 150.0), pos2(520.0, 450.0)] { + let mut d = start(); + let before = accented(d.shapes(), accent); + 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))]); + let local = d.window_local(id, ORIGIN + at.to_vec2()).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + d.step(vec![]); + assert_eq!(accented(d.shapes(), accent), before, "{at:?}: no preview"); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(window_move(&out), None, "{at:?}: {:?}", out.actions); + assert!(window(&d, 5).contains(&6), "{at:?}"); + } + // A docked tab dropped there floats. + let mut d = start(); + let tab = d.hit(&Hit::Tab(3)).unwrap().center(); + let out = d.drag(tab, pos2(520.0, 450.0)); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions); + // The window's right edge, where no dock is yet, and the bottom edge band beside the canvas + // still dock the window. + let mut d = start(); + let out = drag_bar_to(&mut d, 5, pos2(795.0, 300.0)); + assert_eq!(window_move(&out), Some(&Target::NewDock { side: super::Side::End }), "{:?}", out.actions); + let mut d = start(); + let out = drag_bar_to(&mut d, 5, pos2(520.0, 590.0)); + assert!(matches!(window_move(&out), Some(Target::At { placement: Placement::Edge(super::Zone::Bottom), .. })), "{:?}", out.actions); +} + +#[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, here to the middle of frame [1, 2]. + 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(-110.0, 150.0)); + assert_eq!((moved, resize), (rect(290.0, 280.0, 530.0, 580.0), None)); +} + +#[test] +/// Resize bands avoid the close button, are at most 6 pt deep, use 16 pt +/// corners along each edge, and leave the bar below the top band inert. +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] +/// On macOS the close glyph sits 8 pt from the trailing corner in both directions; its hit area +/// extends 10 pt away from the corner, reaches the bar bottom, leaves the drag area clear, and stays +/// reachable outside AppKit's corner resize region. +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] +/// While input is blocked, a native window is dimmed, loses always-on-top, +/// ignores clicks, drags, key presses and close requests, but still forwards key +/// releases; unblocking restores normal input and activation. +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] +/// A blocked native window rejects typing but preserves native control-event order: IME reset +/// completion and focus loss reach both event and raw-event readers, while a shortcut release is +/// still forwarded to the host. +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![]); + } + }; + // Away and back, it stays shown at the normal level: showing it or putting it on top could + // raise it over the modal, and showing it would, 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_eq!(host.windows[&palette].level, Some(WindowLevel::Normal)); + 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); +} + +/// A menu's key equivalent, as an app's menu glue reports it in the main window's input. +fn menu_key(key: Key) -> Vec { + let press = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }; + vec![press(true), press(false)] +} + +#[test] +fn menu_keys_go_to_the_focused_text_field_of_the_native_window_with_the_keyboard() { + let has = |events: &[Event], key: Key| events.iter().any(|e| matches!(e, Event::Key { key: k, .. } if *k == key)); + let pasted = |d: &DockDriver, viewport| d.host().commands.contains(&(viewport, ViewportCommand::RequestPaste)); + let mut d = driver(layout(true)); + let viewport = d.viewport(id_of(&d)); + (d.content.text, d.content.focus_text) = (Some(5), true); + d.host().focus = Some(viewport); + d.settle(); + assert_eq!(super::typing_window(&d.ctx), Some(viewport)); + // Between frames, where an app's `raw_input_hook` asks, the answer is the last frame's. + d.content.blur_text = true; + d.step(vec![]); + assert_eq!(super::typing_window(&d.ctx), None, "right after the field loses focus"); + d.content.focus_text = true; + d.step(vec![]); + assert_eq!(super::typing_window(&d.ctx), Some(viewport), "right after the field takes focus"); + d.step(menu_key(Key::V)); + assert!(!has(&d.content.main_keys, Key::V), "{:?}", d.content.main_keys); + assert!(pasted(&d, viewport), "the field's window asks for the clipboard text"); + d.step(menu_key(Key::C)); + assert!(!has(&d.content.main_keys, Key::C)); + assert!(d.content.field_keys.contains(&Event::Copy), "{:?}", d.content.field_keys); + // Saving is the app's, from any window. + d.step(menu_key(Key::S)); + assert!(has(&d.content.main_keys, Key::S)); + assert!(!has(&d.content.field_keys, Key::S)); + + // Under a block the window's field is inert: the main window keeps every key. + d.input_blocked = true; + d.settle(); + assert_eq!(super::typing_window(&d.ctx), None); + d.host().commands.clear(); + d.step(menu_key(Key::V)); + assert!(has(&d.content.main_keys, Key::V)); + assert!(!pasted(&d, viewport)); + + // With no focused field, nothing moves. + let mut d = driver(layout(true)); + let viewport = d.viewport(id_of(&d)); + d.content.text = Some(5); + d.host().focus = Some(viewport); + d.settle(); + d.step(menu_key(Key::V)); + assert!(has(&d.content.main_keys, Key::V)); + assert!(d.content.field_keys.is_empty(), "{:?}", d.content.field_keys); + assert!(!pasted(&d, viewport)); +} + +#[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_hands_focus_from_a_native_window_to_the_main_window() { + let focus_main = |d: &DockDriver| { + d.host().commands.iter().filter(|(v, c)| *v == egui::ViewportId::ROOT && matches!(c, ViewportCommand::Focus)).count() + }; + let steps = |d: &mut DockDriver, n: usize| { + for _ in 0..n { + d.step(vec![]); + } + }; + let focused_float = || { + let mut d = blocked_driver(); + let viewport = d.viewport(id_of(&d)); + d.host().focus = Some(viewport); + d.step(vec![]); + d.input_blocked = true; + d + }; + // A dialog opened from a floating panel needs the keys there, in the main window: once, + // after a short wait. + let mut d = focused_float(); + steps(&mut d, 2); + assert_eq!(focus_main(&d), 0, "not yet: an OS dialog may still take focus"); + steps(&mut d, 30); + assert_eq!(focus_main(&d), 1); + // An OS dialog took focus meanwhile: no window of the area has it, so nothing is sent. + let mut d = focused_float(); + d.step(vec![]); + d.host().focus = None; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 0); + // Blocking ended before the wait was over. + let mut d = focused_float(); + d.step(vec![]); + d.input_blocked = false; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 0); + // With the main window focused already, nothing is sent. + let mut d = blocked_driver(); + d.input_blocked = true; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 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); +} + +/// A driver whose panel `source` has an app drag source and whose panels `sites` are drop sites. +fn app_drag(layout: Layout, source: u32, sites: &[u32]) -> DockDriver { + let mut d = driver(layout); + (d.content.source, d.content.drop_sites, d.content.marks) = (Some(source), sites.to_vec(), Marks::Route(d.id)); + d.settle(); + d +} + +#[allow(clippy::expect_used)] +fn source(d: &DockDriver) -> Pos2 { + d.content.source_rect.expect("source drawn").center() +} + +#[test] +fn an_app_drag_from_the_main_window_reaches_a_native_windows_panel() { + // Panel 3's frame lies under window [5, 6], which takes the drop. + let mut d = app_drag(layout(true), 1, &[3, 5]); + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + assert!(d.content.hovers.contains(&(5, local)), "{:?}", d.content.hovers); + d.release(point); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)]); + assert!(!d.content.hovers.iter().any(|(panel, _)| *panel == 3), "{:?}", d.content.hovers); +} + +#[test] +fn a_stop_withdraws_a_routed_release_no_window_read_but_not_the_owners() { + let mut d = app_drag(layout(true), 5, &[1]); + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + // The release is queued for the main window's next pass, and the drag is cancelled first. + d.window_release(id, local); + super::cancel_drag::(&d.ctx, d.id); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![]); + // The owner still gets its own release. + assert_eq!(d.content.source_stopped, 1); +} + +#[test] +fn a_plain_egui_drag_over_a_native_window_keeps_its_moves_and_is_not_routed() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Nothing; + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + let from = source(&d); + d.hold(from, over); + d.move_to(over + vec2(20.0, 10.0)); + let moved = over + vec2(20.0, 10.0) - from; + assert!((d.content.source_delta - moved).length() < 0.01, "{:?}, not {moved:?}", d.content.source_delta); + d.release(over); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); + assert_eq!(d.content.source_stopped, 1); +} + +#[test] +fn an_egui_payload_reaches_a_window_drawn_before_the_owner() { + // The main window draws before the native window that owns the drag. + let mut d = app_drag(layout(true), 5, &[1]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.payload_drops, vec![1]); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); +} + +#[test] +fn an_owner_that_discards_its_release_pass_keeps_the_payload_for_the_target() { + let mut d = app_drag(layout(true), 5, &[1]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + // The owner's pass that holds its release runs twice. + d.content.discard_next = true; + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.payload_drops, vec![1]); +} + +#[test] +fn pointer_events_after_a_held_release_wait_behind_it() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let point = d.host().windows[&d.viewport(id)].rect.min + vec2(120.0, 200.0) - ORIGIN.to_vec2(); + d.hold(source(&d), point); + let delta = d.content.source_delta; + // The release over the native window, then the pointer back on the source and a new press, + // in one input. + let at = source(&d); + d.step(vec![button(point, false), Event::PointerMoved(at), button(at, true)]); + assert_eq!(d.content.source_delta, delta, "no move after the release reaches the source's drag"); + d.step(vec![]); + assert!(d.ctx.input(|i| i.pointer.primary_down()), "the new press outlives the held release"); +} + +#[test] +fn the_owners_drag_stops_a_pass_after_its_release_over_another_window() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + assert_eq!((d.content.drops.clone(), d.content.source_stopped), (vec![(5, local)], 0)); + // The owner saw the pointer go, and holds its release and the pointer's going once. + assert_eq!(d.ctx.input(|i| i.pointer.hover_pos()), None); + let held = super::render::held_events(&d.ctx, egui::ViewportId::ROOT); + assert!(matches!(held.as_slice(), [Event::PointerButton { pressed: false, .. }, Event::PointerGone]), "{held:?}"); + d.step(vec![]); + assert_eq!(d.content.source_stopped, 1); + assert_eq!(d.ctx.dragged_id(), None); +} + +#[test] +fn a_press_right_after_a_routed_release_outlives_the_held_release() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let point = d.host().windows[&d.viewport(id)].rect.min + vec2(120.0, 200.0) - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + // The owner's held release comes before a press in its next input. + let at = source(&d); + d.step(vec![Event::PointerMoved(at), button(at, true)]); + assert!(d.ctx.input(|i| i.pointer.primary_down())); +} + +#[test] +fn a_routed_release_clicks_nothing_in_the_target() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.counter = Some(5); + d.step(vec![]); + let id = window(&d, 5).id; + let button = d.content.counter_rect.unwrap().center(); + let point = d.host().windows[&d.viewport(id)].rect.min + button.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, button)]); + assert_eq!(d.content.count, 0); +} + +#[test] +fn an_app_drag_from_a_native_window_reaches_the_main_window() { + let mut d = app_drag(layout(true), 5, &[1]); + let id = window(&d, 5).id; + // Frame [1, 2], left of the native window on the desktop. + let point = pos2(100.0, 300.0); + let local = d.window_local(id, ORIGIN + point.to_vec2()).unwrap(); + d.window_hold(id, source(&d), local); + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(1, point)]); + assert!(d.content.hovers.contains(&(1, point)), "{:?}", d.content.hovers); +} + +#[test] +fn an_app_drag_between_native_windows_drops_in_the_front_one() { + // Window [7] overlaps window [5, 6] at (550, 280) on the desktop; window [8] is apart. + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [350.0, 150.0, 240.0, 150.0])); + layout.floating.push(Floating::single(vec![8], 0, [20.0, 420.0, 240.0, 150.0])); + let overlap = pos2(550.0, 280.0); + for front in [7, 5] { + let mut d = app_drag(layout.clone(), 8, &[5, 7]); + 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 (owner, target) = (window(&d, 8).id, window(&d, front).id); + let local = d.window_local(owner, overlap).unwrap(); + d.window_hold(owner, source(&d), local); + d.window_release(owner, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(front, d.window_local(target, overlap).unwrap())], "front {front}"); + } +} + +#[test] +fn an_app_drag_released_outside_every_window_drops_nowhere() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(&d), over); + assert!(d.content.hovers.iter().any(|(panel, _)| *panel == 5), "{:?}", d.content.hovers); + // Left of the main window, over no window of the app: the native window sees the pointer go. + let out = pos2(-60.0, 120.0); + d.move_to(out); + d.content.hovers.clear(); + d.move_to(out); + d.release(out); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +/// Hold an app drag from panel 1 over window [5, 6] and release it there. +fn drag_onto_window(d: &mut DockDriver) { + let id = window(d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(d), over); + d.release(over); + d.step(vec![]); + d.step(vec![]); +} + +#[test] +fn no_app_drag_is_routed_where_windows_report_no_positions() { + let mut d = app_drag(layout(true), 1, &[5]); + d.host().no_position = true; + d.step(vec![]); + drag_onto_window(&mut d); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +#[test] +fn a_blocked_native_window_gets_no_app_drag() { + let mut d = app_drag(layout(true), 1, &[5]); + d.input_blocked = true; + d.settle(); + drag_onto_window(&mut d); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +#[test] +fn escape_and_cancel_drag_end_the_routing_of_an_app_drag() { + for cancel in [false, true] { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(&d), over); + assert!(!d.content.hovers.is_empty(), "cancel {cancel}"); + if cancel { + super::cancel_drag::(&d.ctx, d.id); + d.step(vec![]); + } else { + d.key(Key::Escape); + } + d.content.hovers.clear(); + d.move_to(over); + d.release(over); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "cancel {cancel}: {:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "cancel {cancel}: {:?}", d.content.drops); + } +} + +#[test] +/// Dragging a tab onto a native window's body emits a tab `Move` rather than +/// an app body drag and leaves the content's drops empty. +fn a_tab_drag_onto_a_native_window_is_no_app_drag() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = d.drag(tab, over); + d.step(vec![]); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 5 | 6, .. }]), "{:?}", out.actions); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +/// `cancel_drag` from an app's per-area native input hook (`DockArea::native_input_hook`) +/// runs while egui holds the `NativeInput` plugin lock. It must not try to lock that plugin +/// again: in a debug build the original deadlocks, and egui's debug mutex panics after its +/// 10 s deadlock bound. +#[test] +fn cancel_drag_from_a_native_input_hook_returns() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + let cancel = Arc::new(AtomicBool::new(false)); + let flag = cancel.clone(); + let mut d = driver(layout(true)).with_native_input_hook(move |ctx, _input| { + if flag.swap(false, Ordering::SeqCst) { + // `driver` uses this area id. + super::cancel_drag::(ctx, egui::Id::unique("dock-driver")); + } + }); + let id = id_of(&d); + d.settle(); + + // The hook runs for the native window's input, inside `NativeInput::input_hook`. + cancel.store(true, Ordering::SeqCst); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 150.0))]); + assert!(!cancel.load(Ordering::SeqCst), "the hook did not run"); +} + +/// `send_keys` from the per-area native input hook, inside `Plugin::input_hook`, returns +/// without locking the plugin again, and the keys reach that window's next input. +#[test] +fn send_keys_from_a_native_input_hook_returns_and_delivers() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + let send = Arc::new(AtomicBool::new(false)); + let flag = send.clone(); + let mut d = driver(layout(true)).with_native_input_hook(move |ctx, input| { + if flag.swap(false, Ordering::SeqCst) { + super::send_keys(ctx, input.viewport_id, vec![Event::Copy]); + } + }); + let id = id_of(&d); + d.content.text = Some(5); + d.settle(); + send.store(true, Ordering::SeqCst); + d.window_step(id, vec![]); + assert!(!send.load(Ordering::SeqCst), "the hook did not run"); + d.step(vec![]); + d.window_step(id, vec![]); + assert!(d.content.field_keys.contains(&Event::Copy), "{:?}", d.content.field_keys); +} + +/// An app drag with a release queued for the main window: `cancel_drag` from the per-area +/// native input hook (inside `Plugin::input_hook`, with egui holding the `NativeInput` plugin +/// lock) returns without deadlocking, withdraws that release, and leaves the owner's own +/// release. +#[test] +fn cancel_drag_from_a_native_input_hook_withdraws_a_queued_release() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + let cancel = Arc::new(AtomicBool::new(false)); + let flag = cancel.clone(); + let mut d = app_drag(layout(true), 5, &[1]).with_native_input_hook(move |ctx, _input| { + if flag.swap(false, Ordering::SeqCst) { + // `driver` uses this area id. + super::cancel_drag::(ctx, egui::Id::unique("dock-driver")); + } + }); + let id = window(&d, 5).id; + // The release is over the main window: it is queued for the main window's next pass. + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + cancel.store(true, Ordering::SeqCst); + d.window_release(id, local); + assert!(!cancel.load(Ordering::SeqCst), "the hook did not run"); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); + assert_eq!(d.content.source_stopped, 0, "the owner's own release is still held"); + d.step(vec![]); + d.step(vec![]); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); + assert_eq!(d.content.source_stopped, 1, "the owner still gets its own release"); + assert!(super::render::held_events(&d.ctx, egui::ViewportId::ROOT).is_empty()); + assert!(super::render::held_events(&d.ctx, d.viewport(id)).is_empty()); +} + +/// A main-window owner that asks for its release pass to be discarded still gets its release +/// exactly once, and the native target reads the payload exactly once. +#[test] +fn a_root_owner_that_discards_its_release_pass_drops_the_payload_once() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + + d.content.discard_next = true; + d.release(point); + assert_eq!(d.content.drops, vec![(5, local)]); + assert_eq!(d.content.payload_drops, vec![5]); + // The owner's release may arrive in the same frame's rerun. + assert_eq!(d.content.source_stopped, 1); + assert_eq!(d.content.source_releases, 1, "the owner receives its release once"); + + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)], "the target drops once"); + assert_eq!(d.content.payload_drops, vec![5], "the payload drops once"); + assert_eq!(d.content.source_stopped, 1, "the owner gets its release once"); + assert_eq!(d.content.source_releases, 1, "the owner receives its release once"); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); + assert!(super::render::held_events(&d.ctx, egui::ViewportId::ROOT).is_empty()); +} + +/// A native target that asks for its release pass to be discarded reads the release and the +/// payload once; its rerun has no events, and the owner's held release follows a pass later. +#[test] +fn a_target_that_discards_its_release_pass_drops_the_payload_once() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Payload; + d.content.discard_drop = true; + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + + d.release(point); + assert_eq!(d.content.drops, vec![(5, local)]); + assert_eq!(d.content.payload_drops, vec![5]); + assert_eq!(d.content.source_stopped, 0, "the owner's release is held for a later pass"); + assert_eq!(d.content.source_releases, 0, "the owner's release is held for a later pass"); + + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)], "the target drops once"); + assert_eq!(d.content.payload_drops, vec![5], "the payload drops once"); + assert_eq!(d.content.source_stopped, 1, "the owner gets its release once"); + assert_eq!(d.content.source_releases, 1, "the owner receives its release once"); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); + assert!(super::render::held_events(&d.ctx, egui::ViewportId::ROOT).is_empty()); +} + +/// Several discarded passes in one frame neither duplicate nor lose the routed release: the +/// target's drop and payload read, and the owner's release, happen exactly once. +#[test] +fn repeated_discards_deliver_the_release_and_drop_once() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + + d.ctx.memory_mut(|memory| memory.options.max_passes = std::num::NonZeroUsize::new(4).unwrap()); + d.content.discard_passes = 3; + d.release(point); + assert_eq!(d.content.drops, vec![(5, local)], "the target drops once"); + assert_eq!(d.content.payload_drops, vec![5], "the payload drops once"); + assert_eq!(d.content.source_stopped, 1, "the owner gets its release once"); + assert_eq!(d.content.source_releases, 1, "the owner receives its release once"); + + // The frame ran its fourth, completing pass here; the release and payload are gone. + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)]); + assert_eq!(d.content.payload_drops, vec![5]); + assert_eq!(d.content.source_stopped, 1); + assert_eq!(d.content.source_releases, 1); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); + assert!(super::render::held_events(&d.ctx, egui::ViewportId::ROOT).is_empty()); +} + +#[test] +fn a_native_window_fades_only_while_its_bar_drag_highlights_a_drop_target() { + let bar_to = |d: &mut DockDriver, to: Pos2| { + let id = window(d, 5).id; + let local = d.window_local(id, ORIGIN + to.to_vec2()).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + drag_opacity(&d.ctx, d.id) + }; + let grab = |d: &mut DockDriver| { + 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))]); + id + }; + // A bar that only moves its window highlights nothing. + let mut d = driver(layout(true)).with_style(|s| { + s.command_press_floats = true; + s.title_bar_docks = false; + }); + grab(&mut d); + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 1.0, "a move that cannot dock"); + + let mut d = driver(layout(true)); + let id = grab(&mut d); + // Past the main window's right side: the desktop. + assert_eq!(bar_to(&mut d, pos2(1000.0, 300.0)), 1.0, "over empty space"); + // The right edge zone of frame [3, 4]. + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 0.75, "over a drop target"); + // The middle of the frame is no target for a whole window. + assert_eq!(bar_to(&mut d, pos2(600.0, 300.0)), 1.0, "after leaving the target"); + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 0.75); + // With Cmd/Ctrl held the drop floats and nothing is highlighted. + d.modifiers = Modifiers::COMMAND; + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 1.0, "a floating drop"); + d.modifiers = Modifiers::NONE; + assert_eq!(bar_to(&mut d, pos2(760.0, 300.0)), 0.75); + d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(drag_opacity(&d.ctx, d.id), 1.0, "after the drop"); +} + +/// A driver whose window system moves a native window for a bar drag, as AppKit does. +fn os_driver(layout: Layout) -> DockDriver { + let d = driver(layout); + d.host().moves_windows = true; + d +} + +/// Where window [5, 6]'s bar is pressed, in its coordinates. +const GRAB: Pos2 = pos2(40.0, 9.0); + +/// Press window [5, 6]'s bar. +fn os_press(d: &mut DockDriver) -> u64 { + let id = id_of(d); + d.window_step(id, vec![Event::PointerMoved(GRAB), button(GRAB, true)]); + id +} + +fn bar_dragging(d: &DockDriver) -> bool { + d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))).is_some() +} + +#[test] +fn the_window_system_moves_a_native_window_for_its_bar_drag() { + let mut d = os_driver(layout(true)); + let id = id_of(&d); + let viewport = d.viewport(id); + let before = moves(&d, viewport).len(); + let sent = d.host().commands.len(); + os_press(&mut d); + assert!(d.host().commands[sent..].iter().any(|(v, c)| *v == viewport && *c == ViewportCommand::StartDrag), "on the press"); + // Over the right edge zone of frame [3, 4]. The window reports its place where the drag + // began, so the target is found from the cursor. + let target = ORIGIN + vec2(760.0, 300.0); + os_bar_drag_on(&mut d, id, target); + assert_eq!(d.host().windows[&viewport].rect.min, target - GRAB.to_vec2()); + assert_eq!(drag_opacity(&d.ctx, d.id), 0.75, "over a drop target"); + // Escape does not cancel it. + d.window_step(id, vec![escape()]); + assert!(bar_dragging(&d)); + assert_eq!(d.host().windows[&viewport].rect.min, target - GRAB.to_vec2()); + // Released on the desktop right of the main window, it stays where the cursor left it. + let desktop = ORIGIN + vec2(1000.0, 300.0); + d.window_pointer(id, desktop); + assert_eq!(drag_opacity(&d.ctx, d.id), 1.0, "over empty space"); + let out = d.window_release(id, GRAB); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + d.settle(); + let to = desktop - GRAB.to_vec2(); + assert_eq!(window(&d, 5).native, Some([to.x, to.y, 240.0, 300.0])); + assert_eq!(moves(&d, viewport).len(), before, "the app moved the window"); +} + +/// Move the cursor of the bar drag of window `id` to the desktop point `to` in two steps. +fn os_bar_drag_on(d: &mut DockDriver, id: u64, to: Pos2) { + let from = d.host().windows[&d.viewport(id)].rect.min + GRAB.to_vec2(); + d.window_pointer(id, from.lerp(to, 0.5)); + d.window_pointer(id, to); +} + +#[test] +fn a_bar_drag_the_window_system_moves_ends_once_on_the_first_release_reported() { + // The window system reports the release a frame before egui: the window docks then. + let mut d = os_driver(layout(true)); + let id = os_press(&mut d); + os_bar_drag_on(&mut d, id, ORIGIN + vec2(760.0, 300.0)); + d.host().held = false; + let out = d.step(vec![]); + assert!(matches!(window_move(&out), Some(Target::At { anchor: 3 | 4, placement: Placement::Split(super::Zone::Right) })), "{:?}", out.actions); + assert!(in_root(&d, 5) && in_root(&d, 6)); + + // With Cmd held it only moves, and egui's release without Cmd a frame later docks nothing. + let mut d = os_driver(layout(true)); + let id = os_press(&mut d); + let viewport = d.viewport(id); + d.modifiers = Modifiers::COMMAND; + let target = ORIGIN + vec2(760.0, 300.0); + os_bar_drag_on(&mut d, id, target); + assert_eq!(drag_opacity(&d.ctx, d.id), 1.0, "a floating drop"); + d.host().held = false; + let out = d.step(vec![]); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + assert!(!bar_dragging(&d)); + d.modifiers = Modifiers::NONE; + let sent = d.host().commands.len(); + let out = d.window_release(id, GRAB); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + assert!(!bar_dragging(&d)); + assert!( + !d.host().commands[sent..].iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::StartDrag | ViewportCommand::OuterPosition(_))) + ); + d.settle(); + let to = target - GRAB.to_vec2(); + assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([to.x, to.y])); + + // A click on the bar over the main window docks and moves nothing. + let mut d = os_driver(layout(true)); + let native = window(&d, 5).native; + let id = os_press(&mut d); + let out = d.window_release(id, GRAB); + assert!(out.actions.is_empty(), "{:?}", out.actions); + d.settle(); + assert_eq!(window(&d, 5).native, native); +} 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/body_drag.rs b/crates/ui/src/docking/render/body_drag.rs new file mode 100644 index 00000000..0d5d5b4a --- /dev/null +++ b/crates/ui/src/docking/render/body_drag.rs @@ -0,0 +1,290 @@ +//! App drags in panel bodies across OS windows (feature `native-windows`). +//! +//! egui gives each window only its own input, and the window where the button went down gets +//! the pointer until the release. An app's drag that began in a panel body (a media item onto +//! a timeline in another window) would never show its pointer or release to the window it is +//! over. So while one that carries something lasts (an egui drag-and-drop payload, or +//! [`crate::docking::route_app_drag`]), the owner's input hook ([`super::native`]'s plugin) +//! sends each pointer move over another of the app's windows to that window, in its +//! coordinates, as `PointerMoved`, `PointerGone` when the pointer leaves it, and the release as +//! a primary `PointerButton` there. The owner sees the pointer gone while it is over another +//! window, so its own panels under that window see no hover and no drop. +//! +//! A window drawn after the owner in the same pass gets the events in that pass: the main +//! window owning the drag reaches every native window at once. The main window, and a native +//! window drawn before the owner, get them on the next pass, which the hook asks for. The +//! owner's own release then waits a pass too, so its payload outlives the target's read. + +use std::{collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, Event, Id, Key, PointerButton, Pos2, RawInput, Rect, ViewportId}; + +use super::Renderer; +use super::native::{WindowDrag, body_input, content_of}; +use crate::docking::desktop::{Over, window_at}; +use crate::docking::{Drag, GroupDrag}; + +/// The application's windows on the desktop, as the main window last mapped them. +#[derive(Clone, Debug, Default)] +pub(super) struct Desktop { + /// The main window's content, where known. + pub main: Option, + /// Shown native windows' content, front first, and how many lie in front of the main window. + pub windows: Vec<(u64, Rect)>, + pub front: usize, + /// The viewport of each native window. + pub viewports: Vec<(u64, ViewportId)>, +} + +impl Desktop { + /// The window under the desktop point `point`, and the point in its coordinates. + fn at(&self, point: Pos2) -> Option<(ViewportId, Pos2)> { + match window_at(point, &self.windows, self.front, Some(self.main?), None) { + Over::Main(local) => Some((ViewportId::ROOT, local)), + Over::Window(window, local) => self.viewports.iter().find(|(w, _)| *w == window).map(|(_, viewport)| (*viewport, local)), + Over::Desktop => None, + } + } +} + +/// A dock area's app drag in a panel body, in the input plugin. +#[derive(Debug)] +pub(super) struct BodyRoute { + area: Id, + /// The window the drag began in, and the widget egui drags there. + owner: ViewportId, + /// [`crate::docking::Floating::id`] of the owner; `None` for the main window. + window: Option, + id: Id, + desktop: Desktop, + /// The window other than the owner that the pointer is over. + target: Option, + /// Nothing more is routed until the drag ends: released, or ended by Escape, a focus loss, + /// input blocking or [`crate::docking::cancel_drag`]. + ended: bool, +} + +/// Events waiting for each viewport's next input, with the main window's pass that queued them +/// and the dock area whose route queued each. +pub(super) type Queued = HashMap)>; + +/// Each owner's held release and the pointer events after it, with the main window's pass that +/// held them: delivered in a later pass only, never taken back. +pub(super) type Held = HashMap)>; + +fn queue(queued: &mut Queued, pass: u64, area: Id, viewport: ViewportId, event: Event) { + let (at, events) = queued.entry(viewport).or_default(); + *at = pass; + events.push((area, event)); +} + +impl BodyRoute { + /// The owner's input before egui reads it: each pointer event over another of the app's + /// windows goes to that window, and the owner sees the pointer gone. Returns whether + /// anything was queued. Where the owner reports no desktop position nothing is routed. + pub(super) fn route(&mut self, input: &mut RawInput, queued: &mut Queued, held: &mut Held, pass: u64) -> bool { + if input.viewport_id != self.owner || self.ended { + return false; + } + let Some((origin, _)) = input.viewports.get(&input.viewport_id).and_then(content_of) else { return false }; + let (mut routed, mut holding) = (false, false); + let mut events = Vec::with_capacity(input.events.len()); + for event in std::mem::take(&mut input.events) { + // Pointer events after a held release wait behind it, in order. + if holding && matches!(event, Event::PointerMoved(_) | Event::PointerButton { .. } | Event::PointerGone) { + held.entry(self.owner).or_default().1.push(event); + continue; + } + let (pos, release) = match &event { + Event::PointerMoved(pos) => (*pos, None), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers } => (*pos, Some(*modifiers)), + _ => { + events.push(event); + continue; + } + }; + if self.ended { + events.push(event); + continue; + } + let over = pos.is_finite().then(|| self.desktop.at(origin + pos.to_vec2())).flatten().filter(|(v, _)| *v != self.owner); + let target = over.map(|(viewport, _)| viewport); + if let Some(left) = self.target.filter(|left| Some(*left) != target) { + queue(queued, pass, self.area, left, Event::PointerGone); + routed = true; + } + self.target = target; + if let Some((viewport, local)) = over { + queue(queued, pass, self.area, viewport, Event::PointerMoved(local)); + if let Some(modifiers) = release { + let release = Event::PointerButton { pos: local, button: PointerButton::Primary, pressed: false, modifiers }; + queue(queued, pass, self.area, viewport, release); + // The owner's own release waits for its input in a later pass of the main + // window (not a discarded pass's rerun), so its drag, and an egui + // drag-and-drop payload, last until a window drawn before it has read the + // release. A stop never takes it back, or the button would stay down. + let (at, held_events) = held.entry(self.owner).or_default(); + *at = pass; + held_events.extend([event, Event::PointerGone]); + holding = true; + routed = true; + (self.ended, self.target) = (true, None); + events.push(Event::PointerGone); + continue; + } + routed = true; + events.push(Event::PointerGone); + } else { + events.push(event); + } + if release.is_some() { + (self.ended, self.target) = (true, None); + } + } + input.events = events; + routed + } +} + +/// End `area`'s routing: the window the pointer was over sees it go. `remove`: the drag is +/// over, so another may start. Otherwise the routing was stopped (Escape, a focus loss, input +/// blocking, [`crate::docking::cancel_drag`]), and events it queued that no window has read +/// yet are taken back, so a release queued before the stop reaches no window. +fn end_route(ctx: &Context, area: Id, remove: bool) { + let body = body_input(ctx); + let queued = { + let mut input = body.lock(); + let input = &mut *input; + if let Some(route) = input.routes.get_mut(&area) { + let mut gone: Vec = route.target.take().into_iter().collect(); + route.ended = true; + if remove { + input.routes.remove(&area); + } else { + for (viewport, (_, events)) in &mut input.queued { + let before = events.len(); + events.retain(|(from, _)| *from != area); + if events.len() != before && !gone.contains(viewport) { + gone.push(*viewport); + } + } + } + for viewport in &gone { + queue(&mut input.queued, input.pass, area, *viewport, Event::PointerGone); + } + Some(!gone.is_empty()) + } else { + None + } + }; + if queued == Some(true) { + ctx.request_repaint_of(ViewportId::ROOT); + } +} + +/// [`crate::docking::cancel_drag`]: an app drag in a panel body goes on, but no longer reaches +/// other windows. +pub(in crate::docking) fn cancel_body_route(ctx: &Context, area: Id) { + end_route(ctx, area, false); +} + +impl Renderer<'_, P> { + /// In the main window, once the desktop is mapped and before native windows draw: give the + /// routing the map, and end it where it cannot go on. `native`: viewports are native. + pub(super) fn map_body_route(&mut self, ctx: &Context, native: bool) { + let desktop = self.desktop(); + let owner = { + let body = body_input(ctx); + let mut input = body.lock(); + input.routes.get_mut(&self.id).map(|route| { + route.desktop = desktop; + route.window + }) + }; + // Windows are no longer native, or the owner closed. + let natives = &self.cross.viewports; + if owner.is_some_and(|window| !native || window.is_some_and(|w| !natives.iter().any(|(id, _)| *id == w))) { + end_route(ctx, self.id, true); + } + if native { + self.route_body_drag(ctx, None); + } + } + + fn desktop(&self) -> Desktop { + Desktop { main: self.cross.main, windows: self.cross.windows.clone(), front: self.cross.front, viewports: self.cross.viewports.clone() } + } + + /// After the window drawing now (`window`; `None` for the main window) drew its panels' + /// bodies: start routing an app drag that began in one of them, and end it when the drag + /// is over. An Escape in any of the app's windows, a focus loss and input blocking end it. + pub(super) fn route_body_drag(&mut self, ctx: &Context, window: Option) { + let (area, viewport, dragged) = (self.id, ctx.viewport_id(), ctx.dragged_id()); + let route = { + let body = body_input(ctx); + let input = body.lock(); + input.routes.get(&area).map(|r| (r.owner, r.id)) + }; + let stops = self.input_blocked || self.cross.focus_lost || ctx.input(|i| i.key_pressed(Key::Escape)); + match route { + Some((owner, id)) if owner == viewport && dragged != Some(id) => end_route(ctx, area, true), + Some(_) if stops => end_route(ctx, area, false), + Some(_) => {} + None => { + let Some(id) = dragged.filter(|id| !stops && self.starts_in_body(ctx, *id)) else { return }; + let route = BodyRoute { area, owner: viewport, window, id, desktop: self.desktop(), target: None, ended: false }; + let _ = body_input(ctx).lock().routes.insert(area, route); + } + } + } + + /// Whether egui's drag of `id` in the window drawing now is the app's and carries something: + /// pressed with the primary button in a panel body, none of the area's own controls or + /// drags, and with an egui drag-and-drop payload or [`crate::docking::route_app_drag`] + /// called this frame or the last. Other drags (sliders, scrollbars, text selection, canvas + /// pans) keep the owner's pointer. + fn starts_in_body(&self, ctx: &Context, id: Id) -> bool { + let payload = ctx.plugin_opt::().is_some() && egui::DragAndDrop::has_any_payload(ctx); + let main = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + let asked = ctx.data(|d| d.get_temp::(app_drag_key(self.id))).is_some_and(|at| at + 1 >= main); + if !payload && !asked { + return false; + } + let (area, viewport) = (self.id, ctx.viewport_id()); + let (down, press) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.press_origin())); + let (pressed, owned, dock) = ctx.data(|d| { + let pressed = d.get_temp::(body_press_key(area, viewport)); + let owned = d.get_temp::>(area.with("owned-interactions")).is_some_and(|ids| ids.contains(&id)) + || d.get_temp::(area.with("pointer-resize")) == Some(id); + let dock = d.get_temp::>(area.with("drag")).is_some() + || d.get_temp::>(area.with("group-drag")).is_some() + || d.get_temp::(area.with("window-drag")).is_some(); + (pressed, owned, dock) + }); + down && press.is_some() && pressed == press && !owned && !dock && !self.owned_interactions.contains(&id) + } +} + +/// The main window's pass in which the app last called [`crate::docking::route_app_drag`]. +fn app_drag_key(area: Id) -> Id { + area.with("route-app-drag") +} + +/// [`crate::docking::route_app_drag`]. +pub(in crate::docking) fn mark_app_drag(ctx: &Context, area: Id) { + let main = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + ctx.data_mut(|d| d.insert_temp(app_drag_key(area), main)); +} + +/// The events held for `viewport`'s input in a later pass. +#[cfg(test)] +pub(in crate::docking) fn held_events(ctx: &Context, viewport: ViewportId) -> Vec { + let body = body_input(ctx); + let events = body.lock().held.get(&viewport).map(|(_, events)| events.clone()); + events.unwrap_or_default() +} + +/// Where the last press in one of the area's panel bodies in `viewport` began. +pub(super) fn body_press_key(area: Id, viewport: ViewportId) -> Id { + area.with(("body-press", viewport)) +} diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs new file mode 100644 index 00000000..0b1dea9a --- /dev/null +++ b/crates/ui/src/docking/render/cross.rs @@ -0,0 +1,460 @@ +//! 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"; +/// Key of whether a native window's bar drag highlighted a drop target on the last frame, under +/// the area's id: [`Cross::highlighted`] as [`Renderer::end_cross`] left it. +pub(super) const HIGHLIGHTED: &str = "drag-highlighted"; + +/// 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 bar drag highlighted a drop target this frame, in whichever window + /// drew its preview. + pub highlighted: 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

, + /// The viewport of each native window drawn this frame. + pub viewports: Vec<(u64, egui::ViewportId)>, +} + +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, + highlighted: false, + held: false, + pointed: None, + viewports: Vec::new(), + } + } +} + +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), + }); + self.write_pointer(ctx, pointer); + pointer + } + + /// Write `pointer` as the drag's for this frame. An Escape from another window is no + /// cancel of a drag that the window system carries out. + pub(super) fn write_pointer(&mut self, ctx: &Context, pointer: DragPointer) { + let id = self.id; + ctx.data_mut(|d| { + d.remove::(id.with(ESCAPE)); + d.insert_temp(id.with(POINTER), pointer); + }); + self.cross.pointer = Some(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); + // A native window has no docks. + let groups = std::mem::take(&mut self.dock_groups); + let result = f(self); + self.dock_groups = groups; + 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.cross.highlighted |= 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.cross.highlighted |= 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, and keep whether + /// a bar drag highlighted a drop target. The dragged window sets its opacity from that on + /// the next frame: it draws before the main window paints its preview. + pub(in crate::docking) fn end_cross(&mut self, ctx: &Context) { + let id = self.id; + ctx.data_mut(|d| d.insert_temp(id.with(HIGHLIGHTED), self.cross.highlighted)); + 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..878d909e --- /dev/null +++ b/crates/ui/src/docking/render/dock.rs @@ -0,0 +1,814 @@ +//! [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, stripless}; +use crate::docking::{ + Action, Column, DockEvent, DockHeights, DockStyle, DropTarget, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, Zone, + dock_model::Unit, + targets::{ + DockDrop, DockTarget, SIDE_EDGE_ZONE_DEPTH, STRIP_REACH, body_zone, column_edge, drop_zone, 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; + +/// [D] An open group of a dock column: its visible body, and the target and line of its left, +/// right, top and bottom zones (see [`DockStyle::side_zone_fraction`]). The top and bottom +/// stack a group above or below it; the left and right make a column on that side. +#[derive(Clone, Debug)] +pub(in crate::docking) struct DockGroup

{ + body: Rect, + /// The group takes tabs, so its body has a middle that joins it. + center: bool, + sides: [Option<(DockTarget

, Rect)>; 4], +} + +impl

DockGroup

{ + fn side(&self, zone: Zone) -> Option<&(DockTarget

, Rect)> { + let index = match zone { + Zone::Left => 0, + Zone::Right => 1, + Zone::Top => 2, + Zone::Bottom => 3, + Zone::Center => return None, + }; + self.sides.get(index)?.as_ref() + } +} + +/// 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 } +} + +/// [D] A dock's columns as drawn in its rect: each column's width bounds, natural width with +/// the floor it shrinks to, and the width [`fit_widths`] gives it, and each column with groups +/// as `(index, width, rect)`, from the window edge inwards. +pub(in crate::docking) struct DockColumns { + bounds: Vec<(f32, f32)>, + natural: Vec<(f32, Option)>, + widths: Vec, + pub(in crate::docking) rects: Vec<(usize, f32, Rect)>, +} + +/// The rects of groups `heights` high, stacked from the top of `area`. +pub(in crate::docking) fn stacked(area: Rect, heights: &[f32]) -> Vec { + let mut top = area.top(); + heights + .iter() + .map(|height| { + let rect = Rect::from_min_max(pos2(area.left(), top), pos2(area.right(), top + height)); + top += height; + rect + }) + .collect() +} + +/// 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), + }; + let (band_width, line_width) = (self.style.column_edge_band, self.style.drop_line_width); + if columns.is_empty() { + let (band, line) = column_edge(outer, rect.y_range(), band_width, line_width); + 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, outer: true }); + return; + } + if usable(rect) { + ui.painter().rect_filled(rect, 0.0, self.style.background); + } + let DockColumns { bounds, natural, widths, rects } = self.dock_columns(side, columns, rect); + let scaled = widths.iter().zip(&natural).any(|(w, (n, _))| w != n); + let total: f32 = widths.iter().sum(); + let mut drawn = Vec::new(); + for (index, width, column_rect) in rects { + let Some(column) = columns.get(index) else { continue }; + let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue }; + let (x, inner) = match edge { + Edge::Left => (column_rect.left(), column_rect.right()), + Edge::Right => (column_rect.right(), column_rect.left()), + }; + let groups = self.dock_groups.len(); + self.column(ui, column, first, column_rect, edge); + let (outer_band, outer_line) = column_edge(x, rect.y_range(), band_width, line_width); + let (inner_band, inner_line) = column_edge(inner, rect.y_range(), band_width, line_width); + let before = (DockTarget::ColumnEdge { anchor: first.clone(), after: false }, outer_line); + let after = (DockTarget::ColumnEdge { anchor: first.clone(), after: true }, inner_line); + let (left, right) = match edge { + Edge::Left => (before.clone(), after.clone()), + Edge::Right => (after.clone(), before.clone()), + }; + for group in self.dock_groups.iter_mut().skip(groups) { + group.sides[0] = Some(left.clone()); + group.sides[1] = Some(right.clone()); + } + if index == 0 { + // Inside the window, as the new-dock band's line is. + let line = vertical_line(x + inward * line_width * 0.5, rect.y_range(), line_width); + self.dock_targets.push(DockDrop { target: before.0, band: outer_band, line, outer: true }); + } + self.dock_targets.push(DockDrop { target: after.0, band: inner_band, line: after.1, outer: false }); + drawn.push((index, column, first, width, 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 }); + } + } + } + } + } + } + + /// The columns of a dock drawn in `rect` ([`DockColumns`]). + pub(in crate::docking) fn dock_columns(&self, side: Side, columns: &[Column

], rect: Rect) -> DockColumns { + let (outer, inward) = match edge_of(side, self.style.right_to_left) { + Edge::Left => (rect.left(), 1.0), + Edge::Right => (rect.right(), -1.0), + }; + 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 mut x = outer; + let mut rects = Vec::new(); + for (index, column) in columns.iter().enumerate() { + if column.groups.first().and_then(|g| g.panels.first()).is_none() { + 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; + rects.push((index, width, Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()))); + x = inner; + } + DockColumns { bounds, natural, widths, rects } + } + + /// A column drawn at `rect`: its bar, the body below it, and the heights of its groups in + /// the body, top to bottom (none when the column is iconic or its body is empty). Groups + /// taller than the body together scroll. + pub(in crate::docking) fn column_layout(&self, column: &Column

, rect: Rect) -> (Rect, Rect, Vec) { + let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom()))); + let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); + if column.iconic || !usable(body) { + return (bar, body, Vec::new()); + } + 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(); + (bar, body, group_heights(&sizes, self.style.tab_height, body.height())) + } + + 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, body, heights) = self.column_layout(column, rect); + self.column_bar(ui, column, first, bar, rect, edge); + 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 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())); + let scrolled = 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); + }); + // Match the drawn content, including rubber-band displacement in egui 0.37. + self.column_scroll.insert(first.clone(), scrolled.state.unclamped_offset().y); + } 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 rects = stacked(area, heights); + for (group, rect) in column.groups.iter().zip(&rects) { + self.group(ui, &group.panels, group.active, group.minimized, *rect, GroupKind::Dock); + } + let top = rects.last().map_or(area.top(), |r| r.bottom()); + let xs = view.x_range(); + let half = self.style.seam_band * 0.5; + let width = self.style.drop_line_width; + // A line on an edge scrolled out of the view shows at the view's edge. + let line = |y: f32| horizontal_line(y.clamp(view.top() + width * 0.5, view.bottom() - width * 0.5), xs, width); + 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, outer: false }); + } + }; + 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, line(rect.top())); + } + let header = if stripless(&group.panels, &*self.content) { 0.0 } else { self.style.tab_height }; + let body = Rect::from_min_max(pos2(rect.left(), (rect.top() + header).min(rect.bottom())), rect.max).intersect(view); + if !group.minimized && usable(body) { + let center = group.panels.get(group.active).is_some_and(|p| self.permissions(p).accepts_tabs); + let (above, below) = + (DockTarget::GroupBand { anchor: anchor.clone(), below: false }, DockTarget::GroupBand { anchor: anchor.clone(), below: true }); + // The left and right sides are the column's edges, set once the column is drawn. + self.dock_groups.push(DockGroup { + body, + center, + sides: [None, None, Some((above, line(rect.top()))), Some((below, line(rect.bottom())))], + }); + } + 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, line(top)); + } + // 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 width = self.style.drop_line_width; + let line = vertical_line(x + inward.signum() * width * 0.5, ys, width); + candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line, outer: true }); + } + candidates + } + + /// Whether `pointer` is on the tab strip of the frontmost group under it, and that group + /// takes tabs: the strip joins the group, and border bands reach into it only + /// [`STRIP_REACH`] from their lines. Not with [`DockStyle::side_edge_zones`]. + pub(super) fn in_tab_strip(&self, pointer: Pos2) -> bool { + self.in_tab_strip_past(pointer, &[]) + } + + /// [`Self::in_tab_strip`] past the groups holding any of `through`. + fn in_tab_strip_past(&self, pointer: Pos2, through: &[P]) -> bool { + let own = |t: &DropTarget

| t.tabs.iter().any(|(p, _)| through.contains(p)); + !self.style.side_edge_zones + && self.targets.iter().rev().find(|t| usable(t.rect) && t.rect.contains(pointer) && !own(t)).is_some_and(|t| { + t.accepts_tabs && t.tab_strip.map(|strip| strip.intersect(t.rect)).is_some_and(|strip| usable(strip) && strip.contains(pointer)) + }) + } + + /// The dock target at `pointer`: a band at a border, else a side zone of a dock group's + /// body ([`SIDE_EDGE_ZONE_DEPTH`] deep with [`DockStyle::side_edge_zones`]). The middle of a + /// group that takes tabs is none: the group's own target joins it. The groups holding + /// `through` (a floating window the drag looks through) hide no strip. + fn dock_drop(&self, pointer: Pos2, through: &[P]) -> Option> { + let mut candidates = self.dock_candidates(); + let strip = self.in_tab_strip_past(pointer, through); + if strip { + candidates.retain(|t| t.line.distance_to_pos(pointer) <= STRIP_REACH); + } + if let Some(drop) = resolve_dock(pointer, &candidates) { + return Some(drop.clone()); + } + if strip { + return None; + } + let group = self.dock_groups.iter().find(|g| g.body.contains(pointer))?; + let zone = if self.style.side_edge_zones { + drop_zone(group.body, 0.0, SIDE_EDGE_ZONE_DEPTH, pointer)? + } else { + body_zone(group.body, 0.0, self.style.side_zone_fraction, group.center, pointer)? + }; + let (target, line) = group.side(zone)?.clone(); + Some(DockDrop { target, band: group.body, line, outer: false }) + } + + /// The dock target a whole group or column drag would take at `pointer`, with the action. + /// A target that leaves the layout unchanged is a no-op: `None` action. A target named by + /// one of the moving panels, such as the window edge of the moving group's own column, is + /// named by another panel of that column or group, as for a tab drag. + pub(super) fn dock_group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, DockDrop

)> { + let through = self.looks_through(drag); + if self.dock_covered(pointer, through.then_some(&drag.panel)) { + return None; + } + let drop = self.dock_drop(pointer, if through { &drag.members } else { &[] })?; + let to = match drop.target.to() { + Target::At { anchor, placement } if drag.members.contains(&anchor) => { + let unit = if matches!(placement, Placement::Column { .. }) { Unit::Column } else { Unit::Group }; + match self.layout.members(&anchor, unit).ok()?.into_iter().find(|p| !drag.members.contains(p)) { + Some(anchor) => Target::At { anchor, placement }, + None => return Some((None, drop)), + } + } + to => to, + }; + self.checked(group_action(drag, to)).map(|action| (action, drop)) + } + + /// 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>, DockDrop

)> { + if self.dock_covered(pointer, None) { + return None; + } + let drop = self.dock_drop(pointer, &[])?; + 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)), + } + } + 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)) + } + + /// `Some(None)` when the action is valid but changes nothing apart from sizes + /// ([`crate::docking::Layout::same_arrangement`]), `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.same_arrangement(self.layout)).then_some(action)) + } + + /// The line of a drop that shows no result rect: on the window edge, into an iconic column, + /// or any dock drop with [`DockStyle::side_edge_zones`]. A line inside an iconic column also + /// outlines the column, unless it is on the window edge (`outer`): a column at that edge holds + /// the line, but the drop docks beside the column, not into it. + pub(super) fn paint_drop_line(&self, ctx: &Context, line: Rect, outer: bool) { + if usable(line) { + let painter = self.preview_painter(ctx); + if !outer { + 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..256b128f --- /dev/null +++ b/crates/ui/src/docking/render/floating.rs @@ -0,0 +1,272 @@ +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, + /// Where the window's tree is drawn. + pub(super) body: 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.minimized { + true => (self.minimized_body(window), self.minimized_body(window)), + 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 height of a minimized window's body: the tab strip of its only group, or nothing. + pub(super) fn minimized_body(&self, window: &Floating

) -> f32 { + if matches!(window.root, Node::Tabs { .. }) { self.style.tab_height } else { 0.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 opacity = self.drag_opacity(ctx, first); + let response = + FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).opacity(opacity).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()] }); + } + } + + /// Opacity of the in-window floating window whose first panel is `first`: faded + /// ([`super::DRAG_OPACITY`]) while its title bar drag highlights a drop target, as the last + /// frame drew it ([`super::preview::GROUP_HIGHLIGHTED`]), else opaque. Its tabs and drag + /// bars only drag a ghost, so the window stays opaque for them. + fn drag_opacity(&self, ctx: &Context, first: &P) -> f32 { + let moving = ctx + .data(|d| d.get_temp::>(self.id.with("group-drag"))) + .is_some_and(|drag| drag.gesture == GroupGesture::Window && drag.panel == *first); + let highlighted = ctx.data(|d| d.get_temp::(self.id.with(super::preview::GROUP_HIGHLIGHTED))).unwrap_or(false); + if moving && highlighted { super::DRAG_OPACITY } else { 1.0 } + } + + /// 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, body, 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..e1b7c429 --- /dev/null +++ b/crates/ui/src/docking/render/frame.rs @@ -0,0 +1,338 @@ +//! 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, Placement, Target, Zone, + dock_model::{Place, node_at, tree_panels}, +}; + +/// 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 outer edge areas of top, bottom, left and right drops; none while a frame is + /// maximized. + edges: Option<[EdgeArea

; 4]>, + /// The panels the view leaves out. + hidden: Vec

, + pub(super) maximized: Option

, + /// Floating windows as drawn, back to front. + pub(super) windows: Vec>, +} + +/// The view node an outer edge drop wraps ([`Layout::edge_path`]), and its rect once drawn. +struct EdgeArea

{ + path: Vec, + /// The node is narrower than the part between the docks: the canvas column. + narrowed: bool, + panels: Vec

, + rect: Option, +} + +impl EdgeArea

{ + fn of(view: &Layout

, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> Self { + let path = view.edge_path(zone, canvas); + let panels = + view.root.as_ref().and_then(|root| node_at(root, &path)).map_or(Vec::new(), |node| tree_panels(node).into_iter().cloned().collect()); + Self { narrowed: view.edge_narrows(zone, canvas), path, panels, rect: None } + } +} + +const EDGES: [Zone; 4] = [Zone::Top, Zone::Bottom, Zone::Left, Zone::Right]; + +fn edge_areas(view: &Layout

, canvas: &dyn Fn(&P) -> bool) -> [EdgeArea

; 4] { + EDGES.map(|zone| EdgeArea::of(view, zone, canvas)) +} + +/// The layout as shown: a maximized frame alone, and hidden panels left out. `narrow` narrows +/// the outer edge area to the canvas column ([`Layout::edge_path`]). +pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( + layout: &'a Layout

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

, + narrow: bool, +) -> (Cow<'a, Layout

>, FrameState

) { + let protected = |p: &P| content.permissions(p).is_protected(); + // The edge area narrows to the canvas column without side edge zones. + let canvas = |p: &P| narrow && protected(p); + 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(), edges: None, hidden: Vec::new(), maximized: None, windows: Vec::new() }; + if maximized.is_none() && matches!(hide, HidePanels::None) { + state.edges = Some(edge_areas(layout, &canvas)); + return (Cow::Borrowed(layout), state); + } + state.hidden = layout.panels().into_iter().filter(|p| hidden(p)).cloned().collect(); + 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.edges = Some(edge_areas(&view, &canvas)); + } + 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_some() { + return; + } + for area in self.frame.edges.iter_mut().flatten().filter(|area| area.path == path) { + area.rect = 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): the top and bottom of + /// [`crate::docking::DockStyle::drop_edges`], all four with + /// [`crate::docking::DockStyle::side_edge_zones`]. Returns a docked anchor that is not + /// moving, the zone and the preview: a line along the edge area's whole edge, or with side + /// edge zones the quarter the new frame takes. Without them, on a tab strip that takes tabs, + /// the band reaches only [`crate::docking::targets::STRIP_REACH`] from the line. + pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> { + let sides = self.style.outer_edges(); + let (distance, zone, area) = EDGES + .into_iter() + .filter(|zone| sides.contains(*zone)) + .filter_map(|zone| { + let area = self.edge_area(zone)?.rect.filter(|area| area.contains(pointer))?; + let distance = match zone { + Zone::Top => pointer.y - area.top(), + Zone::Bottom => area.bottom() - pointer.y, + Zone::Left => pointer.x - area.left(), + Zone::Right | Zone::Center => area.right() - pointer.x, + }; + Some((distance, zone, area)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0))?; + let band = if self.style.side_edge_zones { crate::docking::targets::SIDE_EDGE_BAND } else { self.style.edge_band }; + if distance > band.min(area.width() / 4.0).min(area.height() / 4.0) { + return None; + } + let line = crate::docking::targets::edge_line(area, zone, self.style.drop_line_width); + let reach = |line: Rect| line.distance_to_pos(pointer) <= crate::docking::targets::STRIP_REACH; + if !self.style.side_edge_zones && self.in_tab_strip(pointer) && !line.is_some_and(reach) { + return None; + } + let anchor = self.edge_anchor(members, zone)?; + if !self.style.side_edge_zones { + return Some((anchor, zone, line?)); + } + 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)) + } + + fn edge_area(&self, zone: Zone) -> Option<&EdgeArea

> { + self.frame.edges.as_ref()?.get(EDGES.iter().position(|edge| *edge == zone)?) + } + + /// The docked anchor, not one of `members`, of an outer edge drop at `zone` that wraps the + /// edge area drawn: protected where that area is the canvas column, as the model narrows on + /// a protected anchor, else not. The drawn area and the model's can still differ (hidden + /// panels, a canvas test that differs from these permissions), so the drop is tried: an + /// anchor whose drop wraps other panels than the area shows gives way to one that is not + /// protected, and then to no edge target. + fn edge_anchor(&self, members: &[P], zone: Zone) -> Option

{ + let area = self.edge_area(zone)?; + let protected = |p: &P| self.permissions(p).is_protected(); + let skip = |p: &P| members.contains(p); + let lead = members.first()?; + let wraps_area = |anchor: &P| { + let to = Target::At { anchor: anchor.clone(), placement: Placement::Edge(zone) }; + let action = match members { + [_] => Action::Move { panel: lead.clone(), anchor: anchor.clone(), placement: Placement::Edge(zone) }, + _ => Action::MoveGroup { panel: lead.clone(), to }, + }; + let mut candidate = self.layout.clone(); + if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() { + // Left to the model's check. + return true; + } + let Some(Place::Node(path)) = candidate.place(lead) else { return false }; + let Some((last, parent)) = path.split_last() else { return false }; + let beside = [parent, &[!last]].concat(); + let Some(wrapped) = candidate.root.as_ref().and_then(|root| node_at(root, &beside)) else { return false }; + let shown = |p: &&P| !self.frame.hidden.contains(p) && !members.contains(p); + let wrapped: Vec<&P> = tree_panels(wrapped).into_iter().filter(shown).collect(); + let drawn: Vec<&P> = area.panels.iter().filter(shown).collect(); + wrapped.len() == drawn.len() && wrapped.iter().all(|p| drawn.contains(p)) + }; + let anchor = self.layout.edge_anchor(area.narrowed, &protected, &skip)?; + if wraps_area(&anchor) { + return Some(anchor); + } + self.layout.edge_anchor(false, &protected, &skip).filter(|other| *other != anchor && !protected(other) && wraps_area(other)) + } + + /// 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..e4e04cda --- /dev/null +++ b/crates/ui/src/docking/render/group.rs @@ -0,0 +1,510 @@ +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, DockContent, 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> { + /// Draw one tab group at `rect`. + /// + /// An `active` index outside `panels` is a no-op. A lone protected panel is drawn stripless: + /// its whole rect is the body and its only tab has `Rect::NOTHING`. Side splits are offered + /// only for a `Frame` or `Floating` group whose selected panel accepts splits. Tab labels + /// shrink widest-first to the minimum width, then the remaining tabs move to the overflow menu; + /// hidden overflow tabs still appear in the drop target with `Rect::NOTHING`. The host's extra + /// tabs follow the panel tabs and fit with them ([`fit_with_extra`]). `minimized` hides the body + /// but keeps the strip. Tabs, strip, header and drag bar are mirrored in right-to-left layout. + 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)); + self.host_area(ui, "header", header, selected, |content, ui| content.group_header(ui, panels, selected, floating)); + } + // 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(); + let wanted = self.content.group_extra_tabs_width(panels, selected, floating); + let wanted = if wanted.is_finite() { wanted.clamp(0.0, tabs_width) } else { 0.0 }; + // 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, extra_width) = fit_with_extra(&widths, active, tabs_width, wanted, self.style.min_tab_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 mut extra = Rect::NOTHING; + if extra_width > 0.0 { + extra = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(extra_width, strip.height()))); + self.host_area(ui, "extra-tabs", extra, selected, |content, ui| content.group_extra_tabs(ui, panels, selected, floating)); + x += extra_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, extra, 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); + } + + /// A part of the strip the host draws (`header` or `extra-tabs`) at `rect`. A press in it + /// focuses the shown panel, as a press on its tab does. + fn host_area(&mut self, ui: &mut Ui, name: &'static str, rect: Rect, selected: &P, draw: impl FnOnce(&mut dyn DockContent

, &mut Ui)) { + let mut child = ui.new_child(UiBuilder::new().scope_id(self.id.with((name, selected))).max_rect(rect)); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + draw(&mut *self.content, &mut child); + if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { + self.output.focused = Some(selected.clone()); + } + } + + /// 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. The host's extra tabs + /// (`extra`) keep the wheel for their own widgets. + fn wheel_tabs(&mut self, ui: &Ui, strip: Rect, extra: Rect, panels: &[P], active: usize) { + let key = self.id.with(("tab-wheel", panels.first())); + if !ui.rect_contains_pointer(strip) || ui.rect_contains_pointer(extra) { + 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 + } + + /// A stock menu button for `panel` whose visible text comes from + /// [`crate::docking::DockContent::label`] while its accessible name stays the logical `label_of` + /// value. The function only returns the response; the caller performs the menu action. + 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) + } + + /// Draw one tab inside `rect` and return its response. + /// + /// Clicking activates and focuses the panel. In a minimized group the first click expands and + /// records the time so that same click is not also interpreted as a double-click; a genuine + /// double-click toggles minimized only when the owner minimizes. Middle-click closes only when + /// the style enables it and the panel is closable. The context menu emits `Action`s, with float, + /// move and close entries gated by the panel's permissions. `owner: None` removes minimized + /// semantics and undock latching. An elided label exposes its full text as hover text. + 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_by(egui::PointerButton::Primary) { + 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); + } + }); + } +} + +/// Fit the panel tabs and the host's extra tabs (`extra` wide), which fit as one more tab that +/// never overflows: in a row too narrow for every tab at its minimum width, the panel tabs +/// overflow into the room the extra tabs leave at theirs. Returns the extra tabs' width. +fn fit_with_extra(widths: &[f32], active: usize, available: f32, extra: f32, minimum: f32) -> (tabs::TabFit, f32) { + if extra <= 0.0 { + return (tabs::fit(widths, active, available, minimum, OVERFLOW_WIDTH), 0.0); + } + let all: Vec = widths.iter().copied().chain([extra]).collect(); + let mut fit = tabs::fit(&all, active, available, minimum, OVERFLOW_WIDTH); + if fit.overflow.is_empty() { + let width = fit.shown.pop().filter(|(index, _)| *index == widths.len()).map_or(0.0, |(_, width)| width); + return (fit, width); + } + let fit = tabs::fit(widths, active, (available - extra.min(minimum)).max(0.0), minimum, OVERFLOW_WIDTH); + let used = fit.shown.iter().map(|(_, width)| width).sum::() + fit.overflow_width; + (fit, extra.min(available - used).max(0.0)) +} diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs new file mode 100644 index 00000000..2ceda931 --- /dev/null +++ b/crates/ui/src/docking/render/iconic.rs @@ -0,0 +1,351 @@ +//! [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 width = self.style.drop_line_width; + let line = horizontal_line(line.clamp(view.top() + width * 0.5, view.bottom() - width * 0.5), xs, width); + self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line, outer: false }); + } + } + } + } + + /// 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..751e17b4 --- /dev/null +++ b/crates/ui/src/docking/render/mod.rs @@ -0,0 +1,523 @@ +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 body_drag; +#[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, INACTIVE_PALETTE, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, drag_opacity, resize_bands, session, wayland}, +}; + +#[cfg(all(test, feature = "native-windows"))] +pub(in crate::docking) use body_drag::held_events; +#[cfg(feature = "native-windows")] +pub(in crate::docking) use body_drag::{cancel_body_route, mark_app_drag}; +#[cfg(feature = "native-windows")] +pub(in crate::docking) use native::{InputHook, WindowSystem, send_keys, set_input_hook, typing_window}; + +/// How long after blocking starts a native window that still has the keyboard hands it to the +/// main window: an OS dialog the app opens with the modal (a file dialog) takes focus a little +/// later, and a Focus sent before then could raise the main window over it. +#[cfg(feature = "native-windows")] +const BLOCKED_FOCUS_WAIT: f64 = 0.2; + +/// Opacity of a floating window, in-window or native, while its bar, only tab or only tab row +/// drags it over a drop target: the highlighted target under the window shows through it. +pub(in crate::docking) const DRAG_OPACITY: f32 = 0.75; + +/// While input is blocked (`starts`: from this frame), a native window of the dock area `area` +/// with the keyboard would drop the keys the modal needs, so once, after +/// [`BLOCKED_FOCUS_WAIT`], the main window, where the modal is, takes the keyboard if one still +/// has it. If focus went elsewhere meanwhile, such as to an OS dialog, nothing is sent. +#[cfg(feature = "native-windows")] +pub(in crate::docking) fn focus_main_from_native

(ctx: &Context, area: Id, layout: &Layout

, blocked: bool, starts: bool) { + let (key, now) = (area.with("blocked-focus"), ctx.input(|i| i.time)); + if !blocked { + ctx.data_mut(|d| d.remove::(key)); + return; + } + if starts { + ctx.data_mut(|d| d.insert_temp(key, now)); + } + let Some(since) = ctx.data(|d| d.get_temp::(key)) else { return }; + if now - since < BLOCKED_FOCUS_WAIT { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(BLOCKED_FOCUS_WAIT - (now - since))); + return; + } + ctx.data_mut(|d| d.remove::(key)); + let focused = + ctx.input(|i| layout.floating.iter().any(|w| i.raw.viewports.get(&native_viewport(area, w.id)).and_then(|v| v.focused) == Some(true))); + if focused { + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); + } +} + +/// 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>, + /// [D] The open groups of dock columns, whose body zones stack or make columns. + pub(super) dock_groups: Vec>, + /// [D] The scroll offset of each dock column that scrolls this frame, by its first panel. + pub(super) column_scroll: HashMap, + 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

, + /// What the window system tells of the desktop pointer, and whether it moves a native + /// window for a bar drag. + #[cfg(feature = "native-windows")] + pub(super) window_system: WindowSystem, +} + +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); + } + } + } + + /// Draw a panel body into `rect`. + /// + /// An unusable rect returns without painting. Otherwise the body runs in a clipped child scope, + /// and its minimum height is recorded against `rect.height()` for floating-size policy. Any + /// pointer press inside the body focuses the panel, including secondary and middle presses; with + /// native windows that press origin is stored for body-drag routing and the panel is recorded as + /// the pointed body. The panel's content map keeps the sizing entry for later frames. + 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 let Some(press) = child.input(|i| i.pointer.press_origin()) { + let key = body_drag::body_press_key(self.id, child.ctx().viewport_id()); + child.ctx().data_mut(|d| d.insert_temp(key, press)); + } + } + #[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..f27f6058 --- /dev/null +++ b/crates/ui/src/docking/render/native.rs @@ -0,0 +1,1499 @@ +//! 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 the application's other windows, and +//! drop to the normal level while none of its windows has focus, so they stay shown without +//! covering other applications; 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`), and so do the app's drags in panel bodies +//! (`body_drag.rs`). + +use std::{fmt::Debug, hash::Hash, sync::Arc}; + +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::body_drag::{BodyRoute, Held, Queued}; +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 none of the application's windows has focus before the application counts as +/// inactive, so focus moving between our own windows does not. +const AWAY_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"); +/// The level of a palette while the application is inactive, behind other applications' +/// windows. On Windows `WindowLevel::Normal` is `HWND_NOTOPMOST`, which puts the window in +/// front of every window that is not topmost, the activated application's too; `HWND_BOTTOM` +/// sends it behind them without activating it. +pub(in crate::docking) const INACTIVE_PALETTE: WindowLevel = + if cfg!(target_os = "windows") { WindowLevel::AlwaysOnBottom } else { WindowLevel::Normal }; +/// 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 the native window that a bar drag of the area `area` moves, by the rule for every +/// floating window ([`super::DRAG_OPACITY`]): faded while the drag highlights a drop target, as +/// the last frame drew it ([`super::cross::HIGHLIGHTED`]), opaque over empty space, for a move +/// that cannot dock and while Ctrl/Cmd keeps the drop floating. The window stays above the main +/// window, so the highlighted target shows only through it. +#[cfg(any(target_os = "macos", test))] +pub(in crate::docking) fn drag_opacity(ctx: &Context, area: Id) -> f64 { + let dragging = ctx.data(|d| d.get_temp::(area.with("window-drag"))).is_some(); + let highlighted = ctx.data(|d| d.get_temp::(area.with(super::cross::HIGHLIGHTED))).unwrap_or(false); + if dragging && highlighted { f64::from(super::DRAG_OPACITY) } else { 1.0 } +} + +/// 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); + } + } +} + +/// What the window system tells of the desktop pointer, and whether it moves a native window +/// for a bar drag itself. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(in crate::docking) enum WindowSystem { + /// The running one: AppKit on macOS tells the cursor, the button and the modifiers, and + /// moves a window for `StartDrag`; elsewhere none of these is told. + #[default] + Native, + /// A stand-in (`docking::testing::NativeHost`). + #[cfg(any(test, feature = "testing"))] + Host { + /// The cursor on the desktop, where told. + cursor: Option, + /// Whether the primary button is down, where told. + held: Option, + /// It moves a window for `StartDrag`, as AppKit does. + moves: bool, + }, +} + +impl WindowSystem { + /// Where the cursor is on the desktop, in egui's desktop points. + fn cursor(self, ctx: &Context) -> Option { + match self { + Self::Native => system_cursor(ctx), + #[cfg(any(test, feature = "testing"))] + Self::Host { cursor, .. } => cursor, + } + .filter(|p| p.is_finite()) + } + + /// Whether the primary button is down now. egui can report a release a frame after it. + fn primary_held(self) -> Option { + match self { + Self::Native => system_primary_held(), + #[cfg(any(test, feature = "testing"))] + Self::Host { held, .. } => held, + } + } + + /// Whether Ctrl or Cmd is down now. AppKit sends no key events while it moves a window, so + /// egui's modifiers can be stale then. + fn command_held(self) -> Option { + match self { + Self::Native => system_command_held(), + #[cfg(any(test, feature = "testing"))] + Self::Host { .. } => None, + } + } + + /// Whether it moves a native window for a bar drag (`StartDrag`), and tells the cursor + /// and the button meanwhile: AppKit does, smoothly, while a window moved from the app by + /// `OuterPosition` jitters there. + fn moves_windows(self) -> bool { + match self { + Self::Native => cfg!(target_os = "macos"), + #[cfg(any(test, feature = "testing"))] + Self::Host { moves, .. } => moves, + } + } +} + +/// AppKit's cursor, in the desktop points egui reports window positions in. AppKit measures +/// from the bottom-left of the primary screen (the one with the menu bar, first in +/// `NSScreen::screens`), y up; winit flips window positions against that screen's height, +/// whichever screen a window is on, so the cursor is flipped the same way. egui's points are +/// AppKit's divided by the zoom factor. +#[cfg(target_os = "macos")] +fn system_cursor(ctx: &Context) -> Option { + let mtm = objc2::MainThreadMarker::new()?; + let primary = objc2_app_kit::NSScreen::screens(mtm).firstObject()?; + let at = objc2_app_kit::NSEvent::mouseLocation(); + let height = primary.frame().size.height; + Some(pos2(at.x as f32, (height - at.y) as f32) / ctx.zoom_factor()) +} + +#[cfg(not(target_os = "macos"))] +fn system_cursor(_: &Context) -> Option { + None +} + +/// AppKit's primary button state, current when egui's lags. +#[cfg(target_os = "macos")] +fn system_primary_held() -> Option { + Some(objc2_app_kit::NSEvent::pressedMouseButtons() & 1 != 0) +} + +#[cfg(not(target_os = "macos"))] +fn system_primary_held() -> Option { + None +} + +/// AppKit's Ctrl and Cmd state, current while it sends egui no key events. +#[cfg(target_os = "macos")] +fn system_command_held() -> Option { + use objc2_app_kit::{NSEvent, NSEventModifierFlags}; + Some(NSEvent::modifierFlags_class().intersects(NSEventModifierFlags::Command | NSEventModifierFlags::Control)) +} + +#[cfg(not(target_os = "macos"))] +fn system_command_held() -> Option { + None +} + +/// 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, or under `os`, the + /// cursor less `grab`. + 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, + /// The window system moves the window ([`WindowSystem::moves_windows`]). + pub os: bool, + /// The cursor has gone past the click distance from the press. Under `os` the drag starts + /// on the press, and until then it is a click, which docks nothing. + pub moved: 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, + /// The height of its bar while minimized, as it was last told. `known` keeps the expanded + /// size meanwhile. + collapsed: Option, +} + +/// 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. +pub(super) 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)), + } +} + +/// 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 (active, activated) = if native { self.app_activity(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, 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); + } + self.cross.viewports = natives.iter().map(|(w, _, viewport)| (w.id, *viewport)).collect(); + } + self.map_body_route(ctx, native); + for (window, rect, viewport) in natives { + if let Some(key) = self.native_window(ctx, window, rect, viewport, active) { + 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. `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)], 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()) { + 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 the application is active this frame: one of its windows has focus, or one had it + /// less than [`AWAY_DELAY`] ago. And whether it was just activated: one of its windows has + /// focus after none had it for that long. Losing focus again stops the raise of normal + /// windows that activation started. + fn app_activity(&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-last-focused"); + let last = if focused { now } else { ctx.data(|d| d.get_temp::(key)).unwrap_or(now) }; + let active = focused || now - last < AWAY_DELAY; + if active && !focused { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((AWAY_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 || !active)); + if !focused { + d.remove::>>(self.id.with("native-raise")); + } + }); + (active, focused && away) + } + + /// A window holding a viewer is a normal window, so it stacks with the main window; 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. `active`: the application is active (see + /// [`Self::app_activity`]). Returns the key of its [`Place`]. + fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, active: bool) -> Option { + let normal = self.normal_window(window); + 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); + // A minimized window shows its bar and what `floating_body` draws below it. + let collapsed = window.minimized.then(|| BAR_HEIGHT + self.minimized_body(window)); + let shown = |[x, y, w, h]: [f32; 4]| [x, y, w, collapsed.unwrap_or(h)]; + 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, shown(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, collapsed: None } + } + }; + // Minimized or expanded: the window keeps its top-left corner and width, and its + // height is fixed while it shows only its bar. + // The limits go first: the OS clamps the new size to the limits it has. + if place.collapsed != collapsed { + let (min, max) = match collapsed { + Some(height) => (vec2(MIN_SIZE[0], height), vec2(1_000_000.0, height)), + None => (Vec2::from(MIN_SIZE), Vec2::INFINITY), + }; + ctx.send_viewport_cmd_to(viewport, ViewportCommand::MinInnerSize(min)); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::MaxInnerSize(max)); + place_window(ctx, viewport, shown(place.known), frame); + (place.collapsed, place.pending) = (collapsed, Some(now + MOVE_WAIT)); + } + 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. While the application is + // inactive, its palettes stay shown behind other applications' windows + // (`INACTIVE_PALETTE`). egui sends the level only when it changes. + .with_window_level(match (normal || self.input_blocked, active) { + (true, _) => WindowLevel::Normal, + (false, true) => WindowLevel::AlwaysOnTop, + (false, false) => INACTIVE_PALETTE, + }) + .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); + // While the application is inactive, raising a palette leaves focus with the application + // in use. + if (active || normal) && !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; + if own { + crate::ime::window::begin_window(ui.ctx()); + } + self.floating_body(ui, window, body, full); + if own { + self.content.window_drawn(ui); + crate::ime::window::finish_window(ui.ctx()); + } + if own && !blocked && ui.ctx().text_edit_focused() { + let _ = body_input(ui.ctx()).lock().typing_next.insert(viewport); + } + 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.route_body_drag(ui.ctx(), Some(window.id)); + 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 and the last frame's preview, so leaving a target + // or 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 drag = ctx.data(|d| d.get_temp::(drag_key)); + let dragged = drag.as_ref().and_then(|drag| self.layout.floating.iter().find(|w| w.id == drag.window)); + let this = dragged.and_then(|w| crate::docking::dock_model::first_active(&w.root)).is_some_and(|p| self.label_of(p) == title); + let opacity = if this { drag_opacity(ctx, self.id) } else { 1.0 }; + set_window_opacity(&title, main.as_deref(), opacity); + } + // The collapsed height a minimized window reports is not the size to keep. + let reported = report.placed.map(|[x, y, w, h]| [x, y, w, if place.collapsed.is_some() { place.known[3] } else { h }]); + if let Some(placed) = reported.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 }); + } + // Where the window system moves the window, it takes the press, as a title bar's; + // a drag that egui would only start past the click distance would lose the way there. + let start = if self.window_system.moves_windows() { + response.bar.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) + } else { + response.bar.drag_started() + }; + if movable && start { + 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 let Some(origin) = self.follow_window_drag(ui, window.id, origin) { + 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 by handing the move to the window system: where it moves windows smoothly (macOS), + /// tracking the drag, and where it reports no positions (Wayland), leaving it alone. `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 ctx = ui.ctx(); + let key = self.id.with("window-drag"); + if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| d.window == window) { + return; + } + let os = self.window_system.moves_windows(); + // The window system keeps the window where it is under the pointer now. + let press = + ui.input(|i| if os { i.pointer.latest_pos().or(i.pointer.press_origin()) } else { 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, os, moved: !os || docks }; + ctx.data_mut(|d| d.insert_temp(key, drag)); + if os { + ctx.send_viewport_cmd(ViewportCommand::StartDrag); + } + } + _ => 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. Where the window system + /// moves the window, follow it instead ([`Self::follow_os_drag`]). Returns the desktop + /// position of the window's content while it moves, `origin` as reported or where it is. + fn follow_window_drag(&mut self, ui: &Ui, window: u64, origin: Option) -> Option> { + let key = self.id.with("window-drag"); + let mut drag = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window)?; + if drag.os { + return Some(self.follow_os_drag(ui, drag, origin)); + } + 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 to = content.zip(moved.filter(|p| p.is_finite())).map(|((origin, _), local)| origin + (local.to_vec2() - drag.grab)); + let mut step = None; + if let Some(to) = to.filter(|to| 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. Targets are hit there. + 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 Some(origin); + } + 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); + } + Some(origin) + } + + /// A bar drag that the window system carries out (`StartDrag`): the window stays under the + /// cursor at the pressed point, so it is where the cursor is less the grab. Its reported + /// place lags the move, and AppKit tells the cursor fresh each frame. The drag ends on the + /// first frame on which the window system or egui reports the release, and then once. + /// Escape does not end it, as with Finder's windows: AppKit sends no keys meanwhile. + /// Returns where the window's content is. + fn follow_os_drag(&mut self, ui: &Ui, mut drag: WindowDrag, origin: Option) -> Option { + let ctx = ui.ctx(); + let key = self.id.with("window-drag"); + let local = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite()); + let cursor = self.window_system.cursor(ctx).or_else(|| Some(origin? + local?.to_vec2())); + if let Some(cursor) = cursor { + drag.to = cursor - drag.grab; + drag.moved |= drag.to.distance(drag.start) > ctx.options(|o| o.input_options.max_click_dist); + } + let (egui_down, released) = ui.input(|i| (i.pointer.primary_down(), i.pointer.primary_released())); + let ended = self.window_system.primary_held() == Some(false) || released || !egui_down; + let command = + self.window_system.command_held().unwrap_or_else(|| ui.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd)); + if self.cross.focus_lost || ended { + ctx.data_mut(|d| d.remove::(key)); + } else { + ctx.data_mut(|d| d.insert_temp(key, drag)); + } + // A click on the bar is no drop, and the window's reports say where it is. + if !drag.moved { + return origin; + } + self.cross.moved = Some(drag.to); + if self.cross.focus_lost { + return Some(drag.to); + } + let pointer = DragPointer { + owner: Some(drag.window), + origin: Some(drag.to), + local: Some(drag.grab.to_pos2()), + down: !ended, + released: ended, + escape: false, + command, + window: Some(drag.window), + docks: drag.docks, + }; + self.write_pointer(ctx, pointer); + if ended { + // 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 { + let frame = ui.input(|i| content_of(i.viewport())).map_or(Vec2::ZERO, |(_, frame)| frame); + ctx.send_viewport_cmd(ViewportCommand::OuterPosition(pos2(x, y) - frame)); + self.cross.moved = Some(pos2(x, y)); + } + } + self.end_window_drag(drag, pointer); + } + Some(drag.to) + } + + /// 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. App drags in +/// panel bodies cross windows here too (`body_drag.rs`). +#[derive(Default)] +pub(super) struct NativeInput { + hooks: std::collections::HashMap>, + /// The dock area of each native window shown this frame. + windows: std::collections::HashMap, +} + +/// Each dock area's app drag in a panel body, the events waiting for a window's next input, and +/// the windows whose text fields have the keyboard. Kept outside [`NativeInput`]: egui calls +/// [`egui::Plugin::input_hook`] with the plugin locked, and an app's input hook may call +/// [`crate::docking::cancel_drag`] or [`crate::docking::send_keys`], which must reach this state +/// without locking the plugin again. The lock here is dropped before that hook runs. +#[derive(Default)] +pub(super) struct NativeBodyInput { + /// Each dock area's app drag in a panel body. + pub routes: std::collections::HashMap, + /// Pointer events routed to each window, for its next input. + pub queued: Queued, + /// Owners' held releases, for their input in a later main-window pass. + pub held: Held, + /// The main window's passes so far. + pub pass: u64, + /// The windows that had a focused text field in the last main-window pass, and those + /// noted so far in this one. + typing: std::collections::HashSet, + typing_next: std::collections::HashSet, + /// The main window's completed passes when `typing` was last taken from `typing_next`. + typing_at: u64, + /// Key and clipboard events for each window's next input, and the pass they were queued in. + keys: std::collections::HashMap)>, +} + +/// The app drag routing and routed events shared by every native input hook of `ctx`. +pub(super) fn body_input(ctx: &Context) -> Arc> { + ctx.data_mut(|data| data.get_temp_mut_or_default::>>(body_input_key()).clone()) +} + +/// The temp-data key for the context's native body-routing state. +/// +/// All dock areas in a context deliberately use this one container: its `routes` map is keyed by +/// dock-area `Id`, and queued, held and key events by target viewport. The key is unique only so +/// this state does not collide with unrelated temporary data. +fn body_input_key() -> Id { + Id::unique("craft-ui docking native body input") +} + +pub(in crate::docking) type InputHook = Box; + +impl egui::Plugin for NativeInput { + /// The egui plugin's diagnostic name, `craft-ui docking native input`. + fn debug_name(&self) -> &'static str { + "craft-ui docking native input" + } + + /// Route this viewport's input without deadlocking. + /// + /// Only the root viewport advances the frame pass; queued, held and key entries older than the + /// current pass are pruned. Events waiting for this viewport are taken before routing, so events + /// enqueued by routing wait for its next input. A held release is only released once a later + /// root pass exists. Earlier routed events are spliced before the window's own events, and any + /// accepted route requests a root repaint. The dock area's application hook is called only + /// after the routing mutex is dropped because it may cancel a drag and re-enter the shared + /// body state. + fn input_hook(&mut self, ctx: &Context, input: &mut RawInput) { + let viewport = input.viewport_id; + let body = body_input(ctx); + let mut routed = false; + { + let mut body = body.lock(); + let body = &mut *body; + if viewport == ViewportId::ROOT { + body.pass += 1; + // Every window draws in the main window's pass: events for one that has not + // drawn since the last pass are for a window that is gone. + let pass = body.pass; + body.queued.retain(|_, (at, _)| *at + 1 >= pass); + body.held.retain(|_, (at, _)| *at + 1 >= pass); + body.keys.retain(|_, (at, _)| *at + 1 >= pass); + body.typing = std::mem::take(&mut body.typing_next); + body.typing_at = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + // eframe has run `App::raw_input_hook`, where an app's native menu reports its key + // equivalents (macOS: AppKit's menu takes them before the key window sees them). + // While a native window has the keyboard, the main window gets no keys of its own, + // so these are the menu's: those a focused text field there acts on are its own. + if let Some(window) = typing_window_of(&body.typing, &input.viewports) { + let (moved, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut input.events).into_iter().partition(text_key); + input.events = kept; + if !moved.is_empty() { + let entry = body.keys.entry(window).or_default(); + entry.0 = pass; + entry.1.extend(moved); + ctx.request_repaint_of(window); + } + } + } + // Taken before routing, which may queue more for this window's next input. A held + // release waits for a later main-window pass: a discarded pass reruns within its own. + let waiting = body.queued.remove(&viewport); + let keys = body.keys.remove(&viewport).map(|(_, mut keys)| { + text_clipboard_keys(ctx, viewport, &mut keys); + keys + }); + let held = if body.held.get(&viewport).is_some_and(|(at, _)| *at < body.pass) { body.held.remove(&viewport) } else { None }; + for route in body.routes.values_mut() { + routed |= route.route(input, &mut body.queued, &mut body.held, body.pass); + } + // Before the window's own events: they happened earlier, and a held release must + // not end a press that follows it. + let earlier = held + .into_iter() + .flat_map(|(_, events)| events) + .chain(waiting.into_iter().flat_map(|(_, events)| events.into_iter().map(|(_, e)| e))) + .chain(keys.into_iter().flatten()); + input.events.splice(0..0, earlier); + } + if routed { + ctx.request_repaint_of(ViewportId::ROOT); + } + // After the routing lock is dropped: the app's hook may call `cancel_drag`, which locks + // the same body state through egui's temporary data, not this plugin. + if let Some(hook) = self.windows.get(&input.viewport_id).and_then(|area| self.hooks.get(area)) { + (hook.lock())(ctx, input); + } + } +} + +/// The native window in `typing` that has the keyboard, by the main window's `viewports`. +fn typing_window_of(typing: &std::collections::HashSet, viewports: &egui::ViewportIdMap) -> Option { + typing.iter().copied().find(|viewport| viewports.get(viewport).and_then(|info| info.focused) == Some(true)) +} + +/// Whether a text field acts on `event`: the clipboard events, and the command keys that copy, +/// paste, select all, undo, redo, move or delete in text. Other command keys are the app's +/// (save, close, new window, its own shortcuts). +fn text_key(event: &Event) -> bool { + match event { + Event::Copy | Event::Cut | Event::Paste(_) => true, + Event::Key { key, modifiers: m, .. } if m.command && !m.alt => match key { + Key::C | Key::X | Key::V | Key::A | Key::Backspace | Key::Delete => !m.shift, + Key::Z | Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => true, + _ => false, + }, + _ => false, + } +} + +/// A text field copies and pastes on `Copy`, `Cut` and `Paste` events, which egui-winit makes +/// from the window's own key presses. A command C, X or V press here is a menu's key +/// equivalent, which a text field ignores: give back C and X as `Copy` and `Cut`, and turn V +/// into a request for the clipboard text, which arrives in `viewport` as `Paste`. +fn text_clipboard_keys(ctx: &Context, viewport: ViewportId, events: &mut Vec) { + events.retain_mut(|e| { + let Event::Key { key, pressed: true, modifiers: m, .. } = *e else { return true }; + if !m.command || m.alt || m.shift { + return true; + } + match key { + Key::C => *e = Event::Copy, + Key::X => *e = Event::Cut, + Key::V => { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::RequestPaste); + return false; + } + _ => {} + } + true + }); +} + +/// See [`crate::docking::typing_window`]. +pub(in crate::docking) fn typing_window(ctx: &Context) -> Option { + // Between frames (in `App::raw_input_hook`) a main-window pass has ended since the swap, and + // `typing_next` is the whole of the last pass, which the next swap hands to the routing. + let passes = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + let body = body_input(ctx); + let typing = { + let input = body.lock(); + if passes > input.typing_at { input.typing_next.clone() } else { input.typing.clone() } + }; + ctx.input_for(ViewportId::ROOT, |i| typing_window_of(&typing, &i.raw.viewports)) +} + +/// See [`crate::docking::send_keys`]. +pub(in crate::docking) fn send_keys(ctx: &Context, viewport: ViewportId, events: Vec) { + if events.is_empty() { + return; + } + { + let body = body_input(ctx); + let mut input = body.lock(); + let pass = input.pass; + let entry = input.keys.entry(viewport).or_default(); + entry.0 = pass; + entry.1.extend(events); + } + ctx.request_repaint_of(viewport); +} + +/// 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 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..b3cb6248 --- /dev/null +++ b/crates/ui/src/docking/render/preview.rs @@ -0,0 +1,925 @@ +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, sized_split_depth}, + surface::TAB_PADDING, + targets::{DockDrop, SIDE_EDGE_ZONE_DEPTH, resolve_drop_on}, +}; +use crate::layout::{SplitAxis, SplitSize}; + +/// 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) +} + +/// Share of the accent that fills a tab drop's outline: light, so it does not read as a line. +const TAB_FILL: f32 = 0.08; + +/// The preview of a drop that joins a group as a tab, or of the rect a drop beside something +/// gives the moved panels: a 2 pt accent outline inside `rect`, with a light fill. +fn tab_outline(painter: &egui::Painter, rect: Rect, accent: egui::Color32) { + painter.rect_filled(rect, 0.0, accent.gamma_multiply(TAB_FILL)); + painter.rect_stroke(rect, 0.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Inside); +} + +/// 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; + +/// Key of whether the area's group drag highlighted a drop target on the last frame, under the +/// area's id. An in-window floating window that its bar drag moves fades from it on the next +/// frame: the window draws before the preview. +pub(super) const GROUP_HIGHLIGHTED: &str = "group-drag-highlighted"; + +/// 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(); + ctx.data_mut(|d| d.remove::(self.id.with(GROUP_HIGHLIGHTED))); + 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()); + if self.paint_group_drag(ui, &drag, pointer, float_only) { + ctx.data_mut(|d| d.insert_temp(self.id.with(GROUP_HIGHLIGHTED), true)); + } + } + } + + /// The valid target under the pointer for a whole group. A target holding the group + /// itself is a no-op proposal, and so is a drop that would change only sizes; a target + /// the model would reject is no target. A drag that takes a whole floating window along + /// looks through it ([`Self::looks_through`]). A window moving live 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> { + if let Some(proposal) = self.extra_proposal(&drag.members, pointer, self.looks_through(drag)) { + return self.valid_group(drag, self.settled(proposal, |to| group_action(drag, to))); + } + let target = self.group_target(drag, pointer)?; + self.group_proposal_at(drag, pointer, target) + } + + /// Whether a group drag looks through the floating window it comes from: the window's + /// title bar moves it, or the drag holds the window's only group and so takes the window + /// along. The window then covers nothing behind it. + pub(super) fn looks_through(&self, drag: &GroupDrag

) -> bool { + match drag.gesture { + GroupGesture::Window => true, + GroupGesture::Group => self.whole_window(&drag.panel), + GroupGesture::Column => false, + } + } + + /// The group under the pointer that a group drag addresses, past the drag's own groups + /// when it looks through its window. + fn group_target(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<&DropTarget

> { + let through = self.looks_through(drag); + 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) && !(through && 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(_) | Placement::SplitSized { .. } => !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) + } + + /// [`crate::docking::resolve_drop_by`] with [`crate::docking::DockStyle::zone_rule`] and the + /// canvas sides of [`crate::docking::DockStyle::drop_edges`] (none in the docking profile), + /// where a split that would leave a group without room becomes a join of the target group, + /// or no target when the group takes no tabs. A drop that would change only sizes is a no-op ([`Self::settled`]). + fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget

], action: impl Fn(Target

) -> Action

) -> Option> { + let proposal = resolve_drop_on(panel, pointer, targets, self.style.zone_rule(), self.style.canvas_sides())?; + if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) { + return Some(self.settled(proposal, action)); + } + if self.leaves_room(action(Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() })) { + return Some(self.settled(proposal, action)); + } + if let Some(placement) = self.sized_split(panel, &proposal, &action) + && self.leaves_room(action(Target::At { anchor: proposal.anchor.clone(), placement: placement.clone() })) + { + return Some(self.settled(DropProposal { placement, ..proposal }, action)); + } + let target = targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; + let proposal = resolve_drop_on( + panel, + pointer, + std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), + self.style.zone_rule(), + self.style.canvas_sides(), + )?; + Some(self.settled(proposal, action)) + } + + /// The side split of `proposal`'s frame when halving it leaves no room + /// ([`Placement::SplitSized`]): the frame keeps its size, and the frames beyond the divider + /// on that side, or on the opposite side for a frame at the edge of its area, give the new + /// frame its minimum (with [`DROP_MARGIN`]). A floating panel + /// keeps its size along the split instead, and one in a dock column its column's width or + /// its group's height, when that fits. `None` without such a divider, or when those frames + /// cannot give the new frame its minimum. + fn sized_split(&self, panel: &P, proposal: &DropProposal

, action: &impl Fn(Target

) -> Action

) -> Option> { + let Placement::Split(zone) = proposal.placement else { return None }; + let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let along = |v: Vec2| if axis == SplitAxis::Horizontal { v.x } else { v.y }; + let start = matches!(zone, Zone::Left | Zone::Top); + let anchor = &proposal.anchor; + let now = self.drawn_groups(self.layout); + let frame = along(now.iter().find(|(node, _)| holds(node, anchor))?.1.size()); + let mut candidate = self.layout.clone(); + let halves = action(Target::At { anchor: anchor.clone(), placement: proposal.placement.clone() }); + candidate.apply_with_permissions(halves, |p| self.permissions(p)).ok()?; + let (root, area, path) = match candidate.place(anchor)? { + Place::Node(path) => (candidate.root.as_ref()?, self.workspace, path), + Place::Floating { window, path } => { + let window = candidate.floating.get(window)?; + (&window.root, self.window_body(window), path) + } + Place::Dock { .. } => return None, + }; + // The splits from the root down to the new one, as drawn with halves. + let sizer = self.sizer(); + let (_, split_path) = path.split_last()?; + let mut chain = vec![(root, area)]; + for &second in split_path { + let &(node, rect) = chain.last()?; + let [a, b] = sizer.children(node, rect)?; + chain.push(if second { b } else { a }); + } + let &(Node::Split { first, second, .. }, halved) = chain.last()? else { return None }; + let new = if start { first } else { second }; + let depth = + sized_split_depth(split_path, zone, |d| chain.get(d).is_some_and(|(n, _)| matches!(n, Node::Split { axis: own, .. } if *own == axis)))?; + // The frame is in the outer split's second child when the divider is on its start side. + let in_second = *split_path.get(depth)?; + let &(outer, outer_rect) = chain.get(depth)?; + let Node::Split { size: outer_size, first: a, second: b, .. } = outer else { return None }; + if matches!(**a, Node::Dock { .. }) || matches!(**b, Node::Dock { .. }) { + return None; + } + let [a, b] = sizer.children(outer, outer_rect)?; + let far = if in_second { a } else { b }; + // The far side's edge at the divider is its end edge when the frame is in the second + // child. + let room = sizer.travel(far.0, far.1, axis, in_second).0 - DROP_MARGIN; + let least = along(super::node_limits(new, self.style, &*self.content).min) + DROP_MARGIN; + let gap = self.style.gap; + // A split frame's width is its share of the room, not one of its own. + let saved = match self.place(panel) { + Some(Place::Floating { .. }) => now.iter().find(|(node, _)| holds(node, panel)).map(|(_, rect)| along(rect.size())), + Some(place @ Place::Dock { .. }) if axis == SplitAxis::Horizontal => self.layout.column(place).map(|column| column.width), + Some(place @ Place::Dock { .. }) => self.layout.group(place).and_then(|group| group.height), + _ => None, + }; + let most = along(halved.size()) + room - frame - gap; + if least > most { + return None; + } + let size = saved.filter(|width| (least..=most).contains(width)).unwrap_or(least); + // The anchor's side of the outer split grows by what the halves did not take already. + let grow = frame + gap + size - along(halved.size()); + let first = along(a.1.size()) + if in_second { -grow } else { grow }; + let outer = super::sizes::resized(*outer_size, first, along(a.1.size()) + along(b.1.size()))?; + let size = super::sizes::resized(SplitSize::Ratio(0.5), if start { size } else { frame }, frame + size)?; + Some(Placement::SplitSized { zone, size, outer }) + } + + /// Every group of `layout`'s trees as drawn, with its rect. + fn drawn_groups<'l>(&self, layout: &'l Layout

) -> Vec<(&'l Node

, Rect)> { + let sizer = self.sizer(); + let mut out = Vec::new(); + for (root, area) in self.trees(layout) { + let mut drawn = Vec::new(); + sizer.groups(root, area, &mut drawn); + out.extend(drawn.into_iter().map(|d| (d.node, d.rect))); + } + out + } + + /// `proposal`, marked a no-op when `action` at it would leave every panel where it is, + /// changing only sizes ([`Layout::same_arrangement`]): a lone group dropped beside the + /// group it already borders, or a tab on the edge its frame already holds. Such a drop + /// shows no target and changes nothing. + fn settled(&self, mut proposal: DropProposal

, action: impl Fn(Target

) -> Action

) -> DropProposal

{ + if !proposal.noop { + let mut candidate = self.layout.clone(); + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + proposal.noop = candidate.apply_with_permissions(action(to), |p| self.permissions(p)).is_ok() && candidate.same_arrangement(self.layout); + } + proposal + } + + /// 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, or where the drop would change only sizes, + /// nothing changes; a floating group released over itself or a window's title bar 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; + } + // A no-op anchored on another group would change only sizes. + if proposal.is_some_and(|p| !floating || !drag.members.contains(&p.anchor)) { + 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. + /// Returns whether it highlighted a drop target. + pub(super) fn paint_group_drag(&mut self, ui: &Ui, drag: &GroupDrag

, pointer: Pos2, float_only: bool) -> 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 false; + } + if let Some((action, drop)) = self.dock_group_proposal(drag, pointer) { + let highlighted = action.is_some(); + if let Some(action) = action { + self.paint_dock_preview(ctx, &painter, action, &drag.members, &drop); + if drag.gesture != GroupGesture::Window { + self.paint_float_hint(ctx, pointer); + } + } + return highlighted; + } + 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 { + if zones { + self.paint_marker(&painter, &proposal); + } else { + let split = matches!(proposal.placement, Placement::Split(zone) | Placement::SplitSized { zone, .. } | Placement::Edge(zone) if zone != Zone::Center); + self.paint_preview(ctx, &painter, &proposal, split, &drag.members, |to| group_action(drag, to)); + } + if drag.gesture != GroupGesture::Window { + self.paint_float_hint(ctx, pointer); + } + return true; + } + false + } + + 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, drop)) = 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 let Some(action) = action { + let painter = self.preview_painter(ctx); + self.paint_dock_preview(ctx, &painter, action, std::slice::from_ref(&drag.panel), &drop); + 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> { + let action = |to| tab_action(panel, to); + match self.extra_proposal(std::slice::from_ref(panel), pointer, false) { + Some(proposal) => Some(self.settled(proposal, action)), + None => self.resolve_roomy(panel, pointer, &self.targets, action), + } + } + + /// 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 && !zones { + self.paint_preview(ctx, painter, proposal, true, std::slice::from_ref(&drag.panel), |to| tab_action(&drag.panel, to)); + } else { + self.paint_marker(painter, proposal); + } + if !proposal.noop { + self.paint_float_hint(ctx, pointer); + } + } + + /// The preview of a group target, for tab and group drags of `members`, which `action` moves + /// there: for a drop beside the group, the rect the members then take + /// ([`Self::result_rect`]); on the layout's outer edge band, the line along that edge; else + /// the tab outline of the whole group, and the insertion marker on a strip. With + /// [`crate::docking::DockStyle::side_edge_zones`], the region the drop takes, filled for a + /// tab drag (`filled`) or a split. + fn paint_preview( + &self, + ctx: &Context, + painter: &egui::Painter, + proposal: &DropProposal

, + filled: bool, + members: &[P], + action: impl Fn(Target

) -> Action

, + ) { + if crate::docking::targets::usable(proposal.preview) { + let beside = matches!(proposal.placement, Placement::Split(zone) | Placement::SplitSized { zone, .. } if zone != Zone::Center); + if self.style.side_edge_zones { + drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, filled); + } else if matches!(proposal.placement, Placement::Edge(_)) { + self.paint_drop_line(ctx, proposal.preview, true); + } else if beside { + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + match self.result_rect(action(to), members) { + Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent), + None => self.paint_drop_line(ctx, proposal.preview, false), + } + } else { + tab_outline(painter, proposal.preview.shrink(1.0), self.style.accent); + } + } + self.paint_marker(painter, proposal); + } + + /// The preview of a dock drop that `action` makes of `members`: the rect they then take + /// ([`Self::result_rect`]). A drop on the window edge ([`DockDrop::outer`]) or into an + /// iconic column, and any dock drop with [`crate::docking::DockStyle::side_edge_zones`], + /// shows the drop's line. + fn paint_dock_preview(&self, ctx: &Context, painter: &egui::Painter, action: Action

, members: &[P], drop: &DockDrop

) { + let rect = (!self.style.side_edge_zones && !drop.outer).then_some(action).and_then(|action| self.result_rect(action, members)); + match rect { + Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent), + None => self.paint_drop_line(ctx, drop.line, drop.outer), + } + } + + /// Where `members` are drawn once `action` is applied, laid out by the code that draws the + /// layout: the union of the rects of the groups that hold them, or the rect of the dock + /// column they make up alone. A floating window's tree is laid out in its body as drawn + /// this frame. In a dock column that scrolls, a group's rect sits at the column's scroll + /// offset this frame and is clipped to the column's body. `None` when the model rejects `action`, or when the members join an iconic column, + /// whose icons show no rect. + pub(super) fn result_rect(&self, action: Action

, members: &[P]) -> Option { + let mut candidate = self.layout.clone(); + candidate.apply_with_permissions(action, |p| self.permissions(p)).ok()?; + let moved = |panels: &[P]| panels.iter().any(|p| members.contains(p)); + let sizer = self.sizer(); + let mut rects = Vec::new(); + for (root, area) in self.drawn_trees(&candidate, members) { + let mut drawn = Vec::new(); + sizer.groups(root, area, &mut drawn); + for Drawn { node, rect, .. } in drawn { + let Node::Dock { side, columns } = node else { + if members.iter().any(|p| holds(node, p)) { + rects.push(rect); + } + continue; + }; + for (index, _, rect) in self.dock_columns(*side, columns, rect).rects { + let Some(column) = columns.get(index).filter(|c| c.groups.iter().any(|g| moved(&g.panels))) else { continue }; + if column.groups.iter().flat_map(|g| &g.panels).all(|p| members.contains(p)) { + rects.push(rect); + continue; + } + if column.iconic { + return None; + } + let (_, body, heights) = self.column_layout(column, rect); + // The column keeps the scroll state of its first panel, as drawn. + let overflow = (heights.iter().sum::() - body.height()).max(0.0); + let scroll = column.groups.first().and_then(|g| g.panels.first()).and_then(|p| self.column_scroll.get(p)); + let area = body.translate(vec2(0.0, -scroll.copied().unwrap_or(0.0).min(overflow))); + let groups = column.groups.iter().zip(super::dock::stacked(area, &heights)); + rects.extend(groups.filter(|(g, _)| moved(&g.panels)).map(|(_, rect)| rect.intersect(body))); + } + } + } + rects.into_iter().filter(|r| crate::docking::targets::usable(*r)).reduce(Rect::union) + } + + /// [`Self::trees`] of `layout`, where a floating window that holds a panel other than + /// `members` is laid out in the body that window is drawn in this frame. + fn drawn_trees<'l>(&self, layout: &'l Layout

, members: &[P]) -> Vec<(&'l Node

, Rect)> { + let body = |window: &crate::docking::Floating

| { + let stays = |p: &&P| !members.contains(*p); + let hit = self.frame.windows.iter().find(|hit| window.panels().filter(stays).any(|p| hit.members.contains(p))); + hit.map_or_else(|| self.window_body(window), |hit| hit.body) + }; + let windows = layout.floating.iter().filter(|w| !w.minimized).map(|w| (&w.root, body(w))); + layout.root.iter().map(|root| (root, self.workspace)).chain(windows).collect() + } + + /// The insertion marker of a drop on a tab strip. + fn paint_marker(&self, painter: &egui::Painter, proposal: &DropProposal

) { + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); + } + } + + /// [`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| tab_action(panel, 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 { + if !self.style.side_edge_zones || self.extra_proposal(&drag.members, pointer, self.looks_through(drag)).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) | Placement::SplitSized { 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, SIDE_EDGE_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); + // A window moving by its title bar fades over it, as over the renderer's own. + ctx.data_mut(|d| d.insert_temp(self.id.with(GROUP_HIGHLIGHTED), 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 move a single tab's drag makes. +fn tab_action(panel: &P, to: Target

) -> Action

{ + match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + } +} + +/// Whether the drawn group `node` holds `panel`. +fn holds(node: &Node

, panel: &P) -> bool { + match node { + Node::Tabs { panels, .. } => panels.contains(panel), + Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel), + Node::Split { .. } | Node::Dock { .. } => false, + } +} + +/// 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..415ecf8d --- /dev/null +++ b/crates/ui/src/docking/render/sizes.rs @@ -0,0 +1,371 @@ +//! 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 let Some(size) = resized(size, first, available) { + out.push((path.to_vec(), size)); + } +} + +/// `size` in its own mode for a first child of `first` out of `available` points. +pub(in crate::docking) fn resized(size: SplitSize, first: f32, available: f32) -> Option { + if available <= 0.0 || !available.is_finite() || !first.is_finite() { + return None; + } + let first = first.clamp(0.0, available); + Some(match size { + SplitSize::Ratio(_) => SplitSize::Ratio((first / available).clamp(0.0, 1.0)), + SplitSize::FixedFirst(_) => SplitSize::FixedFirst(first), + SplitSize::FixedSecond(_) => SplitSize::FixedSecond(available - first), + }) +} + +/// 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..f003ff40 --- /dev/null +++ b/crates/ui/src/docking/split_tests.rs @@ -0,0 +1,1019 @@ +//! 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 left 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.left() + 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)); +} + +/// Properties and a narrow Tools frame side by side, Tools `tools` wide in an area `width` +/// wide, with Info floating at the left. +fn beside_tools(width: f32, tools: f32) -> Driver { + let root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(tools), + first: Box::new(tabs(&PROPERTIES)), + second: Box::new(tabs(&[TOOLS])), + }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![INFO], 0, [width + 100.0, 100.0, 280.0, 200.0])] }; + let mut d = driver(layout, Film::default()); + d.area = Rect::from_min_size(Pos2::ZERO, vec2(width, SCREEN.y)); + d.settle(); + d +} + +#[test] +fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() { + let mut d = beside_tools(1000.0, 60.0); + let (tools, properties, info) = (group(&d, TOOLS), group(&d, PROPERTIES[0]), group(&d, INFO)); + let left = zone_shape(tools, 26.0, 40.0, Zone::Left).unwrap(); + let at = pos2(tools.left() + 4.0, tools.center().y + 13.0); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&left), "Tools' left zone is a target"); + let out = d.release(at); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::SplitSized { zone: Zone::Left, .. } }]), + "{:?}", + out.actions + ); + d.settle(); + let (new_tools, new_info, new_properties) = (group(&d, TOOLS), group(&d, INFO), group(&d, PROPERTIES[0])); + assert!((new_tools.width() - tools.width()).abs() < 1.0, "Tools keeps its width: {new_tools:?}"); + assert!((new_info.width() - info.width()).abs() < 1.0, "Info keeps its floating width: {new_info:?}"); + assert!(new_info.right() < new_tools.left() && new_properties.right() < new_info.left()); + assert!(new_properties.width() < properties.width() - 200.0, "Properties gave the room"); +} + +/// Tools first in its row, at the area's left edge: its left side has no divider, so the room +/// comes from beyond its right one. +#[test] +fn a_drop_on_the_outer_side_of_a_frame_too_narrow_to_halve_takes_room_from_the_other_side() { + let root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(60.0), + first: Box::new(tabs(&[TOOLS])), + second: Box::new(tabs(&PROPERTIES)), + }; + let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![INFO], 0, [1100.0, 100.0, 280.0, 200.0])] }; + let mut d = driver(layout, Film::default()); + d.area = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, SCREEN.y)); + d.settle(); + let (tools, properties, info) = (group(&d, TOOLS), group(&d, PROPERTIES[0]), group(&d, INFO)); + // Inside Tools' left zone, past the window edge band. + let at = pos2(tools.left() + 12.0, tools.center().y + 13.0); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(tools, 26.0, 40.0, Zone::Left).unwrap()), "Tools' left zone is a target"); + let out = d.release(at); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::SplitSized { zone: Zone::Left, .. } }]), + "{:?}", + out.actions + ); + d.settle(); + let (new_tools, new_info, new_properties) = (group(&d, TOOLS), group(&d, INFO), group(&d, PROPERTIES[0])); + assert!((new_tools.width() - tools.width()).abs() < 1.0, "Tools keeps its width: {new_tools:?}"); + assert!((new_info.width() - info.width()).abs() < 1.0, "Info keeps its floating width: {new_info:?}"); + assert!(new_info.right() < new_tools.left() && new_tools.right() < new_properties.left()); + assert!(new_properties.width() < properties.width() - 200.0, "Properties gave the room"); +} + +#[test] +fn a_docked_panel_dropped_beside_a_frame_too_narrow_to_halve_takes_its_minimum_width() { + // Info docked below Properties, as wide as it, beside a narrow Tools frame. + let root = Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedSecond(60.0), + first: Box::new(split(SplitAxis::Vertical, 0.6, tabs(&PROPERTIES), tabs(&[INFO]))), + second: Box::new(tabs(&[TOOLS])), + }; + let mut d = driver(Layout { root: Some(root), floating: Vec::new() }, Film::default()); + d.area = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, SCREEN.y)); + d.settle(); + let (tools, properties) = (group(&d, TOOLS), group(&d, PROPERTIES[0])); + let out = d.drag(tab(&d, INFO), pos2(tools.left() + 4.0, tools.center().y + 13.0)); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::SplitSized { zone: Zone::Left, .. } }]), + "{:?}", + out.actions + ); + d.settle(); + let (new_tools, new_info, new_properties) = (group(&d, TOOLS), group(&d, INFO), group(&d, PROPERTIES[0])); + assert!((new_tools.width() - tools.width()).abs() < 1.0, "Tools keeps its width: {new_tools:?}"); + assert!(new_info.width() < 60.0, "Info takes its minimum, not its docked width: {new_info:?}"); + assert!(new_properties.width() > properties.width() - 60.0, "Properties gives only that: {new_properties:?}"); +} + +#[test] +fn a_drop_beside_a_narrow_frame_joins_it_when_no_neighbour_has_room() { + let mut d = beside_tools(130.0, 60.0); + let tools = group(&d, TOOLS); + let at = pos2(tools.left() + 4.0, tools.center().y + 13.0); + let out = d.drag(tab(&d, INFO), at); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) }]), + "{:?}", + out.actions + ); +} + +#[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 shows the rect the panel takes there instead. + let mut d = DockDriver::new(editing(), Film::default()); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = d.screen; + d.settle(); + let at = pos2(timeline.left() + 8.0, timeline.center().y + 13.0); + d.hold(tab(&d, INFO), at); + 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))); + let shapes = d.shapes().to_vec(); + d.release(at); + d.settle(); + let info = group(&d, INFO); + assert_eq!(info.left(), timeline.left()); + let painted = |fill: egui::Color32, stroke: f32| { + shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.rect == info.shrink(1.0) && r.fill == fill && r.stroke.width == stroke)) + }; + let accent = DockStyle::default().accent; + assert!(painted(accent.gamma_multiply(0.08), 0.0) && painted(egui::Color32::TRANSPARENT, 2.0), "Info's rect beside the timeline"); +} + +#[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..a4381c54 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 std::{collections::HashMap, 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,153 @@ 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 (28 pt). It is + /// at most a quarter of the layout's width and height. Not used with + /// [`Self::side_edge_zones`], whose band is 8 pt. + pub edge_band: f32, + /// The split-tree profile. The outer edge band also runs along the left and right edges, + /// where a drop adds a full-height column. A group's side zones are trapezoids + /// [`crate::docking::SIDE_EDGE_ZONE_DEPTH`] deep (see [`crate::docking::drop_zone`]), a drag + /// outlines them, and a split previews the half of the group it takes. Off: only the top and + /// bottom edges add a full-width row, a group's zones are [`Self::side_zone_fraction`] of its + /// body, and every preview is a line or a tab outline. + pub side_edge_zones: bool, + /// Which sides of the layout's outer edge, and of a group that takes no tabs (a canvas) + /// without [`Self::new_dock_zones`], take a drop (all four). With [`Self::new_dock_zones`] + /// a canvas body takes no drop, and without [`Self::side_edge_zones`] the outer left and + /// right edges are new docks, so only top and bottom apply there. A dock-column app whose + /// palettes dock only at the left and right sets [`crate::docking::Sides::NONE`] with + /// [`Self::new_dock_zones`] on. Not used with [`Self::side_edge_zones`]. Groups that take + /// tabs keep all four sides. + pub drop_edges: super::Sides, + /// The share of a group's body, below the tab strip, that means "beside" on each side: of + /// its width from the left and right edges, of its height from the top and bottom edges + /// (one third, at most one half). The middle joins the group as a tab. Not used with + /// [`Self::side_edge_zones`]. + pub side_zone_fraction: f32, + /// Width of the line that a drop on a band along the layout's outer edge (a new dock, a new + /// outermost column or the outer edge band), a drop between the icon groups of an iconic + /// column, and every dock drop with [`Self::side_edge_zones`] preview with, and of the line + /// [`crate::docking::ZoneRule::Fraction`] gives hosts as a side's preview (4 pt, more than + /// zero). Other drops beside a group, column or dock preview the rect the moved panels + /// take. Border bands reach 4 pt past this line into a tab strip. + pub drop_line_width: f32, + /// [D] The docking profile. 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`]). Without + /// [`Self::side_edge_zones`], the body of a frame or floating group that takes no tabs (a + /// canvas) takes no drop: a release there floats as over no target. + 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 centred on the seam between two stacked groups in a dock column + /// (28 pt). + pub seam_band: f32, + /// [D] Width of the band centred on each edge of a dock column, where a drop makes a new + /// column (28 pt). + pub column_edge_band: f32, + /// [D] Width of the band along the docked area's left and right edges, and inside an empty + /// dock, that makes a new dock (28 pt). + 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,20 +183,127 @@ 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: 28.0, + side_edge_zones: false, + drop_edges: super::Sides::ALL, + side_zone_fraction: 1.0 / 3.0, + drop_line_width: 4.0, + new_dock_zones: true, + column_bar_height: 14.0, + seam_band: 28.0, + column_edge_band: 28.0, + new_dock_band: 28.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)) + /// The sides [`Self::drop_edges`] gives in this profile: all four with + /// [`Self::side_edge_zones`]. + pub(super) fn drop_sides(&self) -> super::Sides { + if self.side_edge_zones { super::Sides::ALL } else { self.drop_edges } + } + + /// The sides of a frame or floating group that takes no tabs (a canvas) whose body takes a + /// drop: none in the docking profile ([`Self::new_dock_zones`] without + /// [`Self::side_edge_zones`]), where only the bands along borders dock beside it, else + /// [`Self::drop_sides`]. + pub(super) fn canvas_sides(&self) -> super::Sides { + if self.side_edge_zones || !self.new_dock_zones { self.drop_sides() } else { super::Sides::NONE } + } + + /// The outer edges that take a drop: all four with [`Self::side_edge_zones`], else the top + /// and bottom of [`Self::drop_edges`]. + pub(super) fn outer_edges(&self) -> super::Sides { + let sides = self.drop_sides(); + if self.side_edge_zones { sides } else { super::Sides { left: false, right: false, ..sides } } + } + + /// How a group's body divides into drop zones: [`crate::docking::SIDE_EDGE_ZONE_DEPTH`] deep + /// with [`Self::side_edge_zones`], else [`Self::side_zone_fraction`] of it, with + /// [`Self::drop_line_width`] previews. Pass it to [`crate::docking::resolve_drop_by`]. + pub fn zone_rule(&self) -> super::ZoneRule { + if self.side_edge_zones { + super::ZoneRule::Depth(super::SIDE_EDGE_ZONE_DEPTH) + } else { + super::ZoneRule::Fraction { fraction: self.side_zone_fraction, line: self.drop_line_width } + } + } + + 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.side_zone_fraction, + self.drop_line_width, + self.column_bar_height, + self.seam_band, + self.column_edge_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 + && self.drop_line_width > 0.0 + && self.side_zone_fraction <= 0.5 } } @@ -67,21 +311,309 @@ 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, and how the +/// area draws its outer edge (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)>, + /// The area draws with [`DockStyle::side_edge_zones`]: an outer edge drop wraps the part + /// between the docks, never only the canvas column. + pub(super) side_edge_zones: bool, +} + +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(), side_edge_zones: false } + } + + /// For an area drawn with [`DockStyle::side_edge_zones`] when the area has not drawn yet + /// (a headless run): an outer edge command then wraps what that profile's edge band wraps. + pub fn with_side_edge_zones(mut self, on: bool) -> Self { + self.side_edge_zones = on; + self + } + + /// 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 = layout.shape(); + if now == last.layout.shape() { + return; + } + let mut applied = last.layout; + for action in last.actions { + let _ = applied.apply(action); + } + if now != applied.shape() { + cancel_drag::

(ctx, area); + } +} + +/// 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 +627,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 +639,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 +734,167 @@ 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, except that an app drag in a +/// panel body no longer reaches other OS windows (feature `native-windows`). /// 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(); } + #[cfg(feature = "native-windows")] + super::render::cancel_body_route(ctx, area_id); } -/// One mutable application adapter owns bodies, menus and shaped text, avoiding competing -/// mutable closures. Logical labels remain separate and are used for tab accessibility. +/// Mark the app's drag in a panel body of the area `area_id` as one that carries something, so +/// it reaches the window under the pointer (feature `native-windows`; elsewhere it does +/// nothing). A drag with an egui drag-and-drop payload needs no call. Call it each frame while +/// the drag lasts, from the panel body or before the area shows; a call starts the routing +/// for this frame and the next, and a routing once started lasts until the drag ends. Drags +/// that are never marked (sliders, scrollbars, text selection, canvas pans) stay in their window. +pub fn route_app_drag(ctx: &Context, area_id: Id) { + #[cfg(feature = "native-windows")] + super::render::mark_app_drag(ctx, area_id); + #[cfg(not(feature = "native-windows"))] + let _ = (ctx, area_id); +} + +/// The native window that has the keyboard and a focused text field in its last pass (feature +/// `native-windows`; elsewhere `None`). Not a window drawn under [`DockArea::input_blocked`]. +/// +/// On macOS the menu bar takes key equivalents such as ⌘C and ⌘V before the key window sees +/// them, and an app's menu glue reports them in the main window's input. While this is `Some`, +/// the dock area moves the keys a text field acts on (⌘C, ⌘X, ⌘V, ⌘A, ⌘Z and ⇧⌘Z, ⌘ with the +/// arrows, ⌘Backspace and ⌘Delete, and `Copy`, `Cut` and `Paste` events) from the main window's +/// input to that window's, as [`send_keys`] does; the app's other command keys stay with the +/// main window. A menu that runs commands itself instead of reporting keys can send the text +/// keys to this window with [`send_keys`]. An app whose own `raw_input_hook` turns the main +/// window's ⌘V into `ViewportCommand::RequestPaste` for its text fields skips that while this +/// is `Some`, so the field's window pastes instead. +pub fn typing_window(ctx: &Context) -> Option { + #[cfg(feature = "native-windows")] + return super::render::typing_window(ctx); + #[cfg(not(feature = "native-windows"))] + { + let _ = ctx; + None + } +} + +/// Give `events` to the native window `viewport` at the start of its next input, and repaint +/// it (feature `native-windows`; elsewhere it does nothing). A text field copies and pastes on +/// egui's `Copy`, `Cut` and `Paste` events, not on key presses, so ⌘C and ⌘X presses arrive as +/// `Copy` and `Cut`, and ⌘V asks the window for the clipboard text +/// (`ViewportCommand::RequestPaste`), which arrives as `Paste`. Other events arrive as they are. +/// See [`typing_window`]. +pub fn send_keys(ctx: &Context, viewport: egui::ViewportId, events: Vec) { + #[cfg(feature = "native-windows")] + super::render::send_keys(ctx, viewport, events); + #[cfg(not(feature = "native-windows"))] + let _ = (ctx, viewport, events); +} + +/// 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 + } + /// A native floating window drew its panel bodies (feature `native-windows`): called once + /// per pass of the window, after its last body, with the window's `ui`. An app that + /// publishes its own IME descriptor, outside [`crate::ime::Composer`], publishes it here for + /// the window. Windows drawn in the main window's pass get no call. + fn window_drawn(&mut self, _ui: &mut Ui) {} + /// 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,10 +904,49 @@ 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`]. + /// The header's widgets take their own input, so a drag on them moves nothing. For a widget + /// that should drag (a ≡ button, say), call [`begin_drag`] with the id given to + /// [`DockArea::new`], `active` and the widget's rect when its response reports + /// `drag_started()`; the panel then moves as its tab would. fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {} + /// Width the group's extra tabs want: the host's own tabs, drawn right after the group's + /// panel tabs (and their "…" button), before the drag bar. They fit with the panel tabs: + /// both shrink widest first to [`DockStyle::min_tab_width`] (the extra tabs as one tab); in a + /// row narrower still, the panel tabs overflow and the extra tabs keep that width. Zero draws + /// none. + fn group_extra_tabs_width(&self, _panels: &[P], _active: &P, _floating: bool) -> f32 { + 0.0 + } + /// Draw the extra tabs into `ui.max_rect()`, which can be narrower than + /// [`Self::group_extra_tabs_width`] asked for; the host fits its tabs into it, with an + /// overflow of its own. Their widgets take their own input as the header's do, and a press + /// among them focuses the shown panel. A drop on them adds the panel after the last panel tab. + fn group_extra_tabs(&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); @@ -177,17 +956,195 @@ 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 window_drawn(&mut self, ui: &mut Ui) { + self.inner.window_drawn(ui); + } + 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); + } + fn group_extra_tabs_width(&self, panels: &[P], active: &P, floating: bool) -> f32 { + self.inner.group_extra_tabs_width(panels, active, floating) + } + fn group_extra_tabs(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) { + self.inner.group_extra_tabs(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, + #[cfg(feature = "native-windows")] + window_system: super::render::WindowSystem, } -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, + #[cfg(feature = "native-windows")] + window_system: Default::default(), + } + } + + /// 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. + /// eframe 0.36 keeps winit's event loop, and with it `available_monitors`, to itself, so an + /// eframe application builds the list from the platform's own display API. + 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 + } + + /// A stand-in for the window system that moves native windows and tells the cursor. + #[cfg(all(feature = "native-windows", any(test, feature = "testing")))] + pub(in crate::docking) fn window_system(mut self, window_system: super::render::WindowSystem) -> Self { + self.window_system = window_system; + 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 +1156,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 +1193,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 +1238,105 @@ 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"); + let starts = self.input_blocked && !ui.ctx().data(|d| d.get_temp::(blocked_key).unwrap_or(false)); + if starts { + cancel_drag::

(ui.ctx(), self.id); + } + #[cfg(feature = "native-windows")] + super::render::focus_main_from_native(ui.ctx(), self.id, layout, self.input_blocked, starts); + 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, !style.side_edge_zones); + 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(), + dock_groups: Vec::new(), + column_scroll: HashMap::new(), owned_interactions: Vec::new(), output: DockOutput::default(), + frame, + iconic: super::render::Iconic::new(self.auto_collapse), + float_space: FloatSpace { contents, side_edge_zones: style.side_edge_zones, ..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(), + #[cfg(feature = "native-windows")] + window_system: self.window_system, }; - 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..2ba84165 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -1,9 +1,11 @@ -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. +/// The dock area's docking profile ([`crate::docking::DockStyle::new_dock_zones`]) gives the +/// body of a frame or floating group that rejects tabs no target at all. #[derive(Clone, Debug)] pub struct DropTarget

{ pub anchor: P, @@ -12,6 +14,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,29 +25,155 @@ pub struct DropProposal

{ pub placement: Placement

, pub preview: Rect, pub insertion: Option, + /// The pointer addresses the tab's current insertion slot. The renderer also marks a drop + /// that would leave every panel where it is, changing only sizes. + 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 { +/// Depth of a group's side drop zones with [`crate::docking::DockStyle::side_edge_zones`]: the +/// `depth` that [`drop_zone`], [`zone_shape`] and [`resolve_drop`] take there. +pub const SIDE_EDGE_ZONE_DEPTH: f32 = 40.0; + +/// Width of the band along the layout's outer edge with +/// [`crate::docking::DockStyle::side_edge_zones`], in place of +/// [`crate::docking::DockStyle::edge_band`]. +pub(super) const SIDE_EDGE_BAND: f32 = 8.0; + +/// The picture-frame zones of [`crate::docking::DockStyle::side_edge_zones`]. 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, + }) } -/// Preview of the region occupied after a split or group merge. +/// The zones of the default profile. Tab header (the top `tab_height` of `rect`) is the center. +/// In the body below it, the outer `fraction` of its width from the left or right edge, or of +/// its height from the top or bottom edge, is that side's zone; where two sides' zones meet, the +/// side the pointer is relatively nearer wins. The rest is the center. Without `center` (a group +/// that takes no tabs) the whole body goes to the relatively nearest side, as the dock area has +/// it without [`crate::docking::DockStyle::new_dock_zones`]; with it, such a body is no target. +/// A `fraction` of zero or less leaves only the center; more than one half acts as one half. +/// Outside, non-finite, empty and inverted rectangles have no target. +pub fn body_zone(rect: Rect, tab_height: f32, fraction: f32, center: bool, pointer: Pos2) -> Option { + body_zone_on(rect, tab_height, fraction, center, Sides::ALL, pointer) +} + +/// [`body_zone`] where a body without `center` maps only to the nearest of `sides`, and to no +/// target without any. +pub(super) fn body_zone_on(rect: Rect, tab_height: f32, fraction: f32, center: bool, sides: Sides, pointer: Pos2) -> Option { + if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { + return None; + } + let body = body(rect, tab_height); + if pointer.y < body.top() || !usable(body) || fraction.is_nan() || fraction <= 0.0 { + return Some(Zone::Center); + } + let fraction = fraction.min(0.5); + let (w, h) = (body.width(), body.height()); + let allowed = |zone: &Zone| center || sides.contains(*zone); + let (distance, zone) = [ + ((pointer.x - body.left()) / w, Zone::Left), + ((body.right() - pointer.x) / w, Zone::Right), + ((pointer.y - body.top()) / h, Zone::Top), + ((body.bottom() - pointer.y) / h, Zone::Bottom), + ] + .into_iter() + .filter(|(_, zone)| allowed(zone)) + .min_by(|a, b| a.0.total_cmp(&b.0))?; + Some(if center && distance > fraction { Zone::Center } else { zone }) +} + +/// The preview line of a drop beside `rect` on `zone`'s side: `width` thick along that whole +/// edge, inside `rect`. None for the center. +pub(super) fn edge_line(rect: Rect, zone: Zone, width: f32) -> Option { + if !usable(rect) { + return None; + } + let (w, h) = (width.max(1.0).min(rect.width()), width.max(1.0).min(rect.height())); + Some(match zone { + Zone::Left => Rect::from_x_y_ranges(rect.left()..=(rect.left() + w), rect.y_range()), + Zone::Right => Rect::from_x_y_ranges((rect.right() - w)..=rect.right(), rect.y_range()), + Zone::Top => Rect::from_x_y_ranges(rect.x_range(), rect.top()..=(rect.top() + h)), + Zone::Bottom => Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - h)..=rect.bottom()), + Zone::Center => return None, + }) +} + +/// 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()) +} + +/// The region a split or group merge occupies: the half of `rect` on `zone`'s side, or all of +/// it. [`resolve_drop`] previews with it. pub fn zone_rect(rect: Rect, zone: Zone) -> Option { if !usable(rect) { return None; @@ -57,10 +188,100 @@ 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` +/// ([`SIDE_EDGE_ZONE_DEPTH`] in the docking area): one depth on all four sides, at most a quarter +/// of the body's smaller side. A split previews the half of the group it takes ([`zone_rect`]). +/// Floating groups normally set `allow_splits` to false. These are the split-tree zones; a host +/// that resolves drops for a [`crate::docking::DockStyle`] calls [`resolve_drop_by`] with its +/// [`crate::docking::DockStyle::zone_rule`]. +pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarget

], zone_depth: f32) -> Option> { + resolve_drop_by(panel, pointer, targets, ZoneRule::Depth(zone_depth)) +} + +/// A set of sides: of the layout's outer edge, or of a group's body +/// ([`crate::docking::DockStyle::drop_edges`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sides { + pub left: bool, + pub right: bool, + pub top: bool, + pub bottom: bool, +} + +impl Sides { + pub const ALL: Self = Self { left: true, right: true, top: true, bottom: true }; + pub const NONE: Self = Self { left: false, right: false, top: false, bottom: false }; + pub const LEFT_RIGHT: Self = Self { left: true, right: true, top: false, bottom: false }; + pub const TOP_BOTTOM: Self = Self { left: false, right: false, top: true, bottom: true }; + + /// Whether `zone`'s side is in the set; never the center. + pub fn contains(self, zone: Zone) -> bool { + match zone { + Zone::Left => self.left, + Zone::Right => self.right, + Zone::Top => self.top, + Zone::Bottom => self.bottom, + Zone::Center => false, + } + } +} + +/// How [`resolve_drop_by`] divides a group's body. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ZoneRule { + /// [`drop_zone`] with this depth; a split previews the half it takes. + Depth(f32), + /// [`body_zone`] with this fraction; a split previews a `line` thick line along the + /// group's edge on that side. + Fraction { fraction: f32, line: f32 }, +} + +/// [`resolve_drop`] with the body zones of `rule`: [`crate::docking::DockStyle::zone_rule`] +/// gives the ones the dock area uses. +pub fn resolve_drop_by(panel: &P, pointer: Pos2, targets: &[DropTarget

], rule: ZoneRule) -> Option> { + resolve_drop_on(panel, pointer, targets, rule, Sides::ALL) +} + +/// [`resolve_drop_by`] where a group that takes no tabs splits only on `sides` with +/// [`ZoneRule::Fraction`], and with none is no target. +pub(super) fn resolve_drop_on( + panel: &P, + pointer: Pos2, + targets: &[DropTarget

], + rule: ZoneRule, + sides: Sides, +) -> Option> { if !pointer.is_finite() || targets.len() > super::MAX_PANELS { return None; } @@ -68,26 +289,54 @@ 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 header = header_height(target); + let zone = match rule { + _ if !target.allow_splits => Zone::Center, + ZoneRule::Depth(depth) => drop_zone(target.rect, header, depth, pointer)?, + ZoneRule::Fraction { fraction, .. } => body_zone_on(target.rect, header, fraction, target.accepts_tabs, sides, pointer)?, + }; + 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 +344,87 @@ 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: match rule { + ZoneRule::Fraction { line, .. } if zone != Zone::Center => edge_line(target.rect, zone, line)?, + _ => 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`; `line` is the edge it docks to. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct DockDrop

{ + pub(super) target: DockTarget

, + pub(super) band: Rect, + pub(super) line: Rect, + /// A band along the window edge: a new dock, or a new column outside the outermost one. + /// Its preview is `line`, not the rect the drop takes. + pub(super) outer: bool, +} + +#[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 zone 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()) +} + +/// How far from its line a border band reaches into a tab strip that takes tabs: the rest of +/// the strip joins the group. +pub(super) const STRIP_REACH: f32 = 4.0; + +/// Band and line of a column edge at `x`: the band `band` wide, centred on the edge, and the +/// line `line` wide. +pub(super) fn column_edge(x: f32, ys: Rangef, band: f32, line: f32) -> (Rect, Rect) { + let half = band.max(0.0) * 0.5; + (Rect::from_x_y_ranges((x - half)..=(x + half), ys), vertical_line(x, ys, line)) +} + +pub(super) fn vertical_line(x: f32, ys: Rangef, width: f32) -> Rect { + Rect::from_x_y_ranges((x - width * 0.5)..=(x + width * 0.5), ys) +} + +pub(super) fn horizontal_line(y: f32, xs: Rangef, width: f32) -> Rect { + Rect::from_x_y_ranges(xs, (y - width * 0.5)..=(y + width * 0.5)) } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs new file mode 100644 index 00000000..1d230ecb --- /dev/null +++ b/crates/ui/src/docking/testing.rs @@ -0,0 +1,657 @@ +//! 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, + ime: Option, + 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(), + ime: None, + 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; + host.read_pointer(); + 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(); + #[cfg(feature = "native-windows")] + let window_system = self.host.as_ref().map_or(Default::default(), |host| host.borrow().window_system()); + 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")] + { + dock = dock.window_system(window_system); + } + #[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; + self.ime = full.platform_output.ime; + 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)]) + } + + /// Move the pointer to the desktop point `point` in the native window of `window`, and + /// step. The window gets the point in its coordinates. While the window system moves the + /// window ([`NativeHost::moves_windows`]), the window goes where the pointer keeps its grab. + #[cfg(feature = "native-windows")] + pub fn window_pointer(&mut self, window: u64, point: Pos2) -> DockOutput

{ + let viewport = self.viewport(window); + { + let mut host = self.host(); + let grab = host.os_drag.filter(|(dragged, _)| *dragged == viewport).map(|(_, grab)| grab); + if let Some(target) = host.windows.get_mut(&viewport) { + if let Some(grab) = grab { + target.rect = Rect::from_min_size(point - grab, target.rect.size()); + } + target.events.push(Event::PointerMoved(point - target.rect.min.to_vec2())); + } + } + self.step(Vec::new()) + } + + /// 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 + } + + /// The IME descriptor the main window's last frame published. + pub fn ime(&self) -> Option<&egui::output::IMEOutput> { + self.ime.as_ref() + } + + /// 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, + /// The window system moves a window for `StartDrag` itself, as AppKit does: the window + /// keeps the pointer where it was in it, and reports its place as where the drag began until + /// the release. Meanwhile it tells the cursor and [`Self::held`]. + pub moves_windows: bool, + /// The primary button is down, as the window system tells it with + /// [`Self::moves_windows`]: set by the presses and releases given to windows, and by a test + /// that has the window system report a release before egui does. + pub held: 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)>, + cursor: Option, + /// The window it moves for `StartDrag`, and where the pointer is in it. + os_drag: Option<(egui::ViewportId, egui::Vec2)>, + 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, + /// The IME descriptor its last frame published. + pub ime: Option, + /// Where it reports its content while the window system moves it. + reported: Option, + /// The last pointer position it was given, in its coordinates. + pointer: Option, +} + +#[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, + moves_windows: false, + held: false, + windows: Default::default(), + commands: Vec::new(), + late: Vec::new(), + cursor: None, + os_drag: None, + 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, Rect::from_min_size(window.reported.unwrap_or(window.rect.min), window.rect.size())))), + ) + .collect() + } + + /// What it tells the dock area. + fn window_system(&self) -> super::render::WindowSystem { + let moves = self.moves_windows; + super::render::WindowSystem::Host { cursor: self.cursor, held: moves.then_some(self.held), moves } + } + + /// With [`Self::moves_windows`], the cursor and the button as the pointer events waiting + /// for each window leave them. + fn read_pointer(&mut self) { + if !self.moves_windows { + return; + } + for window in self.windows.values_mut() { + for event in &window.events { + match event { + Event::PointerMoved(pos) => window.pointer = Some(*pos), + Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } => { + window.pointer = Some(*pos); + self.held = *pressed; + } + _ => continue, + } + self.cursor = window.pointer.map(|p| window.rect.min + p.to_vec2()); + } + } + } + + /// 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(), + ime: None, + reported: None, + pointer: None, + }); + 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; + window.ime = full.platform_output.ime; + } + } + + /// End a frame: moves sent in an earlier one land, and a move the window system carries out + /// ends once the button is up. + 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()); + } + } + if !self.held + && let Some((id, _)) = self.os_drag.take() + && let Some(window) = self.windows.get_mut(&id) + { + window.reported = None; + } + } + + /// 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, + egui::ViewportCommand::StartDrag if self.moves_windows && self.held => { + let grab = window.pointer.unwrap_or_default().to_vec2(); + window.reported = Some(window.rect.min); + self.os_drag = Some((*id, grab)); + } + _ => {} + } + } + } + } +} diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index e5841771..0e363276 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,173 @@ 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_edge_zones_are_40_pt_deep_or_a_quarter_of_the_bodys_smaller_side() { + let depth = SIDE_EDGE_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 a_bodys_outer_thirds_are_its_sides_and_the_relatively_nearer_side_wins_at_a_corner() { + let fraction = DockStyle::default().side_zone_fraction; + assert!((fraction - 1.0 / 3.0).abs() < 1e-6); + // A 24 pt header over a 600 x 300 body. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(600.0, 324.0)); + let body = Rect::from_min_max(pos2(10.0, 44.0), rect.max); + let zone = |p| body_zone(rect, 24.0, fraction, true, p); + let y = body.center().y; + assert_eq!(zone(pos2(body.left() + 199.0, y)), Some(Zone::Left)); + assert_eq!(zone(pos2(body.left() + 201.0, y)), Some(Zone::Center)); + assert_eq!(zone(pos2(body.right() - 199.0, y)), Some(Zone::Right)); + assert_eq!(zone(pos2(body.center().x, body.top() + 99.0)), Some(Zone::Top)); + assert_eq!(zone(pos2(body.center().x, body.top() + 101.0)), Some(Zone::Center)); + assert_eq!(zone(pos2(body.center().x, body.bottom() - 99.0)), Some(Zone::Bottom)); + assert_eq!(zone(pos2(body.left() + 2.0, rect.top() + 10.0)), Some(Zone::Center), "the header joins"); + // In a corner: a quarter of the width from the left is further than a fifth of the height + // from the top, a tenth of the width nearer than three tenths of the height. + assert_eq!(zone(pos2(body.left() + 150.0, body.top() + 60.0)), Some(Zone::Top)); + assert_eq!(zone(pos2(body.left() + 60.0, body.top() + 90.0)), Some(Zone::Left)); + // Without a middle, the relatively nearest side takes every point. + let edges = |p| body_zone(rect, 24.0, fraction, false, p); + assert_eq!(edges(body.center() - vec2(10.0, 0.0)), Some(Zone::Left)); + assert_eq!(edges(body.center() - vec2(0.0, 10.0)), Some(Zone::Top)); + assert_eq!(edges(body.center() + vec2(0.0, 10.0)), Some(Zone::Bottom)); + // No fraction: only the middle. Outside or invalid: nothing. + assert_eq!(body_zone(rect, 24.0, 0.0, true, pos2(body.left() + 1.0, y)), Some(Zone::Center)); + assert!(body_zone(rect, 24.0, fraction, true, pos2(0.0, 0.0)).is_none()); + assert!(body_zone(Rect::NOTHING, 24.0, fraction, true, pos2(0.0, 0.0)).is_none()); +} + +#[test] +fn a_drop_beside_a_group_previews_a_line_along_its_edge_and_a_group_without_tabs_has_no_middle() { + let style = DockStyle::from_visuals(&egui::Visuals::dark()); + let rule = style.zone_rule(); + assert_eq!(rule, ZoneRule::Fraction { fraction: 1.0 / 3.0, line: 4.0 }); + assert_eq!(DockStyle { side_edge_zones: true, ..style }.zone_rule(), ZoneRule::Depth(SIDE_EDGE_ZONE_DEPTH)); + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(300.0, 224.0)); + let group = target(2, &[2, 3], rect); + let resolve = |at, group: &DropTarget| resolve_drop_by(&5, at, std::slice::from_ref(group), rule); + let left = resolve(pos2(20.0, 140.0), &group).unwrap(); + assert_eq!(left.placement, Placement::Split(Zone::Left)); + assert_eq!(left.preview, Rect::from_x_y_ranges(10.0..=14.0, rect.y_range()), "the group's whole left edge"); + let bottom = resolve(pos2(160.0, 240.0), &group).unwrap(); + assert_eq!(bottom.placement, Placement::Split(Zone::Bottom)); + assert_eq!(bottom.preview, Rect::from_x_y_ranges(rect.x_range(), 240.0..=244.0)); + let middle = resolve(pos2(160.0, 144.0), &group).unwrap(); + assert_eq!((middle.placement, middle.preview), (Placement::Split(Zone::Center), rect)); + // A canvas: its middle goes to the nearest edge, and its strip takes nothing. + let canvas = DropTarget { accepts_tabs: false, ..group }; + assert_eq!(resolve(pos2(150.0, 144.0), &canvas).unwrap().placement, Placement::Split(Zone::Left)); + assert!(resolve(pos2(90.0, 30.0), &canvas).is_none()); +} + +#[test] +fn a_style_needs_a_drop_line_and_side_zones_of_at_most_half_the_body() { + let style = DockStyle::from_visuals(&egui::Visuals::dark()); + assert!(style.valid()); + assert!(DockStyle { side_zone_fraction: 0.0, ..style.clone() }.valid()); + assert!(DockStyle { side_zone_fraction: 0.5, ..style.clone() }.valid()); + assert!(!DockStyle { side_zone_fraction: 0.6, ..style.clone() }.valid()); + assert!(!DockStyle { drop_line_width: 0.0, ..style }.valid()); } #[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]); } } @@ -299,6 +472,8 @@ fn saved_layout_and_hidden_location_round_trip() { #[cfg(feature = "serde")] #[test] +/// Deserialize an older hidden `Location` without a `sibling` field; the +/// missing list stays empty and the anchor panel is preserved. fn old_hidden_locations_accept_panel_ids_without_a_default_identity() { #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] enum Panel { @@ -314,6 +489,8 @@ fn old_hidden_locations_accept_panel_ids_without_a_default_identity() { assert_eq!(location.anchor, Some(Panel::Layers)); } +/// One 800×600 `DockArea::show` frame with actual body labels. Returns `DockOutput` plus the body +/// scope ids and records the final cursor icon in temp data; clears texture deltas. fn frame(ctx: &Context, layout: &Layout, events: Vec, focused: bool) -> (DockOutput, Vec) { let mut output = DockOutput::default(); let mut ids = Vec::new(); @@ -331,6 +508,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(); @@ -338,6 +517,8 @@ fn frame(ctx: &Context, layout: &Layout, events: Vec, focused: bool) } #[test] +/// Moving a panel from a root tab into a floating window keeps its body scope +/// id across frames. fn renderer_keeps_content_identity_when_panel_moves_into_a_float() { let ctx = Context::default(); let mut layout = Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() }; @@ -350,6 +531,8 @@ fn renderer_keeps_content_identity_when_panel_moves_into_a_float() { } #[test] +/// Escape and window focus loss each discard an in-progress drag; a subsequent release emits no +/// actions. fn cancelling_or_losing_focus_during_a_drag_does_not_move_panels() { let ctx = Context::default(); let layout = sample(); @@ -385,11 +568,14 @@ fn step(ctx: &Context, layout: &mut Layout, events: Vec) -> Vec Event { Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } } #[test] +/// Real pointer input drags a floating panel's tab into the main tab strip, emits a `Move`, removes +/// it from the float, preserves the panel count, and activates it in the target group. fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() { let ctx = Context::default(); let mut layout = sample(); @@ -403,14 +589,41 @@ 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] +/// At wide, narrow and wide-again sizes, every rendered body rect stays horizontally inside the +/// viewport and, at the wide sizes, panel min/max limits bound its width, without mutating the +/// layout's stored preferred widths. fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() { let layout = Layout { root: Some(Node::Split { @@ -469,13 +682,18 @@ fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() { } #[test] +/// A focused bottom-right floating resize grip moves width and height with Right/Down arrows and +/// then loses focus to Tab normally. fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() { let ctx = Context::default(); let mut layout = sample(); 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)]); @@ -485,6 +703,8 @@ fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() { } #[test] +/// A stack divider dragged by pointer grows the open body above and shrinks the adjacent open body +/// while closed entries and unrelated bodies keep their heights; keyboard arrows continue resizing. fn stack_dividers_resize_adjacent_open_bodies_by_pointer_and_keyboard() { let ctx = Context::default(); let mut layout = Layout { @@ -522,9 +742,22 @@ 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] +/// A horizontal split's inner edge inside a scrollable dock remains hit-testable and draggable next +/// to a scrollbar. fn split_inner_edge_remains_draggable_beside_a_scrollbar() { let ctx = Context::default(); let mut layout = Layout { @@ -580,10 +813,12 @@ 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] +/// Closing a panel in a nested vertical/horizontal layout and restoring its +/// saved location reproduces the complete layout. fn restoring_a_panel_beside_a_nested_subtree_preserves_the_complete_workspace() { let mut layout = Layout { root: Some(Node::Split { @@ -607,9 +842,10 @@ fn restoring_a_panel_beside_a_nested_subtree_preserves_the_complete_workspace() } #[test] +/// A layout with only floating windows leaves the caller's available document rect unchanged. 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( @@ -630,6 +866,8 @@ fn floating_only_surface_does_not_allocate_the_callers_document_region() { } #[test] +/// Dragging the floating group grip emits `MoveFloating` and changes the window rect while the +/// group's panels and active panel stay the same; dragging a tab detaches only that panel. fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { let ctx = Context::default(); let mut layout = sample(); @@ -641,8 +879,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 +889,117 @@ 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] +/// A floating group drag cancelled by pointer loss, with or without Escape, restores the +/// original floating rect and emits no group drag or drop. +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 { @@ -666,6 +1010,8 @@ struct CustomContent { } #[test] +/// `cancel_drag` discards a floating group's move before the caller replaces the layout, so later +/// pointer movement and release emit nothing and the replacement stays intact. fn host_cancel_discards_group_drag_before_workspace_replacement() { let ctx = Context::default(); let mut layout = sample(); @@ -698,16 +1044,22 @@ impl DockContent for CustomContent { self.labels.push(logical.to_owned()); ui.painter().layout_no_wrap("Custom visual label".into(), font.clone(), color).into() } + /// Custom visual action label that prefixes the logical text with + /// `Action visual: `. fn action_label(&mut self, ui: &egui::Ui, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { ui.painter().layout_no_wrap(format!("Action visual: {logical}"), font.clone(), color).into() } + /// Record an app menu draw and emit a clickable `App menu` button. fn panel_menu(&mut self, ui: &mut egui::Ui, _: &u32) { self.menus += 1; ui.button("App menu").clicked(); } - fn group_header_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + /// Test custom chrome declares a 40 pt group header width. + fn group_header_width(&self, _: &[u32], _: &u32, _: bool, _: f32) -> f32 { 40.0 } + /// Custom header: records the call, asserts it receives at most the declared 40 pt width, and + /// draws its own glyph. fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { self.headers += 1; assert!(ui.max_rect().width() <= 40.0); @@ -715,6 +1067,8 @@ impl DockContent for CustomContent { } } +/// Run one 800×600 customized dock frame with optional blockers and a +/// programmatic raise of panel 5; returns the output with texture deltas cleared. fn custom_frame( ctx: &Context, layout: &Layout, @@ -738,6 +1092,9 @@ fn custom_frame( } #[test] +/// Custom chrome receives logical labels, draws custom headers and bodies and app-menu +/// popups, while a programmatic raise sets no user focus or focus ring and emits +/// no action. fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() { let ctx = Context::default(); let layout = sample(); @@ -746,11 +1103,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, @@ -771,6 +1129,7 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() { } #[test] +/// An overlay appearing mid-drag blocks the release's float fallback; later frames emit no action. fn overlay_appearing_mid_drag_blocks_release_and_float_fallback() { let ctx = Context::default(); let layout = sample(); @@ -787,6 +1146,7 @@ fn overlay_appearing_mid_drag_blocks_release_and_float_fallback() { } #[test] +/// Escape during a floating group move restores the original floating rect. fn group_move_escape_restores_original_geometry() { let ctx = Context::default(); let mut layout = sample(); @@ -807,6 +1167,8 @@ fn group_move_escape_restores_original_geometry() { } #[test] +/// Custom headers and labels render at 0.75×, 1×, 1.5× and 2×: headers and bodies are produced and +/// panel 6's label is rendered with its logical text. fn custom_headers_and_labels_render_at_small_and_large_scales() { for scale in [0.75, 1.0, 1.5, 2.0] { let ctx = Context::default(); @@ -835,6 +1197,8 @@ fn custom_headers_and_labels_render_at_small_and_large_scales() { } #[test] +/// A protected member blocks whole-group gestures and host drops: drag/drop state stays empty and +/// no `MoveFloating` action is emitted. fn protected_group_member_blocks_whole_group_gesture_and_host_drop() { let ctx = Context::default(); let layout = sample(); @@ -903,6 +1267,8 @@ fn resize_floating_group(permissions: impl Fn(&u32) -> Permissions + Copy) -> Ve } #[test] +/// A floating group's grip resizes a movable group, but a group containing +/// protected panel 6 blocks the `MoveFloating` action. fn protected_group_member_blocks_floating_group_resize() { let moved = |actions: &[Action]| actions.iter().any(|a| matches!(a, Action::MoveFloating { .. })); assert!(moved(&resize_floating_group(|_| Permissions::default())), "the grip resizes a movable group"); @@ -910,6 +1276,8 @@ fn protected_group_member_blocks_floating_group_resize() { } #[test] +/// Chrome focus recognizes a rendered tab but rejects a focused body text field, an invalid layout, +/// an empty layout and an unrendered id. fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid_layout() { let ctx = Context::default(); let layout = sample(); @@ -941,7 +1309,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"))); @@ -949,6 +1317,8 @@ fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid } #[test] +/// Chrome focus recognizes rendered tabs, collapse buttons, stack resizers, split dividers and +/// floating resizers. fn chrome_focus_classifies_rendered_accordion_and_split_and_floating_resizers() { let ctx = Context::default(); let area = Id::unique("focus-chrome-controls"); @@ -986,6 +1356,9 @@ fn chrome_focus_classifies_rendered_accordion_and_split_and_floating_resizers() } #[test] +/// `cancel_drag` ends an owned split/stack/floating resize until a new press: held movement stops +/// producing actions and the layout stays at the cancellation snapshot, then a fresh press resizes +/// again. fn workspace_cancel_stops_owned_resize_controls_until_new_press() { let area = Id::unique("test-area"); let fixtures = [ @@ -1032,6 +1405,8 @@ fn workspace_cancel_stops_owned_resize_controls_until_new_press() { } #[test] +/// Cancelling one area's drag leaves another area's split divider or a body-owned drag in control, +/// and that gesture continues producing its effects. fn workspace_cancel_preserves_other_area_and_body_drag_ownership() { for body_drag in [false, true] { let ctx = Context::default(); @@ -1097,8 +1472,12 @@ fn workspace_cancel_preserves_other_area_and_body_drag_ownership() { } #[test] +/// Custom action labels paint their visual substitution while the popup buttons keep their own +/// logical accessibility names; the visual text is not exposed as an accessibility label. fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { use egui_kittest::{Harness, kittest::Queryable}; + /// Recursively search a shape or shape vector for a text galley whose text is + /// exactly `text`. fn paints(shape: &egui::Shape, text: &str) -> bool { match shape { egui::Shape::Text(shape) => shape.galley.text() == text, @@ -1122,7 +1501,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 +1511,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 +1524,472 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { ); } } + +#[test] +/// At the minimum tab width, only the tabs that fit beside the drag bar and overflow control are +/// shown, the active last tab replaces the last fitting tab, and the overflow menu lists exactly the +/// hidden panels. +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] +/// Long tab labels are truncated with an ellipsis inside their tab and expose the full label in a +/// tooltip on hover. +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] +/// A floating resize keeps drag ownership when another host control rewrites the owned-interaction +/// list; `cancel_drag` then ends it and further movement is ignored. +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] +/// A collapsed 26 pt floating window still resizes its width with the Right arrow under a height +/// cap, growing to the minimum height instead of ignoring the gesture. +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] +/// Dropping a panel back on its own singleton or multi-tab source body is an +/// explicit no-op with no insertion marker, with or without split permission. +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] +/// Dragging each floating resize grip with the pointer past a viewport edge leaves every asserted +/// edge unchanged when the pointer moves partway back while still outside, and returning to the +/// start point restores the original geometry. `resize-right` and `resize` assert the clamped +/// right edge and fixed left edge; `resize-left` and `resize-bottom-left` assert only their fixed +/// right edge. `resize-bottom` asserts the clamped bottom edge and fixed top edge. +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] +/// Dropping a panel within its existing tab slots is a no-op whose insertion +/// marker stays inside the strip; positions outside its own slots move it. +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] +/// A host drag handler that accepts the release as a no-op consumes it, so no fallback float is +/// emitted and the host observes one release. +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) {} + /// Test drop handler that counts releases and accepts every request with + /// default feedback, consuming host-owned drops. + 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] +/// For clipped and singleton strips of several widths, a no-op drop keeps its +/// insertion marker inside the visible strip and at most 2 pt wide. +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] +/// A subpixel-wide tab strip crossing zero still yields a no-op insertion +/// marker contained by the strip. +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] +/// A strip narrower than its tabs paints at least one ellipsis overflow symbol. +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] +/// Replacing the layout during a tab drag ends the drag, so the new layout is not modified. +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..949d9310 --- /dev/null +++ b/crates/ui/src/docking/trial_tests.rs @@ -0,0 +1,497 @@ +//! 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}") + } + /// Host-provided galley label that lays the logical text out without wrapping, + /// letting the dock handle pre-shaped widget text. + 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] +/// Galley-backed labels in shrunk tabs end in an ellipsis and stay wholly inside their tab, both +/// for the active tab and for inactive tabs, at wide and narrow dock widths. +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] +/// Double-clicking the active tab of a minimized floating window or docked +/// group expands it; a second double-click minimizes the docked group again. +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] +/// A pointer click on a tab shows no focus outline even if the platform then focuses it, while +/// keyboard (Tab) focus paints the accent outline. +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)); + + // Space and Enter activate the tab without counting as a pointer click. + for activate in [egui::Key::Space, egui::Key::Enter] { + let out = d.step(vec![key(activate)]); + assert!(out.actions.contains(&Action::Activate { panel: 3 }), "{:?}", out.actions); + d.step(vec![]); + assert!(outlined(&d), "{activate:?} hid the focus outline"); + } +} + +#[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] +/// In a right-to-left strip the first tab sits at the right edge and the bar +/// lies to its left; drops just right of the first tab insert before panel 2, +/// and insertion slots are mirrored at the strip edges. +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] +/// In a right-to-left title bar the close and menu buttons sit on the left, the drag surface ends at +/// the bar's right edge, and a galley title is truncated with an ellipsis inside the bar. +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] +/// With close buttons and close menu items disabled, no close control renders and tab menus contain +/// neither Close Panel nor Close Tab Group; the host can still apply its own CloseWindow action. +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] +/// `apply_all` returns every panel closed by close-others and close-window in +/// order, leaving the remaining panel open until a later `apply` closes it. +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] +/// The column bar button and the column menu item expose distinct accessible names +/// (`Collapse column to icons` versus `Collapse to Icons`). +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..c3711a50 --- /dev/null +++ b/crates/ui/src/docking/workspace.rs @@ -0,0 +1,325 @@ +//! 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::SplitSized { zone, .. } => match zone { + Zone::Left => (Placement::Split(Zone::Right), location.size), + Zone::Right => (Placement::Split(Zone::Left), location.size), + Zone::Top => (Placement::Split(Zone::Bottom), location.size), + Zone::Bottom => (Placement::Split(Zone::Top), location.size), + Zone::Center => return 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/fonts/coverage.rs b/crates/ui/src/fonts/coverage.rs new file mode 100644 index 00000000..c247dd0b --- /dev/null +++ b/crates/ui/src/fonts/coverage.rs @@ -0,0 +1,131 @@ +//! The coverage check each app runs over its shipped translations. + +use std::fmt; + +use egui::{FontFamily, FontId}; + +use super::Stack; + +/// A string the UI shows: one translation, or a sample of content text. +#[derive(Clone, Copy, Debug)] +pub struct Sample<'a> { + /// The language tag the string belongs to. + pub language: &'a str, + /// The message key, or a name for a content sample. + pub key: &'a str, + pub text: &'a str, +} + +/// A character the stack can't draw. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Missing { + pub language: String, + pub key: String, + pub text: String, + pub character: char, +} + +impl fmt::Display for Missing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}: U+{:04X} {:?} in {:?}", self.language, self.key, u32::from(self.character), self.character, self.text) + } +} + +/// Every character in `samples` that no face of `family` in `stack` has. `with_system` also asks +/// the stack's system font provider (the `system_fonts` feature, or [`super::UiFonts::system_provider`]), +/// for an app that relies on installed fonts for some languages. +/// +/// Whitespace, control characters and invisible format characters (joiners, direction marks, +/// variation selectors) are skipped: they draw nothing. +pub fn missing_glyphs<'a>(stack: &Stack, family: &FontFamily, samples: impl IntoIterator>, with_system: bool) -> Vec { + let mut fonts = stack.fonts(with_system); + let font_id = FontId::new(14.0, family.clone()); + let mut missing = Vec::new(); + for sample in samples { + let mut seen = Vec::new(); + for c in sample.text.chars().filter(|c| draws(*c)) { + if !seen.contains(&c) && !fonts.has_glyph(&font_id, c) { + seen.push(c); + missing.push(Missing { + language: sample.language.to_owned(), + key: sample.key.to_owned(), + text: sample.text.to_owned(), + character: c, + }); + } + } + } + missing +} + +/// Fail, naming each language, string and character, if the stack can't draw all of `samples` +/// in the proportional family without system fonts. For an app's coverage test. +pub fn assert_covers<'a>(stack: &Stack, samples: impl IntoIterator>) { + fail_on(&missing_glyphs(stack, &FontFamily::Proportional, samples, false)); +} + +/// [`assert_covers`] with the stack's system font provider asked too. The result depends on the +/// machine's fonts; use it for languages the app leaves to them. +pub fn assert_covers_with_system<'a>(stack: &Stack, samples: impl IntoIterator>) { + fail_on(&missing_glyphs(stack, &FontFamily::Proportional, samples, true)); +} + +#[allow(clippy::panic, reason = "a test assertion")] +fn fail_on(missing: &[Missing]) { + if !missing.is_empty() { + let lines: Vec = missing.iter().take(50).map(|m| format!(" {m}")).collect(); + let more = if missing.len() > lines.len() { format!("\n … and {} more", missing.len() - lines.len()) } else { String::new() }; + panic!("the UI fonts can't draw {} characters:\n{}{more}", missing.len(), lines.join("\n")); + } +} + +/// A few common letters of an ISO 15924 script, for [`Stack::covers_language`]. `None` for a +/// script without a sample, which counts as covered. +pub(super) fn script_sample(script: &str) -> Option<&'static str> { + Some(match script { + "Arab" => "عربي", + "Armn" => "Հայերեն", + "Beng" => "বাংলা", + "Bopo" => "ㄅㄆㄇ", + "Cyrl" => "Кирилица", + "Deva" => "हिन्दी", + "Ethi" => "አማርኛ", + "Geor" => "ქართული", + "Grek" => "Ελληνικά", + "Gujr" => "ગુજરાતી", + "Guru" => "ਪੰਜਾਬੀ", + "Hang" => "한국어", + "Hani" => "日本中文", + "Hebr" => "עברית", + "Hira" => "の", + "Kana" => "ファイル", + "Khmr" => "ខ្មែរ", + "Knda" => "ಕನ್ನಡ", + "Laoo" => "ລາວ", + "Latn" => "Latin", + "Mlym" => "മലയാളം", + "Mong" => "ᠮᠣᠩᠭᠣᠯ", + "Mymr" => "မြန်မာ", + "Orya" => "ଓଡ଼ିଆ", + "Sinh" => "සිංහල", + "Syrc" => "ܣܘܪܝܝܐ", + "Taml" => "தமிழ்", + "Telu" => "తెలుగు", + "Thaa" => "ދިވެހި", + "Thai" => "ไทย", + "Tibt" => "བོད་ཡིག", + _ => return None, + }) +} + +/// Whether `c` shows a glyph: not whitespace, a control, a format character or another invisible +/// mark. +pub(super) fn draws(c: char) -> bool { + !(c.is_whitespace() + || c.is_control() + || matches!( + c, + '\u{00AD}' | '\u{034F}' | '\u{061C}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{FEFF}' + ) + || matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}')) +} diff --git a/crates/ui/src/fonts/fit.rs b/crates/ui/src/fonts/fit.rs new file mode 100644 index 00000000..b37af32a --- /dev/null +++ b/crates/ui/src/fonts/fit.rs @@ -0,0 +1,226 @@ +//! Row metrics for the UI language's script face. +//! +//! An app's theme sets `interact_size` for rows of its own face. When the UI language's script face +//! leads the stack and has taller rows, widgets need that much more height; the theme's own height +//! and padding stay as they are. CJK faces that follow the app's face are fitted into its rows +//! instead ([`into_row`]). + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use egui::epaint::text::{Fonts, TextOptions}; +use egui::{FontData, FontDefinitions, FontFamily, FontId, Style, TextStyle, Theme}; +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::{FontRef, MetadataProvider as _}; + +const BASE: &str = "craft-ui base face"; +const SCRIPT: &str = "craft-ui script face"; + +/// The app's face and the UI language's script face, each as its own family. +#[derive(Clone)] +pub(super) struct RowFaces(FontDefinitions); + +impl RowFaces { + pub(super) fn new(base: Arc, script: Arc) -> Self { + let mut definitions = FontDefinitions::empty(); + for (name, data) in [(BASE, base), (SCRIPT, script)] { + definitions.font_data.insert(name.to_owned(), data); + definitions.families.insert(FontFamily::Name(name.into()), vec![name.to_owned()]); + } + for family in [FontFamily::Proportional, FontFamily::Monospace] { + definitions.families.entry(family).or_default(); + } + Self(definitions) + } +} + +/// How much taller a row of the script face is than a row of the app's face, at `style`'s body and +/// button sizes; `0` when it isn't taller. +pub(super) fn extra_height(faces: Option<&RowFaces>, style: &Style) -> f32 { + let Some(RowFaces(definitions)) = faces else { return 0.0 }; + let mut fonts = Fonts::new(TextOptions::default(), definitions.clone()); + let mut view = fonts.with_pixels_per_point(1.0); + let mut row = |size: f32, face: &str| view.row_height(&FontId::new(size, FontFamily::Name(face.into()))); + let extra = text_sizes(style).into_iter().map(|size| row(size, SCRIPT) - row(size, BASE)).fold(0.0_f32, f32::max); + if extra.is_finite() { extra } else { 0.0 } +} + +fn text_sizes(style: &Style) -> Vec { + [TextStyle::Body, TextStyle::Button].iter().filter_map(|s| style.text_styles.get(s)).map(|font| font.size).collect() +} + +/// Tall and deep ideographs, Hangul, kana, bopomofo and corner brackets: the characters a CJK face +/// fitted into a Latin row keeps inside it. +const ROW_SAMPLES: &str = "漢中國體鬱한뷁あぎアギㄅ「」"; + +/// Fit `face`, a CJK fallback after `primary`, into `primary`'s rows: its glyphs sit on +/// `primary`'s baseline and the row keeps `primary`'s height. The face is scaled down only as far +/// as keeps [`ROW_SAMPLES`] inside the row. +pub(super) fn into_row(primary: &FontData, face: &mut FontData) { + let (Some(p), Some(f)) = (VerticalMetrics::of(primary), VerticalMetrics::of(face)) else { return }; + let ascent = primary.tweak.scale * p.ascent; + let height = primary.tweak.scale * p.height(); + let (above, below) = ink_extent(face, ROW_SAMPLES); + let mut scale = 1.0_f32; + if above > ascent { + scale = scale.min(ascent / above); + } + if below > height - ascent { + scale = scale.min((height - ascent) / below); + } + #[allow(unused_imports, reason = "unused when egui has the inherent method")] + use super::SetBaselineFallback as _; + // egui centres a fallback's row in the primary's row without growing it; the offset moves the + // centred baseline onto the primary's. Both are in units of the font size times the scale. + face.tweak.set_baseline_fallback(false); + face.tweak.scale = scale; + let centred = 0.5 * (height - scale * f.height()) + scale * f.ascent; + face.tweak.y_offset_factor = (ascent - centred + primary.tweak.scale * primary.tweak.y_offset_factor) / scale; + face.tweak.y_offset = primary.tweak.y_offset; +} + +/// A face's ascent, descent and line gap as epaint reads them, in ems. +struct VerticalMetrics { + ascent: f32, + descent: f32, + line_gap: f32, +} + +impl VerticalMetrics { + fn of(data: &FontData) -> Option { + let font = FontRef::from_index(data.bytes(), data.index).ok()?; + let metrics = font.metrics(Size::unscaled(), LocationRef::default()); + let em = f32::from(metrics.units_per_em); + (em > 0.0).then(|| Self { ascent: metrics.ascent / em, descent: -metrics.descent / em, line_gap: metrics.leading / em }) + } + + fn height(&self) -> f32 { + self.ascent + self.descent + self.line_gap + } +} + +/// How far the outlines of `text`'s characters in `data` reach above and below the baseline, in +/// ems, from their control points. +pub(super) fn ink_extent(data: &FontData, text: &str) -> (f32, f32) { + let Ok(font) = FontRef::from_index(data.bytes(), data.index) else { return (0.0, 0.0) }; + let em = f32::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + let (charmap, outlines) = (font.charmap(), font.outline_glyphs()); + let mut pen = Extent::default(); + for glyph in text.chars().filter_map(|c| charmap.map(c)).filter_map(|id| outlines.get(id)) { + let _ = glyph.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen); + } + if em > 0.0 { (pen.top / em, -pen.bottom / em) } else { (0.0, 0.0) } +} + +#[derive(Default)] +struct Extent { + top: f32, + bottom: f32, +} + +impl Extent { + fn add(&mut self, y: f32) { + self.top = self.top.max(y); + self.bottom = self.bottom.min(y); + } +} + +impl OutlinePen for Extent { + fn move_to(&mut self, _x: f32, y: f32) { + self.add(y); + } + fn line_to(&mut self, _x: f32, y: f32) { + self.add(y); + } + fn quad_to(&mut self, _cx0: f32, cy0: f32, _x: f32, y: f32) { + self.add(cy0); + self.add(y); + } + fn curve_to(&mut self, _cx0: f32, cy0: f32, _cx1: f32, cy1: f32, _x: f32, y: f32) { + self.add(cy0); + self.add(cy1); + self.add(y); + } + fn close(&mut self) {} +} + +/// A context's fit, kept in its data. +#[derive(Default)] +struct Fitted { + faces: Option, + /// [`extra_height`] by text sizes, for the faces installed. + measured: Vec<(Vec, f32)>, + /// Per theme: the style's own height and the height it was set to. + applied: [Option<(f32, f32)>; 2], +} + +type Shared = Arc>; + +fn id() -> egui::Id { + egui::Id::unique("craft_ui::fonts::fitted") +} + +fn lock(shared: &Shared) -> MutexGuard<'_, Fitted> { + shared.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Fit `ctx`'s styles to `faces` now, and again at the start of every pass after the app sets a +/// style with another height or text size. +pub(super) fn install(ctx: &egui::Context, faces: Option) { + let shared: Shared = ctx.data_mut(|d| Arc::clone(d.get_temp_mut_or_default::(id()))); + { + let mut fitted = lock(&shared); + fitted.faces = faces; + fitted.measured.clear(); + } + ctx.add_plugin(Refit); + refit(ctx, &shared); +} + +fn refit(ctx: &egui::Context, shared: &Shared) { + let mut fitted = lock(shared); + for (i, theme) in [Theme::Dark, Theme::Light].into_iter().enumerate() { + let style = ctx.style_of(theme); + let height = style.spacing.interact_size.y; + // Any other height than the one set last is the app's, e.g. from a theme applied since. + let base = match fitted.applied[i] { + Some((base, set)) if set == height => base, + _ => height, + }; + let sizes = text_sizes(&style); + let extra = match fitted.measured.iter().find(|(s, _)| *s == sizes) { + Some((_, extra)) => *extra, + None => { + let extra = extra_height(fitted.faces.as_ref(), &style); + // Bounded, for an app that changes text sizes often. + if fitted.measured.len() >= 4 { + fitted.measured.remove(0); + } + fitted.measured.push((sizes, extra)); + extra + } + }; + let target = base + extra; + if target != height { + ctx.style_mut_of(theme, |style| style.spacing.interact_size.y = target); + } + fitted.applied[i] = Some((base, target)); + } +} + +/// Refits a context's styles at the start of each pass. +struct Refit; + +impl egui::plugin::Plugin for Refit { + fn debug_name(&self) -> &'static str { + "craft_ui::fonts::Refit" + } + + fn on_begin_pass(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let shared: Option = ctx.data(|d| d.get_temp(id())); + if let Some(shared) = shared { + refit(&ctx, &shared); + } + } +} diff --git a/crates/ui/src/fonts/mod.rs b/crates/ui/src/fonts/mod.rs new file mode 100644 index 00000000..436f773e --- /dev/null +++ b/crates/ui/src/fonts/mod.rs @@ -0,0 +1,1027 @@ +//! The UI font stack every Craft app installs. +//! +//! egui draws a character only if one of its installed faces has it. [`UiFonts`] builds the same +//! fallback order for every app, so a label or file name in any shipped script draws in every app: +//! +//! 1. the app's own faces for the family (UI face, semibold, mono), or egui's default faces for a +//! family the app has none for; +//! 2. craft-fonts faces for the UI language; +//! 3. craft-fonts faces for every other script the app ships, so a Japanese interface still draws +//! Arabic file names; +//! 4. installed system fonts ([`system_font_provider`]), at regular weight ([`RegularWeight`]); +//! 5. emoji faces ([`Emoji`]) and, behind the app's own faces, egui's default faces. +//! +//! When the faces of step 1 don't cover the UI language's main script (Arabic, Thai, Georgian), +//! that script's face goes first. egui takes a row's height from the first face of a family, so +//! the UI language's own face sets the row height and its marks aren't clipped. Han, kana and +//! Hangul sit inside the em square and fit a Latin row, so in Chinese, Japanese and Korean UIs the +//! Latin face stays first, and rows and widgets keep the same height as in a Latin UI: CJK faces +//! sit on the first face's baseline, scaled down only if a glyph would leave the row +//! ([`CjkRows::Fit`]). Monospace, and every family declared [`Family::monospaced`], always keeps +//! its Latin face first. On the suite's egui fork, the other craft faces and installed text faces +//! used as fallbacks sit on the row's baseline and rows grow to fit them by themselves; emoji and +//! symbol faces stay centred. +//! +//! Apps keep their font files: their `build.rs` embeds craft-fonts' faces with `craft-fonts-build`, +//! as [`CraftFace`]s or as `EmbeddedFace`s that convert into them, or, for a web build, copies them +//! beside the wasm and lists them as [`ServedFace`]s, which the stack orders the same way and +//! fetches when they are needed (see [`UiFonts::served_faces`]). For the interface they take +//! `Embed::subset_to(catalogs)`, which embeds or serves the CJK faces as craft-fonts' interface +//! faces (`Noto Sans TC UI`, BIZ UDPGothic Regular), cut at build time to the characters the +//! translation catalogs use. A character a cut face lacks, such as one in a file name, comes from +//! the next face that has it: another region's interface face, then the installed system fonts. +//! [`UiFonts::stack`] returns a [`Stack`] the app can still edit (add, replace and reorder faces) +//! before [`Stack::install`]. + +mod coverage; +mod fit; +mod providers; +mod served; +#[cfg(not(target_arch = "wasm32"))] +#[cfg_attr(not(feature = "system_fonts"), allow(dead_code, reason = "used by the system font index, and its tests"))] +mod system; +#[cfg(all(feature = "system_fonts", not(target_arch = "wasm32")))] +mod system_index; +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use egui::epaint::text::{Fonts, TextOptions}; +use egui::{FontData, FontDefinitions, FontFamily, FontId, FontProvider, Style}; + +pub use coverage::{Missing, Sample, assert_covers, assert_covers_with_system, missing_glyphs}; +use providers::Installed; +pub use providers::{FaceList, RegularWeight, set_weight}; +pub use served::{ServedFace, loading}; + +/// A font file from craft-fonts that the app embeds, one per line of `fonts/manifest.txt`. +#[derive(Clone, Copy, Debug)] +pub struct CraftFace { + /// Family name, e.g. `"Noto Sans Arabic"`. + pub family: &'static str, + /// `"Regular"` or `"Bold"`; faces with other styles are not used for UI text. + pub style: &'static str, + /// The manifest's script tags: ISO 15924 codes such as `Arab`, `Thai`, `Jpan`, `Kore`, `Hans` + /// and `Hant`. Hong Kong forms, which have no ISO 15924 code, are tagged `Hant-HK`. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], + /// Face index in a font collection (`.ttc`); `0` for a single font. + pub index: u32, +} + +impl From for CraftFace { + fn from(face: craft_fonts_build::EmbeddedFace) -> Self { + let craft_fonts_build::EmbeddedFace { family, style, scripts, index, bytes } = face; + Self { family, style, scripts, bytes, index } + } +} + +impl From for ServedFace { + fn from(face: craft_fonts_build::ServedFace) -> Self { + let craft_fonts_build::ServedFace { family, style, scripts, index, url, size, integrity, coverage } = face; + Self { family, style, scripts, index, url, size, integrity, coverage } + } +} + +impl CraftFace { + /// The egui font name, e.g. `"Noto Sans Arabic Regular"`. + pub fn name(&self) -> String { + format!("{} {}", self.family, self.style) + } + + fn data(&self) -> FontData { + baseline_fallback(FontData { index: self.index, ..FontData::from_static(self.bytes) }) + } + + fn region(&self) -> Option { + self.scripts.iter().find_map(|tag| match *tag { + "Hans" => Some(Han::Simplified), + "Hant" => Some(Han::Traditional), + "Hant-HK" => Some(Han::HongKong), + "Jpan" => Some(Han::Japanese), + "Kore" => Some(Han::Korean), + _ => None, + }) + } + + fn covers(&self, script: &str) -> bool { + self.scripts.iter().any(|tag| { + *tag == script + || match *tag { + "Jpan" => matches!(script, "Hani" | "Hira" | "Kana"), + "Kore" => matches!(script, "Hang" | "Hani"), + "Hans" | "Hant" | "Hant-HK" => script == "Hani", + _ => false, + } + }) + } + + /// `0` for the style a family of `weight` wants, `1` for an acceptable stand-in. + fn style_rank(&self, weight: u16) -> Option { + let regular = self.style.eq_ignore_ascii_case("regular"); + let bold = ["bold", "semibold"].iter().any(|s| self.style.eq_ignore_ascii_case(s)); + match (weight >= 600, regular, bold) { + (false, true, _) | (true, _, true) => Some(0), + (true, true, _) => Some(1), + _ => None, + } + } +} + +pub use craft_i18n::Han; + +/// The suite's egui fork has `FontTweak::set_baseline_fallback`, which puts a fallback face's +/// glyphs on the primary face's baseline and grows the row to fit them; crates.io egui has +/// neither. A method call picks an inherent method over a trait method of the same name, so +/// craft faces reach the fork's method there and this no-op everywhere else, without a feature. +#[allow(dead_code, reason = "unused when egui has the inherent method")] +trait SetBaselineFallback { + fn set_baseline_fallback(&mut self, _baseline: bool) {} +} + +impl SetBaselineFallback for egui::FontTweak {} + +/// A craft face's data, set to sit on the primary face's baseline where egui supports it. +fn baseline_fallback(mut data: FontData) -> FontData { + data.tweak.set_baseline_fallback(true); + data +} + +/// The scripts a language is written in, by language tag. [`I18nScripts`] answers from +/// craft-i18n; an app with its own table implements this instead. +pub trait LanguageScripts { + /// ISO 15924 codes of the language's interface text, main script first: `ja` → `Hani`, `Hira`, + /// `Kana`; `ar` → `Arab`. The unions `Jpan`, `Kore`, `Hans` and `Hant` are accepted too. + fn scripts(&self, language: &str) -> Vec<&'static str>; + + /// The regional Han forms the language uses, `None` for a language not written with Han. + fn han(&self, language: &str) -> Option; +} + +/// [`LanguageScripts`] from craft-i18n's script table ([`craft_i18n::scripts`], +/// [`craft_i18n::han`]). +#[derive(Clone, Copy, Debug, Default)] +pub struct I18nScripts; + +impl LanguageScripts for I18nScripts { + fn scripts(&self, language: &str) -> Vec<&'static str> { + craft_i18n::scripts(language).iter().map(|s| s.code()).collect() + } + + fn han(&self, language: &str) -> Option { + craft_i18n::han(language) + } +} + +/// A font family the app uses, beyond what its own faces imply. +#[derive(Clone, Debug)] +pub struct Family { + family: FontFamily, + weight: u16, + lead: Option, + monospaced: bool, +} + +impl Family { + /// A named family, e.g. `Family::named("semibold").weight(600)`. + pub fn named(name: &str) -> Self { + Self { family: FontFamily::Name(name.into()), weight: 400, lead: None, monospaced: false } + } + + /// egui's proportional family. + pub fn proportional() -> Self { + Self { family: FontFamily::Proportional, weight: 400, lead: None, monospaced: false } + } + + /// egui's monospace family, e.g. to set its weight. + pub fn monospace() -> Self { + Self { family: FontFamily::Monospace, weight: 400, lead: None, monospaced: true } + } + + /// The weight (`wght`) this family's faces draw at: bold craft faces are used from 600 up, and + /// variable faces are set to this weight. + pub fn weight(mut self, weight: u16) -> Self { + self.weight = weight; + self + } + + /// Put `script`'s faces first, e.g. `Family::named("arabic").script("Arab")` for the + /// right-to-left runs an app shapes itself. + pub fn script(mut self, script: &str) -> Self { + self.lead = Some(script.to_owned()); + self + } + + /// Lay the family out as egui's monospace one, e.g. `Family::named("mono").monospaced()` for + /// an app's own code face: its app face stays first in every UI language, and a family without + /// app faces starts with egui's monospace faces. + pub fn monospaced(mut self) -> Self { + self.monospaced = true; + self + } +} + +/// Emoji faces the app embeds, asked in this order after the system fonts. +/// +/// egui 0.37's default faces have only a few monochrome emoji and lack symbols such as ⊞ ☰ 🔍. +/// On the web there are no system fonts, so emoji draw as boxes unless one of these is set. +#[derive(Clone, Default)] +pub struct Emoji { + /// A colour emoji font, e.g. Noto Color Emoji (COLRv1) on desktop. + pub color: Option, + /// A lighter colour emoji font for web builds, e.g. Twemoji. + pub web: Option, + /// A colour emoji font served beside the wasm, fetched the first time text needs an emoji; + /// asked after `web`. Ignored outside the web. + pub served: Option, + /// A monochrome emoji font, e.g. Noto Emoji; the last resort. + pub monochrome: Option, +} + +/// How rows treat the CJK faces that follow the app's face. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CjkRows { + /// Rows keep the height of the family's first face in every language, and widgets the theme's + /// height. CJK glyphs sit on that face's baseline, scaled down only if they would leave the row. + #[default] + Fit, + /// Rows and widgets take the CJK face's taller line in Chinese, Japanese and Korean UIs. Rows + /// grow only on the suite's egui fork; with crates.io egui only widgets do. + Grow, +} + +/// Where installed system fonts come in the stack. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SystemFonts { + /// After every craft face. + #[default] + AfterCraft, + /// After the UI language's craft faces, before the faces for other scripts. + BeforeCraft, + /// Not used: text draws the same on every machine. + Off, +} + +/// Scripts the app's own UI faces cover unless [`UiFonts::app_scripts`] says otherwise. +const APP_SCRIPTS: &[&str] = &["Latn", "Cyrl", "Grek"]; + +/// Scripts whose characters sit inside the em square, so they fit a row of a Latin face. +const EM_SQUARE_SCRIPTS: &[&str] = &["Hani", "Hira", "Kana", "Hang", "Bopo"]; + +/// craft-fonts' interface face for Japanese; its manifest also lists serif Japanese faces. +const DEFAULT_PREFER: &[(&str, &str)] = &[("Jpan", "BIZ UDPGothic")]; + +struct AppFace { + name: String, + data: FontData, + families: Vec, +} + +/// The configuration of an app's UI fonts. Build it, then [`Self::install`] it, or take its +/// [`Self::stack`] to edit first. +pub struct UiFonts<'a> { + scripts: &'a dyn LanguageScripts, + app_faces: Vec, + egui_faces: Vec<(String, Vec)>, + app_scripts: Vec, + craft_faces: Vec, + /// For each of `craft_faces`, the served face it stands for, whose data is fetched later. + served: Vec>, + languages: Vec, + ui_language: String, + prefer: Vec<(String, String)>, + families: Vec, + emoji: Emoji, + system: SystemFonts, + system_provider: Option>, + cjk_rows: CjkRows, +} + +impl<'a> UiFonts<'a> { + pub fn new(scripts: &'a dyn LanguageScripts) -> Self { + Self { + scripts, + app_faces: Vec::new(), + egui_faces: Vec::new(), + app_scripts: APP_SCRIPTS.iter().map(|s| (*s).to_owned()).collect(), + craft_faces: Vec::new(), + served: Vec::new(), + languages: Vec::new(), + ui_language: "en".to_owned(), + prefer: Vec::new(), + families: Vec::new(), + emoji: Emoji::default(), + system: SystemFonts::default(), + system_provider: None, + cjk_rows: CjkRows::default(), + } + } + + /// One of the app's own faces, heading `families` in the order the app adds them. + pub fn app_face(mut self, name: &str, data: FontData, families: &[FontFamily]) -> Self { + self.app_faces.push(AppFace { name: name.to_owned(), data, families: families.to_vec() }); + self + } + + /// One of egui's default faces (`Hack`, `Ubuntu-Light`, `NotoEmoji-Regular`, + /// `emoji-icon-font`) at the end of `families`' configured faces, ahead of installed system + /// fonts, e.g. Hack in the proportional family for symbols the app's face lacks (⇧, ▸). It + /// needs egui's `default_fonts` feature; a name egui doesn't ship is ignored. + pub fn egui_face(mut self, name: &str, families: &[FontFamily]) -> Self { + self.egui_faces.push((name.to_owned(), families.to_vec())); + self + } + + /// The scripts the app's own faces cover (default `Latn`, `Cyrl`, `Grek`). A UI language whose + /// main script isn't among them gets its craft face ahead of the app's faces, unless the + /// script fits a Latin row (Han, kana, Hangul). + pub fn app_scripts(mut self, scripts: &[&str]) -> Self { + self.app_scripts = scripts.iter().map(|s| (*s).to_owned()).collect(); + self + } + + /// The craft-fonts faces this build embedded; may be empty. + pub fn craft_faces(mut self, faces: &[CraftFace]) -> Self { + self.craft_faces.extend_from_slice(faces); + self.served.resize(self.craft_faces.len(), None); + self + } + + /// The craft-fonts faces a web build serves beside its wasm (see `craft-fonts-build`'s + /// `Embed::serve`); may be empty. They take the places embedded faces would, and on the web + /// [`Stack::install`] fetches them: the UI language's faces at once ([`Stack::ui_downloads`]), + /// others the first time text needs a character that their files map and the faces already + /// installed lack ([`Stack::downloads_for`]). Until a face arrives, text falls back to the + /// faces after it. Outside the web they are never fetched. + pub fn served_faces(mut self, faces: &[ServedFace]) -> Self { + for face in faces { + self.craft_faces.push(face.craft_face()); + self.served.push(Some(*face)); + } + self + } + + /// Every language the app offers, in its menu order. + pub fn languages>(mut self, languages: &[S]) -> Self { + self.languages = languages.iter().map(|l| l.as_ref().to_owned()).collect(); + self + } + + /// The current UI language tag. + pub fn ui_language(mut self, language: &str) -> Self { + language.clone_into(&mut self.ui_language); + self + } + + /// Prefer the craft face of `family` for `script` (a face tag such as `Jpan`), ahead of other + /// faces for the same script and region. A family also names its interface face (`Noto Sans TC` + /// names `Noto Sans TC UI`). Earlier calls win over later ones. + pub fn prefer(mut self, script: &str, family: &str) -> Self { + self.prefer.push((script.to_owned(), family.to_owned())); + self + } + + /// A family to build beyond those the app faces name, or a different weight or lead script for + /// one of them. + pub fn family(mut self, family: Family) -> Self { + self.families.push(family); + self + } + + pub fn emoji(mut self, emoji: Emoji) -> Self { + self.emoji = emoji; + self + } + + /// Whether CJK faces after the app's face keep its row height ([`CjkRows::Fit`], the default) + /// or make rows and widgets taller. + pub fn cjk_rows(mut self, rows: CjkRows) -> Self { + self.cjk_rows = rows; + self + } + + pub fn system_fonts(mut self, system: SystemFonts) -> Self { + self.system = system; + self + } + + /// The provider to use for installed fonts instead of [`system_font_provider`] (with the + /// `system_fonts` feature, on desktop). It is wrapped in [`RegularWeight`]. + pub fn system_provider(mut self, provider: Arc) -> Self { + self.system_provider = Some(provider); + self + } + + /// Build the stack and install it in `ctx`. + pub fn install(&self, ctx: &egui::Context) -> Stack { + let stack = self.stack(); + stack.install(ctx); + stack + } + + /// Build the stack without installing it. + pub fn stack(&self) -> Stack { + let mut stack = Stack { + definitions: FontDefinitions::empty(), + after_system: FaceList::default(), + system: self.system, + system_provider: self.system_provider.clone(), + ui_language: self.ui_language.clone(), + language_scripts: std::iter::once(&self.ui_language) + .chain(&self.languages) + .map(|language| (language.clone(), expand(&self.scripts.scripts(language)))) + .collect(), + weights: Vec::new(), + base_face: None, + script_face: None, + served: Vec::new(), + ui_downloads: Vec::new(), + cjk_rows: self.cjk_rows, + }; + let defaults = FontDefinitions::default(); + let cjk_names: Vec = match self.cjk_rows { + CjkRows::Fit => self.craft_faces.iter().filter(|face| EM_SQUARE_SCRIPTS.iter().any(|s| face.covers(s))).map(CraftFace::name).collect(), + CjkRows::Grow => Vec::new(), + }; + let mut cjk_faces: Vec = Vec::new(); + for family in self.family_specs() { + let default_family = if family.monospaced { &FontFamily::Monospace } else { &family.family }; + let default_chain = defaults.families.get(default_family).or_else(|| defaults.families.get(&FontFamily::Proportional)); + let egui_faces: Vec = + default_chain.into_iter().flatten().filter_map(|name| Some(Face::new(name, (**defaults.font_data.get(name)?).clone()))).collect(); + let mut chain = self.chain(&family, egui_faces); + let cjk = chain.configured.iter().chain(&chain.after).filter(|face| cjk_names.contains(&face.name)); + cjk_faces.extend(cjk.map(|face| face.clone().weighted(family.weight).name)); + chain.configured.extend( + self.egui_faces + .iter() + .filter(|(_, families)| families.contains(&family.family)) + .filter_map(|(name, _)| Some(Face::new(name, (**defaults.font_data.get(name)?).clone()))), + ); + let mut configured: Vec = chain.configured.into_iter().map(|face| stack.add_configured(face.weighted(family.weight))).collect(); + let mut tail: Vec = chain.after.into_iter().map(|face| stack.add_after(face.weighted(family.weight))).collect(); + for face in chain.ui_downloads { + if !stack.ui_downloads.contains(&face) { + stack.ui_downloads.push(face); + } + } + if family.family == FontFamily::Proportional { + stack.base_face = chain.base.map(|face| face.weighted(family.weight).name); + stack.script_face = chain.script.map(|face| face.weighted(family.weight).name); + } + dedup(&mut configured); + tail.retain(|name| !configured.contains(name)); + dedup(&mut tail); + stack.definitions.families.insert(family.family.clone(), configured); + stack.after_system.set_chain(family.family.clone(), tail); + stack.weights.push((family.family, family.weight)); + } + stack.fit_into_rows(&cjk_faces); + stack + } + + /// Proportional and Monospace, every family an app face names, then the declared families. + fn family_specs(&self) -> Vec { + let mut specs = vec![Family::proportional(), Family::monospace()]; + for family in self.app_faces.iter().flat_map(|f| &f.families) { + if !specs.iter().any(|s| s.family == *family) { + specs.push(Family { family: family.clone(), weight: 400, lead: None, monospaced: false }); + } + } + for declared in &self.families { + match specs.iter_mut().find(|s| s.family == declared.family) { + Some(spec) => *spec = declared.clone(), + None => specs.push(declared.clone()), + } + } + specs + } + + /// One family's faces. `egui_faces` are egui's defaults for it, which stand in for the app's + /// faces when the app has none for the family. + fn chain(&self, family: &Family, egui_faces: Vec) -> Chain { + let weight = family.weight; + let ui_scripts = expand(&self.scripts.scripts(&self.ui_language)); + let ui_han = self.han(&self.ui_language); + + let app = self.app_faces_for(family); + let own_faces = !app.is_empty(); + let (base, egui_tail) = if own_faces { (app, egui_faces) } else { (egui_faces, Vec::new()) }; + // egui's default faces cover the scripts app faces are assumed to. + let covers = |script: &str| if own_faces { self.app_covers(script) } else { APP_SCRIPTS.contains(&script) }; + + let lead: Vec = expand(family.lead.as_slice()).iter().flat_map(|s| self.candidates(s, ui_han, weight)).collect(); + let language: Vec = ui_scripts.iter().filter(|s| !covers(s)).flat_map(|s| self.candidates(s, ui_han, weight)).collect(); + // Letters of a covered script that the base faces lack, such as Vietnamese ổ and ạ in + // egui's Ubuntu-Light, come from that script's own faces before any other face with them. + // CJK faces carry Latin at CJK proportions, so they don't stand in. + let backup: Vec = ui_scripts + .iter() + .filter(|s| covers(s)) + .flat_map(|s| self.candidates(s, ui_han, weight)) + .filter(|&i| !self.craft_faces[i].covers("Hani")) + .collect(); + let mut others: Vec = Vec::new(); + for tag in self.languages.iter().filter(|l| **l != self.ui_language) { + let han = self.han(tag); + for script in expand(&self.scripts.scripts(tag)).iter().filter(|s| !covers(s)) { + others.extend(self.candidates(script, han, weight)); + } + } + let mut rest: Vec<(u8, usize)> = + self.craft_faces.iter().enumerate().filter_map(|(i, face)| face.style_rank(weight).map(|rank| (rank, i))).collect(); + rest.sort(); + others.extend(rest.into_iter().map(|(_, i)| i)); + + let main_needs_lead = ui_scripts.first().is_some_and(|main| !covers(main) && !EM_SQUARE_SCRIPTS.contains(&main.as_str())); + let language_first = !family.monospaced && main_needs_lead; + let craft = |indices: Vec| indices.into_iter().map(|i| self.craft_face(i)).collect::>(); + // A face that only follows the app's face keeps the app's rows unless the app asks otherwise. + let script = (language_first || self.cjk_rows == CjkRows::Grow).then(|| language.first().map(|i| self.craft_face(*i))).flatten(); + // The best face for each of the UI language's scripts, the ones a web build fetches first. + let ui_downloads = ui_scripts + .iter() + .filter(|s| !covers(s)) + .filter_map(|s| self.candidates(s, ui_han, weight).first().map(|i| self.craft_face(*i))) + .filter(|face| face.served.is_some()) + .map(|face| face.name) + .collect(); + let base_face = base.first().cloned(); + + let mut configured = craft(lead); + if language_first { + configured.extend(craft(language)); + configured.extend(base); + configured.extend(craft(backup)); + } else { + configured.extend(base); + configured.extend(craft(backup)); + configured.extend(craft(language)); + } + let mut after = Vec::new(); + match self.system { + SystemFonts::BeforeCraft => after.extend(craft(others)), + SystemFonts::AfterCraft | SystemFonts::Off => configured.extend(craft(others)), + } + let emoji = |name: &str, data: &Option| Some(Face::new(name, data.clone()?)); + after.extend(emoji("emoji-color", &self.emoji.color)); + after.extend(emoji("emoji-web", &self.emoji.web)); + after.extend(self.emoji.served.map(|face| { + let face = ServedFace { scripts: &[served::EMOJI], ..face }; + Face { served: Some(face), ..Face::new(&face.name(), FontData::from_static(&[])) } + })); + after.extend(emoji("emoji-monochrome", &self.emoji.monochrome)); + after.extend(egui_tail); + Chain { configured, after, base: base_face, script, ui_downloads } + } + + /// The app faces heading `family`; a named family without its own takes the monospace ones + /// if it is [`Family::monospaced`], the proportional ones otherwise. + fn app_faces_for(&self, family: &Family) -> Vec { + let faces = |family: &FontFamily| -> Vec { + self.app_faces.iter().filter(|face| face.families.contains(family)).map(|face| Face::new(&face.name, face.data.clone())).collect() + }; + match faces(&family.family) { + own if !own.is_empty() || !matches!(family.family, FontFamily::Name(_)) => own, + _ if family.monospaced => faces(&FontFamily::Monospace), + _ => faces(&FontFamily::Proportional), + } + } + + /// Craft faces for `script`, best first: the right Han region, then the app's preference, + /// then the right style, then manifest order. craft-fonts' own preference ([`DEFAULT_PREFER`]) + /// orders only the faces of the best region found. + fn candidates(&self, script: &str, han: Option, weight: u16) -> Vec { + let found: Vec<(u8, u8, usize)> = self + .craft_faces + .iter() + .enumerate() + .filter(|(_, face)| face.covers(script)) + .filter_map(|(i, face)| { + let style = face.style_rank(weight)?; + let region = if script == "Hani" { region_rank(face.region(), han) } else { 0 }; + Some((region, style, i)) + }) + .collect(); + let best = found.iter().map(|(region, ..)| *region).min(); + let mut ranked: Vec<(u8, usize, u8, usize)> = found + .into_iter() + .map(|(region, style, i)| { + let face = &self.craft_faces[i]; + let app = preference_of(self.prefer.iter().map(|(s, f)| (s.as_str(), f.as_str())), face); + let default = || (Some(region) == best).then(|| preference_of(DEFAULT_PREFER.iter().copied(), face)).flatten(); + let preference = app.or_else(|| default().map(|p| self.prefer.len() + p)).unwrap_or(usize::MAX); + (region, preference, style, i) + }) + .collect(); + ranked.sort(); + ranked.into_iter().map(|(.., i)| i).collect() + } + + fn craft_face(&self, index: usize) -> Face { + let face = self.craft_faces.get(index); + Face { + name: face.map(CraftFace::name).unwrap_or_default(), + data: face.map_or_else(|| FontData::from_static(&[]), CraftFace::data), + served: self.served.get(index).copied().flatten(), + } + } + + fn app_covers(&self, script: &str) -> bool { + self.app_scripts.iter().any(|s| s == script) + } + + fn han(&self, language: &str) -> Option { + self.scripts.han(language).or_else(|| { + self.scripts.scripts(language).iter().find_map(|s| match *s { + "Hans" => Some(Han::Simplified), + "Hant" => Some(Han::Traditional), + "Jpan" => Some(Han::Japanese), + "Kore" => Some(Han::Korean), + _ => None, + }) + }) + } +} + +/// The first of `prefer`'s `(script tag, family)` entries that names `face`, or the family whose +/// interface face `face` is (`Noto Sans TC` names `Noto Sans TC UI`). +fn preference_of<'p>(mut prefer: impl Iterator, face: &CraftFace) -> Option { + let full_family = face.family.strip_suffix(" UI").unwrap_or(face.family); + prefer.position(|(tag, family)| face.scripts.contains(&tag) && (face.family == family || full_family == family)) +} + +/// How well a face's Han region suits the one wanted: lower is better. +fn region_rank(face: Option, wanted: Option) -> u8 { + match (face, wanted) { + (_, None) => 0, + (Some(f), Some(w)) if f == w => 0, + (Some(Han::Traditional), Some(Han::HongKong)) | (Some(Han::HongKong), Some(Han::Traditional)) => 1, + // The other Chinese forms are closer than Japanese or Korean ones. + (Some(Han::Simplified), Some(Han::Traditional | Han::HongKong)) | (Some(Han::Traditional | Han::HongKong), Some(Han::Simplified)) => 2, + (None, Some(_)) => 2, + _ => 3, + } +} + +/// Script codes with the CLDR unions spelled out. +fn expand>(scripts: &[S]) -> Vec { + let mut out: Vec = Vec::new(); + for script in scripts { + let parts: Vec<&str> = match script.as_ref() { + "Jpan" => vec!["Hani", "Hira", "Kana"], + "Kore" => vec!["Hang", "Hani"], + "Hans" | "Hant" => vec!["Hani"], + other => vec![other], + }; + for part in parts { + if !out.iter().any(|s| s == part) { + out.push(part.to_owned()); + } + } + } + out +} + +fn dedup(names: &mut Vec) { + let mut seen = std::collections::BTreeSet::new(); + names.retain(|n| !n.is_empty() && seen.insert(n.clone())); +} + +/// A face with its egui name. +#[derive(Clone)] +struct Face { + name: String, + data: FontData, + /// The served face this stands for; `data` is empty until it is fetched. + served: Option, +} + +/// One family's faces, as [`UiFonts::chain`] orders them. +struct Chain { + configured: Vec, + /// Faces asked after the system fonts. + after: Vec, + /// The app's first face for the family, or egui's when the app has none. + base: Option, + /// The UI language's face for its main script, when the family's first faces lack it and the + /// face leads, or with [`CjkRows::Grow`]. + script: Option, + /// Names of the served faces the UI language needs in this family. + ui_downloads: Vec, +} + +impl Face { + fn new(name: &str, data: FontData) -> Self { + Self { name: name.to_owned(), data, served: None } + } + + /// Set a variable face to `weight`. A face that isn't at regular weight gets its own name, so + /// the regular and bold uses of one file don't collide in egui's face store. + fn weighted(mut self, weight: u16) -> Self { + if set_weight(&mut self.data, weight) && weight != 400 { + self.name = format!("{} @{weight}", self.name); + } + self + } +} + +/// A built font stack: the configured faces and chains, and the providers asked after them. +#[derive(Clone)] +pub struct Stack { + /// The configured faces and each family's chain. Edit it to add, replace or reorder faces + /// before [`Self::install`]; a chain entry without font data is skipped. + pub definitions: FontDefinitions, + /// Faces asked after the system fonts: emoji, egui's defaults and, with + /// [`SystemFonts::BeforeCraft`], the craft faces for other scripts. + pub after_system: FaceList, + system: SystemFonts, + system_provider: Option>, + ui_language: String, + /// The scripts of the UI language and every offered language. + language_scripts: Vec<(String, Vec)>, + weights: Vec<(FontFamily, u16)>, + /// The proportional family's first app face (or egui's), whose rows the app's theme fits. + base_face: Option, + /// The UI language's main script face when it leads the stack, or with [`CjkRows::Grow`]; + /// widgets fit its rows. + script_face: Option, + /// The served faces in the chains, by name; their data is added as it arrives. + served: Vec, + cjk_rows: CjkRows, + /// Names of the served faces to fetch at install. + ui_downloads: Vec, +} + +impl Stack { + /// Install the faces and providers in `ctx`, and fit the minimum widget height to a row of + /// text. Call it again when the UI language changes. + /// + /// The app's theme sets the widget height for rows of the app's face. When the UI language's + /// script face leads the stack (Arabic, Thai) and has taller rows, the context's styles get + /// that much more height ([`Self::fit_interact_size`]), whether the app sets its theme before + /// or after installing: a style the app sets later is fitted at the start of the next pass. A + /// language whose face follows the app's (Chinese, Japanese, Korean, unless + /// [`CjkRows::Grow`]) or has rows no taller than the app's face leaves the theme's height as + /// it is. + /// + /// The stack's system font provider (the `system_fonts` feature on desktop, or + /// [`UiFonts::system_provider`]) replaces the context's font providers, such as the one + /// eframe adds, as does [`SystemFonts::Off`]. Without one, the context keeps its providers and + /// the stack's faces are asked after them. + /// + /// On the web, a stack with [`UiFonts::served_faces`] also fetches them (see there); the faces + /// already fetched for an earlier stack are added at once. + pub fn install(&self, ctx: &egui::Context) { + #[cfg(target_arch = "wasm32")] + if !self.served.is_empty() { + return served::web::install(ctx, self.clone()); + } + self.install_faces(ctx, None); + } + + /// [`Self::install`] without fetching; `miss` is asked for characters no face has. + fn install_faces(&self, ctx: &egui::Context, miss: Option>) { + ctx.set_fonts(self.checked_definitions()); + let id = egui::Id::unique("craft_ui::fonts::installed"); + let (system, start_index) = self.context_lookup(); + let installed: Option> = ctx.data(|d| d.get_temp(id)); + let installed = installed.unwrap_or_else(|| { + let new = Arc::new(Installed::default()); + ctx.data_mut(|d| d.insert_temp(id, Arc::clone(&new))); + if system.is_none() { + ctx.add_font_provider(Arc::clone(&new) as Arc); + } + new + }); + installed.set(self.after_system.clone(), miss); + if let Some(system) = system { + let mut providers = system.into_iter().collect::>(); + providers.push(installed as Arc); + providers::set_font_providers(ctx, providers); + } + if let Some(start) = start_index { + start(ctx); + } + fit::install(ctx, self.row_faces()); + } + + /// Each family's weight, for wrapping another provider in [`RegularWeight`]. + pub fn weights(&self) -> &[(FontFamily, u16)] { + &self.weights + } + + /// The chain of `family`, if the stack has it. + pub fn chain(&self, family: &FontFamily) -> &[String] { + self.definitions.families.get(family).map_or(&[], Vec::as_slice) + } + + /// Insert a face into `family`'s chain at `index` (clamped), adding its data. + pub fn insert(&mut self, family: &FontFamily, index: usize, name: &str, data: FontData) { + self.definitions.font_data.insert(name.to_owned(), Arc::new(data)); + let chain = self.definitions.families.entry(family.clone()).or_default(); + chain.retain(|n| n != name); + chain.insert(index.min(chain.len()), name.to_owned()); + } + + /// Replace the data of the face called `name`, wherever it is used. + pub fn replace(&mut self, name: &str, data: FontData) { + if self.after_system.contains(name) { + self.after_system.insert(name, data.clone()); + } + self.definitions.font_data.insert(name.to_owned(), Arc::new(data)); + } + + /// Remove the face called `name` from every chain, including those asked after the system + /// fonts. + pub fn remove(&mut self, name: &str) { + self.definitions.font_data.remove(name); + for chain in self.definitions.families.values_mut() { + chain.retain(|n| n != name); + } + self.after_system.remove(name); + } + + /// Add to `style`'s minimum interactive height how much taller a row of the UI language's + /// script face is than a row of the app's face, at the style's body and button sizes, when + /// that face leads the stack or [`CjkRows::Grow`] is set. + /// [`Self::install`] does this for the context's styles; this is for a style used elsewhere. + pub fn fit_interact_size(&self, style: &mut Style) { + style.spacing.interact_size.y += fit::extra_height(self.row_faces().as_ref(), style); + } + + /// Whether the proportional family can draw `language`'s scripts, e.g. to leave a language out + /// of the app's menu on a machine that can't show it. `with_system` also asks the system font + /// provider. The scripts come from the stack's [`LanguageScripts`] for the UI language and + /// [`UiFonts::languages`], and from craft-i18n for any other language. Each script is checked + /// with a few of its common letters. A served face counts for the scripts it is tagged with, + /// fetched or not, since it is fetched when text needs it. + pub fn covers_language(&self, language: &str, with_system: bool) -> bool { + let scripts = match self.language_scripts.iter().find(|(l, _)| l == language) { + Some((_, scripts)) => scripts.clone(), + None => expand(&I18nScripts.scripts(language)), + }; + let mut fonts = self.fonts(with_system); + let font_id = FontId::new(14.0, FontFamily::Proportional); + let served = |script: &str| self.served.iter().any(|face| face.craft_face().covers(script)); + scripts.iter().filter(|s| !served(s)).filter_map(|s| coverage::script_sample(s)).flat_map(str::chars).all(|c| fonts.has_glyph(&font_id, c)) + } + + /// The stack as one set of definitions, without providers: each family's chain followed by the + /// faces asked after the system fonts (emoji and egui's default faces), for a context or + /// [`Fonts`] that takes fonts only through `set_fonts`. + pub fn flat_definitions(&self) -> FontDefinitions { + let mut definitions = self.checked_definitions(); + let FontDefinitions { font_data, families, .. } = &mut definitions; + for (family, chain) in families { + for name in self.after_system.chain(family) { + if let Some(data) = self.after_system.get(name) + && !chain.contains(name) + { + font_data.entry(name.clone()).or_insert_with(|| Arc::new(data.clone())); + chain.push(name.clone()); + } + } + } + definitions + } + + /// An epaint [`Fonts`] with this stack, for tests and measurement. `with_system` adds the + /// system font provider. + pub fn fonts(&self, with_system: bool) -> Fonts { + Fonts::new(TextOptions::default(), self.checked_definitions()).with_font_providers(self.providers(with_system)) + } + + /// The app's face and the UI language's script face, when the stack has both. + fn row_faces(&self) -> Option { + let data = |name: &Option| self.definitions.font_data.get(name.as_ref()?).cloned(); + Some(fit::RowFaces::new(data(&self.base_face)?, data(&self.script_face)?)) + } + + fn checked_definitions(&self) -> FontDefinitions { + let mut definitions = self.definitions.clone(); + for chain in definitions.families.values_mut() { + chain.retain(|name| self.definitions.font_data.contains_key(name)); + } + for family in [FontFamily::Proportional, FontFamily::Monospace] { + definitions.families.entry(family).or_default(); + } + definitions + } + + fn providers(&self, with_system: bool) -> Vec> { + let mut providers: Vec> = self.system_lookup(with_system).into_iter().flatten().collect(); + providers.push(Arc::new(self.after_system.clone())); + providers + } + + /// The system font lookup that goes before the stack's own providers: `Some(None)` for none + /// ([`SystemFonts::Off`]), `None` when the stack has no provider of its own and leaves the + /// lookup to whatever the context has. + fn system_lookup(&self, with_system: bool) -> Option>> { + if !with_system || self.system == SystemFonts::Off { + return Some(None); + } + let system = self.system_provider.clone().or_else(|| system_font_provider(&self.ui_language))?; + Some(Some(self.regular_weight(system))) + } + + /// [`Self::system_lookup`] for [`Self::install`]. The default system provider doesn't block + /// the UI thread; the returned function starts its index for the context. + fn context_lookup(&self) -> (Option>>, Option) { + if self.system_provider.is_none() + && self.system != SystemFonts::Off + && let Some((provider, start)) = installed_system_font_provider(&self.ui_language) + { + return (Some(Some(self.regular_weight(provider))), Some(start)); + } + (self.system_lookup(true), None) + } + + /// `provider` at each family's weight, its text faces on the row's baseline. + fn regular_weight(&self, provider: Arc) -> Arc { + let provider: Arc = Arc::new(providers::OnBaseline(provider)); + Arc::new(self.weights.iter().fold(RegularWeight::new(provider), |p, (f, w)| p.with_weight(f.clone(), *w))) + } + + /// Fit the CJK faces `names` into the rows of the proportional family's first face, except where + /// one of them leads a family. + fn fit_into_rows(&mut self, names: &[String]) { + let Some(primary) = self.chain(&FontFamily::Proportional).first().and_then(|name| self.definitions.font_data.get(name)).cloned() else { + return; + }; + let leads = |name: &String| self.definitions.families.values().any(|chain| chain.first() == Some(name)); + let names: Vec<&String> = names.iter().filter(|name| !leads(name)).collect(); + for name in names { + if let Some(data) = self.definitions.font_data.get_mut(name.as_str()) { + fit::into_row(&primary, Arc::make_mut(data)); + } + if let Some(data) = self.after_system.get(name) { + let mut data = data.clone(); + fit::into_row(&primary, &mut data); + self.after_system.insert(name, data); + } + } + } + + fn add_configured(&mut self, face: Face) -> String { + if !self.add_served(&face) { + self.definitions.font_data.entry(face.name.clone()).or_insert_with(|| Arc::new(face.data)); + } + face.name + } + + fn add_after(&mut self, face: Face) -> String { + if self.add_served(&face) { face.name } else { self.after_system.add(face) } + } + + /// Record a served face, whose data comes later; false for any other face. + fn add_served(&mut self, face: &Face) -> bool { + let Some(served) = face.served else { return false }; + if !self.served.iter().any(|s| s.name() == face.name) { + self.served.push(served); + } + true + } +} + +/// A system font provider that prefers `locale`'s fonts, shared by every stack and check that asks +/// for the same locale. Every locale's provider asks one index of the installed fonts, built once +/// on a background thread; a lookup before it is built waits for it. `None` without the +/// `system_fonts` feature and on the web. +/// +/// To ask whether installed fonts can draw a language, pass it to +/// [`UiFonts::system_provider`] and check with [`assert_covers_with_system`] or +/// [`Stack::fonts`]`(true)`. +#[cfg(all(feature = "system_fonts", not(target_arch = "wasm32")))] +pub fn system_font_provider(locale: &str) -> Option> { + use std::collections::BTreeMap; + use std::sync::{Mutex, OnceLock, PoisonError}; + static PROVIDERS: OnceLock>>> = OnceLock::new(); + let mut providers = PROVIDERS.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner); + Some(Arc::clone(providers.entry(locale.to_owned()).or_insert_with(|| system_index::provider(locale, true)))) +} + +/// A system font provider that prefers `locale`'s fonts, shared by every stack and check that asks +/// for the same locale. `None` without the `system_fonts` feature and on the web. +#[cfg(not(all(feature = "system_fonts", not(target_arch = "wasm32"))))] +pub fn system_font_provider(_locale: &str) -> Option> { + None +} + +/// Starts building the system font index for a context. +type StartIndex = fn(&egui::Context); + +/// The system font provider [`Stack::install`] gives `ctx` when the app sets none. A lookup before +/// the index is built finds nothing, so the UI thread doesn't wait; `ctx` reloads its fonts and +/// repaints once it is. +#[cfg(all(feature = "system_fonts", not(target_arch = "wasm32")))] +fn installed_system_font_provider(locale: &str) -> Option<(Arc, StartIndex)> { + Some((system_index::provider(locale, false), system_index::start)) +} + +#[cfg(not(all(feature = "system_fonts", not(target_arch = "wasm32"))))] +fn installed_system_font_provider(_locale: &str) -> Option<(Arc, StartIndex)> { + None +} diff --git a/crates/ui/src/fonts/providers.rs b/crates/ui/src/fonts/providers.rs new file mode 100644 index 00000000..f805b6ab --- /dev/null +++ b/crates/ui/src/fonts/providers.rs @@ -0,0 +1,233 @@ +//! Font providers: embedded faces asked after the system fonts, and the regular-weight wrapper for +//! the system font provider. + +use std::collections::BTreeMap; +use std::sync::{Arc, PoisonError, RwLock}; + +use egui::epaint::text::Tag; +use egui::{FallbackRequest, FontData, FontFamily, FontInsert, FontPriority, FontProvider, InsertFontFamily}; +use skrifa::MetadataProvider as _; + +/// Set a variable face's weight axis to `weight`, within the axis's range. +/// +/// Leaves a face with no weight axis, or one whose weight is already set, as it is. Returns +/// whether it changed the face. Variable system fonts often default to their lightest instance, +/// e.g. Thin (100), so a face installed without this draws as hairlines. +pub fn set_weight(data: &mut FontData, weight: u16) -> bool { + let wght = Tag::new(b"wght"); + if data.tweak.coords.as_ref().iter().any(|(tag, _)| *tag == wght) { + return false; + } + let Some(axis) = data.variation_axes().into_iter().find(|axis| axis.tag == wght) else { return false }; + let value = f32::from(weight).clamp(axis.range.min, axis.range.max); + if value == axis.default { + return false; + } + data.tweak.coords.push(*b"wght", value); + true +} + +/// Wraps a font provider, usually the system font provider, so variable faces it finds draw +/// at each family's weight (regular, 400, unless set with [`Self::with_weight`]). +pub struct RegularWeight { + inner: Arc, + weights: Vec<(FontFamily, u16)>, +} + +impl RegularWeight { + pub fn new(inner: Arc) -> Self { + Self { inner, weights: Vec::new() } + } + + /// Draw faces found for `family` at `weight`, e.g. 600 for a semibold family. + pub fn with_weight(mut self, family: FontFamily, weight: u16) -> Self { + self.weights.retain(|(f, _)| *f != family); + self.weights.push((family, weight)); + self + } + + fn weight(&self, family: &FontFamily) -> u16 { + self.weights.iter().find(|(f, _)| f == family).map_or(400, |(_, w)| *w) + } +} + +impl FontProvider for RegularWeight { + fn fonts_for_family(&self, family: &FontFamily) -> Vec { + let weight = self.weight(family); + self.inner.fonts_for_family(family).into_iter().map(|insert| weighted(insert, weight)).collect() + } + + fn font_for(&self, request: &FallbackRequest<'_>) -> Option { + let insert = self.inner.font_for(request)?; + Some(weighted(insert, self.weight(request.family))) + } +} + +fn weighted(mut insert: FontInsert, weight: u16) -> FontInsert { + // egui's face store keys faces by name, so a face set to another weight needs its own. + if set_weight(&mut insert.data, weight) && weight != 400 { + insert.name = format!("{} @{weight}", insert.name); + } + insert +} + +/// Wraps the system font provider so the text faces it finds sit on the row's baseline as +/// fallbacks, as craft faces do. Emoji and symbol faces stay centred in the row. +pub(super) struct OnBaseline(pub(super) Arc); + +impl FontProvider for OnBaseline { + fn fonts_for_family(&self, family: &FontFamily) -> Vec { + self.0.fonts_for_family(family).into_iter().map(on_baseline).collect() + } + + fn font_for(&self, request: &FallbackRequest<'_>) -> Option { + self.0.font_for(request).map(on_baseline) + } +} + +fn on_baseline(mut insert: FontInsert) -> FontInsert { + if is_text_face(&insert.name, &insert.data) { + #[allow(unused_imports, reason = "unused when egui has the inherent method")] + use super::SetBaselineFallback as _; + insert.data.tweak.set_baseline_fallback(true); + } + insert +} + +/// Whether a face draws text rather than emoji or symbols: it has no colour glyphs, and neither +/// its egui name nor its family name says emoji or symbols. +pub(super) fn is_text_face(name: &str, data: &FontData) -> bool { + let pictorial = |name: &str| { + let name = name.to_lowercase(); + ["emoji", "symbol"].iter().any(|word| name.contains(word)) + }; + if pictorial(name) { + return false; + } + let Ok(font) = skrifa::FontRef::from_index(data.bytes(), data.index) else { return true }; + let colour = [b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|tag| font.table_data(skrifa::Tag::new(tag)).is_some()); + let family = font.localized_strings(skrifa::string::StringId::FAMILY_NAME).english_or_first().map(|s| s.to_string()); + !colour && !family.is_some_and(|family| pictorial(&family)) +} + +/// Embedded faces asked in order, per family, for a character no earlier face or provider has. +#[derive(Clone, Default)] +pub struct FaceList { + faces: BTreeMap, + chains: BTreeMap>, +} + +impl FaceList { + /// The faces asked for `family`, in order. A family without its own list uses the + /// proportional one. + pub fn chain(&self, family: &FontFamily) -> &[String] { + self.chains.get(family).or_else(|| self.chains.get(&FontFamily::Proportional)).map_or(&[], Vec::as_slice) + } + + /// Replace `family`'s list. Names without data from [`Self::add`] are skipped when asked. + pub fn set_chain(&mut self, family: FontFamily, names: Vec) { + self.chains.insert(family, names); + } + + pub(super) fn add(&mut self, face: super::Face) -> String { + self.faces.entry(face.name.clone()).or_insert(face.data); + face.name + } + + /// Each family's list, for renaming entries in place. + pub(super) fn chains_mut(&mut self) -> impl Iterator)> { + self.chains.iter_mut() + } + + /// Add or replace a face's data. + pub fn insert(&mut self, name: &str, data: FontData) { + self.faces.insert(name.to_owned(), data); + } + + /// Whether the list has data for the face called `name`. + pub fn contains(&self, name: &str) -> bool { + self.faces.contains_key(name) + } + + /// The data of the face called `name`, with its tweak. + pub fn get(&self, name: &str) -> Option<&FontData> { + self.faces.get(name) + } + + /// Every face in the list with its name, in name order. + pub fn faces(&self) -> impl Iterator { + self.faces.iter().map(|(name, data)| (name.as_str(), data)) + } + + /// Remove the face called `name` and drop it from every chain. + pub fn remove(&mut self, name: &str) { + self.faces.remove(name); + for chain in self.chains.values_mut() { + chain.retain(|n| n != name); + } + } +} + +impl FontProvider for FaceList { + /// The first face with the cluster's first character, preferring one that also has the + /// cluster's other visible characters: egui shapes the whole cluster with this face. + fn font_for(&self, request: &FallbackRequest<'_>) -> Option { + let mut chars = request.cluster.chars(); + let first = chars.next()?; + let rest: Vec = chars.filter(|&c| super::coverage::draws(c)).collect(); + let faces = + self.chain(request.family).iter().filter_map(|name| Some((name, self.faces.get(name)?))).filter(|(_, data)| has_char(data, first)); + let (name, data) = faces.clone().find(|(_, data)| rest.iter().all(|&c| has_char(data, c))).or_else(|| faces.clone().next())?; + Some(FontInsert::new(name, data.clone(), vec![InsertFontFamily { family: request.family.clone(), priority: FontPriority::Lowest }])) + } +} + +/// A context's installed [`FaceList`], swapped in place when the stack is installed again, so the +/// providers added before it keep their place, and the provider asked when the list misses too +/// (on the web, the one that fetches served faces). +#[derive(Default)] +pub(super) struct Installed(RwLock<(FaceList, Option>)>); + +impl Installed { + pub(super) fn set(&self, faces: FaceList, miss: Option>) { + *self.0.write().unwrap_or_else(PoisonError::into_inner) = (faces, miss); + } +} + +impl FontProvider for Installed { + fn font_for(&self, request: &FallbackRequest<'_>) -> Option { + let (faces, miss) = &*self.0.read().unwrap_or_else(PoisonError::into_inner); + faces.font_for(request).or_else(|| miss.as_ref()?.font_for(request)) + } +} + +/// Whether the face's character map has `c`. +pub(super) fn has_char(data: &FontData, c: char) -> bool { + skrifa::FontRef::from_index(data.bytes(), data.index).is_ok_and(|font| font.charmap().map(c).is_some()) +} + +/// The font providers last set by [`set_font_providers`], for [`reload_font_providers`]. +#[derive(Clone)] +struct ContextProviders(Vec>); + +fn providers_id() -> egui::Id { + egui::Id::unique("craft_ui::fonts::providers") +} + +/// Set `ctx`'s font providers and remember them for [`reload_font_providers`]. +pub(super) fn set_font_providers(ctx: &egui::Context, providers: Vec>) { + ctx.data_mut(|d| d.insert_temp(providers_id(), ContextProviders(providers.clone()))); + ctx.set_font_providers(providers); +} + +/// Set `ctx`'s providers again, so egui forgets the characters they had no font for and repaints. +#[cfg_attr( + not(all(feature = "system_fonts", not(target_arch = "wasm32"))), + allow(dead_code, reason = "called when the shared system font index is built, and in its tests") +)] +pub(super) fn reload_font_providers(ctx: &egui::Context) { + let providers: Option = ctx.data(|d| d.get_temp(providers_id())); + if let Some(ContextProviders(providers)) = providers { + ctx.set_font_providers(providers); + } +} diff --git a/crates/ui/src/fonts/served.rs b/crates/ui/src/fonts/served.rs new file mode 100644 index 00000000..d1cbcd41 --- /dev/null +++ b/crates/ui/src/fonts/served.rs @@ -0,0 +1,345 @@ +//! Faces a web build serves beside its wasm instead of embedding them, fetched when the UI needs +//! them. + +use std::sync::Arc; + +use egui::FontFamily; +use egui::epaint::text::Blob; + +use super::{CjkRows, CraftFace, EM_SQUARE_SCRIPTS, Stack, baseline_fallback, set_weight}; + +#[cfg(any(test, target_arch = "wasm32"))] +pub(super) mod loader; +#[cfg(target_arch = "wasm32")] +pub(super) mod web; + +/// The script tag served emoji faces get: ISO 15924 `Zsye`, emoji. +pub(super) const EMOJI: &str = "Zsye"; +/// ISO 15924 `Zsym`, symbols drawn as text: arrows, keyboard keys, dingbats. +pub(super) const SYMBOLS: &str = "Zsym"; +/// ISO 15924 `Zmth`, mathematical notation. +pub(super) const MATH: &str = "Zmth"; + +/// A craft-fonts face served beside the app, one per line of `fonts/manifest.txt`. The tables that +/// `craft-fonts-build`'s `Embed::serve` writes are slices of these. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ServedFace { + /// Family name, e.g. `"Noto Sans Arabic"`. + pub family: &'static str, + /// `"Regular"` or `"Bold"`; faces with other styles are not used for UI text. + pub style: &'static str, + /// The manifest's script tags, as in [`CraftFace::scripts`]. + pub scripts: &'static [&'static str], + /// Face index in a font collection (`.ttc`); faces of one collection share a URL. + pub index: u32, + /// Where the file is fetched from, relative to the page or absolute. + pub url: &'static str, + /// File size in bytes. + pub size: u64, + /// Subresource Integrity value (`sha256-…`) the browser checks the file against; empty for + /// none. + pub integrity: &'static str, + /// The characters the face maps, in `craft_fonts_build::coverage`'s encoding. A character no + /// served face maps fetches nothing. + pub coverage: &'static [u8], +} + +impl ServedFace { + /// The egui font name, e.g. `"Noto Sans Arabic Regular"`. + pub fn name(&self) -> String { + format!("{} {}", self.family, self.style) + } + + /// Whether the face maps `c`. + pub fn covers(&self, c: char) -> bool { + craft_fonts_build::coverage::contains(self.coverage, c) + } + + /// The face as the stack orders it, without bytes. + pub(super) fn craft_face(&self) -> CraftFace { + CraftFace { family: self.family, style: self.style, scripts: self.scripts, bytes: &[], index: self.index } + } +} + +impl Stack { + /// The served faces in the stack's chains (see [`super::UiFonts::served_faces`]). + pub fn served(&self) -> &[ServedFace] { + &self.served + } + + /// The served faces the UI language needs, which [`Self::install`] fetches at once on the web: + /// for each family, the best face for each of the language's scripts that the family's own + /// faces lack. Empty for a language the app's faces cover. + pub fn ui_downloads(&self) -> Vec<&ServedFace> { + self.ui_downloads.iter().filter_map(|name| self.served_face(name)).collect() + } + + /// The served faces that draw `c` in `family`, in the order the family asks them: those + /// whose file maps `c` ([`ServedFace::covers`]) and that are tagged with `c`'s script (see + /// [`script_of`]). The family's order puts the UI language's faces first, so a Han character + /// takes the face of the UI language's region (Simplified, Traditional, Hong Kong, Japanese or + /// Korean forms), then the other offered languages' and the rest in manifest order. Letters + /// of Latin, Greek and Cyrillic take faces such as Noto Sans, not the CJK faces, which carry + /// those scripts at CJK proportions. Keyboard keys, arrows and other symbols take the symbol + /// faces (`Zsym`), mathematical notation the math faces (`Zmth`) and then the symbol faces, + /// and emoji the emoji faces. Empty for characters of no particular script and for + /// characters no served face maps. + pub fn downloads_for(&self, family: &FontFamily, c: char) -> Vec<&ServedFace> { + self.downloads(family, c.encode_utf8(&mut [0; 4])) + } + + /// [`Self::downloads_for`] the first character of a cluster, whose variation selector + /// chooses between emoji and symbol faces (see [`script_of_cluster`]). + pub(super) fn downloads(&self, family: &FontFamily, cluster: &str) -> Vec<&ServedFace> { + let (Some(c), Some(script)) = (cluster.chars().next(), script_of_cluster(cluster)) else { return Vec::new() }; + let wanted = if script == MATH { [MATH, SYMBOLS].as_slice() } else { std::slice::from_ref(&script) }; + let alphabet = matches!(script, "Latn" | "Grek" | "Cyrl"); + let configured = self.definitions.families.get(family).or_else(|| self.definitions.families.get(&FontFamily::Proportional)); + let mut faces: Vec<&ServedFace> = Vec::new(); + for want in wanted { + for name in configured.into_iter().flatten().chain(self.after_system.chain(family)) { + if let Some(face) = self.served_face(name) + && face.craft_face().covers(want) + && !(alphabet && face.craft_face().covers("Hani")) + && face.covers(c) + && !faces.contains(&face) + { + faces.push(face); + } + } + } + faces + } + + /// Add the file served at `url` to every served face it holds, for [`Self::install`] to + /// install. A face in a family of another weight is set to that weight, and renamed as + /// [`super::UiFonts::stack`] would have. The web build does this for each file it fetches; an + /// app that gets the files some other way can call it itself. + pub fn provide(&mut self, url: &str, file: &Blob) { + let faces: Vec = self.served.iter().filter(|face| face.url == url).copied().collect(); + for face in faces { + let name = face.name(); + let data = baseline_fallback(egui::FontData::from_blob(Arc::clone(file), face.index)); + let weight = |family: &FontFamily| self.weights.iter().find(|(f, _)| f == family).map_or(400, |(_, w)| *w); + let weighted = |family: &FontFamily| { + let mut data = data.clone(); + let weight = weight(family); + let name = if set_weight(&mut data, weight) && weight != 400 { format!("{name} @{weight}") } else { name.clone() }; + (name, data) + }; + let mut configured = Vec::new(); + for (family, chain) in &mut self.definitions.families { + for entry in chain.iter_mut().filter(|entry| **entry == name) { + let (new_name, data) = weighted(family); + if *family == FontFamily::Proportional && self.script_face.as_ref() == Some(entry) { + self.script_face = Some(new_name.clone()); + } + entry.clone_from(&new_name); + configured.push((new_name, data)); + } + } + let mut after = Vec::new(); + for (family, chain) in self.after_system.chains_mut() { + for entry in chain.iter_mut().filter(|entry| **entry == name) { + let (new_name, data) = weighted(family); + entry.clone_from(&new_name); + after.push((new_name, data)); + } + } + let mut names: Vec = configured.iter().map(|(name, _)| name.clone()).collect(); + for (name, data) in configured { + self.definitions.font_data.insert(name, Arc::new(data)); + } + names.extend(after.iter().map(|(name, _)| name.clone())); + for (name, data) in after { + self.after_system.insert(&name, data); + } + if self.cjk_rows == CjkRows::Fit && EM_SQUARE_SCRIPTS.iter().any(|script| face.craft_face().covers(script)) { + self.fit_into_rows(&names); + } + } + } + + fn served_face(&self, name: &str) -> Option<&ServedFace> { + self.served.iter().find(|face| face.name() == name) + } +} + +/// Whether the UI language's served faces are still being fetched, for an app that waits for them +/// before drawing its interface. Always false outside the web, and once every fetch has finished, +/// failed ones included. +#[cfg(target_arch = "wasm32")] +pub fn loading(ctx: &egui::Context) -> bool { + web::loading(ctx) +} + +/// Whether the UI language's served faces are still being fetched: never, outside the web. +#[cfg(not(target_arch = "wasm32"))] +pub fn loading(_ctx: &egui::Context) -> bool { + false +} + +/// The ISO 15924 script a served face must have to draw `c`, by Unicode block. Symbols with an +/// emoji presentation by default (Unicode's `Emoji_Presentation`) map to [`EMOJI`], other +/// symbols, such as ⌘ ⌥ ⇧ ⌃ ⎋ ⏎ ⌫, to [`SYMBOLS`]. `None` for characters of no block listed. +pub(super) fn script_of(c: char) -> Option<&'static str> { + // Sorted by start; ranges don't overlap. + const BLOCKS: &[(u32, u32, &str)] = &[ + (0x0020, 0x036F, "Latn"), + (0x0370, 0x03FF, "Grek"), + (0x0400, 0x052F, "Cyrl"), + (0x0530, 0x058F, "Armn"), + (0x0590, 0x05FF, "Hebr"), + (0x0600, 0x06FF, "Arab"), + (0x0700, 0x074F, "Syrc"), + (0x0750, 0x077F, "Arab"), + (0x0780, 0x07BF, "Thaa"), + (0x0860, 0x086F, "Syrc"), + (0x0870, 0x08FF, "Arab"), + (0x0900, 0x097F, "Deva"), + (0x0980, 0x09FF, "Beng"), + (0x0A00, 0x0A7F, "Guru"), + (0x0A80, 0x0AFF, "Gujr"), + (0x0B00, 0x0B7F, "Orya"), + (0x0B80, 0x0BFF, "Taml"), + (0x0C00, 0x0C7F, "Telu"), + (0x0C80, 0x0CFF, "Knda"), + (0x0D00, 0x0D7F, "Mlym"), + (0x0D80, 0x0DFF, "Sinh"), + (0x0E00, 0x0E7F, "Thai"), + (0x0E80, 0x0EFF, "Laoo"), + (0x0F00, 0x0FFF, "Tibt"), + (0x1000, 0x109F, "Mymr"), + (0x10A0, 0x10FF, "Geor"), + (0x1100, 0x11FF, "Hang"), + (0x1200, 0x139F, "Ethi"), + (0x1780, 0x17FF, "Khmr"), + (0x1800, 0x18AF, "Mong"), + (0x19E0, 0x19FF, "Khmr"), + (0x1C80, 0x1C8F, "Cyrl"), + (0x1C90, 0x1CBF, "Geor"), + (0x1D00, 0x1EFF, "Latn"), + (0x1F00, 0x1FFF, "Grek"), + (0x2000, 0x20CF, "Latn"), + (0x2100, 0x214F, SYMBOLS), + (0x2150, 0x218F, "Latn"), + (0x2190, 0x21FF, SYMBOLS), + (0x2200, 0x22FF, MATH), + (0x2300, 0x27BF, SYMBOLS), + (0x27C0, 0x27EF, MATH), + (0x27F0, 0x297F, SYMBOLS), + (0x2980, 0x2AFF, MATH), + (0x2B00, 0x2BFF, SYMBOLS), + (0x2C60, 0x2C7F, "Latn"), + (0x2D00, 0x2D2F, "Geor"), + (0x2D80, 0x2DDF, "Ethi"), + (0x2DE0, 0x2DFF, "Cyrl"), + (0x2E00, 0x2E7F, "Latn"), + (0x2E80, 0x303F, "Hani"), + (0x3040, 0x309F, "Hira"), + (0x30A0, 0x30FF, "Kana"), + (0x3100, 0x312F, "Bopo"), + (0x3130, 0x318F, "Hang"), + (0x31A0, 0x31BF, "Bopo"), + (0x31C0, 0x31EF, "Hani"), + (0x31F0, 0x31FF, "Kana"), + (0x3200, 0x9FFF, "Hani"), + (0xA640, 0xA69F, "Cyrl"), + (0xA720, 0xA7FF, "Latn"), + (0xA8E0, 0xA8FF, "Deva"), + (0xA960, 0xA97F, "Hang"), + (0xAB00, 0xAB2F, "Ethi"), + (0xAB30, 0xAB6F, "Latn"), + (0xAC00, 0xD7FF, "Hang"), + (0xF900, 0xFAFF, "Hani"), + (0xFB00, 0xFB06, "Latn"), + (0xFB13, 0xFB17, "Armn"), + (0xFB1D, 0xFB4F, "Hebr"), + (0xFB50, 0xFDFF, "Arab"), + (0xFE30, 0xFE4F, "Hani"), + (0xFE70, 0xFEFF, "Arab"), + (0xFF00, 0xFF65, "Hani"), + (0xFF66, 0xFF9F, "Kana"), + (0xFFA0, 0xFFDC, "Hang"), + (0x1D400, 0x1D7FF, MATH), + (0x1F000, 0x1FAFF, EMOJI), + (0x1FB00, 0x1FBFF, SYMBOLS), + (0x20000, 0x3FFFF, "Hani"), + ]; + // The symbols among the BLOCKS' symbols that are emoji by default. + const EMOJI_PRESENTATION: &[(u32, u32)] = &[ + (0x231A, 0x231B), + (0x23E9, 0x23EC), + (0x23F0, 0x23F0), + (0x23F3, 0x23F3), + (0x25FD, 0x25FE), + (0x2614, 0x2615), + (0x2648, 0x2653), + (0x267F, 0x267F), + (0x2693, 0x2693), + (0x26A1, 0x26A1), + (0x26AA, 0x26AB), + (0x26BD, 0x26BE), + (0x26C4, 0x26C5), + (0x26CE, 0x26CE), + (0x26D4, 0x26D4), + (0x26EA, 0x26EA), + (0x26F2, 0x26F3), + (0x26F5, 0x26F5), + (0x26FA, 0x26FA), + (0x26FD, 0x26FD), + (0x2705, 0x2705), + (0x270A, 0x270B), + (0x2728, 0x2728), + (0x274C, 0x274C), + (0x274E, 0x274E), + (0x2753, 0x2755), + (0x2757, 0x2757), + (0x2795, 0x2797), + (0x27B0, 0x27B0), + (0x27BF, 0x27BF), + (0x2B1B, 0x2B1C), + (0x2B50, 0x2B50), + (0x2B55, 0x2B55), + ]; + // The emoji BLOCKS' characters that are symbols, drawn as text by default: mahjong, domino + // and playing card faces, enclosed letters, ornaments, geometric shapes, arrows, chess. + const TEXT_PRESENTATION: &[(u32, u32)] = &[ + (0x1F000, 0x1F003), + (0x1F005, 0x1F0CE), + (0x1F0D0, 0x1F18D), + (0x1F18F, 0x1F190), + (0x1F19B, 0x1F1E5), + (0x1F650, 0x1F67F), + (0x1F780, 0x1F7DF), + (0x1F800, 0x1F8FF), + (0x1FA00, 0x1FA6F), + ]; + let c = u32::from(c); + let script = find(BLOCKS, c, |(start, end, _)| (*start, *end)).map(|(.., script)| *script)?; + Some(match script { + SYMBOLS if find(EMOJI_PRESENTATION, c, |r| *r).is_some() => EMOJI, + EMOJI if find(TEXT_PRESENTATION, c, |r| *r).is_some() => SYMBOLS, + script => script, + }) +} + +/// [`script_of`] the cluster's first character, with an emoji or text variation selector +/// (U+FE0F, U+FE0E) choosing between [`EMOJI`] and [`SYMBOLS`]. +pub(super) fn script_of_cluster(cluster: &str) -> Option<&'static str> { + let script = script_of(cluster.chars().next()?)?; + Some(if cluster.contains('\u{FE0F}') { + EMOJI + } else if script == EMOJI && cluster.contains('\u{FE0E}') { + SYMBOLS + } else { + script + }) +} + +/// The entry of `ranges` (sorted by start, not overlapping) that holds `c`. +fn find(ranges: &[T], c: u32, range: impl Fn(&T) -> (u32, u32)) -> Option<&T> { + let i = ranges.partition_point(|r| range(r).0 <= c); + let entry = ranges.get(i.checked_sub(1)?)?; + (c <= range(entry).1).then_some(entry) +} diff --git a/crates/ui/src/fonts/served/loader.rs b/crates/ui/src/fonts/served/loader.rs new file mode 100644 index 00000000..b8de5dc3 --- /dev/null +++ b/crates/ui/src/fonts/served/loader.rs @@ -0,0 +1,213 @@ +//! Fetching served faces: the UI language's at install, others when egui asks the context's +//! providers for a character no installed face has. The fetching itself is a [`Fetcher`]: the +//! browser's on the web, a fake one in tests. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; + +use egui::epaint::text::{Blob, MissingGlyphPolicy}; +use egui::{FallbackRequest, FontInsert, FontProvider}; + +use crate::fonts::Stack; + +/// Seconds before a failed file is fetched again; each further failure doubles it, up to +/// [`RETRY_MAX`]. +const RETRY_FIRST: f64 = 2.0; +const RETRY_MAX: f64 = 600.0; + +/// Called with a fetched file's bytes, or why it failed. +pub(in crate::fonts) type Done = Box, String>) + Send>; +/// Called when a timer fires. +pub(in crate::fonts) type Wake = Box; + +/// Fetches files and keeps time for a [`Loader`]. +pub(in crate::fonts) trait Fetcher: Send + Sync { + /// Start fetching `url`, checked against `integrity` when not empty, and call `done` with its + /// bytes or why it failed. + fn fetch(&self, url: &'static str, integrity: &'static str, done: Done); + /// Seconds since some fixed time. + fn now(&self) -> f64; + /// Call `wake` once, `seconds` from now. + fn after(&self, seconds: f64, wake: Wake); +} + +/// Install `stack`, add the files fetched so far, and fetch the UI language's faces. +pub(in crate::fonts) fn install(ctx: &egui::Context, mut stack: Stack, fetcher: Arc) { + let loader = Loader::of(ctx, fetcher); + let fetch = { + let mut state = loader.lock(); + let now = loader.fetcher.now(); + for (url, file) in &state.files { + stack.provide(url, file); + } + state.ui_pending.clear(); + let mut fetch: Vec<(&'static str, &'static str)> = Vec::new(); + for face in stack.ui_downloads() { + if state.files.contains_key(face.url) || !state.due(face.url, now) { + continue; + } + state.ui_pending.insert(face.url); + if !fetch.iter().any(|(url, _)| *url == face.url) { + fetch.push((face.url, face.integrity)); + } + } + state.stack = Some(stack.clone()); + fetch + }; + stack.install_faces(ctx, Some(Arc::clone(&loader) as Arc)); + for (url, integrity) in fetch { + loader.fetch(url, integrity); + } +} + +#[cfg(target_arch = "wasm32")] +pub(in crate::fonts) fn loading(ctx: &egui::Context) -> bool { + let loader: Option> = ctx.data(|d| d.get_temp(id())); + loader.is_some_and(|loader| !loader.lock().ui_pending.is_empty()) +} + +fn id() -> egui::Id { + egui::Id::unique("craft_ui::fonts::served") +} + +/// Make egui ask the providers again for the characters it found no face for. egui remembers those +/// misses until its fonts reload and has no call that only forgets them; changing the missing +/// glyph policy reloads the fonts, and setting it back keeps the app's policy. +fn forget_misses(ctx: &egui::Context) { + let policy = ctx.missing_glyph_policy(); + let other = match policy { + MissingGlyphPolicy::Tofu => MissingGlyphPolicy::Panic, + MissingGlyphPolicy::Panic => MissingGlyphPolicy::Tofu, + }; + ctx.set_missing_glyph_policy(other); + ctx.set_missing_glyph_policy(policy); +} + +/// The context's fetched files and the stack they go into. Also the provider asked for characters +/// no face has, which starts fetching the face that draws them. +struct Loader { + ctx: egui::Context, + me: Weak, + fetcher: Arc, + state: Mutex, +} + +#[derive(Default)] +struct State { + /// The installed stack, without the files' data. + stack: Option, + /// Fetched files by URL; the browser's HTTP cache keeps them across page loads. + files: BTreeMap<&'static str, Blob>, + in_flight: BTreeSet<&'static str>, + failed: BTreeMap<&'static str, Failure>, + /// The UI language's files not fetched yet. + ui_pending: BTreeSet<&'static str>, +} + +struct Failure { + attempts: u32, + retry_at: f64, +} + +impl State { + /// Whether `url` may be fetched: it never failed, or its retry time has come. + fn due(&self, url: &str, now: f64) -> bool { + self.failed.get(url).is_none_or(|failure| now >= failure.retry_at) + } +} + +impl Loader { + fn of(ctx: &egui::Context, fetcher: Arc) -> Arc { + let existing: Option> = ctx.data(|d| d.get_temp(id())); + existing.unwrap_or_else(|| { + let loader = Arc::new_cyclic(|me| Self { ctx: ctx.clone(), me: me.clone(), fetcher, state: Mutex::default() }); + ctx.data_mut(|d| d.insert_temp(id(), Arc::clone(&loader))); + loader + }) + } + + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn fetch(&self, url: &'static str, integrity: &'static str) { + if !self.lock().in_flight.insert(url) { + return; + } + let Some(me) = self.me.upgrade() else { return }; + self.fetcher.fetch(url, integrity, Box::new(move |result| me.arrived(url, result))); + } + + fn arrived(&self, url: &'static str, result: Result, String>) { + let (stack, retry) = { + let mut state = self.lock(); + state.in_flight.remove(url); + state.ui_pending.remove(url); + match result { + Ok(bytes) => { + state.failed.remove(url); + let file: Blob = Arc::new(bytes); + state.files.insert(url, Arc::clone(&file)); + let stack = state.stack.as_mut().map(|stack| { + stack.provide(url, &file); + stack.clone() + }); + (stack, None) + } + Err(_) => { + let attempts = state.failed.get(url).map_or(1, |failure| failure.attempts + 1); + let delay = (RETRY_FIRST * 2f64.powi(attempts.saturating_sub(1).min(16) as i32)).min(RETRY_MAX); + state.failed.insert(url, Failure { attempts, retry_at: self.fetcher.now() + delay }); + (None, Some(delay)) + } + } + }; + if let Some(stack) = stack + && let Some(me) = self.me.upgrade() + { + stack.install_faces(&self.ctx, Some(me as Arc)); + } + // The file that arrived draws characters egui found no face for; after a failure, they go + // to the next face that draws them now and to this one again at its retry time. + forget_misses(&self.ctx); + if let Some(delay) = retry { + let me = self.me.clone(); + self.fetcher.after( + delay, + Box::new(move || { + if let Some(me) = me.upgrade() { + forget_misses(&me.ctx); + me.ctx.request_repaint(); + } + }), + ); + } + self.ctx.request_repaint(); + } +} + +impl FontProvider for Loader { + /// Start fetching the face that draws the character and find nothing for now: the face is + /// installed when it arrives, and egui asks again. Only faces whose files map the character + /// are candidates, the first one the family asks; a failed one leaves the character to the + /// next until its retry time. A character no served face maps fetches nothing. + fn font_for(&self, request: &FallbackRequest<'_>) -> Option { + let now = self.fetcher.now(); + let state = self.lock(); + let mut next = None; + for face in state.stack.as_ref()?.downloads(request.family, request.cluster) { + if state.in_flight.contains(face.url) || state.files.contains_key(face.url) { + // It draws the character once it arrives, or already does. + return None; + } + if state.due(face.url, now) { + next = Some((face.url, face.integrity)); + break; + } + } + drop(state); + let (url, integrity) = next?; + self.fetch(url, integrity); + None + } +} diff --git a/crates/ui/src/fonts/served/web.rs b/crates/ui/src/fonts/served/web.rs new file mode 100644 index 00000000..d0df6332 --- /dev/null +++ b/crates/ui/src/fonts/served/web.rs @@ -0,0 +1,65 @@ +//! The browser's [`Fetcher`]: `fetch` with Subresource Integrity, and `setTimeout`. + +use std::sync::Arc; + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast as _, JsValue}; +use wasm_bindgen_futures::JsFuture; + +use super::loader::{self, Done, Fetcher, Wake}; +use crate::fonts::Stack; + +/// Install `stack`, add the files fetched so far, and fetch the UI language's faces. +pub(in crate::fonts) fn install(ctx: &egui::Context, stack: Stack) { + loader::install(ctx, stack, Arc::new(Browser)); +} + +pub(in crate::fonts) fn loading(ctx: &egui::Context) -> bool { + loader::loading(ctx) +} + +struct Browser; + +impl Fetcher for Browser { + fn fetch(&self, url: &'static str, integrity: &'static str, done: Done) { + wasm_bindgen_futures::spawn_local(async move { + let result = fetch_file(url, integrity).await; + if let Err(error) = &result { + web_sys::console::warn_1(&format!("craft-ui: cannot fetch the font {url}: {error}").into()); + } + done(result); + }); + } + + fn now(&self) -> f64 { + js_sys::Date::now() / 1000.0 + } + + fn after(&self, seconds: f64, wake: Wake) { + let Some(window) = web_sys::window() else { return }; + let callback = Closure::once_into_js(wake); + let millis = (seconds * 1000.0) as i32; + // Without the timer, the face is retried when the next fetch finishes. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), millis); + } +} + +async fn fetch_file(url: &str, integrity: &str) -> Result, String> { + // An Error's message only: its Debug form carries the whole JS stack. The browser logs the + // cause (network, integrity) itself. + let js = |e: JsValue| match e.dyn_ref::() { + Some(error) => String::from(error.message()), + None => e.as_string().unwrap_or_else(|| format!("{e:?}")), + }; + let window = web_sys::window().ok_or("no window")?; + let init = web_sys::RequestInit::new(); + if !integrity.is_empty() { + init.set_integrity(integrity); + } + let response: web_sys::Response = JsFuture::from(window.fetch_with_str_and_init(url, &init)).await.map_err(js)?.dyn_into().map_err(js)?; + if !response.ok() { + return Err(format!("HTTP {}", response.status())); + } + let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?; + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) +} diff --git a/crates/ui/src/fonts/system.rs b/crates/ui/src/fonts/system.rs new file mode 100644 index 00000000..ef486223 --- /dev/null +++ b/crates/ui/src/fonts/system.rs @@ -0,0 +1,216 @@ +//! A font index built once on a background thread and shared by every locale's provider. +//! +//! Lookups that may not block (the UI thread's) find nothing until the index is built: egui +//! draws with the faces it has, and every context that was turned away reloads its fonts when the +//! index arrives, which forgets egui's cached misses and repaints. If the build panics, every +//! lookup finds nothing from then on and egui keeps the faces it has. + +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; + +use egui::{FallbackRequest, FontInsert, FontProvider}; + +/// A font index a [`Lookup`] asks. +pub(super) trait FontIndex: Send + 'static { + /// The provider's preference between fonts for one script, e.g. a locale. + type Locale: Send + Sync + 'static; + + fn font_for(&mut self, request: &FallbackRequest<'_>, locale: &Self::Locale) -> Option; +} + +enum State { + Idle, + /// `turned_away`: a lookup found nothing because the index wasn't built, so `contexts` reload + /// their fonts when it is. + Building { + turned_away: bool, + contexts: Vec, + }, + Ready(Arc>), + Failed, +} + +/// An index built at most once per process, on first use. +pub(super) struct Shared { + state: Mutex>, + built: Condvar, + build: fn() -> T, +} + +impl Shared { + pub(super) const fn new(build: fn() -> T) -> Self { + Self { state: Mutex::new(State::Idle), built: Condvar::new(), build } + } + + /// Start building the index on a background thread unless it is built or building. `ctx` + /// reloads its fonts when the index arrives if a lookup was turned away meanwhile. + pub(super) fn start(&'static self, ctx: Option<&egui::Context>) { + let idle = { + let mut state = self.lock(); + let idle = matches!(*state, State::Idle); + if idle { + *state = State::Building { turned_away: false, contexts: Vec::new() }; + } + if let (State::Building { contexts, .. }, Some(ctx)) = (&mut *state, ctx) { + contexts.push(ctx.clone()); + } + idle + }; + if idle && std::thread::Builder::new().name("craft-ui system fonts".into()).spawn(|| self.finish()).is_err() { + self.finish(); + } + } + + /// The index, or `None` while it builds unless `wait`, which blocks until it is built, or + /// `None` if its build failed. + fn get(&'static self, wait: bool) -> Option>> { + self.start(None); + let mut state = self.lock(); + loop { + match &mut *state { + State::Ready(index) => return Some(Arc::clone(index)), + State::Failed => return None, + State::Building { turned_away, .. } if !wait => { + *turned_away = true; + return None; + } + _ => state = self.built.wait(state).unwrap_or_else(PoisonError::into_inner), + } + } + } + + fn finish(&self) { + let built = std::panic::catch_unwind(self.build).map_err(|payload| { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or("non-string panic payload"); + eprintln!("craft-ui: building the system font index failed, so it adds no fonts: {message}"); + }); + let next = built.map_or(State::Failed, |index| State::Ready(Arc::new(Mutex::new(index)))); + let previous = std::mem::replace(&mut *self.lock(), next); + self.built.notify_all(); + // Outside the lock: the UI thread holds the context's fonts while it asks for the index. + if let State::Building { turned_away: true, contexts } = previous { + for ctx in contexts { + super::providers::reload_font_providers(&ctx); + } + } + } + + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// One locale's provider over a [`Shared`] index. +pub(super) struct Lookup { + index: &'static Shared, + locale: T::Locale, + /// Block until the index is built, for checks; the UI thread doesn't. + wait: bool, +} + +impl Lookup { + pub(super) fn new(index: &'static Shared, locale: T::Locale, wait: bool) -> Self { + Self { index, locale, wait } + } +} + +impl FontProvider for Lookup { + fn font_for(&self, request: &FallbackRequest<'_>) -> Option { + let index = self.index.get(self.wait)?; + let mut index = index.lock().unwrap_or_else(PoisonError::into_inner); + index.font_for(request, &self.locale) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + use egui::{FontData, FontFamily, FontId, FontPriority, InsertFontFamily}; + + use super::*; + + const ARABIC: &[u8] = include_bytes!("../../tests/fonts/NotoSansArabic-test.ttf"); + + struct Gated; + + static OPEN: AtomicBool = AtomicBool::new(false); + static BUILDS: AtomicUsize = AtomicUsize::new(0); + + fn build_when_open() -> Gated { + while !OPEN.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(1)); + } + BUILDS.fetch_add(1, Ordering::Relaxed); + Gated + } + + impl FontIndex for Gated { + type Locale = (); + + fn font_for(&mut self, request: &FallbackRequest<'_>, _: &()) -> Option { + Some(FontInsert::new( + "index:Arabic", + FontData::from_static(ARABIC), + vec![InsertFontFamily { family: request.family.clone(), priority: FontPriority::Lowest }], + )) + } + } + + static GATED: Shared = Shared::new(build_when_open); + + #[test] + fn ui_lookups_skip_the_index_until_it_is_built_then_the_context_reloads() { + let ctx = egui::Context::default(); + let font_id = FontId::new(14.0, FontFamily::Proportional); + let ui: Arc = Arc::new(Lookup::new(&GATED, (), false)); + super::super::providers::set_font_providers(&ctx, vec![ui]); + GATED.start(Some(&ctx)); + let draws_arabic = || { + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + ctx.fonts_mut(|f| f.has_glyph(&font_id, 'ع')) + }; + + assert!(!draws_arabic(), "a lookup before the index is built finds nothing, without blocking"); + let other_locale = Lookup::new(&GATED, (), false); + let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional }; + assert!(other_locale.font_for(&request).is_none()); + + OPEN.store(true, Ordering::Release); + let checks = Lookup::new(&GATED, (), true); + assert!(checks.font_for(&request).is_some(), "a waiting lookup blocks until the index is built"); + let deadline = Instant::now() + Duration::from_secs(10); + while !draws_arabic() { + assert!(Instant::now() < deadline, "the context reloads its fonts once the index is built"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(other_locale.font_for(&request).is_some()); + assert_eq!(BUILDS.load(Ordering::Relaxed), 1, "every locale shares one index"); + } + + fn build_panics() -> Gated { + panic!("stand-in index build failure"); + } + + static PANICS: Shared = Shared::new(build_panics); + + #[test] + fn a_panicking_build_leaves_lookups_finding_nothing() { + let (sent, received) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional }; + let found = Lookup::new(&PANICS, (), true).font_for(&request).is_some(); + let _ = sent.send(found); + }); + let found = received.recv_timeout(Duration::from_secs(10)); + assert_eq!(found, Ok(false), "a waiting lookup returns once the build has failed"); + let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional }; + assert!(Lookup::new(&PANICS, (), false).font_for(&request).is_none()); + assert!(Lookup::new(&PANICS, (), true).font_for(&request).is_none()); + } +} diff --git a/crates/ui/src/fonts/system_index.rs b/crates/ui/src/fonts/system_index.rs new file mode 100644 index 00000000..1058e08f --- /dev/null +++ b/crates/ui/src/fonts/system_index.rs @@ -0,0 +1,157 @@ +//! The installed fonts, indexed once with fontique for every locale. The lookup is +//! egui_system_font_provider 0.37's, whose provider builds its own index per locale. + +use std::sync::{Arc, LazyLock}; + +use egui::{FallbackRequest, FontData, FontFamily, FontInsert, FontPriority, FontProvider, InsertFontFamily}; +use fontique::{ + Collection, CollectionOptions, FallbackKey, GenericFamily, Language, QueryFamily, QueryFont, QueryStatus, Script, SourceCache, SourceCacheOptions, +}; + +use super::system::{FontIndex, Lookup, Shared}; + +pub(super) struct Index { + collection: Collection, + source_cache: SourceCache, +} + +static INDEX: Shared = Shared::new(build); + +#[cfg(test)] +pub(super) static BUILDS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +fn build() -> Index { + #[cfg(test)] + BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Index { + collection: Collection::new(CollectionOptions { shared: false, system_fonts: true }), + source_cache: SourceCache::new(SourceCacheOptions::default()), + } +} + +/// A provider over the shared index that prefers `locale`'s fonts. With `wait`, a lookup before the +/// index is built blocks until it is; without, it finds nothing (see [`start`]). +pub(super) fn provider(locale: &str, wait: bool) -> Arc { + Arc::new(Lookup::new(&INDEX, locale_language(locale), wait)) +} + +/// The language a locale prefers fonts for: a language tag (`zh-Hant`) or a POSIX locale +/// (`zh_TW.UTF-8`). `C`, `POSIX` and anything else without a language prefer none. +pub(super) fn locale_language(locale: &str) -> Option { + let tag = locale.split(['.', '@']).next().unwrap_or_default(); + if matches!(tag, "C" | "POSIX") { + return None; + } + Language::parse(tag).ok() +} + +/// Start building the index in the background. `ctx` reloads its fonts once it is built if a +/// lookup found nothing meanwhile. +pub(super) fn start(ctx: &egui::Context) { + INDEX.start(Some(ctx)); +} + +impl FontIndex for Index { + type Locale = Option; + + fn font_for(&mut self, request: &FallbackRequest<'_>, locale: &Option) -> Option { + let FallbackRequest { cluster, family } = request; + let base_char = cluster.chars().next()?; + let Self { collection, source_cache } = self; + let generic = match family { + FontFamily::Monospace => GenericFamily::Monospace, + FontFamily::Proportional | FontFamily::Name(_) => GenericFamily::SansSerif, + }; + + let font = if let Some(script) = script_of(base_char) { + // A script's fallback can be one locale's font, e.g. a Japanese font for Han that + // lacks many Simplified Chinese characters, so the script's other locales follow. + std::iter::once(locale.as_ref()).chain(other_locales(script).iter().map(Some)).find_map(|locale| { + let mut query = collection.query(source_cache); + query.set_families([QueryFamily::Generic(generic)]); + query.set_fallbacks(fallback_key(script, locale)); + find_font(&mut query, base_char) + }) + } else { + // Punctuation, symbols and emoji belong to no script and have no fallback. + let mut query = collection.query(source_cache); + query.set_families( + [generic, GenericFamily::SystemUi, GenericFamily::Emoji, GenericFamily::Math, GenericFamily::Serif, GenericFamily::Monospace] + .map(QueryFamily::Generic), + ); + find_font(&mut query, base_char) + }?; + + let family_name = collection.family_name(font.family.0).unwrap_or("unknown"); + let name = format!("system:{family_name}:{}:{}", font.family.1, font.index); + let (blob, _) = font.blob.into_raw_parts(); + Some(FontInsert { + name, + data: FontData::from_blob(blob, font.index), + families: vec![InsertFontFamily { family: (*family).clone(), priority: FontPriority::Lowest }], + }) + } +} + +/// The first font in the query that can draw `c`. +fn find_font(query: &mut fontique::Query<'_>, c: char) -> Option { + let mut found = None; + query.matches_with(|font| { + if can_render(font, c) { + found = Some(font.clone()); + QueryStatus::Stop + } else { + QueryStatus::Continue + } + }); + found +} + +const HAN: Script = Script::from_bytes(*b"Hani"); + +/// The locales of the Han fonts, Simplified Chinese first. That is `zh-SG` rather than `zh-CN`: +/// fontique's default Han locale is `zh-CN`, whose font is whatever the first locale-less Han +/// lookup returned, often the Japanese one. +static HAN_LOCALES: LazyLock> = + LazyLock::new(|| ["zh-SG", "zh-TW", "ja", "ko"].iter().filter_map(|locale| Language::parse(locale).ok()).collect()); + +fn fallback_key(script: Script, locale: Option<&Language>) -> FallbackKey { + let locale = match locale { + Some(locale) if script == HAN && is_simplified_chinese(locale) => HAN_LOCALES.first(), + locale => locale, + }; + FallbackKey::new(script, locale) +} + +/// `zh`, `zh-CN`, `zh-Hans` and the like, as fontique tells Simplified from Traditional Chinese. +fn is_simplified_chinese(locale: &Language) -> bool { + locale.language() == "zh" && locale.script() != Some("Hant") && !matches!(locale.region(), Some("HK" | "TW" | "MO")) +} + +/// Only Han needs other locales: its fonts are per locale, each with different characters. +fn other_locales(script: Script) -> &'static [Language] { + if script == HAN { &HAN_LOCALES } else { &[] } +} + +/// The script of a character, `None` for characters shared between scripts. +fn script_of(c: char) -> Option